Что мы будем использовать для профайлинга рендеринга кадра? Ну, во‑первых, в Unity есть родной профайлер (Window→Analysis→Frame Debugger), работающий как внутри эдитора, так и для билдов, в том числе мобильных. Его минусы — он отображает не все события, и отсутствует показатель времени, затраченного на каждую операцию. Второй вариант — всеми любимый RenderDoc. Минусов у него я не нашел, он может подключатся в том числе и к мобильным платформам, и пошагово показывает затраченное время (но на мобильных оно, вроде бы, не соответствует действительности). Для счастливых обладателей «зеленых» видеокарт есть отличное приложение от NVidia — Nsight. Отображает все что нужно, это моя любимая тулза еще с тех времен, когда она называлась NVPerfHud. Аналогичное приложение от «красной» команды — Radeon GPU Profiler. От Intel – GPA. От Microsoft – PIX. Есть еще Open-source проект – Apitrace.
Для мобильных платформ существуют Arm Performance Studio и Snapdragon Profiler, но каких-то их преимуществ перед рендердоком я не заметил.
Юнити при создании проекта озадачивает пользователя необходимостью выбрать один из трех способов рендеринга (переключение между ними в дальнейшем весьма нетривиально) – Built-in (Legacy), URP и HDRP. Для начала мы посмотрим на Built-in рендеринг, существующий в юнити еще со времен, когда рендерить можно было без шейдеров (да, было и такое). Создаем Built-in проект: свет, камера, мотор, анимированный персонаж, и смотрим во фрейм дебаггер.
Первое событие, которое мы видим - это GPU Skinning. Это трансформация вершин костями нашего анимированного персонажа. Она выполняется запуском Compute шейдера Internal‑Skinning.compute, его можно найти в Built‑in шейдерах юнити. Шейдер перемножает позицию, нормаль и тангенциаль каждой вершины меша на матрицу (усредненную с весами) привязанных костей. На GPU этот шаг обычно занимает очень мало времени. В случае платформ без поддержки compute шейдеров, как например WebGL 2.0, эти вычисления ложатся на CPU и могут занять существенную часть времени рендеринга кадра. Вершины, полученные в результате скиннинга, можно скопировать в виде буфера через Mesh.GetVertexBuffer(). Потом этот буфер можно применять в Compute шейдерах, например, чтобы рисовать по поверхности анимированной модели. Но об этом — в другой раз.
Далее происходит собственно рендеринг картинки. Для всех объектов в сцене, сначала может применяться объединения одинаковых объектов. Потом объекты, попадающие в поле зрения камеры, разбиваются по материалам, и в видеокарту поступают уже в виде отдельных DrawCalls — батчей, каждый из которых содержит меш, применяемые текстуры и свойства материала, шейдер, матрицы трансформации, количество объектов (если нужно отрисовать несколько инстансов одного объекта). Тут наверное нужно вспомнить схему стандартного конвейера рендеринга.
Стадии, выделенные желтым цветом - это работа шейдеров, которые мы можем изменить. Стадии, выделенные зеленым, приходится принимать такими, какие они есть.
Вершинный шейдер трансформирует вершины из пространства модели в однородные координаты в пространстве камеры, также могут быть применены эффекты - сдвиг вершин для колебаний веток деревьев например. Тесселяция разбивает треугольники, для сглаживания мешей, но она поддерживается далеко не на всех платформах. Шейдер геометрии позволяет создавать дополнительные треугольники - например можно нарисовать что-нибудь на месте каждого вертекса исходной сетки. Часто используется для создания травы и весьма быстр, но работает по сути только на PC. Пиксельный (фрагментный) шейдер определяет значения, которые будут записаны в буфер кадра (фреймбуфер). В типичном случае - это расчёт освещения, но это может быть и применение постпроцесс-эффекта и просто закрашивание каждого пикселя заданным цветом. Блендинг смешивает результат работы пиксельного шейдера с тем значением что уже есть во фрембуфере (в случае полупрозрачных объектов, это медленно), либо просто записывает значения поверх (это быстро).
Эффекты постпроцесса рендерятся точно также - только в качестве меша используется прямоугольник размером с экран, а в качестве текстуры используется предыдущий буфер кадра - к нему применяется шейдер необходимого эффекта.
Вернемся к нашему анализу кадра. Первая группа событий при рендеринге кадра - Update Depth Texture: вначале происходит полная очистка фреймбуфера, а потом идет early Z-pass: вся непрозрачная геометрия рендерится первый раз - в текстуру глубины сцены (Depth).
В чем его смысл: так как пиксельный шейдер финального рендеринга может быть очень тяжелым, многие игры сначала рендерят все объекта, не записывая ничего в буфер кадра, только в буфер глубины, либо записывая в буфер кадра глубину сцены. При последующем основном проходе рендеринга, пиксели, скрытые за другими объектами, отсекаются проверкой глубины, что дает выигрыш в производительности. Но, в случае большого числа вершин и /или вызовов отрисовки, экономия на пиксельном шейдере нивелируется большей загрузкой вертексного шейдера и СPU. В первую очередь это касается не игр, а приложений для промышленной визуализации, где напрямую используется геометрия из CAD программ. Для мобильных игр Arm не рекомендует использование Depth prepass, т.к. в Arm GPU зашита подобная техника отсечения невидимых пикселей, и Depth prepass не дает преимуществ. В Built-In рендеринге early Z-pass активен только когда включены тени, и текстура глубины используется затем при рендеринге теней.
Далее следует проход рендеринга карты теней - Shadows.RenderShadowMap. Для этого рендерится вид из источника света на всю, отбрасывающую тени, геометрию сцены. Так как тени у нас каскадные - это повторяется 4 раза, со все увеличивающимся зумом.
Следующий проход - рендеринг собственно теней, в экранном пространстве, по карте глубины, которую мы отрендерили в early Z pass, для каждого пикселя изображения восстанавливается его 3D позиция, а по карте теней проверяется, достигает свет этого пикселя или нет. В случае жестких теней карта теней сэмплируется 1 раз (но слегка сглаживается за счет использования comparison filter, это можно поменять здесь), в случае мягких - используется Percentage-Closer Filtering, 16 сэмплов на пиксель.
Наконец мы добрались до рендеринга финальной картинки. Заметьте, что к этому моменту некоторые объекты отрендерились уже 4 раза - один для early Z pass и 4 для карты теней. Финальное освещение в общем случае складывается из N dot L диффузного освещения и Specular, умноженных на значения из рассчитанной на предыдущем шаге интенсивности теней, плюс имитация Global Illumination, плюс отражения.
Следующим шагом рендерится Skybox, затем - прозрачные объекты. Последний шаг - визуальные эффекты, в данном случае только антиалиасинг.
А теперь добавим дополнительный источник света типа Spot (может быть и Omni, не важно). Что мы видим в Frame Debugger: в основном проходе рендеринга объекты стали отображаться по 2 раза.
В первый раз происходит рендеринг с Directional light, во второй - со Spot light. Это именно полноценный рендеринг, т.е код вершинного шейдера и растеризация выполняются 2 раза. При этом посчитать два (и больше) источника света в один проход вполне возможно - в Universal Pipeline так и сделано, да и большинство игр, еще со времен первого Crysis, рассчитывают освещение в один проход, в цикле добавляя вклад от каждого источника. Но в совсем стародавние времена количество инструкций шейдеров модели 2.0 было ограничено 64мя и применить несколько источников света за один проход было невозможно. Впрочем, в Half-Life 2 освещение в один проход было реализовано на 2.0 шейдерах в 2004 году, но с ограничением до 2х источников света. То есть Built-in рендеринг Unity хранит в себе легаси примерно 20-летней давности.
Deferred rendering
Для борьбы с многократным рендерингом объектов при использовании большого количества источников света в условиях ограничений количества шейдерных инструкций был придуман Deferred rendering. Первая игра с его использованием - Шрек (Xbox , 2001). С тех пор Deferred rendering постепенно стал использоваться едва ли не во всех крупных игровых проектах – S.T.A.L.K.E.R., Crysis 2, GTA V, Elden Ring, Cyberpunk 2077, God of War, Genshin Impact... В Unity его можно включить как в Built-in, так и в URP и HDRP. Посмотрим как он работает в Built-in.
Сначала, точно также как и в Forward, происходит GPU Skinning - он никак не зависит от выбранного типа рендеринга. Сам рендеринг начинается с заполнения так называемого G-буфера - набора рендертекстур, содержащего всю информацию для дальнейшего рендеринга.
Здесь используются 4 ARGB рендертекстуры и буфер глубины. Первая рендертекстура, RT0 - информация об альбедо, то есть просто цвет или основная текстура материала. Вторая, RT1 - это цвет бликов (specular) и глянцевитость поверхности (записывается в альфа-канал). Третья, RT2 содержит информацию о нормалях наших объектов. В четвертую, RT3, записывается интенсивность амбиентного освещения (имитация Global Illumination) и интенсивность самосвечения (Emissive color), если оно присутствует.
Почему карта нормалей такая разноцветная, а не привычного голубого цвета?
Потому что в текстурную карту нормалей сохраняется отклонение нормали от перпендикулярного поверхности положения, для чего задействуются R и G компоненты. В данном же случае в G- буфер записывается направление нормалей относительно камеры, для чего требуются все 3 компоненты вектора.
На этом рендеринг непрозрачной геометрии закончен, все последующие операции до рендеринга прозрачных объектов, выполняются в экранном пространстве, это как бы постпроцесс. Следующее событие рендеринга - это копирование буфера глубины в рендертекстуру.
Далее, используя информацию из G-буфера, происходит рендеринг отражений (Cubemap reflections) и следующим шагом отражения добавляются к рендертекстуре RT3.
Потом происходит расчёт Diffuse и Specular освещения объектов, в отдельном проходе для каждого источника света.
При этом для Spot и Omni источников рендерится не весь экран, а только область, которую этот источник покрывает, для этого на рендер вызываются специальные меши - пирамиды для Spot light и сферы для Omni.
Следующая группа событий - рендеринг объектов в карту теней для directional источника света, и рендеринг самих теней. Здесь все точно также, как мы уже видели в Forward рендеринге. Далее, освещение от directional источника, с учетом теней, добавляется к нашему буферу кадра.
После рендерятся прозрачные объекты, их рендеринг происходит за несколько проходов, соответствующих количеству источников света, также как и в Forward рендеринге. В самом конце применяются эффекты постпроцесса, тут опять все также, как и в Forward рендеринге.
Escape from Tarkov
Built-in Deferred рендеринг используется например в Escape from Tarkov, но в кастомизированном виде. Вкратце рассмотрю, как там все устроено. В начале рендеринга кадра создается буфер motion vectors (скорость движения объектов относительно камеры, для создания motion blur) и в него рендерится трава, движимая имитацией ветра в вершинном шейдере, анимированные объекты будут отрендерены гораздо позже. Потом, традиционно, заполняется G-буфер. Он здесь вполне традиционного вида, состоит из 4 RGBA текстур (альбедо, Specular + Glosiness, нормали, Emissive + отражения + амбиентный свет) и DepthStencil буфера. Замечу, что хотя стенсил буфер кажется чем-то забытым, на практике он достаточно часто применяется например для разделения объектов на типы. Здесь он служит для того, чтобы отделить объекты, из которых состоит игрок, от окружения. Для иллюстрации процесса рендеринга я сохранил breakdown заполнения альбедо и нормалей в gif.
Следующим проходом рендерится лежащий на предметах снег - там где нормали в G буфере направлены вверх, и не назначена текстура маски исключения. Тут что то странное, эта текстура пустая (хотя и занимает место в памяти, все же R32), а исключение объектов ведется по значениям из стенсила, при этом цевье оружия не пишет значения в стенсил, и тоже покрывается снегом. Ну ок, Tarkov все же бета-версия. Хотя отдельная маска снега думаю не нужна - можно использовать например альфа каналы G-буфера, они не задействованы, насколько я понял.
Далее применяются декали следов поверх снега - например, от машин на дороге.
Потом считаются и применяются Deferred отражения. У меня их карта получилась черной, не знаю, баг ли это игры или PIX, или так и должно быть.
Следующий шаг - буфер для создания эффекта Volume Light: происходит рендеринг сфер и конусов, соответствующих омни и спот источникам света, в текстуру глубины, в 4 раза меньшую чем размер экрана.
Далее, используя 2 огромные 4к текстуры глубины сцены из источника света (отрендеренные в конце предыдущего кадра, вероятно рендеринг размазан на несколько кадров ) и глубину сцены на виде от игрока, создается что-то типа маски Directional освещения, она используется для создания эффекта volume light от солнца .
После этого происходит рендеринг амбиентного освещения интерьеров и Deferred освещения от источников света в интерьерах, в отдельный буфер.
Потом объекты рендерятся в карту теней от Directional Light (солнца). Тут по-видимому обычные каскадные тени Unity, 4 каскада, карта теней размером 4к.
Потом - шаг применения теней, здесь же учитывается volume light (используются карта теней, глубина сцены и маска directional освещения):
Далее, вызовом Compute шейдера происходят какие-то манипуляции с освещением интерьеров, не разбирался с этим подробно.
Наконец, мы дошли до собственно рендеринга Deferred освещения сцены. Он происходит в несколько шагов, сначала для поверхностей без снега применяется Directional Light, потом считается освещение снега.
Рисуем солнце и небо (используется процедурная модель освещения неба). Потом - облака, в виде групп спрайтов, PIX говорит что используется шейдер геометрии, визуализацию которого он не поддерживает.
Далее идет запись векторов движения в буфер Motion Vectors - в него копируется буфер motion vectors травы (полученный в самом начале рендеринга кадра) а затем отрисовываются анимированные персонажи.
На этом очередь рендеринга непрозрачных объектов заканчивается, и к ее концу привязано копирование экрана в текстуру для Depth of Field (эффект от Prism), процессинг этой текстуры в несколько шагов, применение SSAO (используется эффект от Prism). Далее добавляется заполняющее освещение интерьеров. Точно как оно устроено, я не смотрел, на этой стадии происходит отрисовка каждого объёма в виде бокса.
Далее - рендеринг прозрачных объектов, часть из них отрисовывается традиционным способом (в несколько проходов), а часть с использованием собственной реализации Moment Based Order Independent Transparency (MOIT).
Перед завершением рендеринга применяются эффекты Temporal Antialiasing, DoF, Grain, (используется ассет для постпроцессинга от Prism), Bloom, тонемаппинг в LDR.
Завершает все это рендеринг UI, перед ним рендерится глубина сцены из источника света, она будет использоваться в следующем кадре.
В итоге, в реальной игре рендеринг вообще не похож значительно усложнен по сравнению с обычным рендерингом Unity. И это еще я опустил подробное рассмотрение эффектов постпроцессинга. Но еще большую свободу в организации рендеринга дает Scriptable Rendering Pipeline. Разбор графики игры, построенной на таком, сильно кастомизированном, пайплайне - тема для следующей статьи.
Автор: Anfin3D