概要:大量描画に使うシェーダをShaderGraphで作る
この記事ではIndirectレンダリングのための最低限のシェーダをShaderGraphで作る例を紹介します。
ShaderGraphでSV_InstanceIDにアクセスできること、ShaderGraphでもStructuredBufferを使えることが重要なポイントだと思います。
Indirectレンダリングについて
今時のGPUやグラフィックスAPIには、同じメッシュであれば1ドローコールで大量に描画できる仕組みが存在します。InstancedやIndirectと呼ばれるものです。
少し古いですが、Zennの方に私の記事があるので、InstancedやIndirectについての詳細はそちらを参照してくれると嬉しいです。
一応この場でもIndirectについて簡単に説明すると、「同じメッシュを例えば1万とか大量に描画したい場合、オブジェクトを1つ1つ扱うとCPU-GPUデータ転送がボトルネックになるので、GPUに各オブジェクトの位置とか全部置いといて、描画命令もGPUから出して全部GPUで完結させたら速いんじゃね?」というものです。GPUやグラフィックスAPIに備わっている機能で、Unity特有の話ではありません。
作れるもの
とりあえず球を1万個浮かせて、少し動かしています。
SetPassCallが37で、Batchers(DrawCall)も37です。
数字が同じで、球の描画は1回のDrawCallで実行されているのがわかります。
SetPassCallが多めに見えますが、ブルームを有効にしていて、且つ影を落としているのでそのあたりの影響が大きいです(デフォのBloomで16回)。
なんかチカチカしてるのはDirectionalLightの反射です。
環境
Unity6000.2.8f1
実装
やること
- GPUに渡すべきデータを定義する
- C#側から、大量描画に必要な情報を渡し、描画命令を発行する
- ShaderGraphでシェーダを書く
GPUに渡す情報
とりあえず、各オブジェクトのワールド座標を渡したいので、位置と回転の構造体を定義することにしましょう。
struct Transform
{
float3 Position;
float4 Rotation;
};
public struct Transform
{
public Vector3 Position;
public Quaternion Rotation;
}
C#側で描画命令を出す
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using UnityEngine;
using UnityEngine.Rendering;
public sealed class SimpleIndirectRenderer : MonoBehaviour
{
private struct Transform
{
public Vector3 Position;
public Quaternion Rotation;
}
[SerializeField] private Mesh _mesh;
[SerializeField] private Material _material;
[SerializeField] private Vector3 _min;
[SerializeField] private Vector3 _max;
[SerializeField, Range(1, 100000)] private int _instanceCount;
private GraphicsBuffer _indirectDrawArgsBuffer;
private GraphicsBuffer.IndirectDrawIndexedArgs[] _indirectDrawArgs;
private GraphicsBuffer _transformsBuffer;
// サブメッシュがある場合はサブメッシュごとに描画命令を出します
private const int SubMeshIndex = 0;
private Bounds _bounds;
private void Start()
{
// Indirectレンダリングの命令用データを作ってGPUに渡す
// Indirectは命令自体もGPUが出すので、命令の詳細をGraphicsBufferでGPUに渡してあげる必要がある
_indirectDrawArgs = new GraphicsBuffer.IndirectDrawIndexedArgs[1];
_indirectDrawArgs[0].indexCountPerInstance = _mesh.GetIndexCount(SubMeshIndex);
_indirectDrawArgs[0].instanceCount = (uint)_instanceCount;
_indirectDrawArgs[0].startIndex = _mesh.GetIndexStart(SubMeshIndex);
_indirectDrawArgs[0].baseVertexIndex = _mesh.GetBaseVertex(SubMeshIndex);
_indirectDrawArgs[0].startInstance = 0;
_indirectDrawArgsBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.IndirectArguments,
1,
GraphicsBuffer.IndirectDrawIndexedArgs.size
);
_indirectDrawArgsBuffer.SetData(_indirectDrawArgs);
// CPUカリングの範囲を設定
var center = (_min + _max) / 2;
var size = (_max - _min) / 2;
_bounds = new Bounds(center, size);
// 各オブジェクトのワールド座標をランダムで作ってGPUに渡す。
var list = new List<Transform>(_instanceCount);
for (var i = 0; i < _instanceCount; i++)
{
var positionX = Random.Range(_min.x, _max.x);
var positionY = Random.Range(_min.y, _max.y);
var positionZ = Random.Range(_min.z, _max.z);
var pos = new Vector3(positionX, positionY, positionZ);
var rot = Quaternion.identity; // 向きは今回シェーダにも用意しないので、本当は無くていい
list.Add(new Transform
{
Position = pos,
Rotation = rot,
});
}
_transformsBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.Structured, // 配列ですよってGPUに教える
_instanceCount, // 要素数も教える
Unsafe.SizeOf<Transform>() // 1要素のサイズ(byte)を教える
);
_transformsBuffer.SetData(list); // GPUにデータを渡す
_material.SetBuffer("_Transforms", _transformsBuffer); // マテリアルに参照を渡す
}
private void Update()
{
var renderParam = new RenderParams(_material)
{
worldBounds = _bounds,
receiveShadows = true, // 影を受ける
shadowCastingMode = ShadowCastingMode.On, // 影を落とす
};
Graphics.RenderMeshIndirect(renderParam, _mesh, _indirectDrawArgsBuffer);
}
private void OnDestroy()
{
// マネージドなのでちゃんと破棄する
_indirectDrawArgsBuffer.Release();
_transformsBuffer.Release();
}
}
Shadergraph
C#側からGPUに送られた_Transformsは、シェーダではStructuredBufferという型で使用することができます。しかしShaderGraphにはデフォルトではStructuredBufferを使う方法が提供されていないため、カスタムノードを用意して対応する必要があります。
なおカスタムノードの公式ドキュメントは以下にあります。より詳しく知りたい場合は公式ドキュメントを参照してください。
まず.hlslコードを用意します。これをShaderGraphから使うことになります。
#ifndef SG_INDIRECT_INCLUDED
#define SG_INDIRECT_INCLUDED
struct Transform
{
float3 position;
float4 rotation;
};
StructuredBuffer<Transform> _Transforms;
void GetWorldPosition_float(float instanceID, out float3 worldPos)
{
worldPos = _Transforms[instanceID].position;
}
#endif
ShaderGraphウィンドウでCreate Node > Custom Functionを選択します。

GraphInspectorでTypeをFileにし、使用する関数名を入力し、hlslファイルの参照を割り当てます。hlslの方の名前は精度のサフィックスを付け、戻り値はoutで宣言します。そういうShaderGraphのルールがあるためです。
さて、.hlslの中でStructuredBuffer<Transform> _Transforms;を宣言していましたね。この宣言もまとめてShaderGraphに取り込まれているので、このシェーダは_Transformsをバインドできるようになっています。
ここには、C#側から貰った1万個の球オブジェクトのワールド座標が配列として入っています。ということは、あとはインデックスをどうするかわかればよさそうですね。
Graphビューで、Create Node > Input > Geometory > Instance IDと選んで、InstanceIDを使えるようにしましょう。これがまさにインデックスです。今はインスタンスの個数を1万個にしているので、0~9999の値が入ってくることになります。
頂点シェーダの全貌は以下のようになります。
InstanceIDとGetWorldPositionから各球のワールド座標を知り、各頂点の座標に足しています。それだけ。
あとは自由に座標に何か足してみたり、フラグメントシェーダで色をいじったりすればOKです。普通のShaderGraphと何も変わりません。
ちなみにこの記事の最初ので載せたサンプルは、シェーダ内で_Timeを参照してy座標に足しています。
できたもの

大量の球が描画されていますね。
ちゃんとC#側で作った、一定範囲内のランダムな位置に指定した数のメッシュが生成されていそうです。
まだ動きは入れていないので静止しています。
今回の例は本当に最低限のものなので、例えば個別の球に異なる色を持たせるとか、アニメーションさせるとか、ComputeShaderを使ってGPUカリングするとか、追加でできることはたくさんあります。InstanceIDをShaderGraphで使えることと、StructuredBufferもカスタムノードで使えること、この2点が重要です。
頻繁にCPUからGPUに大きなデータ転送を行うのはパフォーマンス的に問題があるので、上手くデータ転送を節約して大量描画を作ってみてください。
ShaderGraphで作れると何が嬉しいの
Unityエンジンに任せてしまった方が楽な部分を任せられるのが嬉しいですね。
普通にライティングしたいだけなら、そこは全部デフォルトに任せたい。
影用のパスも勝手に生成してくれるし、View空間に合わせるように計算とかもしなくてよくて、見た目の個性に関わるところだけに集中できるのが一番うれしいですね。

