Apr 08, 2009

今更聞けない可変フレームレートなトゥイーンの基本

1つ前のエントリに書いた、可変フレームなトゥイーン効果の実装の話。

今時は便利なJavaScriptのアニメーション用ライブラリが色々あるからわざわざ自分で書くような必要はないんだろうけど、自分はほんの一箇所だけのためにライブラリ全部突っ込むというのは気が引けるタイプなので、ピンポイントな実装とその理屈を(雑学として)書いておこう。

先に、タブのインデント幅変更の処理の完成したものを貼っておく。説明を簡単にするためにちょっと省略してる。

indentDuration : 200,

updateTabIndent : function(aTab, aProp, aIndent)
{
  this.stopTabIndentAnimation(aTab);

  var startIndent = this.getPropertyPixelValue(aTab, aProp);
  var delta       = aIndent - startIndent;
  var duration    = this.indentDuration;
  var startTime   = Date.now();

  aTab.__treestyletab__updateTabIndentTimer = window.setInterval(function(aSelf) {
    var progress = Math.min(1, (Date.now() - startTime) / duration);
    var powerForStyle = Math.sin(90 * power * Math.PI / 180);
    var indent = (progress == 1) ?
                aIndent :
                startIndent + (delta * powerForStyle);
    aTab.setAttribute(
      'style',
      aTab.getAttribute('style')+';'+
        aProp+':'+indent+'px !important;'
    );

    if (progress == 1) aSelf.stopTabIndentAnimation(aTab);
  }, 10, this);
},

stopTabIndentAnimation : function(aTab)
{
  if (!aTab.__treestyletab__updateTabIndentTimer) return;
  window.clearInterval(aTab.__treestyletab__updateTabIndentTimer);
  aTab.__treestyletab__updateTabIndentTimer = null;
},

getPropertyPixelValue : function(aElement, aProp) 
{
  var style = window.getComputedStyle(aElement, null);
  return Number(style.getPropertyValue(aProp).replace(/px$/, ''));
},

なんでCSSOM使ってないの、って所にはツッコまないように。

アニメーションというと、つまりはちょっとずつ値を変えて再描画するということで、単純に考えたら多分こうなる。

function doAnimation(aElement, aStart, aEnd)
{
  for (var i = aStart; i < aEnd; i++)
  {
    aElement.style.marginLeft = i+'px';
  }
}

でもこれはダメ。CSSのプロパティを変更しても、その状態が描画されるより前に次の値がセットされてしまうので、間のアニメーションがアニメーションにならない。(CSSのプロパティが変更された瞬間に再描画する実装だったらこれでもいいかもだけど、少なくともGeckoではダメ。)

もうちょっと改良すると、こう。

function doAnimation(aElement, aStart, aEnd)
{
  var margin = aStart;
  var timer = window.setInterval(function() {
            aElement.style.marginLeft = (margin++)+'px';
            if (margin == aEnd) {
              window.clearInterval(timer);
            }
          }, 10);
}

こういう風にタイマーを使ってやれば、きちんと各コマが描画されるようになる。

しかしこのやり方だと、最初の値から最後の値までの全コマが必ず描画される(=フレームレート固定)ので、貧弱な環境だとものすごい遅いアニメーションになってしまう。上の例だと、10ミリ秒ごとに1ピクセルずらして、というのを移動距離の分だけ繰り返すことになってしまう。

「高速な環境ではたくさん描画していいけど、低速な環境だと再描画を減らしてほしい。とにかく、1回のアニメーションは決まった時間の中できちんと終わらせたい。」これが可変フレームレートの基本的な考え方。

可変フレームレートにする場合、描画と描画の間にどれだけ時間がかかったか、というのが鍵になる。

JavaScriptのsetIntervalのタイマーは、(少なくともGecko/SpiderMonkeyでは、)仮に実行間隔を10ミリ秒にした場合、実際には「1回目の処理の時間」+「10ミリ秒のインターバル」+「2回目の処理の時間」+「10ミリ秒のインターバル」……という風な感じで時間が過ぎていく。アニメーションにかける全体の時間が分かっているなら、「n回目の描画開始時の時点で、アニメーションにかける時間全体の何%が過ぎたか」をまず計算して、移動量をそのパーセンテージから求めてやればいい。

function doAnimation(aElement, aStart, aEnd)
{
  var delta     = aEnd - aStart;
  var duration  = 200; // アニメーション効果全体を200ミリ秒で終わらせる
  var startTime = Date.now(); // (new Date()).getTime() と同等
  var timer = window.setInterval(function() {
        var progress = Math.min(
                      1,
                      (Date.now() - startTime) / duration
                    );
        var margin = (progress == 1) ?
                       aEnd :
                       aStart + (delta * progress) ;
        aElement.style.marginLeft = margin+'px';
        if (progress == 1) {
          window.clearInterval(timer);
        }
      }, 10);
}

これで、可変フレームレートのアニメーションになった。progressはアニメーションの進行状況を示していて、0(開始時点=0%)から1(終了時点=100%)の間の値を取る。開始時の値と終了時の値の差にこれをかけてやれば、「今の時点ではこれだけ移動してるはず(その位置に描画すればよい)」ということが分かるワケ。(ちなみにこの時点で、値が増加していくのか減少していくのかどっちなんだ、ということも気にせずに済むようになっている。)

この時点では、値の変化率は一定なので、理科の時間に習う「等速直線運動」ってやつになっている。実際のUIでこれをそのまま使うとちょっと味気ないので、もうちょっとかっこよくしてやりたくなるところだ。可変フレームレートならそれも簡単にできる。

よくあるのは、「最初はすごい速度で飛んできて、最後はフワッと着地する」みたいな効果だろう。これは三角関数のsin()やcos()を使えば簡単に計算できる。

円と複素数平面の勉強をしたことがあれば、0°から90°の間を1°ずつ動く間に、Xの値は「最初はゆっくりと、最後は急速に」Yの値は「最初は速く、最後はゆっくり」増加していくことが分かるだろう。sin(θ)やcos(θ)を使えば、この「XやYの値の変化率」を取り出して利用できる。

例えば「最初は速く、最後はゆっくり」のアニメーションなら、上の例の(delta * progress)の所を(delta * Math.sin( (progress * 90) * Math.PI / 180 ))にすればいい。これで、0°から90°までの間のYの値の変化率に応じた移動量になる。(Math.sin()Math.cos()は角度をラジアンで指定しないといけないので、θ×π÷180で度数をラジアンに変換している。)

以上、中学や高校で勉強する数学って案外無駄にならないんだよ、というお話でした。

ちなみに、aStart + (delta * progress)の所あたりを任意の式に置き換えれば、動き方を等速直線運動や等加速度運動やはね回る等の色んな形に変えることができる。これを、「<ruby><rb>最初の値</rb><rp>(</rp><rt>Start</rt><rp>)</rp></ruby>・<ruby><rb>値の最終的な変化量</rb><rp>(</rp><rt>total Change</rt><rp>)</rp></ruby>・<ruby><rb>アニメーションにかけたい時間</rb><rp>(</rp><rt>Duration</rt><rp>)</rp></ruby>・<ruby><rb>現在までに過ぎた時間</rb><rp>(</rp><rt>Time</rt><rp>)</rp></ruby>(最後の2つから現在の進度が分かる)という4つのパラメータを受け取り現在の値を返す関数」として定義した物をeasing関数と呼ぶそうで、世にある多くのアニメーションライブラリは、このeasing関数を入れ替えることで色んなエフェクトを得られるようにしたもの、と言うことができる。

……という風に読み解いてみると、普段使ってるライブラリが一体何をどのように処理しているのか分かっておもしろいんじゃないでしょうか。僕はライブラリ使ってませんが。

追記。Norahさんのこれって yield 使ったらダメなんだろうか?というコメントを見た。べつに使っちゃダメってことはないし、むしろ自分も一瞬「あれ、これyieldで書けるんじゃね?」と思ったんだけど、どっちにしろタイマーでループ回すという事には変わりないし、そうなるとここで書いてるくらいの規模だとコードの量が無駄に増えるだけって気がしたので、そのままタイマーだけで書いた次第です。

追記。easing関数の説明を間違えてたので修正しました……

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能