ゲームループ:なぜ 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 には上限を設けてください。

コメント
…