- PVSM.RU - https://www.pvsm.ru -

Как рендерит кадр движок Unreal Engine

Как рендерит кадр движок Unreal Engine - 1

Однажды я искал исходный код Unreal и, вдохновлённый отличным анализом того, как популярные игры рендерят кадр [1] (перевод [2] статьи на Хабре), я решил тоже сделать с ним что-то подобное, чтобы изучить, как движок рендерит кадр (с параметрами и настройками сцены по умолчанию).

Поскольку у нас есть доступ к исходному коду, мы можем изучить исходники рендерера, чтобы понять, что он делает, однако это довольно объёмная часть движка, а пути рендеринга сильно зависят от контекста, поэтому проще будет исследовать чистый низкоуровневный API (иногда заглядывая в код, чтобы заполнить пробелы).

Я собрал простую сцену с несколькими статичными и динамичными пропсами, несколькими источниками освещения, объёмным туманом, прозрачными объектами и эффектом частиц, чтобы задействовать достаточно широкий диапазон материалов и способов рендеринга.

Итак, я пропустил Editor через RenderDoc [3] и включил захват. Возможно, это не слишком похоже на то, как будет выглядеть кадр реальной игры, но даст нам приблизительное представление о том, как Unreal выполняет рендеринг стандартного кадра (я не менял никакие настройки и выбрал максимальное качество для PC):

image

Примечание: анализ основан на захвате информации видеопроцессора и исходном коде рендерера (версии 4.17.1). До этого проекта у меня не было особого опыта работы с Unreal. Если я что-то упустил или в чём-то ошибся, то сообщите мне об этом в комментариях.

К счастью, список вызовов отрисовки Unreal понятен и хорошо аннотирован, и это упростит нашу работу. Список может выглядеть иначе, если в сцене не будет некоторых сущностей/материалов, или если выбрать более низкое качество. Например, если выполнять рендеринг без частиц, то проходы ParticleSimulation будут отсутствовать.

Проход рендеринга SlateUI содержит все вызовы API, выполняемые Unreal Editor для рендеринга своего UI, поэтому мы пропустим его и сосредоточимся на всех проходах в разделе Scene.

Симуляция частиц

Кадр начинается с прохода ParticleSimulation. Он вычисляет в видеопроцессоре движение частиц и другие свойства для каждого эмиттера частиц в сцене для двух целевых рендеров: RGBA32_Float (сюда записываются позиции) и RGBA16_Float (скорости) (и пары связанных с временем/жизнью данных). Вот, например, выходные данные для целевого рендера RGBA32_Float, где каждый пиксель соответствует позиции спрайта в мире:

image

Похоже, что в этом случае добавленный мной в сцену эффект частиц имеет два эмиттера, требующих симуляции в видеопроцессоре без расчёта коллизий, поэтому соответствующие проходы рендеринга могут выполняться на ранних этапах создания кадра.

Предварительный проход Z-буфера

Следующим идёт проход рендеринга PrePass, который является предварительным проходом z-буфера. Он рендерит все непрозрачные полигональные сетки (меши) в буфер глубин R24G8:

image

Стоит заметить, что в Unreal при рендеринге в буфер глубин используется обратный Z-буфер (reverse-Z) [4]. Это значит, что ближней плоскости присваивается значение 1, а дальней — 0. Это обеспечивает бОльшую точность вдоль диапазона глубин и снижает количество z-конфликтов для далёких сеток. Название прохода рендеринга подразумевает, что проход запускается буфером «DBuffer». Так называется буфер декалей, который Unreal Engine использует для рендеринга отложенных декалей. Для него требуется глубина сцены, поэтому активируется предварительный проход Z-буфера. Но, как мы увидим ниже, Z-буфер используется и в других контекстах, например, для вычисления перекрытий (occlusion) и отражений в экранном пространстве.

Некоторые проходы рендера в списке оказываются пустыми. например ResolveSceneDepth, который, как я полагаю, необходим для платформ, действительно требующих «разрешения (resolving) по глубине» целевого рендера перед использованием его в качестве текстуры (на PC он не нужен), а также ShadowFrustumQueries, который выглядит как маркер-болванка, потому что настоящие тесты перекрытия для теней выполняются в следующем проходе рендера.

Проверка перекрытий

BeginOcclusionTests обрабатывает все проверки перекрытий в кадре. По умолчанию для проверки перекрытий Unreal использует аппаратные запросы перекрытий (hardware occlusion queries) [5]. Если вкратце, то она выполняется за три этапа:

  1. Мы рендерим всё, что мы воспринимаем как перекрывающий объект (например, большую непрозрачную сетку), в буфер глубин
  2. Создаём запрос перекрытий, передаём его и рендерим пропс, для которого хотим определить перекрытие. Это реализуется с помощью z-теста и буфера глубин, созданного на этапе 1. Запрос возвращает количество пикселей, прошедших z-test, то есть если значение равно нулю, значит, что пропс целиком находится за непрозрачной сеткой. Поскольку рендеринг всей сетки пропса для перекрытия может быть затратным, мы используем в качестве замены граничный параллелепипед (bounding box) этого пропса. Если он невидим, то и пропс совершенно точно тоже невидим.
  3. Считываем результаты запроса обратно в видеопроцессор и на основании количества отрендеренных пикселей мы можем выбрать, отправлять ли пропс на рендеринг, или нет (даже если видимо небольшое количество пикселей, мы можем решить, что пропс рендерить не стоит).

В Unreal используются разные типы запросов перекрытий, зависящие от контекста:

image

Аппаратные запросы перекрытий имеют свои недостатки — у них есть дробность вызовов отрисовки. Это значит, что они требуют от рендерера выполнять по одному вызову отрисовки на сетку (или группы сеток), для которой нужно определить перекрытие. Они могут значительно увеличить количество вызовов отрисовки на кадр, требуют считывания обратно в ЦП, что добавляет точки синхронизации между ЦП и видеопроцессором, и заставляют ЦП ждать, пока видеопроцессор закончит обработку запроса. Они не очень походят для клонируемой геометрии, но пока мы не будем обращать на это внимания.

Unreal решает проблему точки синхронизации ЦП и видеопроцессора как и любой другой движок, использующий запросы — отложенным для нескольких кадров считыванием данных запроса. Такой подход работает, однако он может добавить проблему «выпрыгивающих» на экран при быстром движении камеры пропсов (на практике это может и не быть серьёзной проблемой, потому что выполнение отсечения перекрытий с помощью граничных параллелепипедов является консервативным, то есть сетка со всей вероятностью будет помечена как видимая даже ещё до того, как она действительно станет видимой). Однако сохраняется проблема излишних вызовов отрисовки, и её решить не так просто. Unreal пытается снизить её влияние, группируя запросы следующим образом: сначала он рендерит всю непрозрачную геометрию в z-буфер (описанный выше предварительный проход Z-буфера). Затем он передаёт отдельные запросы для каждого пропса, который нужно проверить на перекрытие. В конце кадра он получает данные запросов из предыдущего (или ещё более раннего) кадра и разрешает задачу видимости пропса. Если он видим, то движок помечает его для рендеринга в следующем кадре. С другой стороны, если он невидим, то движок добавляет его в «сгруппированный» запрос, который объединяет в группу граничных параллелепипедов пропсов (максимум восемь объектов) и использует его для определения видимости в течение следующего кадра. Если в следующем кадре группа становится видимой (как целое), движок разбивает её и снова передаёт отдельные запросы. Если камера и пропсы статичны (или медленно движутся), то такой подход снижает количество необходимых запросов перекрытий в восемь раз. Единственной странностью, которую я заметил во время группировки (батчинга) перекрываемых пропсов, стало то, что она кажется случайной и не зависит от пространственной близости пропсов друг к другу.

Этот процесс соответствует маркерам IndividualQueries и GroupedQueries в приведённом выше списке проходов рендера. Часть GroupedQueries пуста, потому что движку не удалось создать создать запрос во время предыдущего кадра.

Для завершения прохода перекрытий ShadowFrustumQueries передаёт аппаратные запросы перекрытий граничных сеток локальных (точечных или направленных) (отбрасывающих и не отбрасывающих тень, вопреки названию прохода). Если они перекрыты, то нет смысла выполнять вычисления для них освещения/теней. Стоит заметить, что несмотря на наличие в сцене четырёх локальных источников света, отбрасывающих тень (для которых необходимо каждый кадр вычислять карту теней), количество вызовов отрисовки в ShadowFrustumQueries равно трём. Подозреваю, так получилось, потому что ограничивающий объём одного из источников пересекает ближнюю плоскость камеры, поэтому Unreal считает, что он всё равно будет видимым. Также стоит упомянуть, что для динамического освещения, у которого вычисляется кубическая карта теней, мы передаём для проверок перекрытий сферу,

image

а для статичного динамического освещения, которое Unreal рассчитывает для теней каждого объекта (подробнее об этом ниже), передаётся пирамида:

image

Наконец, я предполагаю, что PlanarReflectionQueries относится к тестам перекрытий, выполняемым при вычислении плоскостных отражений (создаваемых перемещением камеры за/перед плоскостью отражений и перерисовкой сеток).

Генерирование Hi-Z-буфера

Затем Unreal создаёт Hi-Z-буфер (проходы HZB SetupMipXX), хранимый как 16-битное число с плавающей запятой (формат текстуры R16_Float). Он получает в качестве входных данных буфер глубин, созданный при предварительном проходе Z-буфера и создаёт mip-цепочку (т.е. постепенно снижает их разрешение) глубин. Похоже также, что для удобства он ресэмплирует первый mip до размеров степени двойки:

image
image
image
image

Поскольку, как сказано выше, в Unreal используется обратный Z-буфер, пиксельный шейдер при снижении расширения применяет оператор min.

Рендеринг карт теней

Затем следует проход рендеринга вычисления карт теней (ShadowDepths).

image

В сцену я добавил «стационарный» (Stationary) направленный источник освещения, два «подвижных» (Movable) точечных источника, два стационарных точечных источника и «статичный» (Static) точечный источник. Все они отбрасывают тени:

image

В случае стационарных источников рендерер запекает тени статичных пропсов и вычисляет тени только для динамических (подвижных) пропсов. В случае подвижных источников он вычисляет тени для всего и каждый кадр (полностью динамично). Наконец, в случае статичных источников он запекает освещение+тени в карту освещения, чтобы они никогда не появлялись во время рендеринга.

Для направленного источника освещения я также добавил каскадные карты теней с тремя разделениями, чтобы посмотреть, как их будет обрабатывать Unreal. Unreal создаёт текстуру карты теней R16_TYPELESS (три тайла в ряд, по одному для каждого разделения), которая сбрасывается в каждом кадре (поэтому в движке отсутствуют рваные обновления разделений карт теней на основании расстояния). Затем на этапе прохода Atlas0 движок рендерит все непрозрачные пропсы в соответствующий тайл карты теней:

image

Как подтверждает приведённый выше список вызовов, только в Split0 есть геометрия для рендеринга, поэтому остальные тайлы пусты. Карта теней рендерится без использования пиксельного шейдера, что обеспечивает удвоенную скорость генерирования карты теней. Стоит заметить: похоже, что разделение на Stationary и Movable не сохраняется для направленного (Directional) источника освещения, рендерер рендерит в карту теней все пропсы (в том числе и статичные).

Следующим идёт проход Atlas1, который рендерит карты теней для всех стационарных источников освещения. В моей сцене помечен как подвижный (динамичный) только пропс «камень». Для стационарных источников и динамических пропсов Unreal использует пообъектные карты теней, хранящиеся в атласе текстур. Это значит, что он рендерит для каждого источника и для динамического пропса по одному тайлу карты теней:

image

И наконец, для каждого динамического (Movable) источника освещения Unreal создаёт традиционную кубическую карту теней (проходы CubemapXX), используя геометрический шейдер для выбора грани куба, на которой нужно выполнять рендеринг (чтобы снизить количество вызовов отрисовки). В ней он рендерит только динамические пропсы, используя для статичных/стационарных пропсов кэширование карт теней. Проход CopyCachedShadowMap копирует кэшированную кубическую карту теней, после чего поверх неё рендерятся глубины карты теней динамического пропса. Например, вот грань кэшированной кубической карты теней для динамического источника освещения (выходные данные CopyCachedShadowMap):

image

А вот она с отрендеренным динамическим пропсом «камень»:

image

Кубическая карта для статичной геометрии кэшируется и не создаётся в каждом кадре, потому что рендерер знает, что источник освещения на самом деле не движется (хотя он помечен как Movable). Если источник анимирован, то рендерер каждый кадр рендерит «кэшированную» кубическую карту со всей статичной/стационарной геометрией, после чего добавляет к карте теней динамические пропсы (эта картинка из другого теста, который я провёл специально, чтобы в этом убедиться):

image

Единственный статичный источник света вообще не появляется в списке вызовов отрисовки. Это подтверждает, что он не влияет на динамические пропсы и влияет через запечённую карту освещения только на статичные пропсы.

Дам вам совет: если в сцене есть стационарные источники освещения, то перед выполнением профилирования в Editor запеките всё освещение (по крайней мере, я не уверен, что делает запуск игры как «standalone»). Похоже, что в противном случае Unreal обрабатывает их как динамические источники, создавая кубические карты вместо того, чтобы использовать тени для каждого объекта.

Теперь мы продолжим исследование процесса рендеринга кадра в движке Unreal, рассмотрев генерирование сетки освещения, предварительного прохода g-буфера и освещения.

Назначение освещения

Затем рендерер переключается на шейдер compute shader, чтобы привязать освещение к 3D-сетке (проход ComputeLightGrid) способом, похожим на кластерное затенение (clustered shading). Эту сетку освещения можно использовать для быстрого определения источников освещения, влияющих на поверхность в зависимости от её положения.

image

Как следует из названия прохода, сетка освещения видимого пространства имеет размеры 29x16x32. 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.

image

В этом проходе вычисляются и сохраняются проницаемость и рассеяние света в текстуре объёма, что позволяет выполнить простой расчёт тумана с использованием только положения поверхности. Как и в выполненном ранее проходе назначения освещения объём «вписывается» в пирамиду видимости с помощью тайлов 8×8 и 128 градаций глубины. Градации глубины распределены экспоненциально. Они немного отодвигают ближнюю плоскость, чтобы избежать большого количества близких к камере мелких ячеек (это похоже на систему кластерного затенения [6] Avalanche Studios).

Как и в технологии объёмного тумана (LINK) движка Assassin’s Creed IV [7] и Frostbite [8], туман вычисляется за три прохода: первый (InitializeVolumeAttributes) вычисляет и сохраняет параметры тумана (рассеяние и поглощение) в текстуру объёма, а также сохраняет глобальное значение испускания во вторую текстуру объёма. Второй проход (LightScattering) вычисляет рассеяние и затухание света для каждой ячейки, сочетая затенённое направленное освещение, освещение неба и локальные источники освещения, назначенные текстуре объёма освещения в проходе ComputeLightGrid. Также он применяет временное сглаживание (antialiasing, AA) для выходных данных compute shader (Light Scattering, Extinction) с помощью буфера истории, который сам является 3D-текстурой, улучшая качество рассеянного освещения в ячейке сетки. Последний проход (FinalIntegration) просто выполняет raymarching 3D-текстуры по оси Z и накапливает рассеянное освещение и проницаемость, сохраняя результат в процессе в соответствующую ячейку сетки.

Готовый буфер объёма с рассеянием освещения выглядит следующим образом. В нём можно увидеть столбы света из-за направленных источников освещения и локальных источников, рассеивающихся в тумане.

image

Предварительный проход G-буфера

Далее следует собственная версия предварительного прохода G-буфера движка Unreal, обычно используемого в архитектурах с отложенным рендерингом. Этот проход нужен для того, чтобы кэшировать свойства материалов во множество целевых рендеров с целью уменьшения перерисовки во время затратных вычислений освещения и затенения.

image

В этом проходе обычно рендерятся все непрозрачные пропсы (статичные, подвижные и т.д.). В случае Unreal в нём также в первую очередь рендерится небо! В большинстве случаев это плохое решение, потому что небо позже перерисовывается другими, более близкими к камере пропсами, то есть работа оказывается лишней. Однако в этом случае это вполне нормально, потому что ранее выполненный рендерером предварительный проход Z-буфера устраняет перерисовку неба (и большую часть перерисовки в целом, по крайней мере, для непрозрачных пропсов).

Вот список целевых рендеров, в которые выполняет запись предварительный проход g-буфера.

image

Буфер глубин используется только для z-теста, он уже был заполнен в предварительном проходе z-буфера, и теперь рендерер ничего в него не записывает. Однако рендерер выполняет запись в стенсил-буфер, чтобы пометить те пиксели, которые принадлежат рендерящейся непрозрачной геометрии.

Содержимое g-буфера может зависеть от настроек рендера. Например, если рендерер должен записывать в g-буфер скорость, то он займёт GBufferD и данные будут перемещены. Для нашей сцены и пути рендеринга g-буфер имеет следующую схему.

image image
SceneColorDeferred: содержит непрямое освещение GBufferA: нормали пространства мира, хранящиеся как RGB10A2_UNORM. Похоже, что какое-либо кодирование не используется
image image
Distortion: различные свойства материалов (metalness, roughness, интенсивность отражения и модель затенения) GBufferC: Albedo в RGB, AO в альфа-канале
image image
GBufferE: собственные данные, зависящие от модели затенения (например, подповерхностный цвет или вектор касательной). GBufferD: запечённые показатели затенения
image
Stencil, чтобы помечать непрозрачные пропсы

Стоит заметить, что все непрозрачные пропсы в сцене (кроме подвижного камня и неба) сэмплируют информацию об освещении из трёх атласов с mip-уровнями, которые кэшируют облучаемость, тени и нормали поверхностей:

image
image
image

И снова симуляция частиц

Симуляция частиц была первым действием, выполняемым в кадре, это был проход, записывавший позиции в мире и скорости спрайтов частиц. Она происходит в кадре так рано, что рендерер не имеет доступа к буферам глубин и нормалей для расчёта коллизий в видеопроцессоре, поэтому настала пора вернуться и заново выполнить симуляцию для тех частиц, которые того требуют.

image

Рендеринг скоростей

По умолчанию Unreal записывает скорость движущихся пропсов в отдельный буфер формата R16G16. В дальнейшем скорость будет использоваться для размытия в движении (motion blur) и для всех эффектов, требующих повторного проецирования (например, для временного сглаживания). В нашей сцене как подвижный объект помечен только камень, поэтому он единственный рендерится в буфер скоростей.

image

Ambient Occlusion

Получив всю информацию о материалах, рендерер готовится перейти к этапу освещения. Но прежде ему нужно сначала рассчитать ambient occlusion в экранном пространстве.

image

В нашей сцене нет отложенных декалей, но если бы были, то я предполагаю, что пустые проходы DeferredDecals изменили бы свойства некоторых материалов в g-буфере. Ambient occlusion в экранном пространстве рассчитывается за два прохода — в четверти разрешения и на полный экран. Проход AmbientOcclusionPS 908×488 вычисляет AO с помощью буфера нормалей размером с четверть разрешения, созданного в проходе AmbientOcclusionSetup, Hi-Z-буфера, созданного рендерером ранее и текстуры случайных векторов из которой будут сэмплироваться буферы глубин/нормалей. Кроме того, при сэмплировании текстуры из случайных векторов шейдер добавляет к каждому кадру небольшие искажения, чтобы эмулировать «суперсэмплинг» и постепенно улучшать качество AO.

image

Затем проход AmbientOcclusionPS 1815×976 рассчитывает полный экран, с более высоким разрешением, с AO и сочетает их с буфером четверти разрешения. Результаты получаются достаточно хорошими даже без необходимости прохода размытия.

image

И наконец, буфер AO с полным разрешением применяется к буферу SceneColourDeferred (являющемуся частью вышеупомянутого G-буфера), который пока содержит непрямое (окружающее) освещение сцены.

image

Освещение

Прежде чем начать обсуждение освещения, стоит немного отойти в сторону и вкратце рассказать о том, как Unreal освещает просвечивающие объекты, потому что вскоре мы будем часто встречаться с этой системой. Подход Unreal к освещению просвечивающих поверхностей заключается во внесении освещения в две текстуры объёма 64x64x64 формата RGBA16_FLOAT. Две текстуры содержат освещение (затенённое+ослабленное) в виде сферических гармоник, которые достигают каждую ячейку объёма (текстура TranslucentVolumeX) и аппроксимируют направление движения света от каждого источника освещения (текстура TranslucentVolumeDirX). Рендерер хранит 2 набора таких текстур, один для близких к камере пропсов, требующих освещение с повышенным разрешением, второй — для более далёких объектов, которым освещение высокого разрешения не так важно. В нём используется похожий подход, то есть, запись в каскадную карту теней, в которой ближе к камере находится больше текселов, чем в отдалении от неё.

Вот пример текстур объёма для близкого к камере просвечивающего освещения только с (затенённым) направленным источником.

image
image

Эти объёмы просвечивающего освещения не влияют на непрозрачные пропсы, они будут использоваться позже для освещения просвечивающих пропсов и эффектов (частиц и т.д.). Однако они будут заполнены в проходе освещения.

Вернёмся к прямому освещению непрозрачных пропсов — теперь рендерер может вычислить и применить к сцене освещение. При большом количестве источников освещения этот список вызовов отрисовки может быть довольно длинным. Я развернул только самые важные части.

image

Источники освещения обрабатываются двумя группами, NonShadowedLights и ShadowedLights. В группу NonShadowedLights включаются простые источники освещения, например, используемые для эффектов частиц, и не отбрасывающие тени обычные источники в сцене. Разница между ними в том, что обычные источники освещения сцены используют при рендеринге тест границ глубин, чтобы избежать освещения пикселей за пределами приблизительного объёма освещения. Это реализуется с помощью специализированных расширений драйверов [9]. Освещение накапливается в упомянутом выше SceneColourDeferred. Ещё одна разница заключается в том, что простые источники освещения вообще не выполняют запись в объёмы просвечивающего освещения (хотя, похоже, такая возможность предусмотрена в коде рендерера, поэтому, возможно, где-то можно включить этот параметр).

Интересно, что в случае, когда количество не отбрасывающих тень (и нестатичных) видимых источников освещения в сцене превышает 80, то рендерер переключается из режима классического отложенного затенения в режим тайлового отложенного освещения.

Как рендерит кадр движок Unreal Engine - 43

В таком случае рендерер использует для вычисления освещения compute shader (только для таких источников освещения), передавая данные об освещении вниз к шейдеру через постоянные буферы (Благодарю wand de [10] за то, что он указал мне на это.). Кроме того, похоже, что переключение на тайловое отложенное освещение и использование шейдера compute shader для применения всех источников освещения за один проход влияет только на прямое освещение. Проход InjectNonShadowedTranscluscentLighting по-прежнему добавляет все источники освещения отдельно к объёмам просвечивающего освещения (для каждого создаётся отдельный вызов отрисовки):

image

Проход ShadowedLights обрабатывает все отбрасывающие тень источники освещения, как стационарные, таки подвижные. По умолчанию Unreal обрабатывает каждый отбрасывающий тень источник освещения за три этапа:

image

Сначала он рассчитывает тени экранного пространства (ShadowProjectionOnOpaque), затем добавляет влияние освещения в объём просвечивающего освещения (InjectTranslucentVolume) и наконец вычисляет освещение в сцене (StandardDeferredLighting).

Как сказано выше, для этой сцены случае направленного освещения информацию о тенях содержит только Split0. Результат расчётов теней записывается в буфер RGBA8 размером с экран.

image

Следующий этап (InjectTranslucentVolume) записывает влияние направленного освещения для обоих каскадов в описанный выше объём просвечивающего освещения (два вызова на проход InjectTranslucentVolume). Наконец, проход StandardDeferredLighting вычисляет и записывает освещение по маске буфера теней экранного пространства в буфер SceneColorDeferred.

Похоже, что локальные источники используют тот же порядок для проецирования теней в буфер экранного пространства, добавляя освещение в объёмы просвечивающего освещения и вычисляя освещение с записью в буфер SceneColorDeferred.

image

Оба типа обрабатываются примерно одинаково, разница между подвижными/стационарными локальными источниками заключается в том, что подвижыне добавляют освещение с тенями в объём просвечивающего освещения, и, разумеется, в том, что для теней подвижные источники с тенями используют не пообъектный атлас, а кубическую карту.

Все источники освещения используют один целевой рендер буфера теней экранного пространства, очищая соответствующие части для теней каждого источника (полагаю, что это делается для экономии памяти).

После завершения прохода освещения SceneColorDeferred содержит всё накопленное прямое освещение сцены.

image

Стоит заметить, что несмотря на то, что рендерер создал структуру сгруппированных/кластеризированных данных заранее (проход назначения освещения), он вообще не используется на этапе прохода освещения непрозрачной геометрии, используя вместо неё традиционное отложенное затенение с отдельным рендерингом каждого источника освещения.

В качестве последнего этапа выполняется фильтрация объёмов просвечивающего освещения (для обоих каскадов) с целью подавления искажений при освещении просвечивающих пропсов/эффектов.

image

Освещение в пространстве изображения

Затем вычисляются полноэкранные отражения в экранном пространстве (формат целевого рендера — RGBA16_FLOAT).

image

Шейдер также использует вычисленный к началу кадра Hi-Z-буфер для ускорения расчёта пересечений выбором mip-уровня Hi-Z-буфера при raymarching на основании шероховатости (roughness) поверхности (т.е. делая трассировку лучей для шероховатых поверхностей грубее, потому что детали в их отражениях невидимы). Наконец, в каждом кадре к начальному положению луча добавляются колебания, что в сочетании с временным сглаживанием увеличивает качество отображения отражений.

image

Шейдер использует целевой рендер предыдущего кадра для сэмплирования цвета при обнаружении столкновения при raymarching, это можно видеть по объёмному туману в отражениях, а также по отражённым прозрачным пропсам (статуям). Также справа под креслом можно заметить следы эффекта частиц. Поскольку у нас нет правильной глубины для прозрачных поверхностей (для вычисления правильных столкновений), отражения обычно растянуты, но во многих случаях эффект выглядит достаточно убедительно.

С помощью compute shader отражения в экранном пространстве применяются к основному целевому рендеру (проход ReflectionEnvironment). Этот шейдер также применяет отражения окружений, захваченные двумя зондами отражения в сцене. Отражения для каждого зонда хранятся в кубических картах с mip-уровнями:

image

Зонды отражений окружения генерируются при запуске игры и захватывают только статичную/стационарную геометрию (заметьте, что на приведённых выше кубических картах отсутствует анимированный пропс «камень»).

Наша сцена с применёнными отражениями в экранном пространстве и отражениями окружения теперь выглядит вот так.

image

Туман и атмосферные эффекты

Далее следуют туман и атмосферные эффекты, если они тоже включены в нашу сцене.

image

Сначала создаётся маска перекрытия столбов света в четверть разрешения, которая определяет, какие из пикселей получат столбы освещения (которые применяются только к направленному освещению в сцене).

image

Затем рендерер начинает улучшать качество маски с помощью временного сглаживания и применяет для создания этой маски три прохода размытия (маску мне пришлось обработать, потому что она была почти полностью белой):

image

Из этого захвата действий видеопроцессора для меня не совсем понятно, зачем перед размытием к маске применяется временное AA, потому что конечный результат имеет очень низкое разрешение. Возможно, чтобы прояснить это, потребуется больше примеров использования в разных окружениях.

Перед добавлением в сцену тумана и столбов освещения рендерер даёт себе передышку и применяет к основному целевому рендеру атмосферные эффекты (в полном разрешении).

image

Это выглядит как полноценное вычисление рассеяния с помощью заранее вычисленных пропускания, облучения и рассеяния внутрь, похожее на работу Брунетона [11].

image

Наша сцена находится в помещении, поэтому, к сожалению, эффекты симуляции не слишком заметны.

Наконец, рендерер применяет в сцене экспоненциальный туман и столбы освещения.

image

Шейдер использует текстуру объёма объёмного тумана, созданную несколько проходов нада, выполняя сэмплинг на основен позиции непрозрачной геометрии. Также она применяет вычисленную выше маску столбов освещения.

Рендеринг прозрачности

После применения к непрозрачным пропсам тумана рендерер принимается за просвечивающую геометрию и эффекты.

image

Я добавил в сцену две стеклянные статуи, которые рендерятся первыми, с помощью обычного альфа-смешивания поверх основного целевого рендера.

image

Эти два прозрачных пропса хорошо расположены в сцене, на них влияют локальные и направленные источники освещения, отражения окружения, туман, и т.д. По умолчанию рендерер использует для рендеринга прозрачных пропсов высококачественный шейдер, который, среди прочего, сэмплирует заранее вычисленные текстуры атмосферной симуляции, данные запечённых карт освещения, объёмы просвечивающего освещения, содержащие освещение от направленных и локальных источников освещения, и кубические карты зондов освещения. Всё это используется для вычисления освещения. Однако я не увидел, что шейдер считывает текстуру объёма объёмного тумана, похоже, он только вычисляет туман на основании высоты/расстояния, возможно я где-то упустил этот параметр. Зависящий от расстояния туман, как и атмосферное рассеяние, вычисляется в вершинном шейдере.

Эффекты частиц рендерер записывает в отдельный целевой рендер (полного разрешения).

image

Как и в случае прозрачных пропсов, атмосферное рассеяние и туман для них вычисляются в вершинном шейдере. Кроме того, при определённых настройках системы частиц рендерер может использовать для освещения частиц объёмы просвечивающего освещения (в одном из случаев я видел, как он делает это в пиксельном шейдере).

Перед завершением обработки прозрачностей рендерер выполняет ещё один проход для вычисления преломлений.

image

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

image

Во время прохода вычисления преломлений (DistortionApply) рендерер считывает содержимое основного целевого рендера (который есть на данный момент) и векторы искажений, а затем записывает странную текстуру преломлений.

image

Поскольку активен стенсил-буфер, помечающий пиксели, которые получают преломление, рендереру не требуется очищать текстуру.

Как мы уже говорили, последний проход преломлений просто копирует с помощью стенсил-буфера текстуру преломлений в основной целевой рендер.

image

Вы уже могли заметить преломление на правом кресле, вызванные частицами, которые мы ещё не применили. Для прозрачных пропсов преломление рендерится после рендеринга пропса.

Следующий проход (BokehDOFRecombine) наконец применяет к сцене частицы. Это простой шейдер, который делает меньше, чем можно решить из названия прохода (возможно, это зависит от настроек рендеринга).

image

Постобработка

Последняя часть процесса обработки кадра включает в себя несколько проходов постобработки, которые я рассмотрю вкратце.

image

При конфигурации нашей сцены рендерер применяет к основному целевому рендеру расчёты временного сглаживания, размытия в движении, автоматической экспозиции, bloom и tonemapping.

Временное сглаживание Unreal использует буфер истории для постепенного накопления сэмплов, после чего оно рендерится в два прохода. В первом проходе к пикселям, не находящимся в стенсил-буфере (в нашем случае это некоторые из частиц), применяется временное AA с использованием основного целевого рендера, буфера истории и буфера скоростей для повторного проецирования:

image

Затем похожий проход временного AA выполняется для частей в стенсил-буфере, создавая готовое изображение со сглаживанием:

image

Разница между этими двумя проходами временного AA заключается в том, что первый использует коэффициент смешивания (обратную связь) между буфером истории и текущим целевым рендером, который является переменным и может зависеть от освещённости пикселя, расстояния, переданных рендерером весов и т.д. (на основании параметров), а второй проход использует постоянный коэффициент смешивания 0,25: это значит, что конечный пиксель со сглаживанием будет в основном состоять из текущего сэмпла. Думаю, так сделано для снижения эффекта «призрачности» быстродвижущихся частиц, для которых у нас нет информации о скорости.

Затем следует создание размытия в движении, предваряемое проходом выравнивания и наращивания скорости.

image

Эффект размытия в движении в нашем случае не слишком заметен, потому что камера статична и единственным подвижным пропсом, у которого есть скорость, является камень (а он и так уже немного размыт из-за движения и временного сглаживания).

Для реализации автоэкспозиции (адаптации глаза) рендерер с помощью compute shader создаёт гистограмму освещённости текущей сцены. Гистограмма группирует яркость пикселей и вычисляет количество пикселей, относящихся к каждой группе яркости.

image

Преимущество такого подхода заключается в том, что мы легко пропустить те области изображения, в которых находятся очень тёмные или очень яркие значения и создать более разумную аппроксимацию средней освещённости сцены. С помощью этой средней освещённости рендерер может вычислить адаптацию глаза, соответствующим образом отрегулировав экспозицию (яркие изображения приводят к малой экспозиции, а более тёмные — к большой).

Для реализации эффекта Bloom используется несколько проходов снижения разрешения, в которых применяется фильтрация Гаусса, а затем несколько операций повышения разрешения и комбинирования (изображение изменяется таким образом, чтобы сделать его нагляднее без управления экспозицией).

image

В проходе PostProcessCombineLUTs для создания таблицы поиска шкалы цветов (объёмная текстура 32x32x32 RGB10A2) используются геометрический шейдер и довольно долгий пиксельный шейдер. Таблица поиска будет использована на этапе tonemapping:

image

Последний проход кадра (Tonemapper) комбинирует вычисленный ранее bloom с основным целевым рендером, регулирует экспозицию изображения с помощью вычисленной раньше адаптации глаза, после чего передаёт цвет через таблицу поиска шкалы цветов для создания конечного цвета пикселя:

image

Подводим итог

Нужно подчеркнуть, что это всего лишь один путь рендеринга, на него может влиять множество параметров и настроек, и на самом деле мы рассмотрели самые основы.

В целом, это оказалось интересным занятием, несмотря на то, что я скорее узнал, что делает рендерер, а не как он это делает. Очень многое осталось неисследованным и я с хочу снова вернуться к этой теме.

Исходный код Unreal не очень хорошо задокументирован, но он довольно чёток и понятен. Следуя по списку вызовов отрисовки, очень просто найти соответствующий ему код. Однако во многих случаях было довольно сложно понять из исходного кода, что же делают шейдеры, потому что в них активно используется условная компиляция. Для удобства изучения и профилирования производительности было бы неплохо иметь какой-нибудь промежуточный кэш обработанных и готовых к компиляции специализированных шейдеров (названия которых добавлены в список вызовов отрисовки).

Похоже, что по умолчанию рендерер Unreal делает упор на создание высококачественных изображений. Он активно использует запекание данных (окружения, освещения, объёмов и т.д.) и применяет временное сглаживание для значительного улучшения качества изображения.

Если в вашей сцене есть много пропсов и не так много возможностей перекрыть их (например, большим количеством крупных перекрывающих объектов), то стоит внимательно отнестись к проходу расчёта перекрытий. Кроме того, преломления на прозрачных пропсах и частицах приводят к двойному рендерингу. И наконец, многие стационарные или подвижные локальные источники освещения влияют на этапе освещения, потому что они рендерятся индивидуально (и вносят свой вклад в затраты на проход добавляения освещения для эффектов прозрачности и объёма).

В завершение хочу поблагодарить Baldurk [12] за отличный инструмент RenderDoc и компанию Epic за раскрытие исходного кода Unreal для использования, изучения и обучения.

Автор: PatientZero

Источник [13]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/shejdery/266867

Ссылки в тексте:

[1] как популярные игры рендерят кадр: http://www.adriancourreges.com/blog/2015/11/02/gta-v-graphics-study/

[2] перевод: https://habrahabr.ru/company/ua-hosting/blog/271931/

[3] RenderDoc: https://renderdoc.org/

[4] обратный Z-буфер (reverse-Z): https://developer.nvidia.com/content/depth-precision-visualized

[5] аппаратные запросы перекрытий (hardware occlusion queries): https://developer.nvidia.com/gpugems/GPUGems2/gpugems2_chapter06.html

[6] систему кластерного затенения: http://www.humus.name/Articles/PracticalClusteredShading.pdf

[7] Assassin’s Creed IV: http://advances.realtimerendering.com/s2014/index.html

[8] Frostbite: https://www.ea.com/frostbite/news/physically-based-unified-volumetric-rendering-in-frostbite

[9] специализированных расширений драйверов: https://github.com/GPUOpen-LibrariesAndSDKs/DepthBoundsTest11

[10] wand de: https://twitter.com/w0xd_

[11] работу Брунетона: https://hal.inria.fr/inria-00288758/file/article.pdf

[12] Baldurk: https://twitter.com/baldurk

[13] Источник: https://habrahabr.ru/post/341080/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best