ECS (Entity Component System)について
概要
本記事は、以下のシンプルなECSの実装方法を解説しているサイト様で学んだことの備忘録や
個人的な考えについて話そうと思います。
コード
データ指向について
ECSについて話す前に、ECSの中心にあるデータ指向
の考え方について触れたいと思います。データとふるまいをモノごとで扱うオブジェクト指向
に対して、データ指向
ではモノという概念が無く、モノが内包するデータをデータの種類ごとにまとめて扱います。
従来のオブジェクト指向
従来のオブジェクト指向でシーンのゲームオブジェクトを更新する場合、Transform
やMesh
といったデータを内包した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
に紐づいていたComponent
をTransform
、Mesh
といった型ごとにまとめて配列にした状態です。
Entity
はオブジェクトを区別する識別子だけではなく、コンポーネント配列のインデックスとして機能します。例えば、entities[0]
が所有するコンポーネントは、transforms[0]
やmeshes[0]
に存在しています。
ふるまいが少し特殊で、エンティティを全更新するのではなく、指定したコンポーネントを持つエンティティのみを取得して更新します。例の場合、シーンからTransform
とMesh
コンポーネントを持つEntity
のイテレータView
を使ってループしています。
for (auto entity : View<Transform, Mesh>(scene))
{
Transform* ts = scene.GetComponent<Transform>(entity);
Mesh* mesh = scene.GetComponent<Mesh>(entity);
}
実装
ECSの実装パターンは大まかに Bitset
、Sparseset
、Archetype
の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
Entity
はuint64_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>(®istry))
{
Point* p = registry.GetComponent<Position>(e);
}
for (auto e : View<Image, Position>(®istry))
{
Position* p = registry.GetComponent<Position>(e);
Image* img = registry.GetComponent<Image>(e);
}
}
Unityっぽくアレンジ
Unityでは、GameObject
がAddComponent<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が主流になっています。パフォーマンス面に目を向けていくためにはArchetype
やSparseset
ベースのECSを学ぶ必要がありそうです。
Discussion