ゲームループ:なぜ delta time が必須なのか

毎フレーム 5 ピクセル動かすと、60Hz では正常でも 144Hz では 2.5 倍速くなります。経過時間を掛けて物理をリフレッシュレートから切り離し、さらに上限を設けます。

同じ「毎フレーム 5 ピクセル」のコードが、60Hz では正常、144Hz では 2.5 倍速く、フレーム落ちする機械では這うように遅くなります。フレームレートは定数ではなく、時間の代わりにはなりません。

誤った書き方と正しい書き方

// 誤:速度がリフレッシュレートに従う
function frame() {
  player.x += 5;
}

// 正:速度が時間に比例する
let last = performance.now();
function frame(now: number) {
  const dt = (now - last) / 1000;   // 秒
  last = now;
  player.x += 300 * dt;             // 毎秒 300 ピクセル
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

単位を毎秒にすれば、速度はフレームレートに依存しなくなります。

衝突とすり抜け

dt が大きいと、一歩で壁をまたげます。毎秒 300 ピクセルで 1 フレーム 100ms なら一歩 30 ピクセル。薄い壁はそのまま抜けます。

対処は三つ、安い順に次のとおりです。

方法 代償
dt の上限を 1/30 秒にする 重いときは遅くなるがすり抜けない
格子または連続 AABB 判定 衝突算法の作り直しが必要
固定ステップ物理+描画補間 最も安定、最も複雑

小規模なら一つ目で十分です。

const dt = Math.min((now - last) / 1000, 1 / 30);

上限は任意ではありません。 タブを離れて戻ると、数秒の dt を渡してくるブラウザがあります。上限が無ければキャラクタは画面外へ飛びます。

固定ステップ:決定性が要る場合

リプレイ、通信同期、物理の安定性など、毎回同じ結果が必要なら可変 dt は使えません。

const STEP = 1 / 60;
let acc = 0;
function frame(now: number) {
  acc += Math.min((now - last) / 1000, 0.25);
  last = now;
  while (acc >= STEP) {
    update(STEP);      // 物理は常に STEP だけを見る
    acc -= STEP;
  }
  render(acc / STEP);  // 段差を消すための補間
}

物理は常に 1/60 秒で進み、描画は余りの acc で補間します。決定性と滑らかさを同時に得る唯一の方法です。

計時に Date.now を使わない

Date.now() はミリ秒以下に粗化されることがあり、システム時計の補正で飛びます。performance.now() を使います。

  • 単調増加で、時計補正の影響を受けない
  • サブミリ秒の精度

ゲームの計時、アニメーション、性能測定はすべてこちらを使うべきです。

タブが非表示なら止める

ブラウザは非表示タブで rAF をほぼ止めます。戻った瞬間の dt は極端に大きくなります。上限に加えて、可視性を監視するのが確実です。

document.addEventListener('visibilitychange', () => {
  if (document.hidden) pause();
  else last = performance.now();   // 再同期して非表示中の時間を捨てる
});

復帰時に last をリセットする方が、dt の上限に頼るより徹底できます。

フレームは描画の拍であり、時間の単位ではありません。時間に関わるものはすべて dt を掛け、dt には上限を設けてください。

← 記事一覧に戻る

コメント

…