Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

В этой статье я хочу рассказать об основах создания стилизованных эффектов для игр. Процесс создания я буду показывать в Unity Shader Graph (версия 2019 LTS), чтобы при желании их можно было воспроизвести в Unreal Blueprint и любых других нодовых редакторах шейдеров вне зависимости от движка. Если вы знакомы с HLSL и предпочитаете писать шейдеры текстом, вам также не составит труда перевести это в текстовую форму.

Что будем делать?

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

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

В чём заключается стилизация?

Когда говорят о спецеффектах, в преобладающем большинстве случаев говорят о системах частиц.

Давайте возьмём простую систему частиц и рассмотрим, каким образом мы можем заставить частицы "красиво" исчезать. В качестве примера я буду использовать вот такую текстуру облака:

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

По умолчанию частицы просто пропадают по истечению их срока жизни.

Не делайте так.

Это некрасиво и слишком сильно бросается в глаза своей недоделанностью и дешевизной.

С помощью встроенных инструментов, которые предоставляет Shuriken (система частиц в Unity) можно "красиво" убрать частицу двумя основными способами: через прозрачность, через уменьшение размера, или через совмещение этих двух техник.

Убирание через прозрачность

Такое делается с помощью модуля Color over Lifetime.

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

Убирание через уменьшение размера

Делается с помощью модуля Size over Lifetime.

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

Обратите внимание на белые облачка.

Но есть ещё один приём, который не поставляется "из коробки" и про который я расскажу в этой статье. Это эффект растворения, в оригинале Dissolve.

Убирание через растворение

Такой приём встречается повсеместно в играх в стиле cel-shading и играх с аниме стилистикой. Растворение не работает "из коробки" и нам придётся реализовать его самим. Но сначала немного теории.

В чём принцип эффекта растворения?

Суть этого метода заключается в том, что пиксели на текстуре частицы становятся прозрачными не все одновременно, как в первом описанном методе, а постепенно. При этом растворение может иметь жёсткую чёткую границу, а может быть размыта. Нам нужно научиться контролировать этот процесс и "говорить" частице в какой последовательности "растворять" пиксели и насколько размывать границу.

Обычно для этого используется либо один из каналов текстуры (обычно, альфа), либо отдельная текстура, так называемая маска растворения (dissolve mask). Для простоты понимания и большей наглядности мы будем использовать отдельную текстуру.

Обычно маска растворения представляет собой некий чёрно-белый паттерн (точнее не ч/б, а оттенки серого). Он может быть сгенерирован в Photoshop или After Effect (различные типы шума), либо нарисован вручную, если вам нужно больше контроля над процессом растворения.

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

Теперь, глядя на эту маску, нам надо понять, как на её основе будут "растворяться" пиксели.

Правило простое.

Чем темнее пиксель на маске, тем раньше он будет растворён. Чёрные пиксели растворяются первыми, белые пиксели - последними. Все остальные - в зависимости от оттенка серого.

Если вам сложно представить это - не беда. Есть инструменты, которые позволяют это визуализировать. Например, в Photoshop можно наложить Adjustment Layer с функцией Threshold и двигая ползунок получить превью процесса растворения.

Когда ползунок в крайнем левом положении - это начало жизни частицы, когда в крайнем правом - момент "смерти" частицы.

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

Настало время взяться непостредственно за шейдер.

Подготовка

Давайте создадим в Unity новую сцену. На сцене создадим Quad и разместим его напротив камеры.

Теперь создадим новый Unlit Graph. В параметрах master-ноды выберем Surface = Transparent. Внутри создадим следуюшие properties

  • Основная текстура.
  • Маска растворения.
  • Ползунок Progress, с помощью которого будем контролировать процесс в инспекторе. Диапазон ползунка между 0 и 1.
Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Подключим цвет основной текстуры и альфу основной текстуры к master-ноде.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Создадим на основе графа материал и назначим его нашему quad'у.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Теперь давайте внутри шейдера проделаем то же самое, что мы видели в фотошопе. В этом нам поможет функция Step.

Step

Эта функция принимает на вход 2 параметра:

  • VALUE- значение, которое мы хотим смодулировать
  • EDGE - пороговое значение

Принцип работы такой:

Если VALUE < EDGE возвращается 0, в противном случае возвращается 1.

Таким образом, результатом всегда будет либо 0, либо 1. Другими словами, либо чёрный пиксель, либо белый. Именно то, что нам нужно.

Так выглядит график функции Step.
Так выглядит график функции Step.

Параметр EDGE это и есть тот самый ползунок, который мы таскали в фотошопе. В случае шейдера, этим параметром будет ползунок Progress, который мы создали заранее, а параметром VALUE будет цвет пикселя маски растворения.

Но мы не можем взять цвет пикселя, потому что это вектор, состоящий из 4 компонентов (красный, зелёный, синий, альфа). Мы не можем сравнивать вектор и число. Вместо этого мы возьмём значение красного канала текстуры.

Давайте создадим ноду Step на нашем графе и подключим к ней описанные выше входные значения.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Давайте временно отключим цвет основной текстуры от master-ноды и подключим результат Step-ноды ыместо него, чтобы проверить как это работает.

Результатом функции Step является число, а нам на вход Color нужен вектор, поэтому нужно его предварительно создать.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Теперь, двигая ползунок в инспекторе материала, мы видим как "растворяется" белый квадрат.

Точно также как и в Photoshop'e.

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

Для этого давайте перемножим альфа-канал основной текстуры с результатом функции Step и уже это произведение воткнём в альфа-канал master-ноды.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Вот так теперь выглядит итоговая анимация растворения.

Таким образом мы реализовали самый простой вариант "растворения" с жёсткими границами. Давайте посмотрим, как мы можем их "смягчить".

Для этого вместо функции Step мы будем использовать SmoothStep.

SmoothStep

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

Если Step принимает на вход 2 параметра, то SmoothStep принимает 3 параметра:

  • VALUE - значение, которое будет модулироваться
  • EDGE0 - "нижняя" граница плавного перехода
  • EDGE1 - "верхняя" граница плавного перехода

Правило следующее:

Если VALUE < EDGE0 возвращается 0.

Если VALUE > EDGE1 возвращается 1.

Если VALUE находится посередине, то возвращается значение между 0 и 1 на S-кривой.

Что же такое S-кривая?

Если мы посмотрим на формулу, которая работает "под капотом" у этой функции, то мы увидим следующее:

// В интервале [0; 1] y = x * x * (3 - 2 * x);
График функции выглядит так.
График функции выглядит так.

Если мы спроецируем эти значения на чёрно-белый градиент, то получится вот такая картина

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Мы видим, что SmoothStep возвращает не линейную интерполяцию между 0 и 1. В терминах анимации можно сказать, что кривая имеет Ease-In и Ease-Out ("разгон" и "торможение" на входе и выходе).

Другое важное отличие от линейной интерполяции (Lerp) заключается в том, что диапазон значений на выходе всегда ограничен нулём и единицей, в то время как линейная интерполяция может производиться между прозвольными значениями.

Вообще, очень рекомендую разобраться и как следует понять в чём принципиальные отличия Lerp и SmoothStep и как одно может использоваться для другого, например, как использовать выходное значение SmoothStep в качестве параметра t для Lerp.

Также, очень рекомендую вот это видео на канале The Art of Code, где технический художник из Канады подробно рассказывает про SmoothStep, как его самостоятельно вывести, какие у него области применения и ещё много чего интересного.

Теперь давайте применим эти новые знания в нашем шейдере.

Для начала добавим ещё один параметр Falloff который будет отвечать за ширину градиента размытия границы.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

На вход VALUE всё также будем подавать красный канал маски размытия. Теперь надо разобраться, что будет выступать в качестве EDGE0 и EDGE1.

Одним из них должен быть наш параметр Progress. Если мы хотим, чтобы при Progress = 0,5 все пиксели, у которых значение красного канала было меньше 0,5 были чёрными (соответственно, "растворялись"), значит, это должна быть нижняя граница градиента, соответственно, EDGE0.

EDGE1 в таком случае будет Progress + Falloff.

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

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Теперь меняя в инспекторе значение Falloff и двигая ползунок Progress мы можем наблюдать растворение с мягкими границами.

Но есть один неприятный момент.

Если выставить Falloff достаточно большим, а Progress сдвинуть в ноль, мы все равно будем видеть частичное растворение, хотя подразумевается, что при нуле растворения нет.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Если мы ещё раз посмотрим на нашу формулу, то причина появления этого артефакта станет понятна и логична. В соответствии с нашей формулой, значение Progress - это нижняя граница градиента и пиксели на ней черные (в конечном варианте эффекта - прозрачные).

Мы могли бы использовать Progress в качестве входного параметра EDGE1 (верхняя граница градиента), а в качестве EDGE0 использовать формулу Progress-Falloff, но тогда при значении Progress = 1 не все пиксели будут до конца растворяться. Простой перестановкой параметров тут не обойтись.

Что бы исправить этот артефакт, нам понадобится ещё один крайне полезный инструмент под названием Remap.

Remap

На русский язык этот термин можно перевести как переназначение или перераспределение.

На вход этой функции подаётся 5 параметров:

  • VALUE - значение, которое нужно переназначить
  • IN_MIN - минимальное значение начального диапазона
  • IN_MAX - максимальное значение начального диапазона
  • OUT_MIN - минимальное значение нового диапазона
  • OUT_MAX - максимальное значение нового диапазона

На выходе мы получаем значение, переназначенное относительно нового диапазона.

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

Допустим, у нас есть начальный диапазон [0; 1] и внутри него значение 0,5. Нам нужно заремапить это значение на диапазон [0; 2].

Мы подаём эти 5 значений на вход функции. Функия смотрит, где в процентном соотношении находится наше значение внутри начального диапазона. В нашем случае 0,5 находится ровно посередине, т.е. соответствует 50%. После этого функция смотрит какое значение соответствует тому же проценту внутри нового диапазона и возвращает его как переназначенное значение. В нашем случае 50% между 0 и 2 будет равно 1. Это и будет наше переназначенное значение на выходе.

Чуть более сложный пример.

  • Начальный диапазон [0; 2,5], значение 0,25, новый диапазон [-1; 1]
  • 0,25 соответствует 10% между 0 и 2,5
  • 10% между -1 и 1 это -0,8. Это и будет новым значением.

Если выражаться чуть более заумно, то

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

Формула для рассчета выходного значения:

float Remap(float Value, float2 InMinMax, float2 OutMinMax) { return OutMinMax.x + (Value - InMinMax.x) * (OutMinMax.y - OutMinMax.x) / (InMinMax.y - InMinMax.x); }

Как Remap может нам помочь решить проблему с нежелательным растворением при значении Progress = 0?

Смотрите, нам нужно, чтобы при значении Progress = 0 все пиксели были белыми, то есть находились на верхней границе градиента. Затем, при движении ползунка в сторону единицы, должен постепенно начать проявляться градиент и, когда ползунок достигнет единицы, все пиксели должны быть черными, то есть "растворёнными".

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

И если мы теперь сделаем Remap значения Progress из начального диапазона [0; 1] в новый диапазон [-Falloff, 1] мы как раз получим то, что нам нужно.

Так как верхняя граница диапазонов Remap'а совпадает, там всё будет без изменений - все пиксели при Progress = 1 будут растворены.

Давайте реализуем эту логику в шейдере. Для этого добавим ноду Remap.

Создание стилизованных эффектов для игр. Часть 1: Step, SmoothStep, Dissolve

Финальный вариант

Давайте вернём цвет основной текстуры и посмотрим как это выглядит всё вместе.

Не забудьте поставить значение AlphaClipThreshold = 0 на master-ноде для корректного отображения полупрозрачности.
Не забудьте поставить значение AlphaClipThreshold = 0 на master-ноде для корректного отображения полупрозрачности.

Таким образом мы реализовать растворение с мягкими краями.

Что дальше?

В этой статье я объяснил принцип эффекта "растворения" и какие функции для этого используются.

Возможно, вы заметили, что я показывал всё на примере простого GameObject'а, а не на системе частиц. В системе частиц мы не можем вот так просто взять и покрутить ползунок для каждой отдельной частицы. Но в Unity есть встроенные инструменты, которые позволяют нам заставить каждую отдельную частицу "общаться" напрямую с шейдером и получать от него значения, актуальные для этой конкретной частицы.

В следующей части я расскажу про Custom Vertex Streams для системы частиц Shuriken, для чего они нужны и как с ними работать. Мы с вами адаптируем созданный нами шейдер, чтобы он работал с каждой отдельной частицей.

Не переключайтесь!

Данный цикл статей я пишу в рамках работы над своим проектом King, Witch and Dragon, который представляет собой метроидванию в стилистике cel-shading и о которой я веду дневник разработки.

Если вам понравилась статья и вы хотите поддержать проект, добавляйте King, Witch and Dragon в вишлист на Steam, это важно не только для моей мотивации, но и для алгоритмов Steam. Чтобы принять участие в обсуждении, вступайте в группу ВК, а также подписывайтесь на меня в Twitter и Instagram.

Спасибо за внимание!

152152
17 комментариев

шейдер конечно перегруженный вышел)
Есть ещё один вариант, который почему-то мало где упоминается. Правда он сложноват в восприятии и нужно покрутить/повертеть/посчитать что бы свыкнуться с ним, но он очень гибкий и лёгкий по перфомансу — saturate(value * mult + cut) ; value - нужная маска, mult - множитель, который получается из раскрытия всех скобок твоей формулы ремапа,  cut - тоже получается из раскрытия скобок твоей формулы ремапа. На выходе можно сильно дешевле рулить формой дизолва, правда чуть менее красиво, чем смусстепом, но обычно это не сильно видно.

2

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

Круто! Только в Марио не просто уменьшение размера. Насколько видно из гифки - там вообще 3д меш (отбрасывающий тень), который ещё и меняет свою текстуру каким-то шейдером, что выглядит круто)

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

1

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

Спасибо за статью, добавил в желаемое

1

Эффект вышел хуже, чем через обычное исчезновение.

Обожаю dissolve эффект. Собственно именно ради него и начал учить шейдеры.
Здорово, что в статье так подробно расписан агоритм в shader graph'e', я как раз думал подучить его, что то мне подсказывает что прототипировать на нём проще, чем с помощью HLSL. Хотя конечно глядя на конечную блок схему для такой задачи, что то подсказывает что нет.