Благодаря системе нодов редактор материалов является отличным инструментом для создания шейдеров. Однако у него есть свои ограничения. Например, там невозможно создавать циклы и конструкции switch.
К счастью, эти ограничения можно обойти с помощью написания собственного кода. Для этого существует нод Custom, позволяющий писать код HLSL.
В этом туториале вы научитесь следующему:
- Создавать нод Custom и настраивать его входы
- Преобразовывать ноды материалов в HLSL
- Изменять файлы шейдеров с помощью внешнего текстового редактора
- Создавать функции HLSL
Чтобы продемонстрировать все эти возможности, мы воспользуемся HLSL для снижения насыщенности изображения сцены, вывода различных текстур сцены и создания гауссова размытия (Gaussian blur).
Примечание: подразумевается, что вы уже знакомы с основами использования Unreal Engine. Если вы новичок в Unreal Engine, то изучите нашу серию туториалов из десяти частей Unreal Engine для начинающих.
В туториале также предполагается, что вы знакомы с похожими на C языками, такими как C++ или C#. Если вам знакомы синтаксически схожие языки, например, Java, то вы тоже сможете разобраться.
Примечание: этот туториал является частью серии туториалов, посвящённых шейдерам:
- Часть 1: Cel Shading
- Часть 2: Toon-контуры
- Часть 3: Создание собственных шейдеров с помощью HLSL
Приступаем к работе
Начните с загрузки материалов этого туториала (скачать их можно здесь). Распакуйте их, перейдите в CustomShadersStarter и откройте CustomShaders.uproject. Вы увидите следующую сцену:
Сначала мы воспользуемся HLSL для снижения насыщенности изображения сцены. Для этого нам нужно создать и применить нод Custom в материале постобработки.
Создание нода Custom
Перейдите в папку Materials и откройте PP_Desaturate. Этот материал мы будем редактировать, чтобы получить эффект уменьшения насыщенности.
Для начала создайте нод Custom. Как и другие ноды, он может иметь несколько входов, но только всего один выход.
Затем выберите нод Custom и перейдите в панель Details. Вы увидите следующее:
Вот, что делает каждое из свойств:
- Code: сюда мы поместим наш код HLSL
- Output Type: вывод может быть в интервале от одиночного значения (CMOT Float 1) до четырёхканального вектора (CMOT Float 4).
- Description: текст, который будет отображаться на самом ноде. Это хороший способ давать названия нодам Custom. Наберите здесь Desaturate.
- Inputs: здесь можно добавлять и называть контакты входов. Затем с помощью этих имён мы сможем ссылаться на эти входы в коде. Задайте входу 0 имя SceneTexture.
Для снижения насыщенности изображения замените текст внутри Code на следующий:
return dot(SceneTexture, float3(0.3,0.59,0.11));
Примечание:dot()
— это предопределённая функция. Такие функции встроены в HLSL. Если вам понадобится функция наподобиеatan()
илиlerp()
, то сначала проверьте, нет ли уже такой предопределённой функции.
Наконец, соединим всё следующим образом:
Подведём итог:
- SceneTexture:PostProcessInput0 будет выводить цвет текущего пикселя
- Desaturate будет получать цвет и снижать его насыщенность. Затем он выводит результат в Emissive Color
Нажмите на Apply и закройте PP_Desaturate. Теперь насыщенность изображения сцены будет снижена.
Вам может быть интересно, откуда взялся код снижения насыщенности. Когда мы используем нод материала, он преобразуется в HLSL. Если просмотреть сгенерированный код, то можно найти соответствующий фрагмент и скопипастить его. Именно так я преобразовал нод Desaturation в HLSL.
В следующем разделе мы узнаем, как преобразовывать нод материала в HLSL.
Преобразование нодов материалов с HLSL
В этом туториале мы преобразуем в HLSL нод SceneTexture. Это пригодится позже, когда мы будем создавать гауссово размытие.
Для начала перейдите в папку Maps и откройте GaussianBlur. Затем вернитесь в Materials и откройте PP_GaussianBlur.
Unreal генерирует HLSL для всех нодов, участвующих в конечном выводе. В нашем случае Unreal сгенерирует HLSL для нода SceneTexture.
Чтобы просмотреть код HLSL всего материала, выберите Window\HLSL Code. При этом откроется отдельное окно со сгенерированным кодом.
Примечание: если окно HLSL Code окажется пустым, то необходимо будет включить в Toolbar Live Preview.
Так как сгенерированный код имеет длину в несколько тысяч строк, то по нему достаточно сложно перемещаться. Чтобы упростить поиск, нажмите на кнопку Copy и вставьте код в текстовый редактор (я пользуюсь Notepad++). Затем закройте окно HLSL Code.
Теперь нам нужно найти, где находится код SceneTexture. Проще всего это сделать, найдя определение
CalcPixelMaterialInputs()
. Это функция, в которой движок вычисляет все выходы материалов. Если посмотреть в нижнюю часть функции, то можно увидеть конечные значения для каждого выхода:PixelMaterialInputs.EmissiveColor = Local1;
PixelMaterialInputs.Opacity = 1.00000000;
PixelMaterialInputs.OpacityMask = 1.00000000;
PixelMaterialInputs.BaseColor = MaterialFloat3(0.00000000,0.00000000,0.00000000);
PixelMaterialInputs.Metallic = 0.00000000;
PixelMaterialInputs.Specular = 0.50000000;
PixelMaterialInputs.Roughness = 0.50000000;
PixelMaterialInputs.Subsurface = 0;
PixelMaterialInputs.AmbientOcclusion = 1.00000000;
PixelMaterialInputs.Refraction = 0;
PixelMaterialInputs.PixelDepthOffset = 0.00000000;
Так как это материал постобработки, нам важен только EmissiveColor. Как вы видите, его значение — это значение Local1. Переменные вида LocalX — это локальные переменные, которые функция использует для хранения промежуточных значений. Если посмотреть чуть выше выходов, то можно увидеть, как движок вычисляет каждую локальную переменную.
MaterialFloat4 Local0 = SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 14), 14, false);
MaterialFloat3 Local1 = (Local0.rgba.rgb + Material.VectorExpressions[1].rgb);
Конечная локальная переменная (в нашем случае это Local1) обычно является вычислением-«пустышкой», поэтому его можно проигнорировать. Это означает, что функцией для нода SceneTexture является функция
SceneTextureLookup()
.Теперь, когда у нас есть нужная функция, давайте её протестируем.
Использование функции SceneTextureLookup
Для начала зададимся вопросом — что делают параметры? Вот сигнатура
SceneTextureLookup()
:float4 SceneTextureLookup(float2 UV, int SceneTextureIndex, bool Filtered)
Вот, что делает каждый параметр:
- UV: координата UV, из которой нужно выполнять сэмплирование. Например, UV с координатами (0.5, 0.5) будет сэмплировать средний пиксель.
- SceneTextureIndex: определяет, из какой текстуры сцены выполнять сэмплирование. Ниже показана таблица каждой текстуры сцены и её индекса. Например, для сэмплирования Post Process Input 0 мы будем использовать в качестве индекса 14.
- Filtered: определяет, должна ли применяться к текстуре сцены билинейная фильтрация. Обычно имеет значение false.
Для тестирования мы будем выводить World Normal. Перейдите в редактор материалов и создайте нод Custom с именем Gaussian Blur. Затем вставьте в поле Code следующее:
return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 8), 8, false);
Так мы выведем в текущий пиксель World Normal.
GetDefaultSceneTextureUV()
будет получать UV текущего пикселя.Примечание: до версии 4.19 можно было получать UV, передавая в качестве входа нод TextureCoordinate. В 4.19 правильным способом будет использованиеGetDefaultSceneTextureUV()
и передача нужного индекса.
Это пример того, как написанный вручную код HLSL может быть несовместим с разными версиями Unreal.
Далее отсоедините нод SceneTexture. Затем присоедините Gaussian Blur к Emissive Color и нажмите на Apply.
На этом этапе вы получите следующую ошибку:
[SM5] /Engine/Generated/Material.ush(1410,8-76): error X3004: undeclared identifier 'SceneTextureLookup'
Она сообщает нам, что в нашем материале не существует
SceneTextureLookup()
. Почему же это работает при использовании нода SceneTexture, но не работает с нодом Custom? При использовании SceneTexture компилятор включает в код определение SceneTextureLookup()
. Так как мы его не используем этот нод, то не можем использовать функцию.К счастью, решить эту проблему просто. Выберите для нода SceneTexture ту же текстуру, из которой мы выполняем сэмплирование. В нашем случае нужно выбрать WorldNormal.
Затем соедините его с Gaussian Blur. Наконец, нам нужно задать контакту входа имя, отличающееся от None. В этом туториале мы выберем SceneTexture.
Примечание: на момент написания статьи в движке присутствовал баг: если текстуры сцены не одинаковы, то происходит сбой редактора. Однако поскольку это работает, мы можем спокойно изменить текстуру сцены в ноде Custom.
Теперь компилятор включит определение
SceneTextureLookup()
.Нажмите Apply и вернитесь в основной редактор. Теперь вы увидите нормаль мира для каждого пикселя.
Пока редактировать код в ноде Custom вполне удобно, потому что мы работаем с небольшими фрагментами. Однако когда наш код начнёт разрастаться, то поддерживать его будет сложнее.
Для оптимизации рабочего процесса Unreal позволяет нам добавлять внешние файлы шейдеров. Благодаря этому мы можем писать код в собственном текстовом редакторе, а затем возвращаться обратно в Unreal для компиляции.
Использование внешних файлов шейдеров
Для начала нам нужно создать папку Shaders. Unreal будет просматривать эту папку, когда вы будете использовать в ноде Custom директиву
#include
.Откройте папку проекта и создайте новую папку Shaders. Папка проекта должна выглядеть примерно так:
Затем перейдите в папку Shaders и создайте новый файл. Назовите его Gaussian.usf. Он будет нашим файлом шейдера.
Примечание: файлы шейдеров должны иметь расширение .usf или .ush.
Откройте Gaussian.usf в текстовом редакторе и вставьте показанный ниже код. После каждого изменения сохраняйте файл.
return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 2), 2, false);
Это тот же код, что и раньше, но он выводит Diffuse Color.
Чтобы Unreal смог распознать новую папку и шейдеры, нам нужно перезапустить редактор. После перезапуска перейдите к GaussianBlur. Затем повторно откройте PP_GaussianBlur и замените код в Gaussian Blur на следующий:
#include "/Project/Gaussian.usf"
return 1;
Теперь после компиляции компилятор заменит первую строку содержимым Gaussian.usf. Заметьте, что мы не должны заменять
Project
именем своего проекта.Нажмите на Apply и вернитесь в основной редактор. Теперь вместо нормалей мира вы увидите диффузные цвета.
Теперь, когда всё настроено для удобной разработки шейдеров, настало время создания гауссова размытия (Gaussian blur).
Примечание: так как это не туториал по гауссовому размытию, я не буду подробно его объяснять. Если вы хотите узнать подробности, то изучите статьи Gaussian Smoothing и Calculating Gaussian Kernels.
Создание гауссова размытия
Как и в туториале про toon-контуры, в этом эффекте будет использоваться свёртка. Конечный выход — это среднее значение всех пикселей в ядре.
При обычном линейном размытии все пиксели имеют одинаковый вес. При широком размытии это приводит к артефактам. Гауссово размытие позволяет избежать этого, снижая вес пикселя при отдалении от центра. Это придаёт больше важности центральным пикселям.
При использовании нодов материалов свёртка неидеальна из-за большого количества необходимых сэмплов. Например, при ядре 5×5 нам потребуется 25 сэмплов. Удвойте размеры до 10×10, и количество сэмплов увеличится до 100! На этом этапе граф нодов будет походить на тарелку со спагетти.
И здесь нам на помощь приходит нод Custom. С его помощью мы можем написать небольшой цикл
for
, сэмплирующий каждый пиксель в ядре. Первым шагом будет задание параметра, управляющего радиусом сэмплирования.Создание параметра радиуса
Сначала вернёмся к редактору материалов и создадим новый ScalarParameter под названием Radius. Зададим ему значение по умолчанию 1.
Радиус определяет уровень размытия изображения.
Далее создадим новый вход для Gaussian Blur и назовём его Radius. Затем создадим нод Round и соединим всё следующим образом:
Round необходим для того, чтобы размеры ядра всегда были целыми числами.
Теперь пора приступить к кодингу! Так как для каждого пикселя нам нужно вычислять гауссово размытие дважды (вертикальное и горизонтальное смещения), то логично будет превратить это в функцию.
При использовании нода Custom мы не можем создавать функции стандартным образом, потому что компилятор копирует наш код в функцию. Так как мы не можем определять функции внутри функции, то получим ошибку.
К счастью, мы можем воспользоваться таким копипастингом для создания глобальных функций.
Создание глобальных функций
Как сказано выше, компилятор в буквальном смысле копипастит текст из нода Custom в функцию. То есть если у нас есть следующее:
return 1;
то компилятор вставит это в функцию CustomExpressionX. Он даже не поставит отступ!
MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
return 1;
}
Посмотрите, что произойдёт, если мы используем такой код:
return 1;
}
float MyGlobalVariable;
int MyGlobalFunction(int x)
{
return x;
Сгенерированный HLSL превратится в такой:
MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
return 1;
}
float MyGlobalVariable;
int MyGlobalFunction(int x)
{
return x;
}
Как видите,
MyGlobalVariable
и MyGlobalFunction()
не находятся внутри функции. Это делает их глобальными, то есть мы можем использовать их где угодно.Примечание: Заметьте, что во входном коде отсутствует последняя скобка. Это важно, потому что компилятор вставляет в конец скобку. Если оставить скобку, то в результате у нас будет две скобки и мы получим ошибку.
Теперь давайте воспользуемся этим поведением, чтобы создать гауссову функцию.
Создание гауссовой функции
Функция для упрощённой гауссианы в одном измерении выглядит так:
В результате она даёт колоколообразную кривую, получающую на входе значения в интервале приблизительно от -1 до 1. На выход она подаёт значение от 0 до 1.
В этом туториале мы поместим гауссову функцию в отдельный нод Custom. Создайте новый нод Custom и назовите его Global.
Затем замените текст Code на следующий:
return 1;
}
float Calculate1DGaussian(float x)
{
return exp(-0.5 * pow(3.141 * (x), 2));
Calculate1DGaussian()
— это упрощённая 1D-гауссиана в виде кода.Чтобы эта функция была доступна, нам нужно использоватьGlobal где-нибудь в графе материала. Проще всего это сделать, умножив Global на первый нод графа. Так мы гарантируем, что глобальные функции определяются до того, как мы используем их в других нодах Custom.
Для начала задайте для Output Type нода Global значение CMOT Float 4. Мы должны сделать это, потому что мы будем выполнять умножение на SceneTexture, имеющую тип float4.
Далее создадим Multiply и соединим всё следующим образом:
Нажмите на Apply, чтобы выполнить компиляцию. Теперь все последующие ноды Custom смогут использовать функции, определённые в Global.
Следующим шагом будет использование цикла
for
для сэмплирования каждого пикселя в ядре.Сэмплирование нескольких пикселей
Откройте Gaussian.usf и замените код на следующий:
static const int SceneTextureId = 14;
float2 TexelSize = View.ViewSizeAndInvSize.zw;
float2 UV = GetDefaultSceneTextureUV(Parameters, SceneTextureId);
float3 PixelSum = float3(0, 0, 0);
float WeightSum = 0;
Вот, для чего нужна каждая из переменных:
- SceneTextureId: содержит индекс текстуры сцены, которую мы хотим сэмплировать. Благодаря ей мы не обязаны жёстко задавать индекс в вызовах функций. В нашем случае индекс используется для Post Process Input 0.
- TexelSize: содержит размер тексела. Используется для преобразования смещений в UV-пространство.
- UV: UV для текущего пикселя
- PixelSum: используется для накопления цвета каждого пикселя в ядре
- WeightSum: используется для накопления веса каждого пикселя в ядре
Далее нам нужно создать два цикла
for
, один для вертикальных смещений, другой для горизонтальных. Добавьте под списком переменных следующее:for (int x = -Radius; x <= Radius; x++)
{
for (int y = -Radius; y <= Radius; y++)
{
}
}
Таким образом мы создадим сетку, центрированную на текущем пикселе. Её размеры задаются как 2r + 1. Например, если радиус равен 2, то сетка будет иметь размеры (2 * 2 + 1) на (2 * 2 + 1) или 5×5.
Далее нам нужно аккумулировать цвета и веса пикселей. Для этого добавим следующий код во внутренний цикл
for
:float2 Offset = UV + float2(x, y) * TexelSize;
float3 PixelColor = SceneTextureLookup(Offset, SceneTextureId, 0).rgb;
float Weight = Calculate1DGaussian(x / Radius) * Calculate1DGaussian(y / Radius);
PixelSum += PixelColor * Weight;
WeightSum += Weight;
Вот, что делает каждая из строк:
- Вычисляет относительное смещение сэмплируемого пикселя и преобразует его в UV-пространство
- На основе смещения сэмплирует текстуру сцены (в нашем случае это Post Process Input 0)
- Вычисляет вес сэмплированного пикселя. Для вычисления 2D-гауссианы достаточно перемножить две 1D-гауссианы. Деление на
Radius
производится потому, что упрощённая гауссиана ожидает на входе значение от -1 до 1. Это деление нормализуетx
иy
в нужном интервале. - Прибавляет взвешенный цвет к
PixelSum
- Прибавляет вес к
WeightSum
Наконец, нам нужно вычислить результат, являющийся взвешенным средним. Для этого добавим в конец файла (за пределами циклов
for
) следующее:return PixelSum / WeightSum;
И так мы реализовали гауссово размытие! Закройте Gaussian.usf и вернитесь в редактор материалов. Нажмите на Apply и закройте PP_GaussianBlur. Используйте PPI_Blur, чтобы протестировать разные радиусы размытия.
Примечание: иногда кнопка Apply может быть неактивна. Просто внесите не влияющее ни на что изменение (например, переместите нод), и она снова станет активной.
Ограничения
Несмотря на всю мощь нода Custom, у него есть свои недостатки. В этом разделе я расскажу о некоторых ограничениях и изъянах его использования.
Доступ к рендерингу
Ноды Custom не могут получать доступ ко многим частям конвейера рендеринга, например, к информации об освещении и векторах движения. При использовании прямого рендеринга ситуация немного отличается.
Совместимость версий движка
Код HLSL, написанный в одной версии Unreal, не обязательно заработает в другой. Как сказано в туториале, до версии 4.19, для получения UV текстур сцены мы могли пользоваться TextureCoordinate. В версии 4.19 для этого нужно использовать
GetDefaultSceneTextureUV()
.Оптимизация
Вот, что говорит Epic про оптимизацию:
Использование нодов Custom делает невозможным сворачивание констант и может приводить к значительно большему количеству инструкций по сравнению с аналогичной версией, построенной на нодах! Сворачивание констант — это оптимизация, используемая UE4 для снижения при необходимости количества шейдерных инструкций.
Например, цепочка выраженийTime >Sin >Mul by parameter > Add к чему-то
может и будет свёрнута движком UE4 в одну инструкцию, конечное Add. Это возможно, потому что все входы этого выражения (Time, parameter) являются константами на протяжении всего вызова отрисовки, то есть не меняются для каждого пикселя. UE4 не может сворачивать ничего в ноде Custom, что может приводит к созданию менее эффективных шейдеров по сравнению с аналогичными версиями на основе готовых нодов.
Поэтому лучше всего использовать нод Custom только тогда, когда он предоставляет доступ к функционалу, недоступному в готовых нодах.
Куда двигаться дальше?
Готовый проект можно скачать здесь.
Если вы хотите глубже разобраться в ноде Custom, то рекомендую изучить блог Райана Брука. У него есть посты, подробно объясняющие использование нода Custom для создания raymarching и других эффектов.