エフアンダーバー

個人開発の記録

【Unity】 ECS まとめ(後編)

前編の続きです。

www.f-sp.com

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に拡張メソッドで定義されたRunScheduleSingleまたは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();
    }
}

EntitiesRunの代わりに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自体は非同期に対応していないため、 非同期処理の場合にはEntityCommandBufferToConcurrentを呼び出して、EntityCommandBuffer.Concurrentに変換する必要があります。

使用できる操作は概ね同じですが、非同期版のAPIでは最初の引数としてジョブに対してユニークなIDを要求します。 これは複数のスレッドから非同期に操作を記録した場合、操作の記録順序が実行環境依存となってしまい、記録順に実行すると非決定的動作となってしまうためです。 決定的動作にするために、IDによってソートしてから実行する必要があるのです。 IDは通常、ExecuteForEachのラムダ式の引数として渡されるエンティティ番号あるいはチャンク番号を使用します。

ビルトインのEntityCommandBufferSystem

ビルトインのシステムグループの開始時や終了時に実行される、ビルトインのEntityCommandBufferSystemがいくつか定義されています。 ジョブの完了を待つ回数は当然少ない方がいいので、可能な限りこれらを使用すると処理効率がよくなります。

  • BeginInitializationEntityCommandBufferSystem
  • EndInitializationEntityCommandBufferSystem
  • BeginSimulationEntityCommandBufferSystem
  • EndSimulationEntityCommandBufferSystem
  • BeginPresentationEntityCommandBufferSystem

共有コンポーネントの制限

EntityManagerの提供する機能にはジョブでは利用できないものもあります。 その代表的なものが共有コンポーネントとコンポーネントオブジェクトです。

チャンクの列挙について説明した際、チャンクからこれらのデータを取得するにはEntityManagerが必要であることを述べました。 ジョブではEntityManagerは利用できないため、これらのデータも当然取得できません。 これは、これらのコンポーネントが参照型を含むことができるがゆえに、 データ競合の発生を防止する仕組みの提供が困難なためです。

同期点

ジョブによってエンティティを処理している間は、同期システムにおいても配慮が必要となる場合があります。 なぜなら、ジョブがチャンクの内容を参照している最中に、チャンク構造を変える操作を行ってしまう可能性があるからです。 EntityManagerによってチャンク構造を変更する処理、 すなわちエンティティの生成と破棄、コンポーネントの追加と削除、共有コンポーネントの値の変更が行われた場合、 EntityManagerは実行中のすべてのジョブの完了を待ちます。 この完了待ちをするタイミングおよびイベントのことを同期点(sync point)と呼びます。

同期点が発生すれば、当然処理効率は落ちます。 そのため、同期システムであってもジョブと同時に動作しうるのであれば、チャンク構造を変更しうる処理は避けるべきです。 チャンク構造の変更の回避にはEntityCommandBufferSystemを活用します。 また、システムの実行順序の変更で解決する場合もあるかもしれません。

システムの詳細

ここまででシステムの基本的な機能は一通り説明が終わりました。

しかし、システムにはまだ説明しきれていない部分がいくらかあります。 ここではそれらを説明しつつ、システムの詳細についてあらためてまとめていきます。

システムの実体

システムの詳細について理解するには、まずシステムの実体と仕組みについて理解する必要があります。

実はUnity ECSのシステムはとても奇妙な方法によって実現されています。 その奇妙さの片鱗はすでに紹介したコードの中にも表れています。 例えば、並列処理のコードではForEachの引数に指定したラムダ式からジョブを生成していました。 しかも、ローカル変数をキャプチャしているラムダ式でさえも問題なくジョブに変換してしまっています。 これはリフレクションで解析した程度ではどうにもならない機能です。

ではなぜこんなことができるかというと、 これらのコードはコンパイラ拡張によってコンパイル時にまったく別のコードへと変換されているからです。 EntitiesWithXXXForEachは解析のためのマーカに過ぎず、 これらの実装を覗いてみると実はまったく何も書かれていません。 これらは実際には呼び出されることのないコードなのです。

これを理解していないと、システムのあり得ない挙動に首を傾げることになります。 例えば、詳細は後述しますが、 EntitiesにはWithStoreEntityQueryInFieldという自動生成したEntityQueryをフィールドに記憶する機能があります。 普通のC#のコードとして読むと、当然このメソッド呼び出し後にしかそのフィールドは利用できないはずですが、 実際には別の場所に初期化コードが挿入されるので呼び出し前から利用が可能だったりします。 また、C#の文法上は許されていても、システムの記述としては許されていないコードも存在します。

間違ったことをすると大抵はコンパイルエラーで原因を知らせてくれるため、 変換の挙動についてそれほど熟知する必要はありませんが、 書いた通りに動いているわけではないことには常に注意してください。

システムのプロパティ

まずはシステムの重要なプロパティについて確認していきます。

Dependency

Dependencyはシステムが依存するジョブのジョブハンドルを取得、設定します。

DependencyにはOnUpdateの前に、システムが依存するすべてのジョブのジョブハンドルを結合した値が設定されます。 ここで、システムが依存するジョブというのは、システムが読み書きするコンポーネントの情報を元に、 並列実行できない(データ競合を起こす)すべてのジョブが自動的に抽出されます。 また、システムが読み書きするコンポーネントは、 GetEntityQueryGetArchetypeChunkComponentTypeなどの呼び出しから判断されます。 システムのメソッドではなくEntityManagerの同名メソッドを使用してしまうと、 これらの情報が正しく収集されないため注意が必要です。

システム内でジョブをスケジュールする場合には、 必ずその依存対象にDependencyを直接的あるいは間接的に含め、 それらすべての完了を待機できるジョブハンドルでDependencyを更新する必要があります。

後述するEntitiesJobの、引数のないScheduleScheduleParallelによってジョブをスケジュールした場合、 自動的にそのジョブの依存対象にDependencyの値が設定され、そのジョブのジョブハンドルでDependencyが更新されます。 そのため、システム内で引数のないScheduleScheduleParallelを複数回実行した場合、 それらは記述した順番通りに実行されます。

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) 含まなければならない読み込み専用のコンポーネント

entityInQueryIndexnativeThreadIndexについては引数名まで完全に一致する必要があります。

引数は値渡し、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コンパイラの設定

WithoutBurstWithStructuralChangesを指定せずに、 Burstに対応していないコードやチャンク構造を変更するコードを記述するとコンパイルエラーとなります。

実行系メソッド

メソッドチェーンの最後に記述する実行方法を指定するメソッドです。

シグネチャ 説明
Run() ラムダ式をメインスレッドで実行します。この際、すべての依存ジョブの終了を待ちます。
Schedule() ラムダ式を単一のジョブとしてスケジュールします。
ScheduleParallel() ラムダ式をチャンク単位で分割した複数のジョブとしてスケジュールします。

ScheduleScheduleParallelは依存するジョブハンドルを引数として渡すオーバーロードがあります。 このオーバーロードでは戻り値としてスケジュールしたジョブのジョブハンドルが返ってきます。 引数なしのオーバーロードではシステムのDependencyが自動的に参照、更新されます。

RunScheduleScheduleParallelではそれぞれ実行可能な操作が異なります。 マニュアルに実行可能な操作に関する表があるので、同様のものを以下に示します ("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はこれを行うためのユーティリティで、 EnableFixedRateSimpleEnableFixedRateWithCatchUpの二つの更新方法が提供されています。

FixedRateUtils.EnableFixedRateSimple(group, 0.02f);
FixedRateUtils.EnableFixedRateWithCatchUp(group, 0.02f);

EnableFixedRateSimpleは実際にフレーム間にどれだけ時間が経過していたとしても、 指定された時間経過で一回だけグループ内のシステムを更新します。 実際の時間の流れとシステム更新における時間の流れがずれてしまいますが、安定した更新が望めます。

EnableFixedRateWithCatchUpは実際の累積経過時間を元に、 指定された時間間隔での更新を仮定した場合にその時間までに行われているはずの回数だけグループ内のシステムを更新します。 実際の時間の流れとのずれは最小限となりますが、 一度のグループの更新に対してグループ内のシステムが複数回更新される可能性があるため、 更新が不安定となる可能性があります。

旧アーキテクチャからの移行とエディタ機能

Unity ECSにGameObjectComponentを置き換えるだけの能力があるとはいえ、 Unityにはすでに旧アーキテクチャで組まれた多数の資源があります。 これらをどのようにECSへと移行させていくかは大きな問題です。 根本から考え方が違うのでそれほどうまい手があるわけでもないのですが、いくつか支援機能が提供されています。

現在提供されている移行の支援機能は大きく二つです。 ひとつは前述したコンポーネントオブジェクトで、 これはECSに旧アーキテクチャのオブジェクトを直接組み込むことを可能にします。 もうひとつは旧アーキテクチャのオブジェクトをECSの要素へと変換する仕組みです。 ここではこの変換の仕組みについて説明していきます。

旧アーキテクチャから変換とはいっても、根本から違うものをコード変換することはできないので、 特定のGameObjectComponentのインスタンスを、 エンティティや対応するコンポーネントに置き換えるというのが基本方針となります。 もちろん、コンポーネントとそれを処理するシステムは新規に定義する必要があります。 つまり、目的は機能全体の変換ではなく、旧アーキテクチャ用に作られたシーンデータをECSの世界へと移すことです。

旧アーキテクチャとECSの対応

旧アーキテクチャからの変換プロセスでは、 旧アーキテクチャの要素とECSの要素がそれぞれどのように対応するのかが重要となります。 まずはそれらについて確認していきます。

GameObject

旧アーキテクチャにおいて、GameObjectComponentをまとめる役割を担っていました。 ECSでこれに当たる要素はエンティティで、GameObjectはエンティティへと変換されることになります。

ただし、GameObjectとエンティティではその粒度に差があります。 生成負荷の高いGameObjectがひとつのインスタンスで多くのものを表現しようとするのに対し、 生成負荷の低いエンティティは複数のインスタンスに可能な限り分割して機能を表現しようとします。 そのため、変換プロセスにおいて、GameObjectとエンティティの関係は一対多としてモデル化されています。

とはいえ、GameObjectとエンティティの関係が一対一となるケースも少なくなく、常に一対多として扱うのは少し面倒です。 そのため、GameObjectに対してプライマリエンティティという代表的なエンティティがひとつ存在することになっています。 関係が一対一とわかっている場合には、このプライマリエンティティを唯一の変換先エンティティとみなします。

Component

旧アーキテクチャにおいて、ComponentGameObjectに機能を与えるものでした。 ECSでこれに当たる要素はコンポーネントとシステムになります。 しかし、変換プロセスでは処理のコード変換は扱いませんので、Componentはコンポーネントに変換されることになります。

Componentとコンポーネントの関係は、GameObjectとエンティティの関係と同じく、一対多になります。 Componentはそれ単体で何らかの機能を表現しようとしますが、 コンポーネントはクエリによって複数のコンポーネントを組み合わせてひとつの機能を表現しようとするため、 同じ機能でもコンポーネントの方が粒度が細かくなる傾向にあります。 だいたいComponentのフィールドひとつひとつがコンポーネントとなるくらいの感覚です。

ComponentGameObjectにアタッチされているので、 変換結果のコンポーネントは、ComponentがアタッチされているGameObjectに対応したエンティティに対して関連付けることになります。

階層構造

旧アーキテクチャでは、GameObjectは階層構造をとることができ、それを利用した一括操作が可能でした。 ECSではこれらの階層構造に対応する機能が二つのコンポーネントに分割されています。

ひとつは、モデル変換のための親エンティティを指定するParentです。 旧アーキテクチャにおいてもGameObjectではなくTransformが階層構造を管理していたように、 本来、階層構造はモデル変換のために導入されます。 Parentはこの目的のためのコンポーネントで、ローカル変換行列からワールド変換行列を計算するのに利用されます。

もうひとつは、あるエンティティに対する操作を別のエンティティにも伝播させるLinkedEntityGroupです。 旧アーキテクチャでは、階層ごとGameObjectを生成・破棄したり、無効化したりできました。 LinkedEntityGroupはこれらの挙動を模倣するための動的バッファコンポーネントで、 いくつかのエンティティ操作で特別扱いされるようになっています。 例えば、このコンポーネントが関連付けられたエンティティが生成(Instantiate)または破棄(DestroyEntity)された時、 LinkedEntityGroupに記憶されたエンティティもまた、同時に生成または破棄されます。

変換プロセス

旧アーキテクチャからの変換機能はGameObjectConversionUtilityのいくつかの静的メソッドとして提供されています。 これらはシーン内や直接指定したGameObjectを次のプロセスによって変換します。

  1. 変換用の一時的なワールド(変換ワールド)を生成
  2. 変換用のシステムを変換ワールドに追加
  3. 対象のGameObjectとそのComponentを変換ワールドに追加
  4. 変換ワールドを一回だけ更新して出力先のワールドにエンティティとコンポーネントを生成
  5. 変換ワールドを破棄

変換システム

変換処理はGameObjectConversionSystemを継承したシステムを定義することで行います。 これらのシステム*3は、 変換プロセスの中で自動的に変換ワールドに追加され、Updateが一度だけ実行されます。 そのため、OnUpdateに変換処理を記述すると、自動的にその変換が適用されます。

入力となるGameObjectComponentは変換ワールドにエンティティやコンポーネントとして追加されています。 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が少しだけ違います。

システムグループ

通常のシステムと同様に、変換システムにも実行順に次の四つのシステムグループが定義されています。

  1. GameObjectDeclareReferencedObjectsGroup
  2. GameObjectBeforeConversionGroup
  3. GameObjectConversionGroup
  4. GameObjectAfterConversionGroup

UpdateInGroup属性にこれらのグループを指定することで所属するシステムを設定できます。 設定しなかった場合には既定でGameObjectConversionGroupになります。

特定のプレハブに依存する場合など、 変換対象の追加が必要な場合にはGameObjectDeclareReferencedObjectsGroupで宣言する必要があります。

補助メソッド

GameObjectConversionSystemには変換を支援するためのメソッドがいくつか定義されています (表中のObjectSystem.ObjectではなくUnityEngine.Objectです)。

シグネチャ 説明
DeclareReferencedPrefab(GameObject) GameObjectをプレハブとして変換対象に追加
DeclareReferencedAsset(Object) Objectをアセットとして変換対象に追加
DeclareLinkedEntityGroup(GameObject) GameObjectLinkedEntityGroupを追加
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は指定したGameObjectLinkedEntityGroupを追加します。 LinkedEntityGroupの追加は、全ての変換の完了後に、指定したGameObjectのプライマリエンティティに対して行われます。 リンクされる対象はGameObjectとそのすべての子孫GameObjectにそれぞれ対応する全エンティティです。

LinkedEntityGroupの追加はDeclareReferencedPrefabDeclareLinkedEntityGroupによる指定の他、 無効(!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がアタッチされていて、 あらゆるComponentTransformの存在を前提に実装されています。 もちろん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にはConvertAndDestroyConvertAndInjectGameObjectの二つのモードがあります。 ConvertAndDestroyでは、アタッチされたGameObjectとその子孫すべてを変換し、変換後にそれらを破棄します。 ConvertAndInjectGameObjectでは、アタッチされたGameObjectのみを変換し、 そのすべてのComponentをコンポーネントオブジェクトとして生成されたエンティティに関連付けます。

また、StopConvertToEntityをアタッチすると、 ConvertToEntityの再帰的な変換対象から特定のGameObjectを除外することができます。

ConvertToEntityは手軽な変換方法としては便利ですが、 変換コストが実行時にかかってしまうためあまり実用的ではありません。 多くの場面では後述するサブシーンを代わりに使用します。

サブシーン

サブシーンは旧アーキテクチャのシーン上にECSのシーン(のようなもの)を構築する機能です。

サブシーンを利用するには、SubSceneを適当なGameObjectにアタッチして読み込みたいシーンのアセットを設定します (ヒエラルキービューのコンテキストメニューに生成用のショートカットもあります)。 すると、シーン内に指定したシーンが存在するかのようにヒエラルキービューに表示されます。 また、インスペクタからSubSceneの編集を開始すると、 対象シーンのGameObjectがまるで子であるかのようにヒエラルキービューに表示され、編集が可能になります。 シーンの内部にシーンが存在するような形になるので「サブシーン」といいます。

サブシーンはGameObjectConversionUtilityConvertSceneによって、 指定されたシーンのすべてのGameObjectをECSの要素に変換してワールドに読み込みます。 これだけだと、対象がシーンになっただけでConvertToEntityとあまり変わらないように思えますが、 サブシーンの強みはその読み込み方法にあります。 サブシーンでは対象シーンの変換をエディタ上で事前に実行し、 結果をシリアライズしたものをゲーム実行時にデシリアライズして読み込みます。 Unity ECSでは一部を除いて、データをチャンクというメモリブロックで管理しているため、 メモリブロックをそのままコピーするだけで大部分のデータを復元でき、デシリアライズは非常に高速です。 そのため、サブシーンはConvertToEntityと比べてロードが非常に軽くなります。

サブシーンの分割

サブシーンはネストすることができません。 そのため、分割したい場合にはサブシーンを並列に並べるか、セクションという機能を利用します。

セクションの定義にはSceneSectionComponentを使用します。 サブシーン内の任意のGameObjectSceneSectionComponentをアタッチして、 "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専用のエディタ機能も開発されているようです。

その他の便利機能

最後にまだ紹介できていない便利機能について説明します。

DisabledPrefab

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にはGetEnabledSetEnabledという便利メソッドが定義されています。 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アセットの使い方は次の流れになっています。

  1. アセットデータを表す構造体を定義する
  2. アロケータでアセットデータを組み立てる
  3. アセットデータの参照を取得してコンポーネントに記憶する
  4. 参照を利用してアセットデータにアクセスする

まずはアセットデータを構造体で定義します。 参照型を含むことはできませんが、ポインタの代わりに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関連記事

www.f-sp.com

www.f-sp.com

おわりに

つ、疲れた……。
いつの間にやら書き始めてからひと月が経過してました。 書いている途中にもAPIが変わっていくので修正作業なんかもあり大変でした。

一応、ソースコードを読んだりテストコードを書いたりといろいろ確かめながら書きましたが、 まだまだUnity ECSは情報が少ない状態なので間違い等あるかもしれません。 また、今後APIが変わることも多分にあると思います。 何か正しくない記述があれば連絡をもらえると助かります。



執筆時のUnityのバージョン:2019.1.0f2
執筆時のECSのバージョン:0.0.12-preview.30
更新時のUnityのバージョン:2019.4.0f1
更新時のECSのバージョン:0.11.0

*1:もちろんそうなるように書いているのだけれど……

*2:数学的には要素が1つだけの集合のことをそう呼ぶそうです

*3:正確にはGameObjectConversionを指定したWorldSystemFilter属性が設定されたシステム

*4:正確には制御用のCleanupEntityコンポーネントも