В этом туториале я расскажу о том, как с помощью шейдеров воссоздать популярный спрайтовый эффект дудла в Unity. Если для вашей игры необходим такой стиль, то из этой статьи вы узнаете, как достичь его без отрисовки кучи дополнительных изображений.
Последние несколько лет этот стиль становится всё более популярным и активно используется в таких играх, как GoNNER и Baba is You.
В этом туториале рассказано всё необходимое, от основ кодирования шейдеров до используемой математики. В конце статьи есть ссылка на скачивание полного пакета Unity.
На создание этого туториала меня вдохновил успех Doodle Studio 95!.
Введение
В своём блоге я исследую довольно сложные темы, от математики инверсной кинематики до атмосферного рэлеевского рассеяния. Мне очень нравится делать такие трудные темы понятными для широкой аудитории. Но количество людей, заинтересованных в них и имеющих достаточный технический уровень, не так велико. Поэтому вас не должно удивлять, что самыми популярными статьями оказываются самые простые. Это относится и к недавнему твиту Ника Кэмана, в котором он показал как создать эффект дудла в Unity.
После 1000 лайков и 4000 ретвитов стало поняятно, что существует сильный спрос на более простые туториалы, которые могут изучать даже люди, почти не обладающие знаниями создания шейдеров.
Если вы ищете профессиональный и эффективный способ анимирования 2D-спрайтов с большой степенью художественного контроля, то я крайне рекомендую вам Doodle Studio 95! (см. GIF ниже). Здесь можно посмотреть на некоторые игры, в которых используется этот инструмент.
Анатомия эффекта дудла
Для воссоздания эффекта дудла нам сначала нужно понять, как он работает и какие техники в нём применяются.
Шейдерный эффект. Во-первых, мы хотим, чтобы этот эффект был как можно проще и не требовал дополнительных скриптов. Это возможно благодаря использованию шейдеров, сообщающих Unity, как рендерить 3D-модели (в том числе и плоские!) на экране. Если вы незнакомы с миром кодирования шейдеров, то вам стоит изучить мою статью A Gentle Introduction to Shaders.
Спрайтовый шейдер. В комплекте Unity есть множество типов шейдеров. Если вы пользуетесь предоставляемыми Unity 2D-инструментами, то, скорее всего, работаете со спрайтами. Если это так, то вам нужен Sprite Shader — особый тип шейдеров, совместимый с SpriteRenderer
Unity. Или же можно начать с более традиционного Unlit shader.
Смещение вершин. При отрисовке спрайтов вручную ни один кадр не будет полностью совпадать с другими. Мы хотим заставить спрайт каким-то образом «колебаться», чтобы симулировать этот эффект. В шейдерах есть очень эффективный способ для этого, применяющий смещение вершин. Это техника, позволяющая менять позицию вершин 3D-объекта. Если мы случайным образом сдвинем их, то получим желаемый эффект.
Время перемещения. Рисуемые от руки анимации обычно имеют низкую частоту кадров. Если мы хотим симулировать, допустим, пять кадров в секунду, то нужно менять позицию вершин спрайтов по пять раз в секунду. Однако Unity скорее всего будет выполнять игру с гораздо более высокой частотой обновления; возможно, с 30 или даже 60 кадрами в секунду. Чтобы спрайт не менялся по 60 раз в секунду, нужно поработать над компонентом таймингов анимации.
Шаг 1: дополняем спрайтовый шейдер
Если вы захотите создать новый шейдер в Unity, то выбор будет довольно ограниченным. Самым близким шейдером, с которого мы можем начать, будет Unlit Shader, хоть он и не обязательно лучше всего подходит для наших целей.
Если вы хотите, чтобы шейдер дудла был полностью совместим со SpriteRenderer
Unity, то нам нужно дополнить существующий Sprite Shader. К сожалению, мы не можем получить к нему доступ непосредственно из самого Unity.
Добраться до него можно, зайдя на страницу Unity download archive и скачав пакет Build in shaders для той версии Unity, с которой вы работаете. Это zip-файл, содержащий исходный код всех шейдеров, поставляемых с вашей сборкой Unity.
После скачивания распакуйте его и найдите в папке builtin_shaders-2018.1.6f1DefaultResourcesExtra
файл Sprites-Diffuse.shader
. Именно этот файл мы будем использовать в туториале.
Sprites-Default.shader
, а не Sprites-Diffuse.shader
.
Разница между ними в том, что первый не использует освещение, а второй реагирует на освещение в сцене. Из-за особенностей реализации Unity диффузную версию намного проще редактировать, чем версию без освещения.
В конце этого туториала есть ссылка на скачивание шейдеров дудла с учётом освещения и без него.
Шаг 2: смещение вершин
Внутри Sprites-Diffuse.shader
есть функция под названием vert
, являющаяся вершинной функцией, о которой мы говорили выше. Её название не важно, главное, чтобы оно совпадало с указанным в разделе vertex:
директивы #pragma
:
#pragma surface surf Lambert vertex:vert nofog nolightmap nodynlightmap keepalpha noinstancing
Если говорить вкратце, то вершинная функция вызывается для каждой вершины 3D-модели и решает, как наложить её на двухмерное пространство экрана. В данном туториале нас интересует только, как сместить объект.
Параметр appdata_full v
содержит поле vertex
, в котором содержится 3D-позиция каждой вершины в пространстве объекта. При изменении значения вершина сдвигается. То есть, например, показанный ниже код перенесёт объект с его шейдером на одну единицу вдоль оси X.
void vert (inout appdata_full v, out Input o)
{
v.vertex = UnityFlipSprite(v.vertex, _Flip);
v.vertex.x += 1;
#if defined(PIXELSNAP_ON)
v.vertex = UnityPixelSnap (v.vertex);
#endif
UNITY_INITIALIZE_OUTPUT(Input, o);
o.color = v.color * _Color * _RendererColor;
}
По умолчанию создаваемые в Unity 2D-игры оперируют только с осями X и Y, поэтому нам нужно изменить v.vertex.xy
, чтобы переместить спрайт на двухмерной плоскости.
vertex
структуры appdata_full
содержит позицию текущей вершины, обрабатываемой шейдером в пространстве объекта. Это позиция вершины при допущении, что объект расположен в центре мира (0,0,0), без изменения масштаба и без поворота.
Вершины, выраженные в мировом пространстве, отражают их реальное положение в сцене Unity.
x
величины transform.position
в методе Update
скрипта на языке C#, то увидим, как объект летит вправо со скоростью 1 метр на кадр, то есть примерно 216 километров в час.
Так происходит, потому что вносимые C# изменения меняют саму позицию. В вершинной функции этого не происходит. Шейдер меняет только визуальное представление модели, но не обновляет и не изменяет хранящиеся вершины модели. Именно поэтому добавление +1 к v.vertex.x
смещает объект на метр только один раз.
Для более сильного и реалистичного искажения нужно импортировать спрайты, выбрав для параметра Mesh Type значение Tight, которое превращает их в выпуклую фигуру (см. справа на рисунке).
Это увеличивает количество вершин. Такое не всегда желательно, но именно это нам сейчас и нужно.
Случайное смещение
Эффект дудла случайным образом смещает позицию каждой вершины. Сэмплирование случайных чисел в шейдере всегда было сложной задачей. В основном это вызвано распределённой архитектурой GPU, усложняющей и снижающей эффективность воссоздания алгоритма, используемого в большинстве библиотек (в том числе и Mathf.Random
).
В посте Ника Кэмана использовалась текстура шума, которая при сэмплировании даёт иллюзию случайности. В контексте вашего проекта это может быть не самым эффективным подходом, потому что в этом случае удваивается количество операций поиска текстур, выполняемых шейдером.
Поэтому в большинстве шейдеров применяются довольно запутанные и хаотичные функции, которые, несмотря на детерминированность, выглядят для нас не имеющими закономерностей. И поскольку они должны быть распределёнными, каждое случайное число должно генерироваться со своим собственным seed. Это отлично нам подходит, потому что позиция каждой вершины должна быть уникальной. Мы можем использовать это, чтобы привязать к каждой вершине случайное число. Реализацию этой функции случайности мы обсудим позже; пока назовём её random3
.
Мы можем использовать random3
для генерации случайного смещения каждой вершины. В показанном ниже примере случайные числа масштабируются с помощью свойства _NoiseScale
, позволяющего контролировать силу смещения.
void vert (inout appdata_full v, out Input o)
{
...
float2 noise = random3(v.vertex.xyz).xy * _NoiseScale;
v.vertex.xy += noise;
...
}
Теперь нам нужно написать сам код random3
.
Случайность в шейдере
Одна из самых распространённых и знаковых псевдослучайных функций, используемых в шейдерах, взята из статьи 1998 года В. Рея под названием "On generating random numbers, with help of y= [(a+x)sin(bx)] mod 1".
float rand(float2 co)
{
return fract(sin(dot(co.xy ,float2(12.9898,78.233))) * 43758.5453);
}
Эта функция детерминирована (то есть она не по-настоящему случайна), но ведёт себя настолько хаотично, что выглядит совершенно случайной. Такие функции называются псевдослучайными. Для своего туториала я выбрал более сложную функцию, придуманную Никитой Миропольским
Генерирование псевдослучайного числа в шейдере — это очень сложная тема. Если вам интересно узнать о ней больше, то в The Book of Shaders есть хорошая глава, посвящённая ей. Кроме того, Патрисио Гонзалес Виво собрал большой репозиторий псевдослучайных функций под названием GLSL noise, которые можно использовать в шейдерах.
Шаг 3: добавляем время
Благодаря написанному нами коду каждая точка смещается в каждом кадре на одну и ту же величину. Так мы получаем искажённый спрайт, а не эффект дудла. Чтобы исправить это, нужно найти способ менять эффект с течением времени. Один из самых простых способов сделать это — использовать для генерации случайного числа и позицию вершины, и текущее время.
В нашем случае я просто добавил текущее время в секундах _Time.y
к позиции вершины.
float time = float3(_Time.y, 0, 0);
float2 noise = random3(v.vertex.xyz + time).xy * _NoiseScale;
v.vertex.xy += noise;
Для более сложных эффектов могут потребоваться более сложные способы добавления времени в уравнение. Но поскольку нас интересует только прерывистый случайный эффект, то двух значений более чем достаточно.
Переключение времени
Основная проблема с добавлением _Time.y
заключается в том, что оно заставляет спрайт анимироваться в каждом кадре. Это для нас нежелательно, потому что большинство нарисованных от руки анимаций имеет низкую частоту кадров. Компонент времени должен быть не непрерывным, а дискретным. Это значит, что если мы хотим отображать пять кадров в секунду, то он должен меняться только пять раз в секунду. То есть время должно быть привязано к одной пятой секунды. Единственными допустимыми значениями должны быть , , , , , с, и так далее…
Я уже рассматривал привязку в своём блоге, в статье How To Snap To Grid. В этой статье я предложил решение задачи привязки позиции объекта на пространственной сетке. Если нам нужно привязать время к временной сетке, то математика, а значит и код, будут теми же.
Показанная ниже функция берёт число x
и привязывает его к целочисленным значениям, кратным snap
.
inline float snap (float x, float snap)
{
return snap * round(x / snap);
}
То есть наш код становится таким:
float time = snap(_Time.y, _NoiseSnap);
float2 noise = random3(v.vertex.xyz + float3(time, 0.0, 0.0) ).xy * _NoiseScale;
v.vertex.xy += noise;
Заключение
Пакет Unity для этого эффекта можно платно скачать на Patreon.
Дополнительные ресурсы
За последние несколько месяцев появилось большое количество игр в стилистике дудлов. Мне кажется, что причиной этого стал успех Doodle Studio 95! — инструмента для Unity, разработанного Фернандо Рамальо. Если этот стиль подходит к вашей игре, то рекомендую купить этот потрясающий инструмент.
Автор: PatientZero