Scriptable Render Pipeline (далее SRP) в Unity - это бескомпромисная свобода и производительность по сравнению с универсальными, но слишком громозкими URP и HDRP. Результат? Noesis Render: кастомный пайплайн с декалями, продвинутым AO, поддержкой DLSS и FPS которые упираются в процессор. В первой части я обсужу общее состояние SPR и выбор лучшего пути рендера.
Кратко про SRP и текущее состояние Scriptable RenderPipeline'ов
SRP, это можно сказать библиотека с различными core методами для рендеринга, к примеру куллинг объектов и отбор объектов для draw call по их пассам и другим критериям. Всё это также стыкуется с дефолтными компонентами объектов, вроде meshRenderer, через который можно включать и выключать тени (включать и выключать shadowpass). Никакого (почти) кода на шейдерной стороне тоже нет.
Реализация методов рендера в SRP остаётся у тебя в руках. Огромная свобода и большие возможности отбрасывать ненужное. Код отвечающий за графику почти всегда самый нагруженный в играх. Программисты игровой логики могут пользоваться абстракциями (и просто неоптимизированным кодом), ведь оверхеад от них будет в среднем едва заметен при единичном открытии сундука раз в несколько секунд, оптимизация чего не стоит времени программиста. При рендере у вас всего 16.6 миллисекунд, и стоит их тратить только на самое важное. Если кадров мало или есть просадки, это очень негативно влияет на опыт игрока. Поэтому, если вам нужна бескомпромиссная производительность, нужен свой рендер.
Поверх SRP написаны URP и HDRP, так что, при желании, их можно полностью под себя переписать. Минус в том, что для ShaderGraph API закрытое (более того, для URP и HDRP написан свой велосипед реализации его методов). Когда-нибудь наступит тот светлый день, когда API откроют, ну а пока необходимо писать все шейдеры hlsl'ом. По крайне мере, нет ничего такого, что возможно в ShaderGraph, и невозможно в hlsl (а наоборот есть).
При этом API SRP почти не меняется от версии к версии. Крупным нововведением относительно недавно стал RenderGraph, но и его никто не заставляет использовать (при этом он обязателен для URP). В то же время, только по своему опыту могу сказать, что рендер фичи URP 2020 не совместимы с 2021, те не совместимы с 2022, и все они не совместимы с Unity6. Начиная с шестой версии разработчики декларировали, что чтобы реже менять апи, они переходят на модель с версией раз в несколько лет, вместо ежегодного релиза. Но, видимо, чтобы не портить старую традицию, сделали несовместимыми 6.1 и 6.0. Ну и не забывайте, что URP и HDRP тоже между собой не совместимы. Их давно хотят объединить, но произойдёт это ещё не скоро.
При написании своего SRP мне хотелось достичь уровня URP, со всеми его основными фичами, вроде нескольких путей рендера, декалей и ambient occlusion (что успешно удалось). Однако в будущем я буду развивать в основном Deferred путь, поскольку слишком трудоёмко всё делать по два раза, когда после первого уже всё понятно. В целом можно понять, почему так медленно развивается юнити. Если вы когда-то смотрели на исходники шейдеров, вы уже знаете что я хочу сказать. Для тех кто не смотрел, поясню.
В шейдере декали(unlit), только основного файла >3 тысяч строк, а с инклудами эта цифра должна быть по ощущениям где-то в районе 10 тысяч. Весь этот код довольно сложно читать из-за его спагетификации. При этом, если скомпилировать проект, и через рендердок посмотреть на hlsl код (то есть перевести исходный код в hlsl, в моём случае DXBC), то получится только 60 строк фрагментного шейдера, которые действительно занимаются какой-то логикой. Ради этих 60 строк тянется огромный паровоз зависимостей. Собственно поэтому, если мне нужно посмотреть реализацию чего-то в шейдере (если это не касается вопросов портирования), то я лучше уж буду ревёрсинжинерить, чем смотреть исходники.
Сразу скажу, что мой рендер пайплан не стоит рассматривать как потенциальный для вашего проекта, он делался в обучающих целях. И как обучающий проект он полностью окупился. В процессе реализации многих элементов удалось коснуться различных тонкостей, о которых теперь хочется рассказать.
И, конечно, он имеет крутое название, Noesis render (от греческого νόησις, понимание).
Noesis Render Pipeline
Кратко, чего в итоге конкретно удалось добиться. В дальнейшем я могу подробнее раскрыть каждую тему в отдельных статьях. Есть шейдеры со стандартной lit BDRF моделью освещения, довольно продвинутая реализация AO, декали, SPR Batcher, полная поддержка стандартной Particle System, PostFX со всеми функциями стандартного PostFXStack + несколько кастомных, несколько методов антиалиасинга: SMAA2x, FXAA, DLSS, DLAA, а также поддержка рендера порталов при Forward рендере. В планах добавить Intel Taa (поскольку под него был заточен используемый XeGTAO) и FSR. Всё рендерится в 400 fps на RTX3060 в нативном (c FXAA) 1920х1080 на тестовой сцене (1.1м треугольников, около 100 материалов). Если на тестовой сцене включить SPR Batcher, то из-за использования одного шейдера разными материалами fps пробьёт 450 и упрётся в процессор.

Ну а теперь можно переходить к деталям.
Нюансы выбора пути рендера
В первую очередь начнём с того, какой путь рендера быстрее: Forward или Deferred? Для этого прикинем сложность расчёта конечного изображения для Forward. Для него нам нужно высчитать весь конечный меш и его свойства (дальше я всё это буду называть расчётом меша, но держите в уме, что нам для этого нужно рассчитать все трансформации в вертексной части шейдера, а затем, как правило (low poly графика может и без текстур обходиться, записывая цвета напрямую в меш), прочитать переданные свойства и текстуры для конкретного пикселя в фрагментной части), а затем рассчитать модель освещения для этих свойств отдельно для каждого источника света. То есть, сложность можно записать как meshComplexity * lightCount
.
Итак, с большим трудом, мы рассчитали все свойства и освещение для некого треугольника. Но затем мы начинаем обрабатывать новый треугольник, и он частично перекрывает предыдущий. Мы не можем полностью отбросить треугольник при помощи кулинга, так что растеризатор полностью рассчитывает поверхность, а фрагментый шейдер полностью рассчитывает финальный цвет для всей поверхности. Так что остаётся только отбросить всю эту проделанную работу, и в перекрывающейся части высчитать новый финальный цвет. Поэтому все стараются уменьшить overdraw, ведь это буквально впустую израсходованные ресурсы процессора.
Пример


К тому же, пиксельный шейдер работает не совсем попиксельно. Он запускается группами 2х2 пикселя. То есть, в худшей ситуации, для расчёта 3 пикселей треугольника нам придётся запустить пиксельный шейдер 12 раз. К сожалению, с этим буквально ничего нельзя поделать. Разве что используйте хорошие LOD'ы, и следите за плотностью треугольников.
Пример

Deferred путь предлагает другой подход, зачем нам рассчитывать дорогостоящую модель освещения, если можно записать все необходимые свойства в буфера, а затем для всего экрана посчитать конечное освещение? Тут в ситуации с перерисовкой, мы просто обновим значение буфера, что не должно быть слишком дорого. Ну и конечно, всё это работает только с полностью непрозрачными объектами (как правило, их большинство). Тогда сложность становиться meshComplexity + lightCount + BuffersCost
. Здесь уже понятно, что сколько бы не стоила запись и чтение из буферов, при единственном источнике света, Forward путь будет иметь меньшую стоимость при той же сложности сцены (без огромного overdraw). Но когда источников света много, его стоимость растёт экспоненциально, а стоимость Deferred линейно. Поэтому (почти) все "графонистые" игры используют Deferred, а почти все мобильные игры Forward.
Стоимость буферов
А теперь подробнее про стоимость обращения к буферам. Если мы хотим максимально её уменьшить, можно утрировать, что идеальные буфера чуть ли не уникальные для каждого проекта. Основная стоимость исходит из размеров этих буферов, так что битва будет идти буквально за каждый бит. Конкретное наполнение этих буферов будет зависеть от использующейся модели освещения. Например, точность цвета (albedo текстуры) нам не сильно принципиальна (поскольку цвета всё равно изменяются при расчёте освещения, а сами прочитанные значения переменных в шейдере можно спокойно кастить к float (или half, если вы жадный), ведь узкое горлышко обращения к vram мы уже миновали), так что их можно записать в формате R5G6B5_UNormPack16, где G даём на 1 бит информации больше, ведь человеческий глаз наиболее чувствителен к нему. U означает, что мы не записываем положительный или негативный знак числа (вряд ли у нас негативные цвета, но и такое возможно, например, в какой-нибудь игре, где в темноте предметы меняются). Итого выходит 16 бит, довольно экономно.
Далее, нормали, они тоже обычно очень важны для освещения. В своей реализации я их просто пишу как half (16 бит на канал RGBA, где в a
хранится дополнительный параметр), поскольку 8 бит слишком мало, чтобы получить качественный результат. Но если важна память, можно взять что-то среднее между 8 и 16, а именно R11G11B10, и заняться манипуляциями с битами памяти. Возьмём RUInt текстуру, и закодируем в UInt float.
uint FLOAT3_to_R11G11B10_UNORM( float3 unpackedInput )
{
uint packedOutput;
packedOutput =
(
( uint( saturate( unpackedInput.x ) * 2047 + 0.5f ) ) |
( uint( saturate( unpackedInput.y ) * 2047 + 0.5f ) << 11 ) |
( uint( saturate( unpackedInput.z ) * 1023 + 0.5f ) << 22 )
);
return packedOutput;
}
Потом, главное, в deferred calculate шейдере обратно скастить этот формат к float, и читать значения буфера не через SAMPLE_TEXTURE2D_LOD (поскольку можем случайно начать интерполировать упакованные значения между соседними пикселями), а через Texture[pixId] (pixId это int2).
float3 R11G11B10_UNORM_to_FLOAT3( uint packedInput )
{
float3 unpackedOutput;
unpackedOutput.x = (float)( ( packedInput ) & 0x000007ff ) / 2047.0f;
unpackedOutput.y = (float)( ( packedInput >> 11 ) & 0x000007ff ) / 2047.0f;
unpackedOutput.z = (float)( ( packedInput >> 22 ) & 0x000003ff ) / 1023.0f;
return unpackedOutput;
}
Итого мы потратим на RGInt 32 бит, когда RGBHalf буфер потратит 48 бит. При этом, после чтения переменной её можно дополнительно нормализовать при помощи normalize
(аппаратно-ускоренная функция с shader model 2, считайте бесплатная), что сгладит ошибки округления.
Металличность можно хранить как бинарное значение (1 бит) для смены алгоритма освещения с диэлектрика на металл (если наша модель BDRF).
Всё вышеперечисленное можно применить и к другим свойствам материалов, в зависимости от используемой модели освещения и желаемого результата. Например, emission буфер занимает много места. Если у нас не будет ситуации, когда объект немного светиться (плохо различимо под светом, но заметно во тьме), и мы всегда имеем дело с достаточно ярким emission (лампочка или фонарик), то лучше уже отдать рендер этих объектов в отдельный unlit pass после расчёта Deferred. Не должны же пара лампочек тормозить всё остальное, верно? (В Unity по дефолту этот буфер есть)
Также, не советую использовать меши сфер или квадратов покрывающие область действия света в экранном пространстве, чтобы в них в deferred pass рассчитывать освещение, как это предлагают делать в старых статьях. На современном железе мы больше проиграем при передаче этой информации.
В целом написать Deferred pass в его общем виде (то есть без специфичных оптимизаций) было не так уж и сложно. Разве что, юнити не хочет принимать TextureHandle (что используется системой RenderGraph) в commandBuffer.SetRenderTarget(RenderTargetIdentifier[] colors, RenderTargetIdentifier depth)
в GBuffer
'е (когда color один, без массива, всё прекрасно работает, так что это какой-то баг). Придётся городить костыль с созданием RenderTexture (или RTHandle, они эффективнее, когда мы динамически меняем разрешение). В целом RenderGraph очень удобный, в плане управления буферами, но тут явно недоработали. Ну ничего, может через пару лет сделают, как это обычно бывает.
Ссылки на мой код
А также шейдерная часть:
Запись в Gbuffer в GBufferFragment
Преимущества Forward
У Forward есть преимущества не только в скорости при малом количестве света. С ним гораздо проще реализовывать разные модели освещение для разных объектов (например, более быструю), в deferred рендере нам нужно где-то хранить флаг и бранчится на его основе (что не бесплатно для gpu). Из-за отсутствия буферов мы используем меньше пропускной способности (и) памяти.
Если при Forward рендере вы ограниченны освещением, а не геометрией, и при этом вы не на TBDR GPU, то стоит использовать Depth Prepass перед Forward, чтобы уменьшить перерисовку.
К тому же, Forward+ методы помогают бороться с ситуациями, когда огромное количество света обрабатываться пиксельным шейдером. В моей простой реализации, экран разделяется на области заданного размера (скажем, 32х32), алгоритм проходится по всем light bounds, проверяет, в каких областях свет может оказывать влияние, и записывает его в буфер этой области. Минусы этой реализации в том, что мы всегда должны создавать буфер максимального установленного размера. Буфер только двумерный, ничего не отбрасывается по глубине. К тому же, определение областей влияния происходит на CPU со всеми вытекающими. Плюс в том, что у нас нет предела на количество источников света влияющих на один меш, если он у нас большой.
Пример

Реализация для референса https://github.com/5a5ha111/NoesisRender/blob/Main/Assets/CustomRP/Runtime/Passes/Lighting/ForwardPlusTilesJob.cs
https://github.com/5a5ha111/NoesisRender/blob/Main/Assets/CustomRP/Runtime/Passes/LightingPass.cs
Наверное, лучшая мне известная реализация Forward+ была у Doom 2020. Если вкратце, весь процесс куллинга и заполнения буферов происходит при помощи compute shaders, а сами буферы разделены на 24 среза по глубине. Благодаря хорошо сделанному куллингу, сцена может иметь сотни динамических источников света, при этом продолжая использовать преимущества Forward.