エフアンダーバー

個人開発の記録

【Unity】 ECS まとめ(後編)

前編の続きです。

www.f-sp.com

旧アーキテクチャからの移行

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

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

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

変換プロセス

旧アーキテクチャからの変換機能はGameObjectConversionUtilityクラスの次の二つの静的メソッドによって提供されています。

  • ConvertGameObjectHierarchy(GameObject, World)
  • ConvertScene(Scene, Hash128, World, ConversionFlags = 0)

前者はあるGameObject以下のすべてのGameObjectを変換して指定したワールド内に生成します。 後者は指定したシーン内のすべてのGameObjectを変換して指定したワールド内に生成します。 Hash128はシーンを一意に表す識別子です。 変換元シーン情報が不要であればデフォルト値で構いません。

変換は次のプロセスによって行われます。

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

【19/05/29 更新】
preview.32にて、細々とした変更がいくつかあったため、全体的に記述を修正。

GameObjectConversionSystem

変換処理はGameObjectConversionSystemクラスを継承したシステムを定義することで行います。 このシステム*1は自動的に変換ワールドに追加され、 OnUpdateが一度だけ実行されます。 GameObjectConversionSystemには出力先のワールド(DstWorld)とそのEntityManager(DstEntityManager)が定義されているので、 それらを使ってエンティティやコンポーネントを生成します。

入力となるGameObjectComponentは変換ワールドにエンティティやコンポーネントとして追加されています。 GameObjectはそれぞれエンティティに、Componentはコンポーネントオブジェクトとしてそのエンティティに関連付けられています。 これらをクエリで検索することで、変換対象のみを抽出して出力先のワールドへと変換、出力します。

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には変換を支援するためのメソッドがいくつか定義されています。

シグネチャ 説明
HasPrimaryEntity(GameObject) GameObjectに主エンティティが存在するか確認
GetPrimaryEntity(GameObject) GameObjectに対応する主エンティティを取得
GetEntities(GameObject) GameObjectに対応する全エンティティを取得
CreateAdditionalEntity(GameObject) GameObjectに対応するエンティティを追加作成
DeclareReferencedPrefab(GameObject) GameObjectをプレハブとして変換対象に追加
AddLinkedEntityGroup(GameObject) GameObjectの階層構造を維持するように設定

HasPrimaryEntityGetPrimaryEntityGetEntitiesCreateAdditionalEntity にはComponentを引数に取る略記版もあります(実装はgameObjectプロパティを参照しているだけ)。

変換プロセスにおいて、GameObjectとエンティティは一対多の関係でモデル化されていて、 エンティティのうち一つは変換プロセスで自動的に生成される主エンティティです。 もし元のComponentが別のComponentを参照するような機能を提供していた場合には、 参照先のComponentに対応する主エンティティを取得して、 変換後のコンポーネントに記憶するようにしておくと参照関係を維持できます。

プレハブはUnity ECSにおいて、Prefabコンポーネントが関連付けられた単なるエンティティです。 DeclareReferencedPrefabを呼び出すと、指定したGameObjectとそれ以下のGameObjectすべてを変換対象に加えると同時に、 それらに対応するエンティティがプレハブであることを記憶し、変換終了時にPrefabコンポーネントを自動的に付加してくれます。 Prefabコンポーネントについては後述します。

システムグループ

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

  • GameObjectConversionDeclarePrefabsGroup
  • GameObjectBeforeConversionGroup
  • GameObjectConversionGroup
  • GameObjectAfterConversionGroup

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

プレハブの追加は必ずGameObjectConversionDeclarePrefabsGroupで行い、 後はUpdateBeforeUpdateAfterで指定しきれない順序関係がある場合を除き、 既定のシステムグループを利用します。

ConvertGameObjectToEntitySystem

変換ワールドに既定で追加されるシステムとしてConvertGameObjectToEntitySystemがあります。 これはMonoBehaviourに特定のインターフェースを実装することで、 GameObjectConversionSystemと同様の処理を可能にするためのシステムです。 インターフェースを実装したMonoBehaviourはこのシステムによって自動的に検出されて実行されます。

変換処理は次の二つのステップに分解されるため、それぞれに対応したインターフェースが用意されています。

  1. 参照しているプレハブを登録する
  2. 対象スクリプト(MonoBehaviour)を変換する

正確には、後者のみがConvertGameObjectToEntitySystemによって実行され、前者は変換プロセスの一部として実行されます。

IDeclareReferencedPrefabs

MonoBehaviourがプレハブを参照している場合にはIDeclareReferencedPrefabsインターフェースを実装して、プレハブを登録します。

public interface IDeclareReferencedPrefabs
{
    void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs);
}

やることは引数として与えられたリストにプレハブを追加するだけです。

IConvertGameObjectToEntity

MonoBehaviourの変換処理を書くにはIConvertGameObjectToEntityインターフェースを実装します。

public interface IConvertGameObjectToEntity
{
    void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem);
}

Convertの引数には主エンティティと変換先のワールドのEntityManager、 そして補助メソッド呼び出しのためにGameObjectConversionSystem(ConvertGameObjectToEntitySystem)が渡されます。 あとはMonoBehaviourのフィールドを参照しつつ、変換処理を書くだけです。

LinkedEntityGroup

GameObjectにあって、エンティティにない機能として階層構造の管理があります。 もちろん旧アーキテクチャにおいても実際に階層を成していたのはTransformですが、 GameObjectにはそれを前提とした機能が実装されていました。 そのうちのひとつが、GameObjectを生成・破棄した際に、その子も同時に生成・破棄されるというものです。

旧アーキテクチャからの移行において、この機能が存在しないと大きな障壁となるため、 Unity ECSには同等な操作を実現するためのLinkedEntityGroupが定義されています。 LinkedEntityGroupは動的バッファコンポーネント(の要素)として実装されていて、エンティティの配列をデータとして記憶します。 LinkedEntityGroupが関連付けられたエンティティが破棄されたとき、 LinkedEntityGroupに記憶されたエンティティも同時に破棄されます。 また、LinkedEntityGroupが関連付けられたエンティティが複製(Instantiate)されたとき、 LinkedEntityGroupに記憶されたエンティティも同時に複製されます。

プレハブや無効なGameObject(!activeSelf)などの主エンティティには、 変換元のGameObjectの子に対応するすべてのエンティティを含んだLinkedEntityGroupが自動的に関連付けられます。 その他のGameObjectを同様に扱いたい場合には、GameObjectConversionSystemAddLinkedEntityGroupを呼び出します。

TransformConversion

GameObjectには必ずTransformがアタッチされていて、 あらゆるComponentTransformの存在を前提に実装されています。 もちろんUnity ECSにも同様な仕組みは存在していて、それらはUnity.Transforms名前空間に定義されています。

簡単に説明しておくと、ワールド行列を表すLocalToWorldコンポーネントがあり、 このコンポーネントの値を計算するためにその他多数のコンポーネントとシステムが定義されています。 基本的なのはTranslationコンポーネント、Rotationコンポーネント、NonUniformScaleコンポーネントで、 それぞれ平行移動量、回転クォータニオン、非一様スケール値を表します。 これらがLocalToWorldコンポーネントと一緒に関連付けられていると、 対応したシステムがワールド行列を計算してLocalToWorldに格納します。 また、さらにParentコンポーネントとLocalToParentコンポーネントが関連付けられていると挙動が変わり、 各線形変換の結果はローカル行列としてLocalToParentに格納され、 その値とParentコンポーネントに指定された親のLocalToWorldコンポーネントの値によって計算されたワールド行列が、 自身のLocalToWorldコンポーネントに格納されるようになります。

変換プロセスでは組み込みのTransformConversionシステムにより、 Transformの値からこれらのコンポーネントが生成されて、主コンポーネントに関連付けられます。 静的なオブジェクトに対してはStaticEntityOptimizationをアタッチしておくと、 Staticコンポーネントが関連付けられるとともに、 LocalToWorldコンポーネントのみの生成となるように最適化されます。

ConvertToEntity

GameObjectConversionUtilityが提供するのは静的メソッドのため、実行にはスクリプトの記述が必要となります。 ConvertToEntityはそのスクリプト記述の手間をなくすためのComponentです。 ConvertToEntityはそれがアタッチされたGameObjectを変換します。

ConvertToEntityにはConvertAndDestroyConvertAndInjectGameObjectの二つのモードがあります。 ConvertAndDestroyでは、変換後に変換元のGameObjectを破棄します。 ConvertAndInjectGameObjectでは、アタッチされたGameObjectのみ(子は対象外)を変換し、 そのすべてのComponentをコンポーネントオブジェクトとして生成されたエンティティに関連付けます。

ConvertToEntityは便利なように思えますが、変換のコストが実行時にかかり、 後述するサブシーンの方が有用であるため、あまり利用する場面はないかもしれません。

サブシーン

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

サブシーンを利用するには、SubSceneを適当なGameObjectにアタッチして、"Scene Asset"に読み込みたいシーンのアセットを設定します。 すると、シーン内に指定したシーンが存在するかのようにヒエラルキービューに表示されます。 また、インスペクタからSubSceneの"Edit"ボタンを押すと、 対象シーンのGameObjectがまるで子であるかのようにヒエラルキービューに表示され、編集が可能になります。 シーンの内部にシーンが存在するような形になるので「サブシーン」といいます。

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

現時点では、"Assets/StreamingAssets/EntityCache"にチャンクのデータが、 "Assets/EntityCache/Resources"に共有コンポーネントのデータと構成ヘッダが保存されます。 しかし、今後、アセット外に保存する方式に切り替え、最終的には新しいアセットパイプラインに適合させる計画だそうです。

サブシーンの分割

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

セクションの定義にはSceneSectionComponentを使用します。 サブシーン内の任意のGameObjectSceneSectionComponentをアタッチして、 "Section Index"に0以外のセクション番号を設定すると、 それ以下のGameObjectによって生成されたエンティティはそのセクションに属すようになります。 いずれのセクションにも属さないエンティティはメインセクション(セクション番号は0)に属します。

SubSceneはアクティブなワールドに、セクションの数だけSceneDataコンポーネントが関連付けられたエンティティを生成します。 実行時にこれらのエンティティにRequestSceneLoadedコンポーネントを追加すると、 各セクションの読み込みが非同期に行われます。 SubSceneの"Auto Load Scene"を有効にしている場合には、 RequestSceneLoadedコンポーネントは自動的に追加されます。

各セクションのエンティティごとに何か操作をしたい場合にはSceneSection共有コンポーネントが利用できます。 SceneSectionコンポーネントにはシーンのGUIDとセクション番号が記憶されているため、 これらをフィルタとして設定することで、特定のサブシーンの特定のセクションに属するエンティティのみを列挙することができます。

エディタとしての利用

サブシーンはもちろん旧アーキテクチャからの移行支援という意図もあるのですが、ECSのエディタとしての活用をコンセプトに開発されたそうです。 つまり、コンポーネントのデータを設定するだけのMonoBehaviourを定義すれば、 旧アーキテクチャ用のエディタ機能を活用して、ECSのエンティティの設定ができるだろう、ということみたいです。

public class MotionProxy : 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 });
    }
}

元々、コンポーネントとMonoBehaviourを一対一で変換するComponentDataProxyという抽象クラスがあるのですが、 エディタ上の編集単位とコンポーネントの分割単位は同じじゃないだろう、ということでこれは廃止していきたい考えのようです。 ECS専用のエディタ機能も開発中だそうですが、発想自体は同じとのこと。

共有コンポーネントにおける注意点

サブシーンというよりシリアライズの問題のようですが、 共有コンポーネントをサブシーンに含めたい場合には、 対応するSharedComponentDataProxyの継承クラスを定義する必要があるようです。

public class MySharedProxy : SharedComponentDataProxy<MyShared> { }

ComponentDataProxyを廃止する予定ならそのうち必要なくなるとは思いますが……。

C# Job Systemとの連携

冒頭で述べた通り、ECSの利点のひとつに並列化が容易であることが挙げられます。 DOTSにおいて、並列化といえばC# Job System(以下、ジョブシステム)なわけで、これらはとても相性がいいです。 ここまでECSとジョブシステムの区切りを明確にするためにあえて触れずにきましたが、 Unity ECSはジョブシステムをかなり意識して設計されています。 ECSの性能を活かしきるにはジョブシステムの活用は必須といえます。

エンティティの並列列挙

ECSは並列化が容易です。 これはシステムの入出力対象が明確なことに加えて、多くの場合にシステムの処理がエンティティひとつひとつに対して完結しているためです。 つまり、Parallel.Forのようにエンティティ全体を複数のブロックに分割し、複数のスレッドで分担して処理することができます。 Unity ECSにおいてはスレッドをより抽象化したジョブによってこれを実行することになります。

エンティティを並列に列挙する方法には次の三つがあります *2

  • IJobForEach
  • IJobForEachWithEntity
  • IJobChunk

まずはこれらに共通で必要なJobComponentSystemから見ていきます。

JobComponentSystem

JobComponentSystem抽象クラスはジョブを実行するためのシステムです。 ComponentSystemとの違いはOnUpdateのシグネチャです。

public class MyJobSystem : JobComponentSystem
{
    protected override JobHandle OnUpdate(JobHandle inputDeps) { /* ... */ }
}

OnUpdateは引数として依存対象のジョブハンドルを受け取り、 戻り値としてスケジュールしたジョブのジョブハンドルを返す仕様となっています。 依存対象というのはシステムが読み込むコンポーネントに対して書き込みを行っているジョブや、 システムが書き込むコンポーネントに対して読み込みあるいは書き込みを行っているジョブです。 もちろんこれらのジョブは別のシステムによってスケジュールされたものです。

依存対象および操作対象の検出はGetEntityQueryGetArchetypeChunkComponentTypeなどの呼び出しから判断されます。 EntityManagerから直接EntityQueryを取得したりすると、 依存していないとみなされてしまうため注意が必要です。

IJobForEach

IJobForEachはエンティティ単位で処理するジョブを定義するのに使用します。 IJobForEachは型引数の数や制約が異なる複数のジェネリック型が定義されていて、 対象エンティティが持つべきコンポーネントの型を指定して実装します。

public class MotionSystem : JobComponentSystem
{
    [ExcludeComponent(typeof(Static))]
    private struct MotionJob : IJobForEach<Position, Velocity>
    {
        public void Execute(ref Position position, [ReadOnly] ref Velocity velocity)
        {
            position += velocity * 0.02f;
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        MotionJob job = new MotionJob();

        return job.Schedule(this, inputDeps);
    }
}

IJobForEachの定義すべきメソッドはExecuteのみで、 例のごとくScheduleRunが拡張メソッドによって定義されています。 また、シングルスレッドで実行するためのScheduleSingleもあります。 ジョブを生成してこれらのメソッドを呼び出し、ジョブハンドルを返せば、 ジョブ内でExecuteが対象エンティティに対して実行されて、システムの並列化が完了します。

さて、上記のコードには見たところクエリの生成処理がありません。 これはIJobForEach実装型の定義自体がクエリとなっているためです。 型引数が必要コンポーネント(All)を示しているのは先に述べた通りですが、 それだけでは指定可能なクエリが限られてしまうため、 それを補助するいくつかの属性が定義されています。

まず、対象を限定を目的で次の属性が定義されています。

  • ExcludeComponent属性
  • RequireComponentTag属性

これらの属性はIJobForEachの実装型に対して設定します。 ExcludeComponent属性は対象エンティティが含んではならないコンポーネント(None)を示します。 RequireComponentTag属性は対象エンティティが含まなければならないタグコンポーネント(All)を示します。

また、各コンポーネントを必要とする理由を明示する目的で次の属性が定義されています。

  • ReadOnly属性
  • WriteOnly属性
  • ChangeFilter属性

これらの属性はExecuteの引数に対して設定します。 ReadOnly属性はそのコンポーネントに対して書き込みを行わないことを示します。 WriteOnly属性はそのコンポーネントに対して読み込みを行わないことを示します。 ChangeFilter属性はEntityQuerySetFilterChangedと同様に、 そのコンポーネントに変更があった場合にのみ実行が必要なことを示します。

Scheduleなどのメソッドにシステムの参照(this)を渡した場合、 これらの属性が解析されて自動的にクエリが生成されます。 一方、EntityQueryを直接渡すこともできて、その場合は与えたクエリがそのまま使われます。

IJobForEachのバリエーション

IJobForEachには対象コンポーネントの型に応じてIJobForEach_CCIJobForEach_BBCといったバリエーションがあります。 これらはすべてIJobForEach_(アンダースコア)に続けて、 コンポーネントの種類を表す次の文字をアルファベット順に並べた形式となっています。

  • B: 動的バッファコンポーネント (Buffer)
  • C: 基本コンポーネント (Component)

ここで指定したコンポーネントの種類は、そのまま対応した順番の型引数の制約となっていてExecuteの引数の型に関係してきます。 指定できるコンポーネントは最大6つで、全通り網羅されています。

また、サフィックスなしのIJobForEachは基本コンポーネントのみを対象としたもの、 つまり、IJobForEach_CIJobForEach_CCと同等のものとして扱われます。

【19/05/29 更新】
preview.32にて、動的バッファコンポーネントのサポートに伴いサフィックスが追加されたため、説明を追記。

余談:依存対象の検出方法

IJobForEachの使い方を見て、OnUpdateの引数、 依存対象をどうやって検出しているのか不思議に思った方もいると思います。 というか、自分がそう思ったので調べてみました。 仕様バグではないかと疑ってかかったのですが、ちゃんと想定内でした。

まず、Scheduleの内部では引数として与えられたシステムのGetEntityQuery*3を呼び出しています。 従って、二回目以降の実行では、IJobForEach実装型を解析した結果のEntityQueryがシステムに関連付けられているため、 それを利用して依存対象の検出が行われます。

では初回はどうなのかというと、実は依存対象のジョブは一切検出されません。 しかし、このとき依存対象なしとしてジョブを実行してしまうと、本当は依存対象がいた場合にデータ競合が発生してしまいます。 そのため、GetEntityQueryは新しいEntityQueryが関連付けられたとき、 その依存対象のジョブすべてが完了するまで待機するという動作をします。 結果として、初回のみ並列実行ではなく逐次実行になります。

【19/05/06 更新】
preview.31にて、システム内部のジョブ定義型自動検出による依存対象検出機能が削除されたため、該当記述を削除。

IJobForEachWithEntity

IJobForEachでは、エンティティのコンポーネントの値を更新することはできますが、 エンティティ自体に対する操作はできません。 エンティティの破棄やコンポーネントの追加など、エンティティ自体に対する操作をしたい場合には、 IJobForEachWithEntityを使います。

使い方はほぼ同じですが、 IJobForEachWithEntityExecuteの引数の最初には対象エンティティとその通し番号が与えられます。 この番号はワールドにおけるエンティティのID(Entity.Index)ではなく、 列挙対象のエンティティひとつひとつに新たに割り当てられた0から始まる連番です。 同じ列挙対象に対してマルチパスで処理をしたい場合に用意する中間データ記憶用の配列の添え字や、 後述の非同期EntityCommandBufferにてジョブごとのユニークIDとして使います。

struct WithEntityJob : IJobForEachWithEntity<Position, Velocity>
{
    public void Execute(Entity entity, int index, ref Position position, [ReadOnly] ref Velocity velocity) { /* ... */ }
}
IJobForEachWithEntityのバリエーション

IJobForEachWithEntityにもIJobForEachと同様、サフィックスによるバリエーションがあります。 基本的に同じルールに従いますが、IJobForEachWithEntityの場合には最初の文字は常にエンティティ(Entity)を表すEです。 例えば、IJobForEachWithEntity_ECCIJobForEachWithEntity_EBBCのようになります。

サフィックスなしのIJobForEachWithEntityの扱いもIJobForEachと同様で、 IJobForEachWithEntity_ECIJobForEachWithEntity_ECCと同等の扱いになります。

【19/05/29 更新】
preview.32にて、動的バッファコンポーネントのサポートに伴いサフィックスが追加されたため、説明を追記。

IJobChunk

IJobChunkはチャンク単位で処理するジョブを定義するのに使用します。 チャンクコンポーネントやオプショナルなコンポーネントが存在する場合に適しています。

public class ChunkMotionSystem : JobComponentSystem
{
    private struct ChunkMotionJob : IJobChunk
    {
        public ArchetypeChunkComponentType<Position> PositionRW;
        [ReadOnly] public ArchetypeChunkComponentType<Velocity> VelocityRO;

        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            NativeArray<Position> positions = chunk.GetNativeArray(PositionRW);
            NativeArray<Velocity> velocties = chunk.GetNativeArray(VelocityRO);
            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 JobHandle OnUpdate(JobHandle inputDeps)
    {
        ChunkMotionJob job = new ChunkMotionJob
        {
            PositionRW = GetArchetypeChunkComponentType<Position>(),
            VelocityRO = GetArchetypeChunkComponentType<Velocity>(true),
        };

        return job.Schedule(query, inputDeps);
    }
}

IJobChunkExecuteは引数としてチャンク、チャンクの通し番号、チャンクの最初のエンティティの通し番号を受け取ります。 チャンクの番号もエンティティの番号も列挙時に新たに与えられる0から始まる連番です。 最初のエンティティ番号というのは、言い換えればそのチャンクより前の番号を持つチャンクに含まれるエンティティ数の総和になります。 これらの番号の使い道はIJobForEachWithEntityと同様です。

IJobChunkではチャンクに対して処理をすることになるため、 あらかじめアクセスしたいコンポーネントに対応するアクセス型を取得して、ジョブに渡しておく必要があります。 その際、読み込み専用で宣言した型に対してはReadOnly属性を設定する必要があります。

IJobChunkIJobForEachと違って、処理対象を限定できるだけのメタ情報がないため、処理対象の指定にはEntityQueryを使用します。 EntityQueryと依存するジョブハンドルを、例のごとく拡張メソッドで定義されたScheduleに渡せば、ジョブのスケジュールは完了です。

ジョブとEntityManager

さて、ここまでの例では特に問題になりませんでしたが*4、 システムを記述する上でジョブには大きな制約があります。 それはEntityManagerが一切使えないことです。

EntityManagerは参照型のため、ジョブの制限上これにアクセスすることはできません。 もちろんこれは意図的な設計で、非同期にEntityManagerにアクセスできてしまうと、データ競合の原因となるため禁止しているのです。 とはいえ、EntityManagerの全機能を封じられてしまうと何もできないので、 いくつかの機能は特定の型を通すことで利用できるようになっています。 見方によっては、ここまで述べてきたジョブインターフェースやコンポーネントへのアクセス型もそのひとつです。 ここではその他のEntityManager代替型について見ていきます。

ComponentDataFromEntity

EntityManagerの最も基本的な機能はコンポーネントの値の取得と設定です。 これらの機能は各ジョブインターフェースによってある程度達成できますが、これだけではまだ不十分なケースが存在します。 それはコンポーネント内に他のエンティティへの参照を記憶していて、そのエンティティのコンポーネントの値を参照したい場合です。 つまり、エンティティをキーにコンポーネントの値を取得する機能がないのです。

この機能を提供する型としてComponentDataFromEntityが存在します。 これはシステムのGetComponentDataFromEntityによって取得できます。 引数は読み込み専用か否かです。 一応、書き込み可能にすることもできますが、 並列実行時(ParallelFor)にはデータ競合防止のために拒否されるので、あまり使い道はないかもしれません。

ComponentDataFromEntityを取得したらそれをジョブに渡します。 読み込み専用時にはReadOnly属性を設定する必要があります。 あとはインデクサにエンティティ参照を渡せば、コンポーネントの値の取得および設定ができます。

public class FromEntitySystem : JobComponentSystem
{
    private struct FromEntityJob : IJobForEach<Position, Target>
    {
        [ReadOnly] public ComponentDataFromEntity<Velocity> Velocities;

        public void Execute(ref Position position, [ReadOnly]ref Target target)
        {
            position += Velocities[target.Entity] * 0.02f;
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        FromEntityJob job = new FromEntityJob
        {
            Velocities = GetComponentDataFromEntity<Velocity>(true),
        };

        return job.Schedule(this, inputDeps);
    }
}

動的バッファコンポーネント用のBufferFromEntityも存在します。

EntityCommandBufferSystem

ジョブインターフェースだけでは実現できないEntityManagerの重要な機能として、 エンティティの生成と破棄やコンポーネントの追加と削除、そして共有コンポーネントの値の変更があります。 しかし、これらの操作は並列処理する際に非常にやっかいな存在です。 何故ならば、これらの操作はチャンク構造を変更してしまうからです。

このような場合にはEntityCommandBufferを利用して操作を遅延実行するとよいのでした。 しかし、非同期システムでは、同期システムのようにシステム更新直後に操作を実行してしまうと、ジョブがまだ完了していない可能性があるため危険です。 列挙中のチャンク構造を変更してしまうかもしれませんし、操作の追加がまだ終わっていないかもしれません。 かといって、システムごとにジョブの完了を待ってしまうのは効率があまりよくありません。 そこで、ジョブの完了を待つタイミングを決定するための存在としてEntityCommandBufferSystemを導入します。

EntityCommandBufferSystemはその更新時に対応したEntityCommandBufferの内容を実行します。 その際、操作の実行は必要なすべてのジョブの完了を待ってから行われます。 非同期処理の同期タイミングを示すシステムなので元々はBarrierSystemという名前でした。 EntityCommandBufferSystemComponentSystemを継承した抽象クラスで、 ComponentSystem同様に継承したクラスを定義して使います。 ワールドに登録する都合上、別名を付けたいだけなので内容は空で構いません。

[UpdateInGroup(typeof(MySystemGroup))]
[ExecuteAlways]
public class MyECBSystem : EntityCommandBufferSystem { }

使用する側は、ワールドからこのシステムを取得して、 CreateCommandBufferによりEntityCommandBufferを作成した後、ジョブに渡してスケジュールし、 最後にそのジョブハンドルをAddJobHandleForProducerにて登録します。 登録したジョブハンドルはEntityCommandBufferの実行前にジョブの完了を待つために使用されます。

[UpdateInGroup(typeof(MySystemGroup))]
[UpdateBefore(typeof(MyECBSystem))]
public class ECBJobSystem : JobComponentSystem
{
    private struct DestroyJob : IJobForEachWithEntity<Position>
    {
        public EntityCommandBuffer.Concurrent Commands;

        public void Execute(Entity entity, int index, ref Position position)
        {
            Commands.DestroyEntity(index, entity);
        }
    }

    private MyECBSystem barrier;

    protected override void OnCreate()
    {
        barrier = World.GetExistingSystem<MyECBSystem>();
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        EntityCommandBuffer ecb = barrier.CreateCommandBuffer();
        DestroyJob job = new DestroyJob { Commands = ecb.ToConcurrent() };
        JobHandle handle = job.Schedule(this, inputDeps);
        barrier.AddJobHandleForProducer(handle);
        return handle;
    }
}

ジョブを実行するシステムとEntityCommandBufferSystemとの実行順には十分に注意してください。

EntityCommandBuffer.Concurrent

前述のサンプルコードでは、ジョブ内でEntityCommandBufferではなくEntityCommandBuffer.Concurrentを使用していました。 実はEntityCommandBuffer自体は非同期に対応していないため、 非同期処理の場合にはEntityCommandBufferToConcurrentを呼び出して、EntityCommandBuffer.Concurrentに変換する必要があります。

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

システムグループのEntityCommandBufferSystem

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

  • BeginInitializationEntityCommandBufferSystem
  • EndInitializationEntityCommandBufferSystem
  • BeginSimulationEntityCommandBufferSystem
  • EndSimulationEntityCommandBufferSystem
  • BeginPresentationEntityCommandBufferSystem
  • EndPresentationEntityCommandBufferSystem

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

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

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

そもそも共有コンポーネントには、 多くのエンティティ間で共有されるデータの記憶領域の削減と、 参照型を必要とするライブラリのためのエスケープハッチという二つの目的があり、 前者のみであったならジョブでも取得は可能だったはずです。 そのため、参照型を許さない共有コンポーネントが必要なんじゃないかという話もあるみたいです。

同期点

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

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

その他の便利機能

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

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コンポーネントが存在する場合、 それに記憶されたエンティティに対しても同様の操作を実行します。

【19/05/29 更新】
preview.32にて、GetEnabledSetEnabledが追加されたため、説明を追記。

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();

シングルトン

プログラムには設計上インスタンスがひとつしか存在しないとわかっている型がしばしばあります。 これらの型のことをシングルトンと呼びます*5。 シングルトンな型では、型とインスタンスが一対一で対応するため、型からインスタンスを取得するAPIの提供が可能です。

Unity ECSにおいても、シングルトンなコンポーネントをいちいち列挙構文で処理するのは面倒なので、 いくつかの便利メソッドがシステムに定義されています。

シグネチャ 説明
GetSingleton<T>() シングルトンなコンポーネントの値の取得
GetSingletonEntity<T>() シングルトンなコンポーネントを持つエンティティの取得
SetSingleton<T>(T) シングルトンなコンポーネントの値の設定
HasSingleton<T>() シングルトンなコンポーネントの存在の確認

これらは内部でクエリを自動生成してそれを元に処理を行うだけで、 シングルトンなコンポーネントやそのエンティティを自動で生成したりはしません。 コンポーネントの準備とひとつしか存在しないことの保証はプログラマの責務です。 より複雑なクエリをしたい場合にはEntityQueryにも同様のメソッドが定義されています。

これらのシングルトン用メソッドを利用する場合には、システムの自動停止に注意してください。 これらのメソッドはOnUpdate内で呼ばれることになるため、システムにEntityQueryを関連付けるタイミングが少し遅れます。 他のEntityQueryがないならOnUpdateは実行されますが、他にEntityQueryが関連付けられていた場合、 そのクエリ結果によってはOnUpdateは実行されません。 OnCreateRequireSingletonForUpdate<T>を呼んでおくと、この問題は回避できます。

ちなみに、シングルトンにはSingletonパターンと呼ばれる有名なデザインパターンがありますが、 これはオブジェクト指向のデザインパターンであるため、ECSでは避けるべきです。 ECSはすべての状態がエンティティとコンポーネントによって表現されることを前提としています。

システムの状態

ECSではシステムは状態を持ってはいけません。 そのため、システムが状態を必要とする場合にはそれをコンポーネントにしてエンティティに関連付けるのが定石です。 しかし、コンポーネントとしてシステムの管理下から離れてしまうと困る場合というのもあります。 例えば、リソースを確保した際、そのハンドルを格納したコンポーネントがエンティティの破棄とともに削除されてしまうと、 リソースの解放ができなくなってしまいます。

Unity ECSではこの問題に対処するため、 各コンポーネントインターフェースを継承した特別なコンポーネントインターフェースを提供しています。

  • ISystemStateComponentData
  • ISystemStateSharedComponentData
  • ISystemStateBufferElementData

これらを継承したコンポーネントが関連付けられたエンティティは、 破棄された際にこれらのコンポーネントだけ*6を残したまま存在し続け、 これらのコンポーネントすべてが削除されたときに初めて実際に破棄されます。 この動作によりシステムは状態に対して後処理ができるようになります。

システムが状態を必要とするとき、必ずその状態の元となるコンポーネントが存在するはずです。 そのため、『元コンポーネントを含みかつ状態コンポーネントを含まない』というクエリで、 状態コンポーネントを追加すべきエンティティが、 『元コンポーネントを含まずかつ状態コンポーネントを含む』というクエリで、 状態コンポーネントを削除すべきエンティティが列挙できます。 状態が必要なシステムでは必ずこの二つのクエリを更新時に実行する必要があるはずです。

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(1, ref root.Children);
    root.Value = 1;

    ref Node child = ref children[0];
    BlobBuilderArray<Node> grandchildren = builder.Allocate(2, ref child.Children);
    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変数で扱う必要があるようです。

また、現在のところBlobPtr<T>BlobArray<T>による参照を正しくコピーする方法が提供されていないようで、 複雑な構造のデータを保存することはできないみたいです。

【19/05/29 更新】
preview.32にて、生成用の一時メモリの確保方法および関連APIが変更されBlobAllocatorBlobBuilderに置き換えられたため、記述を修正。

非推奨な機能

Unity ECSはまだ正式リリースされていないため、幾度となくAPIの変更が行われています。 そのため、古い記事の内容が現在は当てはまらないということも多いです。 現在すでに存在しない、あるいは非推奨とされている機能についてここにまとめておきます。

  • [Inject]
    • パフォーマンス上の理由で他の列挙APIに置き換え
  • XXXArray
    • パフォーマンスおよび過度な実装隠蔽のため他の列挙APIに置き換え
    • ComponentDataArray
    • SharedComponentDataArray
    • BufferArray
    • EntityArray
    • ComponentGroupArray
  • ComponentSystem.ForEach
    • EntityQueryBuilderに移動
  • ScriptBehaviourManager
    • ワールドが直接EntityManagerを持つようにして削除
    • 関連してComponentSystemBaseのメソッドをリネーム
      • OnCreateManager -> OnCreate
      • OnDestroyManager -> OnDestroy
    • 関連してWorldのメソッドをリネーム
      • CreateManager -> CreateSystem
      • GetOrCreateManager -> GetOrCreateSystem
      • GetExistingManager -> GetExistingSystem
      • DestroyManager -> DestroySystem
  • IJobProcessComponentData
    • IJobForEachにリネーム
  • IJobProcessComponentDataWithEntity
    • IJobForEachWithEntityにリネーム
  • EntityArchetypeQuery
    • EntityQueryDescにリネーム
  • ComponentGroup
    • EntityQueryにリネーム
  • BarrierSystem
    • EntityCommandBufferSystemにリネーム
  • ComponentType.Create<T>
    • ComponentType.ReadWrite<T>にリネーム
  • [RequireSubtractiveComponent]
    • [ExcludeComponent]にリネーム
  • ComponentDataWrapper
    • ComponentDataProxyにリネーム
    • 関連してXXXComponentXXXProxyにリネーム
  • BlobAllocator
    • 内部動作の変更に伴いBlobBuilderに置き換え

【19/05/29 更新】 preview.32: BlobAllocator -> BlobBuilder

標準システム

Unity ECS自体はゲームプログラムの基盤でしかありません。 これに対応した様々なコンポーネントやシステムが標準で提供されるようになって初めて、 UnityはECSのゲームエンジンとしての体を成します。

現在、標準で提供されている機能は次の通りです。

  • モデル変換 ("Entities"パッケージ)
  • ハイブリッドレンダリング ("Hybrid Renderer"パッケージ)
  • 物理演算 ("Unity Physics"パッケージ)

ハイブリッドというのは、ECSと旧アーキテクチャを組み合わせた、という意味です。 Unityのレンダリング機能は基本的にCameraに依存しているため、 新たなレンダリングAPIが整備されない限り旧アーキテクチャから切り離すことができません。 そのため、現在は旧アーキテクチャを利用したレンダリング機能しか提供されていません。

おわりに

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

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



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

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

*2:それ以外にチャンクイテレーションで自力で書くというのもあるにはありますが……

*3:正確にはGetEntityQueryInternal

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

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

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