requestAnimationFrameをPromise化して、ゲームループを書く

#130
2025.8.21
2025.8.21

JavaScriptでは、C/C++を用いたゲームプログラミングで一般的な、いわゆる「ゲームループ」を書くことはできません。ゲームループとは例えば、次のような更新と描画を繰り返すコードです:

while (true) {
  update();
  render();
}

ブラウザ上のJavaScriptにおいては、上記のようなブロッキングなコードを書くことは許されていません。これは、ブラウザという環境が、本質的に非同期であることに起因しています。

このようなブロッキングを避けるために、一般的にはsetTimeoutwindow.requestAnimationFrameが用いられます。特に、画面の更新を行う場合は、window.requestAnimationFrameを用いることで、ブラウザ側の描画のタイミング(垂直同期)に合わせて処理を実行することができます。

function frame() {
  update();
  render();
  window.requestAnimationFrame(frame);
}

window.requestAnimationFrame(frame);

このwindow.requestAnimationFramePromiseでラップすることで、以下のような「非同期な」ゲームループを書くことが可能になります。async/awaitのおかげで、再帰的なコールバック呼び出しが不要となるため、コードがより直感的になります:

function requestAnimationFrame(): Promise<number> {
  return new Promise((resolve) => {
    window.requestAnimationFrame((time) => {
      resolve(time);
    });
  });
}

while (true) {
  await requestAnimationFrame();
  update();
  render();
}

window.requestAnimationFramePromise化する利点は、コードの見栄えだけではありません。次のようにPromise.anyと組み合わせることによって、「次の描画タイミングが訪れるか、または、一定時間が経過する」まで待つ、といったことが可能となります。window.requestAnimationFrameは、タブが切り替わってページが非表示になった時(バックグラウンド時)に、コールバックが呼ばれる頻度が低下したり、全く呼び出されなくなるようになっていますが、このように書くことで全く呼び出されなくなる事態を避けることができます(ただし、setTimeoutもバックグラウンド時には呼び出し頻度が低下するため、指定した間隔になるとは限らないことに注意)。

function requestAnimationFrame(): Promise<number> {
  return new Promise((resolve) => {
    window.requestAnimationFrame((time) => {
      resolve(time);
    });
  });
}

function timeout(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

while (true) {
  await Promise.any([requestAnimationFrame(), timeout(1000)]);
  update();
  render();
}