Unity ECSを学ぶ！

この記事は、[KLab Engineer Advent Calendar 2024](https://qiita.com/advent-calendar/2024/klab) の11日目の記事です。
こんにちは。KLabでエンジニアをしている @tsune2ne です。

去年Unity ECSが正式リリースされました。
これを機に学習していきます。

え？DOTSのリリースから何年経ってると思ってるんだ遅すぎるって？
いいんです。始めるのに遅すぎるということはないんです。

# Unity ECSの経緯
### 課題
今まではGameObjectごとにMonoBehaviourクラスをAddComponentしてプログラミングすることが多いと思います。
これはオブジェクト指向プログラミングに近く、GameObjectのふるまいをC#クラスで表現できるので非常に理解しやすいです。

しかし、これをデータフローで見ると実は効率が悪いです。
データと処理をまとめて管理しいるため、横断的な操作をすると歯抜けになってしまいアクセス効率が落ちます。

![ecs_youtube13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/8d6c1a98-ebb8-7156-6c68-c3ae80091cb0.png)


### 解決策
オブジェクト指向ではデータとふるまいの紐づきが強すぎてメモリ配置を最適化できないと考え
データ(Entity)とふるまい(System)の設計を分離しようというのがECSの考えです。
これによりメモリアクセス速度の向上、マルチコアCPUの効率利用、シリアライズ・デシリアライズの高速化などのメリットがあります。

![ecs_youtube24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/00551d37-4d29-68f5-d71a-194b3a1d0bd4.png)

上図で緑のデータにアクセスするのにECSのほうが有利なことがわかるでしょう。
もちろん状況によりけりですが右のレイアウトにしやすいのがECSのメリットです。


[はじめての Unity ECS - Entity Component System を使ってみよう！](https://www.youtube.com/watch?v=vzF00Wb6wNY)

ECSは以下の３つの要素に分割されます。

- Entity : データ、情報
- Component : 物体にデータを紐づける仕組み
- System : ふるまい、



### UnityEditorでの見た目
UnityEditorのGUIでは現状のGameObjectにC#クラスをコンポーネントとして付属するのは直観的でわかりやすいです

ただデータ指向で効率化すると人間の感覚では直感的ではなくなります
Hierarchy上のGameObjectにC#クラスをAddComponentするというのは非常にわかりやすいです
そのため、ECSでもUnityEditorでは同じ感覚でAddComponentするようにできています

しかし実行するときにECS空間？変換する処理（Bake）を行います

# サンプル実装
実行サンプルを作って比較してみましょう
雑に5000個のCubeを上下運動させます
![Sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/9697b8bf-0fea-7601-4a03-3e94eedbdc33.gif)

実装したコードは下記です
https://github.com/tsune2ne/UnityECSSample

### ECSのデータ定義
ECSコンポーネントごとに持つデータを定義します
データを持つだけで処理やふるまいは持ちません

```c#
public struct MoveRotate : IComponentData
{
    public float Angle;
    public float Speed;
    public float3 BasePosition;
    public bool IsInitialized;
}
```

### GameObjectコンポーネントをEntityに変換
ECS空間？で持つためのEntityおよびEntityへの変換処理を記述します
起動時に自動で変換されます

```c#
public class MoveRotateAuthoring : MonoBehaviour
{
    public float Angle;
    public float Speed;

    class RotateBaker : Baker<MoveRotateAuthoring>
    {
        public override void Bake(MoveRotateAuthoring authoring)
        {
            // Entityを生成
            var entity = GetEntity(TransformUsageFlags.Dynamic); // LocalTransformアリ
            AddComponent(entity, new MoveRotate
            {
                Angle = authoring.Angle,
                Speed = authoring.Speed,
            });
        }
    }
}
```

### Baker処理とは？
UnityEditorで設定した値をECSコンポーネントに変換する処理のこと



### ふるまいの定義
データのふるまいを記述します
これはHierarchy上にAddComponentしなくても実行されます
partialなのはC#のSource Generatorで補助的なコードを自動生成しているためです。

```c#
[BurstCompile]
public partial struct DancerSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        // MoveRotateとLocalTransformが付与されてるEntityを検索して処理
        foreach (var (rotate, xform) in SystemAPI.Query<
                RefRW<MoveRotate>,  // MoveRotateを書き込み権限で取得
                RefRW<LocalTransform>>()) // LocalTransformを書き込み権限で取得
        {
            // 初期化処理
            if (!rotate.ValueRW.IsInitialized)
            {
                rotate.ValueRW.Speed = Random.Range(0.1f, 10f);
                rotate.ValueRW.BasePosition = xform.ValueRO.Position;
                rotate.ValueRW.IsInitialized = true;
            }

            // 移動処理
            rotate.ValueRW.Angle += Time.deltaTime * rotate.ValueRW.Speed;
            xform.ValueRW.Position.y = Mathf.Cos(rotate.ValueRW.Angle) + rotate.ValueRW.BasePosition.y;
        }
    }
}
```

上記はJobSystem対応されておらずECSの効果が発揮されないので対応します

```c#
[BurstCompile]
public partial struct MoveRotateSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // 初期化処理
        foreach (var (rotate, xform) in SystemAPI.Query<
            RefRW<MoveRotate>,
            RefRW<LocalTransform>>())
        {
            if (!rotate.ValueRW.IsInitialized)
            {
                // UnityEngine.Randomはシングルスレッド限定なので先に決めてしまう
                rotate.ValueRW.Speed = Random.Range(0.1f, 10f);
                rotate.ValueRW.BasePosition = xform.ValueRO.Position;
                rotate.ValueRW.IsInitialized = true;
            }
        }

        // ジョブインスタンスを生成して並列実行予約
        var ecb = GetEntityCommandBuffer(ref state);
        new ProcessRotateMoveJob
        {
            DeltaTime = Time.deltaTime,
            Ecb = ecb
        }.ScheduleParallel();
    }

    EntityCommandBuffer.ParallelWriter GetEntityCommandBuffer(ref SystemState state)
    {
        var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
        return ecb.AsParallelWriter();
    }

    [BurstCompile]
    public partial struct ProcessRotateMoveJob : IJobEntity
    {
        public float DeltaTime;
        public EntityCommandBuffer.ParallelWriter Ecb;

        // 引数のComponentを持つEntityを検索して実行される
        void Execute([ChunkIndexInQuery] int chunkIndex, ref MoveRotate rotate, ref LocalTransform xform)
        {
            // 移動処理
            rotate.Angle += DeltaTime * rotate.Speed;
            xform.Position.y = Mathf.Cos(rotate.Angle) + rotate.BasePosition.y;
        }
    }
}
```


### 性能評価
Profilerを眺めて比較してみましょう

**↓普通の実装で動かした場合のPlayerLoop**
![oldのplayerloop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/b53dc3a6-5614-17ee-31f2-536c815b74f3.png)

**↓ECS+BurstCompiler+JobSystemで動かした場合のPlayerLoop**
![ecsのplayerloop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/288859d5-75f7-3fe9-a84c-85845e66cca4.png)

かなり減らせてますね！
※ただサンプル数1000個だと有意差は見られませんでした（なんで？）
※Windows + Unity2022.3.51f1 + Entities1.3.5で検証

**↓Job項目で並列チェック**
![ecs+jobでJobをちゃんと使ってるっぽい？2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/11e862a4-ad39-f1c6-0bc5-5a0126f90149.png)

緑色が今回作った移動処理です。それぞれのジョブで実行されてますね。

# データ指向設計(DOD)の話
このオブジェクト指向ではなくデータ中心の考え方をデータ指向設計(DOD)と呼ばれています。
既存とは全く違う設計が必要になるため、公式で学習コースが用意されています。
気になる人はぜひ受講してみてください。

[DOTS ベストプラクティス](https://learn.unity.com/course/dots-best-practices?uv=2022.3)

# まとめ
雑なサンプルを作ってもプロファイラーに変化があったので、武器として知っておく価値は十分あります。
処理がボトルになっていて並列化で改善できる個所だけECS化するのが既存PJへの最適解です。

PJ全体でECS化すると性能面で大きなアドバンテージを得られますが
既存のオブジェクト指向からデータ指向へパラダイムシフトが必要になります。
開発メンバー全員への教育コストが必要です。

またオブジェクト指向設計は人数のスケールアップがしやすいのが利点であるため
データ指向設計で多人数開発をすると可読性やパッケージ化に新しいノウハウが必要になります。

そのコストを払ってまでECS化するかは難しい判断になりそうです。

# 参考資料
- [はじめての Unity ECS - Entity Component System を使ってみよう！](https://www.youtube.com/watch?v=vzF00Wb6wNY)
- [たのしいDOTS 〜初級から上級まで〜](https://learning.unity3d.jp/3252/)
- [Unity DOTS の概要](https://note.com/npaka/n/n4b61fe4d3087)