Pull to refresh

Интерполяция — мать анимации — Твинеры в Unity

Reading time9 min
Views11K

Одним из неотъемлемых элементов игровых приложений, обеспечивающих красочный пользовательский опыт, является анимация. Основным компонентом Unity для анимации является "Mecanim", имеющий более привычное название "Аниматор". Это очень мощный инструмент, позволяющий управлять сложнейшими системами объектов, совместимый со скелетными анимациями, экспортируемыми из 3D-пакетов, с инструментами для работы с IK, смешивания анимаций или частичного их проигрывания. И как только ты уверишься в том, что пробовал в аниматоре все, будь уверен - на следующий день ты найдешь новую функцию, с которой не сталкивался ранее. И это будет та самая функция, которой тебе так не хватало.

Но есть и у этого инструмента слабое место. Все анимации жестко ограничены, они представляют собой заранее описанный сценарий, который просто воспроизводится на иерархии объектов. Впрочем, это не совсем так. Как уже было отмечено выше, аниматор это чрезвычайно сложный инструмент, во всех его UI можно найти множество кнопочек и настроек, каждая из которых, как ни удивительно, выполняет какую-то функцию. К примеру, Avatar Mask позволяет сделать так, что некоторый слой анимации будет управлять отдельными частями тела, и персонаж будет махать рукой сидя на лошади, хотя отдельной такой анимации на Mixamo не нашлось =( Аниматор просто отыгрывает 2 анимации параллельно на тех костях, которые были заданы масками.

А что если нам нужно в процессе анимации двигать объект? Ну конечно, для этого существует волшебная галочка Root Motion. Теперь перемещение тела обрабатывается отдельно, мы можем просто переключать состояния, и наш персонаж будет перемещаться так, как это было задано анимацией. Он будет не просто плыть с линейной скоростью, скользя ногами по земле, он будет по-настоящему шагать! Если в анимации он хромает, перемещение аватара будет синхронизировано с тем, как его ноги касаются земли. Если карабкается - его перемещения будут соответствовать движениям рук. А если добавить к этому IK и развешать по стене опорные точки, можно заставить его цепляться за хаотично торчащие выступы, как герои из серии Assassin's Creed!

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

Допустим, нам нужна анимация, которая будет активно взаимодействовать с окружением. Например, при контакте с персонажем монетка должна подпрыгнуть, сверкнуть, и улететь в карман нашего героя. То есть, сценарий нашей анимации зависит от контекста своего выполнения. Или нам просто не нужен аниматор на конкретном объекте. Например, анимируя UI нужно очень постараться, чтобы аниматор не заставлял Canvas перерисовываться на каждом кадре. А также, аниматор ничем не сможет помочь, если мы работаем с несовместимыми интерфейсами. Например, если мы захотим сделать анимацию накопления при подсчете очков или двигать вершины меша(Может быть, даже взаимодействуя с окружением! Например, плавно помять корпус авто в точке удара). Здесь нам поможет только описание логики анимации с помощью кода. Наконец-то мы подобрались к основной теме данной статьи.

Если вы загуглите слово "Твинер", без указания контекста найти что-то может оказаться не так то просто, результат потонет в более релевантных синонимах. Правильным запросом будет Inbetweening, сокращенно Tweening. Полная версия термина звучит уже более осмысленно и лучше говорит о сути происходящего.

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

Современная цифровая анимация не такая суровая, здесь нам на помощь приходит математика. А конкретно, многодетная мать геймдева - интерполяция. Среди 1001 способов ее применений анимация если не самый, то уж точно один из самых весомых. Половина тех самых многочисленных функций аниматора - интерполяция под разным соусом. Это и плавные переходы, и смешивание разных анимаций, и, собственно, твининг. В цифровой анимации, имея 2 состояния сцены, параметры объектов на них можно интерполировать, получив сколько угодно промежуточных состояний между начальным и конечным положениями. Благодаря этому, при использовании скелетной анимации не существует предела по FPS. Если на спрайтах отрисовано 60 кадров, их анимация не сможет быть плавнее этого порога. Кость же может повернуться и на сотую, и на тысячную долю градуса. То, как будет выглядеть кадр зависит от того, в какой момент времени происходит отрисовка. И хоть человеческий глаз не способен в полной мере оценить плавность картинки с частотой 240гц, мозг умнейшего из живых существ с легкостью может понять, что такой монитор ему действительно необходим.

Твинер не справа и не слева. Он посередине
Твинер не справа и не слева. Он посередине

Думаю, ни для кого не секрет, что анимации хранятся в памяти не покадрово, в виде бесконечно плотной последовательности значений, для этого используются системы функций, определяющих значение параметра в зависимости от времени. Для гибкости настройки, за очень редкими исключениями, анимации не хранятся в виде чистых функций вроде синусоиды или многочлена. Разве что, в отдельных случаях можно столкнуться с кривыми Безье. Но чаще используются B-Сплайны. Кривые, построенные по опорным точкам, изгиб каждого фрагмента которых можно изменить меняя значение промежуточных точек. Таким образом можно двигать отдельные точки, и добиваться желаемого результата, не меняя общую картину.

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

Итак, мы определились с тем, что твинер это, инструмент, позволяющий описать анимацию некоторых параметров между двумя состояниями. И, как мы поняли выше, есть ряд задач, к решению которых подключать аниматор нецелесообразно или просто невозможно. И у этих задач уже масса решений, убийц друг друга разной успешности. Я не буду вспоминать спойлер, чтобы не провоцировать особо впечатлительных. Все эти инструменты реализуют плюс-минус один набор функций различными способами. Самым удачным из существующих на сегодняшний день решений принято считать DOTween. Он хорошо себя показывает в тестах производительности, широко распространен и удобен в использовании. Позволяет инициализировать анимации в виде методов расширения к анимируемым полям поддерживаемого типа и имеет возможность построения цепочек из анимаций, в том числе, с поддержкой ветвления. А еще, это одно из немногих решений, в котором присутствует единственная необходимая функция в твинере. Хотя она уже выглядит не так привлекательно, как весь синтаксический сахар, за который мы все его так любим. Обратимся к документации DOTween. Приготовьтесь, сейчас будет первая строчка кода в статье про код.

transform.DOMove(new Vector3(2,3,4), 1);
rigidbody.DOMove(new Vector3(2,3,4), 1);
material.DOColor(Color.green, 1);

Здесь мы видим, что DOTween позволяет вызовом одной функции запустить анимацию того или иного параметра стандартных Unity-компонентов. Очень просто, удобно и красиво.

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

// Tween a Vector3 called myVector to 3,4,8 in 1 second
DOTween.To(()=> myVector, x=> myVector = x, new Vector3(3,4,8), 1); 
// Tween a float called myFloat to 52 in 1 second
DOTween.To(()=> myFloat, x=> myFloat = x, 52, 1);

Но на мой взгляд, единственный необходимый и самый полезный способ работы с DOTween выглядит вот так:

DOTween.To(MyMethod, 0, 1, duration);

Это максимально абстрактное описание твинера, позволяющее наглядно и эффективно описать любую задачу и, как самые внимательные уже заметили, может существенно сократить сигнатуру до DOTween.To(duration, MyMethod) вызываемой функции и улучшить читаемость кода. Для большей понятности посмотрим, что может находиться MyMethod:

public void MyMethod(float pt){
    
  // Use easing function in tweening
  var et = EZ.BackIn(pt);

  // Animate scale
  label.transform.localScale = Vector3.Lerp(Vector3.zero,Vector3.one,et);

  //Animate position
  label.transform.localPosition = Vector3.LerpUnclamped(Vector3.down*500,Vector3.zero,et);

  // Animate color(linear)
  label.color = Color.Lerp(Color.clear,Color.white,pt);

  // Animate label value 
  label.text = Mathf.Lerp(0,totalScore,CubicOut(pt)).ToString("0");
}

Проще говоря, вместо того, чтобы привязываться к конкретным типам, параметрам и длительности анимации, мы просто вызываем функцию, линейно прогоняя значение ее аргумента от 0 до 1. Внутри нее мы можем использовать функции плавности, чтобы некоторые из параметров менялись нелинейно, создавая более динамичную картинку. Например, label из примера пролетит увеличиваясь от нижней границы экрана и остановится в центре с небольшим заносом, цвет будет меняться линейно, а накопление счетчика будет происходить таким образом, чтобы последние цифры тикали дольше, создавая приятный визуальный эффект. Конечно, 3 действия из 4 можно было сделать не менее красиво, а вот чего-то более сложного код уже не будет таким лаконичным. Таким образом, мы создаем очень простую и понятную абстракцию, которую легко читать и комфортно использовать вне зависимости от контекста.

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

Здесь проблема DOTween прежняя. Как только мы выходим из зоны комфорта, начинаются бесконечные страдания и нестерпимая боль. Как будет выглядеть подобная цепочка?

Sequence mySequence = DOTween.Sequence();
mySequence.Append(transform.DOMoveX(45, 1))
	.Append(MyMethod,0,1,duration)
  .AppendCallback(MiddleCallback)
	.Append(MyMethod1,0,1,duration1).AppendCallback(FinishCallback);

А еще где-то должна быть реализация. Я предпочитаю использовать лямбды в описании твинера.

DOTween.Sequence()
  .Append(transform.DOMoveX(45, 1))
  .Append(DOTween.To(pt=>{
    
  },0,1,duration))
  .AppendCallback(()=>{
  
  })
  .Append(DOTween.To(pt=>{
  
  },0,1,duration1))
  .AppendCallback(()=>{
  
  });

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

К слову, о захвате контекста. Это больное место всех известных мне твинеров, включая и DOTween. Смена сцены в процессе анимации или уничтожение анимируемых объектов ломает его работу. Issue был открыт в марте 2016, и судя по комментариям, проблема есть по сей день. Вероятно, предполагается, что это настолько очевидно, что здесь даже не нужна защита от дурака. В конце концов, всегда можно подписаться на смену сцены и прерывать их исполнение. Или проверять существование объекта в процессе выполнения, потеряв львиную долю производительности и рискуя создать утечку памяти, захватив в зацикленном твинере ссылку на объект, но не затрагивая при этом выгруженный компонент, откуда мы эту ссылку получили. В этой проблеме кроется одна из главных причин, по которым я использую собственный твинер, и единственная, по которой я взялся его писать.

EZ написан в 200 строк кода, включая пустые и комментарии, и работает максимально просто. Основной тип объектов, с которым мы работаем - EZQueue. Он хранит в себе очередь из пар делегат-длительность, и прогресс выполнения текущего делегата. Хост-объект является синглтоном, хранит в себе ссылки на все очереди, разделяя их на 2 категории и производит их выполнение в LateUpdate.

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

public EZQueue Tween(float duration, Action<float> action);
public EZQueue Tween(Action<float> action) => Tween(0.3f, action);
public EZQueue Call(Action action);
public EZQueue Delay(float duration = 0.1f);
public EZQueue Wait(Func<bool> condition);
public EZQueue Loop();
public void Unloop();
public void Kill();

Стандартная длительность анимации в 0.3 секунды была подсмотрена в гайдлайнах Material Design и очень хорошо зарекомендовала себя на практике. Это та длительность, при которой анимация заметна, ощущается ее плавность и эффект от Easing-функции, но не заставляет пользователя ждать. Обычно я указываю бó‎льшую длительность для каких-то особых анимаций, которые сопровождают значимое действие, но для 90% анимаций это аргумент, который удобнее будет скрыть.

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

private EZQueue ez;
private Text label;
private Image buttonNext;

...
ez = EZ.Spawn().Tween(2.5f, pt => {
  var et = EZ.BackIn(pt);
  
  label.transform.localScale = 
    Vector3.Lerp(Vector3.zero,Vector3.one,et);
  
  label.transform.localPosition = 
    Vector3.Lerp(Vector3.down * 500, Vector3.zero, et);
  
  label.color = 
    Color.Lerp(Color.clear,Color.white,pt);
  
  label.text = 
    Mathf.Lerp(0,totalScore,CubicOut(pt)).ToString("0");

}).Delay(1).Add(()=>{
	buttonNext.visible = true;
  
}).Loop().Tween(pt=>{
  buttonNext.transform.scale = 
    Vector3.one + Vector3.one * (0.1f * Mathf.Sin(6.293f * t));
  
});

...
void OnClickNextButton(){
  ez.Kill();
  EZ.Spawn(true).Tween(t=>{
    // Hide result UI
  });
}

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

Разумеется, было бы глупо исключать вероятность, что автор не прав, ожидания к твинерам завышены и не в ту степь, поэтому прикрепляю 2 опроса и буду рад вашим историям в комментарий: Что самое необычное вы анимировали с помощью твинера? Свой самый необычный кейс я упомянул в статье: плавная деформация корпуса авто в области коллизии путем перемещения вершин.

Only registered users can participate in poll. Log in, please.
Как вы обычно используете твинер?
35.71% В коде анимирую максимум один параметр(возможно, с коллбеком на завершение)5
28.57% Создаю несколько инстансов для параллельных анимаций4
35.71% Использую Lerp внутри лямбда-функций5
14 users voted. 11 users abstained.
Only registered users can participate in poll. Log in, please.
Нужны ли вам в твинерах последовательности анимаций?
0% Последовательности не использую (Альтернативу в комментарии)0
88.89% Использую как есть, интерфейс полностью устраивает (Интересно увидеть примеры кода в комментариях)8
0% Написал обертку для существующего твинера (Идеи по оформлению кода туда же)0
11.11% Использую свой (Хвастовство приветствуется)1
9 users voted. 10 users abstained.
Tags:
Hubs:
Total votes 6: ↑5 and ↓1+4
Comments6

Articles