🚴‍♂️

ECS (Entity Component System)について

2023/05/08に公開

概要

本記事は、以下のシンプルなECSの実装方法を解説しているサイト様で学んだことの備忘録や
個人的な考えについて話そうと思います。
https://www.david-colson.com/2020/02/09/making-a-simple-ecs.html

コード

https://gist.github.com/Suuta/fe48b8efae2fa7f04521646f1407c102

データ指向について

ECSについて話す前に、ECSの中心にあるデータ指向の考え方について触れたいと思います。データとふるまいをモノごとで扱うオブジェクト指向に対して、データ指向ではモノという概念が無く、モノが内包するデータをデータの種類ごとにまとめて扱います。

従来のオブジェクト指向

従来のオブジェクト指向でシーンのゲームオブジェクトを更新する場合、TransformMeshといったデータを内包したObjectを単位とし、それが100個の配列を更新するという考えでいました。これは構造体の配列であり AOS (array of struct) といいます。

struct Object
{
    Transform transforms;
    Mesh      meshes;
};

Object objects[100];

void Update()
{
    for (int i = 0; i < 100; i++)
    {
        objects[i].transforms;
        objects[i].meshes;
    }
}

データ指向では

データ指向ではオブジェクトという概念はなくなり、オブジェクトが内包していたデータごとに配列を作るという考え方になります。これは配列の構造体でありSOA (struct of array) といいます。

Transform transforms[100];
Mesh      meshes[100];

void UpdateTransform(Transform* transforms)
{
    for (int i = 0; i < 100; i++)
        transforms[i];
}

void UpdateMesh(Mesh* meshes)
{
    for (int i = 0; i < 100; i++)
        meshes[i];
}

void Update()
{
    UpdateTransform(transforms);
    UpdateMesh(meshes);
}

じゃあ、コンポーネント指向は?

オブジェクト指向とは別に、Unityで有名なコンポーネント指向があると思います。GameObjectに対してComponentをアタッチすることでオブジェクトのふるまいを表現します。オブジェクト指向の考え方で異なるオブジェクトを表現するには継承を使う必要がありましたが、コンポーネント指向では継承を使わなくても異なるオブジェクトを表現できます。これは、is-A has-A として知られています。

オブジェクト指向とコンポーネント指向が別物と考えるよりも、オブジェクト指向の中でhas-A
の関係を表現したものがコンポーネント指向だと私は考えています。

ECS (Entity Component System) について

データ指向の観点から見るとコンポーネント指向と別物であるかのように見えますが、ECS の表面上はコンポーネント指向と似ています。オブジェクトは、識別子データふるまいの3つで構成されています。ECSとコンポーネント指向の対応表が以下の通りです。

コンポーネント指向 データ指向
識別子 GameObject Entity
データ Component Component
ふるまい ScriptComponent (C#) System

識別子というのはIDやポインターのようなもので、一意に他のオブジェクトと区別が出来るという意味です。Unityで同名、同コンポーネント、同スクリプトでも区別できるのは、GameObjectが内部で識別子を持っているからです。GameObjectが "モノ" を表現するためにデータやふるまいを保持する一つの単位としての意味でなく、識別子という意味でEntityと同じだと考えてください。

using Entity = uint64_t;

オブジェクトの概念が無いECSでは、Entityは、整数型の型エイリアスでしかありません。

// GameObject gameObjects[]; 
Entity    entities[100];

Transform transforms[100];
Mesh      meshes[100];

Unityで例えると、Scene内のGameObjectを削除してEntity(ID)に置き換えます。GameObjectに紐づいていたComponentTransformMeshといった型ごとにまとめて配列にした状態です。

Entityはオブジェクトを区別する識別子だけではなく、コンポーネント配列のインデックスとして機能します。例えば、entities[0]が所有するコンポーネントは、transforms[0]meshes[0]に存在しています。

ふるまいが少し特殊で、エンティティを全更新するのではなく、指定したコンポーネントを持つエンティティのみを取得して更新します。例の場合、シーンからTransformMeshコンポーネントを持つEntityのイテレータViewを使ってループしています。

for (auto entity : View<Transform, Mesh>(scene))
{
    Transform* ts = scene.GetComponent<Transform>(entity);
    Mesh* mesh    = scene.GetComponent<Mesh>(entity);
}

実装

ECSの実装パターンは大まかに BitsetSparsesetArchetype の3つがありますが、本記事ではBitsetを使っていきます。どの実装であってもデータ指向の考え方は変わらず、コンポーネントデータに対する操作に違いがあります。Bitsetの場合、その名の通りエンティティが持つコンポーネントのビットフラグ(std::bitset)を比較し、対応するコンポーネントを処理します。

コンポーネント

Bitsetではビットフラグでコンポーネントの有無を調べるので、コンポーネント固有の識別子が必要です。これは、テンプレ―ト特殊化を使うことでコンポーネント型ごとに個別の識別子を生成できます。ただし、関数インスタンスごとにComponenntID静的変数が初期化されてしまうため、外部関数の静的変数として保存します。

//===============================
// コンポーネントID
//===============================
namespace Component
{
    static uint32_t GenerateID()
    {
        static uint32_t value = 0;
        return value++;
    }
    
    template<class T>
    static uint32_t TypeID()
    {
        static uint32_t ComponenntID = GenerateID();
        return ComponenntID;
    }
}

エンティティ Entity

Entityuint64_tの型エイリアスでしかないですが、64ビット全てを識別子に使うわけではありません。上位32ビットを識別子、下位32ビットをバージョン履歴に割り当てます。バージョン履歴はエンティティの生成や削除する際に使用します。エンティティが削除された場合、そのエンティティは未使用エンティティのフリーリストに追加され、エンティティの生成時に再利用されます。フリーリストがある場合にエンティティを生成すると、以前使用されたインデックスが再利用されるので、参照しているエンティティが不正な値になる場合があります。これを防ぐために、下位ビットのバージョンを変化させて識別子を変化させて一意さを保証します。

using ComponentMask = std::bitset<MaxComponents>;
using EntityIndex   = uint32_t; // インデックス
using EntityVersion = uint32_t; // バージョン
using EntityID      = uint64_t; // インデックス + バージョン

//===============================
// エンティティID
//===============================
namespace Entity
{
    // 上位ビットにシフトしてIDを設定、下位ビットにバージョン情報を設定して結合
    inline static EntityID CreateID(EntityIndex index, EntityVersion version)
    {
        return ((EntityID)index << 32) | ((EntityID)version);
    }

    inline static EntityIndex GetIndex(EntityID id)
    {
        return id >> 32;
    }

    // EntityID(64ビット) を EntityVersion(32ビット) にキャストして上位32ビットを消す
    inline static EntityVersion GetVersion(EntityID id)
    {
        return (EntityVersion)id;
    }

    inline static bool IsValid(EntityID id)
    {
        return (id >> 32) != EntityIndex(-1);
    }
}

コンポーネントプール

コンポーネントプールは最大エンティティ数分のメモリを確保したメモリプールであり、すべてのコンポーネントデータの格納先です。事前にメモリ確保され、コンポーネント追加時にplacement newで任意のコンポーネント型のデータが構築されます。EntityIndexから、コンポーネントの挿入先となるプールのメモリアドレスを返す役目があります。

struct ComponentPool
{
    ComponentPool(uint64_t elementsize)
    {
        ElementSize = elementsize;
        Data = new char[ElementSize * MaxEntities];
    }

    ~ComponentPool()
    {
        delete[] Data;
    }

    inline void* GetPtr(uint64_t index)
    {
        return Data + index * ElementSize;
    }

    char* Data = nullptr;
    uint64_t ElementSize = 0;
};

レジストリ

RegistryはUnityでいうところのSceneのようなもので、ECSの管理クラスです。
エンティティの生成・削除、コンポーネントの追加・削除・取得などのすべての操作を行います。

struct Registry
{
    //====================================================
    // エンティティID操作
    //====================================================
    EntityID CreateEntity();
    void DestroyEntity(EntityID id);

    //====================================================
    // コンポーネント操作
    //====================================================
    template<typename T>
    bool HasComponent(EntityID id);

    template<typename T, typename... Args>
    T* AddComponent(EntityID id, Args&&... args);

    template<typename T>
    void RemoveComponent(EntityID id);
    
    template<typename T>
    T* GetComponent(EntityID id);

    // エンティティ記述子
    struct EntityDesc
    {
        EntityID      EnyityID;
        ComponentMask Mask;
    };

    // エンティティリスト
    std::vector<EntityDesc> Entities;

    // エンティティフリーストア
    std::vector<EntityIndex> FreeEntities;

    // コンポーネントプールリスト
    std::vector<ComponentPool*> ComponentPools;
};

レジストリビュー

Viewは指定したコンポーネントのエンティティインデックスを走査するイテレータを返すヘルパークラスです。コンストラクタで渡したレジストリ内に含まれるコンポーネントの中から、テンプレート引数で指定したコンポーネントを持つエンティティのみを走査するイテレータを返します。

// Transform と Meshコンポーネントを持つエンティティインデックスを取得
for (auto entity : View<Transform, Mesh>(scene))
{
    Transform* ts = scene.GetComponent<Transform>(entity);
    Mesh* mesh    = scene.GetComponent<Mesh>(entity);
}
template<typename... ComponentTypes>
struct View
{
    View(Registry* registry) : Registry(registry)

    struct Iterator
    {
        Iterator(Registry* registry, EntityIndex index, ComponentMask mask, bool all);

        EntityID operator*() const;
	Iterator& operator++();
	
        bool operator==(const Iterator& other) const;
        bool operator!=(const Iterator& other) const;
        
        bool ValidIndex();
	
        EntityIndex   Index         = {};
        Registry*     Registry      = nullptr;
        ComponentMask ComponentMask = {};
        bool          bIterateAll   = false;
    };

    const Iterator begin() const;
    const Iterator end() const;

    Registry*     Registry      = nullptr;
    ComponentMask ComponentMask = {};
    bool          bIterateAll   = false;
};

使い方

struct Position
{
    int x, y, z;
};

struct Image
{
    void* Handle;
};

int main()
{
    Registry registry;

    EntityID id  = registry.CreateEntity();
    registry.AddComponent<Position>(id, Position{});

    EntityID id2 = registry.CreateEntity();
    registry.AddComponent<Position>(id2);
    registry.AddComponent<Image>(id2, Image{nullptr});

    for (auto e : View<Position>(&registry))
    {
	Point* p = registry.GetComponent<Position>(e);
    }

    for (auto e : View<Image, Position>(&registry))
    {
	Position* p = registry.GetComponent<Position>(e);
	Image* img  = registry.GetComponent<Image>(e);
    }
}

Unityっぽくアレンジ

Unityでは、GameObjectAddComponent<T>()GetComponent<T>()でコンポーネントの操作が行えます、EntityをラップしたGameObjectクラスを作り間接的にレジストリを操作できるようにアレンジしました。

class GameObject
{
    friend class Scene;

public:

    GameObject() = default;
    ~GameObject() = default;

    template<typename T, typename... Args>
    T* AddComponent(Args&&... args)
    {
        return m_Registry->AddComponent<T>(m_EntityID, std::forward<Args>(args)...);
    }

    template<typename T>
    void RemoveComponent()
    {
        m_Registry->RemoveComponent<T>(m_EntityID);
    }

    template<typename T>
    T* GetComponent()
    {
        return m_Registry->GetComponent<T>(m_EntityID);
    }

private:

    GameObject(Registry* registry, EntityID id)
        : m_Registry(registry)
        , m_EntityID(id)
    {
    }

    Registry* m_Registry = nullptr; //TODO: Sceneでラップするべき
    EntityID  m_EntityID = {};
};


class Scene
{
    friend class GameObject;

public:

    GameObject CreateGameObject(const char* name)
    {
        EntityID id = Registry.CreateEntity();
        GameObject obj = { &Registry, id };

        GameObjects[name] = obj;
        return obj;
    }

    void DestroyGameObject(const char* name)
    {
        Registry.DestroyEntity(GameObjects[name].m_EntityID);
        GameObjects.erase(name);
    }

    GameObject FindGameObject(const char* name)
    {
        return GameObjects[name];
    }

    std::unordered_map<const char*, GameObject> GameObjects;
    Registry Registry;
};

Unityライクにアレンジしたコード例

GameObjectに対してAddComponent<T>()が行えるようになりました。Unityのコンポーネント指向に慣れているというのもありますが、Sceneに対してAddComponent<T>()をするのは直感に反すると思いました。

int main()
{
    Scene scene;

    GameObject obj0 = scene.CreateGameObject("0");
    obj0.AddComponent<Point>(Position{1, 0, 0});

    GameObject obj1 = scene.CreateGameObject("1");
    obj1.AddComponent<Point>(Position{1, 1, 0});

    GameObject obj2 = scene.CreateGameObject("2");
    obj2.AddComponent<Point>(Position{1, 1, 1});
    obj2.AddComponent<Image>(Image{nullptr});

    for (auto e : View<Point>(scene))
    {
        Point* ts = scene.Registry.GetComponent<Point>(e);
    }

    for (auto e : View<Point, Image>(scene))
    {
        Point* ts  = scene.Registry.GetComponent<Point>(e);
        Image* img = scene.Registry.GetComponent<Image>(e);
    }
}

今回はBitsetのECSを学びましたが、UnrealEngine / Unity共に、ArchetypeベースのECSが主流になっています。パフォーマンス面に目を向けていくためにはArchetypeSparsesetベースのECSを学ぶ必要がありそうです。

https://github.com/SanderMertens/flecs
https://github.com/skypjack/entt

Discussion