はじめに
【御礼】
はじめに、今回のBoidsアルゴリズムのコードは IndieVisualLab様が公開されている UnityGraphicsProgramming の第1章にある「Boidsのアルゴリズム」を利用させていただきました。ありがとうございます。
Boidsアルゴリズムは、鳥や魚、動物の群れの集合的な動きをスクリプトでシミュレートすることができます。
(是非UnityGraphicsProgrammingの参照を)
この記事はBoidsアルゴリズムを
- ComputeShader + GPU Instancing Indirect
- ComputeShader + GameObject
- Dots + ComputeShader
- Dots Only
の4パターンで実行してみた結果の紹介です。
この記事では結果のみでコードはほぼ省いてあります。
コードが気になれば是非Githubを覗いてみてください
◼︎環境
- Unity 6000.0.49f1
- URP
- Macbook M2 Max
実行結果
全サンプル、約16000体のボイドを動かしています
各手法によるパフォーマンス比較
ComputeShader + GPU Instancing Indirect
ComputeShaderで並列に計算した位置と速度をCPUに戻さず、GPU Instancingに渡して描画してもらいます。
つまり、GPU(計算) → GPU(描画) 。CPUが間に挟まないため最も高速です
Graphics,DrawMeshInstancedIndirect
で一度に描画します
// ComputeBufferに格納されたデータを使って大量のメッシュを一度に描画するAPI
//
// 1000個のBoidsでも1回のDrawcall + レイテンシの削減
Graphics.DrawMeshInstancedIndirect
(
InstanceMesh, // インスタンシングするメッシュ
0, // submeshのインデックス
InstanceRenderMaterial, // 描画を行うマテリアル
bounds, // 境界領域
argsBuffer // GPUインスタンシングのための引数のバッファ
);
- FPS avg : 350
- Batches : 7
脅威の350FPS
- なんてったって爆速
- 同一メッシュのドローコールを大幅削減
- GPU Instancingにより16000体のBoidsを1回のドローコール
- 楽に大量描画出来るため、パーティクル演出に利用◎
- ボイド毎にロジックを挟む処理が難しい
GameObjectによるゲームロジックは実行できないため、GPU内部で完結する演出に利用するのが良さそうです
// これはできない
boids[i].GetComponent<HogeHoge>().DoSomething();
boids[i].transform.parent = someParent;
ComputeShader + GameObject
ボイド一体をPrefabで定義。
16000体のPrefabをInstantiateします(この文言からわかる絶対的な遅さ)
そしてUpdateメソッドのComputeShaderで求めた結果をCPUでTransform代入します
毎フレーム、16000体ものボイドのTransformを書き換えているためGameObjectのCPU処理、メモリパフォーマンスが最悪
最も遅いです
- FPS avg : 29.3
- Batches : 100前後
- Prefabであるためコンポーネントを指して処理の変化/ロジックを入れることができる
- 遅い。それが全て
CPUInstancingは同じく利用していますが、
GameObjectベースでは一回のドローコールで扱えるインスタンス数に上限があるためバッチ数が増えていると思われます
そして、ComputeShaderからCPUへのデータの読み戻し(GetData())は同期処理でコストがかかるのもGPUOnlyと比較して重くなってしまう要因ですね
DOTS編
ここから上記のプログラムに加えて、ボイド単位をEntityにしてみます
予測としてはGameObjectよりかは高速に、
ComputeShaderによるGPUOnlyよりかは処理は遅そうです
1. 最適化なし
まずは ComputeShaderでシミュレーション + その結果を単純ループでEntityに結果を指してみます
約16000体のボイド位置を更新するSystemで以下のように直列で回してみる
private void UpdateActorTransforms(ref SystemState state)
{
// GPUBoidsSystemで計算されたBoidDataを共有コンポーネントから取得
if (!SystemAPI.HasSingleton<BoidDataShared>())
{
return; // BoidDataSharedが存在しない場合は処理を終了
}
var boidDataShared = SystemAPI.GetSingleton<BoidDataShared>();
var boidDataArray = boidDataShared.BoidDataArray;
// 各ActorのTransformを更新
foreach (var (actor, transform) in SystemAPI.Query<RefRW<ActorComponent>, RefRW<LocalTransform>>())
{
var index = actor.ValueRO.Index;
// GPUBoidsSystemで計算されたBoidDataを使用
var boidData = boidDataArray[index];
transform.ValueRW.Position = boidData.Position;
// 速度ベクトルから回転を計算して適用
var velocity = boidData.Velocity;
if (math.lengthsq(velocity) > 0.001f)
{
var forward = math.normalize(velocity);
transform.ValueRW.Rotation = quaternion.LookRotationSafe(forward, math.up());
}
}
}
- FPS avg : 23.4
- Batches : 30
Batchesは減りましたが、FPSの改善は全然ですね。
CPU側の高負荷がProfilerでわかります
ECSを利用したからと言ってそれだけでは劇的に改善しないことがわかりました
IJobEntityで最適化
ComputeShaderで求めた変換行列をJobSystemを利用して並列にEntityにいれるように最適化してみます
▼以下のJobSystemを作成
/// <summary>
/// 特定のEntityを対象に並列実行する
/// </summary>
[BurstCompile]
public partial struct ActorTransformUpdateJob : IJobEntity
{
[ReadOnly] public NativeArray<DOTS1.BoidData> BoidDataArray;
/// <summary>
/// このメソッドが、クエリにマッチしたエンティティごとに並列で実行される
/// 'in'は読み取り専用の引数を示す 'ref'は書き込み可能
/// </summary>
private void Execute(ref DOTS1.BoidUnit boid, ref LocalTransform transform)
{
var index = boid.Index;
// BoidDataArrayから対応するBoidDataを取得
var boidData = BoidDataArray[index];
// 位置を更新
transform.Position = boidData.Position;
var velocity = boidData.Velocity;
if (!(math.lengthsq(velocity) > 0.001f)) return;
// 速度ベクトルから回転を計算して適用
var forward = math.normalize(velocity);
transform.Rotation = quaternion.LookRotationSafe(forward, math.up());
}
}
FPSは90前後出る!
JobSystemによる並列化は偉大
しかし100は超えません。
GPUで処理した大量の結果をGetDataでCPUにコピーしていること。
そしてDrawCallが1回ではないからですね。これは仕方ない
JobSystemでBodsアルゴリズムを処理する
ComputeShaderを消して群集の処理自体もJobSystemで動かしてみます
しかし1体動かすのに残り約16000体の位置を見るのは非効率すぎます。
これではGPU処理に勝てないので以下の空間分割による手法を導入してみます
-
グリッド/ハッシュ化 : シミュレーション空間をグリッド(格子)に分割。
- 各Boidがどのグリッドセルにいるかを計算する。この「Boidのインデックス」と「グリッドのキー」をペアで保存
- 近傍探索: 各Boidを自分自身がいるグリッドセルとその周囲のセルにいるBoidだけを計算対象にする
- 並列計算:この近傍探索ロジックをJobに組み込んで並列計算
(すべて「毎フレーム」行います)
つまり、自身がいるセルの周囲8セルにいるボイド達だけをBoidsアルゴリズム処理することで全体を見ない最適化を行います。
セルのサイズを大きくすればするほど正確になりますが、処理は元と変わらなくなるので負荷が膨れ上がります
そして、今回はグリッドセルのサイズは適当に処理します…
▶︎ 新しく作成するSystemとJobSystem
-
DotsBoidsSystem
下のJob達をまとめて処理する親玉System-
UpdateGridJob
各ボイドがどのグリッドにいるかを計算Job -
BoidsForceJob
各ボイドが動く力を計算Job -
UpdateBoidsJob
各ボイドの座標更新Job
-
結果
- FPS ave : 181.0
- Batch : 30
いい速度だ…
しかし、やはり大群が固まるタイミングではFPSが低下する時があります。
これはGridの空間分割処理を最適化すればFPSも安定化しそうです
(ボイドのセル分割も毎フレーム処理する必要ないですし)
- Systemによるロジックを挟むことができる
- 当たり判定も可能
- 全Boidを見るわけではないので動きに粗が出る
- しかし一部の群衆を見る限りでは気にならない
- 速度はGPU Onlyと比較すると遅い
◼︎結論
ComputeShader+GPU Instancing Indirectが適しているケース
参考
https://discussions.unity.com/t/unity-dots-vs-compute-shader/836895
- ComputeShaderの結果はCPUに送らず、直接描画に利用するパターン
- パーティクルシステムやエフェクトなど.. テクスチャの更新もOK
- GPU ↔ GPU間のデータの受け渡しに利用する
DOTS (Jobs + Burst + Collections)の方が優れているケース
- 結果をCPUでアクセスする必要がある場合
- Burst + JobシステムでComputeShaderよりもカバーできるケースがある
- CPU操作はDOTSが最善択
- Entityだけではなく、JobSystem+BurstCompilerの活用により大規模データを高速に動かすことは実用的
以上です。お疲れ様でした。
その他
Dots関連のコードは使い回していますが、一部のSystemはサンプル毎に新しく作り直しています。
動かしたいSystemを切り替えたい時は独自のSystemGroupを作り
/// <summary>
/// ComputeShader + DOTS
/// </summary>
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial class DotsComputeShaderSystemGroup : ComponentSystemGroup { }
これをサンプル毎に切り替えたいSystemに設定
namespace DOTS1
{
// ComputeShader + DOTSの時だけ動くようにしたい
[UpdateInGroup(typeof(DotsComputeShaderSystemGroup))]
public partial class BoidsSystem : SystemBase
.....
}
適当なMonobehaviourクラスを作り、enableのfalse/trueでグループSystemのOn/Offが切り替えられます。テストプロジェクトにおすすめです
_defaultWorld = World.DefaultGameObjectInjectionWorld;
_defaultWorld.GetOrCreateSystemManaged<DotsComputeShaderSystemGroup>().Enabled = false or true;