【Unity】DOTSをVisionPro 上で動かしてみた【VisionOS】

Graffity のUnityエンジニアのCovaです。

今回は AppleVisionPro 上でUnity-DOTS(旧称Unity EntityComponentSystem;ECS) を動かせるらしいので実際に試してみました。


# 結論
- ちゃんと動く！！
- パフォーマンスもやっぱりDOTSなので出る

<iframe width="560" height="315" src="https://www.youtube.com/embed/wVbpjudXuiA?si=8OcEvGBLQgQsKaeA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# Unity-DOTSとは？

https://unity.com/ja/dots

Unity のDataOrientedTechnologyStack といわれているもので、ざっくり説明すると「データ設計とか色々悪くてパフォーマンス出せてなかったから、パフォーマンス最大化するように色々設計見直したわ」っていうやつです。

具体的にはEntityComponentSystem と呼ばれるアーキテクチャパターン（設計パターン。≠デザインパターン）を採用しています。

ECSについては 

@4_mio_11 さんの記事

https://qiita.com/4_mio_11/items/f28f0ed84fc5ca9ba3a0

や

@simplestar さんの記事

https://qiita.com/simplestar/items/17b0886be0170f79aa2e

などを参考にキャッチアップすることをお勧めです。

:::note
- Unity初心者がいきなり手を出すとボコボコにされてしまう可能性大です（マジで挫折します）
- MVPやMVRPでの設計に慣れている方はECSでの各種要素の役割分担は割とすんなり受け入れ易いかと思います
- Unity中級者(目安としてはC#Pureクラスでゲームロジックをある程度意識して書くようになった)程度になってから触れるのがおすすめです
:::

# 検証環境

| 項目 | 説明 |
| :-- | :-- |
| Unity | Unity6000.0.47f1 |
| PolySpatial | 2.2.4 |
| Unity.Entities | 1.3.14 |
| VisionOS | 2.0.4 |

# Unity の設定方法

導入フローとしては以下の通りです

1. Unity のPro以上のライセンスを用意
1. Unity6.0.xの最新をDL
    - VisionOS モジュールも入れる
1. 新規Projectを作成
    -  Core 3D (URP)を選択
    -  VisionOSにSwitchPlatformする
1. 必要なPackage を導入
    1. com.unity.xr.visionos
    1. com.unity.polyspatial
    1. com.unity.polyspatial.extensions
    1. com.unity.polyspatial.visionos
    1. com.unity.physics
    面倒な人は下記のmanifest.json をそのままPackagesフォルダ内に設定して上書きしてください
    ```manifest.json
    {
      "dependencies": {
        "com.unity.ai.navigation": "2.0.7",
        "com.unity.collab-proxy": "2.8.1",
        "com.unity.entities": "1.3.14",
        "com.unity.ide.rider": "3.0.35",
        "com.unity.ide.visualstudio": "2.0.23",
        "com.unity.inputsystem": "1.14.0",
        "com.unity.multiplayer.center": "1.0.0",
        "com.unity.physics": "1.3.14",
        "com.unity.polyspatial": "2.2.4",
        "com.unity.polyspatial.extensions": "2.2.4",
        "com.unity.polyspatial.visionos": "2.2.4",
        "com.unity.render-pipelines.universal": "17.0.4",
        "com.unity.test-framework": "1.5.1",
        "com.unity.timeline": "1.8.7",
        "com.unity.ugui": "2.0.0",
        "com.unity.visualscripting": "1.9.6",
        "com.unity.xr.interaction.toolkit": "3.0.8",
        "com.unity.xr.visionos": "2.2.4",
        "com.unity.modules.accessibility": "1.0.0",
        "com.unity.modules.ai": "1.0.0",
        "com.unity.modules.androidjni": "1.0.0",
        "com.unity.modules.animation": "1.0.0",
        "com.unity.modules.assetbundle": "1.0.0",
        "com.unity.modules.audio": "1.0.0",
        "com.unity.modules.cloth": "1.0.0",
        "com.unity.modules.director": "1.0.0",
        "com.unity.modules.imageconversion": "1.0.0",
        "com.unity.modules.imgui": "1.0.0",
        "com.unity.modules.jsonserialize": "1.0.0",
        "com.unity.modules.particlesystem": "1.0.0",
        "com.unity.modules.physics": "1.0.0",
        "com.unity.modules.physics2d": "1.0.0",
        "com.unity.modules.screencapture": "1.0.0",
        "com.unity.modules.terrain": "1.0.0",
        "com.unity.modules.terrainphysics": "1.0.0",
        "com.unity.modules.tilemap": "1.0.0",
        "com.unity.modules.ui": "1.0.0",
        "com.unity.modules.uielements": "1.0.0",
        "com.unity.modules.umbra": "1.0.0",
        "com.unity.modules.unityanalytics": "1.0.0",
        "com.unity.modules.unitywebrequest": "1.0.0",
        "com.unity.modules.unitywebrequestassetbundle": "1.0.0",
        "com.unity.modules.unitywebrequestaudio": "1.0.0",
        "com.unity.modules.unitywebrequesttexture": "1.0.0",
        "com.unity.modules.unitywebrequestwww": "1.0.0",
        "com.unity.modules.vehicles": "1.0.0",
        "com.unity.modules.video": "1.0.0",
        "com.unity.modules.vr": "1.0.0",
        "com.unity.modules.wind": "1.0.0",
        "com.unity.modules.xr": "1.0.0"
        }
    }
1. Unity.Physics のサンプルを入れておく
    - なぜかSampleを入れないとPhysicsBody コンポーネントが現れなかったりしたので導入推奨
    - ![UnityPhysics.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65257/722699ac-5932-41bd-b8cd-ed861414f2ec.png)

1. PolySpatial 周りのScene作りが大変なのでPolySpatialのSampleも導入推奨
    - ![PolySpatial.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65257/c28556fc-7eb5-4e47-af84-b64b36be73a7.png)
1. Sample を導入していれば `Assets/Samples/PolySpatial/Scenes/Manipulation.unity` のSceneを複製して、そちらを以降は編集する

1. Hierarchy のメニューから `New Empty Sub Scene` を選択してDOTS用のSubSceneを新規に作成します
    - ここでは `dotPhysicsSub` と命名しておきます
    ![subscene.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65257/f3ce0aa4-6928-4078-9b3f-f4e58e87c695.png)

1. 指定のPrefabを生成するScriptを用意します
    ```Spawner.cs
    using Unity.Entities;
    using Unity.Mathematics;
    
    public struct Spawner : IComponentData
    {
        public Entity Prefab;
        public float3 SpawnPosition;
        public float NextSpawnTime;
        public float SpawnRate;
    }
    public struct CubeTag : IComponentData { }
    ```

    ```SpawnerAuthoring.cs
        using UnityEngine;
        using Unity.Entities;
        
        class SpawnerAuthoring : MonoBehaviour
        {
            public GameObject Prefab;
            public float SpawnRate;
        }
        
        class SpawnerBaker : Baker<SpawnerAuthoring>
        {
            public override void Bake(SpawnerAuthoring authoring)
            {
                var entity = GetEntity(TransformUsageFlags.None);
                AddComponent(entity, new Spawner
                {
                    // By default, each authoring GameObject turns into an Entity.
                    // Given a GameObject (or authoring component), GetEntity looks up the resulting Entity.
                    Prefab = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic),
                    SpawnPosition = authoring.transform.position,
                    NextSpawnTime = 0.0f,
                    SpawnRate = authoring.SpawnRate
                });
            }
        }
    ```

    ```SpawnerSystem.cs
    using Unity.Collections;
    using Unity.Entities;
    using Unity.Transforms;
    using Unity.Burst;
    using Unity.Mathematics;
    
    [BurstCompile]
    [UpdateInGroup(typeof(SimulationSystemGroup))]
    public partial struct OptimizedSpawnerSystem : ISystem
    {
        public void OnCreate(ref SystemState state)
        {
            state.RequireForUpdate<Spawner>();
        }
    
        public void OnDestroy(ref SystemState state) { }
    
        [BurstCompile]
        public void OnUpdate(ref SystemState state)
        {
            EntityCommandBuffer.ParallelWriter ecb = GetEntityCommandBuffer(ref state);
            var prefabEntity = SystemAPI.GetSingleton<Spawner>().Prefab;
            var transform = SystemAPI.GetComponentLocalTransform>(prefabEntity);
    
            // Creates a new instance of the job, assigns the necessary data, and schedules the job in parallel.
            new ProcessSpawnerJob
            {
                ElapsedTime = SystemAPI.Time.ElapsedTime,
                defaultRotation = transform.Rotation,
                defaultScale = transform.Scale,
                Ecb = ecb
            }.ScheduleParallel();
        }
    
        private 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 ProcessSpawnerJob : IJobEntity
    {
        public EntityCommandBuffer.ParallelWriter Ecb;
        public double ElapsedTime;
        public quaternion defaultRotation;
        public float defaultScale;
        
        private void Execute([ChunkIndexInQuery] int chunkIndex, ref  Spawner spawner)
        {
            // If the next spawn time has passed.
            if (spawner.NextSpawnTime < ElapsedTime)
            {
                // Spawns a new entity and positions it at the spawner.
                Entity newEntity = Ecb.Instantiate(chunkIndex,  spawner.Prefab);
                Ecb.SetComponent(chunkIndex, newEntity, LocalTransform.FromPositionRotationScale(
                    spawner.SpawnPosition,
                    defaultRotation,
                    defaultScale
                    ));
                Ecb.AddComponent(chunkIndex, newEntity, new CubeTag());
                // Resets the next spawn time.
                spawner.NextSpawnTime = (float)ElapsedTime + spawner.SpawnRate;
            }
        }
    }

    ```
1. 先ほど作ったSubSceneに新規にObjectを2つ作ります
    1. 片方をSpawnerにします
        - SpawnAuthoringコンポーネントをつけます
        - Cube は後ほど作るPrefabを設定予定
        ![Spawner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65257/19ae899e-3ab9-42e2-b925-deea181a495d.png)

    1. 片方を落下防止用のFloorにします(GameObject→3D→Cubeを推奨)
        - LocalScale を調整して平たくします
        - PhysicsBodyコンポーネントをくっつけます
        - MotionType はStaticにしておきます
        - PhysicsShape を導入します
            - 形状は軽いのでBoxにしておきます
        ![Floor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65257/b5f965c1-40af-4c08-a3c2-729b405afde3.png)

1. 自動生成用のPrefabを作ります
    - GameObject→3D→Cube をベースにします
    - PhysicsBodyコンポーネントをくっつけます
        - 今回はデフォルトのままにします
    - PhysicsShape を導入します
        - 形状は軽いのでBoxにしておきます

1. Prefabを先ほどのSpawnAuthoringに登録します

ここまで出来れば無事再生すればPrefabで設定したCubeが落下して一定間隔で生成されます


# DebugのためにUIと連動させたい

まず

:::note warn
PolySpatial ではGraphicRaycaster の代わりに TrackedDeviceGraphicRaycaster コンポーネントがないとCanvas 操作ができないので要注意
:::

で、DOTSで開発するときはUIは今までのUnityのUIを利用するので `GameObjectベースのシステム上で動くUI` と `DOTS のSystem側` と連携させる必要があります。


## Cubeの個数取得UI
下記のようなUIに設定するScriptを用意（今回はMVPパターンにするのが面倒だったのでViewにロジックを全部入れ込んじゃってます）

```InfoView.cs
using TMPro;
using UnityEngine;
using Unity.Entities;

public class InfoView : MonoBehaviour
{
    private static string LABEL = "Cubes: ";
    public TextMeshProUGUI spawnCountText;

    private EntityManager entityManager;
    private EntityQuery cubeQuery;

    void Start()
    {
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        cubeQuery = entityManager.CreateEntityQuery(ComponentType.ReadOnly<CubeTag>());
    }

    void Update()
    {
        int count = cubeQuery.CalculateEntityCount();
        spawnCountText.SetText(LABEL + string.Intern(count.ToString()));
    }
}
```

ここのcubeQuery の部分で予めSpawnSystem側で設定したCubeTag のカウントをとってくるクエリを生成して、Update でそのクエリからEntity個数＝Cubeの個数をとってきています

## 生成間隔の調整
UISlider を用います。

```SliderView.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Unity.Entities;

public class SliderView : MonoBehaviour
{
    private static string LABEL = "Interval: ";
    [SerializeField] Slider spawnRateSlider;
    [SerializeField] TextMeshProUGUI labelText;
    private EntityManager entityManager;
    private EntityQuery spawnerQuery;

    void Start()
    {
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        spawnerQuery = entityManager.CreateEntityQuery(typeof(Spawner));
        spawnRateSlider.onValueChanged.AddListener(OnSpawnRateChanged);
    }

    void OnSpawnRateChanged(float newValue)
    {
        // Spawner コンポーネントを更新
        if (!spawnerQuery.IsEmptyIgnoreFilter)
        {
            var spawnerEntity = spawnerQuery.GetSingletonEntity();
            var spawner = entityManager.GetComponentData<Spawner>(spawnerEntity);
            spawner.SpawnRate = newValue;
            entityManager.SetComponentData(spawnerEntity, spawner);
        }
        
        labelText.SetText( LABEL + string.Intern( newValue.ToString("0.00")) );
    }
}
```

上記のようにSliderにコールバックを登録して、コールバック内でクエリを元に必要なSystemにアクセスしてEntityのパラメータをSetComponentData で更新するという内容です。


#  実践

<iframe width="560" height="315" src="https://www.youtube.com/embed/wVbpjudXuiA?si=8OcEvGBLQgQsKaeA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

Cube 2000個程度なら全然処理落ちしないです!!


# 感想・注意点
- DOTS は専用のUnity.Physics を使っているので、そのままではAppleVisionPro 側で操作できません
    - PolySpatial のRay周りの仕組みはGameObjectベースで組まれているため、Unity.Physics 向けに改変しないとGazeとかが反応しないです
- ユーザーが操作できないので、現状は完全に観賞用という感じです
    - PolySpatial 側のInput周りを改変できたら大革命になりそうです
- 流石に2019年のexpermeentalの頃よりは情報が増えているのとLLMの発達で、LLMを使いながらコーディングすることで２時間程度で今回のScriptは組めました


# 最後に

Graffity ではAppleVisionPro やMeta Quest3 を用いたAR/MRコンテンツを鋭意製作中ですので、開発に興味のある方は是非ご連絡ください

https://herp.careers/v1/graffity/A2od55EDf1M8