🧊

【Unity】10万オブジェクトを回転させたらどこがボトルネックになるのか(後編:ISystemで一桁msへ)

に公開

はじめに

前回は、Job System + Burst を使って
10万オブジェクトの回転処理(46ms → 3.5ms) まで最適化しました。

ただしその時点でも、フレーム全体としてはまだ重く、
ボトルネックはレンダリング側に残っていました。


なぜJob Systemだけでは不十分だったのか

前回の時点で回転処理自体はかなり軽くなっていましたが、
それでもフレーム全体としては大きな負荷が残っていました。

原因は、描画周りの処理です。

たとえロジック(Job)が高速でも、メインスレッド側では依然として
10万個の MeshRenderer を個別に処理する必要があります。

具体的には以下のような処理です:

  • Culling(可視判定)
  • Bounding Volume(バウンディング更新)
  • RenderQueue(描画順の整理)

これらがすべて「オブジェクト単位」で実行されるため、
結果として大きなCPU負荷になっていました。

この問題に対するアプローチが、Entities Graphicsです。

  • 目標:
    → GameObjectベースのオブジェクトをEntityに変換する

  • 仕組み:
    → 変換後は、これらの処理を Entities Graphics が内部的に管理し、
    BatchRendererGroup を利用して描画処理をまとめて実行します。

その結果:

数万〜数十万単位のオブジェクトでも、
ごく少数のバッチとして扱えるようになります。

■ 結果


今回は DOTS(Entities + Entities Graphics)に移行した結果:

  • フレーム時間:約120ms → 約9ms
  • 110FPS前後で安定

ほぼ別物レベルまで改善しています。


■ 比較

指標 改造前(GameObject) 改造後(Pure DOTS) 改善
Batches ~120,000 74 約1486倍削減
SetPass Calls 56 26 約2.1倍改善
Tris / Verts 不安定 安定 描画の一貫性向上
Frame Time 120ms(8FPS) 9ms(110FPS) 約13倍高速化

■ Profilerを見ると何が起きているか



Profilerを確認すると:

  • Jobカテゴリに大量のワーカースレッド(Worker 0〜18)
  • CPU全コアがほぼ均等に使用されている

つまり:

完全に並列化された状態で処理が走っている


■ DOTSのアプローチ

DOTSでは考え方が変わります:

個別オブジェクト管理
データをまとめて処理(データ指向)


■ イメージ

  • 従来:
    → 10万の兵士がバラバラに動く
  • DOTS:
    → 1つの訓練された部隊として一括処理

■ 実装


■ データ定義 + Baker

using UnityEngine;  
using Unity.Entities;  
using Unity.Mathematics;  
  
// Entityに持たせる設定データ(純粋なデータ構造)  
public struct SpawnerConfig : IComponentData  
{  
    public Entity Prefab;  
    public float3 SpawnPosition;  
}  
  
// 各エンティティの回転軸  
public struct RotationAxis : IComponentData  
{  
    public float3 Value;  
}  
  
public class SpawnerAuthoring : MonoBehaviour  
{  
    public GameObject prefab;  
  
    // Bakerは「GameObject → Entity」変換時に実行される  
    class Baker : Baker<SpawnerAuthoring>  
    {  
        public override void Bake(SpawnerAuthoring authoring)  
        {  
            var entity = GetEntity(TransformUsageFlags.None);  
  
            AddComponent(entity, new SpawnerConfig  
            {  
                // PrefabもEntityとして取得(重要)  
                Prefab = GetEntity(authoring.prefab, TransformUsageFlags.Dynamic),  
                SpawnPosition = authoring.transform.position  
            });  
        }  
    }  
}

ポイント

  • MonoBehaviourAuthoring
  • BakerEntity変換の入口
  • IComponentData純粋なデータ

ロジックとデータを完全に分離するのがDOTSの基本思想


■ Entity生成(ISystem)

using Unity.Entities;  
using Unity.Transforms;  
using Unity.Mathematics;  
using Unity.Collections;  
using Unity.Burst;  
  
[BurstCompile]  
public partial struct SpawnSystem : ISystem  
{  
    [BurstCompile]  
    readonly public void OnUpdate(ref SystemState state)  
    {  
        // Singletonから設定を取得  
        if (!SystemAPI.TryGetSingleton<SpawnerConfig>(out var config)) return;  
  
        // 一度だけ実行  
        state.Enabled = false;  
  
        int count = 100000;  
  
        // Entityを一括生成(ここがGameObjectとの大きな違い)  
        var entities = state.EntityManager.Instantiate(config.Prefab, count, Allocator.Temp);  
  
        var rand = new Unity.Mathematics.Random(123);  
  
        foreach (var entity in entities)  
        {  
            // 球内ランダム配置(前回と同じ見た目を維持)  
            float3 dir = rand.NextFloat3Direction();  
            float radius = 50f * math.pow(rand.NextFloat(), 1f / 3f);  
            float3 pos = config.SpawnPosition + (dir * radius);  
  
            state.EntityManager.SetComponentData(entity, LocalTransform.FromPosition(pos));  
  
            // 回転軸を付与(完全にデータとして保持)  
            state.EntityManager.AddComponentData(entity, new RotationAxis  
            {  
                Value = rand.NextFloat3Direction()  
            });  
        }  
    }  
}

ここが重要

一括生成

Instantiate(..., count)

GameObjectのループ生成とは別物(圧倒的に軽い)


■ 回転処理(ISystem)

using Unity.Burst;  
using Unity.Entities;  
using Unity.Mathematics;  
using Unity.Transforms;  
  
[BurstCompile]  
public partial struct RotateSystem : ISystem  
{  
    [BurstCompile]  
    readonly public void OnUpdate(ref SystemState state)  
    {  
        float speed = math.radians(100f);  
        float dt = SystemAPI.Time.DeltaTime;  
  
        // Componentをまとめて取得して並列処理  
        foreach (var (transform, axis) in   
            SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationAxis>>())  
        {  
            // クォータニオン計算(Burst最適化される)  
            quaternion delta = quaternion.AxisAngle(axis.ValueRO.Value, speed * dt);  
  
            // Transformではなく「構造体」を直接更新  
            transform.ValueRW.Rotation =  
                math.mul(math.normalize(delta), transform.ValueRO.Rotation);  
        }  
    }  
}

DOTSで速くなる理由

① データが連続している(キャッシュ効率)

  • LocalTransform は構造体
  • メモリ上に連続配置

CPUキャッシュに乗りやすい


② 分岐が少ない

  • GameObject:状態がバラバラ
  • DOTS:同じデータ構造

SIMD最適化が効く


③ バッチ処理

  • 従来:10万回ループ + 個別処理
  • DOTS:まとめて処理

ここが一番大きい差


④ Entities Graphics

  • Culling
  • DrawCall
  • データ同期

すべてバッチ化


■ まとめ

  • Job Systemだけでは「計算」は速くなるが
    描画側がボトルネックになる
  • DOTSでは
    データ構造ごと変えることで根本解決

■ 最終的な理解

今回一番大きかったのはこれです:

「処理を速くする」のではなく
「処理の形を変える」


■ おわりに

正直ここまで差が出るとは思っていませんでした。

単純な最適化というより、

設計そのものを変えるとここまで変わる

というのが一番の収穫でした。

■ 追記:IJobEntity + ScheduleParallel への置き換え

公式ドキュメントを確認すると、
Component の取得は SystemState を通して扱うのが推奨されているようです。

これは、SystemがどのComponentにアクセスしているかを正しく追跡するためで、
state.Dependency を通じた Job の依存関係管理に重要な役割を持っています。

この方針に合わせて、回転処理も IJobEntity + ScheduleParallel を使う形に書き換えました:

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct RotateSystem : ISystem
{
    [BurstCompile]
    readonly public void OnUpdate(ref SystemState state)
    {
        var rotateJob = new RotateJob2
        {
            dt = SystemAPI.Time.DeltaTime,
            speed = math.radians(100f)
        };

        // Jobを並列実行しつつ、依存関係を自動管理
        state.Dependency = rotateJob.ScheduleParallel(state.Dependency);
    }
}

[BurstCompile]
public partial struct RotateJob2 : IJobEntity
{
    public float dt;
    public float speed;

    // ref = 書き込み、in = 読み取り専用
    readonly public void Execute(ref LocalTransform transform, in RotationAxis axis)
    {
        quaternion delta = quaternion.AxisAngle(axis.Value, speed * dt);
        transform.Rotation = math.mul(math.normalize(delta), transform.Rotation);
    }
}

この書き方にすることで:

  • SystemがアクセスするComponentが明示的にトラッキングされる
  • Job間の依存関係が自動的に解決される
  • よりDOTSの設計思想に沿った形になる

結果として、フレーム時間はさらにわずかに改善し、
最終的には 約8ms前後 で安定するようになりました。

パフォーマンスの差自体は大きくないものの、

DOTSでは「書き方そのもの」がパフォーマンスに影響する

という点が特に印象的でした。

■ なぜ SystemAPI.Query より速くなったのか

今回の改善で重要なのは、APIの違いというよりも
「実行方式の違い」です。

SystemAPI.Query を使った foreach は、
ECSのデータ構造を利用しているものの、
実行自体はメインスレッド上で逐次処理されます。

一方で IJobEntity + ScheduleParallel は:

  • データをChunk単位で分割
  • ワーカースレッドに分散
  • 並列実行

という形で処理されるため、
CPUの全コアを活用することができます。

つまり:

SystemAPI.Query は「高速なforループ」、
IJobEntity は「並列処理前提の仕組み」

という違いになります。

■ 補足:DeltaTimeの取得方法について

SystemAPI.Time.DeltaTimestate.World.Time.DeltaTime
どちらも同じ値を取得できますが、

DOTSの推奨としては SystemAPI.Time を使う形になります。

SystemAPI 経由で取得することで、
Burstコンパイル時に最適化されやすく、
余計なアクセスコストを避けられる可能性があります。

今回の検証でも、SystemAPI.Time を使った方が安定して高速でした。

なぜ SystemAPI の方が高速なのか

SystemAPI.Time が高速に動作する理由は、
単にアクセスが短いからではなく、
Burstコンパイル時に最適化されやすい形になっているためです。

SystemAPI 経由のアクセスは、
Systemの実行コンテキストに紐づいた「純粋なデータ」として扱われるため、

  • インライン展開(inline)
  • 定数伝播(constant propagation)
  • SIMD最適化

といった最適化が効きやすくなります。

一方で state.World を経由する場合は、
内部的にマネージドオブジェクトへの参照を含むため、
Burstから見ると「外部状態に依存する可能性のあるアクセス」として扱われます。

その結果:

  • 毎回値を取得するコードが残る
  • 最適化が制限される

といった違いが生まれます。

■ おまけ:ちょっとした可視化

せっかくなので、回転だけだと少し味気なかったため、
各エンティティに色を持たせてみました。

回転軸をそのまま色に変換することで、
どの方向に回っているのかが視覚的に分かるようになります。

sin関数と時間を使って色が変化するようにしています

単純な処理ですが、こうして見ると
DOTSで大量オブジェクトを動かす面白さが少し伝わる気がします。

Discussion