Обзор самописной 2D системы освещения для top-down Pixel Art игр на расте и wgsl

В последние два с половиной месяца я с головой ушел в разработку системы освещения для top-down 2D игр "с нуля". В данный момент у меня готова полностью динамическая система для первичного и вторичного света (т.е. – global illumination) методом расчета проб через трассировку лучей в SDF пространстве. Решил поделиться прогрессом, кодом и техническим описанием подхода.

Редактирование света от неба. 

Система пока что умеет работать только с омни-источниками света (те что излучают во все стороны), светом от неба, окклюдерами и масками крыш. Также система пересчитывает свет в каждом кадре без запеканий. Так что можно двигать источники и окклюдеры без оффлайн пересчета. В качестве движка используется Bevy (сама система написана на Rust, WGPU и WGSL для шейдеров). Систему можно использовать со стандартным спрайтовым 2D конвейером, хотя у него есть некоторые ограничения. Почти вся система построена на compute шейдерах, фрагментный шейдер используется лишь на финальном этапе "смешивания".

В качестве минимального таргета я изначально выбрал что-то сопоставимое с GPU в Nintendo Switch. На PC и Apple нужен GPU с Vulkan, DX12 или Metal, с 1~2 TFLOPs. Т.е. примерно 25-30% вычислительной мощности Radeon 5500M, 1060 GTX, или M1 2020 года. Саму систему можно настроить на более высокую производительность за счет ухудшения качества и разрешения.

Полное демо:

Код полностью открыт. Можно взять здесь:

Для запуска требуется Cargo

cargo run --example maze

Как это работает

Реализация основана на нескольких подходах с активным использованием кешей свечения экраных проб:

1) Первый проход вычисляет SDF для всех окклюдеров (объектов перекрывающий свет на своем слое, ориентированых прямоугольников – AABB) и сохраняет его в текстуре с одним каналом. SDF будет использоваться для трассировки лучей и для проверки видимости на практически всех последующих проходах.

Это довольно простой шаг, на практике можно использовать JFA, я же пока ограничелся простой аналитически формулой расстояния до всех окклюдеров. Вместо привычной min, расстояния комбинируется с помощью оператора round merge. Комбинирование round merge нужно чтобы свет немного "проникал" внутрь окклюдеров и подсвечивал, например, контуры стен. В шейдере выглядит это примерно так:

fn sdf_aabb_occluder(p: vec2<f32>, occluder_i: i32) -> f32 { let occluder = light_occluder_buffer.data[occluder_i]; var d = abs(p - occluder.center) - occluder.h_extent; let d_o = length(max(d, vec2<f32>(0.0))); let d_i = min(max(d.x, d.y), 0.0); return d_o + d_i; } fn round_merge(s1: f32, s2: f32, r: f32) -> f32 { var intersectionSpace = vec2<f32>(s1 - r, s1 - r); intersectionSpace = min(intersectionSpace, vec2<f32>(0.0)); let insideDistance = -length(intersectionSpace); let simpleUnion = min(s1, s2); let outsideDistance = max(simpleUnion, r); return insideDistance + outsideDistance; }

На выходе получаем такую карту расстояний (слева – финальная картинка, справа – карта SDF):

2) Второй проход вычисляет свечение от прямого света. Здесь мы делим все пространство экрана на тайлы 8x8 пикселей, ставим пробы внутри каждого тайла и проверяем количество света, полученного пробой от каждого источника, с учетом перекрытий, которые проверяется с помощью SDF обычным рей-марчингом.

Размер пробы выбран 8x8 пикселей, таким образом на этом шаге нам нужно делать в 64 раза меньше работы. На каждом кадре мы можем немного сдвигать пробы относительно центра тайла чтобы получить больше информации при комбинировании кадров в последующих шагах. Например, можно использовать низко-дисперстную последовательность для выбора сдвига пробы от центра тайла в зависимости от индекса кадра:

Низко-дисперсная последовательность для заполнения тайла пробами.
Низко-дисперсная последовательность для заполнения тайла пробами.

Окончательный вклад для каждого источника света на этом шаге вычисляется с помощью квадратичного рассеивания. Можно выбрать любую понравившуюся функцию. У меня можно задать параметры a, b и c квадратичного рассеивания для каждого источника как a / (b * distance + c * distance ^ 2). Выглядит это как-то так:

Изменения параметра затухания света в зависимости от расстояния до исчтоника.

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

Атлас хранящий свечение проб для 8 кадров. Размер тайла каждой пробы 8x8 пикселей.
Атлас хранящий свечение проб для 8 кадров. Размер тайла каждой пробы 8x8 пикселей.

3) Третий проход вычисляет вторичный отраженный свет. Подход аналогичен применяемому в втором проходе, но вместо источников прямого света используется сэмплы из посчитанных проб. Грубо говоря, в глобальном освещении у нас каждая поверхность излучает отраженный свет, на предыдущем шаге мы это излучение посчитали и теперь можем использовать. Для выборки мы используем экспоненциальный паттерн чтобы проверить, сколько отраженного света получено пробой. Здесь мы пускаем случайные лучи во все стороны от поверхности и считываем свечение от той пробы в которую попали. Максимальная длина луча на каждой итерации удлиняется экпоненциально. Чем больше у нас итераций и лучей, тем меньше шума.

Как и во втором проходе, SDF используется для трассировки и проверки перекрытия, а окончательный вклад вычисляется с помощью квадратичного рассеивания. После этого шага мы получаем немного шумную карту свечения которая выглядит примерно так:

Пример шума при подсчете вторичного света.
Пример шума при подсчете вторичного света.

Количество лучей и параметры размера ядра шумодава влюяют на картинку примерно так:

Карту свечения полученную на этом шаге мы тоже сохраняем в атлас для 8 кадров.

Атлас со свечением от вторичного света.
Атлас со свечением от вторичного света.

Наконец, на последнем шаге мы объединяем результаты второго и третьего проходов, комбинируя свечение из предыдущих восьми кадров. Самая сложная часть на этом шаге это корректно сделать ре-проекцию из предыдущих кадров. Поскольку наша система изначально рассчитана на 2D top-down игры, то для ре-проекции достаточно лишь посчитать сдвиг по сравнению с текущим и предыдущими кадрами. В 3D со свободной камерой было бы куда сложнее получить приемлемый результат (отсюда и возникают артефакты диз-оклюжена во всяких FSR и DLSS). После этого мы просто суммируем свечение из всех предыдущих кадров и берем взвешенное среднее. По желанию мы также фильтруем результат с помощью сглаживающего фильтра с учетом краев и применяем гамма-коррекцию. Данный фильтр это просто смесь гаусcовских функций учитывающих не только расстояние (т.е. разницу по координатам) но и разницу по значениям. Так выглядит свет после смешивания проб и применения сглаживания:

Далее мы просто применяем эту информацию к тому слою для которого расcчитывали свет (пол, объекты на полу, крыша и тп) внутри фрагментного шейдера пост-обработки. У меня свет добавляется перемножением значения света на дифузную карту. (на выходе также добавляется bloom, но это встроенная функция в Bevy 0.9).

Дифузная карта, посчитаный свет, финальная картинка.

Основной выигрыш в производительности достигается за счет вычисления значения света только 1 / 64 количества пикселей в каждом кадре (для размера тайла пробы 8x8). Остальные пиксели интерполируются из ближайших проб.

Идеи подхода и реализации основаны на данных материала:

  • https://gpuopen.com/download/publications/GPUOpen2022_GI1_0.pdf
  • https://samuelbigos.github.io/posts/2dgi1-2d-global-illumination-in-godot.html
  • https://casual-effects.com/research/McGuire2017LightField/McGuire2017LightField-GDCSlides.pdf
  • https://www.docdroid.net/YNntL0e/godot-sdfgi-pdf#page=2
  • https://www.youtube.com/watch?v=2GYXuM10riw
330330
58 комментариев

Господи, годнота то какая!
Внезапно, геймдев подсайт еще жив.

35
Ответить

Красота!

9
Ответить
Автор

Спасибо)

3
Ответить

Интересно, здесь тот же подход используется? https://www.youtube.com/watch?v=o2kgW2TBpUo

8
Ответить
Автор

Я тоже подписан на автора, но к сожалению не знаю как именно у него устроен расчет GI, но думаю похитрее чем у меня) Хотя бы потому что я этим начал заниматься 2 месяца назад, а автор этого канала насколько мне известно ответственен за написание рендерера в Path of Exlile.

4
Ответить

сложно и ничего не понятно, но очень интересно)

6
Ответить
Автор

Если не понятно – спрашивай)

4
Ответить