はじめに
今回はこちらのDOTS(Data-Oriented Technology Stack)
の公式解説動画をやっていきたいと思います。
www.youtube.com
このブログ記事では上の動画のまとめ的な感じにしたいと考えてますが、時間がある方は素直に動画を見ていただいた方がいいかもしれません。
あくまで備忘録的な意味合いなのであしからず。

またチュートリアルのようにボールが一つだと寂しいので、色々と改造してます。
環境
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"

現在(2021/12/28)から見ると少し古いパッケージですが、サンプルに合わせていきます。
その前に
チュートリアルを見ながら作成していきますが、自作の箇所が大いにあります。
まるっきり同じコードにしたい方は以下のリポジトリを見てみてください。
github.com
またDOTS
の説明とかはしないので、技術的な説明を見たい方は以下のリンクが参考になるかもしれません。
たのしいDOTS 〜初級から上級まで〜 | Unity Learning Materials
【Unity】Unity 2018のEntity Component System(通称ECS)について(1) - テラシュールブログ
【Unity】 ECS まとめ(前編) - エフアンダーバー
ゲームオブジェクトを配置する
壁を作成する
まずは壁を配置して、エンティティに変換してみます。このようにGameObject
をEntity
に変換する手法のことをHybrid ECS
と呼びます。
【Unity】GameObjectもECSも使いたい Hybrid ECSについて - テラシュールブログ
その手順を是非ここで覚えてみてください。
Hierarchy
より3DObject -> Cube
を作成し、以下のようなコンポーネントをアタッチ・設定してください。

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

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

分かりづらいですが、上・右・左に白い壁があります。
ボールを作成する
次にボールを作成します。先程とほぼ同様ですがPhysics Body
のMotion Type
をDynamic
にし、Gravity Factor
を0
にしています。
またPhysics Shape
のShape Type
をSphere
にしてます。


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

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

Scale
のX,Y,Z
が同じ値でないと警告が出てきてしまうようですね。ただ今回は無視しちゃいたいと思います。
適当にボールの初速を持たせて再生してみると、いい感じに動いてくれていそうです。

フォルダ構成
以下のように構成しました。
Scripts |-- Main.ECS | |-- ComponentData | |-- Systems |-- Main
Main.ECS
の中にComponentData
・System
を入れ、いつものコードはMain
の中に入れます。
依存関係としてはMain -> Main.ECS
が正しい気がしますが、チュートリアルだとECS
のSystem
から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
実際に再生してみるとパドルが動くことが確認できます。

ボールのスクリプトを作成する
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; } } }

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

Entityを生成する
あとはGameManager
がBall
を大量生産する処理を書いて完成です。
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 まとめ(前編) - エフアンダーバー
動作確認
インスペクターで設定する必要があるものをちゃんと設定すれば、以下のように動作するはずです。

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

その次にメインスレッドを使っていたのはHybridRenderer
関連の処理のようです。
おそらく描画周りの処理だと思うので、こちらから何か改善するとかは中々難しそうな気もします。
またHybridRenderer
と同じくらい処理時間を必要としていたのはUnity Physics
関連みたいです。

こちらは逆に16384個
もあってよくここまで抑え切れてるなという印象を受けます。といっても従来の物理エンジンと比較したデータがあるわけではないので何とも言えませんが。
ちなみにEntity Debugger
で確認してみても同様にHybridRenderer
・Unity Physics
関連のSystem
が主でした。

後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); } } }


ちなみに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); } } }
こちらも大幅に改善できたようですね。


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