ゲームループと固定タイムステップ
フレームレートが変わっても物理が破綻しないゲームを作りたい人へ。更新と描画を分離し固定タイムステップで回す原理を押さえれば、決定的で滑らかなシミュレーションを設計できます。
- ゲームループは入力→更新→描画を毎フレーム回す骨格。更新(シミュレーション)を固定タイムステップで、描画を可変レートで分離するのが定石で、物理の安定性と描画の滑らかさを両立できる。
- 1フレームの実時間を蓄積し、固定dt(例 1/60秒)ぶん貯まるたびに更新を回す。処理落ちで蓄積が更新コストを上回り続けると更新回数が雪だるま式に増えるスパイラルオブデスに陥るため、1フレームの更新回数に上限を設ける。
- 描画時は更新の残り時間の割合alphaで前状態と現状態を線形補間し、時間的エイリアシングを消す。更新順序と演算を固定すれば固定タイムステップは決定性を持ち、リプレイやネットワーク同期の基盤になる。
ゲームループとは何か:入力・更新・描画の骨格
横にスクロール
ゲームは1枚の応答で完結するアプリと違い、時間とともに状態が進み続けます。その心臓部がゲームループです。おおまかには「入力を取り込む → ワールドの状態を1ステップ進める(更新/シミュレーション)→ 現在の状態を画面に描く(描画/レンダリング)」を、電源を切るまで延々と回します。ここで更新とは物理・当たり判定・AI・アニメーション進行などゲーム世界の状態を前に進める計算を指し、描画とはその時点の状態を1枚の画像に焼く行為を指します。両者は本質的に別物です。
素朴な実装は、1周ごとに前フレームからの経過時間(デルタタイム、dt)を測り、その dt を使って更新も描画も行うものです。しかしこの「可変デルタタイムで更新する」方式には、フレームレートが動くと物理挙動そのものが変わってしまうという致命的な弱点があります。ここが固定タイムステップを導入する動機になります。
更新は「世界を正しく前に進める」ことが目的で、一定の刻み幅で回すと安定します。描画は「今を滑らかに見せる」ことが目的で、モニタのリフレッシュレート(60Hz、144Hz など)に合わせて出せるだけ出したい。目的が違うのだから、両者を同じ頻度に縛る必然性はありません。更新を固定レート、描画を可変レートに分離するのが現代のゲームループの基本設計です。
なぜ可変デルタタイムだと物理が壊れるのか
物理を可変 dt で積分すると、dt の大きさによって数値積分の誤差が変わります。特に前進オイラー法のような単純な積分では、dt が大きいほど誤差が増え、ばね・重力・摩擦のような系では同じ初期条件でもフレームレート次第で軌道が変わり、最悪は発散して吹き飛びます。可変 dt は当たり判定にも牙をむきます。大きな dt では1ステップの移動量が厚みより大きくなり、薄い壁をすり抜けるトンネリングが起きます。
固定タイムステップは、更新に渡す dt を常に一定の定数(例 1/60 秒)に固定します。積分の刻み幅が不変なので誤差特性が安定し、フレームレートが変わってもシミュレーション結果が一定になります。実時間の進みは、後述の「時間の蓄積」で吸収します。
可変dt更新(アンチパターン):
loop:
dt = 実測経過時間 # 5ms のこともあれば 40ms のことも
update(dt) # dt が暴れると積分誤差・トンネリング
render()
固定dt更新(定石):
loop:
dt_real = 実測経過時間
accumulator += dt_real # 実時間を貯金する
while accumulator >= DT: # DT は定数(例 1/60 秒)
update(DT) # 常に同じ刻み幅で world を進める
accumulator -= DT
render() # 描画は貯金が DT 未満になったら1回
要点は、実時間の進みと更新の刻み幅を切り離すことです。1フレームで 40ms 経過しても、DT が 1/60(約 16.7ms)なら while が2回まわり、世界はきっちり2ステップ進みます(消化しきれない端数の約 6.7ms は accumulator に残り、次フレームへ持ち越されます)。逆に描画が速すぎて accumulator が DT に満たないフレームでは更新は0回で、描画だけが走ります。
スパイラルオブデス:処理落ちが雪だるま式に増える罠
固定タイムステップの while ループには危険が潜みます。1フレームぶんの更新群を回すのにかかる実時間が、DT が表す時間よりも長くなると何が起きるか。更新に時間がかかる → その間にも実時間は進み accumulator にさらに時間が積み増される → 消化すべき更新回数が増える → もっと時間がかかる、という正のフィードバックが回り始めます。これがスパイラルオブデス(spiral of death)で、フレーム時間が発散し、ゲームは事実上フリーズします。
1フレームで消化する更新回数、または1フレームで accumulator に取り込む実時間に上限(クランプ)を設けます。たとえば「1フレームの更新は最大5回まで」「dt_real は最大 0.25 秒に丸める」といった具合です。上限に達したら未消化の時間は切り捨てるか持ち越し、そのフレームを一旦終わらせて描画に進みます。結果としてゲーム内時間が実時間より遅れる(スローモーションになる)ことは許容し、発散だけは絶対に防ぐという設計判断です。デバッガでブレークして再開した直後の巨大な dt_real も、このクランプが吸収します。
上限を入れても根本の処理落ちは消えないので、更新1回のコストを DT 内に収める最適化(空間分割、更新対象の間引き、重い処理の分割)は別途必要です。クランプはあくまで「一時的な処理落ちで完全停止しない」ための安全弁です。
補間:残り時間 alpha で滑らかに描く
更新を固定レートにすると、描画のタイミングは更新の境界と一致しません。描画の瞬間、accumulator には次の更新に満たない端数の時間が残っています。この端数を無視して最新の更新状態をそのまま描くと、更新レートと描画レートのずれが時間的エイリアシングとして現れ、カクつきやジッタとして見えます。
そこで、各オブジェクトについて前回更新後の状態(previous)と最新更新後の状態(current)を保持し、描画時に残り時間の割合 alpha = accumulator / DT(0以上1未満)で両者を線形補間した状態を描きます。
描画時の状態補間:
alpha = accumulator / DT # 0.0〜1.0 未満の端数割合
render_state = previous * (1 - alpha) + current * alpha
draw(render_state)
# 位置は線形補間(lerp)、回転は球面線形補間(slerp)が定石
alpha は「現在のフレームが、直近2つのシミュレーション状態のどこに位置するか」を表す内挿係数です。これにより、更新が 60Hz でも描画は 144Hz で滑らかに見え、物理の安定性(固定 DT)と描画の滑らかさ(可変・高レート)を両立できます。補間は外挿(extrapolation)ではなく内挿(interpolation)である点が重要で、常に確定済みの2状態の間を描くため、予測が外れて破綻することがありません(代償として描画は最大1ステップぶん過去を映します)。回転はオイラー角の線形補間だと不自然になるため、クォータニオンの球面線形補間を使います。3D空間での回転と補間の扱いはグラフィックスの変換・アニメーション分野とも密接に関わります。
決定性:リプレイとネットワーク同期の土台
固定タイムステップのもう一つの果実が決定性(determinism)です。刻み幅 DT が常に一定で、かつ更新順序・演算・乱数シードが固定なら、同じ初期状態と同じ入力列を与えれば、何度実行してもビット単位で同一の結果が得られます。この性質は次の機能の土台になります。
- リプレイ:全状態を保存せず「初期状態+各ステップの入力列」だけ記録すれば、再生時に同じシミュレーションを回して完全に再現できる。
- ロックステップ型ネットワーク:各クライアントが同じ入力だけを交換し、各自ローカルで同一シミュレーションを進める。RTSなど大量ユニットの同期を、状態そのものを送らずに実現できる(ネットワーク遅延やパケットロスへの対処はネットワーク分野の課題)。
- セーブ/ロードとデバッグ再現:不具合を起こした入力列があれば確定的に再現でき、原因究明が容易になる。
固定 DT は必要条件であって十分条件ではありません。決定性を壊す典型が (1) 浮動小数点演算の差異(CPUアーキテクチャ・コンパイラ最適化・SSE/x87 の違い、超越関数の実装差で結果が1ビットずれ、時間とともに発散する)、(2) 反復順序が不定なコンテナ(ハッシュ集合の走査順、集合 {A, B} の列挙順がラン間で変わる)、(3) 並列処理の競合(マルチスレッド更新で加算順序が変わる)、(4) 可変 dt の混入です。クロスプラットフォームで厳密同期が要るなら、固定小数点演算やソフトウェア浮動小数点、順序を固定した決定的コンテナ、専用の乱数生成器などを併用します。
| 観点 | 可変タイムステップ | 固定タイムステップ+補間 |
|---|---|---|
| 更新に渡すdt | 毎フレーム実測値(暴れる) | 常に定数 DT(安定) |
| 物理の安定性 | 低い(fpsで挙動が変わる) | 高い(fps非依存で一定) |
| トンネリング | 大dtで貫通しやすい | 刻みが小さく起きにくい |
| 描画の滑らかさ | 更新=描画で一致はする | alpha補間で高fpsでも滑らか |
| 決定性 | 得にくい | 得やすい(条件付き) |
| 処理落ち時の挙動 | dt肥大で不安定化 | 更新回数クランプで発散を防止 |
| 実装の手間 | 小さい | 蓄積器・前後状態・補間が要る |
- 更新と描画の分離: 更新(シミュレーション)は固定
DT、描画は可変・高レート。目的が「正しく進める」と「滑らかに見せる」で異なるため分ける。 - 蓄積器パターン: 実測経過時間を
accumulatorに足し、accumulator >= DTの間だけupdate(DT)を回す。実時間の進みと更新刻みを分離するのが肝。 - スパイラルオブデス: 更新1回のコストが
DTを超えると更新回数が発散する正のフィードバック。1フレームの更新回数か取り込む時間に上限を設けて回避する。 - 補間の係数:
alpha = accumulator / DTで前状態と現状態を内挿(位置はlerp、回転はslerp)。時間的エイリアシングを消す。外挿ではなく内挿なので破綻しない。 - 決定性の条件: 固定
DTに加え、演算・更新順序・乱数・コンテナ走査順・浮動小数点の再現性まで固定して初めて成立。リプレイやロックステップ同期の基盤。
まとめ
ゲームループは入力・更新・描画を回し続ける骨格であり、その設計の要は更新と描画の分離です。更新を固定タイムステップ(一定の DT)で回せば数値積分の刻み幅が不変になり、フレームレートに依存しない安定した物理とトンネリングの抑制が得られます。実時間の進みは蓄積器で吸収し、accumulator が DT 以上たまるたびに更新を消化します。ただし更新1回のコストが DT を超えると更新回数が雪だるま式に膨れるスパイラルオブデスに陥るため、1フレームの更新回数や取り込む時間に上限を設けて発散を防ぎます。描画側は残り時間の割合 alpha = accumulator / DT で前状態と現状態を内挿し、更新レートより高いリフレッシュレートでも滑らかに見せます。さらに刻み幅と演算・更新順序・乱数を固定すれば固定タイムステップは決定性を持ち、入力列だけの記録で完全再現するリプレイや、状態を送らずに同期するロックステップ型ネットワークの土台になります。プログラムの時間管理という観点ではプログラミング一般のスレッド・タイミング設計とも通じる、ゲーム開発の中核的な基礎です。
ゲーム開発の記事ガイド
ゲームループと固定タイムステップを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ゲーム開発
比較で見る軸
難易度: advanced / カテゴリ: ゲーム開発 / タグ数: 6
導入後に効く点
1フレームの実時間を蓄積し、固定dt(例 1/60秒)ぶん貯まるたびに更新を回す。処理落ちで蓄積が更新コストを上回り続けると更新回数が雪だるま式に増えるスパイラルオブデスに陥るため、1フレームの更新回数に上限を設ける。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- ゲーム開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ゲーム開発 / ゲームループ」に近いか確認する。
- 強みである「ゲームループは入力→更新→描画を毎フレーム回す骨格。更新(シミュレーション)を固定タイムステップで、描画を可変レートで分離するのが定石で、物理の安定性と描画の滑らかさを両立できる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。