Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

И сотворил Бог землю.. Только в нашей ситуации не Бог, а графический процессор, и не землю, а изображение земли 👨‍🎨. Впрочем, если глубоко не вдаваться в детали, можно писать шейдеры и даже не подозревать какая это часть рендера и что за нее отвечает.

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

Пример результата:

Иллюстрация графического конвейера
Иллюстрация графического конвейера

Сегодня в нашей ситуации мы не будем вдаваться в технические подробности о работе шейдеров, а только рассмотрим как правильно организовать структуру и написать не просто шейдер, а шейдер который можно будет использовать на лоу-энд девайсах. На самом деле это не просто слова, а супер важная часть любой разработки - оптимизация! И делать мы это будем естественно не кодом, а как умные и прогрессивные пользователи интернет, с помощью Shader Graph(можно использовать нативный и кастомный от Amplify)

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

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

Этап 1

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

Простой Lit Shader
Простой Lit Shader

Тут должно быть все понятно и ничего сложного. Теперь можно сделать переменные для контроля параметров шейдера. По умолчанию они отмечены как Exposed(доступные в инспекторе), что в целом нам и нужно на начальных этапах, но и из за этого эти переменные являются локальными, а нам нужно в будущем сделать их глобальными. Exposed мы уберем, когда у нас появится скрипт-контроллер системой погоды.

Common Shader входные параметры
Common Shader входные параметры

Вкратце:

Сверху - параметры для общего шейдера

WetController - параметры для контроля из кода(степень дождя и влажности)

WetData - детальная настройка визуала

Теперь можно сделать сабграф для дождя, в который мы прокидываем все карты. Для чего все это сделано? Таким образом мы можем "реюзать" уже готовые решения. То есть, если мы вдруг решим, что должен намокать не только террейн, но и игрок, мы просто будем вставлять этот модуль, и в целом работать с этим функционал изолированно, что тоже является большим плюсом.

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

Common Shader
Common Shader

Вернемся немного к теории, первый признак хорошо оптимизированного шейдера - это максимальное ре-использование готовых карт и текстур и минимизирование создание новых. Это я пишу для того, чтобы вы избегали начальных ошибок, когда для каждой карты (Smoothness, Specular, Diffuse, Normal, AO и т.д) делается какая та своя уникальная текстура. Гораздо лучшим вариантом будет создания единой универсальной маски, которая в зависимости от карты может немного модернизироваться или использоваться ранняя ее итерация. Конечно все зависит от конкретного случая, но всегда надо держать в голове, что надо стремиться к минимализму и реюза существующих карт.

Этап 2. Генерация маски

На просторах интернета я нашел уже анимированную текстуру, которая представляет из себя атлас 3х3 с последовательной анимацией капель. Такую текстуру нужно помечать как 2D Array в инспекторе и выставлять размер сетки. Анимированную текстуру можно сделать и самому, главное, чтобы она была зацикленная.

Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

Рассмотрим механизм: сначала мы подготовим UV пространство для текстуры и используем для этого XZ плоскость мира. Для этого мы используем ноду Position. Это делается для того, чтобы текстуры бесшовно проецировались на плоскость и имели одинаковый размер независимо от скейла объекта. Так же можно заметить что я сразу же умножаю Position на tile - это для контролирования размера текстуры в мире. И мы будем использовать эти значения для всех текстур, с относительной корректировкой. Дальше мы анимируем текстуру капель при помощи ноды Time и Fraction.

Fraction - берет остаток от числа.

Умножение перед Fraction - управление скоростью анимации.

Умножение после Fraction - указываем количество кадров, потому что index в Sample Array у нас от 0 до 8. (от 1 до 9 кадра)

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

Использование ноды Blend может быть спорным в некоторых ситуациях. На самом деле за ней скрываются различные математические формулы разной сложности. Тот же Multiply ничем не отличается от дефолтного математического Multiply, но дает дополнительную возможность использование маски через функцию Lerp. Подробнее изучить что скрывается за каждым режимом наложения ТУТ

Вообще говоря о математических формулах, советую использовать как можно меньше сложных вычислений, таких как Pow, Sin, Log и т.п. Даже банальное использование Divide считается плохой практикой, если можно использовать Multiply, потому что ваша видеокарта на аппаратном уровне все равно переведет деление в умножение. Оптимизированней сделать это заранее.

Полезный совет: Если у вас есть 2 текстуры и нужно проявить ее по какой то маске или смешать друг с другом, лучше использовать ноду Lerp. Lerp - сама по себе является линейной интерполяцией между двумя значениями, поэтому применяется очень часто. С помощью ее можно делать как и различные анимации, так и смешивать цвета, а так же использовать вместо branch node. (говорят, это более производительный вариант)

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

  1. Бесконечный скейл без потери качества
  2. У некоторых шумов(например voroni), есть входной параметр благодаря чего его можно анимировать и создавать классные эффекты. Но иногда это можно заменить особой техникой - манипуляции с разверткой для достижения эффекта Distortion.

Этап 3. Normal Map и поверхность луж

Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

На этом этапе нам нужно ограничить рендер поверхность луж в зависимости от плоскости. Наверное, можно подумать, что это происходит еще на первом этапе когда мы выбираем XZ плоскость для проецирования, однако при таком раскладе на другие стороны меша все равно будет проецироваться текстура, только растянутая по бесконечности по оси Y. Поэтому чтобы скрыть эту ненужную часть, мы будем использовать направления нормали как маску. Для этого мы берем Y координату нормали объекта в World координатах. В таком случае, если нормаль направлена вверх - поверхность будет белой и чем больше в иную сторону - тем темнее. Тут можно поиграться со степенью отображения в зависимости от отклонения, но в данном примере и моей игре подходит и стандартный вариант. Далее используем эту маску для сокрытия ненужных частей в нашей маске луж через Multiply.

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

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

Этап 4. Смешивание карт

Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

Тут финальный этап, мы берем исходные карты поступившие из Common Shader и сами решаем как нам лучше их смешивать. В данном случае для дифуз карты я решаю их затемннить, Smoothness и Specular добавить белого. Но дальнейшее применение маски остается за вами. Тут можно придумать много еще разных комбинаций и пробовать дополнять и усложнять шейдер(но без фанатизма, конечно 😁)

По итогу за управлением степени дождя у нас отвечают только 2 слайдера, которые просто меняют яркость двух текстур.

Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

Шейдер не является каким то сложным и комплексным, но демонстрирует фундаментальные принципы как придерживаться структуры и базовой оптимизации. Дальше - просто расширение цепочки и добавления новых фич. Таких сабграфов можно будет сделать десятки подключив их используя keywords и не упасть в обморок от количества нод и беспорядочных связей между ними.

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

А теперь тезисно:

  1. Минимизировать сложные математические операции(в том числе нойзы)
  2. Разбивать функционал на сабграфы.
  3. Реюз текстур и отрисованных карт.
  4. Использовать меньше шейдеров в проекте
  5. Следить за организацией проекта
  6. Цель должна оправдывать средства, избавляться от сомнительных вычислений которые дают неочевидный результат
  7. Максимизировать запеченные решения, процедурные минимизировать
  8. Хоть это и не было упомянуто, но желательно чб карты хранить в разных каналах(RGBA) одной текстуры. Это требует более тщательной подготовки ассетов, поэтому было пропущено в статье. Но это положительно влияет как на производительность, так и на память.

Я не сказал пару слов по поводу keywords, а все потому, что их использование не всегда оправдано. Иногда оптимизированней иметь постоянно включенную фичу, чем два шейдер варианта. Тут все зависит от ситуации и проекта. Если вам не знакомо понятие keywords - это, грубо говоря, параметр, отвечающий за то, какая версия шейдера будет использоваться на материале. Она представлена в виде bool или enum значением и делает разветвление в шейдере. Поэтому, условно, когда нет дождя, чтобы избегать лишних просчетов, можно отключить фичу. Но, тут уже решаете и изучайте сами.

Итог:

Небольшие улучшения

Можно достичь эффекта нелинейного появления луж путем наложения еще одной текстуры в качестве маски-проявителя. Это будет работать как линейная интерполяция от 0 до 0.5 и от 0.5 до 1, где на 0 и 1 текстура будет черной и белой соответственно, а на 0.5 будет видна наша исходная маска. Делается это при помощи упомянутой ранее ноды Lerp.

Как писать шейдеры по уму. Шейдер дождливой погоды [Shader Graph]

Этап 5. Система управления дождем

На этом этапе не буду долго задерживаться, но можете использовать Shader.SetGlobal.. для контролирования глобальных параметров шейдеров. Название параметра по дефолту использует "_" + название вашего параметра. Его можно посмотреть в шейдер графе в поле Reference.

И не забываем убрать галочку Exposed

[ExecuteAlways] public class RainController : MonoBehaviour { [Range(0f, 1f)] public float _rainIntensity = 0; [Range(0f, 1f)] public float _wetIntensity = 0; private float _rainIntensityBuffer; private float _wetIntensityBuffer; void Update() { if (_rainIntensityBuffer != _rainIntensity) { _rainIntensityBuffer = _rainIntensity; Shader.SetGlobalFloat("_RainIntensity", _rainIntensityBuffer); } if(_wetIntensityBuffer != _wetIntensity) { _wetIntensityBuffer = _wetIntensity; Shader.SetGlobalFloat("_WetIntensity", _wetIntensityBuffer); } } }

ExecuteAlways используется для того, чтобы управление работало вне плей мода. Скрипт вешается на любой объект иии.. готово! Можно так же управлять этим контроллером через аниматор и как вам только вздумается.

Заключение:

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

На этом все, дорогие друзья. Спасибо за внимание.

Девблог автора:

33
4
12 комментариев