[Перевод] Создание шейдерной анимации в Unity
Недавно я работал над анимацией респауна и спецэффектом главного героя моей игры «King, Witch and Dragon». Для этого спецэффекта мне нужна была пара сотен анимированных крыс.
Создание двухсот мешей со скиннингом с анимацией ключевыми кадрами всего для одного спецэффекта — это пустая трата ресурсов. Поэтому я решил использовать систему частиц, но для этого мне пришлось выбрать другой подход к анимации.
В этом посте я объясню, как анимировать простых существ при помощи вершинного шейдера. В качестве примера я использую крысу, но тот же способ применим для анимации рыб, птиц, летающих мышей и других существ, не являющихся основной целью взаимодействий игрока.
В большинстве туториалов про шейдерную анимацию для начинающих рассказывается о том, как анимировать флаг при помощи синусоиды. Я покажу чуть более сложную версию, в которой мы разобьём модель на разные части тела и анимируем их по отдельности.
Я подробно опишу создание вот этой анимации:
Для начала я создал в Blender низкополигональную модель крысы.
Для разделения модели на разные части (тело, хвост, лапы) я использую UV-координаты. Чтобы всё работало как нужно, модель нужно развернуть особым образом.
Основное движение крысы — это прыжки. Чтобы все вершины анимировались плавно и синхронно, нам нужно разместить всё тело крысы вдоль одной из осей. Для удобства я разместил её вдоль горизонтальной оси U.
Глаза, внутренняя часть уха и нос должны иметь другой цвет на текстуре, поэтому я разместил их отдельно, но выполнил смещение только по оси V, не меняя горизонтальных координат. Если переместить их по горизонтальной оси, то их движение не будет синхронизировано с движением головы.
Хвост крысы занимает левую половину развёртки (координаты U от 0,0 до 0,5). Это будет нашей маской хвоста, которую мы используем для анимации хвоста.
Лапы расположены в нижней половине развёртки (координаты V от 0,0 до 0,4). Это наша маска лап. Кроме того, лапы сжаты по горизонтальной оси U, чтобы предотвратить нежелательную деформацию при движении вперёд-назад. Так как в своём проекте я использую cel-shading без детализованной текстуры, сжатые UV не вызовут проблем.
На основании этой UV-развёртки я создал диффузную текстуру. Теперь мы можем начать работу над шейдером.
Если вы хотите повторять все шаги, то можете скачать модель в формате FBX с диффузной текстурой отсюда.
Сначала я создам этот шейдер в Shader Graph движка Unity, а затем покажу текстовую версию.
Давайте создадим Unlit Graph, в качестве Preview выберем Custom Mesh, а затем выберем модель крысы.
Накладываем текстуру
Создадим новый параметр Texture 3D, это будет наша основная диффузная текстура. Создадим нод Sample Texture 2D, соединим наш новый параметр с его полем texture, а затем соединим нод с полем Color нода Master.
В дальнейшем мы будем работать только с вершинами.
Основное движение
Мы создадим его при помощи синусоиды. Чтобы настроить форму волны, нам нужны три параметра:
- Jump Amplitude — высота прыжков крысы
- Jump Frequency — частота прыжков крысы
- Jump Speed — скорость движения вдоль вертикальной оси
Два основных нода, которые позволят нам создать анимированное движение — это Time и UV. В ноде UV нам нужно будет использовать каждую ось по отдельности, поэтому мы соединим его с нодом Split, который даст нам доступ к каждому из каналов.
Мы можем управлять скоростью прокрутки синусоиды, умножив нод Time на параметр Jump Speed.
Если умножить горизонтальный компонент UV на Jump Frequency, то мы сможем управлять сжатием и растягиванием синусоиды.
Сумма этих произведений даст нам синусоиду нужной формы. По умолчанию синусоида возвращает значения в интервале от -1,0 до 1,0. Если мы умножим их на Jump Amplitude, то получим траекторию прыжка.
Теперь нам нужно применить результат к позициям вершин, но только к их вертикальной компоненте (локальной оси Y). Мы используем нод Position и соединим его с нодом Split. Затем прибавим значение синусоиды к компоненте Y и соберём всё вместе при помощи нода Combine. Подключим выход этого нода к полю Vertex Position нода Master.
Крыса начала двигаться, но не совсем так, как нам нужно. Она больше походит на дельфина, чем на крысу.
Как говорилось выше, синусоида может возвращать как положительные, так и отрицательные значения. Если мы теперь поместим крысу на поверхность, она будет «нырять» в неё. Кроме того, у синусоиды очень плавные экстремумы, из-за чего анимация походит на плавание.
Чтобы исправить это, мы используем нод Absolute. Эта функция отзеркаливает все отрицательные значения в положительные и выходные данные этой функции всегда положительны.
Сверху — обычные значения, снизу — абсолютные.
Давайте добавим этот нод к нашему графу.
Теперь анимация стала более скачущей.
Это уже больше похоже на прыжки или скачки, но всё равно далеко от желаемого. Сейчас проблема заключается в том, что как только лапы касаются земли, они отскакивают обратно в воздух. Это логично с точки зрения формы синусоиды, но неестественно с точки зрения движения крысы. Давайте будем какое-то время удерживать лапы крысы на земле перед следующим прыжком.
Для этого мы снова модифицируем синусоиду. Давайте переместим её вниз по вертикальной оси, а затем возьмём максимальное значение между синусом и нулём.
Сверху — абсолютное значение, посередине — со смещением, внизу — максимум между синусом и нулём.
Чтобы реализовать это в графе, нам нужен новый параметр Jump Vertical Offset, который позволит нам настраивать величину сдвига синусоиды.
Теперь крыса может какое-то время оставаться на земле.
Дополнительное раскачивание хвоста
В целом всё и так выглядит неплохо, но хвост всегда болтается рядом с землёй. Давайте добавим ему энергичности.
Мы используем UV-координаты для маскировки хвоста и анимирования его отдельно от тела.
Мы знаем, что хвост расположен в левой половине UV-развёртки. Создадим плавный градиент от 0,0 до 0,5 (или даже до 0,6, чтобы эффект был более плавным) по горизонтальной оси U. В 0,0 у нас будет белый цвет, в 0,6 и далее — чёрный. Чем ярче пиксель градиента, тем больше дополнительного движения прикладывается к вершине. По сути, на кончик хвоста он будет влиять сильнее всего, а ближе к телу эффект будет ослабевать.
Для создания этого градиента мы используем нод Smooth Step.
Также нам потребуется новый параметр Tail Extra Swing для задания величины дополнительного движения.
Умножив этот новый параметр на выход нода Smooth Step, мы получим распределение движения вдоль хвоста. Затем мы прибавим его к параметру Jump Amplitude, чтобы получить окончательное движение тела, учитывающее дополнительное раскачивание хвоста.
Теперь движение хвоста более заметно (Tail Extra Swing = 0.3).
Движение лап
Для движения лап мы используем примерно такую же структуру, что и для тела. Нам потребуется ещё пара новых параметров:
- Legs Amplitude — величина перемещения лап относительно их позиций по умолчанию
- Legs Frequency — частота движения лап
Нам не нужен параметр Legs Speed, потому что движение лап должно быть синхронизировано с движением тела, поэтому мы снова используем параметр Jump Speed. Единственное, что здесь стоит учитывать: поскольку мы используем абсолютное значение синусоиды, за один цикл получается два прыжка. Поэтому чтобы компенсировать это, мы используем Jump Speed * 2.
Лапы должны двигаться вперёд и назад (и положительное, и отрицательное смещение), поэтому в этом случае нам не понадобится нод Absolute.
Теперь нужно создать маску для лап, чтобы анимировать их отдельно.
Мы снова используем нод Smooth Step, но на этот раз в качестве входного параметра выберем вертикальную ось UV. Давайте зададим градиент от 0,1 до 0,4.
Почему 0,1, а не 0,0? Чтобы избежать деформации лап. Все вершины ниже уровня 0,1 будут иметь одинаковое смещение.
Нам нужно настроить значение Legs Frequency таким образом, чтобы когда передние лапы идут вперёд, задние двигались назад, и наоборот. В моём случае я задал значение 10.
Давайте прибавим результат к локальной позиции вершин по Z. Я временно отключил движение тела, чтобы отдельно посмотреть на анимацию лап.
Теперь скомбинируем всё вместе и посмотрим, как это выглядит. Я намеренно уменьшил скорость, чтобы легче было находить возможные проблемы.
Мы видим, что передние лапы касаются земли, когда они находятся в крайней левой позиции. Это неверно, они должны приземляться, когда находятся примерно в крайней правой позиции. Это означает, что фазы анимаций тела и лап не соответствуют.
Чтобы решить эту проблему, создадим новый параметр Legs Phase Offset, позволяющий нам компенсировать эту разность фаз и согласовать анимации.
Чтобы это смещение фаз работало, нужно добавить его в нод Time уже после умножения на Jump Speed (чтобы не изменять скорость), но перед всеми остальными манипуляциями.
После настройки значения (я указал -1,0) анимация стала правильной.
Вот как это выглядит при нормальной скорости.
Готовый граф:
Текстовая версия шейдера
Для тех, кто пока не перешёл на URP/HDRP или просто предпочитает писать шейдеры вручную, вот версия в коде:
Shader "Unlit/Rat"
{
Properties
{
_JumpSpeed("Jump Speed", float) = 10
_JumpAmplitude("Jump Amplitude", float) = 0.18
_JumpFrequency("Jump Frequency", float) = 2
_JumpVerticalOffset("Jump Vertical Offset", float) = 0.33
_TailExtraSwing("Tail Extra Swing", float) = 0.15
_LegsAmplitude("Legs Amplitude", float) = 0.10
_LegsFrequency("Legs Frequency", float) = 10
_LegsPhaseOffset("Legs Phase Offset", float) = -1
[NoScaleOffset]
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
half _JumpSpeed;
half _JumpAmplitude;
half _JumpFrequency;
half _JumpVerticalOffset;
half _TailExtraSwing;
half _LegsAmplitude;
half _LegsFrequency;
half _LegsPhaseOffset;
v2f vert (appdata v)
{
float bodyPos = max((abs(sin(_Time.y * _JumpSpeed + v.uv.x * _JumpFrequency)) - _JumpVerticalOffset), 0);
float tailMask = smoothstep(0.6, 0.0, v.uv.x) * _TailExtraSwing + _JumpAmplitude;
bodyPos *= tailMask;
v.vertex.y += bodyPos;
float legsPos = sin(_Time.y * _JumpSpeed * 2 + _LegsPhaseOffset + v.uv.x * _LegsFrequency) * _LegsAmplitude;
float legsMask = smoothstep(0.4, 0.1, v.uv.y);
legsPos *= legsMask;
v.vertex.z += legsPos;
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}
Поздравляю! Мы создали вершинный шейдер, который способен оживить простое существо без использования скелетного рига и анимаций с ключевыми кадрами.
Такой способ подойдёт для фоновых объектов, не находящихся в основном фокусе внимания игрока. Кроме того, по сравнению с рендерерами мешей со скиннингом (Skinned Mesh Renderers), он обеспечивает более высокую производительность.
Надеюсь, он был для вас интересным и/или полезным.