マガジン

ゲームの動作が端末ごとに速さが違う:デルタタイムで解決

遊びをつくる

執筆にClaudeを使用しています。

大きな操作ボタンと横向きの砂時計、複数の操作道具が並ぶ紙と木のゲーム風景
記事のテーマをもとに制作したAI生成イラスト。

ゲームを作ってテストしていると、自分のパソコンでは快適に動くのに、スマートフォンで試したら妙にゆっくりになる、あるいは友人のハイスペックPCで動かしたら敵が一瞬で画面端まで飛んでいく、という現象に出くわすことがあります。原因はコードの書き方ではなく、「ゲームの動きをフレームレートに依存させている」という設計上の問題です。 フレームレートとは1秒間に画面を更新する回数のことで、端末の性能やブラウザの状態によって変わります。「1フレームごとにキャラクターをXピクセル動かす」と書いてしまうと、60fps(フレーム毎秒)の端末では1秒間に60×X、30fpsの端末では30×X しか動かず、速さが半分になります。これを解消するのがデルタタイムの考え方です。 デルタタイムとは「前のフレームから今のフレームまでに経過した時間(秒)」のことです。移動量の計算をピクセル/フレームではなくピクセル/秒で定義し、それにデルタタイムを掛けると、端末のフレームレートに関わらず1秒あたりの移動距離が一定になります。この記事では仕組みの理解から実装の手順、よくある落とし穴まで順に説明します。

この記事の目次
  1. なぜフレーム依存のコードが生まれるのか
  2. デルタタイムの計算方法
  3. 落とし穴:デルタタイムが大きすぎる問題
  4. ゲームの種類ごとの適用判断
  5. 既存コードへの段階的な導入方法
  6. 次の行動

なぜフレーム依存のコードが生まれるのか

ゲームのメインループを初めて書くとき、多くの人は次のように書きます。

js
function update() {
  player.x += 3; // 毎フレーム3px動かす
  requestAnimationFrame(update);
}

これは「毎フレーム3px」というフレーム依存の設計です。requestAnimationFrame は可能な限り高頻度でコールバックを呼びますが、その頻度は端末性能やバッテリー状態、ブラウザのスロットリングによって変動します。高性能な端末では120fps近くになることもあり、その場合は60fpsの端末の2倍速でキャラクターが動きます。

フレーム依存の設計は「動けばいい」段階では問題になりにくいため、気づかないまま完成に近づき、公開後に「端末によって難易度が全然違う」という報告をもらって初めて問題になるケースが多いです。

デルタタイムの計算方法

requestAnimationFrame のコールバックには、呼び出された時刻(ミリ秒)が引数として渡されます。前回の時刻との差を取ることでデルタタイムを求められます。

js
let lastTime = null;

function update(timestamp) {
  if (lastTime === null) {
    lastTime = timestamp;
    requestAnimationFrame(update);
    return;
  }

  const delta = (timestamp - lastTime) / 1000; // 秒に変換
  lastTime = timestamp;

  player.x += speed * delta; // speed は px/秒 で定義
  requestAnimationFrame(update);
}

requestAnimationFrame(update);

speed を「1秒あたりのピクセル数」として定義し、それに delta(秒)を掛けることで「このフレームで移動すべきピクセル数」が得られます。60fpsの端末では delta ≈ 0.0167、30fpsの端末では delta ≈ 0.0333 になり、掛け合わせた結果はどちらもほぼ同じになります。

初回フレームで lastTime が null の場合は計算をスキップして次のフレームを待つのがポイントです。最初の delta は「ページを開いてからの経過時間」になってしまい、非常に大きな値になるためです。

落とし穴:デルタタイムが大きすぎる問題

デルタタイムを導入しても、次の場面で別の問題が起きます。

ブラウザタブを切り替えたり、端末をスリープさせたりした後に戻ると、delta が数秒・数十秒になることがあります。その瞬間にキャラクターが壁をすり抜けたり、ゲームが壊れた状態になったりします。

対策は delta に上限(キャップ)を設けることです。

js
const delta = Math.min((timestamp - lastTime) / 1000, 0.1);

0.1(100ミリ秒)を上限にしておくと、どれだけ長い空白があっても1フレームで最大100ms分しか進まなくなります。上限値はゲームの設計によって調整しますが、0.05〜0.1 の範囲が扱いやすい目安です。

また、物理演算や衝突判定をデルタタイムで動かすときは注意が必要です。デルタタイムが大きいフレームでは1フレームあたりの移動量が増え、薄い壁をすり抜ける「トンネリング」が起きやすくなります。衝突判定を持つゲームでは固定タイムステップ(Fixed Timestep)という手法を使う選択肢もありますが、それはデルタタイムを安定させたうえで考える次のステップです。

ゲームの種類ごとの適用判断

デルタタイムはすべてのゲームに同じように使うわけではなく、要素ごとに適用範囲を判断します。

  • キャラクター移動、デルタタイム適用:必要、理由:フレームレート差が体感に直結する
  • アニメーション(スプライト切り替え)、デルタタイム適用:必要、理由:同じ理由
  • タイマー・カウントダウン、デルタタイム適用:必要、理由:Date.now() か delta 積算で管理する
  • 乱数の抽選タイミング、デルタタイム適用:必要、理由:フレーム数で管理すると端末差が出る
  • ターン制ゲームのロジック、デルタタイム適用:不要、理由:入力待ちで進行するためフレームレートに依存しない
  • 描画そのもの(Canvas描画)、デルタタイム適用:不要、理由:requestAnimationFrame の仕組み上、自動的に最新状態を描く

パズルゲームやターン制RPGはロジックをフレームに依存させても影響が出にくいですが、アクション・シューティング・音ゲーのようにリアルタイムで動くものはデルタタイムが必須です。

既存コードへの段階的な導入方法

すでに動いているゲームにデルタタイムを後から追加するときは、一度にすべてを書き換えようとせず、以下の順番で進めると安全です。

  1. メインループに lastTime と delta の計算を追加し、delta の値をコンソールに出力して正常に取れているか確認する。
  2. 最も目立つ動き(プレイヤーの移動)だけを speed * delta に切り替え、速さの変化を確認する。speed の値を元の「フレームあたりpx × 60」に設定すると60fps相当の速さになる(例:元が 3px/frame なら speed = 180)。
  3. 残りの移動・アニメーション・タイマーを順に切り替える。
  4. タブ切り替えテストを行い、戻ったときに異常な挙動がないか確認する。Math.min のキャップを入れていない場合は必ずここで発覚します。
  5. スマートフォンの実機か、ブラウザの開発者ツールでCPUスロットリングをかけた状態で速さが一定になっているか確認する。

切り替え後に「全体的に速くなった/遅くなった」と感じたら、speed の値を調整するだけで済みます。ロジックをデルタタイム対応にした後の速さ調整はシンプルです。

次の行動

まずは現在のゲームのメインループに delta の計算を1行追加し、コンソールで値を確認するところから始めてみてください。プレイヤーの移動を speed * delta に切り替えてスマートフォン実機かCPUスロットリングで動作を確認すると、フレームレート依存の有無がすぐにわかります。デルタタイム対応が完了したら、アソバースにZIPまたはHTMLをアップロードして異なる端末のプレイヤーに試してもらうと、速さのばらつきが解消されているかを実際に確認できます。