【ゲーム制作】FPS固定とデルタタイムとは?|フレームレートに依存しない動きの作り方

ゲーム制作

今回は、ゲーム制作でよく出てくる「FPS固定と固定タイムステップ」について解説していきます。

「FPSを60に固定したいけど、どう書くの?」
「速いPCだとキャラが爆速で動く…」
「deltaTimeを掛けたのに、当たり判定がすり抜ける…」

こんな疑問はありませんか?

ゲームループをそのまま回すと、PCの速さでゲームの速さが変わってしまいます。その対策が「FPS固定」と「固定タイムステップ」です。名前は似ていますが、解決する問題が違います。ここを整理すると一気にスッキリします。

この記事を読み終えると、あなたはFPS固定のやり方・固定タイムステップの考え方・可変と固定の使い分け・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。

なお、deltaTimeそのものの意味deltaTimeとは?の記事で解説しています。この記事はその一歩先、ゲームループの回し方をどう設計するかという話です。

まず「FPS固定」と「固定タイムステップ」を分けて考える

この2つは混同されがちですが、狙いがまったく違います。

FPS固定(フレーム制限)固定タイムステップ
やること1フレームが速すぎたら待つ更新を決まった間隔で刻む
解決する問題CPUの無駄な使用、描画の速すぎ物理・判定がフレームレートで壊れる
効く場所ループ全体更新(Update)だけ

ざっくり言うと、FPS固定は「速すぎるPCを待たせる」、固定タイムステップは「更新の刻み幅を一定に保つ」ということです。

FPS固定(フレームレート制限)の書き方

まずシンプルなほうから。1フレームにかかった時間を測り、16.67ms(60FPS相当)に足りなければ待つだけです。

#include <chrono>
#include <thread>

using Clock = std::chrono::high_resolution_clock;

int main() {
    // 60FPS = 1フレーム 16.666...ms
    const std::chrono::duration<double, std::milli> kFrameTime(1000.0 / 60.0);

    while (true) {
        auto frameStart = Clock::now();

        Update();
        Draw();

        // かかった時間を測って、余った分だけ待つ
        auto elapsed = Clock::now() - frameStart;
        if (elapsed < kFrameTime) {
            std::this_thread::sleep_for(kFrameTime - elapsed);
        }
    }
    return 0;
}

これで、速いPCでも60FPSに揃います。ただしここに落とし穴があります。sleep_for は「最低でもその時間眠る」だけで、ぴったり起きてくれる保証がありません。Windowsだと標準では15ms前後もズレることがあり、60FPSを狙うと逆にガタつきます。

この精度問題はSleepの精度とtimeBeginPeriodの記事で詳しく解説しているので、FPS固定を実装するなら必ず目を通しておいてください。

なぜ固定タイムステップが必要なのか

「deltaTimeを掛ければフレームレート非依存になる」と言われます。移動だけなら、それは正しいです。

// 移動なら、これでフレームレートが変わっても同じ速さになる
x += speed * deltaTime;

問題は当たり判定です。仮に処理が重くて1フレームに0.2秒かかったとします。弾が秒速1000ピクセルなら、1フレームで200ピクセルも一気に飛ぶことになります。

すると、壁の手前にいた弾が、次のフレームでは壁の向こう側。「当たった瞬間」がどのフレームにも存在せず、すり抜けるわけです。これがトンネリングと呼ばれる現象です。

そこで、更新だけは細かく一定の刻みで進める。これが固定タイムステップです。

固定タイムステップの実装

肝はアキュムレータ(貯金箱)という考え方です。経過時間を貯めていき、決まった量が貯まるたびに1回更新する——それだけです。

#include <chrono>

using Clock = std::chrono::high_resolution_clock;

int main() {
    const double kStep = 1.0 / 60.0; // 更新の刻み(秒)
    double accumulator = 0.0;
    auto  prev = Clock::now();

    while (true) {
        auto now = Clock::now();
        std::chrono::duration<double> frame = now - prev;
        prev = now;

        double dt = frame.count();
        if (dt > 0.25) dt = 0.25; // 保険(後述)

        accumulator += dt; // 経過時間を貯める

        // 貯まった分だけ、決まった刻みで更新する
        while (accumulator >= kStep) {
            Update(kStep);      // 常に同じ幅で進む
            accumulator -= kStep;
        }

        Draw(); // 描画は貯金と関係なく毎回
    }
    return 0;
}

ポイントは Update に渡す値が常に kStep(固定)だということ。フレームが重かった日は while が2回3回と回り、更新回数で帳尻を合わせます。1回あたりの移動量は変わらないので、すり抜けません。

もう一つの肝が if (dt > 0.25) dt = 0.25; です。これがないと、デバッグでブレークポイントを踏んだ瞬間に死にます。理由は体験談で説明します。

【重要】私が実際にFPS周りで困った体験談

個人開発で固定タイムステップを入れたとき、デバッグで止めて再開したら、ゲームが数秒フリーズして戻ってこないという現象にハマりました。

原因はアキュムレータの暴走。ブレークポイントで30秒止めると、再開時の dt が30秒になります。すると while (accumulator >= kStep)1800回回り、その間ずっと描画が止まる。しかもその1フレームがまた重いので dt がさらに膨らみ、永久に追いつかないという悪循環(death spiral)に入っていました。

dt に上限を付けたら一発で直りました。「一定以上遅れたら、もう諦めてスローモーションにする」という割り切りです。

もう一つ。sleep_for だけでFPS固定したとき、60FPSのはずが妙にカクついたことがありました。これはSleepの精度が原因で、timeBeginPeriod を呼んだら安定しました。

FPS固定・固定タイムステップのよくある失敗例と対処法

①deltaTimeに上限を付けない

体験談のとおり、デバッグ再開時などに死のスパイラルに入ります。dt は0.25秒程度で頭打ちにしておきましょう。

②Sleepの精度を信用する

sleep_for はぴったり起きません。Windowsなら timeBeginPeriod(1) で精度を上げる、あるいは残り1ms程度はビジーループで待つといった工夫が必要です。

③更新と描画を混ぜてしまう

固定タイムステップでは、更新は while の中、描画は外です。描画まで while の中に入れると、重い日に何度も描いてさらに重くなります。

注意点

  • 移動だけなら可変(deltaTimeを掛ける)で十分。凝りすぎない
  • 物理や当たり判定がシビアなら固定タイムステップ
  • dt の上限とSleepの精度は、必ずセットで対策する

まとめ

  • FPS固定は「余った時間を待つ」=CPUと描画の話
  • 固定タイムステップは「更新の刻みを一定にする」=すり抜け対策
  • アキュムレータに貯めて、貯まった分だけ更新する
  • dt に上限を付けないと死のスパイラルに入る

まずは可変フレームレート+deltaTimeで作り、すり抜けや挙動のブレが気になってきたら固定タイムステップに切り替える、という順番がおすすめです。

関連記事