Как написать сложный пользовательский шейдер для Unreal Engine

Не привлекая внимания санитаров.

Как написать сложный пользовательский шейдер для Unreal Engine

При написании маршировщика лучей для анрила я столкнулся с двумя противниками: сам движок и язык HLSL. Классные ребята, в целом, но издевательств над собой не терпят.

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

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

Но обо всех нюансах давайте по порядку.

Глобальные функции

Сразу же начнём со взлома кастомной ноды, который я подсмотрел у некого viclw17:

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

Генерация компилятором функции CustomExpressionX нам ещё пригодится ниже, запомните это название и аргумент FMaterialPixelParameters.

Глобальные переменные

В данном примере уже было показано их объявление, но стоит отметить 2 вещи. Объявить глобальную переменную или структуру без функции в конце подобным хаком не выйдет. Можете просто добавить пустышку, это ни на что не повлияет. И самое главное, для запоминания значений между разными операциями нам потребуется модификатор static.

Отметьте локальную переменную так, чтобы она инициализировалась один раз и сохранялась между вызовами функции. Если объявление не включает инициализатор, значение устанавливается равным нулю.

Документация HLSL

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

Можно задать и массивы переменных. А вот размер массива динамически задать нельзя никак. Если вы хотите изменяемый пользователем размер, максимум, можно сделать где-нибудь в удобном месте константу. Например, в секции additional defines.

Пользовательские функции

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

Как написать сложный пользовательский шейдер для Unreal Engine

Самой такой функции на вход можно подать что угодно, это нужно только для компилятора, вызывать всё равно мы собираемся сами со значениями, полученными в ходе выполнения программы. Хотя технически можно использовать и вычисленное значение со входа, к которому мы подключаем нашу функцию. До наших аргументов обязательно подаётся аргумент Parameters, который вы уже тоже видели. В глобальных функциях его нет, но можно просто создать какую-нибудь неинициализированную переменную FMaterialPixelParameters и передавать её. Ну или передавать в функцию аргументом из кастомной ноды, если вы как-либо эти параметры планируете использовать.

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

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

Объединяем вместе

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

Как написать сложный пользовательский шейдер для Unreal Engine

Вложенные циклы и рекурсии.

Сразу скажу, что все пути здесь ведут к макросам.

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

#define fib(name,name0) \ int name(int n){ \ if (n <= 1) \ return n; \ return name0(n - 1) + name0(n - 2); \ } int fib0(int n) { return -1; } fib(fib1,fib0) fib(fib2,fib1) fib(fib3,fib2) fib(fib4,fib3) fib(fib5,fib4) fib(fibonacci,fib5)

Циклы поддерживаются и даже хорошо работают. Даже вложенные один в другой. Но не всегда. Я впервые встретился с таким, но вложенный цикл, при банальном увеличении итераций с двух до трёх, обваливал общую производительность раз в 10.

Итог сидения на форумах таков: неизвестно почему, но иногда компилятор ведёт себя странно)) Что подтверждается ручным разворачиванием цикла. Тогда хоть 3, хоть 5 вызовов подряд, а фепесы на месте.

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

Тут, конечно, извиняюсь, что в моменте не стал это нормально дебажить и просто сделал так, как заработало.

Мелочи

Шаблоны не поддерживаются. У нас тут всё-таки язык си минус, а не си плюс.

Условные операторы ощутимо тратят ресурсы. Но не настолько, чтобы избегать их всеми силами. К слову, выполняться код может как после проверки условия, так и одновременно для всех веток. Для этого есть ключевые слова. Ошибочное выполнение во втором случае просто отбрасывается.

Аккуратнее с проверками не скалярных значений. Для совместимости и всех гарантий лучше написать громоздкую конструкцию, где каждое значение вектора обрабатывается отдельно. Одно if на всех или select могут отработать не всегда правильно или вообще не скомпилироваться при смене версий чего угодно. Иногда можно применить any, тут проблем не должно быть.

Тригонометрия тоже что-то весит. Я встречал разные замечания по этому поводу. Кто-то говорит, что уже давно всё выполняется за 0 циклов, но это неправда. Особенно для чего-то забористее синусов/косинусов. Квадратный корень тоже жручий. Можете запустить для сравнения 100 раз за кадр вычисление длины строго по Евклиду и какое-нибудь приближение без корней, выводы делаем сами, как грится.

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

На этом всё. Теперь вы можете сделать свой крутой шейдер прямо внутри новенького блестящего анрил енжина и очень гордиться собой. Потому что шейдер будет очень выпендрёжный и производительный. Или нет.

33
9
1
31 комментарий