12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Unity] 現在(Entities 0.50)のECS環境でシンプルな Boids Simulation を書く

Last updated at Posted at 2021-04-15

ずいぶん前に Unity ECS の勉強をしたときにhecomiさんのBoidsシミュレーションを題材に使わせていただいたのですが、UnityのDOTS環境がどんどん変わっていって現行環境に合わせて大規模なリファクタリングが必要になったので、その際の情報を備忘録としてまとめます。

2022/4/16 追記

現在の実装でだいぶ構造が変わったので設計のお話として新しく記事を書きました。
[Unity] Entities 0.50 環境で Boids Simulation を再設計した話

環境 (2022/3/24 時点)

  • Unity 2020.3.31f
    • Hybrid Renderer 0.50-preview.24
    • Entities 0.50.0-preview.24

当方の実装結果

GitHub

現在のECSの書き方

▽Prefab Entity の準備

public class Bootstrap : MonoBehaviour
{
    public static Bootstrap Instance { get; private set; }
    public static Param Param {  get { return Instance.param; } }

    // Inspector で Prefab を設定しておく
    [SerializeField] GameObject prefab_obj;

    // Prefab Entity 保持用の変数
    private Entity prefab_entity;

    // Boid の全体数の管理用
    private int n_boid;

    void Awake()
    {
        Instance = this;
    }

    public void Start()
    {
        var world = World.DefaultGameObjectInjectionWorld;
        var manager = world.EntityManager;

        // convert prefab_obj -> prefab_entity
        prefab_entity = GameObjectConversionUtility.ConvertGameObjectHierarchy(
            prefab_obj,
            GameObjectConversionSettings.FromWorld(world, null)
        );

        // add user defined component
        manager.AddComponent<BoidPrefabType>(prefab_entity);  // Prefabであることを示す空のComponent
        manager.AddComponent<Scale>(prefab_entity);
        manager.AddComponent<Velocity>(prefab_entity);
        manager.AddComponent<Acceleration>(prefab_entity);
        manager.AddComponent<NeighborsEntityBuffer>(prefab_entity);

        n_boid = 0;
    }
}

たびたび Hybrid Renderer が使用する Component が追加されているようで、自力で ArchType を組み立てるやり方だと、適当にアップグレードしたときに描画されなくなります。
また、 Hybrid Renderer も設定(V2を使うか否か)によって動かない場合があり、原因の特定に手間取りました。

公式サンプルもそうですが、Hybrid ECS用のAPIを活用してGameObjectから必要な ArchType を構築すれば上記の仕様変更にも自動的に追従できることになり、ほんの少しリスクが減ります。

PrefabなのでSystemの操作対象に紛れ込まないよう、空のコンポーネント BoidPrefabType をつけています。

▽Entityの生成、破棄


public class Bootstrap : MonoBehaviour
{
    public static Bootstrap Instance { get; private set; }
    public static Param Param {  get { return Instance.param; } }

    [SerializeField] public float boidScale = 1.0f;
    [SerializeField] public Param param;

    [SerializeField] GameObject prefab_obj;
    private Entity prefab_entity;

    private int n_boid;
    private Unity.Mathematics.Random random;

    /* 略 */

    void UpdateBoidNum(int n_tgt)
    {
        if (n_tgt < 0) return;

        var manager = World.DefaultGameObjectInjectionWorld.EntityManager;

        int n_diff = n_tgt - n_boid;

        if (n_diff > 0)
        {
            Debug.Log($"update boids num: add {n_diff} boids.");

            var scale = this.boidScale;
            var initSpeed = this.param.initSpeed;

            for (int i = 0; i < n_diff; i++)
            {
                var entity = manager.Instantiate(prefab_entity);

                // BoidPrefabType を BoidType に付け替え
                manager.RemoveComponent<BoidPrefabType>(entity);
                manager.AddComponent<BoidType>(entity);

                // 適当に値を設定
                manager.SetComponentData(entity, new Translation { Value = this.random.NextFloat3(1f) });
                manager.SetComponentData(entity, new Rotation { Value = quaternion.identity });
                manager.SetComponentData(entity, new Scale { Value = scale });
                manager.SetComponentData(entity, new Velocity { Value = this.random.NextFloat3Direction() * initSpeed });
                manager.SetComponentData(entity, new Acceleration { Value = float3.zero });
            }
        }
        if (n_diff < 0)
        {
            int n_delete = -n_diff;

            Debug.Log($"update boids num: remove {n_delete} boids.");

            var entity_query = manager.CreateEntityQuery(new EntityQueryDesc
            {
                All = new[]
                {
                    ComponentType.ReadOnly<BoidType>()
                }
            });
            var entities = entity_query.ToEntityArray(Allocator.Temp);
            manager.DestroyEntity(new NativeSlice<Entity>(entities, n_tgt));

            entities.Dispose();
        }

        n_boid = n_tgt;
    }
}

Entityの生成については元の実装と大して違いはありません。
破棄については、今回は全体の数だけを考えて増減させるため、 BoidType に該当するもの全体の情報にアクセスする必要があり、そのために EntityQuery の生成 -> 対象となる NativeArray<Entity> の取得という流れで処理しています。

このへんは、状況に合わせて公式サンプルSpawnFromMonoBehaviour , SpawnFromEntity, SpawnAndRemove あたりを参考にするのがよいでしょう。

▽Entities.ForEach によるジョブ実装例

まず、entity 自身の情報が主な場合、今回の例でいえば MoveSystem の実装は下記のとおり。

[UpdateAfter(typeof(BoidSystemGroup))]
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // パラメータをローカル変数として置いておき、ラムダ式にキャプチャさせる
        float dt = Time.DeltaTime;
        float minSpeed = Bootstrap.Param.maxSpeed;
        float maxSpeed = Bootstrap.Param.maxSpeed;

        // ジョブの定義
        Dependency = Entities.
            WithName("MoveJob").
            WithAll<BoidType>().
            WithBurst().
            ForEach((ref Translation pos, ref Rotation rotate, ref Velocity vel, ref Acceleration accel) =>
        {
            vel.Value += accel.Value * dt;

            var dir = math.normalize(vel.Value);
            var speed = math.length(vel.Value);
            vel.Value = math.clamp(speed, minSpeed, maxSpeed) * dir;

            pos.Value += vel.Value * dt;

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

            rotate.Value = rot;
            accel.Value = float3.zero;
        }).ScheduleParallel(Dependency);
    }
}

floatint など単純な struct は適当にOnUpdate() 関数のローカル変数においておけば、同じスコープで書くラムダ式でキャプチャしてくれるので簡単に使えます。

ラムダ式の引数は、C#の仕様かEntitiesの仕様かはわかりませんが、
値渡し(実質 Entity 型のみ)、ref 渡し、in 渡し
の順番で並べる必要があります。また、Entity 型以外を値渡しすると、処理中で書き換える引数に ref を、書き換えない引数には in をつけるように警告されます。

EntitiesForEach() の間にある With** 関数でエンティティクエリに属性を追加できます。
主なものはReferenceによれば下表のとおり。

エンティティクエリ 効果
WithName(string) Jobの名前を指定。プロファイラやEntityDebuggerでこの名前が使われる
WithOutBurst Burst を無効化。デバッグ時やBurstに対応できない処理に使う (デフォルトではBurstが有効)
WithBurst(FloatMode, FloatPrecision, bool) Burst Compiler に浮動小数点に関する設定とコンパイルのタイミングを指定する
WithAll<T1, T2, T3> 処理対象のEntityはすべてのComponentTypeを持つ必要がある
WithAny<T1, T2, T3> 処理対象のEntityはいずれかのComponentTypeを持つ必要がある
WithNone<T1, T2, T3> 処理対象のEntityはいずれのComponentTypeも含まない必要がある
WithChangeFilter<T1, T2> 指定されたComponentが前回のSystem実行時に変更されたものだけを処理する
WithSharedComponentFilter(ISharedComponentData) ISharedComponentDataが特定の値のEntityだけを処理する
WithEntityQueryOptions(EntityQueryOptions) EntityQueryOptionsに合致するものだけを処理する
WithStoreEntityQueryInField(EntityQuery) SystemBase.EntityQuery に ForEach実行時に作成された EntityQuery を保存する。 対象Entityの全体数など、EntityQueryのインターフェイスを使いたい場合に用いる

上表中の <T1, T2, T3><T> または <T1, T2> に型パラメータを省略できます。(最大数は表中のとおり)

次に、自身以外の Entity の情報も必要な処理の例として、 CohesionSystem の実装は下記のとおり。

[UpdateInGroup(typeof(BoidSystemGroup))]
public partial class CohesionSystem : SystemBase
{
    private struct CohesionDataContainer
    {
        [ReadOnly] public float alignmentWeight;
        [ReadOnly] public ComponentDataFromEntity<Translation> positionFromGrovalEntity;
    }

    protected override void OnUpdate()
    {
        var common_data = new CohesionDataContainer
        {
            alignmentWeight = Bootstrap.Param.alignmentWeight,
            positionFromGrovalEntity = GetComponentDataFromEntity<Translation>(true),
        };

        Dependency = Entities.
            WithName("CohesionJob").
            WithAll<BoidType>().
            WithBurst().
            ForEach(
        (ref Acceleration accel, in Translation pos, in DynamicBuffer<NeighborsEntityBuffer> neighbors) =>
        {
            if (neighbors.Length == 0) return;

            float3 pos_avg = float3.zero;
            float3 pos0 = pos.Value;
            float3 acc = accel.Value;

            for(int i=0; i<neighbors.Length; i++)
            {
                pos_avg += common_data.positionFromGrovalEntity[neighbors[i].entity].Value;
            }
            pos_avg /= neighbors.Length;

            acc += (pos_avg - pos0) * common_data.alignmentWeight;

            accel = new Acceleration { Value = acc };
        }
        ).ScheduleParallel(Dependency);
    }
}

ここで、 Entity 経由で他の Boid の Translation を参照するため、全EntityのTranslationへのアクセサを SystemBase.GetComponentDataFromEntity<Translation>(isReadOnly) で取得しています。このSystemでは Translation は書き換えないので isReadOnly = true です。この場合代入先は [ReadOnly] アトリビュートが付加されている必要があり、そのままでは関数内の一時変数には代入できません。
そのため、このデータの代入先として struct CohesionDataContainer の中に [ReadOnly] メンバを用意し、 CohesionDataContainer を一時変数として作ることでラムダ式にキャプチャさせることができます。

▼IJobEntityBatch の例 (非推奨)

IJobEntityBatch を使って CohesionSystem を書くと下記のようになります。

[UpdateInGroup(typeof(BoidSystemGroup))]
public partial class CohesionSystem : SystemBase
{
    // チャンク検索のため EntityQuery が必須 (IJobEntityBatch.Schedule()の引数)
    private EntityQuery query;

    protected override void OnCreate()
    {
        base.OnCreate();

        query = GetEntityQuery(new EntityQueryDesc
        {
            All = new[] {
                ComponentType.ReadOnly<BoidType>(),
                ComponentType.ReadOnly<Translation>(),
                ComponentType.ReadOnly<NeighborsEntityBuffer>(),
                ComponentType.ReadWrite<Acceleration>()
            }
        });
    }

    [BurstCompile]
    public struct CohesionJob : IJobEntityBatch
    {
        [ReadOnly] public float alignmentWeight;
        [ReadOnly] public ComponentDataFromEntity<Translation> positionFromGrovalEntity;

        // アクセサのハンドルを受け取るためのメンバ。 var で受け取れないのでひたすら面倒
        [ReadOnly] public ComponentTypeHandle<Translation> translationHandle;
        [ReadOnly] public BufferTypeHandle<NeighborsEntityBuffer> neighborsBufferHandle;
        public ComponentTypeHandle<Acceleration> accelHandle;

        public void Execute(ArchetypeChunk batchInChunk, int batchIndex)
        {
            var pos_array = batchInChunk.GetNativeArray(translationHandle);
            var neighbors_list = batchInChunk.GetBufferAccessor(neighborsBufferHandle);
            var acc_array = batchInChunk.GetNativeArray(accelHandle);

            // チャンク内のイテレーションは自分で書く。
            // 1段ネストが増える分手間だしバグ混入の危険も増える
            for(int i=0; i<batchInChunk.Count; i++)
            {
                var neighbors = neighbors_list[i];
                if (neighbors.Length == 0) continue;

                float3 pos_avg = float3.zero;
                float3 pos0 = pos_array[i].Value;
                float3 acc = acc_array[i].Value;

                for (int j = 0; j < neighbors.Length; j++)
                {
                    pos_avg += positionFromGrovalEntity[neighbors[j].entity].Value;
                }
                pos_avg /= neighbors.Length;

                acc += (pos_avg - pos0) * alignmentWeight;
                acc_array[i] = new Acceleration { Value = acc };
            }

            // ComponentType のアクセサが NativeArray<> を返すので Dispose する必要がある
            pos_array.Dispose();
            acc_array.Dispose();
        }
    }

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;
        var job = new CohesionJob
        {
            alignmentWeight = param.alignmentWeight,
            positionFromGrovalEntity = GetComponentDataFromEntity<Translation>(true),

            // 必要な ComponentType, BufferType の Handle をいちいち渡す必要がある
            translationHandle = GetComponentTypeHandle<Translation>(),
            neighborsBufferHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(),
            accelHandle = GetComponentTypeHandle<Acceleration>()
        };
        // ここで渡す EntityQuery も自分で組み立てる必要がある
        Dependency = job.ScheduleParallel(query, 1, Dependency);
    }
}

上記は Entities.ForEach の例と同じ処理ですが、書かなければならないコードの量と複雑さが圧倒的です。
そのため、公式でも
「どうしてもIJobEntityBatchを使う必要がない限りEntities.ForEachを使おう」
と言っています。

参考記事

Original implementation:
Unity で Boids シミュレーションを作成して Entity Component System (ECS) を学んでみた

Qiita:
ECSの簡単な実装と変更点について
既存のUnityプロジェクトをUniversalRenderPipelineに移行する -サードインパクト修復-

Official:
Entities
Hybrid Renderer
EntityComponentSystemSamples

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?