前にUnityのECSについて調べて記事を書きましたが、 おそらく今後しばらく使うことはないと思われるので、 忘れないうちに現状のUnity ECSについてまとめておくことにします。
……という動機で記事を書き始めたのですが、 なんやかんやで片っ端から調べなおし、 まとめと呼ぶには長すぎる記事になりました。 多分、現時点での内容はだいたい網羅していると思います。
- 【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の利点としては次のようなものがあります。
- 並列化が容易
- システムの入力と出力が明確かつ粒度が小さいため
- キャッシュ効率がいい
- コンポーネント配列への順次アクセスによる空間的局所性のため
- 結合度が低い
- システム同士がデータによってのみやりとりするため
- テストが容易
- システムが状態を持たないため
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
は毎フレーム呼び出されます。
前処理や後処理をしたい場合には、それぞれOnCreate
とOnDestroy
をオーバーライドします。
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
で破棄することもできます。
システム内では同名のプロパティによって所属するWorld
やEntityManager
にアクセスすることができます。
エンティティの基本操作
エンティティに関する基本操作、すなわちエンティティの生成や破棄、コンポーネントの追加、取得、更新、削除などはすべて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>
を利用するバージョンのCreateEntity
、Instantiate
を使用すると効率的です。
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
構造体です。
ComponentType
はReadWrite<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
構造体が表します。
EntityQuery
はEntityManager
のCreateEntityQuery
で作成できますが、
システム内で使う場合は通常、継承した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
内で行ってキャッシュするのが通例です。
EntityQuery
はIDisposable
を実装しますが、
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>());
チャンクの列挙
チャンクの列挙は最も原始的なエンティティの列挙方法です。 直接この方法を使うことは滅多にありませんが、他の列挙方法はこれを元に実装されているため、 一通り手順を確認しておくと他の列挙方法を理解しやすくなります。
全体的な流れとしては、
- アクセスしたいコンポーネントを宣言する
EntityQuery
に合致するチャンクの配列を取得する- チャンクそれぞれに対してコンポーネントの配列を取得する
- コンポーネントの配列を書き換える
となっています。
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> |
チャンクコンポーネントとコンポーネントオブジェクトに関しては、基本コンポーネントと同じ型を使います。 これらのアクセス型はチャンクからデータを取得する際に必要になります。 ちなみに、アクセス型を取得するメソッド呼び出しはそのフレーム内にアクセスすることを示す宣言なので、結果をキャッシュしてはいけません。
宣言が済んだら、次は対象となるチャンクの配列を取得します。
EntityQuery
のCreateArchetypeChunkArray
はクエリに合致するチャンク(ArchetypeChunk
)の配列(NativeArray
)を返します。
引数は配列の確保に使用するAllocator
です。
用途から考えるとAllocator.Temp
でも良さそうな気がしますが、
内部でジョブを利用しているためAllocator.TempJob
以上でないとエラーになります。
チャンクの配列を取得したらそれをforeach
で順番に処理します。
それぞれのチャンクからエンティティやコンポーネントを取り出して処理をするのですが、
種類によってチャンクへの格納のされ方が違うため、それぞれに対応した操作を行う必要があります。
アクセス対象に対応したArchetypeChunk
のメソッドを次の表に示します(各ArchetypeChunkXXXType
はtype
と略記)。
アクセス対象 | メソッド |
---|---|
エンティティ | GetNativeArray(type) |
基本コンポーネント | GetNativeArray<T>(type) |
共有コンポーネント | GetSharedComponentData<T>(type, EntityManager) |
動的バッファコンポーネント | GetBufferAccessor<T>(type) |
チャンクコンポーネント | GetChunkComponentData<T>(type) |
チャンクコンポーネント | SetChunkComponentData<T>(type, T) |
コンポーネントオブジェクト | GetComponentObjects<T>(type, EntityManager) |
基本コンポーネント、動的バッファコンポーネント、コンポーネントオブジェクトはエンティティごとに値を持つため、 取得メソッドはそれぞれ配列ライクな型を戻り値として返します。 変更はその配列の要素に対して行えば、チャンクへと直接反映されます。 共有コンポーネントとチャンクコンポーネントはチャンクごとに値を持つため、 取得メソッドはそれぞれ値そのものを戻り値として返します。 チャンクコンポーネントはチャンクのデータなので、設定もチャンクに対して行えますが、 共有コンポーネントはあくまでもエンティティのデータなので、チャンク自体に設定用のメソッドはありません。 値の変更はエンティティごとに行う必要があります。
ひとつ、共有コンポーネントとコンポーネントオブジェクトの取得にEntityManager
を要求していることに注意してください。
これは、これらのデータがチャンク内に記憶されていないためですが、
参照型を含みうるこれらのデータへの非同期アクセスを防止する目的もあるようです。
詳しくは後述の非同期処理の説明を参照してください。
その他有用な操作として、オプショナルなコンポーネントの存在を確認するHas
やHasChunkComponent
、
変更があったかどうか確認する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のため、後ほど詳細にまとめます。
ToComponentDataArray
とCopyFromComponentDataArray
ToComponentDataArray
とCopyFromComponentDataArray
を利用すると、
各コンポーネントを一度配列にコピーしてから処理することができます。
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 { /* ... */ }
兄弟ノード以外に対してこれらの属性を指定してしまうとエラーになります。
グループ内の先頭や末尾にシステムを追加したい場合にはUpdateInGroup
にOrderFirst
やOrderLast
を設定します。
[UpdateInGroup(typeof(MySystemGroup), OrderLast = true)] public class LastSystem : ComponentSystem { /* ... */ }
このように設定されたシステムが複数あった場合には、
それらの中でさらにUpdateBefore
やUpdateAfter
による並び替えが行われます。
システムを自動的にグループに登録したくない場合には、DisableAutoCreation
属性をシステムに指定します。
その後、グループのAddSystemToUpdateList
とSortSystems
によって手動でシステムを登録します
(あるいはグループに所属させずに直接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
です。
EntityCommandBuffer
はEntityManager
と同等のエンティティ操作APIを持ちますが、
それらの操作をすぐには実行せずに内部に記憶しておき、後から実行することができます。
EntityCommandBuffer
はコンストラクタで生成して使用することも可能ですが、
基本的にはEntityCommandBufferSystem
というシステムのCreateCommandBuffer
によって生成します。
EntityCommandBufferSystem
はその更新のタイミングで、
CreateCommandBuffer
で生成されたすべてのEntityCommandBuffer
を生成された順に実行します。
主に非同期処理に使われるため、非同期処理の説明にて詳細は扱います。
チャンク移動の最適化
コンポーネントの追加や削除、共有コンポーネントの値の変更といった操作は、対象エンティティのチャンク移動を引き起こします。 チャンクの移動には、エンティティとそのすべてのコンポーネントを新しいチャンクへとコピーする必要があります。 一回分の操作はそれほど重い処理ではありませんが、その数が多い場合にはコストが少し気になります。 例えば、すべての物体の動きを一斉に停止させたい、という場合には大量のコピーが発生するかもしれません。
そのような場合にEntityQuery
単位でAddComponent
やRemoveComponent
をするとチャンク移動が最適化されることがあります。
具体的にはタグコンポーネント、共有コンポーネント、チャンクコンポーネントの追加と削除ではデータのコピーが起きません。
これは、これらのコンポーネントがエンティティごとのデータを持たず、
元のチャンクをそのまま使いまわして新しいアーキタイプのチャンクとすることができるためです。
特に前述の一斉停止の例のようなケースに、タグコンポーネントの追加と削除は有用なので覚えておくといいと思います。
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 });