[Перевод] Реализация быстрых 2D-теней в Unity с помощью 1D shadow mapping
Введение
Недавно я приступил к реализации системы 2D-теней в Unity, которую можно было бы использовать в настоящей игре. Как известно профессиональным разработчикам, есть большая разница между тем, чего можно достичь в техническом демо и тем, что применимо для интеграции в полную игру, где реализованная возможность является лишь одной из множества. Влияние на ЦП, видеопроцессор и память должны находиться в балансе со всем остальным в игре. На практике у разных проектов возникают различные ограничения, но я решил создать систему, занимающую не больше пары миллисекунд времени обработки и не больше нескольких мегабайт в памяти.
Таким ограничением я отбрасывал множество уже существовавших способов расчёта теней, которые мне удалось найти. Популярной была пара техник. В одной применялась реализуемая на ЦП трассировка лучей, определяющая границы силуэтов блокирующей свет геометрии. В другой все препятствия для света рендерились в текстуру, а затем для неё выполнялся алгоритм типа ray-stepping с несколькими проходами для создания карты теней. Эти техники обычно используются не более чем с парой источников света и точно не позволили бы мне работать с десятками источников света в соответствии с выбранными мной ограничениями.
Наложение теней
Поэтому я решил создать 2D-аналог способа расчёта теней, который используется в большинстве современных 3D-игр: рендеринг геометрии с точки зрения источника освещения и создание буфера глубин, который позволит определять при рендеринге каждого из пикселя, видим ли он из источника освещения. Эта техника имеет название shadow mapping (наложение теней). В 3D она создаёт двухмерную текстуру, то есть в 2D она будет создавать одномерную текстуру. На скриншоте ниже показана моя готовая карта освещения в режиме просмотра ресурсов Unity;, но на самом деле она не для одного источника освещения, а для 64: каждая строка пикселей в текстуре — это карта теней для отдельного источника.
В этом способе используются полярные координаты для преобразования 2D-положения в угол и расстояние (или глубину) относительно другого объекта.
inline float2 CartesianToPolar(float2 point, float2 origin)
{
float2 d = point - origin;
// x - угол в радианах, y - расстояние
return float2(atan2(d.y, d.x), length(d));
}
То есть каждая строка в карте теней представляет собой 360 градусов вокруг источника освещения, а каждый пиксель представляет расстояние от источника освещения до ближайшей непрозрачной геометрии в этом направлении. Чем больше горизонтальное разрешение текстуры, тем больше точность получающихся теней.
Непрозрачная геометрия, отбрасывающая тени, которую далее я буду называть блокирующей геометрией, передаётся как список линий. Каждая пара вершин создаёт пару позиций в полярном пространстве, после чего пиксельный шейдер заполняет соответствующий отрезок в карте освещения. В каждом пикселе благодаря использованию стандартного теста z-буфера сохраняется только ближайший к геометрии пиксель. Неверно будет просто интерполировать в пиксельном шейдере полярную глубину для получения z-координаты, потому что так мы получим для прямых рёбер изогнутые тени. Вместо этого нам нужно вычислять точку пересечения между отрезком линии и лучом света под текущим углом, но это вопрос всего пары скалярных произведений и деления, что не очень затратно для современных видеопроцессоров.
Сложности
Всё это было бы очень просто, если бы не одна ложка дёгтя — серьёзная проблема при использовании полярных координат возникает тогда, когда мы получаем отрезок прямой в полярных координатах, который находится с двух сторон границы в 360 градусов. Обычным решением стало бы разбиение отрезка прямой на две отдельные части: первая часть бы заканчивалась на 360 градусах, а другая (остаток отрезка) — начиналась с 0. Однако вершинный шейдер получает только одну вершину и выдаёт один результат, и нет никакой возможности вывести два отдельных отрезка. Основная сложность такого подхода заключается в решении этой проблемы.
Можно решить её так: представлять исходные строки карты теней не в 360 градусах, а добавить дополнительные 180 градусов, то есть от 0 до 540. Отрезок прямой в полярном пространстве занимает не больше 180 градусов, поэтому этого достаточно, чтобы вместить любой отрезок, находящийся рядом с точкой 360. Это означает, что каждый отрезок прямой по-прежнему создаёт на выходе для пиксельного шейдера один отрезок прямой, как и нужно.
Недостаток этого способа в том. что первая часть строки (от 0 до 180) и последняя часть (от 360 до 540) фактически являются одной областью в полярном пространстве. Для проверки пикселя относительно карты теней нам нужно определить, попадает ли полярный угол в эту область, и если это так, то взять сэмплы из двух мест и выбрать минимум из двух глубин. Это совсем то, к чему я стремился — ветвление и дополнительное сэмплирование ужасным образом скажутся на производительности, особенно при множественном сэмплировании текстуры для Percentage Closest Filtering (PCF) (эта техника широко используется для создания плавных теней на основе карт теней). Моё решение (после заполнения всех строк карт теней) заключается в следующем: выполнение ещё одного прохода видеопроцессора по карте теней, её ресэмплирование и комбинированиме первых 180 градусов с последними 180 градусами. По обычным стандартам буфера кадра текстура карт теней очень мала, поэтому она занимает незначительное время видеопроцессора. В результате мы получаем готовую текстуру карты теней, в которой достаточно одного сэмпла, чтобы определить, освещён ли текущий пиксель конкретным источником освещения.
Основной недостаток этой системы заключается в том, что для распознавания и обработки пограничных случаев блокирующая геометрия должна иметь специальный формат. Каждая вершина каждого отрезка прямой сама похожа на отрезок прямой, потому что хранит положение другого конца отрезка. Это значит, что нам придётся или строить геометрию в этом формате во время выполнения приложения, или предварительно. Мы не можем просто передать геометрию, которая используется для рендеринга. Однако у этого есть и хорошая сторона: после построения этой специальной геометрии мы по крайней мере не передаём никаких ненужных данных, то есть эффективность оказывается выше.
Ещё одно отличное свойство этой системы заключается в том, что готовая карта теней может быть записана обратно в видеопроцессор, и это позволяет выполнять через ЦП запросы видимости без необходимости трассировки лучей. Копирование текстуры обратно в видеопроцессор может быть достаточно затратной задачей, и несмотря на то, что в Unity 2018 нас ждёт долгожданная реализация асинхронных повторных считываний (asynchronous gpu read-backs) видеопроцессора, эту функцию не стоит применять без реальной необходимости.
Алгоритм
По сути, алгоритм работает следующим образом.
- Создаём меш отрезков прямых для отбрасывающей тень геометрии. Если геометрия не изменяется, то не нужно перестраивать его в каждом кадре. Также можно использовать два меша — один для статичной геометрии, а другой для динамической, чтобы избежать ненужного перестраивания.
- Рендерим эту блокирующую геометрию для каждого отбрасывающего тень источника освещения. В демо для этого используются отдельные вызовы отрисовки, но это прекрасная возможность для применения дублирования (instancing), чтобы меш передавался в видеопроцессор только один раз. Каждому источнику света назначается строка в карте теней, и эти данные передаются в шейдер через константы шейдера, что позволяет создать подходящую координату Y, в которую нужно выполнять запись.
- В вершинном шейдере отрезки прямых преобразуются в полярное пространство.
- Видеопроцессор затеняет отрезки прямых в текущей строке текстуре теней, выполняя z-тест для хранения только ближайших пикселей.
- Ресэмплируем готовую карту теней в другую текстуру, чтобы устранить части, относящиеся к одной полярной области.
- Для каждого источника освещения рендерим четырёхугольник, покрывающий максимальный диапазон источника (например, радиус точечного источника освещения). Для каждого пикселя вычисляем полярную координату в соответствии с источником освещения, и используем её для сэмплирования карты теней. Если полярное расстояние больше, чем значение, считанное из карты теней, то пиксель не освещён источником освещения, то есть к нему освещение не применяется.
И вот готовые результаты. Для стресс-теста я задал 64 подвижных отбрасывающих тень точечных источника освещения с конусами освещения случайных размеров, движущиеся между несколькими поворачивающимися непрозрачными объектами.
Какими же оказались затраты? Если считать, что блокирующая геометрия статична, и что мы используем дублирование геометрии (instancing), то полная карта теней для любого количества источников (подверженного ограничениям размеров текстуры) может быть передана в видеопроцессор за единственный вызов отрисовки. Объём перерисовки в карте теней определяется сложностью блокирующей освещение геометрии, но так как текстура по сравнению с современными буферами кадров очень мала, производительность кэша видеопроцессора должна оказаться фантастической. Аналогично, когда мы доходим до сэмплирования карты теней, оно не отличается от shadow mapping в 3D, за исключением того, что карта теней будет гораздо меньше.
Наша игра состоит из единой крупной среды и у нас есть 64 постоянно активных и отбрасывающих тень источников освещения, поэтому я использовал текстуру карты теней размером 1024×64. Затраты в пределах общего бюджета вычислений кадра оказались минимальными.
Дополнительные возможности
Если вы захотите расширить эту систему, то я могу предложить пару интересных возможностей. При обработке карты теней для устранения двух накладывающихся друг на друга областей можно воспользоваться возможностью и преобразовать значения для создания экспоненциальной карты теней, а затем размыть её (не забывайте, что нужно размывать только в горизонтальном направлении, иначе это подействует на несвязанные друг с другом источники!). Это позволит нам создать плавные тени без мультисэмплирования карты теней. Второе: как я упомянул ранее, сейчас демо выполняет отдельный вызов отрисовки для передачи геометрии затенения каждого источника, но если упаковать положение источника освещения и другие параметры в матрицу, то это можно тривиальным образом сделать в один вызов отрисовки при помощи дублирования (instancing).
Более того, я считаю, что почти без дополнительной работы со стороны ЦП можно реализовать в качестве расширения системы излучательность (radiosity lighting) с одиночным отражением. Для этого можно использовать следующий принцип: видеопроцессор может использовать карту теней из прошлого кадра для расчёта отражений лучей света в сцене. Пока я не могу сказать ничего более подробного, потому что пока не реализовал эту систему. Если она заработает, то будет гораздо более эффективной, чем обычные реализации Virtual Point Light, в которых используется выполняемая ЦП трассировка лучей.
К тому же можно использовать эту систему множеством интересных способов. Например, если заменить источники освещения излучателями звука, то эту систему можно применять для вычисления поглощения звука. Или её можно использовать для определения поля видимости в процедурах ИИ. В общем случае, возможно превратить трассировку лучей в поиск по текстуре.
Завершение
На этом я завершаю рассказ о подробностях реализации моей системы одномерного наложения теней. Если у вас появились вопросы, то задавайте их в комментариях к оригиналу статьи.
Исходный код демо
https://www.double11.com/misc/uploads/1DShadowMapDemo.zip