Search
Write a publication
Pull to refresh

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

Оглавление


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` (если класс написан правильно).
Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.