作業ログ

独自Engine設計|Part 2:ApplicationとGame Loop

1フレームをどの順序で進めるか。Game Loopの構成と、Physicsを固定時間で更新する設計を整理した。

  • game-engine
  • software-design
  • cpp

背景

Part 1では、ゲーム固有処理をGameLogic、汎用処理をEngine側へ分ける方針を整理した。

今回は、その2つをApplicationがどのように接続し、1フレームをどの順序で進めるかを考える。

Applicationの役割

Applicationは、ゲーム全体の実行を管理する。現時点では、主に次の役割を持たせている。

  • EngineとGameLogicを所有する
  • アプリケーションの実行状態を管理する
  • Game Loopを実行する
  • 各Systemを適切な順序で呼び出す
class Application
{
public:
    void Run();

private:
    void GameLoop();

    bool m_isRunning = false;
    double m_physicsAccumulator = 0.0;
    VeEngine::Engine m_engine;
    GameLogic m_gameLogic;
};

Application自身がRenderingやPhysicsの具体的な処理を持つわけではない。あくまで、どの処理を、どの順序で実行するかを管理する。

Game Loopの流れ

現時点では、次のような順序を想定している。

WindowSystem::Update
        ↓
TimeSystem::Update
        ↓
InputSystem::Update
        ↓
GameLogic::FrameUpdate
        ↓
Fixed Physics Update
        ├─ GameLogic::BeforePhysicsUpdate
        ├─ PhysicsSystem::Step
        └─ GameLogic::AfterPhysicsUpdate
        ↓
RenderSystem::Render

簡略化すると、次のような構造になる。

while (m_isRunning)
{
    m_engine.Window().Update();
    m_engine.Time().Update();
    m_engine.Input().Update();

    m_gameLogic.FrameUpdate();

    m_physicsAccumulator += m_engine.Time().DeltaTimeSeconds();

    while (m_physicsAccumulator >= FixedDeltaTimeSeconds)
    {
        m_gameLogic.BeforePhysicsUpdate();
        m_engine.Physics().Step(FixedDeltaTimeSeconds);
        m_gameLogic.AfterPhysicsUpdate();

        m_physicsAccumulator -= FixedDeltaTimeSeconds;
    }

    m_engine.Renderer().Render();
}

経過時間をm_physicsAccumulatorへ足し込み、固定時間分が溜まるたびにPhysicsを1回進める。余った端数は次のフレームへ持ち越す。FixedDeltaTimeSecondsについては次の節で扱う。

通常のフレーム更新とPhysics Updateを分けることで、描画周期と物理演算周期を別々に扱えるようにする。

Physicsを固定時間で更新する

Renderingにかかる時間は、毎フレーム一定ではない。例えば、あるフレームでは16ms、別のフレームでは25msかかることもある。

この変動した時間をそのままPhysicsへ渡すと、

  • フレームレートによって物体の動きが変わる
  • Collisionが不安定になる
  • フレームレート低下時にすり抜けが起きやすくなる
  • 同じ条件での再現が難しくなる

といった問題が発生する可能性がある。

そのため、Physicsは固定された時間間隔で進める。

static constexpr double FixedDeltaTimeSeconds =
    1.0 / 120.0;

この場合、Physicsは120Hzで更新する。Renderingは可能な頻度で実行し、Physicsは一定の時間単位で進める構成になる。

なお、1フレームの処理が大きく遅れると、溜まった時間を消化するために内側のループが何度も回ることになる。その処理自体がさらに遅延を生むため、1フレームで進めるPhysicsの回数には上限が必要になる。具体的な上限値は、実際に動かしてから決める。

Physicsの前後にGameLogicを挟む

Physics Updateでは、単にPhysicsSystem::Step()を呼ぶだけではなく、その前後にGameLogicの処理を入れる。

BeforePhysicsUpdate
        ↓
PhysicsSystem::Step
        ↓
AfterPhysicsUpdate

BeforePhysicsUpdateでは、Physicsを進める前に必要なゲーム側の処理を行う。AfterPhysicsUpdateでは、Simulation後の状態を使ってゲーム側の処理を行う。

これにより、PhysicsそのものはEngine側に置いたまま、Physicsを利用したゲーム固有処理はGameLogic側に残せる。

Applicationは処理内容を知らない

ここでもPart 1と同じ考え方を使う。Applicationは、次の実行順序は知っている。

Windowを更新する
Timeを更新する
Inputを更新する
GameLogicを更新する
Physicsを進める
Renderingする

しかし、

  • InputをどのAPIから取得するか
  • PhysicsでどうCollisionを計算するか
  • DirectX 12でどう描画するか
  • ゲーム側で何を判定するか

といった具体的な処理までは持たない。Applicationは処理の内容ではなく、処理の順序を管理する役割にする。

まとめ

現時点のGame Loopでは、役割を次のように分けている。

Application
    各処理の実行順序を管理する

GameLogic
    ゲーム固有の更新処理を行う

PhysicsSystem
    固定時間でPhysics Simulationを進める

RenderSystem
    Renderingを行う

RenderingとPhysicsの更新周期を分離し、Physicsは固定時間で進める。また、Physicsの前後にGameLogicの更新ポイントを設けることで、Engine側のPhysicsとゲーム固有処理を分離したまま連携できるようにする。

次回は、Engineが各Subsystemをどのように所有し、初期化・終了・参照提供を行うかを整理する。