はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】Unity公式のDOTS(Data-Oriented Technology Stack)解説動画を見ながら簡単なゲームを作る

はじめに

今回はこちらのDOTS(Data-Oriented Technology Stack)の公式解説動画をやっていきたいと思います。
www.youtube.com

このブログ記事では上の動画のまとめ的な感じにしたいと考えてますが、時間がある方は素直に動画を見ていただいた方がいいかもしれません。

あくまで備忘録的な意味合いなのであしからず。

f:id:hanaaaaaachiru:20211228234517g:plain
今回作るゲーム

またチュートリアルのようにボールが一つだと寂しいので、色々と改造してます。

環境

Unity2020.3.18f1

導入

DOTSを利用するにあたって以下のパッケージを導入します。

これらをインストールするために、プロジェクト名 -> Packages -> manifest.jsonを開き、以下を追加してください。

"com.unity.burst": "1.2.0-preview.11",
"com.unity.entities": "0.3.0-preview.4",
"com.unity.physics": "0.2.4-preview",
"com.unity.rendering.hybrid": "0.3.0-preview.4"
f:id:hanaaaaaachiru:20211228141526p:plain
manifest.jsonに追加で記述する

現在(2021/12/28)から見ると少し古いパッケージですが、サンプルに合わせていきます。

その前に

チュートリアルを見ながら作成していきますが、自作の箇所が大いにあります。

まるっきり同じコードにしたい方は以下のリポジトリを見てみてください。
github.com

またDOTSの説明とかはしないので、技術的な説明を見たい方は以下のリンクが参考になるかもしれません。
たのしいDOTS 〜初級から上級まで〜 | Unity Learning Materials
【Unity】Unity 2018のEntity Component System(通称ECS)について(1) - テラシュールブログ
【Unity】 ECS まとめ(前編) - エフアンダーバー

ゲームオブジェクトを配置する

壁を作成する

まずはを配置して、エンティティに変換してみます。このようにGameObjectEntityに変換する手法のことをHybrid ECSと呼びます。
【Unity】GameObjectもECSも使いたい Hybrid ECSについて - テラシュールブログ

その手順を是非ここで覚えてみてください。

Hierarchyより3DObject -> Cubeを作成し、以下のようなコンポーネントをアタッチ・設定してください。

f:id:hanaaaaaachiru:20211228154635p:plain
Wallのコンポーネント群

またCollision Filterを設定することで、ボール同士の衝突・壁とパドル同士の衝突を判定させないように設定してみました。

f:id:hanaaaaaachiru:20211228152603p:plain
PhysicsCategoryNames

壁を三つ作成して,位置調整を行い以下のような画面にしてみました。

f:id:hanaaaaaachiru:20211228145808p:plain
現状のゲームビュー

分かりづらいですが、上・右・左に白い壁があります。

ボールを作成する

次にボールを作成します。先程とほぼ同様ですがPhysics BodyMotion TypeDynamicにし、Gravity Factor0にしています。

またPhysics ShapeShape TypeSphereにしてます。

f:id:hanaaaaaachiru:20211228152316p:plain
Ballのコンポーネント群
f:id:hanaaaaaachiru:20211228150707p:plain
現状のゲームビュー

またFriction0にして、Restitution1にしたことで、衝突によるエネルギー消費はなくなりました。ですので以下のように永遠にジャンプし続けることができます。

f:id:hanaaaaaachiru:20211228151721g:plain
衝突したときの挙動

パドルを作成する

パドルも同様に作成していきます。

f:id:hanaaaaaachiru:20211228154527p:plain
Paddleのコンポーネント群

ScaleX,Y,Zが同じ値でないと警告が出てきてしまうようですね。ただ今回は無視しちゃいたいと思います。

適当にボールの初速を持たせて再生してみると、いい感じに動いてくれていそうです。

f:id:hanaaaaaachiru:20211228155045g:plain
ボールが壁・パドルに衝突して反射する様子

フォルダ構成

以下のように構成しました。

Scripts
    |-- Main.ECS
    |        |-- ComponentData
    |        |-- Systems
    |-- Main

Main.ECSの中にComponentDataSystemを入れ、いつものコードはMainの中に入れます。

依存関係としてはMain -> Main.ECSが正しい気がしますが、チュートリアルだとECSSystemからGameManagerを参照していたのでそのやり方に従います。

まだここら辺は私自身よく分かっていない箇所も多々あるので、模索しながらという感じです。

パドルを動かす

まずはパドルを動かすようにコードを書いていきます。

namespace Main.ECS.ComponentData
{
    // GenerateAuthoringComponent属性をつけると、ゲームオブジェクトにアタッチできるようになる
    [GenerateAuthoringComponent]
    public struct PaddleInputData : IComponentData
    {
        public KeyCode RightKey;
        public KeyCode LeftKey;
    }
}

パドルを動かすKeyCodeを格納するComponentDataを作成しました。また[GenerateAuthoringComponent]を用いるとゲームオブジェクトにコンポーネントとしてアタッチできるようになります。

次にパドルの移動する方向・スピードに関するデータを定義します。

namespace Main.ECS.ComponentData
{
    [GenerateAuthoringComponent]
    public struct PaddleMovementData : IComponentData
    {
        public int Direction;
        public int Speed;
    }
}

ユーザーの入力を受け取り、パドルの移動する方向を決定するPlayerInputSystemを作成します。

namespace Main.ECS.Systems
{
    // AlwaysSynchronizeSystem: 更新の前にすべての依存関係で同期するように強制する(メインスレッドで動作させるため)
    [AlwaysSynchronizeSystem]
    public class PlayerInputSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            // 書き込むComponentDataはref,読み込みだけのComponentDataにはinをつける
            Entities.ForEach((ref PaddleMovementData movementData, in PaddleInputData inputData) =>
            {
                movementData.Direction = 0;     // -1: 左,1: 右

                movementData.Direction += Input.GetKey(inputData.RightKey) ? 1 : 0;
                movementData.Direction -= Input.GetKey(inputData.LeftKey) ? 1 : 0;
            }).Run();                           // Inputを利用するためメインスレッドでないと動かない 
            // }).Schedule(inputDeps);          // マルチスレッドを利用する際はScheduleを利用し、inputDepsを返す
            
            return default;
        }
    }
}

あとはPaddleMovementDataをもとにTranslationに反映させるPaddleMovementSystemを作成。

namespace Main.ECS.Systems
{
    [AlwaysSynchronizeSystem]
    public class PaddleMovementSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var deltaTime = Time.DeltaTime;
            var xBound = GameManager.I.XBound;
            
            Entities.ForEach((ref Translation trans, in PaddleMovementData data) =>
            {
                trans.Value.x = math.clamp(trans.Value.x + (data.Speed * data.Direction * deltaTime), -xBound, xBound);
            }).Run();

            return default;
        }
    }
}

ついでにGameManagerの雛形を作っておきます。

namespace Main
{
    public class GameManager : MonoBehaviour
    {
        public static GameManager I;

        [SerializeField] private float xBound;
        public float XBound => xBound;

        private void Awake()
        {
            if (I == null)
            {
                I = this;
            }
            else
            {
                Destroy(gameObject);
            }
        }
    }
}

ここでフォルダ構成を整理するとこんな感じ。

Scripts
    |-- Main.ECS
    |        |-- ComponentData
    |        |        |-- PaddleInputData.cs
    |        |        |-- PaddleMovementData.cs
    |        |-- Systems
    |                 |-- PlayerInputSystem.cs
    |                 |-- PaddleMovementSystem.cs
    |-- Main
             |-- GameManager.cs

実際に再生してみるとパドルが動くことが確認できます。

f:id:hanaaaaaachiru:20211228180641g:plain
パドルが動く様子

ボールのスクリプトを作成する

Ballであることを示すBallTag.cs(ComponentData)を作成します。

namespace Main.ECS.ComponentData
{
    [GenerateAuthoringComponent]
    public struct BallTag : IComponentData { }
}

こちらはタグとして利用するだけなので、要素を持ちません。

続いてボールがどんどん加速していくようにしたいので、SpeedIncreaseOverTimeDataを作成しました。

namespace Main.ECS.ComponentData
{
    [GenerateAuthoringComponent]
    public struct SpeedIncreaseOverTimeData : IComponentData
    {
        public float IncreasePerSecond;
    }
}

それらのComponentDataを利用したSystemを作成。

namespace Main.ECS.Systems
{
    [AlwaysSynchronizeSystem]
    public class IncreaseVelocityOverTimeSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var deltaTime = Time.DeltaTime;

            Entities.ForEach((ref PhysicsVelocity vel, in SpeedIncreaseOverTimeData data) =>
            {
                var modifier = new float2(data.IncreasePerSecond * deltaTime);

                var newVel = vel.Linear.xy;
                newVel += math.lerp(-modifier, modifier,  math.sign(newVel));
                vel.Linear.xy = newVel;
            }).Run();

            return default;
        }
    }
}
f:id:hanaaaaaachiru:20211228183252g:plain
ボールが加速していく様子

場外に出たら初期値に戻す

チュートリアルだと場外にボールが出たら削除→スポンしますが、今回は大量なボールを出したいので初期値点に戻すようにします。

namespace Main.ECS.Systems
{
    // IncreaseVelocityOverTimeSystemの後に実行されるように設定する
    [AlwaysSynchronizeSystem, UpdateAfter(typeof(IncreaseVelocityOverTimeSystem))]
    public class BallOutOfBoundsCheckSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var initPos = GameManager.I.InitBallPos;
            var initSpeed = GameManager.I.InitBallSpeed;
            var bound = GameManager.I.YBound;

            Entities
                .WithAll<BallTag>()                 // BallTagを含むアーキタイプのチャンクのみ対象にする
                .ForEach((ref Translation trans, ref PhysicsVelocity physicsVelocity) =>
                {
                    var pos = trans.Value;
                    var random = UnityEngine.Random.Range(0f, 2 * math.PI);
                    
                    if (pos.y < -bound || pos.y > bound)
                    {
                        trans.Value = initPos;
                        physicsVelocity.Linear.xy = initSpeed * new float2(math.cos(random), math.sin(random));
                    }
                }).Run();

            return default;
        }
    }
}

ついでにGameManagerにも要素を追加してきます。

namespace Main
{
    public class GameManager : MonoBehaviour
    {
        public static GameManager I;

        [SerializeField] private float xBound;
        public float XBound => xBound;

        [SerializeField] private float yBound;
        public float YBound => yBound;

        [SerializeField] private Vector3 initBallPos;
        public Vector3 InitBallPos => initBallPos;

        [SerializeField] private float initBallSpeed;
        public float InitBallSpeed => initBallSpeed;

        private void Awake()
        {
            if (I == null)
            {
                I = this;
            }
            else
            {
                Destroy(gameObject);
            }
        }
    }
}

フォルダ構成も増えてきました。

Scripts
    |-- Main.ECS
    |        |-- ComponentData
    |        |        |-- BallTag.cs
    |        |        |-- PaddleInputData.cs
    |        |        |-- PaddleMovementData.cs
    |        |        |-- SpeedIncreaseOverTimeData
    |        |-- Systems
    |                 |-- BallOutOfBoundsCheckSystem.cs
    |                 |-- IncreaseVelocityOverTimeSystem.cs
    |                 |-- PlayerInputSystem.cs
    |                 |-- PaddleMovementSystem.cs
    |-- Main
             |-- GameManager.cs
f:id:hanaaaaaachiru:20211228203025g:plain
場外に出たら初期地点・初期速度にリセット

Entityを生成する

あとはGameManagerBallを大量生産する処理を書いて完成です。

namespace Main
{
    public class GameManager : MonoBehaviour
    {
        public static GameManager I;

        // 2048,4096,8192, 16384, 32768, 65536とか色々試してみると面白いかもしれません
        [SerializeField] private int ballCount = 16384;

        [SerializeField] private float xBound;
        public float XBound => xBound;

        [SerializeField] private float yBound;
        public float YBound => yBound;

        [SerializeField] private Vector3 initBallPos;
        public Vector3 InitBallPos => initBallPos;

        [SerializeField] private float initBallSpeed;
        public float InitBallSpeed => initBallSpeed;
        
        // Gameobject -> Entity
        [SerializeField] private GameObject ballPrefab;
        private Entity _ballEntityPrefab;
        private EntityManager _entityManager;
        
        private void Awake()
        {
            if (I == null)
            {
                I = this;
            }
            else
            {
                Destroy(gameObject);
            }
        }

        private void Start()
        {
            // GameObjectをEntityに変換する
            _entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
            var settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, null);
            _ballEntityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(ballPrefab, settings);
            
            SpawnBalls(ballCount);
        }

        /// <summary>
        /// 一つだけボールを生成する
        /// </summary>
        private void SpawnBall()
        {
            var ball = _entityManager.Instantiate(_ballEntityPrefab);
            
            var random = UnityEngine.Random.Range(0f, 2 * math.PI);
            var physicsVelocity = new PhysicsVelocity
            {
                Linear = new float3(initBallSpeed * new float2(math.cos(random), math.sin(random)), 0)
            };
            _entityManager.AddComponentData(ball, physicsVelocity);
        }

        /// <summary>
        /// 一度に複数ボールを生成する
        /// </summary>
        /// <param name="count">生成数</param>
        private void SpawnBalls(int count)
        {
            // 一度の大量のエンティティを生成する場合はNativeArray<Entity>を利用すると効率的らしい
            // https://www.f-sp.com/entry/2019/04/18/175747#fn-41386b89
            using var entities = new NativeArray<Entity>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
            _entityManager.Instantiate(_ballEntityPrefab, entities);
            foreach (var entity in entities)
            {
                var random = UnityEngine.Random.Range(0f, 2 * math.PI);
                var physicsVelocity = new PhysicsVelocity
                {
                    Linear = new float3(initBallSpeed * new float2(math.cos(random), math.sin(random)), 0)
                };
                _entityManager.AddComponentData(entity, physicsVelocity);
            }
        }
    }
}
Scripts
    |-- Main.ECS
    |        |-- ComponentData
    |        |        |-- BallTag.cs
    |        |        |-- PaddleInputData.cs
    |        |        |-- PaddleMovementData.cs
    |        |        |-- SpeedIncreaseOverTimeData
    |        |-- Systems
    |                 |-- BallOutOfBoundsCheckSystem.cs
    |                 |-- IncreaseVelocityOverTimeSystem.cs
    |                 |-- PlayerInputSystem.cs
    |                 |-- PaddleMovementSystem.cs
    |-- Main
             |-- GameManager.cs

コメントにも書いてあるのですが、一度の大量のエンティティを生成する場合はNativeArrayを利用すると効率的なようです。
【Unity】 ECS まとめ(前編) - エフアンダーバー

動作確認

インスペクターで設定する必要があるものをちゃんと設定すれば、以下のように動作するはずです。

f:id:hanaaaaaachiru:20211228234517g:plain
動作する様子

考察

Profilerで確認してみたところ、メインスレッドの半分をEditorLoopが占めていました。こちらはビルドした際には関係ないものみたいなので、ビルドすると処理速度を改善できそうです。
I dont know what EditorLoop is in my profiler?,I don't know what EditorLoop is in my profiler? - Unity Answers

f:id:hanaaaaaachiru:20211228235120p:plain
Profiler

その次にメインスレッドを使っていたのはHybridRenderer関連の処理のようです。

おそらく描画周りの処理だと思うので、こちらから何か改善するとかは中々難しそうな気もします。

またHybridRendererと同じくらい処理時間を必要としていたのはUnity Physics関連みたいです。

f:id:hanaaaaaachiru:20211228235417p:plain
ProfilerのUnityPhysics関連の処理


こちらは逆に16384個もあってよくここまで抑え切れてるなという印象を受けます。といっても従来の物理エンジンと比較したデータがあるわけではないので何とも言えませんが。

ちなみにEntity Debuggerで確認してみても同様にHybridRendererUnity Physics関連のSystemが主でした。

f:id:hanaaaaaachiru:20211229004420p:plain
Entity Debugger

IncreaseVelocityOverTimeSystemに関してはマルチスレッドにした方が処理時間がかなり改善されました。

namespace Main.ECS.Systems
{
    public class IncreaseVelocityOverTimeSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var deltaTime = Time.DeltaTime;

            return Entities.ForEach((ref PhysicsVelocity vel, in SpeedIncreaseOverTimeData data) =>
            {
                var modifier = new float2(data.IncreasePerSecond * deltaTime);

                var newVel = vel.Linear.xy;
                newVel += math.lerp(-modifier, modifier, math.sign(newVel));
                vel.Linear.xy = newVel;
            }).Schedule(inputDeps);
        }
    }
}
f:id:hanaaaaaachiru:20211229000015p:plainf:id:hanaaaaaachiru:20211229000018p:plain
←マルチスレッド,メインスレッド→

ちなみにBallOutOfBoundsCheckSystemもマルチスレッドにしちゃえと思ってコードを書き換えたらUnityEngine.Random.Rangeはメインスレッドでないと利用できないみたいですね。

ですのでUnity.Mathematics.Randomに置き換えて再チャレンジ。

namespace Main.ECS.Systems
{
    // IncreaseVelocityOverTimeSystemの後に実行されるように設定する
    [UpdateAfter(typeof(IncreaseVelocityOverTimeSystem))]
    public class BallOutOfBoundsCheckSystem : JobComponentSystem
    {
        private Unity.Mathematics.Random _random;
        
        protected override void OnCreate()
        {
            base.OnCreate();
            _random = new Random((uint)new System.Random().Next(0, 10000));
        }
        
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var initPos = GameManager.I.InitBallPos;
            var initSpeed = GameManager.I.InitBallSpeed;
            var bound = GameManager.I.YBound;
            var random = _random;
            
            return Entities
                .WithAll<BallTag>() // BallTagを含むアーキタイプのチャンクのみ対象にする
                .ForEach((ref Translation trans, ref PhysicsVelocity physicsVelocity) =>
                {
                    var pos = trans.Value;
                    var randomValue = random.NextFloat() * 2 * math.PI;

                    if (pos.y < -bound || pos.y > bound)
                    {
                        trans.Value = initPos;
                        physicsVelocity.Linear.xy = initSpeed * new float2(math.cos(randomValue), math.sin(randomValue));
                    }
                }).Schedule(inputDeps);
        }
    }
}

こちらも大幅に改善できたようですね。

f:id:hanaaaaaachiru:20211229004306p:plainf:id:hanaaaaaachiru:20211229004304p:plain
←マルチスレッド,シングルスレッド→

ただマルチスレッドにすれば必ず速度改善に繋がるわけではないので注意してください。
マルチスレッドとは - Unity マニュアル