😸

Unity6のゲーム開発にて、DOTSを本格導入してみた!(Vol.1)

に公開

Unity6のDOTSについて

今現在Unityは、Unity6へと進化したことで、今までのオブジェクト指向でのゲーム開発ではなく、データ型指向への転換を推奨し始めています。
私的には、ただUnityでゲームを作れる、オブジェクト指向でゲームが作れる、のではなく、どれだけ既存エンジンの限界を引き出し、どれだけパフォーマンスを下げずにゲームをプレイ出来るようにするのか、という人材が今後求められるのではと感じます。

Vol.1のテーマ

ということで、今回は今私が開発しようと企てている弾幕ゲームを作るために、JobSystem、そして、BurstCompilerを使い、ゲーム開発のCoreから作っていこうと思います。

第一の課題

弾幕ゲームにはもちろん、が必要です。
ですが、例えばヒエラルキーに球オブジェクトを追加、Prefab化、それを標準機能のInstantiateを使い、オブジェクトを大量に生成してしまうと、一気にFPSが低下し、最悪開発中にUnity自体がクラッシュする、ということもあります。
(※実際同じくゲーム開発をしている友人にも、開発中に何度もクラッシュし、コンテストの発表時間でフリーズしたりなどのアクシデントがありました)
それを防ぐために必要なのは、このDOTSの考え方なのです。

GameObjectは「デブすぎる」

UnityのGameObjectは位置情報だけじゃなく、レンダラー、物理、スクリプトなどを贅肉を一人で抱えた巨大な参照型の塊なのです。そのため、それをInstantiateすると、メモリ空間のあちこちに、データが断片化して配置されてしまいます。
それにより、CPUが「次の弾のデータを処理しよう」とした瞬間に、メモリのあちこちを探し回るハメになり、キャッシュミスが発生してしまい、それがゲームでの挙動のカクつき、そして最悪クラッシュに繫がってしまいます。

実験してみよう

実験として、まずは、Sphereをヒエラルキーに追加し、Prefab化、そしてInstantiateコードを書いて、やってみると...

image.png
Statisticsの結果にある通り、FPSはほぼ瀕死状態、インスタンスを9個生成しようとした時点で、DrawCallは35回、これ以上はUnityがクラッシュしかけたので、実行を止めました。

ここから本題

では、ここからCoreの本実装を入っていきます。
まずは、弾のデータ、そして今後作る必要のあるデータを管理するSharedData.csを作っていきます。
(バージョンは6000.4.5f1)

// SharedData.cs
using Unity.Mathematics;
using Unity.Entities;

namespace Core.Shared
{
   public struct BulletComponent : IComponentData
   {
      public float3 Position;
      public float3 Velocity;
      public float LifeTime;
      public bool isActive;
   }
}

まず、こちらのコードは、Unityが提供しているEntitiesパッケージを使用し、その中のIComponentDataというインターフェースをBulletComponentに継承させて、純粋なデータ構造体として、このDataを使います。

なぜ IComponentData(構造体)なのか?

ここがオブジェクト指向(OOD)からデータ指向(DOD)への最大の転換点です。
従来の MonoBehaviour はクラス(参照型)だったためメモリ上にバラバラに配置されていましたが、今回のように IComponentData を実装した構造体(値型)を NativeArray 上で管理することで、データをメモリ上へ連続配置できるようになります。
これにより、CPUはメモリのあちこちを探し回る必要がなくなり、隣接データをまとめてキャッシュへ先読み(プリフェッチ)しやすくなります。
キャッシュミスを極限までゼロにするための、これがデータ指向の第一歩です。

そしてここから、実際に弾の計算をJobSystem,BurstCompilerを使い、UpdateBulletJob.cs、描画を行うために、CoreTicker.csを作っていきます。

// UpdateBulletJob.cs
using UnityEngine;
using Unity.Collections;
using Unity.Jobs;
using Unity.Burst;

namespace Core.Job
{
 [BurstCompile]
public struct UpdateBulletJob : IJobParallelFor
{
    // NativeArrayを使うことで、全コアから安全にアクセスできる
    public NativeArray<Shared.BulletComponent> Bullets;
    public NativeArray<Matrix4x4> Matrices;
    public float DeltaTime;

    // i は各スレッドに割り振られたインデックス
    public void Execute(int i)
    {
        Shared.BulletComponent bullet = Bullets[i];
        if(!bullet.isActive) return;

        // 座標更新
        bullet.Position += bullet.Velocity * DeltaTime;
        bullet.LifeTime -= DeltaTime;

        if(bullet.LifeTime <= 0)
        {
            bullet.isActive = false;
        }
        else
        {
            Matrices[i] = Matrix4x4.TRS(bullet.Position, Quaternion.identity, Vector3.one * 0.2f);
        }

        Bullets[i] = bullet;
    }
}   
}

// CoreTicker.cs
using UnityEngine;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

namespace Core
{
    public class CoreTicker : MonoBehaviour
{
    private const int MaxBullets = 100000;
    private NativeArray<Shared.BulletComponent> _bullets;
    private NativeArray<Matrix4x4> _gpuMatrices;

    [SerializeField] private Mesh _bulletMesh;   // 弾のモデル
    [SerializeField] private Material _bulletMat; //弾の材質

    private Matrix4x4[] _matrices = new Matrix4x4[MaxBullets]; // GPUに渡す行列データ
    private RenderParams _renderParams;

    void Start()
    {
        _renderParams = new RenderParams(_bulletMat);

        // メモリ確保
        _bullets = new NativeArray<Shared.BulletComponent>(MaxBullets, Allocator.Persistent);
        _gpuMatrices = new NativeArray<Matrix4x4>(MaxBullets, Allocator.Persistent);
    }


    void Update()
    {
        TickPlayer();
        TickBullets();
    }

    void OnDestroy()
    {
        // NativeArray必ず手動でDisposeが必要
        if(_bullets.IsCreated) _bullets.Dispose();
        if(_gpuMatrices.IsCreated) _gpuMatrices.Dispose();
    }

    private void TickPlayer()
    {
        // あくまで実験作業のため、旧来のInput.GetKeyを使用して、入力検知を実装
        if(Input.GetKey(KeyCode.D))
        {
            UnityEngine.Debug.Log("D key is pressed!");
            int count = 36;
            float speed = 10f;

            for(int i = 0; i < count; i++)
            {
                float angle = i * (360f / count) * Mathf.Deg2Rad;
                float3 dir = new float3(Mathf.Cos(angle), 0, Mathf.Sin(angle));

                EmitBullet(transform.position, dir * speed);
            }
        }
    }
    private void TickBullets()
    {
        var job = new Job.UpdateBulletJob
        {
           Bullets = _bullets,
           Matrices = _gpuMatrices,
           DeltaTime = Time.deltaTime  
        };

        JobHandle handle = job.Schedule(MaxBullets, 64);

        // Jobの完了を待機 
        handle.Complete();

        int activeCount = 0;

        for (int i = 0; i < MaxBullets; ++i)
        {
            if(_bullets[i].isActive)
            {
                _matrices[activeCount] = _gpuMatrices[i];
                activeCount++;
            }
        }
        
            // まとめて描画命令を発行
        if (activeCount > 0)
        {
           _renderParams.worldBounds = new Bounds(Vector3.zero, Vector3.one * 2000f);
           Graphics.RenderMeshInstanced(_renderParams, _bulletMesh, 0, _matrices, activeCount);
        }
    }

    private void EmitBullet(float3 position, float3 velocity)
    {
        for(int i = 0; i < MaxBullets;i++)
        {
            var bullet = _bullets[i];
            if(!bullet.isActive)
            {
                bullet.Position = position;
                bullet.Velocity = velocity;
                bullet.LifeTime = 60.0f;
                bullet.isActive = true;

                _bullets[i] = bullet;
                return;
            }
        }
    }
}   
}


ここまでやったあとの結果がこちらです。

スクリーンショット 2026-05-17 132153.png
image.png

FPSは65.3 FPS、それも10万個のオブジェクトを、242DrawCallまで抑えて描画できています。

なぜここまで差が出るのか

従来の Instantiate では4.8 FPSでクラッシュ寸前だった環境が、なぜこのコードに変えただけで、10万個を動かしても65.3 FPSを維持できるのか。

理由は大きく分けて3つ、すべて「CPUとメモリの物理的な特性を味方につけたから」です。

  1. [BurstCompile] と float3 による「SIMD自動ベクトル化」
    UpdateBulletJob の頭に宣言されている [BurstCompile] 属性。これにより、JobSystem上で動作するC#コードは、LLVMベースでCPU向けに極限まで最適化されたネイティブマシンコードとして実行されます。

Unity 6のBurstコンパイラは、C#の中間言語(IL)をLLVMベースの最適化パイプラインへ渡し、対象CPU向けに高度に最適化されたネイティブマシンコードへ変換します。

通常のMono/JIT実行とは異なる経路でコンパイルされるため、CPUキャッシュ効率やSIMD命令を前提とした、極めて低レベルな最適化が可能になります。

さらに、Shared.BulletComponent で使用している float3(Unity.Mathematics)による座標計算は、BurstコンパイラによってSIMD(単一命令複数データ処理)命令へ自動ベクトル化されます。

CPUは10万個の弾を1個ずつ逐次計算しているわけではなく、BurstによるSIMD最適化によって、複数の座標計算をまとめて並列実行できるケースがあります。

IJobParallelFor によるマルチスレッドの完全掌握

CoreTicker.cs 内の以下の呼び出しに注目してください。

JobHandle handle = job.Schedule(MaxBullets, 64);

これは、「10万個の弾の更新ループを、64個ずつの塊(インナーループ)にスライスして、CPUの空いている全Worker Thread(論理コア)へ一斉に分配しろ」という命令です。

従来のMonoBehaviourのようにメインスレッドを1本の巨大なループで占有しないため、裏で10万発をブン回している間もメインスレッドのフレームタイムは最小限に抑えられ、ゲームの入力検知やUIの更新が重くなる要素を根本から排除しています。

弾幕にPrefabやBakerは不要

既存のDOTS記事の多くは、Prefabを Baker システムでEntityに変換する手法を紹介しています。しかし、グラフィックスAPIの低レイヤー視点に立てば、描画に必要なのはMesh(形状データ)Material(材質データ)Matrix4x4(位置行列)の3つだけです。
複雑なFBXモデルならまだしも、単一のMeshで済む「弾」に対して、わざわざUnityの重たいPrefabやオーサリングを介してメモリを汚す必要はありません。
CoreTicker.cs で行っているように、生のアセット(Mesh/Material)を直接保持し、Jobで計算した生行列をそのままUnity 6の新API Graphics.RenderMeshInstanced へゼロコピーで流し込む。

Prefabという古い贅肉をメモリに1Bitも載せない。これこそが、本質的なデータ指向(DOD)設計の正体です。

まとめ

今回は、弾幕ゲームのCoreとなる「CPU側の並列化」と「Prefabを完全排除した最速の描画ルート」を実装し、10万個を実用プロトタイプとして動かすファクトを示しました。

ですが、いくらCPU側を爆速にし、Graphics.RenderMeshInstanced で10万発をねじ伏せたとしても、ゲームが複雑化して敵やステージのデータ、複雑なマテリアルが絡んでくると、今度はこのC#配列の詰め直しループ(for (int i = 0; i < MaxBullets; ++i))自体がメインスレッドの新たなボトルネックになり始めます。

そこで次回の Vol.2 では、このCPU・GPU間のデータのやり取りすらも完全に非同期・ゼロコピー化する、Unity 6の真の切り札「BatchRendererGroup(BRG)のハックと、グラフィックスバッファへの生データ流し込み」について、実装したのち、解説していきたいと思います。

Discussion