エフアンダーバー

個人開発の記録

【Unity】 ECS まとめ(前編)

前にUnityのECSについて調べて記事を書きましたが、 おそらく今後しばらく使うことはないと思われるので、 忘れないうちに現状のUnity ECSについてまとめておくことにします。

www.f-sp.com

……という動機で記事を書き始めたのですが、 なんやかんやで片っ端から調べなおし、 まとめと呼ぶには長すぎる記事になりました。 多分、現時点での内容はだいたい網羅していると思います。

【19/05/06 更新】preview.31に対応。

【19/05/29 更新】preview.32-33に対応。

【19/09/25 更新】0.1.1に対応。関連記事へのリンクを追加。

ECS (Entity-Component-System) とは?

ECS自体の解説は本記事の趣旨ではないので簡単に。

ECSはエンティティ、コンポーネント、システムの三つの要素からなるソフトウェアアーキテクチャパターン(括りとしてはMVCとかと同じ)です *1。 ゲームプログラム全体を大きく三つの要素に分解して、それぞれの責務や関係性を規定することでゲームを管理しようというものです。

エンティティはゲーム世界のモノを表します。 エンティティ自体は特に機能を持たず、これをコンポーネントによって肉付けしていくことでモノとして完成させます。 メダロットでいうところのティンペット的なやつです(どれくらいの人に通じるかわかりませんが)。

コンポーネントはモノに付随するデータです。 重要なのはオブジェクトではなく単なるデータであることで、それを操作する術は持ちません。 アクションゲームのキャラクターなんかを考えると、位置、速度、体力といった状態それぞれがコンポーネントとなり、 キャラクターというエンティティに関連付けられます。 また、そのエンティティがキャラクターであるという情報自体もコンポーネントで表現します。

システムはゲーム世界の法則です。 エンティティに紐づいたいくつかのコンポーネントを入力にとって、いくつかのコンポーネントの値を更新します(入力と同じコンポーネントでもかまいません)。 全システムが毎フレーム更新処理を行うことによってゲーム世界は進行していきます。 一番想像しやすいの物理法則かと思います。 例えば、位置と速度という二つのコンポーネントを使って、位置のコンポーネントを更新するようなシステムが考えられます。

ざっくりとまとめると、処理はシステム、データはコンポーネントで、システムの処理対象のフィルタリングのためにコンポーネントをグループ化するのがエンティティです。 こう書くとはっきりしますが、処理とデータを完全に分離して扱うのでオブジェクト指向とは相容れません。 どちらかというと関数型の発想に近いのでオブジェクト指向で理解しようとすると詰みます。

ちなみにここまでで感じた方も多いと思いますが、もともとUnityはECSっぽいアーキテクチャをしています。 エンティティはGameObjectの機能を極限まで削いだもので、コンポーネントはComponentのシリアライズされるデータ部分、システムはそれ以外の部分です。 Unity ECSはこれをより純粋なECSへとリファインしたものになります。

ECSの利点としては次のようなものがあります。

  • 並列化が容易
    • システムの入力と出力が明確かつ粒度が小さいため
  • キャッシュ効率がいい
    • コンポーネント配列への順次アクセスによる空間的局所性のため
  • 結合度が低い
    • システム同士がデータによってのみやりとりするため
  • テストが容易
    • システムが状態を持たないため

ECSの構成図

Unity ECS の基本

Unity ECSはUnityによるECSの実装です (Unityは単にECSと呼称していますが、概念としてのECSと区別がつかないため、本記事では区別が必要な場合はこう書きます)。 Unityの仕組みを根幹から変えようという Data-Oriented Technology Stack (DOTS)の一環として開発されています。 DOTSは名前の通りデータ指向的なアプローチを基本としていて、 High-Performance C# (HPC#)と呼ばれる、C#の遅い部分(標準ライブラリ)を自前実装に置き換えたサブセットをベースに、 Burstコンパイラ、C# Job System、そしてECSから構成されています。 中でもECSはゲーム全体を制御する中心的役割を担っています。

インストール

Unity ECSはUnityのパッケージマネージャによって提供されています。 エディタの "Window" > "Package Manager" からパッケージマネージャを開いて、 "Entities" パッケージをインストールをしてください。 現時点では、パッケージマネージャ上部のメニューで "show preview packages" を選択する必要があります。

Unity ECSに関係する型の多くはUnity.Entities名前空間に定義されています。 また、Unity ECSはHPC#に依存しているため、Unity.Collections名前空間やUnity.Mathmatics名前空間が必要になることがあります。 適宜usingディレクティブを記述するようにしてください。

アーキタイプとチャンク

ECSアーキテクチャはデータ指向であるため、ある程度実装を意識したアーキテクチャではありますが、細部の構成はやはり実装依存です。 ここで、特に実装者の腕の見せ所となるのはコンポーネントのデータをどのように管理するかだと思います。 多数のエンティティが存在する中、システムの要求するコンポーネントを持つエンティティだけをどれだけ高速に列挙できるかが性能に直結します。 Unity ECSではこのための仕組みとしてアーキタイプ(Archetype)とチャンク(Chunk)を導入しています。

アーキタイプはコンポーネントの型の集合を表します。 主にエンティティがどんな型のコンポーネントを持っているかを表すのに使います。 同じアーキタイプを持つエンティティ同士はまったく同じ型のコンポーネントの集合を持ちます。 システムはエンティティの持つコンポーネントの型によってのみ、そのエンティティが処理対象かどうかを決定するため、 あるエンティティが処理対象であるとき、同じアーキタイプを持つエンティティも必ず処理対象になります。

チャンクはコンポーネントのデータを格納するメモリブロックです。 各チャンク内には同じアーキタイプを持つエンティティとそのコンポーネントのデータが型ごとに配列として格納されます。 そのため、任意のシステムに対してチャンク内のエンティティは、 すべて処理対象であるか、すべて処理対象でないかのどちらかとなります。 これにより、システムはチャンクのアーキタイプが処理対象か否かを確認するだけで、 チャンク内のエンティティ集合すべてを処理すべきか判断できるようになり、列挙が高速になります。 また、各コンポーネントが配列として格納されていることでメモリ上の位置が近くなるため、 順次アクセス時にCPUのキャッシュヒット率が高くなります。 一方で、チャンクの構造を維持するためのコストがかかるという欠点はあります。

アーキタイプとチャンクの対応は一対多になります。 つまり、あるアーキタイプに対して複数のチャンクが存在することはありますが、 あるチャンクに対して複数のアーキタイプが存在することはありません。

エンティティ

ECSでは基本的にエンティティは単なるIDとして実装されます。 モデルとしてはエンティティがコンポーネントを所有していると考えた方がわかりやすいのですが、 実装としてはエンティティ(のID)にコンポーネントを関連付けるとした方がシンプルになり、 コンポーネントをキャッシュ効率のいい配列として確保できます。

Unity ECSもこれに倣っていて、Entity構造体の定義は要約すると次のようになっています。

public struct Entity
{
    public int Index;
    public int Version;
}

Indexがエンティティを表すIDです。 エンティティのIDは削除後に使いまわされることがあるため、 削除済みのエンティティと新しいエンティティを区別するためにVersionが必要になります。

コンポーネント

Unity ECSでは構造体に特定のマーカーインターフェース*2を実装することでコンポーネントを表します。 このインターフェースにはいくつか種類があり、それによってメモリの管理方法が変わります。

IComponentData

IComponentDataは最も基本的なインターフェースです。 ほとんどのコンポーネントはこのインターフェースを実装します。

public struct Position : IComponentData
{
    public float Value;
}

// 単なるラッパーの場合には暗黙的型変換を定義しておくと便利
public struct Velocity : IComponentData
{
    public float Value;
    public static implicit operator Velocity(float value) => new Velocity { Value = value };
    public static implicit operator float(Velocity value) => value.Value;
}

IComponentDataを実装したコンポーネントはチャンク内に値が直接格納されます (エンティティごとの値が記憶されるため配列になります)。 チャンク内でデータが完結する必要があるため、参照型を含むことはできません。

以降、他の種類のコンポーネントと区別するため、 IComponentDataを実装したコンポーネントを「基本コンポーネント」と呼ぶことにします (後述するチャンクコンポーネントもIComponentDataを実装しますがこれは含まないこととします)。

ISharedComponentData

ISharedComponentDataは複数のエンティティが共有するデータを表すインターフェースです。

[Serializable]
public struct RenderMesh : ISharedComponentData, IEquatable<RenderMesh>
{
    public Mesh mesh;
    public Material material;

    /* ... */
}

ISharedComponentDataを実装したコンポーネントはその値ごとに別々のチャンクが割り当てられ、チャンクに対してひとつの値を記憶します。 これにより同じ値のコンポーネントを持つ同じアーキタイプのエンティティが多数存在する場合に、エンティティ単位の記憶容量がほぼ0になります。 一方で、各エンティティのコンポーネントの値がバラバラだと、中身がスカスカなチャンクが多数生成されるためメモリ効率が極端に悪くなります。 そのため、コンポーネントの取り得る値が十分に制限されている場合にのみ利用します。

ISharedComponentDataを実装したコンポーネントの値はチャンク外に記憶され、チャンクにはその参照情報しか記憶されないため、参照型を含んでも構いません。 ただし、保存と復元を可能にするためにシリアライズ可能である必要があります。 必ずSystem.Serializable属性を付加して定義してください。 また、共有のために同一性判定が必要なため、 参照型を含む場合にはSystem.IEquatable<T>の実装とGetHashCodeのオーバーライドが必須となっています。

以降、他の種類のコンポーネントと区別するため、 ISharedComponentDataを実装したコンポーネントを「共有コンポーネント」と呼ぶことにします。

【19/05/29 更新】
preview.32にて、参照型を含む場合のIEquatable<T>GetHashCodeの定義が必須化されたため、記述を追記。

IBufferElementData

IBufferElementDataは配列の要素を表すインターフェースです。

ECSではあるエンティティに対して同じ型のコンポーネントを複数関連付けることはできません。 そのため同じ型のデータを複数持ちたい場合には、コンポーネント内にデータの配列を持つ必要がありますが、 基本コンポーネントは参照型を利用できないため可変長配列を含むことができません。 そこで、Unity ECSでは動的バッファコンポーネント(Dynamic Buffer Component)という仕組みを提供しています。

動的バッファコンポーネントは可変長配列を表すコンポーネントで、 その要素の型にIBufferElementDataを実装した型を指定します。

[InternalBufferCapacity(8)]
public struct MyElement : IBufferElementData
{
    public int Value;
}

IBufferElementDataを実装した型にはInternalBufferCapacity属性により、推定される必要要素数を指定します。 すると、動的バッファコンポーネントを作成した際、チャンク内にヘッダとこの要素数分の配列が確保されます (エンティティごとの値が記憶されるため配列の配列になります)。 もしもこの容量を超えて要素を追加した場合には、要素を格納可能な配列が新たにヒープに確保されます。 挙動からわかるように、基本的には小さな配列がエンティティごとに必要な場合に使用するものです。 ケースにもよりますが、巨大な配列を使いたい場合には共有コンポーネントや要素のエンティティ化を検討すべきです。

IBufferElementDataを実装した型のデータはチャンク内で完結する必要があるため、参照型を含むことはできません。

その他

上記以外のコンポーネントとしてチャンクコンポーネントとコンポーネントオブジェクトがあります。

チャンクコンポーネントは共有コンポーネントに似ていて、チャンクに対してひとつの値を記憶するコンポーネントです。 ただし、共有コンポーネントが省メモリのためにデータを共有する(つまり、モデルとしてはエンティティそれぞれがデータを所有する)のに対し、 チャンクコンポーネントはチャンク自体のデータとしてチャンク内のエンティティでデータを共有します。 チャンクコンポーネントはIComponentDataを実装している必要があります。

コンポーネントオブジェクトは普通のオブジェクトをそのままコンポーネントとしてエンティティに関連付けます。 主に旧アーキテクチャからの移行用で、Componentを継承したクラス等をエンティティに関連付けるのに使います。 効率の悪い方法で記憶されるため、新規に作成する場合には使用しません。

また、単なる用語の話ですが、データを何も持たない基本コンポーネントのことをタグコンポーネントと呼びます。 これはシステムが処理対象を限定する目的でよく使われます。 コンポーネントを持っているということ自体が情報なわけです。

システム

Unity ECSでは、ComponentSystemを継承したクラス*3を実装することでシステムを定義できます。

public class MySystem : ComponentSystem
{
    protected override void OnUpdate() { /* ... */ }
}

ComponentSystemの使い方はとてもシンプルで、OnUpdateをオーバーライドして好きな処理を書くだけです。 OnUpdateは毎フレーム呼び出されます。 前処理や後処理をしたい場合には、それぞれOnCreateOnDestroyをオーバーライドします。

ComponentSystemクラスにはいくつかのメンバが定義されていて、 それらを利用してシステムの役割を果たすわけですが、そのあたりは追々説明します。

クラスとして書くのでついつい間違えそうになりますが、ECSのシステムは状態を持ちませんのでクラス内に状態を記憶しないように注意してください。 クラスではなく、OnUpdateという関数を書いているくらいの感覚でいることが重要です。

ワールド

ワールドは、存在たるエンティティ、データたるコンポーネント、法則たるシステム、すべてを包含するまさに世界そのものです。 ワールドは複数存在しても問題ありません。 まったく別の法則で動く複数の世界があってもいいし、同じ法則で動く世界に別々の状況を与えてシミュレーションさせてもかまいません。 ただし、異なる世界は完全に独立しているため、直接的に干渉することはできません。 とはいえ、エンティティやコンポーネントは所詮データなので、それらを複製して別の世界に送り込むことは造作もないことです。

ワールドはWorldクラスで表されます。 デフォルトでは定義されたすべてのシステムを含むワールドが自動的に生成され、 アクティブなワールドとしてWorld.Active静的プロパティの初期値に設定されます。 この動作はICustomBootstrapを実装したクラスを定義することでカスタマイズできます。 また、UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAPシンボルを定義することで無効化もできます (UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_RUNTIME_WORLDや、 UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_EDITOR_WORLDによって、 実行時のみあるいはエディタのみの無効化も可能です)。

Worldから取得できる重要なクラスとしてEntityManagerがあります。 ワールドはエンティティ、コンポーネント、システムのすべてを包含すると書きましたが、 Worldが直接的に保持するのはシステムだけで、エンティティとコンポーネントはEntityManagerが管理します (エンティティとコンポーネントを管理するということは、すなわちチャンクも管理するということです)。

手動でWorldにシステムを生成するにはCreateSystem<T>あるいはGetOrCreateSystem<T>を呼び出します。 生成したシステムはGetExistingSystem<T>あるいはGetOrCreateSystem<T>で取得できます。 システムはWorldとともに破棄することが多いですが、DestroySystemで破棄することもできます。

システム内では同名のプロパティによって所属するWorldEntityManagerにアクセスすることができます。

Unity ECSのアーキテクチャ

【19/09/25 更新】
0.1.0にて、UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_RUNTIME_WORLDUNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_EDITOR_WORLDが追加されたため、記述を追記。

エンティティの基本操作

エンティティに関する基本操作、すなわちエンティティの生成や破棄、コンポーネントの追加、取得、更新、削除などはすべてEntityManagerのメソッドによって行います。 実際には他の型を介して操作することの方が多いのですが、構成を捉えるためにまずはこの基本操作から説明していきます。

エンティティの生成

エンティティの生成にはCreateEntityを使用します。

Entity entity = EntityManager.CreateEntity();

コンポーネントを持たないエンティティは無意味なのでこれにコンポーネントを追加していくわけですが、 元々必要なコンポーネントがわかっている場合には、 生成時にアーキタイプを指定して予め追加しておく方が効率的です。

EntityArchetype archetype = EntityManager.CreateArchetype(typeof(Position), typeof(Velocity));
Entity entity = EntityManager.CreateEntity(archetype);

// 略記も可
Entity entity = EntityManager.CreateEntity(typeof(Position), typeof(Velocity));

また、Instantiateにより既存のエンティティとそのコンポーネント群を複製することも可能です。

Entity copy = EntityManager.Instantiate(entity);

一度に大量のエンティティを生成する場合には、 NativeArray<Entity>を利用するバージョンのCreateEntityInstantiateを使用すると効率的です。

using (var entities = new NativeArray<Entity>(42, Allocator.Temp, NativeArrayOptions.UninitializedMemory))
{
    EntityManager.CreateEntity(archetype, entities);
    EntityManager.Instantiate(source, entities);
}

エンティティの破棄

エンティティの破棄にはDestroyEntityを使用します。

EntityManager.DestroyEntity(entity);

NativeArray<Entity>によるオーバーロードも存在します。

コンポーネントの追加

コンポーネントの追加には、そのコンポーネントの種類に応じたAddXXXを使用します。

シグネチャ 対象 初期値
AddComponent(Entity, ComponentType) すべて -
AddComponentData<T>(Entity, T) 基本コンポーネント
AddSharedComponentData<T>(Entity, T) 共有コンポーネント
AddBuffer<T>(Entity) 動的バッファコンポーネント -
AddChunkComponentData<T>(Entity)*4 チャンクコンポーネント -
AddComponentObject(Entity, object) コンポーネントオブジェクト
EntityManager.AddComponent(entity, typeof(Position));
EntityManager.AddComponentData(entity, new Velocity() { Value = 42f });

他にも複数のコンポーネントを同時に追加するものや、複数のエンティティにコンポーネントを追加するものもあります。

コンポーネントの取得

コンポーネントの取得も同様に、コンポーネントの種類に応じたGetXXXを使用します。

シグネチャ 対象
GetComponentData<T>(Entity) 基本コンポーネント
GetSharedComponentData<T>(Entity) 共有コンポーネント
GetBuffer<T>(Entity) 動的バッファコンポーネント
GetChunkComponentData<T>(ArchetypeChunk) チャンクコンポーネント
GetComponentObject<T>(Entity) コンポーネントオブジェクト
Position position = EntityManager.GetComponentData<Position>(entity);

ひとつ確認しておくべきは、チャンクコンポーネントの取得対象がちゃんとチャンク(ArchetypeChunk)になっていることでしょうか。 エンティティから取得できるオーバーロードもありますが、 GetChunkでエンティティの所属するチャンクを取得するだけの略記になっています。

コンポーネントの更新

コンポーネントの更新も、コンポーネントの種類に応じたSetXXXがあります。

シグネチャ 対象
SetComponentData<T>(Entity, T) 基本コンポーネント
SetSharedComponentData<T>(Entity, T) 共有コンポーネント
SetChunkComponentData<T>(ArchetypeChunk, T) チャンクコンポーネント
EntityManager.SetComponentData(entity, new Position { Value = 42f });

動的バッファコンポーネントの場合はGetBuffer<T>DynamicBuffer<T>構造体を取得して、それを用いて操作します。

DynamicBuffer<MyElement> buffer = EntityManager.GetBuffer<MyElement>(entity);
buffer.Add(new MyElement { Value = 42 });
buffer.RemoveAt(0);

SetComponentObjectは内部的には存在しますが公開されていません。 おそらく付け替えるような用途は想定されていないのだと思います。 コンポーネントオブジェクトは普通のオブジェクトなのでGetComponentObject<T>で取得して、メンバの値を更新すればOKです。

コンポーネントの削除

コンポーネントの削除には、各RemoveXXXを使用します。

シグネチャ 対象
RemoveComponent(Entity, ComponentType) すべて
RemoveComponent<T>(Entity) チャンクコンポーネント以外
RemoveChunkComponent<T>(Entity) チャンクコンポーネント
EntityManager.RemoveComponent<Position>(entity);

エンティティの列挙

システムの記述には、対象のコンポーネントを持つすべてのエンティティを列挙する機能が必要不可欠です。 それらについて、クエリの作成と実行に分けて説明していきます。

クエリの作成

エンティティを列挙するためには、まず対象とするエンティティはどんなエンティティなのかを記述しなければなりません。 どんなエンティティかというのは、すなわちどんなコンポーネントを持っているか、あるいは持っていないかです。

ComponentType

まず「どんなコンポーネント」の部分、 すなわち、対象エンティティのコンポーネント制約をどうやって表すかを考えます。 単純に考えるとTypeクラスのインスタンスによる型の制約がそれに当たりそうですが、 もう少しメタデータを与えてやると最適化しやすくなる場合があります。

Unity ECSはシステムを並列に実行するための仕組みを備えていますが、 そのためには同じコンポーネントへの同時アクセスをうまく制御してやる必要があります。 そのときに重要となるのが、コンポーネントが読み込み対象なのか書き込み対象なのかという情報です。 読み込み同士は同時に実行できますが、それ以外はできません。

ここで改めて対象エンティティのコンポーネント制約について考えてみると、 それにはコンポーネントの型以外に、読み込み専用か否かという目的を表す情報を含められそうです。 これらをまとめて表すのがComponentType構造体です。

ComponentTypeReadWrite<T>あるいはReadOnly<T>によって生成することができます。

ComponentType positionRW = ComponentType.ReadWrite<Position>();
ComponentType velocityRO = ComponentType.ReadOnly<Velocity>();

Unity ECSにおいてコンポーネントの型といえばこのComponentTypeを指します。 EntityManagerのメソッドでtypeof(Position)のように書けていたのは、 Typeから読み書き可能(ReadWrite)なComponentTypeへの暗黙的型変換が定義されているためです。

ComponentType positionRW = typeof(Position);

ちなみにComponentTypeには読み込み専用かどうかの他に、 チャンクコンポーネントかどうかや型の除外を表すこともできますが、 直接使うことはあまりないと思います。

EntityQueryDesc

対象コンポーネントを表せるようになったため、これを利用してクエリの内容を記述します。 これにはEntityQueryDescクラスを使用します。

// PositionとVelocityを含み、ForceかAccelerationのどちらかを最低含み、Staticを含まない
EntityQueryDesc desc = new EntityQueryDesc()
{
    All = new ComponentType[] { typeof(Position), typeof(Velocity) },
    Any = new ComponentType[]
    {
        ComponentType.ReadOnly<Force>(),
        ComponentType.ReadOnly<Acceleration>(),
    },
    None = new ComponentType[] { typeof(Static) },
};

それぞれのプロパティの意味は次の通り。

プロパティ 意味
All 含まなければならないコンポーネントの型の配列
Any いずれかを含まなければならないコンポーネントの型の配列
None 含んではならないコンポーネントの型の配列

注意点として、Anyは持っていてもいなくてもいい型ではなくて、いずれかを必ず含まなければならない型です。 持っていてもいなくてもいい型はエンティティの選別に一切関与しないためクエリには含めず、 エンティティ取得後にそのコンポーネントを持っているかどうか確認します。

EntityQuery

さて、ここまででクエリの作成に必要な情報は揃いました。 これをEntityManagerと関連付けることでクエリを作成し、クエリ結果のビューを得ます。 このビューをEntityQueryクラスが表します。

EntityQueryEntityManagerCreateEntityQueryで作成できますが、 システム内で使う場合は通常、継承したGetEntityQueryで取得します。 これは、クエリをシステムとも関連付けることで、様々な処理を最適化するためです。

public class MySystem : ComponentSystem
{
    private EntityQuery query;

    protected override void OnCreate()
    {
        query = GetEntityQuery(new EntityQueryDesc()
        {
            All = new ComponentType[] { typeof(Position), typeof(Velocity) },
        });

        // 略記も可
        query = GetEntityQuery(typeof(Position), typeof(Velocity));
    }

    protected override void OnUpdate() { /* ... */ }
}

EntityQueryの取得はOnCreate内で行ってキャッシュするのが通例です。 EntityQueryIDisposableを実装しますが、 GetEntityQueryで取得した場合には自動的に解放されるため、解放処理を書く必要はありません。

フィルタの設定

EntityQueryにはさらに列挙対象を絞るためのフィルタを設定することができます。 フィルタには次の二種類があります。

  • 共有コンポーネントの値によるフィルタ
  • 前回から変更があったかどうかによるフィルタ

前者はSetFilter、後者はSetFilterChangedによって設定します。

query.SetFilter(new MyShared { Value = 42 });
query.SetFilterChanged(typeof(Position));

SetFilterChangedは実際に値の変更を見ているわけではなく、 そのチャンクの対象コンポーネントに対して書き込み得るクエリが実行されたか否かで判定しています。 そのため、主な用途は特定のコンポーネントから計算される値を別のコンポーネントにキャッシュすることになります。

EntityQueryBuilder

EntityQueryBuilder構造体はEntityQueryDescEntityQueryをFluentなAPI(メソッドチェーン)で書けるようにしたものです。 EntityQueryBuilderはシステムのEntitiesによって取得します。

// PositionとVelocityを含み、ForceかAccelerationのどちらかを最低含み、Staticを含まない
EntityQuery query = Entities
    .WithAll<Position, Velocity>()
    .WithAnyReadOnly<Force, Acceleration>()
    .WithNone<Static>()
    .ToEntityQuery();

EntityQueryBuilderの意義は後述するForEachという列挙用APIにあります。

【19/05/29 更新】
preview.32にて、読み込み専用を表すWithAllReadOnlyWithAnyReadOnlyが追加されたため、記述を修正。

クエリの実行

ここまででクエリの内容に応じたEntityQueryを得ることができたので、 あとはそのEntityQueryによってエンティティを列挙していきます。 エンティティの列挙には、処理効率や書きやすさの異なるいくつかの方法が提供されています。 ここでは同期的に実行可能な方法のみ扱い、非同期に実行する方法は後々説明します。

この項の以降のサンプルコードはすべてシステム内のOnUpdate内に記述されているものとし、 次のようにqueryフィールドが初期化されているものとします。

query = GetEntityQuery(typeof(Position), ComponentType.ReadOnly<Velocity>());

チャンクイテレーション

チャンクイテレーション(手動イテレーション)は最も原始的なエンティティの列挙方法です。 名前の通り、処理対象のチャンクを反復処理していきます。 他の列挙方法もすべてこれを元に実装されているため、 これさえ理解しておけばとりあえず困ることはありません。 しかし、原始的ゆえにコードが長くなる傾向にあります。

全体的な流れとしては、

  1. アクセスしたいコンポーネントを宣言する
  2. EntityQueryに合致するチャンクの配列を取得する
  3. チャンクそれぞれに対してコンポーネントの配列を取得する
  4. コンポーネントの配列を書き換える

となっています。

var positionType = GetArchetypeChunkComponentType<Position>();
var velocityType = GetArchetypeChunkComponentType<Velocity>(true);

using (NativeArray<ArchetypeChunk> chunks = query.CreateArchetypeChunkArray(Allocator.TempJob))
{
    foreach (ArchetypeChunk chunk in chunks)
    {
        NativeArray<Position> positions = chunk.GetNativeArray(positionType);
        NativeArray<Velocity> velocities = chunk.GetNativeArray(velocityType);
        for (int i = 0; i < chunk.Count; i++)
        {
            positions[i] += velocities[i] * 0.02f;
        }
    }
}

チャンクのコンポーネントやエンティティにアクセスするためには、あらかじめその旨を宣言する必要があります。 これにはコンポーネントの種類に応じたGetArchetypeChunkXXXTypeというシステムのメソッドを使用します。 基本コンポーネントと動的バッファコンポーネントの場合には、引数に読み込み専用か否かを指定します(省略した場合は読み書き可)。 エンティティや共有コンポーネントは常に読み込み専用です。 アクセス対象と呼び出しの結果得られるアクセス型の対応は次の通りです。

アクセス対象 アクセス型
エンティティ ArchetypeChunkEntityType
基本コンポーネント ArchetypeChunkComponentType<T>
共有コンポーネント ArchetypeChunkSharedComponentType<T>
動的バッファコンポーネント ArchetypeChunkBufferType<T>

チャンクコンポーネントとコンポーネントオブジェクトに関しては、基本コンポーネントと同じ型を使います。 これらのアクセス型はチャンクからデータを取得する際に必要になります。 ちなみに、これらの型を得るためのメソッド呼び出しはこのフレーム内にアクセスすることを示す宣言なので、結果をキャッシュしてはいけません。

宣言が済んだら、次は対象となるチャンクの配列を取得します。 EntityQueryCreateArchetypeChunkArrayはクエリに合致するチャンク(ArchetypeChunk)の配列(NativeArray)を返します。 引数は配列の確保に使用するAllocatorです。 用途から考えるとAllocator.Tempでも良さそうな気がしますが、 内部でジョブを利用しているためAllocator.TempJob以上でないとエラーになります。

チャンクの配列を取得したらそれをforeachで順番に処理します。 それぞれのチャンクからエンティティやコンポーネントを取り出して処理をするのですが、 種類によってチャンクへの格納のされ方が違うため、それぞれに対応した操作を行う必要があります。 アクセス対象に対応したArchetypeChunkのメソッドを次の表に示します(各ArchetypeChunkXXXTypetypeと略記)。

アクセス対象 メソッド
エンティティ GetNativeArray(type)
基本コンポーネント GetNativeArray<T>(type)
共有コンポーネント GetSharedComponentData<T>(type, EntityManager)
動的バッファコンポーネント GetBufferAccessor<T>(type)
チャンクコンポーネント GetChunkComponentData<T>(type)
チャンクコンポーネント SetChunkComponentData<T>(type, T)
コンポーネントオブジェクト GetComponentObjects<T>(type, EntityManager)

基本コンポーネント、動的バッファコンポーネント、コンポーネントオブジェクトはエンティティごとに値を持つため、 取得メソッドはそれぞれ配列ライクな型を戻り値として返します。 変更はその配列の要素に対して行えば、チャンクへと直接反映されます。 共有コンポーネントとチャンクコンポーネントはチャンクごとに値を持つため、 取得メソッドはそれぞれ値そのものを戻り値として返します。 チャンクコンポーネントはチャンクのデータなので、設定もチャンクに対して行えますが、 共有コンポーネントはあくまでもエンティティのデータなので、チャンク自体に設定用のメソッドはありません。 値の変更はエンティティごとに行う必要があります。

ひとつ、共有コンポーネントとコンポーネントオブジェクトの取得にEntityManagerを要求していることに注意してください。 これは、これらのデータがチャンク内に記憶されていないためですが、 参照型を含みうるこれらのデータへの非同期アクセスを防止する目的もあるようです。 詳しくは後述の非同期処理の説明を参照してください。

その他有用な操作として、オプショナルなコンポーネントの存在を確認するHasHasChunkComponent、 変更があったかどうか確認するDidChangeなどがArchetypeChunkには定義されています。

ForEach

チャンクイテレーションは見ての通り記述が非常に面倒なため、 機能とパフォーマンスの一部を犠牲に、より簡単に書くための方法としてForEachというAPIが導入されました。 ForEachEntityQueryBuilderのメソッドとして実装されています。

Entities.With(query).ForEach((ref Position position, ref Velocity velocity) =>
{
    position += velocity * 0.02f;
});

さて、EntityQueryBuilderには前述の通り、EntityQueryを作成する機能がありました。 上記のコードではWithによって既存のEntityQueryを指定していますが、 クエリの指定に直接ForEachを繋げることもできます。

Entities
    .WithAllReadOnly<Velocity>()
    .WithAnyReadOnly<Force, Acceleration>()
    .WithNone<Static>()
    .ForEach((ref Position position, ref Velocity velocity) =>
    {
        position += velocity * 0.02f;
    });

ForEachに渡すコールバックの引数に指定したコンポーネントは自動的にWithAllに指定されたものとされます。

【19/05/29 更新】
preview.32にて、読み込み専用を表すWithAllReadOnlyWithAnyReadOnlyが追加されたため、記述を修正。

【19/09/25 更新】
インクリメンタルガベージコレクションの導入により、 ラムダ式による使い捨てのメモリ確保が問題とならなくなったため、記述を削除。

ToComponentDataArrayCopyFromComponentDataArray

前述の二つはコンポーネントの格納方法に合わせて処理しているため、状況によっては少々扱いづらいこともあります。 そこで、一度配列にすべてコピーしてしまうことで処理しやすくしようというAPIがToComponentDataArrayCopyFromComponentDataArrayです。 ToComponentDataArrayで配列にコピーして、CopyFromComponentDataArrayで書き戻します。

NativeArray<Position> positions = query.ToComponentDataArray<Position>(Allocator.TempJob);
NativeArray<Velocity> velocities = query.ToComponentDataArray<Velocity>(Allocator.TempJob);

for (int i = 0; i < positions.Length; i++)
{
    positions[i] += velocities[i] * 0.02f;
}

query.CopyFromComponentDataArray(positions);

positions.Dispose();
velocities.Dispose();

CreateArchetypeChunkArrayと同様に、Allocatorを指定して呼び出してNativeArrayを受け取りますが、 using文を使うと変数が読み込み専用になるため、配列に書き込みができなくなるので注意してください。 メモリリークが気になるのであればtry~finallyで囲むのがよいと思います。

エンティティ配列を得るためのToEntityArrayもあります。

システムの実行順序

システムは順番をよく考えて実行する必要があります。 例えば、あるシステムがイベントの発生を検知してそれをコンポーネントに書き込んでも、 他のシステムが読み込んで反応する前にコンポーネントが削除されてしまっては意味がありません。

Unity ECSではシステムの実行順序をComponentSystemGroupによって管理します。 各システムとそれをまとめるグループはCompositeパターンのような形で木構造をとります。 この木構造に加えて、兄弟関係にあるノードの順序関係を定義することで各システムの実行順序が決定します。

システムグループを定義するにはComponentSystemGroupクラスを継承したクラスを定義します。

[UpdateInGroup(typeof(SimulationSystemGroup))]
public class MySystemGroup : ComponentSystemGroup { }

クラスの内容は空で構いません(むしろ余計なものを書いてはいけません)。 グループを定義する際には、そのグループの親グループをUpdateInGroup属性で指定します。 ルートとなるグループには次の三つのグループがあります。

  • InitializationSystemGroup
    • フレーム内で使用するデータの準備やリセットなど
    • PlayerLoopのInitializationの最後に実行
  • SimulationSystemGroup
    • ゲームモデルの状態更新
    • 本来はPlayerLoopのFixedUpdateの最後に実行
    • 現在はPlayerLoopのUpdateの最後に実行
  • PresentationSystemGroup
    • 表示モデルの状態更新
    • 本来はPlayerLoopのUpdateの最後に実行
    • 現在はPlayerLoopのPreLateUpdateの最後に実行

PlayerLoopについてはこちらの記事を参照してください。

一部、本来の実行タイミングと現在の実行タイミングが異なっているのは、 まだ旧アーキテクチャからECSへの移行段階で、いろいろと準備ができていないためみたいです。 SimulationSystemGroupは最終的にFixedUpdateに戻すつもりみたいですが、 PresentationSystemGroupはECS的におそらくどっちでも構わないので、 旧アーキテクチャとの共存を考えてそのままになりそうな気もします。 PreLateUpdateというフェーズ自体、旧アーキテクチャの遺産っぽいのでちょっと気持ち悪いですが……。

システムをグループに所属させるには同じくUpdateInGroup属性をシステムに設定します (指定しなかったシステムは既定でSimulationSystemGroupに所属します)。

[UpdateInGroup(typeof(MySystemGroup))]
public class MySystem : ComponentSystem { /* ... */ }

また、兄弟ノード(システムまたはグループ)間の順序関係の指定にはUpdateBefore属性とUpdateAfter属性を使います。

[UpdateInGroup(typeof(MySystemGroup))]
[UpdateBefore(typeof(SecondSystem))]
public class FirstSystem : ComponentSystem { /* ... */ }

[UpdateInGroup(typeof(MySystemGroup))]
public class SecondSystem : ComponentSystem { /* ... */ }

[UpdateInGroup(typeof(MySystemGroup))]
[UpdateAfter(typeof(SecondSystem))]
public class ThirdSystem : ComponentSystem { /* ... */ }

兄弟ノード以外に対してこれらの属性を指定してしまうとエラーになります。

システムを自動的にグループに登録したくない場合には、DisableAutoCreation属性をシステムに指定します。 その後、グループのAddSystemToUpdateListSortSystemUpdateListによって手動でシステムを登録します (あるいはグループに所属させずに直接Updateを呼び出します)。 テストなどの目的でシステムの自動登録を全体的に無効にしたい場合には、 DisableAutoCreation属性をアセンブリに対して設定することもできます。

【19/05/29 更新】
preview.33にて、DisableAutoCreationがアセンブリに設定可能になったため、説明を追記。

システム実装の注意点

ここまでで、システムの記述に最低限必要な知識は説明しましたが、 実際に実装しようとするとうまくいかない場面が出てくると思います。 そこで、もう少しUnity ECSの仕組みに深く踏み込んで、詳細な制御について説明します。

システムの自動停止

システムのGetEntityQueryを呼び出すと、そのEntityQueryはシステムに関連付けられます。 こうして関連付けられたEntityQueryのいずれにも対応するエンティティが存在しない場合に、 Unity ECSはそのシステムの更新をスキップします。 システムはエンティティのコンポーネントのみを入力とするため、 エンティティが存在しないならば処理は必要ないだろうという判断です。 この挙動はAlwaysUpdateSystem属性をシステムに設定すると無効にできます。

GetEntityQueryによる自動の最適化では保守的に動く必要があるため、 各EntityQueryのいずれか(OR)にエンティティが存在すればシステムが動作してしまいます。 ある特定のEntityQueryすべて(AND)にエンティティが存在する場合のみ、 システムを動作させたい場合にはRequireForUpdateを呼び出します。

protected override void OnCreate()
{
    this.queryA = GetEntityQuery(typeof(Position));
    this.queryB = GetEntityQuery(typeof(Velocity));
    RequireForUpdate(this.queryA);
    RequireForUpdate(this.queryB);
}

エンティティは存在するけれど動作はさせたくないという場合には、 システムのEnabledプロパティにfalseを代入します。 しかし、このプロパティは使い方を誤ると、システムの状態となってしまう危険があります。 タグコンポーネントなどを駆使して、システムは可能な限り自動停止によって制御すべきです。

チャンク構造の変更

エンティティに対する操作の中にはチャンクの構造を変更するものがあります。 ここでチャンクの構造とは、アーキタイプと含まれているエンティティ集合を指します。 チャンクの構造を変更する操作には次のようなものがあります。

  • エンティティの生成・破棄 (CreateEntity, Instantiate, DestroyEntity)
  • コンポーネントの追加・削除 (AddComponent, RemoveComponent)
  • 共有コンポーネントの値の変更 (SetSharedComponentData)

エンティティの操作をするときには、それがチャンクの構造を変更しうる操作か常に意識する必要があります。 なぜなら、チャンク構造の変更は、そのチャンクのデータに対する参照の正当性を破壊してしまうためです。 チャンクのエンティティを列挙している最中に、チャンク内のエンティティの数が増減したら何が起こるかは想像に難くないはずです。 Unity ECSではこのようなミスを防ぐため、例外を投げたり、処理をブロックしたりしてくれますが、 そもそもそんなことを起こさないようにコーディングするのが一番です。

EntityCommandBuffer

チャンク構造の変更が問題を起こすのは、チャンクのデータへのアクセスと同じタイミングで変更を適用しようとするからです。 つまり、変更をアクセス後まで遅延させれば問題は起きません。 そのための仕組みがEntityCommandBufferです。

EntityCommandBufferEntityManagerと同等のエンティティ操作APIを持ちますが、 それらの操作をすぐには実行せずに内部に記憶しておき、特定のタイミングで遅延実行します。 EntityCommandBufferが実行されるタイミングはその取得方法によって異なります。 EntityCommandBufferには次の二つの取得方法があります。

  • ComponentSystemPostUpdateCommandプロパティ
  • EntityCommandBufferSystemCreateCommandBufferメソッド

PostUpdateCommandはそのシステムの更新直後に実行されます。 即座に反映される必要がないのであれば、基本的にシステム内でのエンティティの操作にはこれを使います。 EntityCommandBufferSystemComponentSystemを継承したクラスで、 その更新のタイミングにCreateCommandBufferで生成されたすべてのEntityCommandBufferを実行します。 主に非同期処理に使われるため、非同期処理の説明にて詳細は扱います。

チャンク移動の最適化

コンポーネントの追加や削除、共有コンポーネントの値の変更といった操作は、対象エンティティのチャンク移動を引き起こします。 チャンクの移動には、エンティティとそのすべてのコンポーネントを新しいチャンクへとコピーする必要があります。 一回分の操作はそれほど重い処理ではありませんが、その数が多い場合にはコストが少し気になります。 例えば、すべての物体の動きを一斉に停止させたい、という場合には大量のコピーが発生するかもしれません。

そのような場合にEntityQuery単位でAddComponentRemoveComponentをするとチャンク移動が最適化されることがあります。 具体的にはタグコンポーネント、共有コンポーネント、チャンクコンポーネントの追加と削除ではデータのコピーが起きません。 これは、これらのコンポーネントがエンティティごとのデータを持たず、 元のチャンクをそのまま使いまわして新しいアーキタイプのチャンクとすることができるためです。 特に前述の例のようなケースに、タグコンポーネントの追加と削除は有用なので覚えておくといいと思います。

EntityManager.AddComponent(query, typeof(Disabled));

共有コンポーネントに関しては、SetSharedComponentData<T>でも同様の最適化が可能です。

また、あるエンティティのアーキタイプを大幅に変更したいとします。 その際、コンポーネントをひとつひとつ追加・削除していてはその度にチャンク移動が発生してしまいます。 そのような場合にはSetArchetypeでまずアーキタイプを変更し、チャンク移動を起こしてから、 コンポーネントのデータを設定すると最小限のチャンク移動で済みます。

EntityArchetype archetype = EntityManager.CreateArchetype(typeof(Position), typeof(Velocity));
EntityManager.SetArchetype(entity, archetype);
EntityManager.SetComponentData(entity, new Position { Value = 42f });
EntityManager.SetComponentData(entity, new Velocity { Value = 42f });

ただし、SetArchetypeによって、 後述する『システムの状態を表すコンポーネント』を削除しようとすると、エラーとなるため注意が必要です。

【19/09/25 更新】
0.1.0にて、SetArchetypeが追加されたため、説明を追記。
0.1.1にて、SetSharedComponentData<T>のオーバーロードが追加されたため、記述を追記。

後編へ続く

www.f-sp.com

*1:エンティティとコンポーネントからなるシステムではないです

*2:中身のない識別用のインターフェース

*3:正確にはComponentSystemBaseを継承したクラス

*4:多分名前を間違えていると思われる