Location via proxy:   
[Report a bug]   [Manage cookies]                

凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity 6 + ECS 1.3 で Boids シミュレーションを書き直してみた

はじめに

だいぶ前、2018 年の終わりごろに Boids(群体)シミュレーションを ECS の勉強のために書きました。

tips.hecomi.com

上記記事では当時の API を使っており、Unity 側では ECS の実装もまだ色々と検証中だったように思われます。最近は全く追っていませんでしたがあれから 6 年ほど経過し、API 体系も円熟したと思われるため、あらためて現状の実装の調査も兼ねて書き直してみることにしました。以前の実装と比較しながら色々と考察もしていければと思います。

Boids 自体の実装解説については以前の記事をご参照ください。本記事では「ECS のセットアップ」の項以降の内容について、新しい ECS の仕組みの概念獲得のためにあれこれ試行錯誤していく形式で展開していきます。前回はチュートリアル形式でしたが、今回は一つ一つ意味を確認しながら見ていきますので、結構冗長な感じの記事になっています。

目次

環境

  • Unity 6000.0.25f1
  • Unity.Entities 1.3.8
  • Unity.Entities.Graphics 1.3.2
  • Mac Sequoia 15.1.1

デモ

最終的には Job + Burst で 20000 個体ほど出してもフレームレートに影響しない形になりました。

コード

github.com

ECS の正式版リリースについて

ECS は Unity 2022.2 のタイミングより ECS の正式版(バージョン 1.0)をリリースしました。

unity.com

公式で専用のページも作られています。

unity.com

なんか日本語訳が怪しい(製品名であるはずの Unity まで自動翻訳してしまっている...)ので、英語版を別途翻訳して見るほうが良いかもしれません。。。

unity.com

公式のサンプルは GitHub に上がっています。

github.com

日本語としては Keijiro さんが UTJ 日本語オフィシャル YouTube チャンネルにていくつか解説を上げてくださっています。

こちらの動画で紹介されている Keijiro さんのサンプルもあります。

github.com

その他公式で様々なリソースが用意されています。

unity.com

ECS パッケージ

ECS 関連のパッケージを Package Manager から入れる必要があります。

詳しくは後述しますが、以下の 2 つのパッケージを入れる必要があります。

  • Entities
    • ECS のコア部分を提供
  • Entities Graphics
    • ECS でオブジェクト描画する部分を提供

エンティティ・コンポーネント・システムの実装と調査

以前のセットアップ

さて、以前の記事では以下のようにマネージャ経由でエンティティを生成し、コンポーネントを付与する形でセットアップしていました。

// Entity マネージャの取得
var manager = World.Active.GetOrCreateManager<EntityManager>();

// アーキタイプの作成
var archetype = manager.CreateArchetype(typeof(Hoge), ...);

// エンティティの生成とコンポーネントの初期化
for (int i = 0; i < n; ++i)
{
    var entity = manager.CreateEntity(archetype);
    manager.SetComponentData(entity, new Hoge { Value = random.NextFloat3(1f) });
    ...
}

ただ、シンプルなセットアップであればこれで良いですが、位置や回転がバラバラだったり、各オブジェクトが異なるパラメタセットを持っていたり、といったケースにおいてはセットアップが大変です。

SubScene

そこで Unity 2020 あたりのタイミングから SubScene という仕組みが導入されました。

docs.unity3d.com

SubScene では通常のシーンのように GameObject と MonoBehaviour のコンポーネントのセットアップによりシーン構築を行います。するとこれらが自動的に ECS の世界へと変換される、という仕組みになっています。具体的に見ていきましょう。

適当な SubScene を作成、配置し、その中に適当なマテリアルを適用したオブジェクトを配置してみます。メインシーンにて SubScene の右端にチェックボックスがあるのでそれにチェックを入れてみると、次のように Inspector の下でどのようなコンポーネントが自動セットアップされるかが出てきます。

ECS の構造は通常の Unity のそれとは異なるのでシーンは特殊な構造で扱われます。この SubScene はメインシーン上では GameObject と SubScene という MonoBehaviour 継承コンポーネントとして配置されているようです。

SubScene コンポーネントはシーンファイルである .unity を保持し、ロード・アンロードの役割を行うもののようですね。 このあたりの詳しい流れは先の UTJ の動画に詳しくまとめられているのでそちらをご参照ください。

ちなみに、仕組みとしては以前も Pure ECS / Hybrid ECS という形の区分けがされており、後者は GameObject ベースで ECS をセットアップするものでした。SubScene の導入により、これらが洗練されたようです。

エンティティとコンポーネントと Baker

さて、ではこの SubScene でセットアップしたオブジェクトにコンポーネントを付与してみます。コンポーネントの付与は Baker という仕組みを通じて行います。

docs.unity3d.com

Baker は GameObject をエンティティへと変換するものです。次のようなコードを書いてみます。

public struct Velocity : IComponentData
{
    public float3 Value;
}

public struct Acceleration : IComponentData
{
    public float3 Value;
}

public class FishAuthoring : MonoBehaviour
{
    public float Speed = 1f;
}

public class FishBaker : Baker<FishAuthoring>
{
    public override void Bake(FishAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.Dynamic);
        
        AddComponent(entity, new Velocity()
        {
            Value = UnityEngine.Random.insideUnitSphere * src.Speed
        });
        
        AddComponent(entity, new Acceleration()
        {
            Value = 0f
        });
    }
}

IComponentData 継承の Velocity および Acceleration が ECS コンポーネントです。

ここに値を受け渡すための FishAuthoring はオーサリンコンポーネントと呼ばれる役割のもので、シリアライズされたエディタ上で使用できる変数を有し、ECS コンポーネントへ値を受け渡す役割をします。SubScene のゲームオブジェクトにはこちらを付与する形になります。

そして FishBakerBaker<T> 継承したクラスであり、この中の Bake 関数が自動実行されます。FishAuthoring オーサリンコンポーネントを付与した GameObject に対してはこの Baker が処理され、AddComponent した ECS コンポーネントが自動付与されることになります。Bake 関数の中を覗いてみると、GetEntity() でまずエンティティを取得しています。引数としては TransformUpdateFlag を与えており、これは GameObject の Transform コンポーネントがどのような ECS コンポーネントへと変換されるべきかを指定するものとなっています。

docs.unity3d.com

固定オブジェクトのような場合は不要な ECS コンポーネントの付与を避けることができる訳ですね。Dynamic を指定した場合は LocalTransformLocalToWorld が付与され、移動可能になります。

ではこの FishAuthoring を GameObject に付与してインスペクタを見てみます:

このようにセットアップした ECS コンポーネント及び GetEntity で指定した TransformUpdateFlag に応じたコンポーネントが付与されている事がわかりました。またセットアップした値が各 ECS コンポーネントに設定されていますね。なお、この Bake 処理は様々なタイミング(サブシーンの開閉や保存など)で行われるようで、そのため予期しないバグを避けるため Baker はステートレスにしないといけないようです。

ちなみに MeshRenderer のチェックを外すといくつかの Unity.Rendering.* 系の ECS コンポーネントが外れるのがわかります。また、作成した FishAuthoring も外すと何もなくなりますね。

これらから、GetEntity() したり、対応標準 MonoBehavior コンポーネントMeshRenderer など)をつけると自動で色々とセットアップされることがわかります。こうしたエンティティについた複数のコンポーネントの組み合わせ(アーキタイプ)が暗黙的に行われる Bake 処理によって決定されています。

ちなみに、Inspector の右上にある◯から Runtime を選択すると、Inspector 上でどのようなコンポーネントへと変換されたかがわかります。Mixed にすると実行時のみ見えるようになります。

システム

さて、ではこの ECS コンポーネントを見て動かしてみましょう。ECS ではデータとしてのコンポーネントをシステムによって処理します。システムは以前はコンポーネントのデータを取ってくるのに専用の構造体を用意したりと色々と手間が要りました。以前のコードを以下に示してみます。

public class MoveSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        public ComponentDataArray<Position> positions;
        [WriteOnly] public ComponentDataArray<Rotation> rotations;
        public ComponentDataArray<Velocity> velocities;
        public ComponentDataArray<Acceleration> accelerations;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var dt = Time.deltaTime;

        for (int i = 0; i < data.Length; ++i)
        {
            ...
            var v = data.velocities[i].Value;
            var pos = data.positions[i].Value;
            var a = data.accelerations[i].Value;

            v += a * dt;
            pos += v * dt;

            var dir = math.normalize(velocity);
            var rot = quaternion.LookRotationSafe(dir, new float3(0, 1, 0));

            data.velocities[i] = new Velocity { Value = v };
            data.positions[i] = new Position { Value = pos };
            data.rotations[i] = new Rotation { Value = rot };
            data.accelerations[i] = new Acceleration { Value = float3.zero };
        }
    }
}

結構なコード量が必要ですね。それに対して現在は以下のような短いコードを書くことで同様のことが実現できます。

public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var dt = SystemAPI.Time.DeltaTime;
        
        foreach (var (v, a, lt) in 
            SystemAPI.Query<
                RefRW<Velocity>,
                RefRW<Acceleration>,
                RefRW<LocalTransform>>())
        {
            v.ValueRW.Value += a.ValueRO.Value * dt;
            a.ValueRW.Value = 0f;
            lt.ValueRW.Position += v.ValueRO.Value * dt;
            var dir = math.normalize(v.ValueRO.Value);
            var up = math.up();
            lt.ValueRW.Rotation = quaternion.LookRotationSafe(dir, up);
        }
    }
}

このシステムのコードを書くと、この OnUpdate() は自動実行され別の何処かから呼び出さなくても動作するようになります。

まず一番の注目ポイントは SystemAPI.Query<...> で、ここで複数の指定した ECS コンポーネントを列挙してあげるだけで、それらを持つアーキタイプのエンティティを見つけてきて、各コンポーネントのデータがタプルで受け取れる foreach で回すことが出来る、という大変シンプルな構造になっています。`

唯一の煩雑な点は RefRO<>(値の利用のみ)/ RefRW<>(値の読み書き)でコンポーネントをラップしている点(今回はすべて書き込みが必要なので RefRW のみでしたが)及びそれらのアクセスが ValueRW / ValueRO 経由で取得されている点です。これらはネイティブメモリへのポインタを保持する構造体になっており、そのメモリへのアクセス方法を指定している形ですね。これらは unsafe ref となっており、実際のコンポーネントのメモリにポインタ経由でアクセスするため、直接値の読み書きが可能になっています。

また、ちょっと細かい話に入りますが、内部的には SystemAPI.QueryEntityQueryBuilder という型を返し、ここには様々な条件を追加して絞り込みが出来ます。

docs.unity3d.com

例えば WithAll<Hoge, Fuga> を後ろにつけてあげるとこのクエリの中で更にこれらコンポーネントを持っているもの、のような指定ができる訳ですね。

参照引数の SystemState はこのシステムが生成されたときに作られるシステムのステートで、外から与えられる形になっています。EntityManager への参照を保持していたり、型情報やジョブの依存などが格納されます。こちらの利用は後で少し出てきます。

最後の注目点は partial な構造体として生成されている点です。これは裏側でソースジェネレータという自動コード生成が走っており、作成した OnUpdate() をもとに別の関数が作られています。

我々が開発するのは ISystem に実装された抽象化された API を使ったロジックで、実際には ISystemCompilerGenerated を継承した別の partial な構造体で低レベル API を使いながらメモリ効率良くコンポーネントのデータにアクセスしたり、別のジョブの完了を待ってデータにアクセスする、といったような処理が追加されます。先程の RefRW / RefRO などが CompleteDependencies() という自動生成関数の中で RW なら書き込み可能となるタイミング待ちを、RO なら読み取り可能となるタイミング待ち(ほとんどなし)を行うようになっています。SystemAPI となっているのも、このソースジェネレーション上で成り立つシステム内で利用可能な API ですね。

なお、ソースジェネレータを動作させるためには特定の IDEVisual Studio / Rider)が必要となります。

docs.unity3d.com

余談ですが、サンプルなどを見ていると ECS の変数はパスカルケースで持つのが通例のようです。

Enter Play Mode Settings

上記ページを見ると Enter Play Mode Settings で Domain と Scene のリロードをしない設定にすることが推奨されています。そのままだと実行開始時に Domain Reload や Import Assets などの時間がそれなりに掛かりイテレーション速度上不利になりますが、オフにするとボタンを押したらすぐ実行されるような速度になります。

ドメインリロードをしないとすると static 変数が初期化されないといった予期しない動作をすることにもつながるので注意が必要です。

docs.unity3d.com

適切なタイミング(ECS 関連のコード制作)のみにしたり、static 変数の初期化は手動で行うなど、使用には幾つか注意する必要があります。が、イテレーションはかなり早くなるので、なるべくオフに出来るような設計にすることをおすすめします。

実行

さて実行してみると次のように動きます。まだ各種 Boids の動きをさせるための挙動を何も実装してないので最初にランダムで決めた速度で等速直線運動するだけです。

注意点としては、SubScene を開いている状態(Hierarchy 上でチェックが入っている状態)では何も見えないことです。シーンビュー側にはオーサリング用に配置された SubScene 内の GameObject があるだけで、ゲームビューのそれとは異なります。シーンビューでは GameObject が見えていて、その GameObject は編集可能ですが、それは ECS 側のものでありません。GameObject 側をいじると再 Bake されるようです。

SubScene を閉じる(チェックを外す)と、Scene 上でも動いて見えるようになります。

Boids の実装をしていく

では勘所が掴めたところで Boids として動くようにシステムの実装をしていきたいと思います。

大量のインスタンスの生成

Boids のために大量のオブジェクト生成をしたいので、これを配置した GameObject からの生成でやるのはちょっと大変すぎます。そこで生成処理をコードからやってみます。

やり方としては UTJ の動画で解説されている方法で行います。

まずは何匹作るか、どれくらいの範囲で作るか、といった設定を行うための設定コンポーネントを作ります。シーンに1つだけ存在する、生成に必要なパラメタを格納するコンポーネントというものになります。

public struct Config : IComponentData
{
    public Entity Prefab;
    public int SpawnCount;
    public uint RandomSeed;
}

public class ConfigAuthoring : MonoBehaviour
{
    public GameObject Prefab;
    public int SpawnCount = 100;
    public uint RandomSeed = 100;
}

public class ConfigBaker : Baker<ConfigAuthoring>
{
    public override void Bake(ConfigAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.None);
        var prefab = GetEntity(src.Prefab, TransformUsageFlags.Dynamic);
        
        AddComponent(entity, new Config()
        {
            Prefab = prefab,
            SpawnCount = src.SpawnCount,
            RandomSeed = src.RandomSeed,
        });
    }
}

注目点は Bake 処理のところです。コンフィグ自体のエンティティは特に位置などは必要ないので、TransformUsageFlags.None を指定してエンティティを取得しています。これにより LocalTransform といった位置に関連したコンポーネントは付与されません。不要なコンポーネントはセットアップしないことでメモリやパフォーマンス上、有利になります。

一方、第 1 引数で GameObject の Prefab を指定する 2 つの引数を取る GetEntity() の方では TransformUsageFlags.Dynamic を指定しています。これは位置情報が必要なので LocalTransform などを付与するためにこのフラグになります。第 1 引数で与えている Prefab は、FishAuthoring を付与したゲームオブジェクトを Prefab 化したものを指定します。

これにより、その GameObject の Prefab をもとにしたエンティティが取得できます。このエンティティは後で複数のインスタンスを生成するシステムの雛形として使います。

では生成システムの方を見てみます。

[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial struct SpawnSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Config>();
    }
    
    public void OnUpdate(ref SystemState state)
    {
        var config = SystemAPI.GetSingleton<Config>();
        var manager = state.EntityManager;
        var entities = manager.Instantiate(config.Prefab, config.SpawnCount, Allocator.Temp);
        var random = new Random(config.RandomSeed);
        foreach (var entity in entities)
        {
            var lt = SystemAPI.GetComponentRW<LocalTransform>(entity);
            
            var pos = random.NextFloat3() - 0.5f;
            pos *= 5f; // 適当な範囲
            lt.ValueRW.Position = pos;
            
            var dir = random.NextFloat3Direction();
            var up = math.up();
            lt.ValueRW.Rotation = quaternion.LookRotation(dir, up);
            
            var v = SystemAPI.GetComponentRW<Velocity>(entity);
            v.ValueRW.Value = dir * 2f; // 適当なスピード
            
            var a = SystemAPI.GetComponentRW<Acceleration>(entity);
            a.ValueRW.Value = 0f;
        }
        
        state.Enabled = false;
    }
}

まず、UpdateInGroup アトリビュートInitializationSystemGroup を指定することで、他のシステムよりも先に更新されるようになります。このようにシステムにはアトリビュートを付与することによって順序制御が行えるようになっています。

docs.unity3d.com

なお、設定した順序は Window > Entities > Systems から確認することが出来ます。

ではこれを実行してみます。

100 を SpawnCount として指定したので 100 個のランダムな向きに進むオブジェクトが生成されました。

Prefab について

さて、ここで一つ気になりました。Fish の Prefab についた Baker はいつどうして実行されているのだろう?という点です。LocalTransform などは GetEntity() の引数によって付与されているのはわかりますが、この Prefab は SubScene の中には配置しているわけではなく、あくまで ConfigAuthoring の Prefab 変数フィールドに指定しているだけです。Prefab 自体も SubScene に直接配置しているわけではないため、いったい何を以てしてこのエンティティに VelocityAcceleration といったコンポーネントがついているのだろう、と考えました。

これはどうやら SubScene の Bake 処理のプロセスの中に、オーサリンコンポーネントのフィールドも含んだ解析がなされているようです。Windows > Entities > Hierarchy を選択すると、GameObject の階層だけではなく、SubScene 内に変換して生成されたエンティティおよびそれに紐づいたコンポーネント一覧が見えるようになります。

するとこの中に青い Prefab ベースのエンティティが見えますね。これは ConfigAuthoring コンポーネントPrefab の指定を解除するとエンティティ一覧から見えなくなります。なので SubScene を編集したり開閉したりすると、都度 Bake が走ります。なんとなく仕組みがわかってきましたね。

パラメタの持たせ方の検討

さて、ここから色々と挙動を作っていきたいのですが、その際に気持ち良い動きを作るためにはパラメタが重要になってきます。また動き具合を動的に変えたりもしたいのでパラメタは可変にしたいです。そこで、何らかの形でパラメタを各システムから参照できるようにしたいです。

まず最初に考えたのは、SharedComponentData でした。

docs.unity3d.com

これは複数のエンティティが共通のコンポーネントデータを持つための仕組みです。実際はコンポーネントを直接持たずにその参照を保持する形になります。ただ、エンティティは内部的にはアーキタイプ(どんなコンポーネント組み合わせか)+ 同じ値を持つ SharedComponentData、という構造によって別のチャンクに割り振られています。この関係で、SharedComponentData の値が書き換わった際はチャンクの再構築が走ってしまうようです。これにはもちろんそれなりのコストが掛かるので、パラメタを頻繁にバシバシ動的に切り替えたいときには向いていないようです。また SharedComponentData を各エンティティからアクセスするには現状 GetSharedComponentManaged<T>() のようなマネージドな呼び出しのコストが発生し、後で見る Burst の恩恵も受けられません。折角の ECS のメモリ効率の恩恵が薄れてしまう懸念もある点のでこのアプローチはやめました。

そこで、起点となっている Config のついたエンティティにパラメタ用のコンポーネントを付与し、それを ComponentLookup という仕組みで取ってくることにしました。

docs.unity3d.com

こちらは、エンティティを添え字としてアクセスできる指定したコンポーネントを格納しておく仕組みです。アクセスは O(1) で軽く Burst の恩恵も受けられるようです。各 Fish から自身を生成したエンティティを経由してパラメタコンポーネントにアクセスする、というのを実装してみます。

パラメタを考慮した実装

色々構造を書き直していきます。まずはパラメタはエディタ上で設定したいのでオーサリンコンポーネントを作ります。

public struct Parameter : IComponentData
{
    public float MinSpeed;
    public float MaxSpeed;
    public float3 AreaScale;
    public float AreaDistance;
    public float AreaForce;
    public float NeighborDistance;
    public float NeighborAngle;
    public float SeparationForce;
    public float AlignmentForce;
    public float CohesionForce;
}

public class ParameterAuthoring : MonoBehaviour
{
    [Header("Move")]
    public float MinSpeed = 2f;
    public float MaxSpeed = 5f;
    
    [Header("Area")]
    public float3 AreaScale = 5f;
    public float AreaDistance = 3f;
    public float AreaForce = 1f;

    [Header("Neighbors")]
    public float NeighborDistance = 1f;
    public float NeighborFov = 90f;
    
    [Header("Separation")]
    public float SeparationForce = 5f;
    
    [Header("Alignment")]
    public float AlignmentForce = 2f;
    
    [Header("Cohesion")]
    public float CohesionForce = 2f;
}

public class ParameterBaker : Baker<ParameterAuthoring>
{
    public override void Bake(ParameterAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.None);
        
        AddComponent(entity, new Parameter()
        {
            MinSpeed = src.MinSpeed,
            MaxSpeed = src.MaxSpeed,
            AreaScale = src.AreaScale,
            AreaDistance = src.AreaDistance,
            AreaForce = src.AreaForce,
            NeighborDistance = src.NeighborDistance,
            NeighborAngle = src.NeighborAngle,
            SeparationForce = src.SeparationForce,
            AlignmentForce = src.AlignmentForce,
            CohesionForce = src.CohesionForce,
        });
    }
}

いろいろなパラメタを用意しています。ゴールは、これらを各システムの計算の中で使えるようにするところです。また、複数の Parameter が混在できるようにもします。

これは SubScene の中の GameObject に付与しておきます。なお、Config としていたものは School とちょっと名前を変えました(パラメタとちょっと意味がかぶるため...、School of Fish = 魚群)。これら 2 つのコンポーネントをアタッチしたオブジェクトを Bootstrap と名付けています。

次に FishAuthoring 周りを書き直します。VelocityAcceleration を分けていましたが、十分小さな粒度なので取り回しが楽なようにまとめてしまうことにします。

public struct Fish : IComponentData
{
    public float3 Velocity;
    public float3 Acceleration;
    public Entity ParamEntity; // ここが新しい
}

public class FishAuthoring : MonoBehaviour
{
}

public class FishBaker : Baker<FishAuthoring>
{
    public override void Bake(FishAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.Dynamic);
        
        AddComponent(entity, new Fish()
        {
            Velocity = UnityEngine.Random.insideUnitSphere,
            Acceleration = 0f,
            ParamEntity = Entity.Null,
        });
    }
}

コンポーネントのメンバとして Entity 型の変数が追加されました。さて、次はここにパラメタの所属するエンティティをセットしていきます。SpawnSystem を改造していきます。

public partial struct SpawnSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (school, param, entity) in 
            SystemAPI.Query<
                RefRW<School>,
                RefRO<Parameter>>().WithEntityAccess()) // Entity も取得
        {
            if (school.ValueRO.Initialized) continue;
            Create(ref state, school.ValueRO, param.ValueRO, entity);
            school.ValueRW.Initialized = true;
        }
    }
    
    void Create(
        ref SystemState state, 
        in School school, 
        in Parameter param, 
        Entity groupEntity)
    {
        ...
        var entities = state.EntityManager.Instantiate(
            school.Prefab, 
            school.SpawnCount, 
            Allocator.Temp);
        ...
        foreach (var entity in entities)
        {
            ...
            var fish = SystemAPI.GetComponentRW<Fish>(entity);
            ...
            fish.ValueRW.ParamEntity = groupEntity; // ここでセット
        }
    }
}

こんな形で School および Parameter コンポーネントがついたエンティティを探してきて、一回だけ Create() を実行するようにします。Create() の中身は先程の SpawnSystem とほぼ同じですが、新しく追加したエンティティをセットするところが異なります。ちなみにエンティティはクエリに WithEntityAccess() をつけることで取得可能です。

最後にこれをシステムから利用します。ここで ComonentLookUp<T> を利用します。

public partial struct MoveSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        ...
        foreach (var (fish, lt) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRW<LocalTransform>>())
        {
            var param = _paramLookUp[fish.ValueRO.ParamEntity];
            
            fish.ValueRW.Velocity += fish.ValueRO.Acceleration * dt;
            var speed = math.length(fish.ValueRO.Velocity);
            speed = math.clamp(speed, param.MinSpeed, param.MaxSpeed);
            var dir = math.normalize(fish.ValueRO.Velocity);
            fish.ValueRW.Velocity = dir * speed;
            ...
        }
    }
}

SystemState から GetComponentLookup<T>()ComponentLookUp を取得し、OnUpdate() の最初でその Update() を呼びます。すると、添字で与えたエンティティのコンポーネントを取ってこれるという仕組みです。先ほどチラッと SharedComponentData のところで触れましたが、エンティティはアーキタイプの変更や SharedComponentData の値の変更などでチャンクが再構築されたりするため、これに追従して適切なコンポーネントの参照が取ってこれるように毎回 Update() を呼ぶ必要があります。

これで指定したパラメタを各インスタンスに保持させることが出来ました!

壁跳ね返り

ではこの勢いで壁の跳ね返りを実装してみましょう。AreaSystem とします。このコードはほとんど以前と同じです。ISystemComponentLookUp などの書き方だけ修正したコードが以下になります。

public partial struct AreaSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        
        foreach (var (fish, lt) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRO<LocalTransform>>())
        {
            var param = _paramLookUp[fish.ValueRW.ParamEntity];
            var scale = param.AreaScale * 0.5f;
            var thresh = param.AreaDistance;
            var weight = param.AreaForce;
            
            float3 pos = lt.ValueRO.Position;
            fish.ValueRW.Acceleration +=
                GetAccelAgainstWall(pos.x - -scale.x, math.right(), thresh, weight) +
                GetAccelAgainstWall(pos.y - -scale.y, math.up(), thresh, weight) +
                GetAccelAgainstWall(pos.z - -scale.z, math.forward(), thresh, weight) +
                GetAccelAgainstWall(+scale.x - pos.x, math.left(), thresh, weight) +
                GetAccelAgainstWall(+scale.y - pos.y, math.down(), thresh, weight) +
                GetAccelAgainstWall(+scale.z - pos.z, math.back(), thresh, weight);
        }
    }
    
    float3 GetAccelAgainstWall(float dist, float3 dir, float thresh, float weight)
    {
        if (dist < thresh)
        {
            dist = math.max(dist, 0.01f);
            var a = dist / math.max(thresh, 0.01f);
            return dir * (weight / a);
        }
        return float3.zero;
    }
}

これで実行すると次のようになります。

複数のパラメタが持てるようにしたので、もう一つ生成オブジェクト(Bootstrap)を増やして別の Prefab を割り当ててみました。

これでパラメタ設定が出来ました!

近傍検索

DynamicBuffer の付与

では Boids のアルゴリズムを実装していきましょう。最も手間がかかりパフォーマンスコストの大きいものが周辺のインスタンスの探索です。ここは多分に最適化の余地があるのですが、ひとまず愚直に全探索としてコードを書いてみることにします。つまり、すべてのインスタンスに対して他のすべてのインスタンスとの距離を測って近いものをリスト化する二重ループになります。

他のシステムでは SystemAPI.Query で型を指定してコンポーネントをかき集めてきていましたが、二重ループをするので少し情報を取ってくる方法やループのさせ方が異なります。

まず、可変長のデータである DynamicBuffer<T> を付与します。

[InternalBufferCapacity(8)]
public struct NeighborsEntityBufferElement : IBufferElementData
{
    public Entity Entity;
}

...

public class FishBaker : Baker<FishAuthoring>
{
    public override void Bake(FishAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.Dynamic);
        ...
        AddBuffer<NeighborsEntityBufferElement>(entity);
    }
}

IBufferElementData は可変長データの一要素となります。そして AddBuffer<T>() すると指定したエンティティへ DynamicBuffer<T> が付与されます。このバッファは予め決められた要素数(Capacity)の範囲内でエンティティのチャンクの中にインライン展開されます。これによってキャッシュ効率の良いアクセスが可能になっています。ただ、このサイズを超えると可変長データはヒープ側へと移りチャンクの中ではそこへの参照を持つ形になるようです(パフォーマンスが落ちます)。こういった仕組みで可変長が実現されているようです。

docs.unity3d.com

ちなみにデフォルトの Capacity は InternalBufferCapacity で指定できます。指定しない場合は、展開したデータが 128 byte 以内になる要素数が指定されるようです。例えば上記データは一要素 8 byte(Entity のサイズ)なので、128/8 = 16 がデフォルトとなります。

今回はそんなにたくさんの周囲の周囲のインスタンスの取得は要らないので、8 個程度にしています。

近傍探索

ではここに各インスタンスの視界前方にいる別のインスタンスを見つけるシステムを書いてみます。少し長いのでコード中にコメントします。

public partial struct NeighborsDetectionSystem : ISystem
{
    // パラメタ用の LookUp
    ComponentLookup<Parameter> _paramLookUp;

    // DynamicBuffer をエンティティ引きするための LookUp
    BufferLookup<NeighborsEntityBufferElement> _neighborsLookUp;

    // 毎回 SystemAPI.Query ではなく予めクエリをキャッシュしておく
    EntityQuery _query;

    public void OnCreate(ref SystemState state) 
    {
        // LookUp 生成
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _neighborsLookUp = state.GetBufferLookup<NeighborsEntityBufferElement>(isReadOnly: false);

        // クエリはこのような形で作成可能
        _query = SystemAPI.QueryBuilder().WithAll<Fish, LocalTransform>().Build();
    }
    
    public void OnUpdate(ref SystemState state)
    {
        // LookUp 更新
        _paramLookUp.Update(ref state);
        _neighborsLookUp.Update(ref state);
        
        // すべて Dispose をスコープから抜けたら行うために using をつけている
        // 二重ループを回すために、クエリからエンティティやコンポーネントの配列を取得
        using var entities = _query.ToEntityArray(Allocator.Temp);
        using var localTransforms = _query.ToComponentDataArray<LocalTransform>(Allocator.Temp); 
        using var fishes = _query.ToComponentDataArray<Fish>(Allocator.Temp);
        
        // ループは for 文で回してインデックスでアクセス
        for (int i = 0; i < entities.Length; ++i)
        {
            var entity0 = entities[i];

            // DynamicBuffer はエンティティ添字アクセスで取得可能
            // バッファはクリアしておく
            var neighbors0 = _neighborsLookUp[entity0];
            neighbors0.Clear();
            
            var fish0 = fishes[i];
            var param = _paramLookUp[fish0.ParamEntity];
            
            var neighborAngle = math.radians(param.NeighborAngle);
            var neighborDist = param.NeighborDistance;
            var prodThresh = math.cos(neighborAngle);
            
            var lt0 = localTransforms[i];
            var pos0 = lt0.Position;
            var fwd0 = math.normalizesafe(fish0.Velocity);
            
            // 二重ループで総当り
            for (int j = 0; j < entities.Length; ++j)
            {
                if (i == j) continue;
                
                // 相手との距離を見る
                var lt1 = localTransforms[j];
                var pos1 = lt1.Position;
                var to = pos1 - pos0;
                var dist = math.length(to);
                if (dist > neighborDist) continue;
                
                // 視界に入っているかをチェック
                var dir = to / math.max(dist, 1e-3f);
                var prod = math.dot(dir, fwd0);
                if (prod < prodThresh) continue;
                
                // バッファに追加
                var entity1 = entities[j];
                var elem = new NeighborsEntityBufferElement() { Entity = entity1 };
                neighbors0.Add(elem);
                
                // インライン展開から外れないようにキャパに到達したら終わり
                if (neighbors0.Length == neighbors0.Capacity) break;
            }
        }
    }
}

二重ループを行うために SystemAPI.Query をネストした場合はループの要素がタプルとして返ってきてしまう上に、ループ内で再度クエリを組み立てないとなりません。クエリ作成のコストもただではないので、代わりに EntityQuery を最初に作ってキャッシュしておいて使いまわすことにします。また、ここから ToEntityArray()ToComponentDataArray<T> によって配列として要素を得ておきます。配列があれば二重ループを書くのは簡単ですね。

バッファは BufferLookUp<T> を用いてアクセスしています。残念ながら先の仕様のような関係からかクエリからはアクセス出来ないようですが、ComponentLookUp<T> と同じ操作で DynamicBuffer が取得できます。

ワーストケースでは全てのインスタンスが要素として入ってしまうのですが、そんなに多くのインスタンスを得ても Boids の見た目に大きな影響は与えないので、インライン化が解除されデータがヒープへ移ってしまうのを防ぐためにも、配列の最大数は Capacity で制限するようにしました。

Boids のアルゴリズムの実装

それでは各アルゴリズムを実装していきましょう。まずは分離(Separation)からです。こちらもコメントを付記します。

public partial struct SeparationSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;

    // Neighbors のバッファのエンティティから LocalTransform 引きするためのルックアップ
    ComponentLookup<LocalTransform> _transformLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _transformLookUp = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _transformLookUp.Update(ref state);
        
        // DynamicBuffer をクエリに指定してアクセス可能
        foreach (var (fish, lt, neighbors) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRO<LocalTransform>,
                DynamicBuffer<NeighborsEntityBufferElement>>())
        {
            var n = neighbors.Length;
            if (n == 0) continue;
            
            var param = _paramLookUp[fish.ValueRW.ParamEntity];
            var pos = lt.ValueRO.Position;
            
            // 平均の離れる方向ベクトルを計算
            var forceDir = float3.zero;
            for (int i = 0; i < n; ++i)
            {
                var neighborEntity = neighbors[i].Entity;
                var neighborPos = _transformLookUp[neighborEntity].Position;
                var to = neighborPos - pos;
                forceDir += -math.normalizesafe(to);
            }
            forceDir /= n;
            forceDir = math.normalizesafe(forceDir);
            
            // 加速度に足す
            fish.ValueRW.Acceleration += forceDir * param.SeparationForce;
        }
    }
}

肝は DynamicBuffer<T> をクエリに指定することでタプルで簡単に配列を取得できるところです。こうして格納したエンティティを通じて、今度は LocalTransform コンポーネントを取ってきています。これには ComponentLookUp を使っています。

さて、これで分離が実装できました。GIF だと分かりづらいですが…近づきすぎると離れるように動きます。

整列

次は整列(Alignment)です。視界内の個体の速度平均に近づけるシステムになります。こちらは特別な新しいコードは必要としません。

public partial struct AlignmentSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;
    ComponentLookup<Fish> _fishLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _fishLookUp = state.GetComponentLookup<Fish>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _fishLookUp.Update(ref state);
    
        foreach (var (fish, neighbors) in 
            SystemAPI.Query<
                RefRW<Fish>,
                DynamicBuffer<NeighborsEntityBufferElement>>())
        {
            var n = neighbors.Length;
            if (n == 0) continue;

            var averageV = float3.zero;
            for (int i = 0; i < n; ++i)
            {
                var neighborEntity = neighbors[i].Entity;
                var neighborV = _fishLookUp[neighborEntity].Velocity;
                averageV += neighborV;
            }
            averageV /= n;
            
            var param = _paramLookUp[fish.ValueRW.ParamEntity];
            var v = fish.ValueRO.Velocity;
            
            fish.ValueRW.Acceleration += (averageV - v) * param.AlignmentForce;
        }
    }
}

結合

最後に結合(Cohesion)です。これは近隣の個体の中心へと移動する力を与えるシステムになります。こちらも新しい文法などは出てきません。

public partial struct CohesionSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;
    ComponentLookup<LocalTransform> _transformLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _transformLookUp = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _transformLookUp.Update(ref state);
    
        foreach (var (fish, lt, neighbors) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRW<LocalTransform>,
                DynamicBuffer<NeighborsEntityBufferElement>>())
        {
            var n = neighbors.Length;
            if (n == 0) continue;

            var averagePos = float3.zero;
            for (int i = 0; i < n; ++i)
            {
                var neighborEntity = neighbors[i].Entity;
                var neighborPos = _transformLookUp[neighborEntity].Position;
                averagePos += neighborPos;
            }
            averagePos /= n;
            
            var pos = lt.ValueRO.Position;
            var param = _paramLookUp[fish.ValueRW.ParamEntity];
            fish.ValueRW.Acceleration += (averagePos - pos) * param.CohesionForce;
        }
    }
}

実行順の調整

さて、うまく動いているように見えますが実はこのままではシステムの順番がばらばらになっています。本来は MoveSystem が、他の全てのシステムの加速度の更新結果をもとに速度の更新をし、それを LocalTransform の位置・回転として反映してほしいため、最後に実行するのが望ましいです。この順番制御に便利な機能として UpdateBeforeUpdateAfter といった順序制御アトリビュートが用意されています。

docs.unity3d.com

これを次のように各種システムへ組み込みます。

[UpdateBefore(typeof(MoveSystem))]
public partial struct AlignmentSystem : ISystem
{
...
}

これにより、MoveSystem が用意したシステムの中で最後に実行されるようになります。

加えて、更に UpdateAfter() を使って、NeighborsDetectionSystem よりも後にする必要があります。

[UpdateBefore(typeof(MoveSystem))]
[UpdateAfter(typeof(NeighborsDetectionSystem))]
public partial struct AlignmentSystem : ISystem
{
...
}

さて、これで一通り Boids の挙動が出来ましたが、パフォーマンス周り(Job や Burst)の話に入る前に、いくつか実用上気になる点に対応していきたいと思います。

パラメタのリアルタイム更新

Boids の動きを調整するにはパラメタの調整がとても大事なのですが、現状パラメタは Bake されてしまっており、ランタイムでの変更ができません。また実際のユースケースでは周囲の状況に応じて動的にパラメタを変更したいケースも出てくる可能性もあります。これらの要件を満たせる構造を考えてみます。いろいろな方法があると思いますのでここでは一例、という感じでご覧ください。

更新は Managed な世界から行うことになります。パラメタは複数持てるように更新したため、それらを識別する情報も必要となります。そこでパラメタ構造を Managed な世界と Unmanaged でやり取りできるようにパラメタ構造に少し手を入れます。更にこれに対応できるパラメタの Set / Get コードを実装してみます。

// MonoBehaviour 上に露出できるようシリアライズ属性を付与
[System.Serializable]
public struct Parameter : IComponentData
{
    // この Type を ID として使うことにする
    // 同じタイプが複数の Boids に割り当てられていれば全部更新
    public int Type;
    public float MinSpeed;
    public float MaxSpeed;
    public float3 AreaScale;
    public float AreaDistance;
    public float AreaForce;
    public float NeighborDistance;
    public float NeighborAngle;
    public float SeparationForce;
    public float AlignmentForce;
    public float CohesionForce;

    // デフォルトパラメタ
    public static Parameter Default
    {
        get => new Parameter()
        {
            Type = 0,
            MinSpeed = 2f,
            MaxSpeed = 5f,
            AreaScale = 5f,
            AreaDistance = 3f,
            AreaForce = 1f,
            NeighborDistance = 1f,
            NeighborAngle = 90f,
            SeparationForce = 5f,
            AlignmentForce = 5f,
            CohesionForce = 5f,
        };
    }
    
    // 全ての合致する Type のデータを上書き
    // 実装の解説は後述
    public static bool Set(in Parameter newParam)
    {
        var manager = World.DefaultGameObjectInjectionWorld.EntityManager;
        var query = manager.CreateEntityQuery(ComponentType.ReadWrite<Parameter>());
        var entities = query.ToEntityArray(Allocator.Temp);
        if (entities.Length == 0) return false;
        
        bool set = false;
        foreach (var entity in entities)
        {
            var param = manager.GetComponentData<Parameter>(entity);
            if (param.Type != newParam.Type) continue;
            manager.SetComponentData(entity, newParam);
            set = true;
        }
        return set;
    }
    
    // Type の合致するパラメタを取得
    public static bool Get(ref Parameter outParam)
    {
        var manager = World.DefaultGameObjectInjectionWorld.EntityManager;
        var query = manager.CreateEntityQuery(ComponentType.ReadOnly<Parameter>());
        var parameters = query.ToComponentDataArray<Parameter>(Allocator.Temp);
        if (parameters.Length == 0) return false;
        
        foreach (var param in parameters)
        {
            if (param.Type != outParam.Type) continue;
            outParam = param;
            return true;
        }
        return false;
    }
}

public class ParameterAuthoring : MonoBehaviour
{
    // 直接オーサリングコンポーネントが値を持つ代わりに
    // シリアライズした IComponentData を使うようにする
    public Parameter param = Parameter.Default;
}

public class ParameterBaker : Baker<ParameterAuthoring>
{
    public override void Bake(ParameterAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.None);
        // 直接セットする
        AddComponent(entity, src.param);
    }
}

少し長いですが、こんな感じです。まず、パラメタのデータをオーサリンコンポーネントから外し、シリアライズ可能な IComponentData としました。そして複数このパラメタを切り替えられるように、パラメタを識別できるような変数として Type を追加しました。ではこれを使って、パラメタにデータをセットしたり読み出したりする、Parameter.Set()Parameter.Get() を見ていきましょう。

まず ECS の外の世界からエンティティのマネージャにアクセスするには World.DefaultGameObjectInjectionWorld.EntityManager を利用します。以前は World.Active でデフォルトのワールドにアクセスできましたが、ちょっと長くなりましたね。

このマネージャを経由して CreateEntityQuery() でクエリを生成し、必要なコンポーネントにアクセスできます。ちなみに EntityQueryBuilder でもクエリは作れますが、CreateEntityQuery() のほうが少しシンプルに書けます。

// var query = manager.CreateEntityQuery(ComponentType.ReadWrite<Parameter>());
var query = new EntityQueryBuilder(Allocator.Temp).WithAll<Parameter>().Build(manager);

こうしてクエリが作れてしまえば後は既出の ToComponentDataArray<T>()Parameterコンポーネントの配列が得られます。あとは得られたコンポーネントType を見ていって合致するものを上書きしていく感じです。ちなみに ToComponentDataArray で返ってくる配列は実体のコンポーネントではなくコピーされた一時オブジェクトとなります。そのため、実体を書き換えるためには SetComponentData() を呼んであげる必要があります。同様のプロセスを Get 側でも行えば準備完了です。

あとはこれを利用する適当な MonoBehaviour コンポーネントを用意すれば書き換えが出来ます。今回は、エディタ上で値を編集すると指定したタイプのパラメタを書き換えできる次のようなものを作ってみました。利用しないときは Disable にしておき、使いたいときは On にすると毎フレーム書き換える、というものです。本番にそのまま使うというか、パラメタの動的書き換えチェック用みたいな位置づけのものです。

public class ParameterUpdater : MonoBehaviour
{
    // エディタ上で見えるデータ
    public Parameter param;

    // 指定したタイプのデータが取得済みか
    bool _isGot = false;

    // エディタ上のタイプの変更を監視
    int _type = 0;

    void OnEnable()
    {
        _type = param.Type;
        _isGot = Parameter.Get(ref param);
    }
    
    void OnDisable()
    {
        _isGot = false;
    }

    void Update()
    {
        if (param.Type != _type)
        {
            _isGot = false;
        }
        
        // タイプ変更後はパラメタを取得
        if (!_isGot)
        {
            _isGot = Parameter.Get(ref param);
            if (_isGot) 
            {
                _type = param.Type;
            }
        }
        // パラメタ取得済みの場合はエディタで指定したもので書き換え
        else
        {
            Parameter.Set(param);
        }
    }
}

これで次のように動作します。

エディタのデバッグ描画

以前はエリアをシーン上で MonoBehaviour.OnDrawGizmos() を使ってデバッグ表示していたのですが、今の構成だとエントリポイントとなる MonoBehaviour コンポーネントがいないので描画できません。しかしデバッグ表示としてどのエリアを Boids が動くのかは見たいところです。

方法としては例えば以下の 2 つが考えられます。

  • 専用の MonoBehaviour コンポーネントを用意して描画
    • 先程のパラメタ更新と同じように毎フレーム取得
    • OnDrawGizmos() で描画
  • デバッグ描画用 ISystem を作成
    • メインスレッドで動くシステムであればデバッグ描画が可能
    • Debug.DrawLine() などで描画

今回は実行していないときでも範囲を確認したいので、前者が良さそうです。ではコードを書いてみます。

using UnityEngine;
using Unity.Entities;
using Unity.Collections;
using Unity.Transforms;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Boids.Runtime
{
    
public class DebugViewer : MonoBehaviour
{
    public bool area = false;
    
    void OnDrawGizmos()
    {
        if (area) DrawAreas();
    }
    
    void DrawAreas()
    {
        var manager = World.DefaultGameObjectInjectionWorld.EntityManager;
        var query = manager.CreateEntityQuery(
            ComponentType.ReadOnly<Parameter>(),
            ComponentType.ReadOnly<LocalTransform>());
        var entities = query.ToEntityArray(Allocator.Temp);
        
        foreach (var entity in entities)
        {
            var param = manager.GetComponentData<Parameter>(entity);
            var lt = manager.GetComponentData<LocalTransform>(entity);
            Gizmos.color = (Vector4)(new float4(param.DebugAreaColor, 1f));
            Gizmos.matrix = Matrix4x4.TRS(lt.Position, lt.Rotation, param.AreaScale);
            Gizmos.DrawWireCube(Vector3.zero, Vector3.one);
            Gizmos.matrix = Matrix4x4.identity;

#if UNITY_EDITOR
            Handles.Label(lt.Position, "Boids");
#endif
        }
    }
}

}

ギズモはそのままの API だと位置しか指定できないのですが、Gizmos.matrix にトランスフォーム行列を入れてあげると回転やスケールもいじれます。なお、Parameter には色分けできるように DebugAreaColor を追加しておきます。

これで実行してないときは次のようになります。

ちなみにデバッグ描画を伴うシステムは、ジョブなどを使わないケースでは、次のように普通に Debug.DrawLine() で見たり出来ます。サンプルとして専用のシステムを作るのは面倒なので、ひとまず MoveSystem に入れてみます。なお、デバッグ描画を見るにはギズモ表示を ON にする必要があります。

public partial struct MoveSystem : ISystem
{
    ....
    public void OnUpdate(ref SystemState state)
    {
        ...
        foreach (var (fish, lt) in SystemAPI.Query<...>())
        {
            ...
            Debug.DrawRay(pos, v * 0.3f, Color.green, 0f, true);
        }
    }
}

こんな感じになります。

中心座標の移動

さて、デバッグ描画は作ったのですが、そもそも現状、シミュレーションは原点中心となっています。これをシミュレーション中心を動かせるようにしてみます。また、デバッグ描画を作って思いましたがスケール変更は Bootstrap 用のオブジェクトのスケールをそのまま使ったほうが楽そうなのでそのように改変してみます。

スケールの取り扱い

ここで少し理解しないといけない点があります。これまでトランスフォームを使うエンティティを生成する際は、TransformUsageFlags.Dynamic を使っていました。これにより LocalTransform コンポーネントが付与されます。こちらのドキュメントを見てみます。

docs.unity3d.com

するとスケール要素は XYZ が同じ値として float 型となっています。これはパフォーマンス(行列計算コストなど)やメモリ量的に有利ではありますが、今回のようなケースでは XYZ を別々に取り扱いたいところです。このようなケースのために TransformUsageFlags.NonUniformScale というものが用意されています。これを与えると、LocalTransform の他に PostTransformMatrix というコンポーネントがセットアップされます。

docs.unity3d.com

public class ParameterBaker : Baker<ParameterAuthoring>
{
    public override void Bake(ParameterAuthoring src)
    {
        var entity = GetEntity(TransformUsageFlags.NonUniformScale);
        AddComponent(entity, src.param);
    }
}

スケール行列として得られるわけですね(ちなみにシアーも表現できるようです)。ではこうして得た PostTransformMatrix をどのように使うかを見てみます。まずは SpawnSystem で、この中でスケールを考慮したエリアの中にランダムに分布するようにしてみます。

public partial struct SpawnSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (school, param, lt, ptm, entity) in 
            SystemAPI.Query<
                ...
                RefRO<PostTransformMatrix>>().WithEntityAccess())
        {
            ...
            var localTransform = lt.ValueRO.ToMatrix();
            var scaleTransform = ptm.ValueRO.Value;
            var transform = math.mul(localTransform, scaleTransform);
            Create(..., transform, ...);
            ...
        }
    }
    
    void Create(
        ...
        in float4x4 areaTransform,
        ...)
    {
        ...
        foreach (var entity in entities)
        {
            var lt = SystemAPI.GetComponentRW<LocalTransform>(entity);
            var localPos = random.NextFloat3() - 0.5f;
            lt.ValueRW.Position = math.transform(areaTransform, localPos);
            ...
        }
    }
}

PostTransformMatrix にはスケール行列が入っています。これを math.mul()LocalTransform のトランスフォーム行列にかけることで、XYZ を考慮したスケール込みの行列を得ることが出来ます。なお、LocalTransform から得られるトランスフォーム行列のスケール成分は、スケールがユニフォームではない場合は 1.0 となるようです。

ちなみに最初 math.mul() をしないで localTransform * scaleTransform としていてバグりました。。単なる * operator による掛け算は内積を取ってしまうので位置や回転成分が消えてしまうようですね。。

得られた行列を位置に対して適用する場合は math.transform() を使用します。これで箱にランダムに広がる位置が得られるようになりました。

システムの改変

これに伴い AreaSystem のシステムも改変します。

...
public partial struct AreaSystem : ISystem
{
    ...
    ComponentLookup<PostTransformMatrix> _postTransformMatrixLookUp;

    public void OnCreate(ref SystemState state) 
    {
        ...
        _postTransformMatrixLookUp = state.GetComponentLookup<PostTransformMatrix>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        ...
        _postTransformMatrixLookUp.Update(ref state);
        
        foreach (var (fish, lt) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRO<LocalTransform>>())
        {
            ...
            // グループの PostTarnsformMatrix を得る
            var areaPtm = _postTransformMatrixLookUp[paramEntity];

            // float3 スケールは PostTransformMatrix から得られる
            var scale = areaPtm.Value.Scale() * 0.5f;
            
            // 箱のローカル座標系に変換する
            // ただし、スケール成分だけはそのまま = ワールド座標系での大きさを使う
            var pos = lt.ValueRO.Position;
            var transformRt = float4x4.TRS(areaLt.Position, areaLt.Rotation, 1f);
            pos = math.transform(math.inverse(transformRt), pos);

            // 座標系としてはローカル座標系(大きさはそのままに位置は原点、回転なし)
            var addAccel =
                GetAccelAgainstWall(pos.x - -scale.x, math.right(), thresh, weight) +
                GetAccelAgainstWall(pos.y - -scale.y, math.up(), thresh, weight) +
                GetAccelAgainstWall(pos.z - -scale.z, math.forward(), thresh, weight) +
                GetAccelAgainstWall(+scale.x - pos.x, math.left(), thresh, weight) +
                GetAccelAgainstWall(+scale.y - pos.y, math.down(), thresh, weight) +
                GetAccelAgainstWall(+scale.z - pos.z, math.back(), thresh, weight);

            // 力のかかる方向をワールド座標系
            addAccel = math.rotate(areaLt.Rotation, addAccel);
            fish.ValueRW.Acceleration += addAccel;
        }
    }
    ...
}

実行

これで実行すると次のようにエリアの中におさまる魚群が見られるようになります(わかりやすいように壁以外の力は弱くしています)。

ちなみに動的な位置移動やスケールを行うと次のようになります(サブシーンを開いているためそのままだと個体が見えないので、デバッグ表示の拡張で見えるようにしています)。

パラメタを調整するとこんな感じで交わるエリア内ではくっついてエリアが離れるタイミングで別れていく、みたいなことが出来ました。

その他

パフォーマンスの話に行く前に、これまで紹介してこなかったところを最後に見ていきます。

ISystem と SystemBase

これまでは ISystem 継承の構造体ベースのシステムで作成してきました。一方、SystemBase 継承のクラスベースのシステムも作成可能です。

docs.unity3d.com

SystemBase の場合は Burst が効かないといったパフォーマンス上不利な点がありますが、Managed なメンバを持てたりといった柔軟性の面での利点があります。

Aspect

IAspect という複数のコンポーネントをまとめ上げる抽象化の仕組みが存在します。

docs.unity3d.com

これは複数のコンポーネントをまとめ上げ、それに伴う各変数のアクセスを簡略化したり、処理を記述したりすることができるものです。例えば次のような FishAspect を作ってみます。

public readonly partial struct FishAspect : IAspect
{
    readonly RefRW<Fish> _fish;
    readonly RefRW<LocalTransform> _localTransform;
    readonly DynamicBuffer<NeighborsEntityBufferElement> _neighborsEntityBuffer;
    
    public Fish Fish
    {
        get => _fish.ValueRO;
        set => _fish.ValueRW = value;
    }
    
    public float3 Velocity
    {
        get => _fish.ValueRO.Velocity;
        set => _fish.ValueRW.Velocity = value;
    }
    
    public float3 Acceleration 
    {
        get => _fish.ValueRO.Acceleration;
        set => _fish.ValueRW.Acceleration = value;
    }
    
    public Entity ParamEntity
    {
        get => _fish.ValueRO.ParamEntity;
    }
    
    public LocalTransform LocalTransform
    {
        get => _localTransform.ValueRO;
        set => _localTransform.ValueRW = value;
    }
    
    public DynamicBuffer<NeighborsEntityBuffeElementr> Neighbors => _neighborsEntityBuffer;
}

これを SeparationSystem で使ってみましょう。すると以下のように各パラメタへのアクセスの名前が直感的になります。

...
public partial struct SeparationSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        ...
        foreach (var fish in SystemAPI.Query<FishAspect>())
        {
            var n = fish.Neighbors.Length;
            if (n == 0) continue;
            
            var pos = fish.LocalTransform.Position;
            
            var forceDir = float3.zero;
            for (int i = 0; i < n; ++i)
            {
                var neighborEntity = fish.Neighbors[i].Entity;
                var neighborPos = _transformLookUp[neighborEntity].Position;
                var to = neighborPos - pos;
                forceDir += -math.normalizesafe(to);
            }
            forceDir /= n;
            
            var param = _paramLookUp[fish.ParamEntity];
            fish.Acceleration += forceDir * param.SeparationForce;
        }
    }
}

また、この処理をまとめて Aspect 側に記述することも出来ます。

public readonly partial struct FishAspect : IAspect
{
    ...
    public void UpdateSeparation(
        in Parameter param, 
        in ComponentLookup<LocalTransform> transformLookUp)
    {
        var n = Neighbors.Length;
        if (n == 0) return;
        
        var pos = LocalTransform.Position;
        
        var forceDir = float3.zero;
        for (int i = 0; i < n; ++i)
        {
            var neighborEntity = Neighbors[i].Entity;
            var neighborPos = transformLookUp[neighborEntity].Position;
            var to = neighborPos - pos;
            forceDir += -math.normalizesafe(to);
        }
        forceDir /= n;
        
        Acceleration += forceDir * param.SeparationForce;
    }
}
...
public partial struct SeparationSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        ...
        foreach (var fish in SystemAPI.Query<FishAspect>())
        {
            var param = _paramLookUp[fish.ParamEntity];
            fish.UpdateSeparation(param, _transformLookUp);
        }
    }
}

Unmanaged な世界で動くようになっており、Burst の恩恵も受けることが出来ます。場合によってはうまく働くと思いますが、ただ複数のシステムにまたがって動作させようとすると、例えば LocalTransform がものによっては RefRO で良かったり、ものによっては RefRW が必要だったり、場合によっては要らなかったり…、といったケースが有り、システム依存度が高いです。余計な情報を取ってこようとしたりすると、その分のオーバーヘッドもあるため、むやみに全てを Aspect 化しようとするとコードの抽象度が上がってしまって読みづらくなるデメリットもありそうです。今回のケースでは使わないほうが良さそうなので Aspect 化はせずに進めていこうと思います。

ちなみに Aspect を作成すると、Inspector からそのエンティティがどの Aspect に対応しているかが確認できます。

近傍探索のパフォーマンス改善

現状のパフォーマンス

Job 化と Burst の適用の前に一旦現状のパフォーマンスを確認しておきます。ひとまず 300 体ほどの Boid を生成してみてみます。

他のシステムが 1 ms 未満なのに対し、NeighborsDetectionSystem はかなり長い時間が掛かっています。これは内部で二重ループを行っているからで、計算量が O(n2) でとても重い処理になっていることによります。

高速化手法検討

Boids の計算量改善にはいろいろな手法が考えられると思いますが、ここではハッシュ関数を使ったグリッドによる高速化を試してみます。

概念としては、3 次元空間を適当な大きさでグリッド分割し、それぞれの個体が所属する空間のインデックスを  (i_x, i_y, i_z) で求めます。そしてこの 3 次元の int を入力とするハッシュ関数(この 3 つの数字を使って適当な 1 次元の数字を返す関数)を通して得た値をインデックスとした、エンティティリストを保管するテーブルを作成します。こうすると、同じグリッドに所属するエンティティが配列として得られるようになります。ハッシュ関数はインデックスのみで得られるので、自身および隣り合うセル 27 個に所属するエンティティをかき集めてくるのも高速です。こうして得たエンティティの配列を使って NeighborsDetectionSystem の処理を実行してあげれば、総当たりで見ていた二重ループのところが開かれて、軽く済むというわけです。

実装

テーブルの構造としては、NativeParallelMultiHashMap という構造体が利用できます。これは 1 つのキーに対して複数の値を格納できるというものです。

docs.unity3d.com

ではこれを使って NeighborsDetectionSystem を書き直してみましょう。少し大きなクラスになります。

public partial struct NeighborsDetectionSystem : ISystem
{
    // LookUp は前と同じく利用
    ComponentLookup<Parameter> _paramLookUp;
    BufferLookup<NeighborsEntityBufferElement> _neighborsLookUp;

    // ハッシュマップのサイズを決めたいのでクエリで全体数を確認
    EntityQuery _query;

    // グリッドのセルの設定
    float _cellsize;
    NativeArray<int3> _cellOffsets;
    
    // int3 でセルのインデックスを受け取り適当な int を返す
    int GetHash(int3 cell)
    {
        return cell.x * 73856093 ^ cell.y * 19349663 ^ cell.z * 83492791;
    }

    public void OnCreate(ref SystemState state) 
    {
        // 諸々初期化
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _neighborsLookUp = state.GetBufferLookup<NeighborsEntityBufferElement>(isReadOnly: false);
        _query = SystemAPI.QueryBuilder().WithAll<Fish, LocalTransform>().Build();
        _cellsize = 0.5f;

        // 3 次元グリッドの周囲 27 セルのオフセット
        _cellOffsets = new NativeArray<int3>(27, Allocator.Persistent);
        {
            var i = 0;
            for (int x = -1; x <= 1; ++x)
            {
                for (int y = -1; y <= 1; ++y)
                {
                    for (int z = -1; z <= 1; ++z)
                    {
                        _cellOffsets[i++] = new int3(x, y, z);
                    }
                }
            }
        }
    }
    
    public void OnDestroy(ref SystemState state) 
    {
        // Persistent で確保したネイティブコレクションはメモリリークしないように破棄
        if (_cellOffsets.IsCreated) _cellOffsets.Dispose();
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _neighborsLookUp.Update(ref state);
        
        using var entities = _query.ToEntityArray(Allocator.Temp);
        using var fishes = _query.ToComponentDataArray<Fish>(Allocator.Temp);
        using var localTransforms = _query.ToComponentDataArray<LocalTransform>(Allocator.Temp);
        
        // ハッシュマップのサイズを決める
        int n = fishes.Length;

        // ハッシュマップはキーがハッシュ値、
        // バリューがそこに含まれているインスタンスのインデックス
        using var hashMap = new NativeParallelMultiHashMap<int, int>(n, Allocator.Temp);

        // セルのサイズは設定された NeighborsDistance パラメタの中で
        // もっとも大きいものの半分サイズとする
        // 大きすぎるとエンティティの数が多くなってしまい、
        // 逆に小さすぎると十分な範囲が見れないため
        float nextCellSize = 0.1f;
        
        // 全てのインスタンスの位置をハッシュ値に変換してハッシュマップに格納
        // 加えて上記のグリッドサイズ決めもここで行う(次フレームで使う)
        for (int i = 0; i < n; ++i)
        {
            var pos = localTransforms[i].Position;
            var cell = (int3)(pos / _cellsize);
            var hash = GetHash(cell);
            hashMap.Add(hash, i);
            
            var fish = fishes[i];
            var paramEntity = fish.ParamEntity;
            var param = _paramLookUp[paramEntity];
            nextCellSize = math.max(nextCellSize, param.NeighborDistance * 0.5f);
        }
        
        // 全てのインスタンスを見る
        for (int i = 0; i < n; ++i)
        {
            // 種々の自身のパラメタを計算
            var pos0 = localTransforms[i].Position;
            var cell0 = (int3)(pos0 / _cellsize);
            
            var fish0 = fishes[i];
            var fwd0 = math.normalizesafe(fish0.Velocity);
            
            var param = _paramLookUp[fish0.ParamEntity];
            var neighborAngle = math.radians(param.NeighborAngle);
            var neighborDist = param.NeighborDistance;
            var prodThresh = math.cos(neighborAngle);
            
            var entity0 = entities[i];
            var neighbors0 = _neighborsLookUp[entity0];
            neighbors0.Clear();
            
            // 隣り合うセルを見ていく
            for (int offsetIndex = 0; offsetIndex < _cellOffsets.Length; ++offsetIndex)
            {
                // 隣り合うセルのハッシュを計算
                var hash0 = GetHash(cell0 + _cellOffsets[offsetIndex]);

                // ハッシュ値からそのセルに含まれている個体のインデックスを取得
                // TryGetFirstValue -> TryGetNextValue でイテレーション
                // 含まれていない場合はスキップ
                if (!hashMap.TryGetFirstValue(hash0, out var j, out var it)) continue;
                
                // 含まれている分だけ TriyGetNextValue で見ていく
                do
                {
                    // 内部の判定は以前と全く同じ
                    var entity1 = entities[j];
                    if (entity0 == entity1) continue;
                    
                    var lt1 = localTransforms[j];
                    var pos1 = lt1.Position;
                    var to = pos1 - pos0;
                    var dist = math.length(to);
                    if (dist > neighborDist) continue;
            
                    var dir = to / math.max(dist, 1e-3f);
                    var prod = math.dot(dir, fwd0);
                    if (prod < prodThresh) continue;
            
                    var elem = new NeighborsEntityBufferElement() { Entity = entity1 };
                    neighbors0.Add(elem);
                    if (neighbors0.Length >= neighbors0.Capacity) break;
                } while (hashMap.TryGetNextValue(out j, ref it));
            }
        }
        
        // 次フレームでは自動計算されたセルサイズを使う
        _cellsize = nextCellSize;
    }
}

NativeParallelMultiHashMap の使い方はコードのとおりです。TryGetFirstValue() で値の他にイテレータが得られます。これを使って TryGetNextValue() をしていくことで、同一キーに対するバリュー全てが得られる寸法です。ちょっとイテレーションが面倒ですが、比較的シンプルに使えますね。

ハッシュの計算のところは謎な数値が並んでいますが…、これは XYZ のそれぞれの値を使って大きな素数と掛け算をし、それらを XOR で組み合わせています。これにより、計算が軽量で偏りの少ないハッシュ値を計算することが出来ています(セルによってはハッシュが被りうるケースがありますが、結果的にはこれは距離判定で弾かれます)。

流れをまとめると、

  • 位置  (x, y, z) => グリッド  (i, j, k) => ハッシュ値へと変換
  • ハッシュ値をキーとして、複数要素格納出来るコンテナへそのグリッドにいる個体をリストとして登録
  • それぞれの個体の近隣個体は周囲のセル 27 マスにいる個体を利用

という感じです。なお、クエリでイテレーションするのではなく事前に配列を取得して回す形になっていますが、ハッシュマップ化により 2 重ループでなくなったため他のシステム同様クエリによっても処理できると思います。ただ、勉強のためにこちらの形式をキープすることにします。

パフォーマンス改善

これでパフォーマンスを見てみると次のようになっています。

O(n2) ではなくなったので、かなり速くなっていますね。

余談

最初はこのハッシュマップを School に持たせて、ハッシュマップの構築と DynamicBufferインスタンスを詰めていくところを別のシステムとして作ろうとしたのですが、IComponentData にネイティブコレクションを Persistent なアロケーションで持たせるには、

  • ICleanupComponentData の作成
  • そのコンポーネントデータを付与するシステム
  • ICleanupComponentData からネイティブコレクションを破棄するシステム

といった追加要素が必要となり、コードと説明が煩雑になりはじめたので止め、今回は一つのシステムの中に納める選択をしました。

docs.unity3d.com

他にも良い方法がありそうでしたら教えて下さい。

Burst 化

では作成したシステムを Burst 化してみます。とはいってもやることは簡単で、全てのシステムの OnUpdate()BurstCompile 属性をつけるだけです。

...
public partial struct SeparationSystem : ISystem
{
    ...
    [BurstCompile] // これをつけるだけ
    public void OnUpdate(ref SystemState state)
    {
        ...
    }
}

うまく行けば Jobs > Burst > Open Inspector... から Burst コンパイル結果が確認できます。

これで実行すると次のように劇的にパフォーマンスが改善されます。

AlignmentSystem などに至っては 0.03 ms ほどになりました。3000 匹くらいに増やしても一応 60 fps で動きます。

ただ、どうしても局所的に分布が集中してしまうようなケース(みんなが近いグリッドセルにいる)では重くなってしまいます。

Job 化

Boids のアルゴリズムは近傍探索以外は特に順序依存性もないですし、それぞれの個体別に並列計算するのに向いています。では最後に Job 化について見ていきましょう。

IJobEntity

コンポーネントのデータを使って並列処理を行うのは、IJobEntity を用いることで簡単に実現できます。

docs.unity3d.com

IJobEntity は Job の Execute 関数の中で foreach でやったときのような形で引数としてコンポーネントのデータなどを受け取ることが出来る便利な Job の形態です。

例えば IJobParallelFor では次のように自分で入力出力を定義、取ってきていました。

public struct CustomJob : IJobParallelFor
{
    // 入力パラメタ
    [ReadOnly] public ComponentDataArray<Position> positions;
    ...

    // 出力パラメタ
    public ComponentDataArray<Acceleration> accelerations;
    ...

    // 入力を使って出力
    public void Execute(int index)
    {
        float3 pos = positions[index].Value;
        float3 accel = accelerations[index].Value;
        accel += ...;
    }
    ...
}

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
    var job = new Job 
    {
        positions = data.positions,
        accelerations = data.accelerations,
        ...
    };
    return job.Schedule(data.Length, 32, inputDeps);
}

これに対し、IJobEntity では次のようにクエリからコンポーネントのデータなどを受け取れます。

public partial struct CustomJob : IJobEntity
{
    // 同じようにパラメタ
    [ReadOnly] public Something something;
    ...

    void Execute(ref LocalTransform lt, ref MoveComponent move)
    {
        // ... なにかする
    }
}

public partial struct CustomSystem : ISystem
{
    ...
    
    public void OnUpdate(ref SystemState state)
    {
        ...
        var job = new CustomJob()
        {
            something = ...,
        };
        
        var query = SystemAPI
            .QueryBuilder()
            .WithAll<LocalTransform, MoveComponent>()
            .Build();
        state.Dependency = job.ScheduleParallel(query, state.Dependency);
    }
}

以前も IJobProcessComponentData というものがありましたが、更にシンプルになっています。

Job 化の試み

そこで、このお作法に従い既存の AlignmentSytem を Job 化してみます。OnUpdate() の中にあった SystemAPI.Query<...> によって回していた foreach 文の中身を Job の Execute に移し、その外側でセットアップしていた変数(ComponentLookUp など)は Job の入力変数として渡す形です。

[BurstCompile]
public partial struct AlignmentJob : IJobEntity
{
    [ReadOnly] public ComponentLookup<Parameter> ParamLookUp;
    [ReadOnly] public ComponentLookup<Fish> FishLookUp;
    
    void Execute(
        ref Fish fish,
        in DynamicBuffer<NeighborsEntityBufferElement> neighbors)
    {
        var n = neighbors.Length;
        if (n == 0) return;

        var averageV = float3.zero;
        for (int i = 0; i < n; ++i)
        {
            var neighborEntity = neighbors[i].Entity;
            var neighborV = FishLookUp[neighborEntity].Velocity;
            averageV += neighborV;
        }
        averageV /= n;
        
        var param = ParamLookUp[fish.ParamEntity];
        var v = fish.Velocity;
        
        fish.Acceleration += (averageV - v) * param.AlignmentForce;
    }
}

[UpdateBefore(typeof(MoveSystem))]
[UpdateAfter(typeof(NeighborsDetectionSystem))]
public partial struct AlignmentSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;
    ComponentLookup<Fish> _fishLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _fishLookUp = state.GetComponentLookup<Fish>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _fishLookUp.Update(ref state);
    
        var job = new AlignmentJob()
        {
            FishLookUp = _fishLookUp,
            ParamLookUp =  _paramLookUp,
        };
        
        var query = SystemAPI
            .QueryBuilder()
            .WithAll<Fish, NeighborsEntityBufferElement>()
            .Build();
        state.Dependency = job.ScheduleParallel(query, state.Dependency);
    }
}

しかし、これで実行すると次のようにエラーとなってしまいます。

InvalidOperationException: The writeable ComponentTypeHandle<Boids.Sample03.Runtime.Fish> AlignmentJob.JobData.__TypeHandle.__Boids_Sample03_Runtime_Fish_RW_ComponentTypeHandle is the same ComponentLookup<Boids.Sample03.Runtime.Fish> as AlignmentJob.JobData.FishLookUp, two containers may not be the same (aliasing).

これは、同じ Fish に対して Execute 関数の引数の ref によっても ComponentLookUp<Fish> によってもアクセスできてしまうことによるエラーのようです。単一のコンポーネントへ複数のアクセス方法をもつことは競合の可能性があるため、許可されないようです。

そこで、他の個体の情報を参照したいときは、それを前フレームに格納しておいた別のコンポーネントを作り、そちらを参照するようにします。こんな感じで新たに別コンポーネントを足します。

public struct FishJobData : IComponentData
{
    public float3 Position;
    public float3 Velocity;
}

public class FishBaker : Baker<FishAuthoring>
{
    public override void Bake(FishAuthoring src)
    {
        ...
        AddComponent(entity, new FishJobData()
        {
            Position = src.transform.position,
            Velocity = v,
        });
        ...
    }
}

public partial struct SpawnSystem : ISystem
{
    ...
    void Create(...)
    {
        ...
        foreach (var entity in entities)
        {
            ...
            var jobData = SystemAPI.GetComponentRW<FishJobData>(entity);
            jobData.ValueRW.Position = lt.ValueRO.Position;
            jobData.ValueRW.Velocity = fish.ValueRO.Velocity;
        }
    }
}

public partial struct MoveSystem : ISystem
{
    ...
    public void OnUpdate(ref SystemState state)
    {
        ...
        foreach (var (fish, jobData, lt) in 
            SystemAPI.Query<
                RefRW<Fish>,
                RefRW<FishJobData>,
                RefRW<LocalTransform>>())
        {
            ...
            var pos = ...;
            var v = ...;
            jobData.Position = pos;
            jobData.Velocity = v;
        }
    }
}

前フレームの最終情報を保持しておくものです。そしてこの上で AlignmentSystem に手を入れます。

[BurstCompile]
public partial struct AlignmentJob : IJobEntity
{
    [ReadOnly] public ComponentLookup<Parameter> ParamLookUp;
    [ReadOnly] public ComponentLookup<FishJobData> FishJobDataLookUp;
    
    void Execute(
        // 自身へのアクセスおよび書き込みは Fish コンポーネント
        ref Fish fish,
        in DynamicBuffer<NeighborsEntityBufferElement> neighbors)
    {
        var n = neighbors.Length;
        if (n == 0) return;

        var averageV = float3.zero;
        for (int i = 0; i < n; ++i)
        {
            var neighborEntity = neighbors[i].Entity;
            // 他の個体へのアクセスは FishJobData コンポーネントを通じて行う
            var neighborV = FishJobDataLookUp[neighborEntity].Velocity;
            averageV += neighborV;
        }
        averageV /= n;
        
        var param = ParamLookUp[fish.ParamEntity];
        var v = fish.Velocity;
        
        fish.Acceleration += (averageV - v) * param.AlignmentForce;
    }
}

[UpdateBefore(typeof(MoveSystem))]
[UpdateAfter(typeof(NeighborsDetectionSystem))]
public partial struct AlignmentSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;
    ComponentLookup<FishJobData> _fishJobDataLookUp;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _fishJobDataLookUp = state.GetComponentLookup<FishJobData>(isReadOnly: true);
    }
    
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _fishJobDataLookUp.Update(ref state);
    
        var job = new AlignmentJob()
        {
            FishJobDataLookUp = _fishJobDataLookUp,
            ParamLookUp =  _paramLookUp,
        };
        
        var query = SystemAPI
            .QueryBuilder()
            .WithAll<Fish, NeighborsEntityBufferElement>()
            .Build();
        state.Dependency = job.ScheduleParallel(query, state.Dependency);
    }
}

これで書き込みが行われるコンポーネントFish)へのアクセス方法が限定され、安全に書き込みが可能なことが保証されました。

同じ点に注意し、AreaSystemSeparationSystemCohesionSystemMoveSystem は後は同様に機械的に Job 化することが可能です。

近傍探索の Job 化

さて、一筋縄ではいかないのが近傍探索(NeighborsDetectionSystem)のところです。メインスレッド編でもループのさせ方が違ったりと一癖ありました。これを Job 化してみます。

近傍探索の中では 2 つのことをやっていました。一つはそれぞれの 3 次元グリッドのセルのどこに所属しているかを見るハッシュマップの構築、もう一つはそのハッシュマップを使ってそれぞれの個体の周辺に、どの個体がいるかの DynamicBuffer(NeighborsEntityBufferElement)の構築です。これらを別々の Job で処理することにします。

ハッシュマップ構築処理

次のようなコードになります。

// 2 つの Job から使うので別クラスへ分離
[BurstCompile]
internal static class NeighborsDetectionUtil
{
    [BurstCompile]
    public static int GetHash(in int3 cell)
    {
        return cell.x * 73856093 ^ cell.y * 19349663 ^ cell.z * 83492791;
    }
}

[BurstCompile]
public struct NeighborsHashMapBuildJob : IJobParallelFor
{
    // ハッシュマップは ParallelWriter で受け取る
    [WriteOnly] public NativeParallelMultiHashMap<int, int>.ParallelWriter HashMap;
    [ReadOnly] public float CellSize;
    [ReadOnly] public NativeArray<LocalTransform> LocalTransforms;
    
    public void Execute(int index)
    {
        var pos = LocalTransforms[index].Position;
        var cell = (int3)(pos / CellSize);
        var hash = NeighborsDetectionUtil.GetHash(cell);
        HashMap.Add(hash, index);
    }
}

public partial struct NeighborsDetectionSystem : ISystem
{
    ComponentLookup<Parameter> _paramLookUp;
    BufferLookup<NeighborsEntityBufferElement> _neighborsLookUp;
    NativeParallelMultiHashMap<int, int> _hashMap;
    EntityQuery _fishQuery;
    EntityQuery _paramQuery;
    NativeArray<int3> _cellOffsets;

    public void OnCreate(ref SystemState state) 
    {
        _paramLookUp = state.GetComponentLookup<Parameter>(isReadOnly: true);
        _neighborsLookUp = state.GetBufferLookup<NeighborsEntityBufferElement>(isReadOnly: false);
        _hashMap = new NativeParallelMultiHashMap<int, int>(100, Allocator.Persistent);
        _fishQuery = SystemAPI.QueryBuilder().WithAll<Fish, LocalTransform>().Build();
        _paramQuery = SystemAPI.QueryBuilder().WithAll<Parameter>().Build();
        _cellOffsets = new NativeArray<int3>(27, Allocator.Persistent);
        {
            var i = 0;
            for (int x = -1; x <= 1; ++x)
            {
                for (int y = -1; y <= 1; ++y)
                {
                    for (int z = -1; z <= 1; ++z)
                    {
                        _cellOffsets[i++] = new int3(x, y, z);
                    }
                }
            }
        }
    }
    
    public void OnDestroy(ref SystemState state) 
    {
        if (_cellOffsets.IsCreated) _cellOffsets.Dispose();
        if (_hashMap.IsCreated) _hashMap.Dispose();
    }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        _paramLookUp.Update(ref state);
        _neighborsLookUp.Update(ref state);
        
        // バッファは Temp だとすぐ解放されてしまうので TempJob でアロケーション
        // 解放は後で見ますが、スコープ外で解放を行うので using はつけない
        var entities = _fishQuery.ToEntityArray(Allocator.TempJob);
        var fishes = _fishQuery.ToComponentDataArray<Fish>(Allocator.TempJob);
        var localTransforms = _fishQuery.ToComponentDataArray<LocalTransform>(Allocator.TempJob);

        // 最大値を見たいセルサイズ決定は並列にしづらい & 
        // パラメタ数が少なければ旨味がないので、ここで計算してしまう
        using var parameters = _paramQuery.ToComponentDataArray<Parameter>(Allocator.Temp);
        float cellSize = 0.1f;
        for (int i = 0; i < parameters.Length; ++i)
        {
            var param = parameters[i];
            cellSize = math.max(cellSize, param.NeighborDistance * 0.5f);
        }
        
        // ハッシュマップのクリアとサイズ変更
        _hashMap.Clear();
        int n = fishes.Length;
        if (_hashMap.Capacity < n) _hashMap.Capacity = n;

        // ハッシュマップ構築ジョブの実行
        var hashMapBuildJob = new NeighborsHashMapBuildJob()
        {
            HashMap = _hashMap.AsParallelWriter(), // これが大事
            CellSize = cellSize,
            LocalTransforms = localTransforms,
        };
        state.Dependency = hashMapBuildJob.Schedule(n, 32, state.Dependency);
        
        // ...この後に近傍の個体を収集するジョブを作る
    }
}

ここでは、事前に配列を取得する方式をしているので、IJobEntity を使う代わりに IJobParallelFor でインデックスベースのジョブで回すことにしています。この際、クエリから取得するコンポーネントやエンティティの NativeArrayAllocator.Temp で取得してしまうとスコープ単位の寿命しかないため、Allocator.TempJob として確保します(解放は次の節で見ます)。

ハッシュマップを並列な Job で取り扱う際は、そのままでは次のようなエラーが出てしまいます。

InvalidOperationException: NeighborsHashMapBuildJob.HashMap is not declared [ReadOnly] in a IJobParallelFor job. The container does not support parallel writing. Please use a more suitable container

幾つかのネイティブコレクションでは、並列な書き込みを行うための専用のインターフェースが用意されており、それが ParallelWriter です。

docs.unity3d.com

取得するためには AsParallelWriter() をハッシュマップに対して呼びます。これにより並列操作によるハッシュマップ構築が可能になりました。

近傍探索とバッファへの格納

さて、このように構築したハッシュマップを使い、近傍探索し、それを DynamicBuffer へと詰める処理をしたいと思います。しかしながら、次のように DynamicBuffer 参照用のルックアップを IJobParallelFor にもたせるとエラーが出てしまいます。

public struct NeighborsDetectionJob : IJobParallelFor
{
    ...
    public BufferLookup<NeighborsEntityBufferElement> BufferLookUp;
    ...
}

InvalidOperationException: NeighborsDetectionJob.BufferLookUp is not declared [ReadOnly] in a IJobParallelFor job. The container does not support parallel writing. Please use a more suitable container type.

ここでは BufferLookUp は並列書き込みをサポートしていない点、また [ReadOnly] をつけていないことにより安全性が保証できない点がエラーとして報告されています。ちなみに [ReadOnly] をつけると、もちろん書き込みを行う際にエラーが報告されます。例えばコードは次のようになります。

[BurstCompile]
public struct NeighborsDetectionJob : IJobParallelFor
{
    ...
    [ReadOnly] public NativeArray<Entity> Entities;
    ...
    [ReadOnly] public BufferLookup<NeighborsEntityBufferElement> BufferLookUp;
    ...
    public void Execute(int index)
    {
        ...
        var entitySelf = Entities[index];
        ...
        var buf = BufferLookUp[entitySelf];
        buf.Clear();
        ...
        for (...)
        {
            ...
            buf.Add(...);
            ...
        }
    }
}

このようにな書き込み処理を試みてみます。しかしながら、Clear() するところや Add() するところで書き込み処理が入り、これらが次のようなエラーとなります。

The BufferLookup<Boids.Sample03.Runtime.NeighborsEntityBufferElement> has been declared as [ReadOnly] in the job, but you are writing to it.

そこで、もう一段階処理をかませることにします。具体的には、いったんこの近傍探索の結果を並列書き込みをサポートしたコンテナに格納し、それを別のジョブで DynamicBuffer へと書き込む、という方針を取ってみます。だいぶ長くなりますが、以下のような形になります。

[BurstCompile]
public struct NeighborsDetectionJob : IJobParallelFor
{
    // 一旦格納しておく情報
    // 各エンティティをキーとして隣接する個体を複数個格納
    // 後にこれを DynamicBuffer へと移す
    [WriteOnly] 
    public NativeParallelMultiHashMap<
        Entity, 
        NeighborsEntityBufferElement>.ParallelWriter Neighbors;

    // その他ジョブの処理に必要なシステムから渡す情報
    [ReadOnly] public NativeArray<Entity> Entities;
    [ReadOnly] public NativeArray<Fish> Fishes;
    [ReadOnly] public NativeArray<LocalTransform> LocalTransforms;
    [ReadOnly] public ComponentLookup<Parameter> ParamLookUp;
    [ReadOnly] public NativeParallelMultiHashMap<int, int> HashMap;
    [ReadOnly] public float CellSize;
    [ReadOnly] public NativeArray<int3> CellOffsets;
    
    public void Execute(int index)
    {
        // 近傍探索の処理はこれまでの解説とだいたい同じ
        var posSelf = LocalTransforms[index].Position;
        var cellSelf = (int3)(posSelf / CellSize);
        
        var fishSelf = Fishes[index];
        var fowardSelf = math.normalizesafe(fishSelf.Velocity);
        
        var param = ParamLookUp[fishSelf.ParamEntity];
        var neighborAngle = math.radians(param.NeighborAngle);
        var neighborDist = param.NeighborDistance;
        var prodThresh = math.cos(neighborAngle);
        
        var entitySelf = Entities[index];
        int neighborsCount = 0;
        bool isNeighborCountFull = false;
        
        for (int offsetIndex = 0; offsetIndex < CellOffsets.Length; ++offsetIndex)
        {
            var cell = cellSelf + CellOffsets[offsetIndex];
            var hashSelf = NeighborsDetectionUtil.GetHash(cell);
            if (!HashMap.TryGetFirstValue(hashSelf, out var j, out var it)) continue;
            
            do
            {
                var entityTarget = Entities[j];
                if (entitySelf == entityTarget) continue;
                
                var ltTarget = LocalTransforms[j];
                var posTarget = ltTarget.Position;
                var to = posTarget - posSelf;
                var dist = math.length(to);
                if (dist > neighborDist) continue;
        
                var dir = to / math.max(dist, 1e-3f);
                var prod = math.dot(dir, fowardSelf);
                if (prod < prodThresh) continue;
        
                // 近傍探索の結果見つかった個体は一時的に HashMap に入れておく
                var elem = new NeighborsEntityBufferElement() { Entity = entityTarget };
                Neighbors.Add(entitySelf, elem);
                
                // 密集している場合は数が多いので打ち切りする
                ++neighborsCount;
                isNeighborCountFull = neighborsCount >= FishConfig.NeighborsEntityBufferMaxSize;
                if (isNeighborCountFull) break;
            } while (HashMap.TryGetNextValue(out j, ref it));
            
            if (isNeighborCountFull) break;
        }
    }
}

[BurstCompile]
public partial struct NeighborsWriteJob : IJobEntity
{
    // NeighborsDetectionJob で集めたバッファを受け取り
    [ReadOnly] 
    public NativeParallelMultiHashMap<
        Entity, 
        NeighborsEntityBufferElement> Neighbors;
    
    // IJobEntity 経由で DynamicBuffer を受け取り
    public void Execute(
        Entity entity,
        ref DynamicBuffer<NeighborsEntityBufferElement> buffer)
    {
        // この DynamicBuffer へ DetectionJob の結果を移す
        buffer.Clear();
        if (!Neighbors.TryGetFirstValue(entity, out var elem, out var it)) return;
        do
        {
            buffer.Add(elem);
        } while (Neighbors.TryGetNextValue(out elem, ref it));
    }
}

[BurstCompile]
public partial struct NeighborsDetectionSystem : ISystem
{
    ...
    // DetectionJob の結果を一時的に格納するハッシュマップ
    NativeParallelMultiHashMap<Entity, NeighborsEntityBufferElement> _neighborMap;
    ...
    public void OnCreate(ref SystemState state) 
    {
        ...
        _neighborMap = new NativeParallelMultiHashMap<Entity, NeighborsEntityBufferElement>(100, Allocator.Persistent);
    }
    
    public void OnDestroy(ref SystemState state) 
    {
        ...
        if (_neighborMap.IsCreated) _neighborMap.Dispose();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        ... これまでは所属セルのハッシュマップのビルド

        // 一時格納バッファのサイズは各個体が見る最大個体数 * Entity の数
        _neighborMap.Clear();
        var maxBufferSize = n * FishConfig.NeighborsEntityBufferMaxSize;
        if (_neighborMap.Capacity < maxBufferSize) _neighborMap.Capacity = maxBufferSize;

        // 近傍探索ジョブの予約
        var detectionJob = new NeighborsDetectionJob()
        {
            Entities = entities,
            Fishes = fishes,
            LocalTransforms = localTransforms,
            ParamLookUp = _paramLookUp,
            HashMap = _hashMap,
            Neighbors = _neighborMap.AsParallelWriter(),
            CellSize = cellSize,
            CellOffsets = _cellOffsets,
            BufferLookUp = _neighborsLookUp,
        };
        state.Dependency = detectionJob.Schedule(n, 16, state.Dependency);
        
        // 書き込みジョブの予約
        var writeJob = new NeighborsWriteJob()
        {
            Neighbors = _neighborMap,
        };
        state.Dependency = writeJob.ScheduleParallel(_fishQuery, state.Dependency);
        
        ... (バッファを削除する処理)
    }
}

システム側でそれぞれのエンティティとその近くにいる複数のエンティティに対応する並列書き込み可能なハッシュマップ(NativeParallelMultiHashMap<Entity, NeighborsEntityBufferElement>)を確保しておきます。この際、ハッシュマップは並列処理の前にサイズを予め指定しておく必要があるので、近傍にいると判断する個体の最大数を決めておき、この数 ✕ 全エンティティの数をバッファの Capacity として確保しておきます。後は、このバッファに IJobParallelFor 継承の NeighborsDetectionJob の並列処理で、先程構築した所属セルのハッシュマップを参考に、確保したハッシュマップに近傍情報を構築していきます。

そしてこのように構築した単一のハッシュマップを、各エンティティに紐づいた DynamicBuffer へと分配していきます。これを行うジョブが NeighborsWriteJob です。IJobEntity 継承のジョブでは Entity や各コンポーネントDynamicBuffer が受け取れるのでこの中で処理をしていく形です。得られた情報を移していくだけの処理で処理もシンプルですし、実際処理も軽いです。

なお、今回はこれまでの仕組みを変換するために 2 つのジョブに分割しましたが、所属セルのハッシュマップのキーが Entity となっていれば、単一の IJobEntity 継承のジョブで両方の処理を行えると思われます。

一時バッファの削除

最後に、システムで確保した一時バッファの解放です。TempJob で確保したものは手動で Dispose() を呼び、解放する必要があります。ただシステムではジョブを投げる(スケジュールする)だけなので、実際は解放は OnUpdate() の中では行えません。そこで、確保した一時バッファを開放するためのジョブを用意し、state.Dependency の依存関係を用いて、全てのジョブが終わった最後に解放する、ということをする必要があります。これは以下のようなコードになります。

[BurstCompile]
public struct NeighborsCleanUpJob : IJob
{
    // 空のジョブでアトリビュートによる解放を行う
    [DeallocateOnJobCompletion][ReadOnly] public NativeArray<Entity> Entities;
    [DeallocateOnJobCompletion][ReadOnly] public NativeArray<Fish> Fishes;
    [DeallocateOnJobCompletion][ReadOnly] public NativeArray<LocalTransform> LocalTransforms;
    public void Execute() {}
}

...
public partial struct NeighborsDetectionSystem : ISystem
{
    ...
    public void OnUpdate(ref SystemState state)
    {
        ...
        // 様々な一時バッファの確保
        var entities = _fishQuery.ToEntityArray(Allocator.TempJob);
        var fishes = _fishQuery.ToComponentDataArray<Fish>(Allocator.TempJob);
        var localTransforms = _fishQuery.ToComponentDataArray<LocalTransform>(Allocator.TempJob);
        ...
        // いろいろなジョブの処理
        state.Dependency = ...Schedule(state.Dependency);
        ...
        var cleanUpJob = new NeighborsCleanUpJob()
        {
            Entities = entities,
            Fishes = fishes,
            LocalTransforms = localTransforms,
        };
        state.Dependency = cleanUpJob.Schedule(state.Dependency);
    }
}

[DeallocateOnJobCompletion] はその名の通りジョブが完了すると解放してくれるアトリビュートです。これを実行内容は空のジョブとして実行してあげれば TempJob 確保のバッファの解放を行ってくれます。

パフォーマンス

これで全てのジョブがワーカスレッドで並列に動作するようになりました。

3000 匹出してもトータルで 1.5 msec 程で、メインスレッドには影響がなくなりました。[UpdateBefore()]state.Dependency で設計した各ジョブの前後関係もしっかりと動作しています。

これで 20000 匹くらい動かせるようになりました。

おわりに

データ指向はすべてのユースケースで向いているものではないと思いますが、ルールに則って記述することで大幅なパフォーマンス改善の恩恵を受けられるところではとても強力だと思うので、このように以前より簡単にセットアップできるようになったのは嬉しいですね。

色々と便利にはなりましたが、それでも内部的に理解しないとうまく書けないところや、いろいろな新しい概念を前提とした仕組みの理解の積み重ねが必要だと思いますので、やりたいことを色々出来るようになるにはかなり勉強が必要な印象を覚えました。