[Перевод] Как рендерит кадр движок Unreal Engine
Однажды я искал исходный код Unreal и, вдохновлённый отличным анализом того, как популярные игры рендерят кадр (перевод статьи на Хабре), я решил тоже сделать с ним что-то подобное, чтобы изучить, как движок рендерит кадр (с параметрами и настройками сцены по умолчанию).
Поскольку у нас есть доступ к исходному коду, мы можем изучить исходники рендерера, чтобы понять, что он делает, однако это довольно объёмная часть движка, а пути рендеринга сильно зависят от контекста, поэтому проще будет исследовать чистый низкоуровневный API (иногда заглядывая в код, чтобы заполнить пробелы).
Я собрал простую сцену с несколькими статичными и динамичными пропсами, несколькими источниками освещения, объёмным туманом, прозрачными объектами и эффектом частиц, чтобы задействовать достаточно широкий диапазон материалов и способов рендеринга.
Итак, я пропустил Editor через RenderDoc и включил захват. Возможно, это не слишком похоже на то, как будет выглядеть кадр реальной игры, но даст нам приблизительное представление о том, как Unreal выполняет рендеринг стандартного кадра (я не менял никакие настройки и выбрал максимальное качество для PC):
Примечание: анализ основан на захвате информации видеопроцессора и исходном коде рендерера (версии 4.17.1). До этого проекта у меня не было особого опыта работы с Unreal. Если я что-то упустил или в чём-то ошибся, то сообщите мне об этом в комментариях.
К счастью, список вызовов отрисовки Unreal понятен и хорошо аннотирован, и это упростит нашу работу. Список может выглядеть иначе, если в сцене не будет некоторых сущностей/материалов, или если выбрать более низкое качество. Например, если выполнять рендеринг без частиц, то проходы ParticleSimulation будут отсутствовать.
Проход рендеринга SlateUI содержит все вызовы API, выполняемые Unreal Editor для рендеринга своего UI, поэтому мы пропустим его и сосредоточимся на всех проходах в разделе Scene.
Симуляция частиц
Кадр начинается с прохода ParticleSimulation. Он вычисляет в видеопроцессоре движение частиц и другие свойства для каждого эмиттера частиц в сцене для двух целевых рендеров: RGBA32_Float (сюда записываются позиции) и RGBA16_Float (скорости) (и пары связанных с временем/жизнью данных). Вот, например, выходные данные для целевого рендера RGBA32_Float, где каждый пиксель соответствует позиции спрайта в мире:
Похоже, что в этом случае добавленный мной в сцену эффект частиц имеет два эмиттера, требующих симуляции в видеопроцессоре без расчёта коллизий, поэтому соответствующие проходы рендеринга могут выполняться на ранних этапах создания кадра.
Предварительный проход Z-буфера
Следующим идёт проход рендеринга PrePass, который является предварительным проходом z-буфера. Он рендерит все непрозрачные полигональные сетки (меши) в буфер глубин R24G8:
Стоит заметить, что в Unreal при рендеринге в буфер глубин используется обратный Z-буфер (reverse-Z). Это значит, что ближней плоскости присваивается значение 1, а дальней — 0. Это обеспечивает бОльшую точность вдоль диапазона глубин и снижает количество z-конфликтов для далёких сеток. Название прохода рендеринга подразумевает, что проход запускается буфером «DBuffer». Так называется буфер декалей, который Unreal Engine использует для рендеринга отложенных декалей. Для него требуется глубина сцены, поэтому активируется предварительный проход Z-буфера. Но, как мы увидим ниже, Z-буфер используется и в других контекстах, например, для вычисления перекрытий (occlusion) и отражений в экранном пространстве.
Некоторые проходы рендера в списке оказываются пустыми. например ResolveSceneDepth, который, как я полагаю, необходим для платформ, действительно требующих «разрешения (resolving) по глубине» целевого рендера перед использованием его в качестве текстуры (на PC он не нужен), а также ShadowFrustumQueries, который выглядит как маркер-болванка, потому что настоящие тесты перекрытия для теней выполняются в следующем проходе рендера.
Проверка перекрытий
BeginOcclusionTests обрабатывает все проверки перекрытий в кадре. По умолчанию для проверки перекрытий Unreal использует аппаратные запросы перекрытий (hardware occlusion queries). Если вкратце, то она выполняется за три этапа:
- Мы рендерим всё, что мы воспринимаем как перекрывающий объект (например, большую непрозрачную сетку), в буфер глубин
- Создаём запрос перекрытий, передаём его и рендерим пропс, для которого хотим определить перекрытие. Это реализуется с помощью z-теста и буфера глубин, созданного на этапе 1. Запрос возвращает количество пикселей, прошедших z-test, то есть если значение равно нулю, значит, что пропс целиком находится за непрозрачной сеткой. Поскольку рендеринг всей сетки пропса для перекрытия может быть затратным, мы используем в качестве замены граничный параллелепипед (bounding box) этого пропса. Если он невидим, то и пропс совершенно точно тоже невидим.
- Считываем результаты запроса обратно в видеопроцессор и на основании количества отрендеренных пикселей мы можем выбрать, отправлять ли пропс на рендеринг, или нет (даже если видимо небольшое количество пикселей, мы можем решить, что пропс рендерить не стоит).
В Unreal используются разные типы запросов перекрытий, зависящие от контекста:
Аппаратные запросы перекрытий имеют свои недостатки — у них есть дробность вызовов отрисовки. Это значит, что они требуют от рендерера выполнять по одному вызову отрисовки на сетку (или группы сеток), для которой нужно определить перекрытие. Они могут значительно увеличить количество вызовов отрисовки на кадр, требуют считывания обратно в ЦП, что добавляет точки синхронизации между ЦП и видеопроцессором, и заставляют ЦП ждать, пока видеопроцессор закончит обработку запроса. Они не очень походят для клонируемой геометрии, но пока мы не будем обращать на это внимания.
Unreal решает проблему точки синхронизации ЦП и видеопроцессора как и любой другой движок, использующий запросы — отложенным для нескольких кадров считыванием данных запроса. Такой подход работает, однако он может добавить проблему «выпрыгивающих» на экран при быстром движении камеры пропсов (на практике это может и не быть серьёзной проблемой, потому что выполнение отсечения перекрытий с помощью граничных параллелепипедов является консервативным, то есть сетка со всей вероятностью будет помечена как видимая даже ещё до того, как она действительно станет видимой). Однако сохраняется проблема излишних вызовов отрисовки, и её решить не так просто. Unreal пытается снизить её влияние, группируя запросы следующим образом: сначала он рендерит всю непрозрачную геометрию в z-буфер (описанный выше предварительный проход Z-буфера). Затем он передаёт отдельные запросы для каждого пропса, который нужно проверить на перекрытие. В конце кадра он получает данные запросов из предыдущего (или ещё более раннего) кадра и разрешает задачу видимости пропса. Если он видим, то движок помечает его для рендеринга в следующем кадре. С другой стороны, если он невидим, то движок добавляет его в «сгруппированный» запрос, который объединяет в группу граничных параллелепипедов пропсов (максимум восемь объектов) и использует его для определения видимости в течение следующего кадра. Если в следующем кадре группа становится видимой (как целое), движок разбивает её и снова передаёт отдельные запросы. Если камера и пропсы статичны (или медленно движутся), то такой подход снижает количество необходимых запросов перекрытий в восемь раз. Единственной странностью, которую я заметил во время группировки (батчинга) перекрываемых пропсов, стало то, что она кажется случайной и не зависит от пространственной близости пропсов друг к другу.
Этот процесс соответствует маркерам IndividualQueries и GroupedQueries в приведённом выше списке проходов рендера. Часть GroupedQueries пуста, потому что движку не удалось создать создать запрос во время предыдущего кадра.
Для завершения прохода перекрытий ShadowFrustumQueries передаёт аппаратные запросы перекрытий граничных сеток локальных (точечных или направленных) (отбрасывающих и не отбрасывающих тень, вопреки названию прохода). Если они перекрыты, то нет смысла выполнять вычисления для них освещения/теней. Стоит заметить, что несмотря на наличие в сцене четырёх локальных источников света, отбрасывающих тень (для которых необходимо каждый кадр вычислять карту теней), количество вызовов отрисовки в ShadowFrustumQueries равно трём. Подозреваю, так получилось, потому что ограничивающий объём одного из источников пересекает ближнюю плоскость камеры, поэтому Unreal считает, что он всё равно будет видимым. Также стоит упомянуть, что для динамического освещения, у которого вычисляется кубическая карта теней, мы передаём для проверок перекрытий сферу,
а для статичного динамического освещения, которое Unreal рассчитывает для теней каждого объекта (подробнее об этом ниже), передаётся пирамида:
Наконец, я предполагаю, что PlanarReflectionQueries относится к тестам перекрытий, выполняемым при вычислении плоскостных отражений (создаваемых перемещением камеры за/перед плоскостью отражений и перерисовкой сеток).
Генерирование Hi-Z-буфера
Затем Unreal создаёт Hi-Z-буфер (проходы HZB SetupMipXX), хранимый как 16-битное число с плавающей запятой (формат текстуры R16_Float). Он получает в качестве входных данных буфер глубин, созданный при предварительном проходе Z-буфера и создаёт mip-цепочку (т.е. постепенно снижает их разрешение) глубин. Похоже также, что для удобства он ресэмплирует первый mip до размеров степени двойки:
Поскольку, как сказано выше, в Unreal используется обратный Z-буфер, пиксельный шейдер при снижении расширения применяет оператор min.
Рендеринг карт теней
Затем следует проход рендеринга вычисления карт теней (ShadowDepths).
В сцену я добавил «стационарный» (Stationary) направленный источник освещения, два «подвижных» (Movable) точечных источника, два стационарных точечных источника и «статичный» (Static) точечный источник. Все они отбрасывают тени:
В случае стационарных источников рендерер запекает тени статичных пропсов и вычисляет тени только для динамических (подвижных) пропсов. В случае подвижных источников он вычисляет тени для всего и каждый кадр (полностью динамично). Наконец, в случае статичных источников он запекает освещение+тени в карту освещения, чтобы они никогда не появлялись во время рендеринга.
Для направленного источника освещения я также добавил каскадные карты теней с тремя разделениями, чтобы посмотреть, как их будет обрабатывать Unreal. Unreal создаёт текстуру карты теней R16_TYPELESS (три тайла в ряд, по одному для каждого разделения), которая сбрасывается в каждом кадре (поэтому в движке отсутствуют рваные обновления разделений карт теней на основании расстояния). Затем на этапе прохода Atlas0 движок рендерит все непрозрачные пропсы в соответствующий тайл карты теней:
Как подтверждает приведённый выше список вызовов, только в Split0 есть геометрия для рендеринга, поэтому остальные тайлы пусты. Карта теней рендерится без использования пиксельного шейдера, что обеспечивает удвоенную скорость генерирования карты теней. Стоит заметить: похоже, что разделение на Stationary и Movable не сохраняется для направленного (Directional) источника освещения, рендерер рендерит в карту теней все пропсы (в том числе и статичные).
Следующим идёт проход Atlas1, который рендерит карты теней для всех стационарных источников освещения. В моей сцене помечен как подвижный (динамичный) только пропс «камень». Для стационарных источников и динамических пропсов Unreal использует пообъектные карты теней, хранящиеся в атласе текстур. Это значит, что он рендерит для каждого источника и для динамического пропса по одному тайлу карты теней:
И наконец, для каждого динамического (Movable) источника освещения Unreal создаёт традиционную кубическую карту теней (проходы CubemapXX), используя геометрический шейдер для выбора грани куба, на которой нужно выполнять рендеринг (чтобы снизить количество вызовов отрисовки). В ней он рендерит только динамические пропсы, используя для статичных/стационарных пропсов кэширование карт теней. Проход CopyCachedShadowMap копирует кэшированную кубическую карту теней, после чего поверх неё рендерятся глубины карты теней динамического пропса. Например, вот грань кэшированной кубической карты теней для динамического источника освещения (выходные данные CopyCachedShadowMap):
А вот она с отрендеренным динамическим пропсом «камень»:
Кубическая карта для статичной геометрии кэшируется и не создаётся в каждом кадре, потому что рендерер знает, что источник освещения на самом деле не движется (хотя он помечен как Movable). Если источник анимирован, то рендерер каждый кадр рендерит «кэшированную» кубическую карту со всей статичной/стационарной геометрией, после чего добавляет к карте теней динамические пропсы (эта картинка из другого теста, который я провёл специально, чтобы в этом убедиться):
Единственный статичный источник света вообще не появляется в списке вызовов отрисовки. Это подтверждает, что он не влияет на динамические пропсы и влияет через запечённую карту освещения только на статичные пропсы.
Дам вам совет: если в сцене есть стационарные источники освещения, то перед выполнением профилирования в Editor запеките всё освещение (по крайней мере, я не уверен, что делает запуск игры как «standalone»). Похоже, что в противном случае Unreal обрабатывает их как динамические источники, создавая кубические карты вместо того, чтобы использовать тени для каждого объекта.
Теперь мы продолжим исследование процесса рендеринга кадра в движке Unreal, рассмотрев генерирование сетки освещения, предварительного прохода g-буфера и освещения.
Назначение освещения
Затем рендерер переключается на шейдер compute shader, чтобы привязать освещение к 3D-сетке (проход ComputeLightGrid) способом, похожим на кластерное затенение (clustered shading). Эту сетку освещения можно использовать для быстрого определения источников освещения, влияющих на поверхность в зависимости от её положения.
Как следует из названия прохода, сетка освещения видимого пространства имеет размеры 29×16x32. Unreal использует тайл экранного пространства из 64×64 пикселей и 32 частей z-глубины. Это значит, что количество размерностей X-Y сетки освещения будет зависеть от разрешения экрана. Кроме того, тоже судя по названию, мы назначаем 9 источников освещения и два зонда отражения (reflection probes). Зонд отражения — это «сущность» с позицией и радиусом, считывающая среду вокруг себя и используемая для создания отражений на пропсах.
Согласно исходному коду compute shader (LightGridInjection.usf), разделение выполняется экспоненциально: это значит, что размер по z каждой ячейки сетки в видимом пространстве становится при удалении от камеры больше. Кроме того, в нём используется выровненный по осям координат параллелепипед каждой ячейки для выполнения пересечений ограничивающих объёмов источников освещения. Для хранения индексов источников освещения используется связанный список, который в проходе Compact преобразуется в сплошной массив.
Эта сетка освещения будет использоваться для в проходе расчёта объёмного тумана для добавления рассеяния света в тумане, в проходе отражений окружения и проходе рендеринга просвечиваемости.
Я заметил ещё один интересный факт: проход CullLights начинается с очистки Unordered Access Views для данных освещения, но в нём используется ClearUnorderedAccessViewUint только для двух из трёх UAV. Для оставшегося он использует compute shader, задающий значение вручную (первый Dispatch в приведённом выше списке). Очевидно, что исходный код в случае размеров буферов больше 1024 байт предпочитает использовать очистку с помощью compute shader вместо использования вызова «очистки» API.
Объёмный туман
Далее следуют вычисления объёмного тумана, в которых снова используются шейдеры compute shader.
В этом проходе вычисляются и сохраняются проницаемость и рассеяние света в текстуре объёма, что позволяет выполнить простой расчёт тумана с использованием только положения поверхности. Как и в выполненном ранее проходе назначения освещения объём «вписывается» в пирамиду видимости с помощью тайлов 8×8 и 128 градаций глубины. Градации глубины распределены экспоненциально. Они немного отодвигают ближнюю плоскость, чтобы избежать большого количества близких к камере мелких ячеек (это похоже на систему кластерного затенения Avalanche Studios).
Как и в технологии объёмного тумана (LINK) движка Assassin«s Creed IV и Frostbite, туман вычисляется за три прохода: первый (InitializeVolumeAttributes) вычисляет и сохраняет параметры тумана (рассеяние и поглощение) в текстуру объёма, а также сохраняет глобальное значение испускания во вторую текстуру объёма. Второй проход (LightScattering) вычисляет рассеяние и затухание света для каждой ячейки, сочетая затенённое направленное освещение, освещение неба и локальные источники освещения, назначенные текстуре объёма освещения в проходе ComputeLightGrid. Также он применяет временное сглаживание (antialiasing, AA) для выходных данных compute shader (Light Scattering, Extinction) с помощью буфера истории, который сам является 3D-текстурой, улучшая качество рассеянного освещения в ячейке сетки. Последний проход (FinalIntegration) просто выполняет raymarching 3D-текстуры по оси Z и накапливает рассеянное освещение и проницаемость, сохраняя результат в процессе в соответствующую ячейку сетки.
Готовый буфер объёма с рассеянием освещения выглядит следующим образом. В нём можно увидеть столбы света из-за направленных источников освещения и локальных источников, рассеивающихся в тумане.
Предварительный проход G-буфера
Далее следует собственная версия предварительного прохода G-буфера движка Unreal, обычно используемого в архитектурах с отложенным рендерингом. Этот проход нужен для того, чтобы кэшировать свойства материалов во множество целевых рендеров с целью уменьшения перерисовки во время затратных вычислений освещения и затенения.
В этом проходе обычно рендерятся все непрозрачные пропсы (статичные, подвижные и т.д.). В случае Unreal в нём также в первую очередь рендерится небо! В большинстве случаев это плохое решение, потому что небо позже перерисовывается другими, более близкими к камере пропсами, то есть работа оказывается лишней. Однако в этом случае это вполне нормально, потому что ранее выполненный рендерером предварительный проход Z-буфера устраняет перерисовку неба (и большую часть перерисовки в целом, по крайней мере, для непрозрачных пропсов).
Вот список целевых рендеров, в которые выполняет запись предварительный проход g-буфера.
Буфер глубин используется только для z-теста, он уже был заполнен в предварительном проходе z-буфера, и теперь рендерер ничего в него не записывает. Однако рендерер выполняет запись в стенсил-буфер, чтобы пометить те пиксели, которые принадлежат рендерящейся непрозрачной геометрии.
Содержимое g-буфера может зависеть от настроек рендера. Например, если рендерер должен записывать в g-буфер скорость, то он займёт GBufferD и данные будут перемещены. Для нашей сцены и пути рендеринга g-буфер имеет следующую схему.
SceneColorDeferred: содержит непрямое освещение | GBufferA: нормали пространства мира, хранящиеся как RGB10A2_UNORM. Похоже, что какое-либо кодирование не используется |
Distortion: различные свойства материалов (metalness, roughness, интенсивность отражения и модель затенения) | GBufferC: Albedo в RGB, AO в альфа-канале |
GBufferE: собственные данные, зависящие от модели затенения (например, подповерхностный цвет или вектор касательной). | GBufferD: запечённые показатели затенения |
Stencil, чтобы помечать непрозрачные пропсы |
Стоит заметить, что все непрозрачные пропсы в сцене (кроме подвижного камня и неба) сэмплируют информацию об освещении из трёх атласов с mip-уровнями, которые кэшируют облучаемость, тени и нормали поверхностей:
И снова симуляция частиц
Симуляция частиц была первым действием, выполняемым в кадре, это был проход, записывавший позиции в мире и скорости спрайтов частиц. Она происходит в кадре так рано, что рендерер не имеет доступа к буферам глубин и нормалей для расчёта коллизий в видеопроцессоре, поэтому настала пора вернуться и заново выполнить симуляцию для тех частиц, которые того требуют.
Рендеринг скоростей
По умолчанию Unreal записывает скорость движущихся пропсов в отдельный буфер формата R16G16. В дальнейшем скорость будет использоваться для размытия в движении (motion blur) и для всех эффектов, требующих повторного проецирования (например, для временного сглаживания). В нашей сцене как подвижный объект помечен только камень, поэтому он единственный рендерится в буфер скоростей.
Ambient Occlusion
Получив всю информацию о материалах, рендерер готовится перейти к этапу освещения. Но прежде ему нужно сначала рассчитать ambient occlusion в экранном пространстве.
В нашей сцене нет отложенных декалей, но если бы были, то я предполагаю, что пустые проходы DeferredDecals изменили бы свойства некоторых материалов в g-буфере. Ambient occlusion в экранном пространстве рассчитывается за два прохода — в четверти разрешения и на полный экран. Проход AmbientOcclusionPS 908×488 вычисляет AO с помощью буфера нормалей размером с четверть разрешения, созданного в проходе AmbientOcclusionSetup, Hi-Z-буфера, созданного рендерером ранее и текстуры случайных векторов из которой будут сэмплироваться буферы глубин/нормалей. Кроме того, при сэмплировании текстуры из случайных векторов шейдер добавляет к каждому кадру небольшие искажения, чтобы эмулировать «суперсэмплинг» и постепенно улучшать качество AO.
Затем проход AmbientOcclusionPS 1815×976 рассчитывает полный экран, с более высоким разрешением, с AO и сочетает их с буфером четверти разрешения. Результаты получаются достаточно хорошими даже без необходимости прохода размытия.
И наконец, буфер AO с полным разрешением применяется к буферу SceneColourDeferred (являющемуся частью вышеупомянутого G-буфера), который пока содержит непрямое (окружающее) освещение сцены.
Освещение
Прежде чем начать обсуждение освещения, стоит немного отойти в сторону и вкратце рассказать о том, как Unreal освещает просвечивающие объекты, потому что вскоре мы будем часто встречаться с этой системой. Подход Unreal к освещению просвечивающих поверхностей заключается во внесении освещения в две текстуры объёма 64×64x64 формата RGBA16_FLOAT. Две текстуры содержат освещение (затенённое+ослабленное) в виде сферических гармоник, которые достигают каждую ячейку объёма (текстура TranslucentVolumeX) и аппроксимируют направление движения света от каждого источника освещения (текстура TranslucentVolumeDirX). Рендерер хранит 2 набора таких текстур, один для близких к камере пропсов, требующих освещение с повышенным разрешением, второй — для более далёких объектов, которым освещение высокого разрешения не так важно. В нём используется похожий подход, то есть, запись в каскадную карту теней, в которой ближе к камере находится больше текселов, чем в отдалении от неё.
Вот пример текстур объёма для близкого к камере просвечивающего освещения только с (затенённым) направленным источником.
Эти объёмы просвечивающего освещения не влияют на непрозрачные пропсы, они будут использоваться позже для освещения просвечивающих пропсов и эффектов (частиц и т.д.). Однако они будут заполнены в проходе освещения.
Вернёмся к прямому освещению непрозрачных пропсов — теперь рендерер может вычислить и применить к сцене освещение. При большом количестве источников освещения этот список вызовов отрисовки может быть довольно длинным. Я развернул только самые важные части.
Источники освещения обрабатываются двумя группами, NonShadowedLights и ShadowedLights. В группу NonShadowedLights включаются простые источники освещения, например, используемые для эффектов частиц, и не отбрасывающие тени обычные источники в сцене. Разница между ними в том, что обычные источники освещения сцены используют при рендеринге тест границ глубин, чтобы избежать освещения пикселей за пределами приблизительного объёма освещения. Это реализуется с помощью специализированных расширений драйверов. Освещение накапливается в упомянутом выше SceneColourDeferred. Ещё одна разница заключается в том, что простые источники освещения вообще не выполняют запись в объёмы просвечивающего освещения (хотя, похоже, такая возможность предусмотрена в коде рендерера, поэтому, возможно, где-то можно включить этот параметр).
Интересно, что в случае, когда количество не отбрасывающих тень (и нестатичных) видимых источников освещения в сцене превышает 80, то рендерер переключается из режима классического отложенного затенения в режим тайлового отложенного освещения.
В таком случае рендерер использует для вычисления освещения compute shader (только для таких источников освещения), передавая данные об освещении вниз к шейдеру через постоянные буферы (Благодарю wand de за то, что он указал мне на это.). Кроме того, похоже, что переключение на тайловое отложенное освещение и использование шейдера compute shader для применения всех источников освещения за один проход влияет только на прямое освещение. Проход InjectNonShadowedTranscluscentLighting по-прежнему добавляет все источники освещения отдельно к объёмам просвечивающего освещения (для каждого создаётся отдельный вызов отрисовки):
Проход ShadowedLights обрабатывает все отбрасывающие тень источники освещения, как стационарные, таки подвижные. По умолчанию Unreal обрабатывает каждый отбрасывающий тень источник освещения за три этапа:
Сначала он рассчитывает тени экранного пространства (ShadowProjectionOnOpaque), затем добавляет влияние освещения в объём просвечивающего освещения (InjectTranslucentVolume) и наконец вычисляет освещение в сцене (StandardDeferredLighting).
Как сказано выше, для этой сцены случае направленного освещения информацию о тенях содержит только Split0. Результат расчётов теней записывается в буфер RGBA8 размером с экран.
Следующий этап (InjectTranslucentVolume) записывает влияние направленного освещения для обоих каскадов в описанный выше объём просвечивающего освещения (два вызова на проход InjectTranslucentVolume). Наконец, проход StandardDeferredLighting вычисляет и записывает освещение по маске буфера теней экранного пространства в буфер SceneColorDeferred.
Похоже, что локальные источники используют тот же порядок для проецирования теней в буфер экранного пространства, добавляя освещение в объёмы просвечивающего освещения и вычисляя освещение с записью в буфер SceneColorDeferred.
Оба типа обрабатываются примерно одинаково, разница между подвижными/стационарными локальными источниками заключается в том, что подвижыне добавляют освещение с тенями в объём просвечивающего освещения, и, разумеется, в том, что для теней подвижные источники с тенями используют не пообъектный атлас, а кубическую карту.
Все источники освещения используют один целевой рендер буфера теней экранного пространства, очищая соответствующие части для теней каждого источника (полагаю, что это делается для экономии памяти).
После завершения прохода освещения SceneColorDeferred содержит всё накопленное прямое освещение сцены.
Стоит заметить, что несмотря на то, что рендерер создал структуру сгруппированных/кластеризированных данных заранее (проход назначения освещения), он вообще не используется на этапе прохода освещения непрозрачной геометрии, используя вместо неё традиционное отложенное затенение с отдельным рендерингом каждого источника освещения.
В качестве последнего этапа выполняется фильтрация объёмов просвечивающего освещения (для обоих каскадов) с целью подавления искажений при освещении просвечивающих пропсов/эффектов.
Освещение в пространстве изображения
Затем вычисляются полноэкранные отражения в экранном пространстве (формат целевого рендера — RGBA16_FLOAT).
Шейдер также использует вычисленный к началу кадра Hi-Z-буфер для ускорения расчёта пересечений выбором mip-уровня Hi-Z-буфера при raymarching на основании шероховатости (roughness) поверхности (т.е. делая трассировку лучей для шероховатых поверхностей грубее, потому что детали в их отражениях невидимы). Наконец, в каждом кадре к начальному положению луча добавляются колебания, что в сочетании с временным сглаживанием увеличивает качество отображения отражений.
Шейдер использует целевой рендер предыдущего кадра для сэмплирования цвета при обнаружении столкновения при raymarching, это можно видеть по объёмному туману в отражениях, а также по отражённым прозрачным пропсам (статуям). Также справа под креслом можно заметить следы эффекта частиц. Поскольку у нас нет правильной глубины для прозрачных поверхностей (для вычисления правильных столкновений), отражения обычно растянуты, но во многих случаях эффект выглядит достаточно убедительно.
С помощью compute shader отражения в экранном пространстве применяются к основному целевому рендеру (проход ReflectionEnvironment). Этот шейдер также применяет отражения окружений, захваченные двумя зондами отражения в сцене. Отражения для каждого зонда хранятся в кубических картах с mip-уровнями:
Зонды отражений окружения генерируются при запуске игры и захватывают только статичную/стационарную геометрию (заметьте, что на приведённых выше кубических картах отсутствует анимированный пропс «камень»).
Наша сцена с применёнными отражениями в экранном пространстве и отражениями окружения теперь выглядит вот так.
Туман и атмосферные эффекты
Далее следуют туман и атмосферные эффекты, если они тоже включены в нашу сцене.
Сначала создаётся маска перекрытия столбов света в четверть разрешения, которая определяет, какие из пикселей получат столбы освещения (которые применяются только к направленному освещению в сцене).
Затем рендерер начинает улучшать качество