JavaScript で描画ループに requestAnimationFrame
を使いつつフレームレートを調節する方法についてです。
HTML5 では setTimeout
の代わりに requestAnimationFrame
を利用してアニメーションループを作成することが推奨されています。実際、setTimeout
を用いて 60FPS に調整したコンテンツをスマホで見ると、画面更新のタイミングが合わないことがあるらしく、体感 FPS が 60 未満になっているように感じられることがありました。一方 requestAnimationFrame
を利用するようにしたところ、60FPS の滑らかなアニメーション体験が得られました。
では今後は requestAnimationFrame
を使いましょうということになるのですが、困ったことに requestAnimationFrame
は 60FPS で呼び出される保証がない のです。次のアニメーションフレームに最適なタイミングで呼び出されるため、環境によっては 30FPS になったり 144FPS になったりすることがあります。
アニメーションの速度が関数の呼び出し頻度に依存しない場合(=時刻のみによって状態が決定する)は特に問題ないのですが、ゲームや物理シミュレーションなどの場合は固定フレームレートで実行しないと動作が不安定になるため、呼び出し頻度の影響をモロに受けることになります。サイトのトップページもそうなっていました。(144Hz のモニタでサイトがやたら速く動く報告を受けて発覚しました)
ではどうするかというと、状態の更新と描画を分離して requestAnimationFrame
の内部で状態更新の関数を呼び出す回数を調整するしかありません。例えば requestAnimationFrame
が秒間 30 回呼び出される環境で 60FPS と同じアニメーションの速さを実現するには、一度の呼び出しで状態更新の関数を 2 回呼び出す……という具合です。実際のところは綺麗な分数になるとは限らないので、実時間経過を測定しながら呼び出し回数を動的に調整します。
疑似コードにするとこんな感じです。
const UPDATE_LOAD_COEFF = 0.5;
let targetInterval = 1000 / fps;
let prevTime = Date.now() - targetInterval;
function loop() {
let currentTime = Date.now();
let updated = false;
while (currentTime - prevTime > targetInterval * 0.5) {
update();
updated = true;
prevTime += targetInterval;
const now = Date.now();
const updateTime = now - currentTime;
if (updateTime > targetInterval * UPDATE_LOAD_COEFF) {
// overloaded
if (prevTime < now - targetInterval) {
// do not accumulate too much
prevTime = now - targetInterval;
}
break;
}
}
if (updated) {
draw();
}
requestAnimationFrame(loop);
}
loop();
追記: コード内では Date.now()
が使われていますが、performance.now()
でもいいと思います。というか今なら多分こっちの方がいいです。
UPDATE_LOAD_COEFF
は CPU 負荷が爆発しないようにするための定数で、1 フレームの間に使用可能な時間のうち、状態更新に割り当てられる時間の割合の最大値です。この時間を超えて処理が行われると、フレームスキップが無効になって処理落ちが発生します。
oimo.io と works にある作品すべてが呼び出し頻度の影響を受けていたので、上記の処理を入れてアップロードし直しました。もう節電モードの iPhone でトップページを見てもアニメーション速度が半分になることはないはずです。