古典的ゲームループからECSアーキテクチャまで

6 min読了の目安(約4100字TECH技術記事
Likes48

はじめに

ゲームエンジンアーキテクチャ、というと代表的名著と被ってしまって畏れ多いのですが、昨今のゲームエンジンがどのような経緯でアーキテクチャの形を変えていったのか、まとめたくなったのでまとめたものです。

ゲームループとオブジェクト

どんなゲームでも「状態更新・描画」のループ構造を持ちます。
ゲーム中に出てくる要素を「ゲームオブジェクト」という形で表現して、このループ構造を再利用しようとしたのが、ゲームエンジンの始まりと言えます。

Window window;
std::vector<GameObject> objects;

while (window.draw()) {
  for (auto&& obj : objects) {
    obj.update();
  }
}

これであなたもゲームエンジンの開発者です。おめでとうございます!

継承と包含

ゲームオブジェクトというのは偉大な発明ですが、ゲーム中に出てくる要素は実にバリエーションに富んでいます。これに対応するべく「GameObject型を基底として、様々な更新処理(update())を実装した派生クラスを作ろう!」というのは、オブジェクト指向プログラミングを学びたてならば至極真っ当な発想です。

ではどんな派生クラスが想定されるでしょうか?モデルを持つModelObject。うん、いかにもありそうですね。じゃあアニメ付きのモデルはModelObjectを継承してAnimationModelObjectにしましょう。え、歩いたときに砂ぼこりのエフェクトを出したい?じゃあEffectAnimationModelObjectですね。音も鳴らす?そりゃそうだ。じゃあSoundEffectAnimationModelObjectを……あ、それをプレイヤーってことにしてPlayerObject、あっ、ハイ。え、効果音だけ配置したい?じゃあSoundObjectを……でもさっき作った差分の実装と被るな……うーん……。

  • GameObject
    • ModelObject
      • AnimationModelObject
        • EffectAnimationModelObject
          • SoundAnimationModelObject
            • PlayerObject
            • RichEnemyObject
        • DynamicBackgroundObject
      • StaticBackgroundObject
    • SoundObject

とまぁ、こんな感じでカオスになっていくわけです。

  • 機能の組み合わせパターンが爆発することが予見できる
  • 意味合いの異なる型が継承ツリー上に発生する
    • 今回の場合「どのような機能を持つか」と「どのような役割のオブジェクトか」で混線している

このような場合は、継承で何とかしようとするのは考え直した方が良さそうですね。

そこで「継承より包含」というコンセプトに立ち返り、誕生するのがコンポーネントです。

class IComponent {
public:
  virtual void update() = 0;
};

class GameObject {
public:
  void addComponent(IComponent* cmp) {
    components.push_back(cmp);
  }
  void update() {
    for (auto&& cmp : components) {
      cmp->update();
    }
  }
private:
  std::vector<IComponent*> components;
};

IComponentを継承して、ModelComponentAnimationComponentを実装し、GameObjectaddComponent()してやるという寸法です。とても馴染みのある構造になってきましたね。

これなら継承を使わずとも、様々な機能の組み合わせを持つオブジェクトをGameObject型だけで表すことができますし、その上で必要ならゲームごとの役割にあわせたオブジェクトの派生型を作ることもできます。

コンポーネント間での処理順が重要なケース(アニメはモデルより先に更新したいなど)もあるので、そういった場合はaddComponent()時に処理順が想定通りになるよう挿入するなどすればよいでしょう。

実際にはジェネリクスやテンプレートを使い、addComponent()時に型を指定してコンポーネントの生成も行うようにすることが多いと思いますが、本筋ではないので割愛します。

物量への対応

さて、このゲームオブジェクト&コンポーネントシステムは、概ねうまく回っていました。しかし「もっと大量のオブジェクトを出したい!」という要求に対しては、頭打ちの様相を呈してきました。いくつか理由はあるのですが、最大の要因は「メモリ上のあちこちにデータが散らばっている」ことにあります。

オブジェクトごとにコンポーネントを生成すると、その実体はメモリ空間のあちこちに、バラバラに配置されます。

人間の目にとっても、目線をあちこちに動かさないといけないのは大変ですが、実はコンピュータにとっても、これは好ましくない状態です。

このように、まとめて処理するものは固まっていてくれた方が、目線を動かさずに済むので高速に処理できます。

これを実現するために有用であるとされ、近年採用事例が増えてきているのがEntity Component System (ECS)アーキテクチャです。

ECSアーキテクチャでは、これまでのゲームオブジェクト&コンポーネントシステムを次のように分解して再構築します。

  • ゲームオブジェクト->Entity
    • ただのIDとして表現し、後述するComponent配列に対するキーとして用いる
    • コンポーネント以外にメンバとして持っていた要素もすべてComponentとSystemに分離する
  • コンポーネントのデータ部分->Component
    • 参照を持たない値型にする
    • Componentの型ごとに全Entity分の値を配列として保持する
  • コンポーネントのロジック部分->System
    • 必要なComponentを入出力とする関数として実装する
    • System間の順序は任意に定め、Componentの配列に対して逐次関数を適用するかたちで実行する

実行効率の観点では「値型の配列をループ(あるいはもっとリッチな並列処理系)でブン回せる」という点がポイントですが、データと処理を分離できる、データが値型であることを徹底できることでシリアライズが容易になる、といった点もメリットであると言えます。

ゲームオブジェクト&コンポーネントシステムがArray of Structs(AoS)型のアーキテクチャだとすれば、ECSはStruct of Arrays(SoA)型のアーキテクチャと言えるかもしれません。

ECSアーキテクチャを用いたゲームループは、次のようなものになります。C++で実現するには色々工夫が必要になりそうですが、基本形はこうだと思ってください。

using Entity = uint64_t;
EntityManager manager;  // この内部でComponentを配列(std::vector)で持っている
SystemList systems;

// マネージャーでID発行を管理する
auto entity = manager.create();

// entityをキーにしてComponentを作成・取得
manager.AddComponent<HogeComponent>(entity);
auto& data = components.GetComponent<HogeComponent>(entity);
data.value = 3.1415f;

// Systemはリスト順の逐次実行が基本だが、参照するComponentがかち合わない場合は並列実行を検討してもいいかもしれない
for (auto&& system : systems) {
  // Systemが要求するComponentの配列を得る
  auto componentArray = manager.getComponents(system);
  
  // ParallelForEach的なものが使える環境ならぜひ使うべき
  for (auto&& component : componentArray) {
    system.update(component);
  }
}

ECSアーキテクチャは、Component配列に対する一括処理が持ち味なので、Entityが大量に存在するシーンでは有効な反面、Entity間での処理順序や親子関係などが重要なシーンでは、一括処理のメリットを活かしにくくなります。よって、これまでのシステムを完全に置き換えずに併用したり、有効な配列の分割や階層化などを検討する必要があるでしょう。