前編の続きです。
C# Job Systemとの連携
冒頭で述べた通り、ECSの利点のひとつに並列化が容易であることが挙げられます。 DOTSにおいて、並列化といえばC# Job System(以下、ジョブシステム)なわけで、これらはとても相性がいいです。 ここまでECSとジョブシステムの区切りを明確にするためにあえて触れずにきましたが、 Unity ECSはジョブシステムをかなり意識して設計されています。 ECSの性能を活かしきるにはジョブシステムの活用は必須といえます。
エンティティの並列列挙
ECSは並列化が容易です。
これはシステムの入出力対象が明確なことに加えて、多くの場合にシステムの処理がエンティティひとつひとつに対して完結しているためです。
つまり、Parallel.For
のようにエンティティ全体を複数のブロックに分割し、複数のスレッドで分担して処理することができます。
Unity ECSにおいてはスレッドをより抽象化したジョブによってこれを実行することになります。
エンティティを並列に列挙する方法はいくつかありますが、例のごとく原始的な方法から見ていくことにします。
IJobChunk
エンティティを並列に列挙する最も原始的な方法は、
手動でチャンク配列を取得してそれをIJobParallelFor
で分割することですが、
このチャンクの分割処理は多くの場合に共通しています。
IJobChunk
はこの共通処理を自動化してくれます。
IJobChunk
はチャンク単位で処理するジョブを定義するのに使用します。
チャンクコンポーネントやオプショナルなコンポーネントが存在する場合に適しています。
public class ChunkMotionSystem : SystemBase { [BurstCompile] private struct ChunkMotionJob : IJobChunk { public ArchetypeChunkComponentType<Position> PositionType; [ReadOnly] public ArchetypeChunkComponentType<Velocity> VelocityType; public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) { NativeArray<Position> positions = chunk.GetNativeArray(PositionType); NativeArray<Velocity> velocties = chunk.GetNativeArray(VelocityType); for (int i = 0; i < chunk.Count; i++) { positions[i] += velocties[i] * 0.02f; } } } private EntityQuery query; protected override void OnCreate() { query = GetEntityQuery(typeof(Position), ComponentType.ReadOnly<Velocity>()); } protected override void OnUpdate() { ChunkMotionJob job = new ChunkMotionJob { PositionType = GetArchetypeChunkComponentType<Position>(), VelocityType = GetArchetypeChunkComponentType<Velocity>(true), }; Dependency = job.ScheduleParallel(query, Dependency); } }
IJobChunk
はそれを実装した構造体を定義し、それにEntityQuery
を渡してスケジュールすることで、
Execute
に記述した処理をチャンクごとに並列に実行することができます。
Execute
は引数としてチャンク、チャンクの通し番号、チャンクの最初のエンティティの通し番号を受け取ります。
チャンクの番号もエンティティの番号も列挙時に新たに与えられる0から始まる連番です。
最初のエンティティ番号というのは、言い換えればそのチャンクより前の番号を持つチャンクに含まれるエンティティ数の総和になります。
これらの番号はチャンクあるいはエンティティの長さで予め用意した配列のインデックスや、ジョブごとのユニークIDとして使用できます。
IJobChunk
ではチャンクに対して処理をすることになるため、
あらかじめアクセスしたいコンポーネントに対応するアクセス型を取得して、ジョブに渡しておく必要があります。
その際、読み込み専用で宣言した型に対してはReadOnly
属性を設定する必要があります。
ジョブの実行にはIJobChunk
に拡張メソッドで定義されたRun
、ScheduleSingle
またはScheduleParallel
を呼び出します。
これらに列挙対象を指定するEntityQuery
と依存するジョブハンドルを渡すと、
ジョブがスケジュールされ、そのジョブのジョブハンドルが返ってきます。
システムが依存するジョブハンドルはDependency
で取得と設定ができるため、
基本的にはこれを渡して、戻り値で上書きすることになります。
ForEach
IJobChunk
はチャンクごとの詳細なコントロールができますが、見ての通り記述量が多くなってしまいます。
これを解決するのにForEach
が再び登場します。
public class ForEachJobSystem : SystemBase { protected override void OnUpdate() { Dependency = Entities .ForEach((ref Position position, in Velocity velocity) => { position += velocity * 0.02f; }) .ScheduleParallel(Dependency); // 略記も可 Entities .ForEach(/* ... */) .ScheduleParallel(); } }
Entities
はRun
の代わりにSchedule
またはScheduleParallel
で実行すると、ジョブとしてスケジュールされるようになります。
Schedule
ではひとつのジョブとして、ScheduleParallel
ではチャンク単位で複数のジョブに分割されてスケジュールされます。
エンティティやエンティティの通し番号が必要な場合には、ラムダ式の引数を次のように変更すると取得できます。
Entities .ForEach((Entity entity, int entityInQueryIndex, /* ... */) => { /* ... */ }) .ScheduleParallel()
ジョブとEntityManager
さて、ここまでの例では特に問題になりませんでしたが*1、
システムを記述する上でジョブには大きな制約があります。
それはEntityManager
が一切使えないことです。
EntityManager
は設計上、ジョブからアクセスすることはできません。
非同期にEntityManager
にアクセスできてしまうと、データ競合の原因となるため禁止しているのです。
とはいえ、EntityManager
の全機能を封じられてしまうと何もできないので、
いくつかの機能は特定の型を通すことで利用できるようになっています。
ComponentDataFromEntity
EntityManager
の最も基本的な機能はコンポーネントの値の取得と設定です。
これらの機能はチャンクのメソッドやForEach
のラムダ式引数によってある程度達成できますが、これだけではまだ不十分なケースが存在します。
それはコンポーネント内に他のエンティティへの参照を記憶していて、そのエンティティのコンポーネントの値を参照したい場合です。
つまり、エンティティをキーにコンポーネントの値を取得することがこれらではできないのです。
ComponentDataFromEntity
はこのルックアップ機能を提供する型です。
ComponentDataFromEntity
はインデクサに対象エンティティを渡すことで、
そのエンティティのコンポーネントの値を取得あるいは設定できます。
ComponentDataFromEntity
はコンポーネントの型ごとに、
システムのGetComponentDataFromEntity
を呼び出すことで取得できます。
このメソッドには引数として読み込み専用か否かを指定します。
public class FromEntitySystem : SystemBase { protected override void OnUpdate() { Entities .ForEach((ref Position position, in Target target) => { var velocities = GetComponentDataFromEntity<Velocity>(true); position += velocities[target.Entity] * 0.02f; }) .Schedule(); } }
IJobChunk
の場合には、システムでComponentDataFromEntity
を取得し、それをジョブに渡して参照することになります。
このとき読み込み専用ならばReadOnly
属性を設定する必要があります。
EntityCommandBufferSystem
チャンクのメソッドやForEach
のラムダ式引数だけでは実現できないEntityManager
の重要な機能として、
エンティティの生成と破棄やコンポーネントの追加と削除、そして共有コンポーネントの値の変更があります。
しかし、これらの操作は並列処理する際に非常にやっかいな存在です。
なぜならば、これらの操作はチャンク構造を変更してしまうからです。
このような場合にはEntityCommandBuffer
を利用して操作を遅延実行するとよいのでした。
しかし、非同期システムではこの遅延実行をいつ行うかが問題となります。
ジョブが完了しないうちに実行してしまうと、列挙中のチャンク構造を変更してしまうかもしれませんし、操作の追加がまだ終わっていないかもしれません。
かといって、システムごとにジョブの完了を待ってしまうのは効率があまりよくありません。
そこで、ジョブの完了を待つタイミングを決定するための存在としてEntityCommandBufferSystem
を導入します。
EntityCommandBufferSystem
はそのシステム更新時に対応したEntityCommandBuffer
の内容を実行します。
その際、操作の実行は必要なすべてのジョブの完了を待ってから行われます。
非同期処理の同期タイミングを示すシステムなので元々はBarrierSystem
という名前でした。
EntityCommandBufferSystem
は抽象クラスで、使用するにはこれを継承したクラスを定義する必要があります。
ワールドに登録する都合上、別名を付けたいだけなので内容は空で構いません。
[UnityEngine.ExecuteAlways] [UpdateInGroup(typeof(MySystemGroup))] public class MyECBSystem : EntityCommandBufferSystem { }
使用する側は、ワールドからこのシステムを取得して、
CreateCommandBuffer
によりEntityCommandBuffer
を作成した後、ジョブに渡してスケジュールし、
最後にそのジョブハンドルをAddJobHandleForProducer
にて登録します。
登録したジョブハンドルはEntityCommandBuffer
の実行前にジョブの完了を待つために使用されます。
[UpdateInGroup(typeof(MySystemGroup))] [UpdateBefore(typeof(MyECBSystem))] public class ECBJobSystem : SystemBase { private MyECBSystem ecbSystem; protected override void OnCreate() { ecbSystem = World.GetExistingSystem<MyECBSystem>(); } protected override void OnUpdate() { EntityCommandBuffer.Concurrent ecb = ecbSystem.CreateCommandBuffer().ToConcurrent(); Entities .WithAll<Position>() .ForEach((Entity entity, int entityInQueryIndex) => { ecb.DestroyEntity(entityInQueryIndex, entity); }) .ScheduleParallel(); ecbSystem.AddJobHandleForProducer(Dependency); } }
ジョブを実行するシステムとEntityCommandBufferSystem
との実行順には十分に注意してください。
EntityCommandBuffer.Concurrent
前述のサンプルコードでは、ジョブ内でEntityCommandBuffer
ではなくEntityCommandBuffer.Concurrent
を使用していました。
実はEntityCommandBuffer
自体は非同期に対応していないため、
非同期処理の場合にはEntityCommandBuffer
のToConcurrent
を呼び出して、EntityCommandBuffer.Concurrent
に変換する必要があります。
使用できる操作は概ね同じですが、非同期版のAPIでは最初の引数としてジョブに対してユニークなIDを要求します。
これは複数のスレッドから非同期に操作を記録した場合、操作の記録順序が実行環境依存となってしまい、記録順に実行すると非決定的動作となってしまうためです。
決定的動作にするために、IDによってソートしてから実行する必要があるのです。
IDは通常、Execute
やForEach
のラムダ式の引数として渡されるエンティティ番号あるいはチャンク番号を使用します。
ビルトインのEntityCommandBufferSystem
ビルトインのシステムグループの開始時や終了時に実行される、ビルトインのEntityCommandBufferSystem
がいくつか定義されています。
ジョブの完了を待つ回数は当然少ない方がいいので、可能な限りこれらを使用すると処理効率がよくなります。
BeginInitializationEntityCommandBufferSystem
EndInitializationEntityCommandBufferSystem
BeginSimulationEntityCommandBufferSystem
EndSimulationEntityCommandBufferSystem
BeginPresentationEntityCommandBufferSystem
共有コンポーネントの制限
EntityManager
の提供する機能にはジョブでは利用できないものもあります。
その代表的なものが共有コンポーネントとコンポーネントオブジェクトです。
チャンクの列挙について説明した際、チャンクからこれらのデータを取得するにはEntityManager
が必要であることを述べました。
ジョブではEntityManager
は利用できないため、これらのデータも当然取得できません。
これは、これらのコンポーネントが参照型を含むことができるがゆえに、
データ競合の発生を防止する仕組みの提供が困難なためです。
同期点
ジョブによってエンティティを処理している間は、同期システムにおいても配慮が必要となる場合があります。
なぜなら、ジョブがチャンクの内容を参照している最中に、チャンク構造を変える操作を行ってしまう可能性があるからです。
EntityManager
によってチャンク構造を変更する処理、
すなわちエンティティの生成と破棄、コンポーネントの追加と削除、共有コンポーネントの値の変更が行われた場合、
EntityManager
は実行中のすべてのジョブの完了を待ちます。
この完了待ちをするタイミングおよびイベントのことを同期点(sync point)と呼びます。
同期点が発生すれば、当然処理効率は落ちます。
そのため、同期システムであってもジョブと同時に動作しうるのであれば、チャンク構造を変更しうる処理は避けるべきです。
チャンク構造の変更の回避にはEntityCommandBufferSystem
を活用します。
また、システムの実行順序の変更で解決する場合もあるかもしれません。
システムの詳細
ここまででシステムの基本的な機能は一通り説明が終わりました。
しかし、システムにはまだ説明しきれていない部分がいくらかあります。 ここではそれらを説明しつつ、システムの詳細についてあらためてまとめていきます。
システムの実体
システムの詳細について理解するには、まずシステムの実体と仕組みについて理解する必要があります。
実はUnity ECSのシステムはとても奇妙な方法によって実現されています。
その奇妙さの片鱗はすでに紹介したコードの中にも表れています。
例えば、並列処理のコードではForEach
の引数に指定したラムダ式からジョブを生成していました。
しかも、ローカル変数をキャプチャしているラムダ式でさえも問題なくジョブに変換してしまっています。
これはリフレクションで解析した程度ではどうにもならない機能です。
ではなぜこんなことができるかというと、
これらのコードはコンパイラ拡張によってコンパイル時にまったく別のコードへと変換されているからです。
Entities
のWithXXX
やForEach
は解析のためのマーカに過ぎず、
これらの実装を覗いてみると実はまったく何も書かれていません。
これらは実際には呼び出されることのないコードなのです。
これを理解していないと、システムのあり得ない挙動に首を傾げることになります。
例えば、詳細は後述しますが、
Entities
にはWithStoreEntityQueryInField
という自動生成したEntityQuery
をフィールドに記憶する機能があります。
普通のC#のコードとして読むと、当然このメソッド呼び出し後にしかそのフィールドは利用できないはずですが、
実際には別の場所に初期化コードが挿入されるので呼び出し前から利用が可能だったりします。
また、C#の文法上は許されていても、システムの記述としては許されていないコードも存在します。
間違ったことをすると大抵はコンパイルエラーで原因を知らせてくれるため、 変換の挙動についてそれほど熟知する必要はありませんが、 書いた通りに動いているわけではないことには常に注意してください。
システムのプロパティ
まずはシステムの重要なプロパティについて確認していきます。
Dependency
Dependency
はシステムが依存するジョブのジョブハンドルを取得、設定します。
Dependency
にはOnUpdate
の前に、システムが依存するすべてのジョブのジョブハンドルを結合した値が設定されます。
ここで、システムが依存するジョブというのは、システムが読み書きするコンポーネントの情報を元に、
並列実行できない(データ競合を起こす)すべてのジョブが自動的に抽出されます。
また、システムが読み書きするコンポーネントは、
GetEntityQuery
やGetArchetypeChunkComponentType
などの呼び出しから判断されます。
システムのメソッドではなくEntityManager
の同名メソッドを使用してしまうと、
これらの情報が正しく収集されないため注意が必要です。
システム内でジョブをスケジュールする場合には、
必ずその依存対象にDependency
を直接的あるいは間接的に含め、
それらすべての完了を待機できるジョブハンドルでDependency
を更新する必要があります。
後述するEntities
やJob
の、引数のないSchedule
やScheduleParallel
によってジョブをスケジュールした場合、
自動的にそのジョブの依存対象にDependency
の値が設定され、そのジョブのジョブハンドルでDependency
が更新されます。
そのため、システム内で引数のないSchedule
やScheduleParallel
を複数回実行した場合、
それらは記述した順番通りに実行されます。
Entities
Entities
はこれまで見てきた通り、エンティティ列挙の核となる機能です。
すでに紹介したもの以外にも多数の設定用メソッドが定義されているため順に見ていきます。
クエリ設定系メソッド
ForEach
で処理する対象のエンティティを絞り込むEntityQuery
を生成するための設定メソッドです。
シグネチャ | 説明 |
---|---|
WithAll<T1,...>() |
含まなければならないコンポーネントの型の指定 |
WithAny<T1,...>() |
いずれかを含まなければならないコンポーネントの型の指定 |
WithNone<T1,...>() |
含んではならないコンポーネントの型の指定 |
WithSharedComponentFilter<T>(T) |
共有コンポーネントの値によるフィルタの設定 |
WithChangeFilter<T1,...>() |
値が変更されたかどうかによるフィルタの設定 |
WithEntityQueryOptions(EntityQueryOptions) |
クエリオプションの設定 |
WithStoreEntityQueryInField(ref EntityQuery) |
設定されたクエリでシステムのフィールドを初期化 |
ForEach
のラムダ式の引数に含める型は自動的に含まなければならないものと判断されるため、
WithAll
で重複して指定してはいけません。
WithStoreEntityQueryInField
で指定したフィールドには、OnCreate
よりも前の時点でEntityQuery
が設定されます。
そのため、コード上WithStoreEntityQueryInField
が実行されるように見えるタイミングよりも前の時点で、
EntityQuery
の機能を使用することができます。
ForEach
内で参照するNativeArray
の初期化のために、
CalculateEntityCount
で列挙されるエンティティ数を事前に取得したい場合などに使います。
処理設定系メソッド
処理を設定するためのメソッドはForEach
のみです。
ForEach
のラムダ式には次の種類の引数を指定することができます。
引数の種類 | 説明 |
---|---|
エンティティ(Entity ) |
列挙中のエンティティ |
int 型のentityInQueryIndex |
列挙中のエンティティの通し番号 |
int 型のnativeThreadIndex |
実行中のスレッドの通し番号 |
任意のコンポーネント(ref ) |
含まなければならない読み書き可能なコンポーネント |
任意のコンポーネント(in ) |
含まなければならない読み込み専用のコンポーネント |
entityInQueryIndex
とnativeThreadIndex
については引数名まで完全に一致する必要があります。
引数は値渡し、ref
指定、in
指定の順番に8つまで指定することができます。
ただし、これはビルトインのデリゲート型が存在するか否かによる制限のため、
新規にデリゲート型を定義して、それを引数とするForEach
を拡張メソッドで追加することで、
任意の数と順番による引数指定が可能となります。
ForEach
のラムダ式ではローカル変数をキャプチャすることができますが、
ジョブとして実行する場合にはキャプチャできる変数に二つの制限があります。
ひとつはネイティブコンテナ(NativeArray
等)かBlittable型しかキャプチャできないこと、
もうひとつはネイティブコンテナにしか書き込めないことです。
キャプチャされた変数がジョブのフィールドとなることを考えれば、これは当たり前の制限です。
単一の値を計算したい場合には長さが1のNativeArray
を生成してキャプチャすることで対応できます。
ジョブ設定系メソッド
キャプチャする変数に対してジョブ用の属性を設定するためのメソッドです。
With
を除いた同名の属性が指定した変数に対して設定されます。
シグネチャ |
---|
WithReadOnly<T>(T) |
WithDeallocateOnJobCompletion<T>(T) |
WithNativeDisableParallelForRestriction<T>(T) |
WithNativeDisableContainerSafetyRestriction<T>(T) |
WithNativeDisableUnsafePtrRestriction<T>(T*) |
また、生成されるジョブの型名を指定するWithName(string)
も存在します。
追加設定系メソッド
その他の設定用メソッドです。
シグネチャ | 説明 |
---|---|
WithoutBurst() |
ラムダ式のBurstコンパイルの無効化 |
WithStructuralChanges() |
ラムダ式内でのチャンク構造の変更を宣言 |
WithBurst(FloatMode,FloatPrecision,bool) |
Burstコンパイラの設定 |
WithoutBurst
やWithStructuralChanges
を指定せずに、
Burstに対応していないコードやチャンク構造を変更するコードを記述するとコンパイルエラーとなります。
実行系メソッド
メソッドチェーンの最後に記述する実行方法を指定するメソッドです。
シグネチャ | 説明 |
---|---|
Run() |
ラムダ式をメインスレッドで実行します。この際、すべての依存ジョブの終了を待ちます。 |
Schedule() |
ラムダ式を単一のジョブとしてスケジュールします。 |
ScheduleParallel() |
ラムダ式をチャンク単位で分割した複数のジョブとしてスケジュールします。 |
Schedule
とScheduleParallel
は依存するジョブハンドルを引数として渡すオーバーロードがあります。
このオーバーロードでは戻り値としてスケジュールしたジョブのジョブハンドルが返ってきます。
引数なしのオーバーロードではシステムのDependency
が自動的に参照、更新されます。
Run
、Schedule
、ScheduleParallel
ではそれぞれ実行可能な操作が異なります。
マニュアルに実行可能な操作に関する表があるので、同様のものを以下に示します
("w/o Burst"はWithoutBurst
指定時、"w/ SC"はWithStructuralChanges
指定時、"as ReadOnly"は読み込み専用の場合です)。
内容 | Run | Schedule | ScheduleParallel |
---|---|---|---|
値型ローカル変数のキャプチャ | X | X | X |
参照型ローカル変数のキャプチャ | X w/o Burst | ||
キャプチャした変数への書き込み | X | ||
システムのフィールドの使用 | X w/o Burst | ||
参照型のメソッド | X w/o Burst | ||
共有コンポーネント | X w/o Burst | ||
マネージドコンポーネント | X w/o Burst | ||
チャンク構造の変更 | X w/o Burst w/ SC | ||
GetComponent |
X | X | X |
SetComponent |
X | X | |
GetComponentDataFromEntity |
X | X | X as ReadOnly |
HasComponent |
X | X | X |
WithDeallocateOnJobCompletion |
X | X | X |
また、現在対応していない操作についてもマニュアルに次のように記載されています。
.With
実行における動的なコードref
による共有コンポーネント引数- ネストされた
Entities.ForEach
ラムダ式 [ExecuteAlways]
が指定されたシステム内でのEntities.ForEach
(現在修正中)- 変数やフィールド、メソッド指定によるデリゲートでの呼び出し
- ラムダ式引数の型に対する
SetComponent
- 書き込み可能なラムダ式引数の型に対する
GetComponent
- ラムダ式のジェネリック型引数
- ジェネリック型のシステム内での使用
これらについては随時変更されていくものと思われますので、 最新版のマニュアルを参照するようにしてください。
Job
ForEach
で各エンティティから情報を集めた後、それらを集計して何かをしたいということはよくあります。
このとき、ForEach
をジョブとしてスケジュールしたなら、その集計処理もジョブとして実行しなければなりません。
集計処理をするためにメインスレッドでジョブの完了を待ってしまうと、処理効率が大幅に落ちてしまいます。
Job
はそのような場合のジョブの生成を簡単にするための機能です。
使い方はEntities
とほとんど変わりません。
違いはクエリ設定系のメソッドがないことと、
ForEach
の代わりに引数を受け取らないWithCode
でラムダ式を指定することです。
public class JobChainSystem : SystemBase { private EntityQuery query; protected override void OnUpdate() { var length = query.CalculateEntityCount(); var positions = new NativeArray<Position>(length, Allocator.TempJob); var center = new NativeArray<float>(1, Allocator.TempJob); Entities .WithStoreEntityQueryInField(ref query) .ForEach((int entityInQueryIndex, in Position position) => { positions[entityInQueryIndex] = position; }) .ScheduleParallel(); Job .WithCode(() => { float acc = 0f; for (int i = 0; i < positions.Length; i++) { acc += positions[i]; } center[0] = acc / positions.Length; }) .WithReadOnly(positions) .WithDeallocateOnJobCompletion(positions) .Schedule(); Entities .ForEach((ref Position position) => { position = (position + center[0]) / 2f; }) .WithReadOnly(center) .WithDeallocateOnJobCompletion(center) .ScheduleParallel(); } }
システムのメソッド
次にシステムの実装を補助するメソッドについて説明していきます。
コンポーネントアクセス
ForEach
はどの実行メソッドを呼ぶかで、メインスレッドで実行するかジョブで実行するかを簡単に切り替えられます。
しかし、そのラムダ式内で許される処理はメインスレッドとジョブとで違いがあります。
例えば、あるエンティティのコンポーネントの値を取得したい場合に、
メインスレッドであればEntityManager
から直接取得することができますが、
ジョブではComponentDataFromEntity
を経由する必要があります。
EntityManager
から直接取得するコードをラムダ式内に書いてしまうと、
そのForEach
をジョブとしてスケジュールすることはできなくなってしまいます。
システムにはこのような状況を緩和するため、GetComponent
を始めとするいくつかの補助メソッドが定義されています。
これらのメソッドは、メインスレッドで実行される場合にはEntityManager
による直接操作に、
ジョブとして実行される場合にはComponentDataFromEntity
を介した操作に自動的に変換されます。
シグネチャ | 説明 |
---|---|
GetComponent<T>(Entity) |
コンポーネントの値の取得 |
SetComponent<T>(Entity, T) |
コンポーネントの値の設定 |
HasComponent<T>(Entity) |
コンポーネントの存在の確認 |
また、同様にコード変換されるメソッドとしてGetComponentDataFromEntity<T>(bool)
があります。
これはジョブとしてスケージュールされるラムダ式内に記述された場合、
ラムダ式外で呼び出されて取得したComponentDataFromEntity
をジョブに渡すようにコード変換されます。
シングルトン
プログラムには設計上インスタンスがひとつしか存在しないとわかっている型がしばしばあります。 これらの型のことをシングルトンと呼びます*2。 シングルトンな型では、型とインスタンスが一対一で対応するため、型からインスタンスを取得するAPIの提供が可能です。
ECSにおいてはシングルトンなコンポーネント型が想定されます。 これらをいちいち列挙構文で処理するのは面倒なので、システムにいくつかの便利メソッドが定義されています。
シグネチャ | 説明 |
---|---|
GetSingleton<T>() |
シングルトンなコンポーネントの値の取得 |
GetSingletonEntity<T>() |
シングルトンなコンポーネントを持つエンティティの取得 |
SetSingleton<T>(T) |
シングルトンなコンポーネントの値の設定 |
HasSingleton<T>() |
シングルトンなコンポーネントの存在の確認 |
RequireSingletonForUpdate<T> |
シングルトンなコンポーネントの存在時のみの更新を宣言 |
これらは内部でクエリを自動生成してそれを元に処理を行うだけで、
シングルトンなコンポーネントやそのエンティティを自動で生成したりはしません。
コンポーネントの準備とひとつしか存在しないことの保証はプログラマの責務です。
より複雑なクエリをしたい場合にはEntityQuery
にも同様のメソッドが定義されています。
ちなみに、シングルトンにはSingletonパターンと呼ばれる有名なデザインパターンがありますが、 これはオブジェクト指向のデザインパターンであるため、ECSでは避けるべきです。 ECSはすべての状態がエンティティとコンポーネントによって表現されることを前提としています。
フレームレートと時間
各フレームにおける時間はシステムのTime
プロパティによって取得できます。
Time
の型はTimeData
という構造体で、
DeltaTime
によってフレーム間の経過時間を、ElapsedTime
によって全体の経過時間を取得できます。
public class MotionSystem : SystemBase { protected override void OnUpdate() { float deltaTime = Time.DeltaTime; Entities .WithNone<Static>() .ForEach((ref Position position, in Velocity velocity) => { position += velocity * deltaTime; }) .Schedule(); } }
システムが既定で所属するSimulationSystemGroup
は可変フレームレートで動作します。
しかし、可変フレームレートには実行環境によってゲームの動作が変化してしまうという非決定性の欠点があります。
実行の度に挙動が変化してしまうため再現性がなく、また時間変化量に上限のある物理演算等では使用できません。
そこで、システムグループにはその更新方法を変更する手段としてUpdateCallback
が用意されています。
システムグループのUpdateCallback
にコールバック関数を設定すると、
その関数がシステムグループの更新の際に呼び出され、
コールバック関数がtrue
を返し続ける限り、グループ内のシステムを更新し続けます。
これを利用して、Time
を更新しつつ、うまく呼び出し回数を調整すると、
グループ内のシステムを固定フレームレートで動作させることができます。
FixedRateUtils
はこれを行うためのユーティリティで、
EnableFixedRateSimple
とEnableFixedRateWithCatchUp
の二つの更新方法が提供されています。
FixedRateUtils.EnableFixedRateSimple(group, 0.02f); FixedRateUtils.EnableFixedRateWithCatchUp(group, 0.02f);
EnableFixedRateSimple
は実際にフレーム間にどれだけ時間が経過していたとしても、
指定された時間経過で一回だけグループ内のシステムを更新します。
実際の時間の流れとシステム更新における時間の流れがずれてしまいますが、安定した更新が望めます。
EnableFixedRateWithCatchUp
は実際の累積経過時間を元に、
指定された時間間隔での更新を仮定した場合にその時間までに行われているはずの回数だけグループ内のシステムを更新します。
実際の時間の流れとのずれは最小限となりますが、
一度のグループの更新に対してグループ内のシステムが複数回更新される可能性があるため、
更新が不安定となる可能性があります。
旧アーキテクチャからの移行とエディタ機能
Unity ECSにGameObject
やComponent
を置き換えるだけの能力があるとはいえ、
Unityにはすでに旧アーキテクチャで組まれた多数の資源があります。
これらをどのようにECSへと移行させていくかは大きな問題です。
根本から考え方が違うのでそれほどうまい手があるわけでもないのですが、いくつか支援機能が提供されています。
現在提供されている移行の支援機能は大きく二つです。 ひとつは前述したコンポーネントオブジェクトで、 これはECSに旧アーキテクチャのオブジェクトを直接組み込むことを可能にします。 もうひとつは旧アーキテクチャのオブジェクトをECSの要素へと変換する仕組みです。 ここではこの変換の仕組みについて説明していきます。
旧アーキテクチャから変換とはいっても、根本から違うものをコード変換することはできないので、
特定のGameObject
とComponent
のインスタンスを、
エンティティや対応するコンポーネントに置き換えるというのが基本方針となります。
もちろん、コンポーネントとそれを処理するシステムは新規に定義する必要があります。
つまり、目的は機能全体の変換ではなく、旧アーキテクチャ用に作られたシーンデータをECSの世界へと移すことです。
旧アーキテクチャとECSの対応
旧アーキテクチャからの変換プロセスでは、 旧アーキテクチャの要素とECSの要素がそれぞれどのように対応するのかが重要となります。 まずはそれらについて確認していきます。
GameObject
旧アーキテクチャにおいて、GameObject
はComponent
をまとめる役割を担っていました。
ECSでこれに当たる要素はエンティティで、GameObject
はエンティティへと変換されることになります。
ただし、GameObject
とエンティティではその粒度に差があります。
生成負荷の高いGameObject
がひとつのインスタンスで多くのものを表現しようとするのに対し、
生成負荷の低いエンティティは複数のインスタンスに可能な限り分割して機能を表現しようとします。
そのため、変換プロセスにおいて、GameObject
とエンティティの関係は一対多としてモデル化されています。
とはいえ、GameObject
とエンティティの関係が一対一となるケースも少なくなく、常に一対多として扱うのは少し面倒です。
そのため、GameObject
に対してプライマリエンティティという代表的なエンティティがひとつ存在することになっています。
関係が一対一とわかっている場合には、このプライマリエンティティを唯一の変換先エンティティとみなします。
Component
旧アーキテクチャにおいて、Component
はGameObject
に機能を与えるものでした。
ECSでこれに当たる要素はコンポーネントとシステムになります。
しかし、変換プロセスでは処理のコード変換は扱いませんので、Component
はコンポーネントに変換されることになります。
Component
とコンポーネントの関係は、GameObject
とエンティティの関係と同じく、一対多になります。
Component
はそれ単体で何らかの機能を表現しようとしますが、
コンポーネントはクエリによって複数のコンポーネントを組み合わせてひとつの機能を表現しようとするため、
同じ機能でもコンポーネントの方が粒度が細かくなる傾向にあります。
だいたいComponent
のフィールドひとつひとつがコンポーネントとなるくらいの感覚です。
Component
はGameObject
にアタッチされているので、
変換結果のコンポーネントは、Component
がアタッチされているGameObject
に対応したエンティティに対して関連付けることになります。
階層構造
旧アーキテクチャでは、GameObject
は階層構造をとることができ、それを利用した一括操作が可能でした。
ECSではこれらの階層構造に対応する機能が二つのコンポーネントに分割されています。
ひとつは、モデル変換のための親エンティティを指定するParent
です。
旧アーキテクチャにおいてもGameObject
ではなくTransform
が階層構造を管理していたように、
本来、階層構造はモデル変換のために導入されます。
Parent
はこの目的のためのコンポーネントで、ローカル変換行列からワールド変換行列を計算するのに利用されます。
もうひとつは、あるエンティティに対する操作を別のエンティティにも伝播させるLinkedEntityGroup
です。
旧アーキテクチャでは、階層ごとGameObject
を生成・破棄したり、無効化したりできました。
LinkedEntityGroup
はこれらの挙動を模倣するための動的バッファコンポーネントで、
いくつかのエンティティ操作で特別扱いされるようになっています。
例えば、このコンポーネントが関連付けられたエンティティが生成(Instantiate
)または破棄(DestroyEntity
)された時、
LinkedEntityGroup
に記憶されたエンティティもまた、同時に生成または破棄されます。
変換プロセス
旧アーキテクチャからの変換機能はGameObjectConversionUtility
のいくつかの静的メソッドとして提供されています。
これらはシーン内や直接指定したGameObject
を次のプロセスによって変換します。
- 変換用の一時的なワールド(変換ワールド)を生成
- 変換用のシステムを変換ワールドに追加
- 対象の
GameObject
とそのComponent
を変換ワールドに追加 - 変換ワールドを一回だけ更新して出力先のワールドにエンティティとコンポーネントを生成
- 変換ワールドを破棄
変換システム
変換処理はGameObjectConversionSystem
を継承したシステムを定義することで行います。
これらのシステム*3は、
変換プロセスの中で自動的に変換ワールドに追加され、Update
が一度だけ実行されます。
そのため、OnUpdate
に変換処理を記述すると、自動的にその変換が適用されます。
入力となるGameObject
やComponent
は変換ワールドにエンティティやコンポーネントとして追加されています。
GameObject
にはそれぞれ対応したエンティティが生成され、
Component
はコンポーネントオブジェクトとしてそのエンティティに関連付けられています。
これらをクエリで検索することで、変換対象のみを抽出することができます。
出力は継承したDstEntityManager
によって出力先のワールドのEntityManager
を取得し、
それによってエンティティやコンポーネントを生成することで行います。
また、いくつかの補助メソッドの利用も可能です。
public class MyScriptConversionSystem : GameObjectConversionSystem { protected override void OnUpdate() { Entities.ForEach((MyScript script) => { Entity entity = GetPrimaryEntity(script); DstEntityManager.AddComponentData(entity, new MyComponent { Value = script.Value }); }); } }
注:GameObjectConversionSystem
は新しいSystemBase
ではなく、旧いComponentSystem
を継承しているため、
APIが少しだけ違います。
システムグループ
通常のシステムと同様に、変換システムにも実行順に次の四つのシステムグループが定義されています。
GameObjectDeclareReferencedObjectsGroup
GameObjectBeforeConversionGroup
GameObjectConversionGroup
GameObjectAfterConversionGroup
UpdateInGroup
属性にこれらのグループを指定することで所属するシステムを設定できます。
設定しなかった場合には既定でGameObjectConversionGroup
になります。
特定のプレハブに依存する場合など、
変換対象の追加が必要な場合にはGameObjectDeclareReferencedObjectsGroup
で宣言する必要があります。
補助メソッド
GameObjectConversionSystem
には変換を支援するためのメソッドがいくつか定義されています
(表中のObject
はSystem.Object
ではなくUnityEngine.Object
です)。
シグネチャ | 説明 |
---|---|
DeclareReferencedPrefab(GameObject) |
GameObject をプレハブとして変換対象に追加 |
DeclareReferencedAsset(Object) |
Object をアセットとして変換対象に追加 |
DeclareLinkedEntityGroup(GameObject) |
GameObject にLinkedEntityGroup を追加 |
DeclareDependency(GameObject, GameObject) |
GameObject 間に依存関係を宣言 |
DeclareAssetDependency(GameObject, Object) |
GameObject とアセット間に依存関係を宣言 |
HasPrimaryEntity(Object) |
Object にプライマリエンティティが存在するか確認 |
GetPrimaryEntity(Object) |
Object に対応するプライマリエンティティを取得 |
TryGetPrimaryEntity(Object) |
存在すればObject に対応するプライマリエンティティを取得 |
GetEntities(Object) |
Object に対応する全エンティティを取得 |
CreateAdditionalEntity(Object) |
Object に対応するエンティティを追加生成 |
これらのメソッドの多くにはComponent
を引数に取るオーバーロードも定義されています
(実装はgameObject
プロパティを参照しているだけ)。
プレハブはUnity ECSにおいて、Prefab
コンポーネント(詳細は後述)が関連付けられた単なるエンティティです。
DeclareReferencedPrefab
を呼び出すと、指定したGameObject
とその子孫のGameObject
すべてを変換対象に加えると同時に、
それらに対応するエンティティがプレハブであることを記憶し、変換終了時にPrefab
コンポーネントを自動的に付加してくれます。
また、指定したGameObject
には自動的にLinkedEntityGroup
が追加されます。
アセットは同様に、Asset
コンポーネントが関連付けられた単なるエンティティです。
DeclareReferencedAsset
を呼び出すと、
Asset
と指定されたObject
のコンポーネントオブジェクトが関連付けられたエンティティが生成されます。
DeclareLinkedEntityGroup
は指定したGameObject
にLinkedEntityGroup
を追加します。
LinkedEntityGroup
の追加は、全ての変換の完了後に、指定したGameObject
のプライマリエンティティに対して行われます。
リンクされる対象はGameObject
とそのすべての子孫GameObject
にそれぞれ対応する全エンティティです。
LinkedEntityGroup
の追加はDeclareReferencedPrefab
とDeclareLinkedEntityGroup
による指定の他、
無効(!activeSelf
)なGameObject
に対しても自動的に行われます。
MonoBehaviour
からの変換
GameObjectConversionSystem
は汎用性の高い仕組みですが、
記述がやや面倒で、またComponent
の状態を外部から参照して変換しなければなりません。
多くのユーザスクリプト(MonoBehaviour
)の変換では、複数のComponent
を組み合わせてクエリする必要はなく、
それならばユーザスクリプト内に直接変換処理を書いた方がシンプルに記述できるはずです。
次の二つのインターフェースはこれを実現するためのものです。
IDeclareReferencedPrefabs
IConvertGameObjectToEntity
変換対象のGameObject
にこれらのインターフェースを実装したユーザスクリプトがアタッチされていると、
自動的に検出されて、それぞれのインターフェースに対応した処理が実行されます。
IDeclareReferencedPrefabs
IDeclareReferencedPrefabs
はユーザスクリプトが参照しているプレハブを追加の変換対象として宣言します。
public interface IDeclareReferencedPrefabs { void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs); }
やることは引数として与えられたリストにプレハブを追加するだけです。
IConvertGameObjectToEntity
IConvertGameObjectToEntity
はユーザスクリプトの変換処理を実装します。
public interface IConvertGameObjectToEntity { void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem); }
Convert
の引数にはプライマリエンティティと変換先のワールドのEntityManager
、
そして補助メソッド呼び出しのためにGameObjectConversionSystem
が渡されます。
あとはユーザスクリプトのフィールドを参照しつつ、変換処理を書くだけです。
Transform
の変換
GameObject
には必ずTransform
がアタッチされていて、
あらゆるComponent
がTransform
の存在を前提に実装されています。
もちろんUnity ECSにも同様な仕組みは存在していて、それらはUnity.Transforms
名前空間に定義されています。
簡単に説明しておくと、ワールド行列を表すLocalToWorld
コンポーネントがあり、
このコンポーネントの値を計算するためにその他多数のコンポーネントとシステムが定義されています。
基本的なのはTranslation
コンポーネント、Rotation
コンポーネント、NonUniformScale
コンポーネントで、
それぞれ平行移動量、回転クォータニオン、非一様スケール値を表します。
これらがLocalToWorld
コンポーネントと一緒に関連付けられていると、
その組み合わせに対応したシステムがワールド行列を計算してLocalToWorld
に格納します。
また、さらにParent
コンポーネントとLocalToParent
コンポーネントが関連付けられていると挙動が変わり、
各線形変換の結果はローカル行列としてLocalToParent
に格納され、
その値とParent
コンポーネントに指定された親のLocalToWorld
コンポーネントの値によって計算されたワールド行列が、
自身のLocalToWorld
コンポーネントに格納されるようになります。
変換プロセスでは組み込みのTransformConversion
システムにより、
Transform
の値からこれらのコンポーネントが生成されて、プライマリコンポーネントに関連付けられます。
静的なオブジェクトに対してはStaticOptimizeEntity
をアタッチしておくと、
Static
コンポーネントが関連付けられるとともに、
LocalToWorld
コンポーネントのみの生成となるように最適化されます。
ConvertToEntity
GameObjectConversionUtility
が提供するのは静的メソッドのため、実行にはスクリプトの記述が必要となります。
ConvertToEntity
はそのスクリプト記述の手間をなくすためのComponent
です。
ConvertToEntity
はそれがアタッチされたGameObject
を変換します。
ConvertToEntity
にはConvertAndDestroy
とConvertAndInjectGameObject
の二つのモードがあります。
ConvertAndDestroy
では、アタッチされたGameObject
とその子孫すべてを変換し、変換後にそれらを破棄します。
ConvertAndInjectGameObject
では、アタッチされたGameObject
のみを変換し、
そのすべてのComponent
をコンポーネントオブジェクトとして生成されたエンティティに関連付けます。
また、StopConvertToEntity
をアタッチすると、
ConvertToEntity
の再帰的な変換対象から特定のGameObject
を除外することができます。
ConvertToEntity
は手軽な変換方法としては便利ですが、
変換コストが実行時にかかってしまうためあまり実用的ではありません。
多くの場面では後述するサブシーンを代わりに使用します。
サブシーン
サブシーンは旧アーキテクチャのシーン上にECSのシーン(のようなもの)を構築する機能です。
サブシーンを利用するには、SubScene
を適当なGameObject
にアタッチして読み込みたいシーンのアセットを設定します
(ヒエラルキービューのコンテキストメニューに生成用のショートカットもあります)。
すると、シーン内に指定したシーンが存在するかのようにヒエラルキービューに表示されます。
また、インスペクタからSubScene
の編集を開始すると、
対象シーンのGameObject
がまるで子であるかのようにヒエラルキービューに表示され、編集が可能になります。
シーンの内部にシーンが存在するような形になるので「サブシーン」といいます。
サブシーンはGameObjectConversionUtility
のConvertScene
によって、
指定されたシーンのすべてのGameObject
をECSの要素に変換してワールドに読み込みます。
これだけだと、対象がシーンになっただけでConvertToEntity
とあまり変わらないように思えますが、
サブシーンの強みはその読み込み方法にあります。
サブシーンでは対象シーンの変換をエディタ上で事前に実行し、
結果をシリアライズしたものをゲーム実行時にデシリアライズして読み込みます。
Unity ECSでは一部を除いて、データをチャンクというメモリブロックで管理しているため、
メモリブロックをそのままコピーするだけで大部分のデータを復元でき、デシリアライズは非常に高速です。
そのため、サブシーンはConvertToEntity
と比べてロードが非常に軽くなります。
サブシーンの分割
サブシーンはネストすることができません。 そのため、分割したい場合にはサブシーンを並列に並べるか、セクションという機能を利用します。
セクションの定義にはSceneSectionComponent
を使用します。
サブシーン内の任意のGameObject
にSceneSectionComponent
をアタッチして、
"Section Index"に0以外のセクション番号を設定すると、
それ以下のGameObject
によって生成されたエンティティはそのセクションに属すようになります。
いずれのセクションにも属さないエンティティはメインセクション(セクション番号は0)に属します。
SubScene
はセクションの数だけSceneSectionData
コンポーネントが関連付けられたエンティティを生成します。
実行時にこれらのエンティティにRequestSceneLoaded
コンポーネントを追加すると、
各セクションが非同期に(BlockOnStreamIn
フラグを指定すると同期的に)読み込まれます。
SubScene
の"Auto Load Scene"を有効にしている場合には、
RequestSceneLoaded
コンポーネントは自動的に追加されます。
各セクションのエンティティごとに何か操作をしたい場合にはSceneSection
共有コンポーネントが利用できます。
SceneSection
コンポーネントにはシーンのGUID(Hash128
)とセクション番号が記憶されているため、
これらをフィルタとして設定することで、特定のサブシーンの特定のセクションに属するエンティティのみを列挙することができます。
自動の再変換
サブシーンは変換を事前に実行してその結果を保存します。 そのため、変換結果に何らかの変化があった場合には、それを検出して再変換をしなければなりません。
変換結果が変わる要因は大きく二つ考えられます。
ひとつは変換元のシーンの内容が変わること、もうひとつはシーンの変換方法が変わることです。
前者は変換時に依存関係を適切に設定することで自動的に検出されます。
後者を検出できるようにするにはConverterVersion
属性を設定する必要があります。
ConverterVersion
属性はユーザ名とバージョン番号を指定して、変換システムや変換元のユーザスクリプトに対して設定します。
変換方法を変更した際にConverterVersion
属性のバージョン番号を上げることで、それが自動的に検出されて再変換が行われます。
エディタとしての利用
サブシーンはもちろん旧アーキテクチャからの移行支援という意図もあるのですが、ECSのエディタとしての活用をコンセプトに開発されたそうです。 つまり、コンポーネントのデータを設定するためのユーザスクリプトを定義すれば、 旧アーキテクチャ用のエディタ機能を活用して、ECSのエンティティの設定ができるだろう、ということみたいです。
public class MotionAuthoring : MonoBehaviour, IConvertGameObjectToEntity { public float Position; public float Velocity; public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem) { dstManager.AddComponentData(entity, new Position { Value = Position }); dstManager.AddComponentData(entity, new Velocity { Value = Velocity }); } }
また、一対一の単純な対応で済む場合にはGenerateAuthoringComponent
属性で、
自動的にオーサリングコンポーネントを生成することもできます。
[GenerateAuthoringComponent] public struct MyComponent : IComponentData { public int Value; }
ただし、旧アーキテクチャの制限として、ファイルごとのオーサリングコンポーネント数はひとつに限られます。
ファイル内にGenerateAuthoringComponent
属性の付いたコンポーネントを複数定義したり、
MonoBehaviour
継承クラスと一緒に定義したりすることはできません。
これらの機能とは別に、ECS専用のエディタ機能も開発されているようです。
その他の便利機能
最後にまだ紹介できていない便利機能について説明します。
Disabled
とPrefab
Disabled
コンポーネントとPrefab
コンポーネントは特別な意味を持つ組み込みのタグコンポーネントで、
それぞれ無効化されたエンティティとプレハブ化されたエンティティを表します。
これらのコンポーネントは、利用しやすいようにいくつかの操作において特別な扱いを受けます。
Disabled
コンポーネントが関連付けられたエンティティは、
明示的にクエリにDisabled
コンポーネントを含めない限り、自動的に列挙対象から除外されます。
これにより無効化されたエンティティは、通常のシステムの処理対象から外されることになります。
再度対象に含めたい場合には、Disabled
コンポーネントを指定してクエリを作成し、
対象エンティティからDisabled
コンポーネントを削除すればOKです。
Prefab
コンポーネントが関連付けられたエンティティも、
Disabled
コンポーネントと同様に、明示的にクエリ対象に含めない限り列挙対象から除外されます。
Prefab
コンポーネントではさらに、Instantiate
のソースとしてそのエンティティを指定した際、
自動的にPrefab
コンポーネントが削除された状態でエンティティが生成されます。
これによりプレハブエンティティは通常のシステムに処理されず、それを元に生成されたエンティティは処理されるようになります。
Disabled
コンポーネントとPrefab
コンポーネントをクエリに明示的に指定するには、
All
の対象として含めてもよいですが、
EntityQueryDesc
のオプションで指定する方法もあります。
var query1 = EntityManager.CreateEntityQuery(typeof(Position), typeof(Disabled), typeof(Prefab)); var query2 = EntityManager.CreateEntityQuery(new EntityQueryDesc { All = new ComponentType[] { typeof(Position) }, Options = EntityQueryOptions.IncludeDisabled | EntityQueryOptions.IncludePrefab, });
Disabled
コンポーネントの操作は直接行うこともできますが、
EntityManager
にはGetEnabled
とSetEnabled
という便利メソッドが定義されています。
GetEnabled
はエンティティにDisabled
コンポーネントが存在するかどうか確認し、
SetEnabled
はエンティティにDisabled
コンポーネントを追加あるいは削除します。
またSetEnabled
は、エンティティにLinkedEntityGroup
コンポーネントが存在する場合、
それに記憶されたエンティティに対しても同様の操作を実行します。
WriteGroup
WriteGroup
属性は、あるコンポーネントの値を計算するための入力コンポーネントをすべて登録することで排他処理を可能にします。
あるコンポーネントの値を計算する方法が複数ある場合、 仮に複数の入力コンポーネントがエンティティに存在してしまったとすると、 あるシステムが値を計算した後に、別のシステムが別の方法で値を計算しなおして上書きする、という現象が起きてしまいます。 これは無駄な処理である上にバグの原因にもなりかねません。 そこでそれぞれのシステムのクエリを、コンポーネントAは含むがコンポーネントBは含まない、コンポーネントBは含むがコンポーネントAは含まない、 コンポーネントAもコンポーネントBも両方含む、といったようにあらゆる組み合わせに対応したものに変えたいのですが、 これにはすべての入力コンポーネントを知っている必要があり、依存関係が複雑化してしまいます。
そこで、あらかじめ出力コンポーネントに対する入力コンポーネントをすべて登録しておくことで、
指定した入力コンポーネント以外の入力コンポーネントを含むエンティティを弾いてしまおう、というのがWriteGroup
属性の発想です。
WriteGroup
属性を入力コンポーネントに設定すると、指定した出力コンポーネントのグループに登録されます。
あとはEntityQueryDesc
のオプションとしてFilterWriteGroup
を指定すれば、
クエリに明示的に含まれているグループ内コンポーネント以外の、同グループ内コンポーネントを含むエンティティを列挙対象から除外します。
例えば、次のようにコンポーネントが定義されているとします。
struct Output : IComponentData { } [WriteGroup(typeof(Output))] struct InputA : IComponentData { } [WriteGroup(typeof(Output))] struct InputB : IComponentData { }
これに対するクエリと結果は次のようになります。
EntityManager.CreateEntity(typeof(Output), typeof(InputA)); EntityManager.CreateEntity(typeof(Output), typeof(InputB)); EntityManager.CreateEntity(typeof(Output), typeof(InputA), typeof(InputB)); var query = EntityManager.CreateEntityQuery(new EntityQueryDesc { All = new ComponentType[] { typeof(Output), ComponentType.ReadOnly<InputA>() }, Options = EntityQueryOptions.FilterWriteGroup, }); using (var entities = query.ToEntityArray(Allocator.TempJob)) { Assert.AreEqual(1, entities.Length); } query.Dispose();
システムステートコンポーネント
ECSではシステムは状態を持ってはいけません。 そのため、システムが状態を必要とする場合にはそれをコンポーネントにしてエンティティに関連付けるのが定石です。 しかし、コンポーネントとしてシステムの管理下から離れてしまうと困る場合というのもあります。 例えば、リソースを確保した際、そのハンドルを格納したコンポーネントがエンティティの破棄とともに削除されてしまうと、 リソースの解放ができなくなってしまいます。
Unity ECSではこの問題に対処するため、 各コンポーネントインターフェースを継承した特別なコンポーネントインターフェースを提供しています。
ISystemStateComponentData
ISystemStateSharedComponentData
ISystemStateBufferElementData
これらを継承したコンポーネントが関連付けられたエンティティは、 破棄された際にこれらのコンポーネントだけ*4を残したまま存在し続け、 これらのコンポーネントすべてが削除されたときに初めて実際に破棄されます。 この動作によりシステムは状態に対して後処理ができるようになります。
システムが状態を必要とするとき、必ずその状態の元となるコンポーネントが存在するはずです。 そのため、『元コンポーネントを含みかつ状態コンポーネントを含まない』というクエリで、 状態コンポーネントを追加すべきエンティティが、 『元コンポーネントを含まずかつ状態コンポーネントを含む』というクエリで、 状態コンポーネントを削除すべきエンティティが列挙できます。 状態が必要なシステムでは必ずこの二つのクエリを更新時に実行する必要があるはずです。
マネージドコンポーネント
Unity ECSでは基本的にコンポーネントは構造体で参照型を含んではいけません。 これは単純な構造を保ち、パフォーマンスを向上させる上で重要な制限です。 しかし、既存コードを移行させるときやプロトタイピング時など、 いくつかの利点を捨ててでも参照型によって手早くコードを書きたいケースは存在します。
マネージドコンポーネントはこれらのケースに対応するための、参照型を含むことができるコンポーネントです。
マネージドコンポーネントはIComponentData
を実装したクラスによって定義できます。
基本コンポーネントとの定義上の違いは構造体かクラスかでしかなく、 操作方法も概ね基本コンポーネントと同じですが、 マネージドコンポーネントは内部的にはまったく別のものとして扱われます。 メモリ配置による最適化が効かないため操作は遅く、Burstやジョブを利用できず、メインスレッド以外からの設定はできません。 移行時や一時的な利用以外では極力使用しないようにすべきです。
誤って使用してしまうことを防ぐため、
UNITY_DISABLE_MANAGED_COMPONENTS
シンボルを定義することで機能ごと無効化も可能です。
Blobアセット
BlobアセットはイミュータブルなBLOB(Binary Large OBject)のアセットをコンポーネント間で共有するための機能です。
旧アーキテクチャとの対応で言うと、ScriptableObject
やその他アセットに相当する機能です。
Blobアセットは専用のアロケータにて生成し、その参照をコンポーネントに記憶して使います。 この参照はシリアライズにおいて特別な扱いを受けるため、基本コンポーネントでも問題なく利用が可能です。 また、Blobアセットはアセットデータを連続したメモリ領域に平坦化して記憶するため、データの読み込みが非常に高速です。
Blobアセットの使い方は次の流れになっています。
- アセットデータを表す構造体を定義する
- アロケータでアセットデータを組み立てる
- アセットデータの参照を取得してコンポーネントに記憶する
- 参照を利用してアセットデータにアクセスする
まずはアセットデータを構造体で定義します。
参照型を含むことはできませんが、ポインタの代わりにBlobPtr<T>
型、配列の代わりにBlobArray<T>
型を利用できます。
struct Node { public int Value; public BlobArray<Node> Children; }
構造体を定義したら、BlobBuilder
によってアセットデータを作成します。
BlobBuilder
は最終的なメモリ領域を確保する前に、一時的なメモリ領域を確保して、そこにアセットデータを記憶していきます。
コンストラクタの引数には、この一時領域の確保に利用するAllocator
を指定します。
using (var builder = new BlobBuilder(Allocator.Temp)) { ref Node root = ref builder.ConstructRoot<Node>(); BlobBuilderArray<Node> children = builder.Allocate(ref root.Children, 1); root.Value = 1; ref Node child = ref children[0]; BlobBuilderArray<Node> grandchildren = builder.Allocate(ref child.Children, 2); child.Value = 2; // ... }
BlobBuilder
を生成したら、まずConstructRoot<T>
を呼び出し、
アセットのルートとなる型に領域を割り当ててその参照を取得します。
その後、そのメンバに値を設定していくのですが、
BlobPtr<T>
型やBlobArray<T>
型のメンバに対しては、
Allocate<T>
を呼び出して領域を割り当ててから値を設定する必要があります。
すべて設定し終えたら、CreateBlobAssetReference<T>
を呼び出してアセットの参照を取得します。
これにより、これまでに要求されたメモリサイズの合計分の領域が新たに確保された後に値がすべてコピーされ、
その参照がBlobAssetReference<T>
型として返ってきます。
BlobAssetReference<Node> asset = builder.CreateBlobAssetReference<Node>(Allocator.Persistent);
あとはその参照を適当なコンポーネントに格納すればOKです。
struct Tree : IComponentData { public BlobAssetReference<Node> Root; } EntityManager.AddComponentData(entity, new Tree { Root = asset });
値を参照するにはBlobAssetReference<T>
型のValue
を参照します。
ref Node node = ref asset.Value;
注意点として、
BlobPtr<T>
やBlobArray<T>
は参照先のメモリ領域を自身のアドレスからの相対位置で記憶しているため、
値をコピーしてしまうとまったく違う場所を指してしまいます。
そのため、Blobアセットを扱う際にはref変数を用いないとエラーとなります。
非推奨な機能
Unity ECSはまだ正式リリースされていないため、幾度となくAPIの変更が行われています。 そのため、古い記事の内容が現在は当てはまらないということも多いです。 現在すでに存在しない、あるいは非推奨とされている機能についてここにまとめておきます。
ComponentSystem
SystemBase
に置き換え
ScriptBehaviourManager
- ワールドが直接
EntityManager
を持つようにして削除 - 関連して
ComponentSystemBase
のメソッドをリネームOnCreateManager
->OnCreate
OnDestroyManager
->OnDestroy
- 関連して
World
のメソッドをリネームCreateManager
->CreateSystem
GetOrCreateManager
->GetOrCreateSystem
GetExistingManager
->GetExistingSystem
DestroyManager
->DestroySystem
- ワールドが直接
ComponentType.Create<T>
ComponentType.ReadWrite<T>
にリネーム
EntityArchetypeQuery
EntityQueryDesc
にリネーム
ComponentGroup
EntityQuery
にリネーム
SetFilter
SetFilterChanged
SetSharedComponentFilter
SetChangedVersionFilter
に置き換え
EntityQueryBuilder
ForEachLambdaJobDescription
に置き換え
[Inject]
- 他の列挙APIに置き換え
ComponentDataArray
EntityArray
ComponentGroupArray
他- 他の列挙APIに置き換え
IJobProcessComponentData
IJobForEach
ForEach
の機能の一部に置き換え
IJobProcessComponentDataWithEntity
IJobForEachWithEntity
ForEach
の機能の一部に置き換え
BarrierSystem
EntityCommandBufferSystem
にリネーム
ComponentDataWrapper
ComponentDataProxy
[GenerateAuthoringComponent]
に置き換え
BlobAllocator
BlobBuilder
に置き換え
標準システム
Unity ECS自体はゲームプログラムの基盤でしかありません。 これに対応した様々なコンポーネントやシステムが標準で提供されるようになって初めて、 UnityはECSのゲームエンジンとしての体を成します。
現在、標準で提供されている機能は次の通りです。
- モデル変換 ("Entities"パッケージ)
- ハイブリッドレンダリング ("Hybrid Renderer"パッケージ)
- 物理演算 ("Unity Physics"パッケージまたは"Havok Physics for Unity"パッケージ)
- 通信("Unity Transport"パッケージおよび"Unity NetCode"パッケージ)
- オーディオ("DSPGraph Audio Framework"パッケージ)
- アニメーション("Animation"パッケージ)
- エディタ機能("DOTS Editor"パッケージ)
オーディオとアニメーションについては、 まだパッケージマネージャからのインストールはできないようで、 直接マニフェストファイルに依存対象を記述する必要があります。
ハイブリッドというのは、ECSと旧アーキテクチャを組み合わせた、という意味です。
Unityのレンダリング機能は基本的にCamera
に依存しているため、
新たなレンダリングAPIが整備されない限り旧アーキテクチャから切り離すことができません。
そのため、現在は旧アーキテクチャを利用したレンダリング機能しか提供されていません。
ECS関連記事
おわりに
つ、疲れた……。
いつの間にやら書き始めてからひと月が経過してました。
書いている途中にもAPIが変わっていくので修正作業なんかもあり大変でした。
一応、ソースコードを読んだりテストコードを書いたりといろいろ確かめながら書きましたが、 まだまだUnity ECSは情報が少ない状態なので間違い等あるかもしれません。 また、今後APIが変わることも多分にあると思います。 何か正しくない記述があれば連絡をもらえると助かります。
執筆時のUnityのバージョン:2019.1.0f2
執筆時のECSのバージョン:0.0.12-preview.30
更新時のUnityのバージョン:2019.4.0f1
更新時のECSのバージョン:0.11.0