[JavaScript] requestAnimationFrameに関するトラブルシューティングと実際の問題解決事例

1. イントロ

requestAnimationFrame(略して rAF)は、ゲームやアニメーションの描画において非常に便利な関数ですが、実際に使用すると意外な罠にハマることがあります。特に、同期非同期の間で状態が変化するような場合、予期しない挙動が発生しやすいです。

なぜ requestAnimationFrame がバグりやすいのか?それは、アニメーションフレームの描画がブラウザのレンダリングエンジンに依存しているためです。ここでは、フレームごとのタイミングの問題、状態変更のタイミングがずれることによるエラー、リソースの再描画タイミングがズレてしまうことなどが関わってきます。

例えば、昨日のブロック崩しゲーム開発中に直面した問題では、ゲームの再開処理アニメーションの停止/再開のタイミングがうまく噛み合わず、ゲームの進行が不安定になり、最終的にはrequestAnimationFrameの呼び出しタイミングが原因で意図しない再描画が発生してしまいました。

この問題について、後半で詳しく掘り下げつつ、requestAnimationFrameを使う際に気をつけるべきよくあるトラブル事例とその対処法を紹介します。

2. requestAnimationFrame の正体

requestAnimationFrame(rAF)は、単なるアニメーションのための関数ではありません。その背後にある動作や特性を理解することは、問題解決の大きな手助けになります。ここでは、rAFが何をしているのか、他のタイマー関数(setIntervalやsetTimeout)との違いを明確にし、正しく使うための基礎知識を解説します。

1. 「1フレーム先を予約する関数」であること

requestAnimationFrameの基本的な役割は、次のレンダリングフレームの描画をブラウザに予約することです。通常、ブラウザはフレームを60Hz(1秒間に60回の描画)で描画しますが、rAFを使うことで、次の描画タイミングに合わせて描画処理を行うことができます。

具体的には、次のフレームが描画される前に、指定されたコールバック関数を実行するようブラウザに伝えます。これにより、アニメーションが滑らかになり、CPUやGPUのリソースを効率的に使うことができます。

// 例: requestAnimationFrameの使い方
function animate() {
  // アニメーションの処理
  requestAnimationFrame(animate); // 次のフレームを予約
}
requestAnimationFrame(animate); // 初回呼び出し

2. 「現在実行中のコールバックは cancel できない」こと

requestAnimationFrameの呼び出しは、現在実行中のコールバック関数を直接キャンセルすることはできません。具体的には、requestAnimationFrameが呼び出されると、そのコールバックは次のフレームが描画されるまで実行されません。

しかし、cancelAnimationFrameを使って予約されたアニメーションフレームをキャンセルすることはできます。これにより、アニメーションが必要なくなった場合などに、描画の処理を停止させることができます。

let frameId = requestAnimationFrame(animate);

// キャンセル
cancelAnimationFrame(frameId);

3. setInterval / setTimeout との違い

requestAnimationFrameは、setIntervalやsetTimeoutとは異なる動作をします。それぞれの違いを理解することは、適切なタイミングで適切な方法を選択するために重要です。

  • setInterval / setTimeout: これらは指定した時間後または繰り返しでコールバック関数を実行します。しかし、これらはブラウザの描画フレームのタイミングとは関係なく、指定された時間に関数を実行しようとします。これにより、アニメーションがフレームレートに合わなくなることがあり、特に動作がカクついたりすることがあります。

  • requestAnimationFrame: rAFは、ブラウザのレンダリングエンジンに最適化されており、フレームレートに基づいてアニメーションを描画します。これにより、CPUの負荷を最小限に抑えつつ、滑らかなアニメーションを実現できます。さらに、ブラウザがビジーな場合やタブが非アクティブになった場合、rAFは自動的にフレームレートを調整し、無駄なリソースの消費を防ぎます。

まとめ

  • requestAnimationFrameは次のレンダリングフレームで実行するコールバックを予約するための関数。
  • 予約されたコールバックはキャンセルできないが、cancelAnimationFrameで予約自体をキャンセルすることは可能。
  • setIntervalやsetTimeoutは描画に関する最適化がないが、requestAnimationFrameはブラウザの描画タイミングに合わせて最適化されており、アニメーションのパフォーマンス向上に貢献する。

これらを理解することで、requestAnimationFrameを適切に使いこなし、スムーズなアニメーションと効率的なリソース管理が実現できます。

3. よくあるトラブルパターン集

requestAnimationFrame(rAF)の使用中に遭遇することの多いトラブルパターンを集めました。これらは、アニメーションをスムーズに実行する上で問題を引き起こす要因となります。それぞれのパターンについて、原因と解決策を解説します。

(A) ループが二重起動して加速する問題

症状: requestAnimationFrameを使っていると、意図せずアニメーションのループが二重に起動してしまい、処理が加速してしまうことがあります。この問題は、ループが複数回呼び出されることで発生します。

原因: requestAnimationFrameは非同期で実行されるため、アニメーションのフレームが重なって複数回呼び出されることがあります。このため、同じフレームが何度も描画され、意図しない動作が発生します。

解決策: ループの中で、状態フラグやキャンセル処理を確実に行い、複数回の実行を防ぐことが重要です。具体的には、以下のようにアニメーションフレームが重複しないようにする方法があります。

let isAnimating = false;
function animate() {
  if (isAnimating) return;
  isAnimating = true;
  requestAnimationFrame(animate);

  // アニメーションの処理
  // ここに描画や更新処理を入れる

  isAnimating = false;
}
requestAnimationFrame(animate);

(B) cancelAnimationFrame してるのに止まらない問題

症状: cancelAnimationFrameを使ってアニメーションを停止しているはずなのに、アニメーションが停止しないという問題です。requestAnimationFrameで予約されたフレームがキャンセルされていないと、描画が続きます。

原因: cancelAnimationFrameは、指定されたフレームIDに対してのみ効果があります。もしrequestAnimationFrameを呼び出す際に異なるIDを使っていたり、requestAnimationFrameの呼び出しタイミングが不適切だった場合、キャンセルできていないことがあります。

解決策: cancelAnimationFrameが正しく機能するように、必ずそのフレームIDを追跡し、正しいタイミングでキャンセルするようにします。また、アニメーションを完全に停止するためには、requestAnimationFrameの呼び出し自体を制御する必要があります。

let frameId;
function startAnimation() {
  frameId = requestAnimationFrame(animate);
}

function stopAnimation() {
  cancelAnimationFrame(frameId);
}

function animate() {
  // アニメーションの更新
  startAnimation(); // ループを続ける
}

(C) 状態フラグと requestAnimationFrame の順番レース

症状: requestAnimationFrameと状態フラグ(gameState.isActiveなど)を組み合わせて使っている場合、状態フラグの変更タイミングとアニメーションの実行タイミングがずれて、状態が正しく反映されないことがあります。

原因: requestAnimationFrameは非同期で呼び出されるため、状態フラグが変更された後にフレームが描画されるタイミングで、まだ古い状態が使用されている場合があります。これが原因で、フレーム描画が期待通りに反映されないことがあります。

解決策: 状態変更とアニメーションの実行順序をしっかり制御し、状態が更新されたことを保証してからアニメーションを描画するようにします。例えば、次のフレームで状態をチェックしてから描画を開始する方法が有効です。

let gameState = { isActive: false };
function animate() {
  if (!gameState.isActive) return; // 状態が無効な場合、アニメーションを開始しない
  requestAnimationFrame(animate);

  // アニメーションの更新
}

(D) async/await や setTimeoutと混ぜてカオスになる問題

症状: async/awaitやsetTimeoutとrequestAnimationFrameを組み合わせると、非同期処理のタイミングがずれ、アニメーションが意図しないタイミングで実行されることがあります。

原因: async/awaitはPromiseが解決されるまで次の処理を待つため、アニメーションのフレームが遅れて実行されてしまうことがあります。同じように、setTimeoutも指定した時間後に処理を実行するため、アニメーションのタイミングに影響を与える可能性があります。

解決策: 非同期処理とrequestAnimationFrameは異なる目的で使われるため、それぞれの用途を明確に分けることが重要です。setTimeoutやasync/awaitはアニメーションとは独立して使い、アニメーション処理が必要なタイミングでrequestAnimationFrameを使うようにします。

async function startGame() {
  await loadResources();
  requestAnimationFrame(startAnimation); // 非同期処理後にアニメーションを開始
}

(E) 「ゲーム再開」「リスタート」で地獄を見るパターン(今回)

症状: ゲームの再開やリスタート時に、requestAnimationFrameの処理がうまくリセットされず、アニメーションが不安定になったり、二重に描画されることがあります。

原因: ゲームの再開やリスタート処理のタイミングで、requestAnimationFrameがうまくキャンセルされていない、もしくは複数回呼び出されている場合、アニメーションの描画が重複してしまうことがあります。特に、isGameActiveなどのフラグが正しく管理されていないと、このような問題が発生します。

解決策: ゲームの状態が変わるタイミングで、確実にアニメーションフレームをリセットし、描画処理が一度だけ実行されるようにします。リスタート時には、状態フラグを明示的に管理し、アニメーションの再開前にすべての処理を初期化することが重要です。

function restartGame() {
  gameConfig.gameState.isGameActive = false;
  cancelAnimationFrame(gameConfig.animation.animationFrameId);
  gameConfig.animation.animationFrameId = null;

  // 必要な初期化処理
  resetBall();
  gameConfig.gameState.isGameActive = true;
  draw(); // リスタート後に描画
}

これらのトラブルパターンに注意し、適切に対処することで、requestAnimationFrameをよりスムーズに使いこなすことができます。ゲーム開発において、アニメーションは重要な要素ですが、非同期処理や状態管理のタイミングに注意を払い、コードが意図通りに動作するように工夫することが求められます。

4. ケーススタディ:ブロック崩しのリスタートバグ

ここでは、実際にブロック崩しゲームで発生したリスタートバグの事例をもとに、問題の原因と解決策を整理します。特に、restartGameが二重に呼ばれてしまう問題と、その対処法について掘り下げます。

4.1 実際に起こったことを図付きで整理

まず、ゲームのリスタート処理がどのように動作しているかを整理します。

  • プレイヤーのボールが画面外に出ると、「MISS」判定が発生し、残機を減らす処理が呼ばれます。
  • その後、restartGame関数が実行され、ゲームがリスタートします。
  • しかし、アニメーションのループが適切に停止されないため、restartGameが二重に呼ばれ、問題が発生します。

以下はその流れを示した図です:


[ボールが画面外] -> [MISS判定] -> [restartGame] -> [ゲーム状態変更] -> [二重呼び出し]

二重呼び出しの問題

restartGame関数が二重に呼ばれる原因は、ゲーム状態の管理が不完全であることです。具体的には、ゲーム状態を無理にリセットしようとした際に、アニメーションが完全に停止する前にゲームが再開されてしまうことが関係しています。

4.2 「なぜ restartGame が2回呼ばれたのか」

restartGame関数が二重に呼ばれる理由は、非同期処理の影響を受けていることが主な原因です。具体的には、以下の2点が関係しています。

  • requestAnimationFrame のアニメーションが停止する前に、次のフレームがリクエストされているため、restartGameがもう一度呼び出されてしまう。
  • ゲームの再開フラグ (gameConfig.gameState.isGameActive) が予期せず true に設定され、二重にrestartGameが呼び出されることがある。

例えば、次のようなコードでは、ゲームがリスタートした際にisGameActiveがtrueに設定されるため、再びアニメーションが開始され、二重に実行されることになります。

function restartGame() {
  if (!gameConfig.gameState.isGameActive) {
    return; // ゲームがアクティブでない場合は何もしない
  }
  gameConfig.gameState.isGameActive = false; // ゲームを一時停止
  cancelAnimationFrame(gameConfig.animation.animationFrameId);
  gameConfig.animation.animationFrameId = null; // アニメーションを停止

  // ゲーム状態のリセット
  resetBall();
  gameConfig.gameState.isGameActive = true; // ゲームを再開
  draw(); // リスタート処理
}

このように、gameConfig.gameState.isGameActiveが不完全にリセットされることで、二重にrestartGameが実行される原因となっています。

4.3 「sleep(100) を入れたら安定した理由」

問題が解決しない理由の1つとして、アニメーションのフレーム処理がリセットされる前に次のフレームが描画されてしまっていたことが考えられます。そこで、sleep(100)を使用することで、アニメーションが停止するまで少し待機し、その後処理を再開するようにしました。

sleep(100)を追加した理由は、アニメーションのループが完全に停止してから次の処理が行われるようにするためです。これにより、ゲームのリスタート処理が完全に停止するタイミングで実行され、二重呼び出しを防ぐことができました。

async function restartGame() {
  if (!gameConfig.gameState.isGameActive) {
    return;
  }

  // ゲームアクティブを解除
  gameConfig.gameState.isGameActive = false;

  // アニメーションを停止
  cancelAnimationFrame(gameConfig.animation.animationFrameId);
  gameConfig.animation.animationFrameId = null;

  // 少し待機
  await sleep(100); // 100ミリ秒待機

  // リスタート処理
  resetBall();
  gameConfig.gameState.isGameActive = true;
  draw(); // ゲームを再開
}

await sleep(100)を挿入することで、アニメーションが完了してから次のフレームを描画するように調整しました。これにより、予期しないタイミングでフレームが重なったり、restartGameが二重に呼び出されることを防ぐことができました。


このケーススタディでは、requestAnimationFrameと状態管理に関する複雑な問題を解決するための手順を解説しました。問題を解決するために、非同期処理を使ってタイミングを調整する方法が有効であることがわかります。また、状態フラグとアニメーションの管理が適切に行われることの重要性も再確認できました。

5. 安全なループ設計パターン

ゲームにおいてループ処理を適切に管理することは非常に重要です。特に、requestAnimationFrameを使ったアニメーションループを実装する際には、コードが複雑になりすぎないように注意しなければなりません。以下のポイントに従って、アニメーションループを安全に管理する方法を紹介します。

5.1 「ループを回す場所は1か所だけ」にする

複数箇所でrequestAnimationFrameを呼び出すと、ループが二重に起動してしまったり、予期しない挙動が発生する原因になります。そのため、ゲーム内でアニメーションを管理する場所は1か所だけにしておくことが基本です。

アニメーションのループ処理を1か所にまとめ、draw()関数の外で呼び出すのが良い設計パターンです。これにより、ゲームの状態管理が簡単になり、デバッグも容易になります。

例えば、以下のようにアニメーションの開始と停止を適切に制御します:

let animationFrameId = null;
let isRunning = false;

function startAnimation() {
  if (isRunning) return; // すでにアニメーションが実行中の場合は終了
  isRunning = true;
  requestAnimationFrame(draw); // ループを開始
}

function stopAnimation() {
  if (!isRunning) return; // すでにアニメーションが停止している場合は終了
  isRunning = false;
  cancelAnimationFrame(animationFrameId); // ループを停止
}

function draw() {
  // ゲームの描画処理
  if (isRunning) {
    animationFrameId = requestAnimationFrame(draw); // 次のフレームをリクエスト
  }
}

このように、requestAnimationFrameは1か所でのみ呼び出すようにし、startAnimationとstopAnimationでゲームのアニメーションを制御します。

5.2 状態は isRunning / gameState で制御

アニメーションループの状態をisRunningやgameStateなどのフラグで管理すると、状態が明確になります。これにより、ゲームの状態に基づいて適切にループを制御できます。

例えば、gameState.isGameActiveなどを用いて、ゲームがアクティブなときだけループを回すようにすると、不要な処理が起きません。

const gameState = {
  isGameActive: true, // ゲームの状態(開始・停止)
};

function update() {
  if (!gameState.isGameActive) return; // ゲームがアクティブでない場合は処理しない
  // ゲーム更新処理
  // 例: キャラクターの移動、アイテムの管理など
}

function draw() {
  if (!gameState.isGameActive) return; // ゲームがアクティブでない場合は描画しない
  // 描画処理
  update();
  requestAnimationFrame(draw); // ループ処理
}

5.3 再開ロジックは draw() の外でトリガーする

アニメーションの再開ロジックをdraw()関数内で実行しないようにしましょう。draw()関数は描画処理に専念させ、ゲームの状態を制御する部分はdraw()の外でトリガーします。

例えば、ゲームが一時停止している場合、再開する際にdraw()関数を呼び出すだけで、状態が変わるように設計します。

function pauseGame() {
  gameState.isGameActive = false; // ゲームを停止
  cancelAnimationFrame(animationFrameId); // アニメーション停止
}

function resumeGame() {
  gameState.isGameActive = true; // ゲームを再開
  startAnimation(); // アニメーションを開始
}

resumeGame()の中で、draw()関数が呼ばれるだけで、ゲームが再開するようにします。このように、状態を変更するロジックをdraw()関数とは切り離しておくと、コードが整理され、管理がしやすくなります。


このように、ゲームループを1か所にまとめ、状態フラグで適切に制御することで、アニメーションの処理が安定します。ゲームの再開や停止をdraw()外で管理することで、コードの可読性と保守性が向上し、予期せぬ不具合を防ぐことができます。

6. デバッグのコツ

requestAnimationFrame を使ったゲームループのデバッグは、予期しない挙動を防ぐために非常に重要です。アニメーションの制御やタイミングに関する問題を特定するためのデバッグのコツをいくつか紹介します。

6.1 frameId をログる

requestAnimationFrameを使用している場合、アニメーションフレームのID(frameId)をログに出力して、実際にループが正常に動作しているかどうかを確認します。これにより、二重にアニメーションが実行されているのか、正しい順番でフレームが呼ばれているのかを追跡できます。

let animationFrameId = null;

function draw() {
  // 現在のアニメーションフレームIDをログ出力
  console.log("Current Animation Frame ID:", animationFrameId);

  // ゲームの描画処理
  animationFrameId = requestAnimationFrame(draw); // 次のフレームをリクエスト
}

// 初回の呼び出し
requestAnimationFrame(draw);

フレームIDをログ出力することで、どこでアニメーションが止まっているのか、または二重起動しているのかを素早く確認できます。複数のrequestAnimationFrameが同時に呼ばれている場合、IDが複数存在していることが分かります。

6.2 「どこで requestAnimationFrame を呼んでいるか」を1本化して確認

requestAnimationFrameがどこで呼ばれているかを明確にし、その呼び出しを1か所に統一することで、アニメーションループが複数回実行されることを防ぎます。もし複数の場所でrequestAnimationFrameを呼んでいる場合、それが原因でループが二重に実行されている可能性があります。

デバッグの際には、requestAnimationFrameの呼び出しをコメントアウトするか、1か所でしか呼ばれないようにして、コードを簡素化します。以下は、requestAnimationFrameが一度だけ呼ばれるように管理する方法の例です。

let animationFrameId = null;

function draw() {
  // ゲームの描画処理
  // ...

  // 再度フレームをリクエスト
  animationFrameId = requestAnimationFrame(draw);
}

function startAnimation() {
  if (!animationFrameId) {
    animationFrameId = requestAnimationFrame(draw);
  }
}

function stopAnimation() {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = null;
  }
}

これにより、startAnimationが呼ばれたときのみrequestAnimationFrameを開始し、停止する場合もstopAnimationで制御することで、無駄なループの発生を防ぎます。

6.3 多重起動検出のテクニック(ガードフラグ)

アニメーションループが二重に起動してしまうのを防ぐためのガードフラグを設定することが有効です。isAnimatingというフラグを使い、アニメーションが開始される前にフラグをチェックすることで、重複したrequestAnimationFrameの呼び出しを防ぐことができます。

let isAnimating = false;

function draw() {
  if (isAnimating) return; // アニメーション中なら描画しない

  isAnimating = true;

  // ゲームの描画処理
  // ...

  // アニメーションを次のフレームにリクエスト
  requestAnimationFrame(draw);
  isAnimating = false; // 次の描画を許可
}

isAnimatingフラグを使うことで、描画処理が実行中に新たに描画処理が実行されないようにガードします。この方法は、requestAnimationFrameが不要に重複しないようにするため、ループが二重に動いてしまうことを防ぎます。


このセクションでは、requestAnimationFrameに関連するデバッグのコツを紹介しました。フレームIDのログ出力や、requestAnimationFrameの呼び出し箇所を1本化することで、アニメーションの管理が楽になります。また、ガードフラグを使った多重起動の検出方法も有効です。デバッグの際には、これらの方法を組み合わせて、問題の特定を素早く行いましょう。