Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
После появления прошлым летом графических карт Nvidia RTX трассировка лучей (ray tracing) снова обрела былую популярность. За последние несколько месяцев мою ленту в Twitter заполнил бесконечный поток сравнений графики со включенным и отключенным RTX.
Полюбовавшись на такое количество красивых изображений, я захотел самостоятельно попробовать скомбинировать классический упреждающий рендерер (forward renderer) с трассировщиком лучей.
Страдая синдромом неприятия чужих разработок, я в результате создал собственный гибридный движок рендеринга на основе WebGL1. Поиграть с демо рендеринга уровня из Wolfenstein 3D со сферами (которые я использовал из-за трассировки лучей) можно здесь.
Прототип
Я начал этот проект с создания прототипа, пытаясь воссоздать глобальное освещение с трассировкой лучей из Metro Exodus.
Первый прототип, демонстрирующий рассеянное глобальное освещение (Diffuse GI)
Прототип основан на упреждающем рендерере (forward renderer), отрисовывающем всю геометрию сцены. Шейдер, использованный для растеризации геометрии, не только вычисляет прямое освещение, но и испускает случайные лучи с поверхности отрендеренной геометрии для накопления с помощью трассировщика лучей непрямого отражения света, возникающего от неблестящих поверхностей (Diffuse GI).
На изображении выше можно увидеть, как все сферы корректно освещены только непрямым освещением (лучи света отражаются от стены за камерой). Сам источник света закрыт коричневой стеной в левой части изображения.
Wolfenstein 3D
В прототипе используется очень простая сцена. В ней есть только один источник освещения и рендерятся всего несколько сфер и кубов. Благодаря этому код трассировки лучей в шейдере очень прост. Цикл грубого перебора проверки пересечений, в котором луч тестируется на пересечение со всеми кубами и сферами в сцене, всё равно достаточно быстр, чтобы программа могла выполнять его в реальном времени.
После создания этого прототипа я захотел сделать нечто более сложное, добавив в сцену больше геометрии и множество источников освещения.
Проблема с более сложным окружением заключается в том, что мне всё равно нужно иметь возможность трассировки лучей в сцене в реальном времени. Обычно для ускорения процесса трассировки лучей использовалась бы структура bounding volume hierarchy (BVH), но моё решение создавать этот проект на WebGL1 не позволило этого сделать: в WebGL1 невозможно загружать 16-битные данные в текстуру и нельзя использовать в шейдере двоичные операции. Это усложняет предварительное вычисление и применение BVH в шейдерах WebGL1.
Именно поэтому я решил использовать для этого демо уровень из Wolfenstein 3D. В 2013 году я создал в Shadertoy один фрагментный шейдер WebGL, который не только рендерит похожие на Wolfenstein уровни, но и процедурно создаёт все необходимые текстуры. Из опыта работы над этим шейдером я знал, что основанную на сетке конструкцию уровней Wolfenstein можно также использовать как быструю и простую структуру ускорения, и что трассировка лучей по этой структуре выполняться будет очень быстро.
Ниже показан скриншот демо, а в полноэкранном режиме в него можно поиграть здесь: https://reindernijhoff.net/wolfrt.
Краткое описание
В демо используется гибридный движок рендеринга. Для рендеринга всех полигонов в кадре он использует традиционную растеризацию, а затем комбинирует результат с тенями, diffuse GI и отражениями, созданными трассировкой лучей.
Тени
Diffuse GI
Отражения
Упреждающий рендеринг
Карты Wolfenstein можно полностью закодировать в двумерхную сетку размером 64×64. Карта, использованная в демо, основана на первом уровне эпизода 1 Wolfenstein 3D.
При запуске создаётся вся геометрия, необходимая для прохода упреждающего рендеринга. Меш стен генерируется из данных карты. Также создаются плоскости пола и потолка, отдельные меши для источников освещения, дверей и располагаемых в случайном порядке сфер.
Все текстуры, использованные для стен и дверей, упакованы в единый атлас текстур, поэтому все стены можно отрисовывать за один вызов отрисовки.
Тени и освещение
Прямое освещение вычисляется в шейдере, используемом для прохода упреждающего рендеринга. Каждый фрагмент может быть освещён (максимум) четырьмя разными источниками. Чтобы знать, какие источники могут влиять на фрагмент в шейдере, при запуске демо предварительно вычисляется текстура поиска. Эта текстура поиска имеет размер 64 на 128 и кодирует позиции 4 ближайших источников освещения для каждой позиции в сетке карты.
varying vec3 vWorldPos;
varying vec3 vNormal;
void main(void) {
vec3 ro = vWorldPos;
vec3 normal = normalize(vNormal);
vec3 light = vec3(0);
for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) {
light += sampleLight(i, ro, normal);
}
Для получения мягких теней для каждого фрагмента и источника освещения сэмплируется случайная позиция в источнике освещения. С помощью имеющегося в шейдере кода трассировки лучей (см. ниже раздел «Трассировка лучей») испускается луч тени в точку сэмплирования для определения видимости источника освещения.
После добавления (вспомогательных) отражений (см. ниже раздел «Отражение») к вычисленному цвету фрагмента добавляется diffuse GI выполнением поиска в Diffuse GI Render Target (см. ниже).
Трассировка лучей
Хотя в прототипе код трассировки лучей для diffuse GI был объединён с упреждающим шейдером, в демо я решил их разделить.
Разделил я их, выполнив вторую отрисовку всей геометрии в отдельный render target (Diffuse GI Render Target) с помощью другого шейдера, который только испускает случайные лучи для сбора diffuse GI (см. ниже раздел «Diffuse GI»). Собранное в этом render target освещение прибавляется к вычисленному в проходе упреждающего рендера прямому освещению.
Благодаря разделению упреждающего прохода и diffuse GI мы можем испускать менее одного луча diffuse GI на экранный пиксель. Это можно сделать, уменьшив Buffer Scale (сдвинув ползунок в параметрах в правом верхнем углу экрана).
Например, если Buffer Scale равен 0.5, то будет испускаться только один луч на каждые четыре экранных пикселя. Это даёт огромное повышение производительности. С помощью того же UI в правом верхнем углу экрана можно изменять также количество сэмплов на пиксель в render target (SPP) и количество отражений луча.
Испускаем луч
Чтобы иметь возможность испускания лучей в сцену, вся геометрия уровня должна иметь формат, который может использовать трассировщик лучей в шейдере. Уровень Wolfenstein закодировал сеткой 64×64, поэтому достаточно легко закодировать все данные в одну текстуру 64×64:
- В канале красного цвета текстуры кодируются все объекты, находящиеся в соответствующей ячейке x,y сетки карты. Если значение канала красного равно нулю, то в ячейке нет никаких объектов, в противном случае, её занимает стена (значения от 1 до 64), дверь, источник освещения или сфера, которые нужно проверить на пересечение.
- Если ячейку сетки уровня занимает сфера, то зелёный, синий и альфа-канал используются для кодирования радиуса и относительных координат x и y сферы внутри ячейки сетки.
Испускание луча в сцене выполняется обходом текстуры с помощью следующего кода:
bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max,
inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) {
vec3 pos = floor(ro);
vec3 ri = 1.0/rd;
vec3 rs = sign(rd);
vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri;
for( int i=0; i<MAXSTEPS; i++ ) {
vec3 mm = step(dis.xyz, dis.zyx);
dis += mm * rs * ri;
pos += mm * rs;
vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.));
if (isWall(mapType)) {
...
return true;
}
}
return false;
}
Аналогичный код трассировки лучами сетки можно найти в этом шейдере Wolfenstein на Shadertoy.
После вычисления точки пересечения со стеной или дверью (с помощью теста пересечения с параллелограммом), поиск в том же атласе текстур, который использовался для прохода упреждающего рендеринга, даёт нам albedo точки пересечения. Сферы имеют цвет, который процедурно определяется на основании их координат x,y в сетке и функции цветового градиента.
С дверями всё немного сложнее, потому что они движутся. Чтобы представление сцены в ЦП (используемое для рендеринга мешей в проходе упреждающего рендеринга) было бы таким же, как представление сцены в GPU (используемое для трассировки лучей), все двери движутся автоматически и детерминированно, на основании расстояния от камеры до двери.
Diffuse GI
Рассеянное глобальное освещение (diffuse GI) вычисляется испусканием лучей в шейдере, который используется для отрисовки всей геометрии в Diffuse GI Render Target. Направление этих лучей зависит от нормали к поверхности, определяемой с помощью сэмплирования взвешенной по косинусу полусферы.
Имея направление луча rd и начальную точку ro, отражённое освещение можно вычислить с помощью следующего цикла:
vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) {
vec3 emitted = vec3(0);
vec3 recPos, recNormal, recColor;
for (int i=0; i<MAX_RECURSION; i++) {
if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) {
// if (isLightHit) { // direct light sampling code
// return vec3(0);
// }
col *= recColor;
for (int i=0; i<2; i++) {
emitted += col * sampleLight(i, recPos, recNormal);
}
} else {
return emitted;
}
rd = cosWeightedRandomHemisphereDirection(recNormal);
ro = recPos;
}
return emitted;
}
Чтобы снизить шум, в цикл добавляется сэмплирование прямого освещения. Это похоже на технику, использованную в моём шейдере Yet another Cornell Box на Shadertoy.
Отражение
Благодаря возможности трассировки сцены лучами в шейдере позволяет очень просто добавлять отражения. В моём демо отражения добавляются вызовом того же метода getBounceCol, который показан выше, с использованием отражённого луча камеры:
#ifdef REFLECTION
col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15);
#endif
Отражения добавляются в проходе упреждающего рендеринга, следовательно на один экранный пиксель всегда будет испускаться один луч отражения.
Временное сглаживание (Temporal anti-aliasing)
Так как и для мягких теней в проходе упреждающего рендеринга, и в аппроксимации diffuse GI используется примерно один сэмпл на пиксель, конечный результат получается чрезвычайно шумным. Для снижения количества шума использовано временное сглаживание (temporal anti-aliasing, TAA), реализованное на основе TAA компании Playdead: Temporal Reprojection Anti-Aliasing in INSIDE.
Повторное проецирование
Идея, лежащая в основе TAA, достаточно проста: TAA вычисляет по одному субпикселю на кадр, а затем усредняет его значения с коррелирующим пикселем из предыдущего кадра.
Чтобы знать, где находился текущий пиксель в предыдущем кадре, выполняется повторное проецирование позиции фрагмента с помощью матрицы model-view-projection предыдущего кадра.
Отбрасывание сэмплов и ограничение соседства
В некоторых случаях сохранённый из прошлого сэмпл недействителен, например, когда камера переместилась таким образом, что фрагмент текущего кадра в предыдущем кадре был закрыт геометрией. Для отбрасывания таких недействительных сэмплов используется ограничение соседства. Я выбрал наиболее простой тип ограничения:
vec3 history = texture2D(_History, uvOld ).rgb;
for (float x = -1.; x <= 1.; x+=1.) {
for (float y = -1.; y <= 1.; y+=1.) {
vec3 n = texture2D(_New, vUV + vec2(x,y) / _Resolution).rgb;
mx = max(n, mx);
mn = min(n, mn);
}
}
vec3 history_clamped = clamp(history, mn, mx);
Я также пытался использовать способ ограничения на основе ограничивающего параллелограмма, но не увидел особой разницы со своим решением. Вероятно, так получилось потому, что в сцене из демо есть много одинаковых тёмных цветов и почти отсутствуют подвижные объекты.
Колебания камеры
Чтобы получить сглаживание, камера в каждом кадре колеблется благодаря использованию (псевдо)случайного субпиксельного смещения. Это реализовано изменением матрицы проецирования:
this._projectionMatrix[2 * 4 + 0] += (this.getHaltonSequence(frame % 51, 2) - .5) / renderWidth;
this._projectionMatrix[2 * 4 + 1] += (this.getHaltonSequence(frame % 41, 3) - .5) / renderHeight;
Шум
Шум — это основа алгоритмов, используемых для вычисления diffuse GI и мягких теней. Использование хорошего шума сильно влияет на качество изображения, в то время как плохой шум создаёт артефакты или замедляет схождение изображений.
Боюсь, что использованный в этом демо белый шум не очень хорош.
Вероятно, использование хорошего шума — самый важный аспект повышения качества изображения в этом демо. Например, можно использовавть синий шум.
Я провёл эксперименты с шумом на основе золотого сечения, но они не увенчались особым успехом. Пока используется имеющий дурную репутацию Hash without Sine Дейва Хоскинса:
vec2 hash2() {
vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3);
p3 += dot(p3, p3.yzx + 19.19);
return fract((p3.xx+p3.yz)*p3.zy);
}
Снижение количества шума
Даже при включенном TAA в демо всё равно видно много шума. Особенно сложно отрендерить потолок, потому что он освещается только непрямым освещением. Не упрощает ситуацию и то, что потолок — это большая плоская поверхность, залитая сплошным цветом: если бы на нём была текстура или геометрические детали, то шум стал бы менее заметным.
Я не хотел тратить много времени на эту часть демо, поэтому попробовал применить только один фильтр снижения шума: Median3x3 Моргана Макгуайра и Кайла Уитсона. К сожалению, этот фильтр не очень хорошо работает с «пиксель-артной» графикой текстур стен: он убирает все детали вдали и скругляет углы пикселей близких стен.
В ещё одном эксперименте я применил тот же фильтр к Diffuse GI Render Target. Хотя он немного и снизил шум, в то же время почти не изменив детали текстур стен, я решил, что это улучшение не стоит лишних потраченных миллисекунд.
Демо
В демо можно сыграть здесь.