独自Engine設計|Part 2:ApplicationとGame Loop
1フレームをどの順序で進めるか。Game Loopの構成と、Physicsを固定時間で更新する設計を整理した。
背景
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をどのように所有し、初期化・終了・参照提供を行うかを整理する。