📌 この記事は Zenn に投稿した内容のアーカイブです。

最近、DOTS(Data-Oriented Tech Stack)が実際どのくらい効果があるのか気になっていたので、
簡単な検証を兼ねて触ってみることにしました。

せっかくなので「大量のオブジェクトを動かした場合にどこがボトルネックになるのか?」も含めて、
段階的に確認していこうと思います。

この記事ではまず、LateUpdateでの回転処理のボトルネックと、
Job Systemによる最適化の効果にフォーカスします。

レンダリング周り(CullingやDrawCall)については、次回以降で触れる予定です。

■ 検証環境

  • CPU:i7-12700K
  • GPU:RTX 3070
  • Unity:URPの新規プロジェクト
  • オブジェクト数:10万(Cube)

■ テスト内容

シンプルに「10万個のCubeを生成して回転させるだけ」です。

  • BoxColliderは削除

  • ランダム位置に配置

  • 各オブジェクトにランダムな回転軸

    void Start() { var root = new GameObject(“CubeRoot”).transform;

    for (int i = 0; i < spawnCount; i++)
    {
        var go = Instantiate(prefab, Random.insideUnitSphere * 50f, Quaternion.identity, root);
        transforms.Add(go.transform);
    }
    

    }

    void LateUpdate() { float dt = Time.deltaTime;

    for (int i = 0; i < transforms.Count; i++)
    {
        transforms[i].Rotate(rotationAxes[i], 100f * dt);
    }
    

    }


■ 結果(最適化前)


フレーム時間は約 120ms 前後。

特に目立つのが:

  • LateUpdate:約46ms

■ ボトルネックの正体

これはかなりシンプルで、

10万回のTransform操作をメインスレッドで直列実行している

というのが原因です。

Unityの Transform は軽く見えて実はそこそこ重い処理なので、それを10万回ループすると普通に詰みます。


■ レンダリング側の状況(軽く触れる)

Profilerを見ると以下もかなり重いです:

  • RenderLoop.Draw(約44ms)
    • 主に Culling(可視判定)
    • Transformとの同期コスト
  • Gfx.WaitForGfxCommands(約42ms)
    • Render Thread待ち(DrawCall地獄)

ざっくり言うと:

10万個のMeshRendererを1個ずつチェック&送信してる

このあたりは次回の記事でちゃんとやります。


■ レンダリングを止めてみる


全オブジェクトを SetActive(false) にすると:

  • フレーム時間:約65ms
  • 内訳:ほぼ LateUpdate(46ms)

つまり、

回転処理だけでも既に重い

ことが確認できます。


■ Job Systemで最適化してみる

ここからが本題です。

「単純なループなら並列化できるよね」ということで、
IJobParallelForTransform + Burst を使います。


■ 実装

// Transform を Job で扱うための専用コンテナ
private TransformAccessArray transformAccessArray;

// 各オブジェクトごとの回転軸(Job から参照するため NativeArray を使用)
private NativeArray<Vector3> rotationAxes;

// スケジュールした Job のハンドル(依存関係管理用)
private JobHandle jobHandle;

void Start()
{
    // NativeArray はマネージド外のメモリに確保されるため、明示的な解放が必要
    rotationAxes = new NativeArray<Vector3>(spawnCount, Allocator.Persistent);

    for (int i = 0; i < spawnCount; i++)
    {
        rotationAxes[i] = Random.onUnitSphere;
    }

    // Transform 配列を Job 用にラップ
    // IJobParallelForTransform ではこれを通して Transform にアクセスする
    transformAccessArray = new TransformAccessArray(transformArray);
}

void Update()
{
    // Job に渡すデータをセット
    var job = new RotateJob
    {
        DeltaTime = Time.deltaTime,
        Axes = rotationAxes
    };

    // Job をスケジュール(非同期で実行開始)
    // この時点ではまだ処理は完了していない点に注意
    jobHandle = job.Schedule(transformAccessArray);
}

void LateUpdate()
{
    // Job の完了を待機
    // ここで Complete することで、このフレーム内で結果を反映させる

    // ※ Complete を呼ばない場合:
    // - 次フレームまで処理が持ち越される可能性がある
    // - Transform の更新タイミングが不定になる

    // ※ LateUpdate で待つ理由:
    // - Update 直後に待つと並列化のメリットが薄れる
    // - できるだけギリギリまで Job を走らせておきたい

    jobHandle.Complete();
}

void OnDestroy()
{
    // Native メモリは自動解放されないため必ず Dispose する
    if (rotationAxes.IsCreated) rotationAxes.Dispose();
    if (transformAccessArray.isCreated) transformAccessArray.Dispose();
}

// Burst によってネイティブコードに最適化される
[BurstCompile]
struct RotateJob : IJobParallelForTransform
{
    public float DeltaTime;

    // Job 内では読み取り専用データとして扱う(安全性&最適化のため)
    [ReadOnly] public NativeArray<Vector3> Axes;

    public void Execute(int index, TransformAccess transform)
    {
        // Transform.Rotate は使用できないため、
        // localRotation を直接更新する必要がある
        Quaternion currentRot = transform.localRotation;

        Quaternion delta = Quaternion.AngleAxis(100f * DeltaTime, Axes[index]);

        transform.localRotation = delta * currentRot;
    }
}

■ 結果(最適化後)

  • LateUpdate(相当処理):約3.5ms

■ 何が起きたのか

ポイントは2つです:

① 並列化

  • 10万回の処理 → 複数コアで分散

② Burstコンパイル

  • SIMD最適化
  • ネイティブコード化
  • キャッシュ効率改善

結果として:

単なるforループが「CPUフル活用の並列処理」に変わった

というのが本質です。


■ まとめ

  • 10万オブジェクトの回転は
    メインスレッドでは完全にボトルネックになる
  • Job System + Burstで
    46ms → 3.5msまで削減

■ ここまでやってみて

今回の検証はあくまで「回転処理」に絞ったものでしたが、
実際にはまだレンダリング側の負荷がかなり大きく残っています。

一方で、このあと試しにDOTS(ISystem)ベースに置き換えてみたところ、
フレーム時間自体はさらに大きく改善し、最終的には一桁ms台まで落とすことができました。

ただし、このあたりは少し話が長くなるので、
詳細は次回の記事でまとめようと思います。