- 指数平滑化は、トグルボタン、カメラ、UI要素、オーディオ音量のように、目標値に向かって滑らかに追従させたいアニメーションに幅広く使えるシンプルな手法
- 中核となる式は
position += (target - position) * (1 - exp(- speed * dt)) で、現在位置と目標位置だけで目標変更に自然に反応する
- 線形移動や一般的なイージング(easing)は、途中クリック、高速なカメラ入力、大きな
dt に対してジャンプ・揺れ・入力キューのような処理が必要になるが、指数平滑化は同じ構造で複雑さを減らせる
1 - exp(- speed * dt) は、比例更新式が解く微分方程式の解から出てくるもので、小さな dt では従来の式とほぼ同じで、大きな dt でもオーバーシュートを避けられる
- 目標値に数学的に正確に到達するわけではないが、浮動小数点精度とユーザーが感知できる変化量の限界により、実際のアニメーションでは十分に完了したように振る舞う
トグルボタンで見えてくる問題
- トグルボタンのスイッチ位置は
turned_on ? max_x : min_x のように単純に計算できるが、状態が変わったときに位置が即座に瞬間移動してしまい、生き生きとした感じに欠ける
- 線形アニメーションは、現在位置を一定速度で更新し、範囲を制限する方法で実装される
position.x += (turned_on ? 1 : -1) * speed * dt;
position.x = clamp(position.x, min_x, max_x);
- 一定速度の移動では、位置が時間に対する線形関数になり、動きが鈍く見えることがある
- イージング関数を重ねると、動きをより滑らかにできる
- 古典的な cubic smoothstep:
3t² - 2t³
- 平方根イージング:
sqrt(t)
smoothstep には 1 - f(t) = f(1 - t) という対称性があり、前後のアニメーションに同じコードを使える
sqrt は方向によって異なる式が必要
- オンになるとき:
sqrt(t)
- オフになるとき:
1 - sqrt(1 - t)
sqrt は開始時に素早く動き、目標に近づくほど滑らかに減速するが、単純な2状態トグルでも t、方向、イージング計算といった状態管理が必要になる
- ユーザーがアニメーションの途中でもう一度クリックすると、従来のイージング方式では位置が突然ジャンプする不連続性が生じることがある
指数平滑化の式
- 指数平滑化は、目標位置を決めたうえで、現在位置を目標のほうへ少しずつ引き寄せる方法
target = (state.value ? max_x : min_x);
position.x += (target - position.x) * (1 - exp(- dt * speed));
- 更新量は、現在位置と目標位置の差である
target - position.x を基準に決まる
1 - exp(- dt * speed) は、フレームごとに目標のほうへどれだけ移動するかを決める補間係数
- 途中で目標が変わっても、別途の進行時間やアニメーション方向の状態なしに、新しい目標へ向かってそのまま動き続ける
- トグルボタンの例では、
sqrt イージングのように素早く始まり、目標付近で減速しつつ、途中クリック時のジャンプ問題も減る
カメラ移動でさらに大きくなる利点
- マップ上を移動するカメラにも同じ問題が現れる
- 一定速度の補間を単純に実装すると、軸ごとに目標方向の符号を使う
position.x += sign(target.x - position.x) * speed * dt;
position.y += sign(target.y - position.y) * speed * dt;
- アニメーション完了地点で
target - position の符号が正と負の間を行き来し、揺れが発生することがある
- これを防ぐには、
delta を max_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;
}
- cubic イージングをカメラ移動に適用するには、要求された移動イベントをキューに入れて1つずつ処理する必要があり、構造が複雑になる
- アニメーション中にユーザー入力を無視する方式は、ユーザーにとって非常にストレスに感じられる
- 指数平滑化を使えば、トグルボタンとほぼ同じコードでカメラ移動を処理できる
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 - position) * speed * dt;
- この式は、目標と現在位置の差が大きいほど速く動き、現在位置と目標位置以外の追加状態を必要としない
lerp で書くと同じ式になる
position = lerp(position, target, speed * dt);
speed * dt が0に近いとゆっくり動き、1に近いと目標へ素早く近づく
speed * dt が1より大きいと、補間が目標を通り過ぎるオーバーシュートが発生する
- 例では
speed = 220、dt = 1 / 125 のとき speed * dt が1を超え、揺れが現れる
speed * dt < 2 なら絶対的な差は小さくなり得るが、speed * dt > 2 では有用な動作をしない
- 補間係数を
min(1, speed * dt) に制限することはできるが、dt が大きくなる状況まで滑らかに扱う解決策ではない
- コードが遅く、フレームレートが落ちることがある
- ユーザーが別のタブやウィンドウに移動してコードが止まり、数秒分の
dt で再開することがある
- 物理シミュレーションでは大きな
dt を制限したり複数の更新に分割したりできるが、アニメーションではカメラやボタンが大きな dt でも自然に動作するほうがユーザー体験に良い
微分方程式から見た解法
A += B * dt 形式は一般に、dA/dt = B という微分方程式の数値解法に相当する
- 単純な更新式は次の方程式を解く形になっている
d(position) / dt = (target - position) * speed
- 変数を
x = position、a = target、c = speed と置くと次のようになる
dx / dt = (a - x) * c
x = x0 + (a - x0) * (1 - exp(-c * t))
- したがって、小さな
dt で正しい式が position += (target - position) * speed * dt なら、任意の dt で使える式は次のようになる
position += (target - position) * (1 - exp(- speed * dt));
- テイラー展開で
exp(x) ≈ 1 + x なので、小さな dt では 1 - exp(-speed * dt) ≈ speed * dt となり、従来の単純な式と同じになる
speed * dt が非常に大きくても exp(-speed * dt) は0に近づき、1 - exp(...) は1に近づくため、目標に近い値へ安定して移動する
- 同じ式は
lerp でも書ける
position = lerp(position, target, 1 - exp(- speed * dt));
position = lerp(target, position, exp(- speed * dt));
速度値の選び方
- 一般的なアニメーションは「0.125秒以内に移動」のように持続時間で考えやすいが、指数平滑化は技術的には無限の時間が経たないと目標に正確には到達しない
exp(- speed * time) は時間が経つほど小さくなるが0にはならないため、開始値と目標値が異なる場合、position も数学的には target と完全には等しくならない
- 実際には浮動小数点精度の限界に達するか、カメラ位置の差がユーザーに見えないほど小さくなり、アニメーションが終わったように見える
speed の意味は、1 / speed の時間のあいだに position が target にちょうど e = 2.71828... 倍近づくということ
- 実使用では通常
speed を 5..50 の範囲にし、同じ感覚の線形・cubic アニメーション速度に対しては、指数平滑化の 2 * speed 程度が適切に感じられることがある
信号処理とのつながり
- “exponential smoothing” または “exponential moving average” で検索すると、WikipediaのExponential smoothing 記事を見つけられる
- 固定された
dt で target が反復ごとに変わる場合、値は反復インデックス基準で次のように更新される
factor = 1 - exp(- speed * dt)
- 通常は
factor を0から1の間の値として直接決める
- 離散的な指数平滑化は、アニメーションで使う方法の離散的アナログ
- 信号処理でも、過去の値のリストや複雑な状態を保持せず、現在の平均値だけで動作するという理由で使われる
- デジタルオーディオでは通常、
dt がサンプリング周波数の逆数である 1 / freq に固定される
1件のコメント
Hacker News のコメント
ここでは肝心な点が十分に扱われていないように思う。これは単に 0 と 1 の間の別のイージング曲線や
smoothstep()ではなく、ほぼどんな入力でも規則的に処理できるステートレスな方法なので、本当に便利だ。CSS トランジションを使ったことがあれば、この問題はなじみがあるはず。継続時間を 400ms にしたとして、なぜよりによって 400ms なのか?移動すべき距離に応じて変わるべきではないのか?
他の人も述べているように、指数平滑化には目的地へ漸近的に近づくだけで決して到達しないという問題がある。ステップがしきい値より小さくなったらアニメーションを止めるという明白な解決策はあるが、エレガントではない。
慣性スクロールで似た方法を使うときは、擬似的な摩擦項を追加するのが有用だった。この項が指数項を相殺して、実質的に最低速度のように働く。Desmos の例はこちら: https://www.desmos.com/calculator/98ufbuzxhj
Dに関する常微分方程式として解釈すれば解決できるかもしれない。指数平滑化はdD/dt=-C*Dのオイラー更新で、解はD(t)=A*exp(-C*t)なので 0 に漸近するが到達しない。これを有限時間内に 0 へ行く式、たとえば
dD/dt=-C*sqrt(D)に変えればよい。解は半分だけの二次式のように動き、0 に到達するとそのまま留まる。この式のオイラー更新も望みどおりステートレスだ。ゲーム開発者としては、ほとんどの UI にはあらかじめ決めた継続時間を持つイージング・トゥイーンのほうがよいと思う。ただし、開始点と終了点が明確でない連続的・予測不能な動きを滑らかにしたいときは、この別種のアニメーションが非常に役立つ。
たとえば、プレイヤーがマウスでグリッド上のタイルをドラッグするときにグリッドへスナップさせる場合や、記事の例のようにカメラを動かす場合がそうだ。
こうした場合、指数補間のコツは非常に有用だが、広く知られているわけではない。多くのゲームはより不正確な線形補間を使っていて、60fps が標準だった時代とは違い、誰かが 240Hz モニターで実行するとアニメーションの感触が完全におかしくなる問題に遭遇する。
だからこの記事はうれしい。こういう過度に具体的な知識は、たいていチームのシニアがジュニアに徒弟制度の口伝で伝えるようなものなので、アクセスしづらい。
記事は好きだが、トグルスイッチでは
sqrtが三次関数より優れているという著者の判断は、客観的に間違っていると言いたい。実際のトグルスイッチがおおむねどのように動作するかを見ると、この状況では三次関数のほうがよい選択だ。家のブレーカーのスイッチや、アナログシンセ、オーディオ機器でよく見かけるスイッチを思い浮かべればよい。特定の美感を狙った機器で、私が持っている小さな Hughes & Kettner のギターアンプにも押し心地のよいスイッチが 2 つある。
こうしたスイッチは最初に少し抵抗があり、その後スプリング機構のために突然新しい位置へカチッと跳ねる。この動きは
sqrtや指数平滑化よりも三次関数のほうがうまくモデル化できる。その些細な指摘を除けば、記事はとても良かった。適切なイージング関数のようにアニメーションをうまく使えばユーザー体験を高められるが、線形補間の例のように慎重でない実装をすると目障りになり、体験を損ねることがよく示されている。
[0] トグルスイッチの種類にもよる。ただ、Minimoog のような機器で見られる種類も「抵抗してから新しい位置へカチッと跳ねる」動作を見せ、操作していて楽しい。ちなみに機材自慢ではなく、私は Minimoog を持っていない。
単純な非線形のコツがオンラインのインタラクションに楽しさを加えることがいかに多いか、今でも驚かされる。色知覚の場合には、2 つの色がある人にとって十分に区別できない理由を理解するうえで中核的でもある。
奇妙なのは、人間が常に加速をうまく理解しているわけではないことだ。火が平地と同じようにほぼ一定速度で進むと思って、丘の上へ逃げてはいけない。火は丘を上りながら加速する。
子どもは投げたボールが地面に沿って動く速さはすぐに覚えるが、重力のせいで手にぶつかるときにどれほど速く動いているかは、いつもうまく理解しているわけではない。
この記事の大部分が結局イージングに行き着く点が興味深い。新しい世代ごとにこれを自分たちで再発見しなければならないように見える。
90 年代後半、Yugo Nakamura の実験的なウェブサイトに魅了された記憶がある。イージングを自在に使って有機的な感触を与えていた、私が見た初期のウェブサイトの一つだった: https://www.youtube.com/watch?v=NLt7Gwnt3WY
なんとなく、こんなトグルがあるとよさそうだ。タッチまたはクリックしている間は目標の約 75% までゆっくり移動し、離すと残りをカチッと移動する方式。
UX の観点でどんな意味を持つのかはよく分からない。設定が最後に実際に適用または保存されるという意味かもしれない。
あるいは「本当に確かですか?」ダイアログの一部かもしれない。押している間は設定が適用されるが、所定位置にカチッとはまる前なら Escape で戻せる、という具合だ。
input:checked + .slider:active:before { transform: translateX(8px); transition: 1s; }input:not(:checked) + .slider:active:before { transform: translateX(18px); transition: 1s; }この記事が気に入った。ほぼまったく同じ手法を10年ほど前に書いたことがあり、当時は
lazy-easyと呼んでいて、今でも使っている。ときには状態管理を一切せずに、なめらかなアニメーションだけが欲しいことがある: https://www.hailpixel.com/articles/lazy-animation-with-lazy-...
記事自体は本当に良い。デモは Chrome ではうまく動いているようだが、Firefox ではスクロール中に固まり、ページのレンダリングが完全に止まってしまう
実際かなり良いアプローチで、アニメーション/イージング手法の概念実証としても良い。Flickity を強く思い出す。
https://metafizzy.co/blog/initial-demos/
https://metafizzy.co/blog/math-time-resting-position/
https://metafizzy.co/blog/particle-to-slider/
https://metafizzy.co/blog/flickity-begins/
特にこのデモ: https://codepen.io/desandro/pen/myXdej
この手法はスイッチにだけ有用なわけではない。実際、スイッチにこうした要素を使うこともないだろうし、サイト全体で
requestAnimationFrameループを20個並列に走らせることもないだろう。意図的に壊れた要素を入れることもないはずだ。また、デルタが十分小さくなったらレンダリングを止める最適化もなく、本番品質にするための小さな調整はさらに何十個もある可能性が高い。
ここの反応を見ると、記事を読まずに決めつけているか、木を見て森を見ていないか、偏見と冷笑が強すぎて、賢く見せようとして素人判断を共有しているように見える。
HN はいつから Reddit みたいになったのか?
HN が Reddit のように変わったという点については、技術全般に漠然とした冷笑的な空気があるのかもしれない。政府や企業が台頭しつつある強力な技術をどう使おうとしているのか、個人の自由を拡大するのではなく、個人を制限し管理する意図で構築しているのではないかという不信が根底にある。
エモーショナルデザイン(https://en.wikipedia.org/wiki/Emotional_Design)の真髄に近い。ごく小さなアニメーションの裏にも、語るべきことは多い。