Как стать автором
Обновить

Твины за рамками анимации

Оглавление


1. Вступление
2. Что такое твины
3. Проблема
4. Решение
5. Заключение


Вступление


Эта статья про твины (tween, tweenline, tween animation) и их нестандартное использование. Обычно о твинах вспоминают когда нужно что-то анимировать, будь то объект в игре или всплывающее меню на сайте. Но область их применения гораздо шире.

В статье будут приведены примеры кода — они будут написаны на языке C#.


Что такое твины


Твин (Tween) — аббревиатура для «In-between». В анимации твины описывают движение объекта (или изменение его свойства) между ключевыми кадрами.

Твины используются тогда, когда у вас есть следующие исходные данные:

  • Положение A — начальное (текущее)
  • Положение B — конечное (желаемое)
  • Время T — время, за которое объект должен переместиться из положения A в положение B

Допустим, вы хотите анимировать персонажа. Вы хотите, чтобы персонаж в начале анимации был в левой части экрана, а через две секунды — уже в правой. Благодаря твинам, вам не нужно анимировать всю сотню кадров между этими двумя положениями — достаточно указать только два ключевых положения — начальное в первом кадре анимации и конечное в том кадре, в котором объект должен достигнуть точки B. Большинство инструментов для анимации интерполируют этот переход из положения A в положение B самостоятельно. Это и будет твин.

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


Проблема


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

Наша команда занимается разработкой различных интерактивных и вспомогательных элементов для российского телевидения. Один из текущих проектов над которым мы работаем это робот-оператор. Вместо того, чтобы находится на съёмочной площадке и ловить планы в видоискатель руками — оператор может сидеть в офисе на удобном кресле и управлять камерой с джойстика ориентируясь по картинке на мониторе.
План зависит от следующих параметров — ориентации и наезда (zoom) камеры.
На репетициях в софт вносятся основные планы которым уделяется 90% времени. Между этими планами нужны переходы — был план `A`, нам нужно аккуратно перейти на план `B` за время `T`.

Управление ориентацией камеры производится с помощью робота-манипулятора, а её наезд управляется через API объектива. Роботом и объективом нужно управлять синхронно.
Робот имеет команду «Перевести камеру из положения `A` в положение `B` за `T` миллисекунд», а вот объектив имеет только команду «Установить наезд в значение `Z`».
Сотрудник, который занимался этим функционалом, столкнулся с проблемой — рассинхрон в 1-2 кадра (40-80 мс.) между тем когда робот занимает финальное положение и тем когда на объективе выставляется финальный наезд. При том рассинхрон был как в одну сторону, так и в другую, т.е. то объектив наводился раньше чем заканчивалось движение, либо наоборот.

Изначальная реализация работала следующим образом:
1. Отправляется команда роботу о том, чтобы он переходил в положение `B` за `T` мс.
2. В отдельном потоке раз в `N` мс. на объектив отправляется команда, чтобы он выставил наезд в текущее значение `Z`, это значение меняется перед каждой отправкой на шаг `S`.
3. Текущее значение `S` считается сразу после отправки команды роботу и представляет собой шаг который нужно прибавить к текущему значению наезда каждые `N` мс., чтобы за `T` мс. из текущего значения достигнуть финального. Т.е. `S = (B.Zoom - A.Zoom) / (T / N)`
4. После `T / N` шагов отправка команд на объектив прекращалась.

Вот упрощенный пример кода который делал переход из плана A в план B:
//Задержка между отправкой команд объективу (N)
private const int ZoomChangeDelay = 20;

...

//Команда роботу
Robot.SendSetOrientationCommand(B.Orientation, B.InDuration);

//Считаем S
double zoomDifference = B.Zoom - A.Zoom;
TotalStepsCount = B.InDuration / ZoomChangeDelay;
ZoomStep = zoomDifference / TotalStepsCount;
StepsPassed = 0;

...

//Выполняется в отдельном потоке
while (true)
{
    if (StepsPassed < TotalStepsCount)
    {
        //Считаем текущий наезд
        Camera.CurrentZoom += ZoomStep;
        
        //Отправляем значение объективу
        Camera.SendCurrentZoomCommand();
        
        StepsPassed++;
    }
    Thread.Sleep(ZoomChangeDelay);
}

Основная проблема была в том, что Thread.Sleep не всегда делает задержку именно на заданное количество миллисекунд. В основном это зависело от того, что в определенные моменты времени у системы есть более приоритетные задачи и наш поток каждый раз мог спать немного больше положенного времени, но за 100+ вызовов набиралось до 80 мс. отставания, что является 2 кадрами телевизионной съёмки (25 кадров в секунду, 40мс на кадр). Из-за этого было отставание наезда от положения камеры. Если из-за чего-либо происходил большой лаг, допустим в пол секунды — фокусировка производилась как минимум на полсекунды позже того как робот занимал финальное положение.

Также Thread.Sleep всегда делает задержку не равную указанному значению, а +- рядом. Если замерять сколько времени прошло между вызовами Thread.Sleep более точными инструментами — разброс будет +-3 мс. от заданной задержки, что и давало нам случаи, когда наезд происходил слегка быстрее изменения положения камеры.

Рассмотрим эту проблему графически.

Состояние робота описывается его ориентацией в пространстве, это шесть значений: `X`, `Y`, `Z`, `RX`, `RY`, `RZ`. Первая тройка отвечает за положение объектива относительно центра робота, а вторая за направление в котором направлен объектив. Для упрощения, чтобы не указывать все значения — будем рассматривать только первую тройку отвечающую за положение — `X`, `Y` и `Z`. Эти значения не зависят друг от друга и могут меняться по отдельности, но чтобы переход был плавным — их изменение должно как начинаться, так и заканчиваться в одно время. Функционал робота из коробки уже позволяет сделать это одной командой.

Так выглядит график перехода робота из состояния A в состояние B:



Координаты начинают изменяться в начале перехода, а заканчивают ровно через выделенное на переход время.

А так выглядит переход наезда:



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

Анимированные примеры правильно перехода:


И не правильного перехода:


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


Решение


Как я упоминал выше — твины отлично подходят для синхронизации различных действий во времени, в чем и является наша проблема!

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

Для этого опишем два простых класса:

//Общий интерфейс твинов
public abstract class Tween<T>
{
    protected T _start;
    
    protected T _end;
    
	public Tween(T start, T end) 
	{
		_start = start;
		_end = end;
	}
	
	public abstract T GetValueAtProgress(double progress);
}

//Класс твина для вещественных чисел - линейный переход 
public class LinearDoubleTween : Tween<double>
{
	public LinearDoubleTween(double start, double end) : base(start, end)
	{
	
	}
	
	override public double GetValueAtProgress(double progress)
	{
		return _start + (_end - _start) * progress;
	}
}


Первый класс `Tween` — абстрактный класс который описывает общий интерфейс всех твинов и принимает в конструкторе начало и конец перехода.

Второй класс `LinearDoubleTween` — класс который унаследован от `Tween` и реализует линейный переход для вещественных чисел.

В этом коде интересен только один момент — функция `GetValueAtProgress`. Вместо того, чтобы каждый шаг высчитывать на сколько значение должно было измениться — мы будем считать каким значение должно быть в эту единицу времени.

У нас уже есть значения `start` и `end` для функции `GetValueAtProgress` — это начальный и конечный наезды (`A.Zoom` и `B.Zoom`) соответственно, что же такое `progress`?

Для пояснения возьмём `start` и `end` равными каким-либо константам `A` и `B` соответственно (Для упрощения допустим что `A` всегда меньше `B`). Расположив значения `start` и `end` на числовой прямой мы получим простой отрезок:



Так как этот отрезок представляет переход из значения `start` в значение `end` мы можем представить эти значения в новой системе отсчета как 0 и 1 соответственно:



Так вот, `progress` — это точка на отрезке между 0 и 1 включительно в нашей новой системе отсчёта. Это какой-то момент внутри перехода между значениями `start` и `end`, началу перехода соответствует 0, середине 0.5, а окончанию 1. Взяв любую точку на этом отрезке, например 0.7, мы можем получить значение которое должно быть на изначальной числовой прямой в этот момент перехода:



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

Чтобы получить значение `progress` в текущий момент времени нужно сделать следующее: сразу после начала перехода мы будем запоминать текущее время Tstart. И зная сколько должен занимать переход `T` — мы сможем перевести в прогресс перехода любую временную метку Tcurrent между началом перехода Tstart и его окончанием Tstart + `T` включительно, по следующей формуле: `progress` = (TcurrentTstart) / `T`.

Вот упрощенный пример кода который делает переход из плана A в план B с использованием твинов:
//Задержка между отправкой команд объективу (N)
private const int ZoomChangeDelay = 20;

...

//Команда роботу
Robot.SendSetOrientationCommand(B.Orientation, B.InDuration);

//Запоминаем время старта (T start) и длительность текущего перехода
Transition.Start = Now();
Transition.Duration = B.InDuration;

//Создаём твин перехода
ZoomTween = new LinearDoubleTween(A.Zoom, B.Zoom);


...

//Выполняется в отдельном потоке
while (true)
{
    //Считаем текущий прогресс перехода
    double currentTime = Now();
    double progress = (currentTime - Transition.Start) / Transition.Duration;
    
    //Укладываем progress в рамки от 0.0 до 1.0 включительно
    double normalizedProgress = Math.Clamp(progress, 0.0, 1.0);

    //Получаем текущий наезд
    Camera.CurrentZoom = ZoomTween.GetValueAtProgress(normalizedProgress);
        
    //Отправляем значение объективу
    Camera.SendCurrentZoomCommand();
        
    Thread.Sleep(ZoomChangeDelay);
}


Всё! Теперь независимо от задержек вызываемых Thread.Sleep — посылаемое на объектив значение всегда будет соответствовать прогрессу перехода.


Заключение


В этой статье мы рассмотрели только самое базовое использование твинов — линейная зависимость от времени, но твины становятся намного интереснее когда они становятся сложнее. Внеся небольшие изменения в функцию `GetValueAtProgress` мы можем сделать переход, например:

* Синусоидным
return _start + (_end - _start) * Math.Sin(progress * Math.PI);


* Кубическим
return _start + (_end - _start) * Math.Pow(progress, 3);


* Ступенчатым:
Step1EndProgress = 0.333;
Step2EndProgress = 0.667;
Step3EndProgress = 1.0;

...

if (progress < Step1EndProgress)
{
    //Если это первый шаг (например первая треть), то значение растёт до середины перехода
    progress = (progress / Step1EndProgress) * 0.5;
}
else if (progress < Step2EndProgress)
{
    //Если это второй шаг (вторая треть), то значение замирает на середине перехода
    progress = 0.5;
}
else
{
    //Если это третий шаг (последняя треть), то значение растёт до конца перехода
    double stepDuration = Step3EndProgress - Step2EndProgress;
    double stepProgress = ((progress - Step2EndProgress) / stepDuration)
    progress = 0.5 + stepProgress * 0.5;
}
return _start + (_end - _start) * progress;


Если скомбинировать несколько твинов в зависимости от одного значения `progress` — мы получим таймлайн. Если мы запустим несколько объектов по одному твину, но с разными значениями `progress`, когда каждый следующий объект отстаёт от предыдущего на некоторое значение — мы получим змейку и т.д.

Твины являются очень простым инструментом для создания зависимостей от чего угодно — времени, расстояния, уровня заряда батареи и т.п. Их удобно использовать и заменять, т.к. все твины унаследованы от одного родительского класса — для изменения зависимости вам достаточно заменить используемый экземпляр твина с одного класса на другой и вы получите совсем другой эффект, т.к. независимо от того какой класс используется — то твин всегда в завершении оставит вам конечное значение `end` (если класс написан правильно).
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.