Unity JobSystemを使ったコンテンツ開発設計を考える

こんにちは、[Unity Adnvent Calender 2024](https://qiita.com/advent-calendar/2024/unity)15日目の記事になります。

私Shitakamiは普段JobSystemを使った魚群シミュレーションを開発してまして、ここ最近はゲームに転用する試みをしていました。

https://x.com/CCPJ_a/status/1804346136020554124/video/1

今回はこれらコンテンツやゲームの開発で考えた設計と実際に開発で感じた所感についてまとめます。


## 前提
この記事ではUnity JobSystemについて解説しておりません🙇‍♂️
JobSystemの詳細は以下の記事などを参考にしていただけると助かります。

https://www.slideshare.net/slideshow/cjobsystem-ecscpu/94697315#7

## 背景

簡単にどのようなものを作っていて、設計を考えることになった背景についてお話します。


### 1. 技術検証の時代

初期段階では、何かを開発する意識もなく技術検証のため開発をしていました。
この時期は、単純に1つのMonoBehaviourにJobSystemの処理をまとめて魚群シミュレーション（魚群クラス）を実装していました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939578/22889c10-89c3-7a2b-9006-c3cd3b12cb08.png" width=80%>


### 2. 機能実装を開始

ある程度技術検証が完了したあと、コンテンツにすることを意識して機能実装を始めました。
この段階ではまだまだ機能は少なく、魚群クラスが他のMonoBehaviourにアクセスするのみでした。

また、少しずつ魚群クラスに新しいJobや計算に必要なNativeArrayが増えていきました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939578/70e3b77e-7250-10bb-6def-174de69c978a.png" width=80%>

### 3. 設計の破綻

機能実装を続けるうちに、魚群クラスが他のMonoBehaviourにアクセスし始めました。
他MonoBehaviourが魚群クラスにアクセスする目的として、JobSystemの計算結果を使用することが主でした。
JobSystemの計算結果を使用するにはJobの完了を意識する必要があり、魚群クラスに `JobHandle` を複数個保持させることになりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939578/976be21c-bf7e-3674-7487-6032c636b421.png" width=80%>


結果として、魚群クラスが肥大化してしまいました。
他MonoBehaviourそれぞれが自律的に動作するため、処理の起点がバラバラで流れを追いづらくなっていました。
このままでは開発の継続は不可能と判断し、再設計するに至りました。


### 発生した問題のまとめ

もともと大きなコンテンツを作るつもりもなく始めた開発でしたが、気づけばコードが膨れ上がり3つの問題が発生しました。

1. 一つのクラスに魚群シミュレーションの全てが詰め込まれている
2. NativeContainerが依存するJobについて不透明
3. 各GameObjectが勝手に処理を呼ぶため、処理の流れや依存関係が把握しづらくなる

今後も開発していくために、この3つの問題を解決できるアーキテクチャを考えるに至った経緯です。


## JobSystemを使用したコンテンツの設計を考える

作成している魚群シミュレーション自体はGameObjectに依存しておらず、純粋なクラスで動作させられるものになっています。（魚群データはすべてNativeContainerに保持、描画は `RenderMeshIndirect` を使用）

これを元に、Unity上のMonoBehaviourとJobSystem側の処理を分ける形で設計を考えました。

### レイヤーを定義

作成した設計では4つのレイヤーに分けました。
既存のアーキテクチャや設計などの背景が若干入っていますが、あまり関係ないので適当なレイヤー名にしています。

* Unity層: Scene上に配置されるMonoBehaviour群、Unity上のことしか知らない
* System層: 魚群シミュレーションの実行とUnityとの連携を行う、処理の流れを持つ
* Logic層: 魚群シミュレーションに関するロジック（Jobの実行など）を持つ
* Data層: NativeContainerやScriptableObjectを保持

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939578/e77c2dda-e537-6a30-9b7c-0c0a6361078f.png" width=90%>


この設計を作るうえで[VContainer](https://vcontainer.hadashikick.jp/ja/)を使用しており、以下のルールを設けました。

* **Unity層ではInjectionを使用しない**
    * MonoBehaviourはDIコンテナに登録されるのみ
* **System層でのみEntryPoint（ `Start()` `Update()` など）を作る**
    * ただし、Unity上で完結するMonoBehaviourは無視
* **レイヤーの飛び越えを許容**
    * System層からData層へのアクセスは問題なし


この設計により、「問題1. 一つのクラスに魚群シミュレーションの全てが詰め込まれている」と「問題3. 各GameObjectが勝手に処理を呼ぶため、処理の流れや依存関係が把握しづらくなる」が解決しました。

### NativeContainerとJobの依存をまとめる

残る問題は「問題2. NativeContainerが依存するJobについて不透明」となりました。
これを解決するため、Data層のNativeContainerにJobの依存もまとめることにしました。

以下、NativeContainerとJobをまとめたクラスの簡単なコードです。

```cs
// 障害物データ
public class ObstaclesDataStore : IDisposable
{
    private NativeArray<ObstacleData> _obstacleDatas;

    private JobHandle _dependencyJobHandle;

    public void Set(NativeArray<ObstacleData> obstacleDatas)
    {
        if (!_dependencyJobHandle.IsCompleted)
        {
            _dependencyJobHandle.Complete();
        }

        if (_obstacleDatas.IsCreated)
        {
            _obstacleDatas.Dispose();
        }

        _obstacleDatas = obstacleDatas;
    }

    public NativeArray<ObstacleData> GetAfterCompleteDependency()
    {
        if (!_dependencyJobHandle.IsCompleted)
        {
            _dependencyJobHandle.Complete();
        }

        return _obstacleDatas;
    }

    public NativeArray<ObstacleData> GetIgnoreDependency()
    {
        return _obstacleDatas;
    }

    public void SetDependency(JobHandle jobHandle)
    {
        _dependencyJobHandle = jobHandle;
    }

    public void Dispose()
    {
        _dependencyJobHandle.Complete();

        _obstacleDatas.Dispose();
    }
}
```

何かしらNativeContainerを使ったJobを実行する場合は、必ず依存を表す `JobHandle` をクラスに保持させます。

Jobの計算結果をもとにロジックを実行するときは `GetAfterCompleteDependency()` を使用することで、Jobの完了を保証し安全にデータが取得できます。

```cs
// MonoBehaviourの障害物データを反映
var obstacleDatas = _obstaclesDataStore.GetAfterCompleteDependency();
for (var i = 0; i < obstaclesCount; i++)
{
    obstacleDatas[i] = sceneObstacles.Obstacles[i];
}
```

反対に複数のJobを組み合わせるときは、毎回Jobの完了を待つ必要がないため `GetIgnoreDependency()` を使います。 

```cs
// 完了を待たずにデータを取得
var obstacleDatas = _boidsObstaclesDataStore.GetIgnoreDependency();
var hogeJob = new HogeJob(obstacleDatas);

// beforeJobHandleが完了後にhogeJobHandleを実行するようスケジュール
var hogeJobHandle = hogeJob.Schedule(arrayLength, batchCount, beforeJobHandle);

// hogeJobの依存を登録
_boidsObstaclesDataStore.SetDependency(hogeJobHandle); 
```

この対応により、「問題2. NativeContainerが依存するJobについて不透明」を解決できました。


## 運用しての感想

この設計は2024年の8月ぐらいに考えて実装したものになりまして、数か月ほど開発を続けていました。
そのうえで、感じた良かった点と問題についてまとめます。


### 良かった点

* 最初に取り上げた3つの問題が解消
    * 継続的な開発が可能
    * 問題解消による、開発のモチベ維持
* ルールを守ることで、自然と設計が保たれる
    * 設計について考えずに、機能実装できる

まとめますと、システム全体について認知負荷が低くなり、機能実装に集中できたと感じています。


### 問題
いくつか設計を取り入れた故の問題もありますが、自分が感じた問題をまとめます。

* 必要なコード、クラスが増える
    * 若干モチベが下がる
* Unity Scene上のGameObjectと魚群シミュレーションが仲良くしづらい
    * 必ずDIに登録して、System層から扱う必要が出る
* ゲームフローをどこに書くのか問題
    * ゲームの進行度に応じた敵Objectの配置やUIの操作など

一番問題と感じているものが**「ゲームフローをどこに書くのか問題」**でした。
設計上のルールよりゲームフローはSystem層に置く形が近いですが、行う処理のほとんどがUnity Scene上のことになります。
反対に、Unity層に持っていくとゲームフローによる魚群シミュレーションの制御がしづらくなります。

今のところ結論は出ておらず、System層でStateMachineみたいなものを作りゲームフローを回すようにしています。ただ、ゲームフローが複雑化すると破綻しそうなので再設計の余地があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939578/9abc5354-3154-5b12-bd03-7c3c86ad7fec.png)


## まとめ

現在開発しているコンテンツの設計について、簡単にまとめました。
Unityの設計の話はよく話題に上がりますが、JobSystemにまつわるものは少ないかなと思ったりしています。
（そもそもJobSystemをメインで使っているものがそんなにない 🙈）

今のところはまだ開発に行き詰まるほどの大きな問題にはぶつかっていないですが、今後は設計を大きく変えるかもしれません。

正直、これ以上大きいものになってくるとECSを使った方が早いのかなぁとも思いつつ、処理の流れを自由に書きたいので未だにJobSystemを使っています。

作っているものが完成しましたら、どこかしらの機会で公開する予定ですので縁がありましたら見ていただけると嬉しいです。