Хабрахабр

Глобальное освещение с использованием трассировки вокселей конусами

В этой статье я расскажу о реализации одного из алгоритмов расчёта глобального (переотражённого / ambient) освещения, применяемого в некоторых играх и других продуктах, — Voxel Cone Tracing (VCT). Возможно, кто-то читал старенькую статью ([VCT]) 2011 года или смотрел видео. Но статья не даёт исчерпывающих ответов на вопросы, как реализовать тот или иной этап алгоритма.

Например, один из разработчиков UE4 рассказывал в подкасте (1:34:05 — 1:37:50), что они рендерили демку с использованием VCT, а потом с использованием карт окружения, и картинка получилась примерно одинаковой.
Рендер сцены без глобального освещения, и с использованием VCT:
Прежде всего стоит сказать, что реализация глобального освещения с использованием карт окружения (PMREM / IBL) получается дешевле, чем VCT. На его идеях, например, базируется современный VXGI от Nvidia. Тем не менее, ведутся исследования в рамках такого подхода.

Также в реализации не учтены динамические объекты сцены. Во время работы я опирался на кучу источников, которые можно найти в конце статьи.
Для упрощения задачи, в реализации учтено только диффузное освещение (модель Лабмерта), но утверждается, что этот алгоритм подойдёт для множества других BRDF.

В работе используются некоторые фишки DX 11. Описывать реализацию буду в терминах DirectX, но такой алгоритм вполне можно реализовать и на OpenGL. 1 (например, использование UAV в вертексном шейдере), но можно обойтись и без них, тем самым понизив минимальные системные требования для этого алгоритма.

Наиболее общие части алгоритма: Для лучшего понимания работы алгоритма опишем его общие этапы, а после детализируем каждый из них.

  1. Вокселизация сцены. Мы растеризуем сцену в набор вокселей, содержащих свойства материала (например, цвет) и нормали поверхностей.

  2. Запекание отражённого освещения. Для вокселей рассчитывается входящее или исходящее излучение от источников освещения, а результат записывается в 3D-текстуру.

  3. Трассировка конусов. Для каждого рассчитываемого пикселя поверхности создаётся группа конусов. Конус — это абстракция, имитирующая группу лучей, испускаемую для захвата излучения из различных мипов 3D-текстуры освещения. Конечный результат взвешенно усредняется и складывается с основным освещением.

Эти этапы могут иметь несколько идейных вариантов реализаций или содержать дополнительные оптимизации. Я хочу показать в общем виде, как это можно сделать.

Можно вокселизовать все объекты в 3D-текстуру. Для начала определимся со структурой хранения вокселизированной сцены. Для вокселизации с разрешением 256х256х256 формата R8G8B8A8 такая текстура будет занимать 64 Мб, и это без учёта мипов. Недостаток этого решения — неоптимальный расход памяти, потому что бо̒льшая часть сцены является пустым пространством. Такие текстуры требуются для цвета и нормалей поверхностей, а также для запекаемого освещения.

В нашей реализации мы воспользуемся Sparse Voxel Octree (SVO), как в оригинальной статье. Для оптимизации расхода памяти применяются алгоритмы упаковки данных. Но есть и другие алгоритмы, например, 3D Clipmap [S4552].

Каждый узел такого дерева разбивает подпространство сцены на 8 равных частей. SVO — это разреженное октодерево сцены. Мы будем пользоваться этой структурой для поиска вокселей по их координатам в пространстве, а также для сэмплирования запеченного освещения. Разреженное дерево при этом не хранит информацию о пространстве, которое ничем не занято. Запеченное освещение будет храниться в специальной 3D-текстуре — буфере блоков, о котором поговорим ниже.

Рассмотрим каждый из этапов алгоритма по отдельности.

Вокселизация сцены

На GPU этот массив реализован через StructuredBuffer / RWStructuredBuffer. Вокселизированную сцену будем хранить в виде массива вокселей. Структура вокселей будет следующей:

struct Voxel
{ uint position; uint color; uint normal; uint pad; // 128 bits aligment
};

Для компактного хранения сцены мы используем SVO. Дерево будет храниться в 2D-текстуре формата R32_UINT. Структура узла дерева:


Схематическое представление узлов SVO [DP]

В листьях SVO на последнем уровне будут храниться индексы соответствующих вокселей из этого массива.

Но мы не предполагаем хранить полное дерево. Допустим, разрешение нашей сцены 256x256x256, тогда для хранения полностью заполненного дерева нам потребуется текстура размером 6185x6185 (145,9 Мб). По моим наблюдениям, для сцены Sponza при таком разрешении сцены разреженное дерево помещается в текстуру 2080х2080 (16,6 Мб).

Создание массива вокселей

Для создания массива вокселей нужно вокселизовать все объекты сцены. Воспользуемся простой вокселизацией из смежной статьи Octree-Based Sparse Voxelization. Эта техника проста в реализации и работает за один проход GPU.


Пайплайн вокселизации объекта [SV]

Устанавливаем вьюпорт по разрешению вокселизированной сцены (например, 256х256). Вычисляем ограничивающий куб сцены. Рендерим каждый объект сцены, который мы хотим учитывать. Выставляем ортографическую камеру над сценой так, чтобы уместить ограничивающий куб.

С помощью нормали треугольника выбирается ось, в направлении которой площадь проекции этого треугольника максимальна. В геометрическом шейдере мы занимаемся обработкой треугольников объекта. В зависимости от оси поворачиваем треугольник наибольшей проекцией к растеризатору — так мы получим больше вокселей.

Создаём структуру Voxel с координатами, цветом и нормалью, и добавляем его в массив вокселей. В пиксельном шейдере каждый фрагмент будет являться вокселем.

На этом этапе желательно использовать низкополигональные лоды (LOD) объектов, чтобы не иметь проблем с отсутствующими вокселями и со слиянием вокселей смежных треугольников (такие случаи рассмотрены в статье [SV]).

Следует использовать низкополигональные лоды или дополнительно растягивать треугольники.
Артефакты вокселизации — не все треугольники растеризуются.

Создание октодерева по списку вокселей

Когда вся сцена вокселизована, у нас есть массив вокселей с их координатами. По этому массиву можно построить SVO, с помощью которого сможем находить воксели в пространстве. Изначально каждый пиксель текстуры с SVO инициализируется значением 0xffffffff. Для записи узлов дерева через шейдер текстура SVO будет представлена как UAV-ресурс (RWTexture2D). Для работы с деревом заведём атомарный счётчик узлов (D3D11_BUFFER_UAV_FLAG_COUNTER).

Под адресом узла в SVO далее подразумевается индекс узла, который преобразуется в 2D-координаты текстуры. Опишем поэтапно алгоритм создания октодерева. Под аллоцированием узла далее подразумевается следующий набор действий:

  • C помощью текущего значения счётчика узлов вычисляются адреса, в которых будут располагаться дочерние узлы аллоцируемого узла.
  • Счётчик узлов увеличивается на количество дочерних узлов (8).
  • Адреса дочерних узлов записываются в поля текущего узла.
  • В поле флага текущего узла записывается, что он аллоцирован.
  • Адрес текущего узла записывается в родительское поле дочерних узлов.


Схематичное изображение октодерева и его представления в текстуре [SV]

Алгоритм создания октодерева высотой N выглядит следующим образом:

  1. Аллоцируем корневой узел. Текущий уровень дерева = 1. Счётчик узлов = 1.
  2. Перебираем воксели. Для каждого вокселя находим узел (подпространство) на текущем уровне дерева, которому он принадлежит. Помечаем такие узлы флагом, что необходима аллокация. Этот этап можно распараллелить на вершинном шейдере с помощью Input-Assembler без буферов, используя только количество вокселей и атрибут SV_VertexID для адресации по массиву вокселей.
  3. Для каждого узла текущего уровня дерева проверяем флаг. Если необходимо, аллоцируем узел. Этот этап также можно распараллелить на вершинном шейдере.
  4. Для каждого узла текущего уровня дерева прописываем в дочерних узлах адреса соседей.
  5. Текущий уровень дерева++. Повторяем шаги 2-5, пока текущий уровень дерева < N.
  6. На последнем уровне вместо аллокации текущего узла записываем индекс вокселя в массиве вокселей. Таким образом, узлы последнего уровня будут содержать до 8 индексов.

Построив такое октодерево, мы легко можем найти воксель сцены по координатам в пространстве, спускаясь вниз по SVO.

Создание буфера блоков по октодереву

Теперь, когда мы сконструировали октодерево вокселизированной сцены, можно использовать эту информацию для сохранения освещённости, переотражённой вокселями от источников освещения. Освещённость сохраняется в 3D-текстуру формата R8G8B8A8. Это позволит нам использовать трилинейную интерполяцию GPU при сэмплировании текстуры, и в результате получим более гладкое итоговое изображение. Такая текстура называется буфером блоков (brick buffer), потому что она состоит из блоков, расположенных в соответствии с SVO. Блок — это другое представление группы вокселей в пространстве.

Группы из 2х2х2 вокселей, индексы которых расположены в листьях SVO, отображаются на блоки вокселей 3х3х3 из 3D-текстуры:


Отображение группы вокселей в блок вокселей [DP]

Поэтому для корректной интерполяции между соседними группами вокселей необходимо проделать дополнительную работу по усреднению значений между соседними блоками. Группы вокселей, расположенные в сцене рядом друг с другом, в текстуре могут оказаться разбросаны (поскольку текстура строится на основе SVO).


Усреднение границ соседних блоков

Таким образом, блоки занимают в пространстве больше места, чем исходные воксели. Воксели из блока располагаются по углам исходной группы вокселей и между ними. Так появляется подобие некоторого разграничителя шириной в полпикселя, который никак не используется. Но сэмплирование внутри блока будет производиться по границам исходных вокселей.

Блоки, расположенные в соответствии с остальными узлами SVO на определённых уровнях октодерева, — это MIP-уровни буфера блоков. Блоки, расположенные в соответствии с листьями SVO, — это отображение исходных вокселей.


Схематичное изображение октодерева, его представления в текстуре и отображение в буфер блоков [SV]

Блоки 2х2х2 были бы ещё одним хорошим способом экономии памяти, но такого подхода я нигде не встречал. При этом блоки не обязаны быть 3х3х3, они могут быть и 2x2x2, и 5х5х5 — это вопрос точности представления.


Сравнение интерполяции при сэмплировании четырёх соседних вокселей: без буфера блоков, из буфера блоков 3х3х3 и из буфера блоков 5х5х5

Мы создадим два буфера: для хранения прозрачности сцены и для хранения освещения. Создание такого буфера блоков — довольно трудоёмкое занятие.

Этапы создания буфера блоков

Первый шаг — заполнение вокселей блоков, сопоставляемых с листьями октодерева. Допустим, мы вычисляем отражённое освещение в конкретных вокселях SVO. Тогда эти значения будут отображаться в соответствующие угловые воксели буфера блоков.

После окончания вычисления исходящего освещения, для каждого блока дозаполним оставшиеся воксели усреднёнными значениями соседних вокселей внутри блока:

Именно для этого в узлах дерева хранятся соседи в пространстве. Теперь усредним значения между соседними блоками.

Красным прямоугольником помечены возможные артефакты при таком усреднении [DP]
Один из вариантов усреднения буфера блоков — по каждой из осей за три прохода.

Узлы верхних уровней SVO будут отображаться в блоки, которые содержат усреднённые значения с нижележащего уровня. Создадим MIP-уровни такого буфера. Также это могут быть блоки, соответствующие не только непосредственно дочерним узлам в дереве, но и их соседям (см. Каждый блок, соответствующий узлу с верхнего уровня дерева, включает в себя информацию из блоков соответствующих потомков. рисунок ниже).

Справа — блок включает в себя информацию только из дочерних блоков [DP]
Слева — блок включает в себя информацию из дочерних блоков и их соседей.

После этого усредняются значения между соседними блоками, как описано выше. Чтобы уменьшить количество обращений к памяти, для каждого вычисляемого блока используются только воксели дочерних блоков (рисунок справа). И так мы создаём MIP-уровни, пока не дойдём до корневого узла SVO.

Создадим два буфера — буфер блоков прозрачности сцены и буфер блоков отражённого освещения.

Мы будем хранить направленную прозрачность вокселей. Буфер блоков прозрачности создаётся предварительно, на основе вокселизированной геометрии сцены. Такое решение помогает учитывать преграждение света в пространстве в определённом направлении. Для этого заполним RGB-значения вокселей блоков с нижнего уровня в соответствии с прозрачностью вокселей, а при создании MIP-уровней блоков для каждого вычисляемого вокселя будем выбирать усреднённое максимальное значение прозрачности по осям XYZ, и сохранять их в соответствующие RGB-значения текстуры.

1.
В каждом направлении выбирается максимум в значении прозрачности (т.е. Далее, при конструировании MIP-уровней максимумы по осям XYZ в дочерних блоках усредняются и складываются в компоненты RGB. 0 — совершенно непрозрачный объект, и это значение будет максимальным). [DP]

Запекание отражённого освещения

Освещение, переотражённое вокселем, вычисляется путём обработки карты теней от рассчитываемых источников освещения. С помощью этой карты мы можем получить координаты объектов в пространстве и, используя SVO, преобразовать их в индексы освещаемых вокселей.

Оригинальная статья описывает подход, при котором каждый пиксель карты теней рассматривается как условный фотон. Обычно разрешение карты теней выше разрешения октодерева. В своей реализации я упростил этот этап и считал освещённость только один раз для каждого вокселя, попавшего в карту теней. Соответственно, в зависимости от количества фотонов будет разный вклад в освещённость.

Также, при обработке карты теней можно среди пикселей, входящих в один и тот же воксель, выбирать для вычисления только левый верхний. Чтобы избежать повторных вычислений, для каждого вокселя, для которого уже было рассчитано отражённое освещение, мы воспользуемся битом флага узла октодерева, который хранит указатель на воксель.

Справа — красным показаны левые верхние пиксели, входящие в один и тот же воксель.
Фрагмент карты теней (слева).

Вычисленная освещённость записывается в буфер блоков освещения. Для вычисления отражённой освещённости используется стандартный albedo * lightColor * dot(n, l), но в общем случае это зависит от BRDF. После обработки карты теней производится дозаполнение буфера блоков и создание MIP-уровней, как описано выше.

Необходимо пересчитывать буфер блоков освещения при каждом изменении освещения. Алгоритм достаточно дорогостоящий. Один из возможных способов оптимизации — размазывание обновления буфера по кадрам.

Создание G-Buffer

Чтобы для конкретного пикселя применить трассировку вокселей конусами, необходимо знать нормаль и местоположение пикселя в пространстве. Эти атрибуты используются для обхода SVO и соответствующего сэмплирования буфера блоков освещения. Получить эти атрибуты можно из G-buffer.

С помощью G-buffer также можно вычислять переотражённое освещение в меньшем разрешении, чем у вьюпорта, что способствует улучшению производительности.

Трассировка вокселей конусами

Когда буфер блоков освещения вычислен, можно переходить к вычислению освещения, переотражённого от сцены на объекты в кадре — то есть глобального освещения. Для этого применяется трассировка вокселей конусами.

В случае модели освещения по Ламберту, конусы испускаются равномерно по полусфере, ориентированной с помощью нормали, полученной из G-buffer. Для каждого пикселя испускается несколько конусов в соответствии с BRDF.


Испускание конусов с поверхности, для которой рассчитывается входящее освещение [VCT]

При вычислении мы допускаем, что освещённость, получаемая из точки на поверхности в направлении вокселя, будет такой же, как испущенная из вокселя в сторону поверхности в определённом конусе видимости.


[VCT]

На каждом шаге сэмплируется буфер блоков освещения в соответствии с уровнем октодерева (т.е. Трассировка производится пошагово. в соответствии с созданными MIP-уровнями), начиная с самого нижнего и заканчивая самым верхним.


Сэмплирование буфера блоков с помощью конуса в соответствии с уровнями SVO [DP]

Т.е. Трассировка конусом производится аналогично рендерингу volumetric-объектов [A]. используется модель alpha front-to-back, при которой на каждом следующем шаге вдоль конуса цвет и прозрачность вычисляются следующим образом:

с’ = с’ + ( 1 — а’ ) * с
а’ = а’ + ( 1 — а’ ) * а

где а — прозрачность, с — значение, полученное из буфера блоков освещения, умноженное на прозрачность (premultiplied alpha).

Значение прозрачности вычисляется с помощью буфера блоков прозрачности:

opacityXYZ = opacityBrickBuffer.Sample( linearSampler, brickSamplePos ).rgb;
alpha = dot( abs( normalize( coneDir ) * opacityXYZ ), 1.0f.xxx );

Иными словами, считывается прозрачность в направлении конуса в конкретной точке. Это не единственный подход. Можно, например, хранить прозрачность в альфа-канале буфера блоков освещения. Но тогда теряется направленность переотражённого освещения. Также я пробовал хранить направленную освещённость в шести буферах блоков освещения (по два на каждую ось) — затрат больше, чем видимых преимуществ.

Если все конусы одинаковые, вес делится поровну. Итоговый результат по конусам взвешенно суммируется в зависимости от углов раствора конусов. В моей реализации 5 конусов по 60 градусов (один в центре, и 4 по бокам). Выбор количества конусов и размера углов раствора — это вопрос соотношения скорости и качества.

Рекомендуется выбирать расположение сэмплов таким образом, чтобы в конус вписывался узел октодерева с соответствующего уровня. На рисунке выше схематично показаны точки сэмплирования вдоль оси конуса. Но тогда могут проявляться резкие границы вокселей, поэтому расположение сэмплов корректируется. Либо можно заменить на соответствующую сферу [SB]. В своей реализации я просто сдвигаю сэмплы ближе друг к другу.

Также при просчёте AO на каждом шаге вдоль оси конуса вносится коррекция в зависимости от расстояния 1 / (1 + lambda * distance), где lambda — калибровочный параметр. Бонусом к переотражённому освещению мы также получаем затенение Ambient Occlusion, которое вычисляется исходя из значения альфы, полученного при трассировке.

Для более гладкого результата на текстуру можно наложить размытие. Полученный результат сохраняется в текстуру RGBA (RGB для освещения и A для AO). В моём случае вычисленное переотражение сначала умножается на альбедо поверхности, полученное из G-buffer, затем опционально умножается на AO, и наконец добавляется к основному освещению. Итоговый результат будет зависеть от BRDF.

В целом, после определённых настроек и доработок картинка похожа на результат из статьи.


Сравнение результатов — слева сцена, отрендеренная в Mental Ray, справа — Voxel Cone Tracing из оригинальной статьи, в центре — моя реализация.

Подводные камни

Проблема самоосвещения. Поскольку в большинстве случаев поверхность физически расположена в своих же вокселях, необходимо как-то бороться с самоосвещением. Одним из решений является отодвигание начала конусов в направлении нормалей. Также этой проблемы теоретически можно избежать, если в вокселях хранить направленное распределение освещения.

Алгоритм довольно сложный, и придётся потратить немало времени на эффективную реализацию на GPU. Проблема производительности. При этом даже оптимизированный алгоритм при поиске вокселей в дереве будет требовать множество зависящих друг от друга сэмплов текстуры SVO. Я писал реализацию «в лоб», применяя довольно очевидные оптимизации, и нагрузка получилась высокой.

Согласно Intel GPA, относительное распределение производительности получилось таким: В моей реализации я испускаю 5 конусов, сэмплируя буферы блоков для 4 предпоследних уровней октодерева 256х256х256.

G-Buffer и Shadow Map без предварительного кулинга.
Распределение производительности.

Вместе с прямым освещением это занимает ~45% времени кадра 512х512. В оригинальной статье используются три диффузных конуса на текстуре 512х512 на всём SVO (512x512x512 — 9 уровней). Также необходимо уделить внимание оптимизации обновления буфера освещения. Есть куда стремиться.

Такое случается с тонкими объектами, которые вносят малый вклад в буфер блоков прозрачности. Ещё одной проблемой алгоритма является просачивание света сквозь объекты (light leaking). Также этому явлению подвержены плоскости, расположенные рядом с освещёнными вокселями нижних уровней SVO:

Этот эффект можно ослабить с помощью AO.
Light leaking: слева — при использовании алгоритма VCT, справа — в реальной жизни.

Из всего вышесказанного вытекает ещё одно следствие: не так просто настроить алгоритм, чтобы получить хорошую картинку — сколько брать конусов, сколько уровней SVO, с какими коэффициентами и т.д.

Для хранения динамических объектов потребуется модифицировать SVO и соответствующие буферы. Мы рассмотрели только статические объекты сцены. Как правило, в этом случае предлагают раздельно хранить статические и динамические узлы.

Но зато она поможет добиться правдоподобного ambient-освещения. Подводя итог, я бы не стал рекомендовать эту технику, если у вас нет достаточно времени, терпения и сил.

Демонстрация в динамике:

Ссылка на приложение: https://github.com/Darkxiv/VoxelConeTracing (bin/VCT.exe)

Список источников и ресурсов, которые могут помочь в реализации воксельной трассировки конусами:

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть