作業ログ

独自Engine設計|Part 3:EngineとSubsystemの責務分離

EngineがSubsystemをどう所有し管理するか。参照の返し方と、Game側からRenderingへ渡す情報の形を整理した。

  • game-engine
  • software-design
  • cpp

背景

Part 2では、ApplicationがEngineとGameLogicをどの順序で実行するかを整理した。

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

Engineの役割

Engineは、Input、Physics、Rendering、Time、WindowなどのSubsystemを所有する。現時点では、次のような構成を想定している。

class Engine
{
public:
    void Initialize();
    void Shutdown();

    InputSystem& Input();
    LogSystem& Logger();
    PhysicsSystem& Physics();
    RenderSystem& Renderer();
    TimeSystem& Time();
    WindowSystem& Window();

private:
    InputSystem m_inputSystem;
    LogSystem m_logSystem;
    PhysicsSystem m_physicsSystem;
    RenderSystem m_renderSystem;
    TimeSystem m_timeSystem;
    WindowSystem m_windowSystem;
};

Engineの主な責務は、各Subsystemそのものの処理を実装することではなく、

  • Subsystemを所有する
  • 正しい順序で初期化する
  • 使用できる期間を管理する
  • 必要なSubsystemへの参照を提供する
  • 適切な順序で終了する

ことにある。

例えば、EngineはRenderSystem::Initialize()を呼び出すが、DirectX 12のPipeline State ObjectをEngine自身が作るわけではない。Renderingの具体的な処理はRenderSystem側に置く。

初期化と参照提供をまとめる

Engineには、次の2種類の機能がある。

void Initialize();
void Shutdown();

そして、

RenderSystem& Renderer();
PhysicsSystem& Physics();

一見すると、初期化・終了と参照提供は別の責務にも見える。ただし、現在の規模ではどちらも次の役割に含めている。

Engineが所有しているSubsystemを管理する

所有しているから初期化し、所有しているから終了させ、利用側に必要な参照を提供する。

一方で、Engine自身が、

  • DirectX 12 Deviceを作成する
  • Collision Detectionを実装する
  • Input Stateを直接更新する
  • ゲーム固有の状態を管理する

ところまで担当すると、責務が広がりすぎる。具体的な処理は、それぞれのSubsystemやGameLogicへ置く。

Subsystemを参照で返す

Subsystemへのアクセスは、参照で返す形にしている。

RenderSystem& Engine::Renderer()
{
    return m_renderSystem;
}

利用側は、

m_engine.Renderer().Render();

と書ける。

RenderSystemはDirectX 12のResourceや内部状態を所有するためコピーして使うものではなく、またEngineが有効である間は必ず存在する。参照を返すことで、その2つの前提をInterfaceに表せる。

値返しやPointer返しと比べてなぜこの形にしたのかは、EngineのAccessorはなぜ参照を返すのかで整理している。

Game側からRenderingへ何を渡すか

Game側がDirectX 12のGPU Resourceを直接扱うと、ゲームロジックとRendering APIが強く結びついてしまう。そのため、Game側からは次の情報だけを渡す形を考えている。

何を、どのTransformで描画するか

例えば、次のようなRenderObjectを使う。

struct RenderObject
{
    MeshId meshId;
    Transform transform;
};

meshIdは描画するMeshを示し、transformは位置・回転・Scaleを示す。Game側は、

RenderObject renderObject{};
renderObject.meshId = meshId;
renderObject.transform = transform;

までを決める。その後、RenderSystemがMeshIdから対応するGPU Resourceを取得し、実際の描画命令を発行する。

これにより、Game側はVertex BufferやCommand ListなどのDirectX 12固有情報を知る必要がなくなる。

RenderSystemも必要に応じて分ける

Subsystemについても、一つのClassへすべての処理を集めないようにする。例えばRenderSystemが大きくなってきた場合、

  • GraphicsPipeline
  • SwapChain
  • CommandContext
  • GpuSynchronizer
  • MeshResource

といった単位へ分けることができる。

ただし、細かく分割すること自体が目的ではない。数行の処理を別Classへ移しただけでは、ファイル間の移動が増えて、かえって処理を追いにくくなる場合もある。

どこまで分けるかの判断基準は、Subsystemをいつ別クラスとして分けるべきかで整理している。

まとめ

現在は、Engine内部の役割を次のように整理している。

Engine
    Subsystemを所有し、Lifetimeを管理する

InputSystem
    Inputを扱う

LogSystem
    Logを出力する

PhysicsSystem
    Physics Simulationを扱う

RenderSystem
    Renderingを扱う

TimeSystem
    時間を管理する

WindowSystem
    Windowを管理する

Engineは各Subsystemをまとめて管理するが、具体的な処理までは担当しない。また、Game側からはDirectX 12などの低レベルAPIを直接扱わず、MeshIdやTransformのようなゲーム側で扱いやすい情報を渡す形にする。

この構成をベースにしながら、実際の3Dアクションゲームを作る中で、Subsystemの責務や分割単位が適切かを検証していく。