見出し画像

Unity ハイパフォーマンスゲーム制作入門 Vol.2-2

はじめに

今回はいよいよBoids(群れのアルゴリズム)を実装していきます。
前回の記事はこちら👇

Boids(群れのアルゴリズム)を実装

Boidアルゴリズム(分離・整列・結合)- 準備 -

仲間に近づくとか、仲間と同じ方向に行きたがるといったことには、近隣の個体の位置や速度にアクセスしなければなりません。DynamicBuffer という仕組みを利用します。

まず、近隣の個体をリストで格納できる場所を確保していきます。新たにスクリプト NeighborsEntityBuffer.cs を作成します。

using Unity.Entities;
 
[InternalBufferCapacity(8)]
public struct NeighborsEntityBuffer : IBufferElementData
{
    public Entity Value;
}

ECS では可変長配列の仕組みとして、IBufferElementData という型を提供しています。これもComponentの一種です。

InternalBufferCapacityは配列の長さを設定します。多くしすぎるとチャンクを圧迫して格納できるエンティティの数が少なくなるらしいですが、オーバーしても大丈夫なので適当な値を選びましょう(その場合はヒープメモリとして処理されるらしいです)

そしてこのバッファをEntityに追加する必要があるので、Bootstrap.cs の Start() に追記しましょう。

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

public class Bootstrap : MonoBehaviour
{
    public GameObject Prefab;
    public static Bootstrap Instance { get; private set; }
    public static Param Param { get { return Instance.param; } }
    [SerializeField] int boidCount = 100;
    [SerializeField] float3 boidScale = new float3(0.1f, 0.1f, 0.3f);
    [SerializeField] Param param;
    BlobAssetStore blobAssetStore;

    void Awake()
    {
        Instance = this;
        blobAssetStore = new BlobAssetStore();
    }

    void Start()
    {
        var settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, blobAssetStore);
        var prefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(Prefab, settings);
        var entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        var random = new Unity.Mathematics.Random(853);

        for (int i = 0; i < boidCount; ++i)
        {
            var instance = entityManager.Instantiate(prefab);
            var position = transform.TransformPoint(random.NextFloat3(1f));

            entityManager.SetComponentData(instance, new Translation { Value = position });
            entityManager.SetComponentData(instance, new Rotation { Value = quaternion.identity });
            entityManager.SetComponentData(instance, new NonUniformScale { Value = new float3(boidScale.x, boidScale.y, boidScale.z) });
            entityManager.SetComponentData(instance, new Velocity { Value = random.NextFloat3Direction() * param.initSpeed });
            entityManager.SetComponentData(instance, new Acceleration { Value = float3.zero });

            // Dynamic Buffer の追加
            entityManager.AddBuffer<NeighborsEntityBuffer>(instance);
        }
    }

    void OnDrawGizmos()
    {
        if (!param) return;
        Gizmos.color = Color.green;
        Gizmos.DrawWireCube(Vector3.zero, Vector3.one * param.wallScale);
    }

    void OnDestroy()
    {
        blobAssetStore.Dispose();
    }
}

これで追加されたのでSystemを作成していきましょう。今回は単純にするため、近隣の個体を得るのに、全ての個体のリストを用いていくことにします。これで計算量が$O(n^2)$となってしまします。(他にも色々な方法があるらしいですが…)

ここで、`EntityQuery`というものを使い、ここでは`Translation、Velocity、NeighborsEntityBuffer`を全て持つEntityを検索してきています。つまり魚たちなら全て持っているComponentですので、全ての魚のEntityを取得できます。

public class BoidsSimulationSystem : SystemBase
{
    EntityQuery wallQuery;
    EntityQuery boidsQuery;

    protected override void OnCreate()
    {
        wallQuery = GetEntityQuery(typeof(Translation), typeof(Acceleration));
        boidsQuery = GetEntityQuery(typeof(Translation), typeof(Velocity), typeof(NeighborsEntityBuffer));
    }
    ...

そして次にジョブを作成します。次の内容を追記します。

[BurstCompile]
    public struct NeighborsDetectionJob : IJobChunk
    {
        public BufferTypeHandle<NeighborsEntityBuffer> NeighborsEntityBufferTypeHandle;
        [ReadOnly] public ComponentTypeHandle<Velocity> VelocityTypeHandle;
        [ReadOnly] public ComponentTypeHandle<Translation> TranslationTypeHandle;
        [ReadOnly] public EntityTypeHandle EntityType;
        [ReadOnly] public float prodThresh;
        [ReadOnly] public float distThresh;
        [ReadOnly] public ComponentDataFromEntity<Translation> positionFromEntity;
        [DeallocateOnJobCompletion][ReadOnly] public NativeArray<Entity> entities;

        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var chunkBuffer = chunk.GetBufferAccessor<NeighborsEntityBuffer>(NeighborsEntityBufferTypeHandle);
            var chunkVel = chunk.GetNativeArray(VelocityTypeHandle);
            var chunkTrans = chunk.GetNativeArray(TranslationTypeHandle);
            var entitiy = chunk.GetNativeArray(EntityType);
            for (var i = 0; i < chunk.Count; i++)
            {
                chunkBuffer[i].Clear();

                float3 pos0 = chunkTrans[i].Value;
                float3 fwd0 = math.normalize(chunkVel[i].Value);

                for (int j = 0; j < entities.Length; ++j)
                {
                    var neighbor = entities[j];
                    if (neighbor == entitiy[i]) continue;

                    float3 pos1 = positionFromEntity[neighbor].Value;
                    var to = pos1 - pos0;
                    var dist = math.length(to);

                    if (dist < distThresh)
                    {
                        var dir = math.normalize(to);
                        var prod = Unity.Mathematics.math.dot(dir, fwd0);
                        if (prod > prodThresh)
                        {
                            chunkBuffer[i].Add(new NeighborsEntityBuffer { Value = neighbor });
                        }
                    }
                }
            }
        }
    }

クエリの結果は NativeArray という配列になりますので定義しておきます。NativeArrayとはNativeContainerの一種です。[DeallocateOnJobCompletion] を付けておくことで自動的にメモリを開放してくれますので付けておきましょう。

また、BufferTypeHandle<NeighborsEntityBuffer> で先程追加したバッファにアクセスします。

OnUpdate はこのようにして完了です。解説としては、boidsQuery.ToEntityArray(Allocator.TempJob) で先程のクエリの結果を取得できます。(Allocatorに関しての解説は下)

    protected override void OnUpdate()
    {
        var neighbors = new NeighborsDetectionJob
        {
            NeighborsEntityBufferTypeHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(),
            VelocityTypeHandle = GetComponentTypeHandle<Velocity>(true),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            EntityType = GetEntityTypeHandle(),
            prodThresh = math.cos(math.radians(Bootstrap.Param.neighborFov)),
            distThresh = Bootstrap.Param.neighborDistance,
            positionFromEntity = GetComponentDataFromEntity<Translation>(true),
            entities = boidsQuery.ToEntityArray(Allocator.TempJob),
        };
        this.Dependency = neighbors.ScheduleParallel(boidsQuery, this.Dependency);

        var wall = new WallJob
        {
            AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            scale = Bootstrap.Param.wallScale * 0.5f,
            thresh = Bootstrap.Param.wallDistance,
            weight = Bootstrap.Param.wallWeight,
        };
        this.Dependency = wall.ScheduleParallel(wallQuery, this.Dependency);

        var dt = Time.DeltaTime;
        var minSpeed = Bootstrap.Param.minSpeed;
        var maxSpeed = Bootstrap.Param.maxSpeed;

        Entities
            .WithName("MoveJob")
            .ForEach((ref Translation translation, ref Rotation rot, ref Velocity velocity, ref Acceleration accel) =>
            {
                var v = velocity.Value;
                v += accel.Value * dt;
                var dir = math.normalize(v);
                var speed = math.length(v);
                v = math.clamp(speed, minSpeed, maxSpeed) * dir;

                translation = new Translation { Value = translation.Value + v * dt };
                rot = new Rotation { Value = quaternion.LookRotationSafe(dir, new float3(0, 1, 0)) };
                velocity = new Velocity { Value = v };
                accel = new Acceleration { Value = float3.zero };
            }).ScheduleParallel();
    }

NativeContainer

NativeContainerを利用すればメインスレッドとデータを共有可能です、しかしアンマネージドでGCがないので注意が必要です。

Allocator(メモリの割当タイプ)を自分で決める必要があります。

  • Temp 開放が速いらしい。1フレームで開放

  • TempJob まあまあ開放が早い。4フレームで開放、`[DeallocateOnJobCompletion]`でジョブが終われば自動的に開放してくれる

  • Persistent 開放が遅い・永続的な割当。要・Destroy等で開放

使い終わったら開放しないとメモリリークを起した警告が出るので気をつけましょう。また、途中で要素を増やせません。

ジョブが同じ NativeArray に書き込みを行なうと例外となるため、この場合は依存関係を設定必要があります。書き込みがないのであれば[ReadOnly] を設定することで複数でもアクセス可能です。

種類

  • NativeList - サイズ変更可能な NativeArray

  • NativeHashMap - キーと値のペア

  • NativeMultiHashMap - 各キーに複数の値

  • NativeQueue - 先入れ先出し (FIFO) キュー

Boidアルゴリズム(分離・整列・結合)- 実装 -

さて、近隣の個体のリストは無事取得できたので一気に実装していきましょう。

分離

近隣の個体から離れる方向に力を加え、個体数で平均します。かかる力は一定とします。

    [BurstCompile]
    public struct SeparationJob : IJobChunk
    {
        public ComponentTypeHandle<Acceleration> AccelerationTypeHandle;
        [ReadOnly] public BufferTypeHandle<NeighborsEntityBuffer> NeighborsEntityBufferTypeHandle;
        [ReadOnly] public ComponentTypeHandle<Translation> TranslationTypeHandle;
        [ReadOnly] public float separationWeight;
        [ReadOnly] public ComponentDataFromEntity<Translation> positionFromEntity;

        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var chunkBuffer = chunk.GetBufferAccessor<NeighborsEntityBuffer>(NeighborsEntityBufferTypeHandle);
            var chunkAccel = chunk.GetNativeArray(AccelerationTypeHandle);
            var chunkTrans = chunk.GetNativeArray(TranslationTypeHandle);
            for (var i = 0; i < chunk.Count; i++)
            {
                var neighbors = chunkBuffer[i];
                if (neighbors.Length == 0) return;

                var pos0 = chunkTrans[i].Value;

                var force = float3.zero;
                for (int j = 0; j < neighbors.Length; ++j)
                {
                    var pos1 = positionFromEntity[neighbors[j].Value].Value;
                    force += math.normalize(pos0 - pos1);
                }
                force /= neighbors.Length;

                var dAccel = force * separationWeight;
                chunkAccel[i] = new Acceleration { Value = chunkAccel[i].Value + dAccel };
            }
        }
    }

整列

整列は近隣の個体の速度平均を求め、それに近づくように accel にフィードバックをします。

    [BurstCompile]
    public struct AlignmentJob : IJobChunk
    {
        public ComponentTypeHandle<Acceleration> AccelerationTypeHandle;
        [ReadOnly] public BufferTypeHandle<NeighborsEntityBuffer> NeighborsEntityBufferTypeHandle;
        [ReadOnly] public ComponentTypeHandle<Velocity> VelocityTypeHandle;
        [ReadOnly] public float alignmentWeight;
        [ReadOnly] public ComponentDataFromEntity<Velocity> velocityFromEntity;

        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var chunkBuffer = chunk.GetBufferAccessor<NeighborsEntityBuffer>(NeighborsEntityBufferTypeHandle);
            var chunkAccel = chunk.GetNativeArray(AccelerationTypeHandle);
            var chunkVel = chunk.GetNativeArray(VelocityTypeHandle);
            for (var i = 0; i < chunk.Count; i++)
            {
                var neighbors = chunkBuffer[i];
                if (neighbors.Length == 0) return;

                var averageVelocity = float3.zero;
                for (int j = 0; j < neighbors.Length; ++j)
                {
                    averageVelocity += velocityFromEntity[neighbors[j].Value].Value;
                }
                averageVelocity /= neighbors.Length;

                var dAccel = (averageVelocity - chunkVel[i].Value) * alignmentWeight;
                chunkAccel[i] = new Acceleration { Value = chunkAccel[i].Value + dAccel };
            }
        }
    }

結合

最後の結合は近隣の個体の平均的な中心方向へ accel を増やすように更新します。

    [BurstCompile]
    public struct CohesionJob : IJobForEachWithEntity<Translation, Acceleration>
    {
        [ReadOnly] public float cohesionWeight;
        [ReadOnly] public BufferFromEntity<NeighborsEntityBuffer> neighborsFromEntity;
        [ReadOnly] public ComponentDataFromEntity<Translation> positionFromEntity;
 
        public void Execute(Entity entity, int index, [ReadOnly] ref Translation pos, ref Acceleration accel)
        {
            var neighbors = neighborsFromEntity[entity].Reinterpret<Entity>();
            if (neighbors.Length == 0) return;
 
            var averagePos = float3.zero;
            for (int i = 0; i < neighbors.Length; ++i)
            {
                averagePos += positionFromEntity[neighbors[i]].Value;
            }
            averagePos /= neighbors.Length;
 
            var dAccel = (averagePos - pos.Value) * cohesionWeight;
            accel = new Acceleration { Value = accel.Value + dAccel };
        }
    }

`OnUpdate`はこのような感じ

protected override void OnUpdate()
    {
        var neighbors = new NeighborsDetectionJob
        {
            NeighborsEntityBufferTypeHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(),
            VelocityTypeHandle = GetComponentTypeHandle<Velocity>(true),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            EntityType = GetEntityTypeHandle(),
            prodThresh = math.cos(math.radians(Bootstrap.Param.neighborFov)),
            distThresh = Bootstrap.Param.neighborDistance,
            positionFromEntity = GetComponentDataFromEntity<Translation>(true),
            entities = boidsQuery.ToEntityArray(Allocator.TempJob),
        };
        this.Dependency = neighbors.ScheduleParallel(boidsQuery, this.Dependency);

        var wall = new WallJob
        {
            AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            scale = Bootstrap.Param.wallScale * 0.5f,
            thresh = Bootstrap.Param.wallDistance,
            weight = Bootstrap.Param.wallWeight,
        };
        this.Dependency = wall.ScheduleParallel(wallQuery, this.Dependency);

        var separation = new SeparationJob
        {
            AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
            NeighborsEntityBufferTypeHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(true),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            separationWeight = Bootstrap.Param.separationWeight,
            positionFromEntity = GetComponentDataFromEntity<Translation>(true),
        };
        this.Dependency = separation.ScheduleParallel(wallQuery, this.Dependency);

        var alignment = new AlignmentJob
        {
            AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
            NeighborsEntityBufferTypeHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(true),
            VelocityTypeHandle = GetComponentTypeHandle<Velocity>(true),
            alignmentWeight = Bootstrap.Param.alignmentWeight,
            velocityFromEntity = GetComponentDataFromEntity<Velocity>(true),
        };
        this.Dependency = alignment.ScheduleParallel(wallQuery, this.Dependency);

        var cohesion = new CohesionJob
        {
            AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
            NeighborsEntityBufferTypeHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(true),
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
            cohesionWeight = Bootstrap.Param.cohesionWeight,
            positionFromEntity = GetComponentDataFromEntity<Translation>(true),
        };
        this.Dependency = cohesion.ScheduleParallel(wallQuery, this.Dependency);

        var dt = Time.DeltaTime;
        var minSpeed = Bootstrap.Param.minSpeed;
        var maxSpeed = Bootstrap.Param.maxSpeed;

        Entities
            .WithName("MoveJob")
            .ForEach((ref Translation translation, ref Rotation rot, ref Velocity velocity, ref Acceleration accel) =>
            {
                var v = velocity.Value;
                v += accel.Value * dt;
                var dir = math.normalize(v);
                var speed = math.length(v);
                v = math.clamp(speed, minSpeed, maxSpeed) * dir;

                translation = new Translation { Value = translation.Value + v * dt };
                rot = new Rotation { Value = quaternion.LookRotationSafe(dir, new float3(0, 1, 0)) };
                velocity = new Velocity { Value = v };
                accel = new Acceleration { Value = float3.zero };
            }).ScheduleParallel();
    }

実行してみると非常に魚っぽいです。

実行サンプル

MonobehaviourとECSの比較

パフォーマンスも良いこと分かります、100匹のシュミレーションではMonobehaviour(並列化処理なし)とECS(並列化あり)では、36ms/フレームから8ms/フレームまで短縮できました。

Monobehaviour
ECS

ちなみにプロファイラーでjobの実行状況をモニタリングすることができます。ところどころでidleの時間が挟まっており、まだまだ最適化のしがいあることが分かりますね。

まとめ

メリット

  • CPUがボトルネックとなっているプロジェクトに関しては劇的にパフォーマンスが改善される

  • メモリ効率が良くなる

デメリット

  • 結局ゲームはGPUがボトルネックになってくることが多いのでそこまで多大な期待は持てないケースもある

  • Entity間の情報のやり取りが面倒くさい

  • まだpreview版なので対応してない機能も多い

  • オブジェクト指向で組むことに慣れている現代プログラマが新しく学習するにはコストが高く理解が難しい

ただ、個人的にはシステムを使い回せるのが利点?データとシステムの組み合わせて汎用的に使えて、しかも高速で動き、マルチタスクなどもそのまま対応できるので使いこなせば利点が大きいのではないかと感じた。
これからネットワークは5Gなどで高速化していくと見られているので、ネットワークの最適化よりもオブジェクト数&メモリ転送速度との戦いになると思われ、その面では非常に有用性のあるプロダクトではないかと感じた。

次回はキャラクタの実装とNetCodeでオンライン化をしていきます。あと、MultiPlayerの解説などもしていければと思います。どうぞよろしくお願いします!

ここまで実装した BoidsSimulationSystem はこちら👇

Vol.3はこちら👇
(工事中・・・)

この記事が気に入ったらサポートをしてみませんか?