マガジン

requestAnimationFrameとは:ゲームがカクつく原因と直し方

遊びをつくる

執筆にClaudeを使用しています。

紙の飛行機が、海辺の小さな街と山の上を飛んでいく絵。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

ブラウザゲームを作っていて「動きがカクカクする」「端末によってスピードが変わる」という問題に直面したとき、その原因のほとんどは描画ループの作り方にあります。requestAnimationFrame(以下 rAF)はその問題を解決するためにブラウザが用意した仕組みです。 setIntervalやsetTimeoutでゲームループを回していた場合、タイマーの精度が低く、ブラウザの描画タイミングとずれることでカクつきが生じます。rAF はブラウザの描画サイクルに合わせてコールバックを呼ぶため、無駄な描画が減り、動きが滑らかになります。 この記事では rAF の仕組みを整理したうえで、よくある実装ミスと、端末ごとのフレームレート差を吸収する「デルタタイム」の使い方まで順を追って説明します。

この記事の目次
  1. なぜ setInterval ではカクつくのか
  2. rAF の基本的な使い方
  3. デルタタイムで端末差を吸収する
  4. よくある実装ミスとその直し方
  5. パフォーマンスを測る指標と判断の目安
  6. 次の行動

なぜ setInterval ではカクつくのか

setInterval(update, 16) のように書くと「16ミリ秒ごとに実行」を指示できます。しかし実際には次の二つの問題が起きます。

一つ目はタイマーの精度問題です。ブラウザはタブの状態や他の処理の混雑によってタイマーを遅延させます。指定した16msのつもりが20msになったり12msになったりするため、アニメーションが一定速度で進みません。

二つ目は描画との非同期問題です。ブラウザの画面更新(リフレッシュ)は通常60Hzのモニターで約16.7ms周期で行われます。setIntervalはこの周期を知らないため、描画の直後に更新処理が走ったり、一つのフレームに二回走ったりします。前者は無駄な計算、後者は表示されない描画を生み出し、どちらもカクつきの原因になります。

requestAnimationFrameはこの両方を解決します。ブラウザが「次に画面を更新するタイミング」を判断し、そのタイミングに合わせてコールバックを一回だけ呼びます。タブが非表示のときは自動的に停止するため、バックグラウンドでCPUを浪費することもありません。

rAF の基本的な使い方

最小限の構造は次のようになります。

javascript
function gameLoop() {
  update();   // ゲーム状態の更新
  draw();     // 描画
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

requestAnimationFrame(gameLoop) を関数の末尾で呼ぶことで、ループが続きます。最初の一回だけ外から呼び出せばあとは自走します。

ループを止めたいときは戻り値の ID を使います。

javascript
let animationId;

function gameLoop() {
  update();
  draw();
  animationId = requestAnimationFrame(gameLoop);
}

animationId = requestAnimationFrame(gameLoop);

// 停止するとき
cancelAnimationFrame(animationId);

ゲームオーバー画面や一時停止を実装するとき、cancelAnimationFrameを呼ばずにループを「放置」すると複数のループが同時に走り始める原因になります。停止のタイミングを明示的に管理するのが重要です。

デルタタイムで端末差を吸収する

rAF を使っても、まだ問題が残ります。60Hzのモニターと120Hzのモニターでは呼ばれる頻度が変わるため、「フレームごとに5ピクセル動かす」という実装では120Hzの端末でキャラクターが2倍の速度で走ります。

これを解決するのがデルタタイム(前フレームからの経過時間)の活用です。rAF のコールバックは引数として高精度タイムスタンプ(DOMHighResTimeStamp)を受け取れます。

javascript
let lastTime = 0;

function gameLoop(timestamp) {
  const delta = timestamp - lastTime; // ミリ秒単位の経過時間
  lastTime = timestamp;

  update(delta);
  draw();
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

updateの中では「1秒あたりの移動量」を基準にします。

javascript
const SPEED = 300; // 1秒あたり300ピクセル

function update(delta) {
  player.x += SPEED * (delta / 1000); // delta はミリ秒なので1000で割る
}

こうすることで、フレームレートがいくつでも1秒間の移動距離が一定になります。60Hzなら1フレーム約5ピクセル、120Hzなら約2.5ピクセルずつ動き、合計で同じ300ピクセルになります。

初回フレームの大きなデルタへの対処

一点だけ注意があります。ゲーム開始直後や、タブを長時間放置してから再度アクティブにしたとき、deltaが極端に大きな値(数百ミリ秒から数秒)になることがあります。そのまま使うとキャラクターが画面外へ飛び出すことがあるため、上限を設けるのが安全です。

javascript
const MAX_DELTA = 100; // ミリ秒
const delta = Math.min(timestamp - lastTime, MAX_DELTA);

100msを上限にすると、極端な遅延があったフレームでも最大6フレーム分(60fps換算)の動きに抑えられます。適切な上限値はゲームの速度感に合わせて調整してください。

よくある実装ミスとその直し方

rAF に切り替えた直後に起きやすいミスをまとめます。

ループが二重に走る:ゲームの初期化関数を複数回呼んだとき、rAF のループがその分だけ起動します。cancelAnimationFrameで前のループを止めてから新しいループを始める、あるいは「すでにループが起動しているか」をフラグで管理するのが確実です。

停止後も描画が続く:cancelAnimationFrameを呼んでも、すでにキューに入っているコールバックは実行されます。コールバックの先頭で「停止フラグ」を確認して早期リターンするのが安全です。

javascript
let running = false;

function gameLoop(timestamp) {
  if (!running) return;
  // ...
  requestAnimationFrame(gameLoop);
}

Canvas のサイズとCSS表示サイズがずれている:rAF とは直接関係しませんが、カクつきの原因として同時に現れることが多い問題です。canvas.widthとcanvas.style.widthが一致していないと、描画がぼやけたりスケールがずれたりします。Canvas の論理サイズ(width属性)と表示サイズ(CSSのwidth)は意図的に分離して管理してください。

パフォーマンスを測る指標と判断の目安

滑らかさの基準として、一般的に60fps(1フレーム約16.7ms以内)が目標値として使われます。rAF を使っていても、updateやdrawの処理が重くなると1フレーム16msを超え、コマ落ちが起きます。

ブラウザの開発者ツール(ChromeならPerformanceパネル)でフレームタイムを記録すると、どの処理が時間を食っているか確認できます。Canvasへの描画処理、当たり判定のループ、DOM操作のいずれかが原因になっていることが多いです。

判断の目安として、updateとdrawを合わせた処理時間が常に8ms以下に収まっていれば、rAF の呼び出し間隔(約16.7ms)に十分な余裕があります。8msを超えるフレームが頻繁に出始めたら、処理の最適化か描画範囲の絞り込みを検討してください。

次の行動

まず手元のゲームで setIntervalやsetTimeoutによるループを探し、この記事の基本構造に置き換えてみてください。デルタタイムを導入したあとは、ブラウザの開発者ツールのPerformanceパネルでフレームタイムを確認し、16ms以内に収まっているか確認するのが次のステップです。HTMLファイルやZIPとしてまとめたゲームはアソバース(https://aso-verse.com)に投稿して、実際の端末でのなめらかさを確かめることもできます。