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


Рендер сцены без глобального освещения, и с использованием VCT:

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

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

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

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

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

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

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


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

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

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

SVO — это разреженное октодерево сцены. Каждый узел такого дерева разбивает подпространство сцены на 8 равных частей. Разреженное дерево при этом не хранит информацию о пространстве, которое ничем не занято. Мы будем пользоваться этой структурой для поиска вокселей по их координатам в пространстве, а также для сэмплирования запеченного освещения. Запеченное освещение будет храниться в специальной 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, — это отображение исходных вокселей. Блоки, расположенные в соответствии с остальными узлами SVO на определённых уровнях октодерева, — это MIP-уровни буфера блоков.


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

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


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

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

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


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



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



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


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

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


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

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

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

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


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

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


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

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

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


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

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

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

Создание G-Buffer


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

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

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


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

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


Испускание конусов с поверхности, для которой рассчитывается входящее освещение [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]. Но тогда могут проявляться резкие границы вокселей, поэтому расположение сэмплов корректируется. В своей реализации я просто сдвигаю сэмплы ближе друг к другу.

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

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

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


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

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


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

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

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


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

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

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


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

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

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

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

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


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

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

Комментарии (7)


  1. FadeToBlack
    20.04.2018 09:09

    Я так понял, что получается некоторая схема, подобная лайтмапам. Уже давно существует алгоритм, который называется precomputed radiance transfer. Можете описать, в чем преимущество/радикальное отличие данного алгоритма от PRT? Я понимаю, что PRT существует только для world light, но а в других аспектах?


    1. DrZlodberg
      20.04.2018 10:55

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


    1. Darkxiv Автор
      20.04.2018 11:01

      Не изучал глубоко PRT, но насколько понимаю, здесь отличие в отсутствии precomputed шага, если не считать таковым вокселизацию и построение SVO, которые работают достаточно быстро.
      Также алгоритм больше заточен на динамику, перестройку сцены на лету и другие вещи. Думаю, можно придумать подходы, при которых, например, можно было бы в динамике менять свойства материалов в вокселях, и это бы ничего не стоило, т.к. пересчитывая свет мы бы опирались на уже существующее SVO.
      Да, пересчёт освещения довольно дорогой, но есть подходы по оптимизации.
      С другой стороны, в UE4 использовали и VCT, и лайтмапы, и отдали предпочтение последним.


  1. SmallSnowball
    20.04.2018 11:02

    Спасибо за статью!

    Возник вопрос по поводу самоосвещения. Направление полусферы конусов расчитывается исходя из локальной нормали, взятой из g-buffer'а, в которой уже учтены нормал мапы. Отсюда выходит, что при заметном отклонении локальной нормали от нормали к треугольнику меша часть конусов пойдет трассироваться прямиком внутрь меша. Не лучше ли будет фильтровать такие конусы, ведь ничего корректного мы из них все равно не получим?

    Попробовал сам сделать это, но разница в результатах получается недостаточно очевидная, плюс я не уверен, что не накосячил с ambient occlusion при правках шейдеров.

    Вот вариант без фильтрации
    image


    1. Darkxiv Автор
      20.04.2018 11:04

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


  1. topheracher
    20.04.2018 12:51

    Лучшеб рассказали про GI в Скайфордже. На реальных примерах. А то сферических Спонз в вакууме весь интернет. А GI юзают 2.5 студии.


    1. Darkxiv Автор
      20.04.2018 12:53

      Я рассказал про алгоритм, который исследовал в свободное время.
      Про Skyforge, к сожалению, ничего не могу сказать. Возможно, вам будет интересно почитать эту статью и комментарии к ней. Но, например, AW сделан на CryEngine, в котором для GI используется Light Propagation Volumes.