最近は Web Animation API なるものが現れて、ついにjQueryのアレがWeb標準にとしみじみとするところですが、JSで生のアニメーションコードを書くこともあるかと思います。そんな時のちょっとしたTipsです。

コードでぼんやりアニメーションを書いてみる

A - Bに値が変化するアニメーションを考えますと線形補完が浮かんできます。何も考えずに、こんな形で書いてみます。

function lerp(a: number, b: number, time: number, totalTime: number){
  return (totalTime - time) / totalTime * a + time / totalTime * b;
}

そうすると、このような形で利用できます。

const startTime = Date.now();
function animate(){
  const elapsedTime = (Date.now() - startTime) / 1000;

  const value = lerp(0, 100, elapsedTime, 5);
  console.log(value);
  if(elapsedTime < 5){
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

わかりやすいですね。では、イージング関数をつけて、挙動にメリハリをつけてみます。
良くあるイージング関数を準備します。 引用元: イージング関数チートシート

function easeInOutQuad(x: number): number {
  return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}

この関数を、先ほどの描画関数に仕込んでみます。

const startTime = Date.now();
function animate(){
  const elapsedTime = (Date.now() - startTime) / 1000;

  const easedTime = easeInOutQuad(elapsedTime / 5) * 5;
  const value = lerp(0, 100, elapsedTime, 5);
  console.log(value);

  if(elapsedTime < 5){
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

雲行きが怪しくなってきましたが、次に行って戻るようなpingPong関数を追加してみます。

function pingPong(time: number, totalTime: number){
  const clamped = Math.min(Math.max(0, time), totalTime);
  return clamped < totalTime / 2 ? clamped * 2 : (totalTime - clamped) * 2;
}
const startTime = Date.now();
function animate(){
  const elapsedTime = (Date.now() - startTime) / 1000;

  const easedTime = easeInOutQuad(elapsedTime / 5) * 5;
  const value = lerp(0, 100, pingPong(elapsedTime, 5), 5);
  console.log(value);

  if(elapsedTime < 5){
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

なんとか実装できましたが、やや匂いの強いコードになってきました。

  • コードを眺めると「5」というシンボル(変数かもしれない)が何度か出てきます。
  • 利用者側のコードも、ロジック側のコードも何やら複雑になってしまいました

ことの問題は、トータル時間を計算の中に混ぜてしまったのが問題のようです。

そうだ、時間なんてすべて0-1で正規化してしまえばいいじゃない

そもそもですが、時間をわざわざ関数ロジックで扱う必要なんてなかったんです。

すなわち、あるアニメーションをする関数がある場合、 f(t: 単位時間) としてしまえば、1秒の時は、 f(time) 5秒の時は f(time/5) 10秒の時は f(time/10) と表現することができます。

そのようなアイディアで、コードを書き直します。

function lerp(a: number, b: number, time: number){
  return (1 - time) * a + time * b;
}

function pingPong(time: number){
  const clamped = clamp01(time);
  return clamped < 1 / 2 ? clamped * 2 : (1 - clamped) * 2;
}

// 参考
function clamp01(x: number){
  return x < 0 ? 0 : x > 1 ? 1 : x;
}

どうでしょうか、大変すっきりして、再利用できそうな感じがします。最後に利用側のコードを書いてみます。

const startTime = Date.now();
function animate(){
  const elapsedTime = (Date.now() - startTime) / 1000;

  const time = elapsedTime / 5;
  const value = lerp(0, 100, pingPong(easeInOutQuad(time)));
  console.log(value);

  if(elapsedTime < 5){
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

時間計算部分が0-1で正規化されたため、時間入力から値の取得までがきれいな形になりました。この形でライブラリを大きくしていっても、さまざまな関数の互換性が得られ、最大時間を引き回すような必要性もなくなります。

以上から、コードでアニメーション時間を扱う時は0-1の正規化するのがおすすめです。