Привет! В данной статье представлена простая реализация Reflective Shadow Maps (алгоритм описан в предыдущей статье). Далее я объясню, как я это сделал и какие подводные камни были. Также будут рассмотрены некоторые возможные оптимизации.
Рисунок 1: Слева направо: без RSM, с RSM, разница
Результат
На рисунке 1 вы можете увидеть результат полученный с помощью RSM. Для создания этих изображений использовались “Стэнфордский кролик” и три разноцветных четырехугольника. На изображении слева вы можете увидеть результат рендера без RSM, используя только spot light. Все, что в тени, ― полностью черное. На изображении по центру изображен результат с RSM. Заметны следующие различия: повсюду более яркие цвета, розовый цвет, заливающий пол и кролика, затенение не полностью черное. Последнее изображение демонстрирует разницу между первым и вторым, и следовательно вклад RSM. На среднем изображении видны более жесткие края и артефакты, но это можно решить настройкой размера ядра, интенсивности непрямого освещения и количества сэмплов.
Реализация
Алгоритм был реализован на собственном движке. Шейдеры написаны на HLSL, а рендер на DirectX 11. Я уже настроил deferred shading и shadow mapping для directional light (направленного источника освещения) до написания этой статьи. Сначала я реализовал RSM для directional light и только после добавил поддержку shadow map и RSM для spot light.
Расширение shadow map
Традиционно, Shadow Maps (SM) ― это не более, чем карта глубины. Это означает, что вам даже не нужен пиксельный / фрагментный шейдер для заполнения SM. Однако для RSM вам понадобится несколько дополнительных буферов. Вам нужно хранить world-space позицию, world-space нормали и flux (световой поток). Это означает, что вам нужен пиксельный / фрагментный шейдер с несколькими render target. Имейте в виду, что для этой техники вам нужно отсекать задние грани (face culling), а не передние. Использование face culling передних граней является широко используемым способом избежать артефактов теней, но это не работает с RSM.
Вы передаете world-space позиции и нормали в пиксельный шейдер и записываете их в соответствующие буферы. Если вы используете normal mapping, то также рассчитывайте их в пиксельном шейдере. Flux рассчитывается там же, умножением albedo материала на цвет источника освещения. Для spot light вам нужно умножить полученное значение на угол падения. Для directional light получится не затененное изображение.
Подготовка к расчету освещения
Для основного прохода вам нужно сделать несколько вещей. Вы должны забиндить все буферы, используемые в проходе теней, в качестве текстур. Вам также нужны случайные числа. В официальной статье говориться, что нужно предварительно рассчитать эти числа и сохранить их в буфере, чтобы уменьшить количество операций в проходе сэмплинга RSM. Поскольку алгоритм тяжелый с точки зрения производительности, я полностью согласен с официальной статьей. Там также рекомендуется придерживаться временной когерентности (использоваться одинаковый паттерн сэмплинга для всех расчетов непрямого освещения). Это позволит избежать мерцаний, когда каждый кадр используются разные тени.
Вам нужно два случайных числа с плавающей точкой в диапазоне [0, 1] для каждого сэмпла. Эти случайные числа будут использоваться для определения координат сэмпла. Вам также понадобится та же матрица, которую вы используете для преобразования позиций из world-space (мировое пространство) в shadow-space (пространство источника освещения). Также понадобится такие параметры сэмплинга, который дадут черный цвет, если сэмплить за границами текстуры.
Выполнение прохода освещения
Теперь сложная для понимания часть. Я рекомендую рассчитывать непрямое освещение после того, как вы рассчитали прямое для конкретного источника света. Это потому, что вам нужен full-screen квад для directional light. Однако для spot и point light вы, как правило, хотите использовать меши определенной формы с выполнением culling, чтобы заполнить меньше пикселей.
На фрагменте кода ниже для пикселя рассчитывается непрямое освещение. Далее я объясню, что там происходит.
float3 DoReflectiveShadowMapping(float3 P, bool divideByW, float3 N)
{
float4 textureSpacePosition = mul(lightViewProjectionTextureMatrix,
float4(P, 1.0));
if (divideByW) textureSpacePosition.xyz /= textureSpacePosition.w;
float3 indirectIllumination = float3(0, 0, 0);
float rMax = rsmRMax;
for (uint i = 0; i < rsmSampleCount; ++i)
{
float2 rnd = rsmSamples[i].xy;
float2 coords = textureSpacePosition.xy + rMax * rnd;
float3 vplPositionWS = g_rsmPositionWsMap
.Sample(g_clampedSampler, coords.xy).xyz;
float3 vplNormalWS = g_rsmNormalWsMap
.Sample(g_clampedSampler, coords.xy).xyz;
float3 flux = g_rsmFluxMap.Sample(g_clampedSampler, coords.xy).xyz;
float3 result = flux
* ((max(0, dot(vplNormalWS, P – vplPositionWS))
* max(0, dot(N, vplPositionWS – P)))
/ pow(length(P – vplPositionWS), 4));
result *= rnd.x * rnd.x;
indirectIllumination += result;
}
return saturate(indirectIllumination * rsmIntensity);
}
Первый аргумент функции ― P, который является world-space позицией (в мировом пространстве) для определенного пикселя. DivideByW используется для перспективного деления, необходимого для получения правильного значения Z. N ― это world-space нормаль.
float4 textureSpacePosition = mul(lightViewProjectionTextureMatrix,
float4(P, 1.0));
if (divideByW) textureSpacePosition.xyz /= textureSpacePosition.w;
float3 indirectIllumination = float3(0, 0, 0);
float rMax = rsmRMax;
В этой части кода рассчитывается light-space (относительно источника освещения) позиция, инициализируется переменная непрямого освещения, в которой будут суммироваться значения рассчитанные от каждого сэмпла, и задается переменная rMax из уравнения освещения в официальной статье, значение которой я объясню в следующем разделе.
for (uint i = 0; i < rsmSampleCount; ++i)
{
float2 rnd = rsmSamples[i].xy;
float2 coords = textureSpacePosition.xy + rMax * rnd;
float3 vplPositionWS = g_rsmPositionWsMap
.Sample(g_clampedSampler, coords.xy).xyz;
float3 vplNormalWS = g_rsmNormalWsMap
.Sample(g_clampedSampler, coords.xy).xyz;
float3 flux = g_rsmFluxMap.Sample(g_clampedSampler, coords.xy).xyz;
Здесь мы начинаем цикл и подготавливаем наши переменные для уравнения. В целях оптимизации, случайные выборки, которые я рассчитал, уже содержат смещения координат, то есть для получения UV-координат мне достаточно добавить rMax * rnd к light-space координатам. Если полученные UV выходят за пределы диапазона [0,1], сэмплы должны быть черными. Что логично, поскольку они выходит за пределы диапазона освещения.
float3 result = flux
* ((max(0, dot(vplNormalWS, P – vplPositionWS))
* max(0, dot(N, vplPositionWS – P)))
/ pow(length(P – vplPositionWS), 4));
result *= rnd.x * rnd.x;
indirectIllumination += result;
}
return saturate(indirectIllumination * rsmIntensity);
Это та часть, где рассчитывается уравнению непрямого освещения (рисунок 2), а также взвешивается согласно дистанции от light-space координаты до сэмпла. Уравнение выглядит устрашающе, и код не помогает все понять, поэтому я объясню подробнее.
Переменная Φ (phi) ― это световой поток (flux), который является интенсивностью излучения. Предыдущая статья описывает flux более подробно.
Flux масштабируется двумя скалярными произведениями. Первое ― между нормалью источника освещения (текселя) и направлением от источника освещения к текущей позиции. Второе ― между текущей нормалью и вектором направления от текущей позиции к позиции источника освещения (текселя). Чтобы не получить отрицательный вклад в освещение (получается, если пиксель не освещен), скалярные произведения ограничиваются диапазоном [0, ∞]. В этом уравнении в конце выполняется нормализация, я полагаю, из соображений производительности. В равной степени допустимо нормализовывать векторы направлений перед выполнением скалярных произведений.
Рисунок 2: Уравнение освещенности точки с позицией x и нормалью n направленным пиксельным источником освещения p
Результат этого прохода можно смешать с backbuffer (прямое освещение), и получится результат как на рисунке 1.
Подводные камни
При реализации этого алгоритма я столкнулся с некоторыми проблемами. Я расскажу об этих проблемах, чтобы вы не на наступили на те же грабли.
Неправильный сэмплер
Я потратил значительное количество времени на выяснение того, почему у меня непрямое освещение выглядело повторяющимся. У Crytek Sponza текстуры затайлены, поэтому для нее нужен был wrapped сэмплер. Но для RSM он не очень подходит.
Настраиваемые значения
Для улучшения рабочего процесса важно иметь возможность менять некоторые переменные нажатием кнопок. Например, интенсивность непрямого освещения и диапазон сэмплинга (rMax). Эти параметры должны настраиваться для каждого источника освещения. Если у вас большой диапазон сэмплинга, то вы получаете непрямое освещение отовсюду, что полезно для больших сцен. Для более локального непрямого освещения вам понадобится меньший диапазон. На рисунке 3 показано глобальное и локальное непрямое освещение.
Рисунок 3: Демонстрация зависимости rMax.
Отдельный проход
Сначала я думал, что смогу сделать непрямое освещение в шейдере, в котором считаю прямое освещение. Для directional light это работает, потому что вы все равно отрисовываете полноэкранный квад. Однако для spot и point light вам нужно оптимизировать расчет непрямого освещения. Поэтому я считал непрямое освещение отдельным проходом, что необходимо, если вы также хотите сделать screen-space интерполяцию.
Кэш
Этот алгоритм вообще не дружит с кэшем. В нем выполняется сэмплинг в случайных точках в нескольких текстурах. Количество сэмплов без оптимизаций также недопустимо велико. С разрешением 1280 * 720 и количеством сэмплов RSM 400 вы сделаете 1.105.920.000 сэмплов для каждого источника освещения.
За и против
Я перечислю плюсы и минусы данного алгоритма расчета непрямого освещения.
За | Против |
Легкий для понимания алгоритм | Вообще не дружит с кэшем |
Хорошо интегрируется с deferred renderer | Требуется настройка переменных |
Можно использовать в других алгоритмах (LPV) | Принудительный выбор между локальным и глобальным непрямым освещением |
Оптимизации
Я сделал несколько попыток увеличить скорость этого алгоритма. Как было описано в официальной статье, можно реализовать screen-space интерполяцию. Я это сделал, и рендеринг немного ускорился. Ниже я опишу некоторые оптимизации, и проведу сравнение (в кадрах в секунду) между следующими реализациями, используя сцену с 3 стенами и кроликом: без RSM, наивная реализация RSM, интерполированный RSM.
Z-check
Одна из причин, по которой мой RSM работал неэффективно, заключалась в том, что я также рассчитывал непрямое освещение для пикселей, которые были частью скайбокса. Скайбокс определенно не нуждается в этом.
Предрасчет рандомных сэмплов на CPU
Предварительный расчет сэмплов не только даст большую временную когерентность, но также избавляет вас от необходимости пересчитывать эти сэмплы в шейдере.
Screen-space интерполяция
В официальной статье предлагается использовать render target низкого разрешения для расчета непрямого освещения. Для сцен с большим количеством гладких нормалей и прямых стен информацию об освещении можно легко интерполировать между точками с более низким разрешением. Я не буду подробно описывать интерполяцию, чтобы эта статья была немного короче.
Заключение
Ниже представлены результаты для разного количества сэмплов. У меня есть несколько замечаний относительно этих результатов:
- Логически, FPS остается около 700 для разного количества сэмплов, когда не выполняется расчет RSM.
- Интерполяция дает некоторый оверхед и не очень полезна при небольшом количестве сэмплов.
- Даже при 100 сэмплах итоговое изображение выглядело достаточно хорошо. Это может быть связано с интерполяцией, которая “размывает” непрямое освещение.
Sample count | FPS for No RSM | FPS for Naive RSM | FPS for Interpolated RSM |
100 | ~700 | 152 | 264 |
200 | ~700 | 89 | 179 |
300 | ~700 | 62 | 138 |
400 | ~700 | 44 | 116 |
Автор: Александр Петренко