【Unityにおける大量描画のテクニック】ライフゲームタワーを使ったパフォーマンス比較
はじめに
株式会社QualiArtsの今井です。テクニカルアーティストとしてIDOLY PRIDEのライブシステムの設計や、描画を担当しています。
今回はUnityで大量描画を行うテクニックとして、どのような手法が利用できるのか、そしてそれぞれの手法のメリット・デメリットを解説します。
今回作るサンプル
今回はサンプルとしてライフゲームを1層ずつ積み上げていくプロジェクト(以下ライフゲームタワー)を作成しました。 ここではライフゲームの仕組みや、ロジックの詳細については省略しますが、簡単に言うと1ステップごとに周囲のマスの状況によってマス目が変化するゲームです。
サンプルプロジェクト
この記事で使用しているサンプルプロジェクトはこちらに公開しています。
UnityLifeGameTowerSample - Github
概要
このサンプルでは1フレームに1ステップずつ上に積み上げてライフゲームタワーを成長させていきます。
1ステップを描画するための処理速度が小さければより早くタワーが成長することになり タワーは256ステップ(=256段)まで成長します。 つまり、このサンプルはタワーの成長が早い=パフォーマンスが良いと判断できます。
環境
本記事は以下の環境で動作検証した結果を元に記述しています。
- MacBook Pro (M1 Max)
- Unity 2022.2.15f1
最適化に向けて
今回は大量のキューブをいかに高速に描画するかが最適化の焦点になります。
そこでUnityで大量のキューブを描画する際に、どの手法が高速なのかを比較します。
GameObject
GameObjectにMeshRendererをアタッチして描画するもっとも一般的な方法です。 以下が擬似的なソースコードです。
var g = new GameObject();
var meshFilter = g.AddComponent<MeshFilter>();
var meshRenderer = g.AddComponent<MeshRenderer>();
meshRenderer.material = material;
meshFilter.mesh = mesh;
g.transform.position = position;
GameObjectでライフゲームタワーを積み上げたGIFが以下になります。
FPSを測定すると、タワーが成長するにつれ徐々にFPSが低下します。 最初は150FPSを超えていましたが、徐々に速度が低下していき最終的には10FPSまで低下しました。
最終的に256ステップが完了し、タワーが成長し切った時までを平均したFPSは22FPSになりました。
GameObjectの欠点
汎用的に3Dを扱えるGameObjectにはその分オーバーヘッドが存在します。 ヒエラルキーへの登録もそのひとつです。 さらにGameObjectは複数のメッシュの描画に対応しているため、毎フレームメッシュの情報をGPUに転送する必要があります。
今回の用途では単一のメッシュ(キューブ)を複数描画できれば良いためこれがオーバーヘッドとなり、不要なリソースを消耗してしまいます。
DrawMesh
Graphics.DrawMesh - Unityマニュアル
DrawMeshはGameObject を作らずにスクリプトのみでメッシュを描画するメソッドです。 Updateの中で呼び出すことで描画が行われます。
以下は擬似的なコードです。
public void Update()
{
var materix4x4 = Matrix4x4.TRS(position, rotation, scale);
Graphics.DrawMesh(mesh, materix4x4, material, 0);
}
Matrix4x4型は、位置、回転、スケールを表します。 GameObjectにあるTransformコンポーネントと同じ情報を持つと思えばわかりやすいでしょう。
これを実行すると以下のようになります。 やはりオブジェクト数が増えるにつれFPSが低下します。
平均FPSは27FPSでGameObjectの22FPSよりも僅かながら優っていますが、大幅に高速化されたとは言い難いでしょう。
DrawMeshもGameObjectと同じように、毎フレーム描画対象の情報をGPUに転送していることがボトルネックになっています。
DrawMeshInstanced
Graphics.DrawMeshInstanced - Unityマニュアル
DrawMeshInstancedはGPUインスタンスを使って同一のメッシュを大量に描画するためのメソッドです。 ライフゲームタワーでは描画するキューブの形は同じなため、この特性を使って大量に描画ができそうです。
今回のような単一のオブジェクトを大量に高速に描画するという目的に合致したメソッドになります。 これを使うにはまずマテリアルのEnable Instancingにチェックをつけます。
DrawMeshInsrancedを使うための疑似コードです。
public void Update()
{
var matrix4x4Array = new Matrix4x4[3];
matrix4x4Array[0] = Matrix4x4.TRS(new Vector3(0, 0, 0), rotation, scale);
matrix4x4Array[1] = Matrix4x4.TRS(new Vector3(1, 0, 0), rotation, scale);
matrix4x4Array[2] = Matrix4x4.TRS(new Vector3(2, 0, 0), rotation, scale);
Graphics.DrawMeshInstanced(mesh, 0, material, matrix4x4Array);
}
DrawMeshInstancedはDrawMeshと違いMatrix4x4の配列を引数に渡します。 これにより同じメッシュの位置だけを変えてGPU側でまとめて描画できます。
GameObjectやDrawMeshは毎回メッシュの情報をGPU側に転送していましたが、DrawMeshInstancedはGPUインスタンスを使用して、1つのメッシュと座標の配列を渡すことで大量の描画を高速に行います。
これを実行すると以下のようになります。 GameObjectやDrawMeshよりも高速なことがわかります。
DrawMeshInstancedの欠点
平均FPS133と、GameObjectやDrawMeshと比べて十分早いDrawMeshInstancedですが、やはりオブジェクトが多くなるにつれFPSは低下します。
FPS低下の一因となっているのが、DrawMeshInstancedの描画最大数が1024個までという制約です。
そのため、Matrix4x4の配列は1024個以下にする必要があり、たとえば2048個のオブジェクトを描画する際には1024個ごとの配列を作成し、2回DrawMeshInstancedを呼ぶ必要があります。 つまり描画するキューブが多くなるにつれ繰り返しメソッドを呼ぶ必要があり、これがパフォーマンスの低下につながります。
DrawMeshInstancedProcedural
Graphics.DrawMeshInstancedProcedural - Unityマニュアル
DrawMeshInstancedProceduralはプログラムから描画を行う際にもっとも柔軟に扱えるように設計されたメソッドで、事前に定義した構造情報をCPUからGPUに受け渡すことで描画を行います。
このメソッドに関しては先に速度を見てもらいたいですが、DrawMeshInstancedProceduralを実行すると以下のようになり、これまでで最速の描画方法になります。
圧倒的に速く、オブジェクトが増えていってもはそれほど低下していません。
圧倒的な速度とは引き換えに、これまでMatrix4x4の配列を渡すことでUnity側がやってくれていた座標計算や、ライティングの計算もすべて自分で行う必要があります。 その手順を紹介します。
DrawMeshInstancedProceduralはC#とシェーダーそれぞれに実装が必要になるため、まずはC#側のコードです。
public class DrawMeshInstancedSample : MonoBehaviour
{
public Material material;
public Mesh mesh;
public Vector3[] positions = new[]
{
new Vector3(0, 0, 0),
new Vector3(2, 0, 0),
new Vector3(4, 0, 0)
};
private GraphicsBuffer _graphicsBuffer;
private void OnEnable()
{
// GraphicsBufferを生成して座標情報を設定する
_graphicsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, positions.Length,
Marshal.SizeOf<Vector3>());
_graphicsBuffer.SetData(positions);
}
private void OnDisable()
{
_graphicsBuffer.Dispose();
}
public void Update()
{
// マテリアルにバッファを設定
material.SetBuffer("_Positions", _graphicsBuffer);
Graphics.DrawMeshInstancedProcedural(mesh, 0, material, mesh.bounds, _graphicsBuffer.count);
}
}
C#側ではGraphicsBufferを作成して_Positions
の情報をGPU側に転送する処理を記述しています。
次にシェーダーです。
Shader "Sample/DrawMeshInstancedSampleShader"
{
SubShader
{
Tags
{
"RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline"
}
Pass
{
HLSLPROGRAM
#pragma multi_compile_instancing
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
uint instancedId : SV_InstanceID;
};
struct Varyings
{
float4 vertex : SV_POSITION;
};
// C#側から座標情報が渡される
StructuredBuffer<float3> _Positions;
Varyings vert(Attributes IN)
{
Varyings OUT;
float3 positionOS = IN.positionOS.xyz + _Positions[IN.instancedId];
OUT.vertex = TransformWorldToHClip(positionOS);
return OUT;
}
half4 frag() : SV_Target
{
return half4(0, 0, 0.5, 1);
}
ENDHLSL
}
}
}
C#側から _Positions
に座標情報が渡されます。
この情報を SV_InstanceID
セマンティクスを指定したinstancedID
を使うことで、CPUから受け渡される座標情報を取得できます。
取得した座標情報を使い頂点を移動することでオブジェクトの移動を表現します。
コードを見てC#側からはVector3の配列を渡しているにもかかわらず、シェーダー側ではfloat3を受け取っている部分に疑問を感じたかもしれません。 しかしVector3もfloat3も内部ではfloatを3つ持っている構造体であり、メモリレイアウトは同じです。 このため、C#とシェーダーで型が違っていてもメモリレイアウトが一致しているため問題なく値の受け渡しができます。
以上でDrawMeshInstancedProceduralを使った描画処理の完成です。
このコードを実行すると3つのキューブが描画されます。
DrawMeshInstancedProceduralのポテンシャルは凄まじく、GameObjectでは到底描画できない量のオブジェクトであっても難なく描画できます。
先述のコードで作ったシェーダーは色を単色で表示するだけの簡易的なものですが、陰影やライティングを行うフラグメントシェーダーを実装することでこのような表現も可能です。
DrawMeshInstancedProceduralの欠点
GameObject,DrawMesh,DrawMeshInstancedはUnityの標準シェーダーを使ったライティングやMatrix4x4構造体を使ったオブジェクトの制御が可能でした。 しかしDrawMeshInstancedProceduralを使用する場合、シェーダー、オブジェクトの制御は独自に実装する必要があります。この点においてDrawMeshInstancedProceduralはその他の描画法よりも扱いが難しいといった欠点があります。
まとめ
ライフゲームタワーをサ ンプルに大量のメッシュを高速に描画する方法について記載しました。
手軽に大量描画を行いたい場合はDrawMeshInstancedを使い、より高速な描画を行いたい場合はDrawMeshInstancedProceduralを使うことが選択肢に入ると思われます。 本記事が大量描画の最適化に少しでも役に立てば幸いです。