Что мы будем использовать для профайлинга рендеринга кадра? Ну, во‑первых, в 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 проект: свет, камера, мотор, анимированный персонаж, и смотрим во фрейм дебаггер.

Unity Frame Debugger
Unity Frame Debugger

Первое событие, которое мы видим - это 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 раза, со все увеличивающимся зумом.

Shadow Map - 4 каскада
Shadow Map - 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.

Теперь профайлим Deferred проект, к Directional источнику света добавлено 2 Spot
Теперь профайлим Deferred проект, к Directional источнику света добавлено 2 Spot

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

Здесь используются 4 ARGB рендертекстуры и буфер глубины. Первая рендертекстура, RT0 - информация об альбедо, то есть просто цвет или основная текстура материала. Вторая, RT1 - это цвет бликов (specular) и глянцевитость поверхности (записывается в альфа-канал). Третья, RT2 содержит информацию о нормалях наших объектов. В четвертую, RT3, записывается интенсивность амбиентного освещения (имитация Global Illumination) и интенсивность самосвечения (Emissive color), если оно присутствует.

G-буфер
G-буфер

Почему карта нормалей такая разноцветная, а не привычного голубого цвета?

Потому что в текстурную карту нормалей сохраняется отклонение нормали от перпендикулярного поверхности положения, для чего задействуются R и G компоненты. В данном же случае в G- буфер записывается направление нормалей относительно камеры, для чего требуются все 3 компоненты вектора.

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

Далее, используя информацию из G-буфера, происходит рендеринг отражений (Cubemap reflections) и следующим шагом отражения добавляются к рендертекстуре RT3.

Потом происходит расчёт Diffuse и Specular освещения объектов, в отдельном проходе для каждого источника света.

При этом для Spot и Omni источников рендерится не весь экран, а только область, которую этот источник покрывает, для этого на рендер вызываются специальные меши - пирамиды для Spot light и сферы для Omni.

Вот так выглядят проекции сфер от Omni lights
Вот так выглядят проекции сфер от Omni lights

Следующая группа событий - рендеринг объектов в карту теней для directional источника света, и рендеринг самих теней. Здесь все точно также, как мы уже видели в Forward рендеринге. Далее, освещение от directional источника, с учетом теней, добавляется к нашему буферу кадра.

Первый Spot Light - Второй Spot Light – Directional Light
Первый Spot Light - Второй Spot Light – Directional Light

После рендерятся прозрачные объекты, их рендеринг происходит за несколько проходов, соответствующих количеству источников света, также как и в 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 от солнца .

Shadow Mask
Shadow Mask

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

Потом объекты рендерятся в карту теней от Directional Light (солнца). Тут по-видимому обычные каскадные тени Unity, 4 каскада, карта теней размером 4к.

Shadow Map - 4 каскада
Shadow Map - 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. Разбор графики игры, построенной на таком, сильно кастомизированном, пайплайне - тема для следующей статьи.

Комментарии (2)


  1. Jijiki
    12.01.2025 09:50

    тоесть костная анимация не так нагрузна? ведь ИК блендера даёт 100+ костей, на гпу допустим удастся вытащить из блендера 13 костей, итого на сцене может быть без запекания модельки и их может быть много что отразится на производительности (и ведь тут как раз делают обратную штуку вытаскивают кости запекают модельки и играют фреймы статичные) - получается скининг и кости это ключевой вопрос производительности (легко проверить кинув 10 моделек инстансингом с костями запустить их и кинуть главного персонажа и накинуть партиклов как дождик, и тут поидее будет всё видно), помимо этого еще лучи пускать ну к примеру о главного персонажа (ладно допустим лучи, а если расчеты обьемов делать, плюс еще какиенить дельта расчеты в пространстве понятно что всё упрощается ради производительности) (например поняв как из блендера вытаскивать своим форматом списки анимаций кейфреймами тогда как я понимаю в блендере вы делаете костную анимацию в движке будут просто 3д фреймы, просто недавно я смотрел 2 реализации 1 95 года вторая тоже но измененная - обе вытаскивают фреймы в движке не видел костей)


    1. Tirarex
      12.01.2025 09:50

      Гпу скиннинг быстрый но шейдеры не резиновые + циклы по костям и куча расчётов по влиянию той или иной кости на вертекс. И эти расчёты не батчатся на гпу нормально. В общем это все еще далеко не быстро и не дешево.

      Если надо очень много дешевой анимации то есть техника VAT (Vertex animation texture), суть которой в записи позиции и нормали каждого фрейма в столбец текстуры, а по строкам уже раскидать разные фреймы анимации а далее 2 семпла текстуры в вертексном шейдере(для интерполяции) и передвижение вертекса в полученные данные. Естественно сильно большие меши потребуют огромных текстур что не очень хорошо, но десятки тысяч лоуполи человечков на трибунах будут работать с огромным фпс даже на смартфонах. Батчинг с такой техникой дружит очень хорошо, в юнити относительно дешево можно накинуть Material Property Block, и море вызовов из одной текстуры очень хорошо оптимизируется конвеером гпу.