BatchRendererGroupを使って大量の物体を効率的に描画する
こんにちは。バーチャルキャストクライアント開発のnotargs(のたぐす)です。今回は技術ブログシリーズの第2回を担当させていただくことになりました。
さて、Unity2019.1からしれっと追加されていたマイナーなAPI、BatchRendererGroupをご存知でしょうか。
このAPIはDOTS/SRP向けに最適化されたメッシュを描画するためのAPIで、Graphics.DrawMeshなどを置き換えるものとして設計されています。
ECS向けの描画APIであるHybrid Renderer V2の基盤としても使われているため、より深く知りたい人はそちらのコードを追ってみてください。
この記事では、BatchRendererGroupの使い方について紹介していきます。
Graphics.DrawMesh系との主な違い
- DOTSベースでJob/NativeArrayに対応しているため、マルチスレッド・Burstで動かすことができる
- カメラごとの任意のカリング処理を挟むことができる
- Updateなどで毎フレーム呼び出す必要がない
- SRP Batcherフレンドリー
- ユーザーが直接触ることをあまり想定していないのか、わりと容赦なくUnityがクラッシュする(!)
前提知識
- Unityの基本的な知識
- Burst、JobSystemを使ったことがある
- Graphics.DrawMesh系のAPIを使ったことがある
今回用意したサンプル
この記事では、1000個のMeshがクルクルと回るサンプルを用意しました。
Unity2019.2.8f1、Burst1.1.2を使用しています。
こちらがEditor上でのProfilerのスクリーンショットです。
Jobへ処理を逃がしているので、MainThreadへの負荷はほとんど掛かっていないことがわかります。
生成・破棄
それでは、コードを見ていきましょう。
BatchRenderGroupの生成・破棄はシンプルで、newで生成、Disposeで破棄するだけです。
コンストラクタでカリング処理のCallbackが要求されますが、一旦カリングを行わず、全てのオブジェクトを表示する実装を渡しておきます。
Disposeを忘れてしまうと、実行を終了しても処理が残り続けてしまう点に注意しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
using Unity.Jobs; using UnityEngine; using UnityEngine.Rendering; public class Sample : MonoBehaviour { private BatchRendererGroup _batchRendererGroup; private void OnEnable() { _batchRendererGroup = new BatchRendererGroup(CullingCallback); } private void OnDisable() { _batchRendererGroup.Dispose(); } // カリング処理のコールバック private JobHandle CullingCallback(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext) { // Batchの数だけループを回す for (var i = 0; i < cullingContext.batchVisibility.Length; i++) { var batchVisibility = cullingContext.batchVisibility[i]; // 全てのインスタンスを表示 var visibleInstancesIndex = 0; for (var j = 0; j < batchVisibility.instancesCount; ++j) { // 表示するインスタンスは、"batchVisibility.offset + Batch内のインスタンスのindex"で指定する cullingContext.visibleIndices[visibleInstancesIndex] = batchVisibility.offset + j; visibleInstancesIndex++; } batchVisibility.visibleCount = visibleInstancesIndex; cullingContext.batchVisibility[i] = batchVisibility; } return default; } } |
Meshを表示する
続いて、Meshをグリッド状に並べてみます。
次がBatchを追加し、Matricesを更新するコードを追記したコードです。
[SerializeField]で公開されているメッシュとマテリアルは、インスペクタからお好みのものを渡してあげましょう。ここではUnityプロジェクトにデフォルトで入っている「Cube」と「Default-Diffuse」を使用しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
using Unity.Jobs; using UnityEngine; using UnityEngine.Rendering; public class Sample : MonoBehaviour { [SerializeField] private Mesh mesh; [SerializeField] private Material material; // XYZ軸それぞれの分割数 private const int Split = 10; // インスタンスの総数 private const int InstanceCount = Split * Split * Split; private BatchRendererGroup _batchRendererGroup; private void OnEnable() { _batchRendererGroup = new BatchRendererGroup(CullingCallback); // 戻り地は追加されたBatchのindex var batchIndex = _batchRendererGroup.AddBatch( mesh, // メッシュ 0, // サブメッシュのindex material, // マテリアル 0, // レイヤー ShadowCastingMode.Off, // このオブジェクトから影を落とすか? false, // このオブジェクトに他のオブジェクトからの影を落とすか? false, // 面のカリングを反転させるか? new Bounds(Vector3.zero, Vector3.one * float.MaxValue), // Batch全体を覆うBounds、ここではすごく大きいBoundsを渡しておく InstanceCount, // Batch内に含まれるインスタンスの総数 null, // MaterialPropertyBlock、今回は不要なのでnull gameObject); // Batch内のメッシュがエディタ上でクリックされたとき、選択されるGameObject // インスタンスの姿勢を初期化 var matrices = _batchRendererGroup.GetBatchMatrices(batchIndex); for (var i = 0; i < InstanceCount; ++i) { matrices[i] = Matrix4x4.Translate(new Vector3(i / Split / Split, i / Split % Split, i % Split) * 5); } } private void OnDisable() { _batchRendererGroup.Dispose(); } // ** カリング処理は同じなので省略 ** } |
たくさんのMeshを表示することができました。
JobSystemを使ってインスタンスを回転させる
ただ動かないMeshが置いてあるだけでも味気ないので、Jobを使って回転させるコードを書いてみましょう。
Matrixを更新するJob
IJobParallelForを使い、それぞれのインスタンスの更新を並列で行うようにしてみました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Matrixを更新するJob [BurstCompile] private struct UpdateMatrixJob : IJobParallelFor { public NativeArray<Matrix4x4> Matrices; public float Time; public void Execute(int index) { var id = new Vector3(index / Split / Split, index / Split % Split, index % Split); Matrices[index] = Matrix4x4.TRS(id * 5, quaternion.EulerXYZ(id + Vector3.one * Time), Vector3.one); } } |
呼び出し側(Update)
JobHandleを保持しておき、同時に複数のJobから同じNativeArrayが触られてしまわないように注意しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 毎フレーム、確実に前回のJobを終わらせるためのJobHandle private JobHandle _jobDependency; private void Update() { // Matrixをいじる前に前回のJobを終了しておく _jobDependency.Complete(); // Matrixを更新 _jobDependency = new UpdateMatrixJob { Matrices = _batchRendererGroup.GetBatchMatrices(_batchIndex), Time = Time.time }.Schedule(InstanceCount, 16); } |
それぞれのMeshが回転しました。
カリング処理をもう少し真面目に書く
BatchRendererGroupでは、Graphics.DrawMesh系にない特徴として、「カメラごとの任意のカリング処理を挟み込むことができる」点が挙げられました。
せっかくなので、簡単なカリング処理を用意してみましょう。
カリングを行うJob
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
private struct CullingJob : IJob { public BatchCullingContext CullingContext; public NativeArray<Matrix4x4> Matrices; public int BatchIndex; public void Execute() { var batchVisibility = CullingContext.batchVisibility[BatchIndex]; var visibleCount = 0; for (var i = 0; i < batchVisibility.instancesCount; ++i) { if (!Contains(CullingContext.cullingPlanes, Matrices[i])) continue; CullingContext.visibleIndices[visibleCount] = batchVisibility.offset + i; ++visibleCount; } batchVisibility.visibleCount = visibleCount; CullingContext.batchVisibility[BatchIndex] = batchVisibility; } // 全てのPlaneの内側に各Meshの原点があったらtrueを返す // 実際は原点ではなく、メッシュを覆うAABBなどでカリングを行いたい private bool Contains(NativeArray<Plane> planes, Matrix4x4 matrix) { for (var i = 0; i < planes.Length; ++i) { if (!planes[i].GetSide(matrix.MultiplyPoint(Vector3.zero))) { return false; } } return true; } } |
CallBack処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
private JobHandle CullingCallback(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext) { var inputDependency = _jobDependency; for (var i = 0; i < cullingContext.batchVisibility.Length; ++i) { var job = new CullingJob { CullingContext = cullingContext, Matrices = rendererGroup.GetBatchMatrices(i), BatchIndex = i }.Schedule(inputDependency); // Jobの依存関係を更新 _jobDependency = JobHandle.CombineDependencies(job, _jobDependency); } return _jobDependency; } |
解説
BatchGroupごとにIJobを生成し、CullingContext.cullingPlanesを元にカリングする仕組みにしました。
CullingContext.cullingPlanesからNativeArray<Plane>
今回は実装をシンプルにするために原点のみを用いてカリングの内外判定を行いましたが、画面端でパッと消えるような挙動になってしまうので、実運用するならMesh.boundsなどを元にカリングを行ってあげると良さそうです。
カリングあり・なしの比較
実際にStatusを表示しながらカメラを動かしてみました。
カリングなしの場合はBatchesが一定ですが、カリングありの場合はカメラに映る物体の数によってBatchesの数が変動している事がわかります。
カリングなし
カリングあり
サンプルコード全文
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
using Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using UnityEngine.Rendering; public class Sample : MonoBehaviour { [SerializeField] private Mesh mesh; [SerializeField] private Material material; private BatchRendererGroup _batchRendererGroup; // 毎フレーム、確実に前回のJobを終わらせるためのJobHandle private JobHandle _jobDependency; // XYZ軸それぞれの分割数 private const int Split = 10; // インスタンスの総数 private const int InstanceCount = Split * Split * Split; private int _batchIndex; private void OnEnable() { _batchRendererGroup = new BatchRendererGroup(CullingCallback); _batchIndex = _batchRendererGroup.AddBatch( mesh, 0, material, 0, ShadowCastingMode.Off, false, false, new Bounds(Vector3.zero, Vector3.one * float.MaxValue), // ここではすごく大きいBoundsを渡しておく InstanceCount, null, gameObject); } private void OnDisable() { _batchRendererGroup.Dispose(); } private void Update() { // Matrixをいじる前に前回のJobを終了しておく _jobDependency.Complete(); // Matrixを更新 _jobDependency = new UpdateMatrixJob { Matrices = _batchRendererGroup.GetBatchMatrices(_batchIndex), Time = Time.time }.Schedule(InstanceCount, 16); } private JobHandle CullingCallback(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext) { var inputDependency = _jobDependency; for (var i = 0; i < cullingContext.batchVisibility.Length; ++i) { var job = new CullingJob { CullingContext = cullingContext, Matrices = rendererGroup.GetBatchMatrices(i), BatchIndex = i }.Schedule(inputDependency); // Jobの依存関係を更新 _jobDependency = JobHandle.CombineDependencies(job, _jobDependency); } return _jobDependency; } // カリングを行うJob private struct CullingJob : IJob { public BatchCullingContext CullingContext; public NativeArray<Matrix4x4> Matrices; public int BatchIndex; public void Execute() { var batchVisibility = CullingContext.batchVisibility[BatchIndex]; var visibleCount = 0; for (var i = 0; i < batchVisibility.instancesCount; ++i) { if (!Contains(CullingContext.cullingPlanes, Matrices[i])) continue; CullingContext.visibleIndices[visibleCount] = batchVisibility.offset + i; ++visibleCount; } batchVisibility.visibleCount = visibleCount; CullingContext.batchVisibility[BatchIndex] = batchVisibility; } // 全てのPlaneの内側に各Meshの原点があったらtrueを返す // 実際は原点ではなく、メッシュを覆うAABBなどでカリングを行いたい private bool Contains(NativeArray<Plane> planes, Matrix4x4 matrix) { for (var i = 0; i < planes.Length; ++i) { if (!planes[i].GetSide(matrix.MultiplyPoint(Vector3.zero))) { return false; } } return true; } } // Matrixを更新するJob [BurstCompile] private struct UpdateMatrixJob : IJobParallelFor { public NativeArray<Matrix4x4> Matrices; public float Time; public void Execute(int index) { var id = new Vector3(index / Split / Split, index / Split % Split, index % Split); Matrices[index] = Matrix4x4.TRS(id * 5, quaternion.EulerXYZ(id + Vector3.one * Time), Vector3.one); } } } |
まとめ
BatchRendererGroupを使うことで、これまで以上に深い部分へのパフォーマンスチューニングが出来るようになりました。
使い方は少し難しいですが、既にUnity標準のAPIとして使える状態になっているため、ぜひパフォーマンスが要求される箇所に活用してみてください!
- Tag
- Unity