エフアンダーバー

個人開発の記録

【Unity】 ECS まとめ(前編)

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

www.f-sp.com

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

  • 【19/05/06 更新】0.0.12-preview.31に対応。
  • 【19/05/29 更新】0.0.12-preview.33に対応。
  • 【19/09/25 更新】0.1.1に対応。関連記事へのリンクを追加。
  • 【20/06/15 更新】0.11.0に対応。全体的に記述を変更。

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

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

Unity ECSのデバッグにはエンティティデバッガウィンドウを使用します。 "Entities"パッケージインストール後にエディタメニューの "Window" > "Analysis" > "Entity Debugger" から開くことができます。

アーキタイプとチャンク

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が必要になります。

Entityにはエンティティが存在しないことを表す値としてEntity.Nullが定義されていて、 参照型のnullのように扱うことができます。

コンポーネント

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を実装したコンポーネントの値はチャンク外に記憶され、チャンクにはその参照情報のみが関連付けられます。

現在、ISharedComponentDataを実装したコンポーネントには参照型を含むことができます。 しかし、これは今後制限されることが予定されています。 また、参照型を含む場合には共有のための同一性判定が必要なため、 System.IEquatable<T>の実装とGetHashCodeのオーバーライドが必須となっています。

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

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では、SystemBaseを継承したクラスを実装することでシステムを定義できます。

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

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

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

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

ワールド

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

ワールドはWorldクラスで表されます。 デフォルトでは定義されたすべてのシステムを含むワールドが自動的に生成され、 World.DefaultGameObjectInjectionWorld静的プロパティに設定されます。 この動作は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>AddSystem<T>のいずれかを呼び出します。 生成したシステムはGetExistingSystem<T>あるいはGetOrCreateSystem<T>で取得できます。 システムはWorldとともに破棄することが多いですが、DestroySystemで破棄することもできます。

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

Unity ECSのアーキテクチャ

エンティティの基本操作

エンティティに関する基本操作、すなわちエンティティの生成や破棄、コンポーネントの追加、取得、更新、削除などはすべて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) チャンクコンポーネント -
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 : SystemBase
{
    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にはさらに列挙対象を絞るためのフィルタを設定することができます。 フィルタには次の二種類があります。

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

前者はSetSharedComponentFilter、後者はSetChangedVersionFilterによって設定します。

query.SetSharedComponentFilter(new MyShared { Value = 42 });
query.SetChangedVersionFilter(typeof(Position));

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

クエリの実行

ここまででクエリの内容に応じた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が用意されています。 ForEachはシステムのEntitiesというプロパティを介して使用します。

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

ForEachではラムダ式の引数に指定したコンポーネントの型を自動的に検出し、 その型のコンポーネントを必須とするEntityQueryを自動的に生成してエンティティを列挙します。 このとき、refで指定した型は読み書き可能(ReadWrite)、inで指定した型は読み込み専用(ReadOnly)とみなされます。

また、ForEachではWithXXXという形式のメソッドをチェーンさせていくことで、 EntityQueryDesc相当の設定やフィルタの設定等が可能となっています。

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

最後にRunを呼び出して実行するのを忘れないようにしてください。

ForEachは非常に強力で重要なAPIのため、後ほど詳細にまとめます。

ToComponentDataArrayCopyFromComponentDataArray

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のUpdateの最後に実行
  • LateSimulationSystemGroup
    • ゲームモデルの状態更新後の処理
    • SimulationSystemGroupの最後に実行
  • PresentationSystemGroup
    • 表示モデルの状態更新
    • PlayerLoopのPreLateUpdateの最後に実行

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

システムをグループに所属させるには同じく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 { /* ... */ }

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

グループ内の先頭や末尾にシステムを追加したい場合にはUpdateInGroupOrderFirstOrderLastを設定します。

[UpdateInGroup(typeof(MySystemGroup), OrderLast = true)]
public class LastSystem : ComponentSystem { /* ... */ }

このように設定されたシステムが複数あった場合には、 それらの中でさらにUpdateBeforeUpdateAfterによる並び替えが行われます。

システムを自動的にグループに登録したくない場合には、DisableAutoCreation属性をシステムに指定します。 その後、グループのAddSystemToUpdateListSortSystemsによって手動でシステムを登録します (あるいはグループに所属させずに直接Updateを呼び出します)。 テストなどの目的でシステムの自動登録を全体的に無効にしたい場合には、 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はコンストラクタで生成して使用することも可能ですが、 基本的にはEntityCommandBufferSystemというシステムのCreateCommandBufferによって生成します。 EntityCommandBufferSystemはその更新のタイミングで、 CreateCommandBufferで生成されたすべてのEntityCommandBufferを生成された順に実行します。 主に非同期処理に使われるため、非同期処理の説明にて詳細は扱います。

チャンク移動の最適化

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

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

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

共有コンポーネントに関しては、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 });

後編へ続く

www.f-sp.com

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

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