【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
});
}
}
}
ポイント
-
MonoBehaviour→ Authoring -
Baker→ Entity変換の入口 -
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.DeltaTime と state.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