Home » Мой любимый трюк с анимацией: экспоненциальное сглаживание.

Мой любимый трюк с анимацией: экспоненциальное сглаживание.

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

Говоря о пользовательском интерфейсе, предположим, вы создаете какой-то компонент пользовательского интерфейса, например кнопку-переключатель. Что-то вроде этого (нажмите!):

Это просто вычисляет положение переключателя как функцию его состояния:

position.x = turned_on ? max_x : min_x;

Это работает отлично, но кажется немного безжизненным. Добавление некоторых анимация было бы круто! Анимации — это не просто причудливая визуальная вещь, они помогают пользователю понять, что происходит. Вместо того, чтобы телепортировать индикатор переключения в новое положение, давайте переместим его плавно:

Обратной стороной является то, что сейчас нам нужно запустить анимацию обновления:

position.x += (turned_on ? 1 : -1) * speed * dt;
position.x = clamp(position.x, min_x, max_x);

Однако это по-прежнему выглядит немного неуклюже из-за постоянной скорости (т. е. позиция представляет собой линейный функция времени). Давайте добавим немного смягчение функция поверх этого, например классический кубический 3t^2-2t^3:

или квадратный корень sqrt

Разницу между ними может быть трудно увидеть, поэтому давайте замедлим анимацию в 8 раз:

Линейный:

Кубический:

Квадратный корень:

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

t += (turned_on ? 1 : -1) * speed * dt;
t = clamp(t, 0, 1);
ease = (3 * t * t - 2 * t * t * t);
position.x = lerp(min_x, max_x, ease);

Здесь я использую тот факт, что smoothstep симметричен в следующем смысле: 1 - f

ease = turned_on ? sqrt(t) : 1 - sqrt(1 - t);

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

К счастью, существует аналогичная версия, которая использует минимально возможное состояние и не имеет проблемы «прыжков»:

Я называю это экспоненциальное сглаживание (по причинам, которые станут ясны позже). Я тоже слышал, как это называют подход, и я уверен, что в каждом движке у него есть свое имя. Здесь он замедлен в 8 раз по сравнению с sqrt:

Квадратный корень:

Экспонента:

Вот код экспоненциальной версии:

target = (state.value ? max_x : min_x);
position.x += (target - position.x) * (1 - exp(- dt * speed));

Интуитивно, на каждом кадре мы подталкиваем текущую позицию к ее целевая позиция (что определяется состоянием включения/выключения). Однако количество подталкиваний (1 - exp(- dt * speed)) выглядит очень странно, не так ли? Прежде чем мы поймем, откуда это взялось, давайте взглянем на некоторые более сложные анимации.

Допустим, у нас есть какая-то карта и камера, прокручивающая/перемещающая ее.

Да, я создал целый процедурный генератор и рендерер карт только для этого примера, и ни о чем не жалею.

Опять же, это напрашивается на добавление анимации. Давайте интерполируем это с постоянной скоростью:

Вот код:

position.x += sign(target.x - position.x) * speed * dt;
position.y += sign(target.y - position.y) * speed * dt;

Видите это дрожание после завершения анимации? Это потому что target.x - position.x продолжает чередовать то положительное, то отрицательное. Вместо sign(delta) нам нужна какая-то функция, которая фиксирует дельту:

float update(float & value, float target, float max_delta)
{
    float delta = target - value;
    delta = min(delta,  max_delta);
    delta = max(delta, -max_delta);
    value += delta;
}

update(position.x, target.x, speed * dt);
update(position.y, target.y, speed * dt);

Довольно глоток для такой простой вещи! И вот результат:

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

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

Идеальное решение? Конечно же, экспоненциальное сглаживание! Код практически не меняется по сравнению с примером кнопки переключения:

position.x += (target.x - position.x) * (1.0 - exp(- speed * dt));
position.y += (target.y - position.y) * (1.0 - exp(- speed * dt));

и вот как это выглядит:

Довольно приятно, если вы спросите меня! Обратите внимание, как оно естественным образом ускоряется, если вы нажимаете достаточно быстро.

Хорошо, так что с этим случилось 1 - exp(- speed * dt)что это такое?

Начнем с упрощенного варианта: у нас есть какая-то анимация, у нее есть текущая position и новая должность target к которому оно должно двигаться с некоторым speed. Чтобы сделать движение быстрее, когда разница между position и target велика, мы делаем скорость пропорциональной этой разнице:

position += (target - position) * speed * dt;

Обратите внимание, что он не требует поддержания любой состояние, отличное от текущего и целевого положения! (speed обычно является константой.) Ему даже не нужно отслеживать время, прошедшее с момента начала анимации, и оно автоматически корректируется, если target внезапно меняется.

Это уже прекрасно работает во многих ситуациях, но есть небольшая загвоздка. Вот еще раз кнопка переключения с приведенным выше кодом обновления:

Видите дрожание? Это потому, что я установил speed ценность настолько высока, что speed * dt стало больше 1! В частности, я использовал speed = 220 и dt = 1 / 125.

Чтобы понять, что происходит, полезно переписать приведенный выше код, используя lerp:

position = lerp(position, target, speed * dt);

Вы можете убедиться, что это в конечном итоге одна и та же формула. Мы можем ясно видеть, что происходит: формула интерполирует текущее значение и целевое значение. Чем ближе параметр интерполяции speed * dt до нуля, тем медленнее интерполяция. Чем ближе оно к единице, тем быстрее движение.

Теперь, что происходит, когда speed * dt больше 1, заключается в том, что интерполяция промахи! Единственная причина, по которой это все еще работает, заключается в том, что speed * dt меньше 2, так что абсолютный дельта между position и target все еще уменьшается со временем. Вот пример с speed * dt = 248 / 125 < 2:

и вот один с speed * dt = 252 / 125 > 2:

Последний вообще ничего полезного не делает.

Чтобы решить эту проблему, мы могли бы просто ограничить значение на 1:

position = lerp(position, target, min(1, speed * dt));

Однако это не кажется правильным поступком во всех сценариях. Подумайте, почему speed * dt может на самом деле оказаться таким большим?

Одна из причин заключается в том, что ваш speed значение слишком велико, потому что вам нужна очень быстрая анимация. Однако, как мы видели на примере приведенных выше кнопок переключения, на самом деле это происходит слишком быстро для любого разумного пользователя — реальную анимацию невозможно заметить. Итак, выход speed стоимость обычно не так высока.

Другая причина в том, что dt слишком велик. Возможно, потому, что ваш код работает слишком медленно и частота кадров падает. Возможно, потому что пользователь перешел на другую вкладку/окно, а ваш код спал, а теперь он проснулся с dt многих секунд.

При применении такого dt на что-то вроде физики, ее непременно хочется зажать, или разделить на несколько обновлений и т. д. С анимациями, однако, не было бы круто, если бы даже в этом случае все работало идеально? Даже если ваша физика может отставать, по крайней мере, камера и кнопки будут работать хорошо — как пользователь, я бы очень признателен за такую ​​заботу.

Хорошо, мы хотим решить проблему, но как? Вот двухэтапный рецепт:

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

Зависимое от времени обновление, подходящее для небольших dt но ломается по-крупному dt довольно типично для численных решателей дифференциальных уравнений. Какое уравнение делает position += (target - position) * speed * dt решать? Всякий раз, когда вы видите A += B * dtэто соответствует уравнению

В нашем случае уравнение имеет вид

Я умру, если продолжу вводить эти формулы со всеми написанными словами, поэтому давайте внесем несколько изменений в переменные: call x = position, a = targetи c = speed:

Для решения этой проблемы нужно всего несколько хитростей:

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

Не важно точно понимать, откуда все это берется. Дело в том, что если мы верим, что position += (target - position) * speed * dt это правильная формула для маленький dtто формула position += (target - position) * (1 - exp(- speed * dt)) правильная формула для использования любой dt. Это дополнительно подтверждается расширением последнего уравнения в виде ряда Тейлора для показателя степени: exp(x) ~ 1 + xтак что 1 - exp(- speed * dt) ~ 1 - (1 - speed * dt) = speed * dtт.е. мы получаем в точности первое уравнение.

Самое интересное, что старые значения не важны: если у вас есть предыдущее значение и вы знаете, сколько времени прошло между предыдущей и текущей итерацией, вы можете вычислить новое значение. (Это прямое следствие того, что уравнение является дифференциальным уравнением первого порядка.)

Итак, TL;DR заключается в том, что position += (target - position) * (1 - exp(- speed * dt)) это правильная формула, которая работает для любого speed и dt. Даже если продукт speed * dt слишком велик, exp(- speed * dt) прекрасно справляется с этим, поскольку exp большого отрицательного числа — это что-то близкое к нулю, поэтому 1 - exp будет близко к единице.

Мы можем, как и раньше, переписать это, используя lerp: position = lerp(position, target, 1 - exp(- speed * dt)) или даже position = lerp(target, position, exp(- speed * dt)). Есть много способов переписать это уравнение.

Обычно мы думаем об анимации с точки зрения ее продолжительность. Мол, кнопка переключения должна переместиться на новое место за 0,125 секунды (реальное значение, использованное в примерах в начале поста), после этого она перестает двигаться. Однако с помощью этой экспоненциальной формулы анимация технически требует бесконечный пора завершать! exp(- speed * time) со временем становится меньше, но никогда равен нулю, так что position технически никогда равно target (при условии, что они изначально были разными).

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

Итак, что же означает speed параметр означает именно? Это означает следующее: 1 / speed это время, в которое position становится ближе к target в разы e = 2.71828... точно. Делайте с этой информацией что хотите.

Я обычно устанавливаю speed к чему-то в диапазоне 5..50. Для линейной/кубической анимации определенного speedя обычно устанавливаю скорость экспоненциальной версии 2 * speedэто кажется правильным (опять же, это то, что использовалось в примерах выше).

Если вы погуглите «экспоненциальное сглаживание» (или «экспоненциальное скользящее среднее»), вы можете найти вики-статья о чём-то совершенно несвязанном, но, тем не менее, имеющем довольно похожие формулы. На самом деле это дискретный аналог того, о чем мы говорили в этом посте!

Предположим, что наш dt всегда одно и то же; также предположим, что target меняется так же часто, как каждая итерация. Затем, индексируя значения номером итерации, мы вычисляем что-то вроде position[i] = (target[i] - position[i - 1]) * factorгде factor = 1 - exp(- speed * dt). В этом случае обычно устанавливают factor непосредственно к некоторому значению от 0 до 1 вместо того, чтобы получать его из других значений (хотя вышеупомянутая статья вики объясняет что это factor на самом деле означает).

Люди используют его при обработке сигналов по тем же причинам, что и я для анимации: он не требует сохранения предыдущих значений или какого-либо другого непонятного состояния, а только текущее усредненное значение. Они также используют его в цифровом аудио, где обычно используется фиксированный звук. dt из 1 / freq обратная частота дискретизации (например, 1/44100 или 1/48000).

Идея этого поста вынашивалась у меня много месяцев, и я рад, что наконец-то воплотил ее в жизнь 🙂

Как обычно, смотрите мои девлоги:

и спасибо за чтение!

2024-03-08 03:27:06


1709900055
#Мой #любимый #трюк #анимацией #экспоненциальное #сглаживание

Read more:  Джек Тейшейра: Насколько разрушительны утечки американской разведки?

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.