Unity6のDOTSについて
今現在Unityは、Unity6へと進化したことで、今までのオブジェクト指向でのゲーム開発ではなく、データ型指向への転換を推奨し始めています。
私的には、ただUnityでゲームを作れる、オブジェクト指向でゲームが作れる、のではなく、どれだけ既存エンジンの限界を引き出し、どれだけパフォーマンスを下げずにゲームをプレイ出来るようにするのか、という人材が今後求められるのではと感じます。
Vol.1のテーマ
ということで、今回は今私が開発しようと企てている弾幕ゲームを作るために、JobSystem、そして、BurstCompilerを使い、ゲーム開発のCoreから作っていこうと思います。
第一の課題
弾幕ゲームにはもちろん、弾が必要です。
ですが、例えばヒエラルキーに球オブジェクトを追加、Prefab化、それを標準機能のInstantiateを使い、オブジェクトを大量に生成してしまうと、一気にFPSが低下し、最悪開発中にUnity自体がクラッシュする、ということもあります。
(※実際同じくゲーム開発をしている友人にも、開発中に何度もクラッシュし、コンテストの発表時間でフリーズしたりなどのアクシデントがありました)
それを防ぐために必要なのは、このDOTSの考え方なのです。
GameObjectは「デブすぎる」
UnityのGameObjectは位置情報だけじゃなく、レンダラー、物理、スクリプトなどを贅肉を一人で抱えた巨大な参照型の塊なのです。そのため、それをInstantiateすると、メモリ空間のあちこちに、データが断片化して配置されてしまいます。
それにより、CPUが「次の弾のデータを処理しよう」とした瞬間に、メモリのあちこちを探し回るハメになり、キャッシュミスが発生してしまい、それがゲームでの挙動のカクつき、そして最悪クラッシュに繫がってしまいます。
実験してみよう
実験として、まずは、Sphereをヒエラルキーに追加し、Prefab化、そしてInstantiateコードを書いて、やってみると...

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 を実装した構造体(値型)は、Unityの内部システム(Archetypeチャンク)によって、メモリ上で隙間なく一列に並べられます。
これにより、CPUはメモリのあちこちを探し回る必要がなくなり、1つのデータを読み込むついでに、隣にある次の弾のデータも一気にキャッシュメモリへ先読み(プリフェッチ)できるようになります。キャッシュミスを極限までゼロにするための、これがデータ指向の第一歩です。
そしてここから、実際に弾の計算を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;
}
}
}
}
}
ここまでやったあとの結果がこちらです。
FPSは65.3 FPS、それも10万個のオブジェクトを、242DrawCallまで抑えて描画できています。
なぜここまで差が出るのか
従来の Instantiate では4.8 FPSでクラッシュ寸前だった環境が、なぜこのコードに変えただけで、10万個を動かしても65.3 FPSを維持できるのか。
理由は大きく分けて3つ、すべて「CPUとメモリの物理的な特性を味方につけたから」です。
- [BurstCompile] と float3 による「SIMD自動ベクトル化」
UpdateBulletJob の頭に宣言されている [BurstCompile] 属性。これがC#の実行速度を完全にネイティブ(C++)の領域へと引き上げます。
Unity 6のBurstコンパイラは、C#の中中間言語(IL)を完全に無視し、LLVMを介して対象CPU専用の最適化マシンコード(ネイティブアセンブリ)を直接吐き出します。
さらに、Shared.BulletComponent で使用している float3(Unity.Mathematics)による座標計算は、コンパイラによってSIMD(単一命令複数データ処理)命令へと自動変換されます。
CPUは10万個の弾を1個ずつモタモタ計算しているのではなく、1回の命令サイクルで4個、あるいは8個の弾の座標を同時に並列書き換えしているのです。
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)のハックと、グラフィックスバッファへの生データ流し込み」について、実装したのち、解説していきたいと思います。

