はじめに
GPU Instancing を使うと同一メッシュ・マテリアルを使用したオブジェクトを一度に大量に描画することが出来ます。GPU Instancing そのものについてはテラシュールブログさんや公式ドキュメントをご参照ください。
本エントリでは、シェーダ内でどのようにこの GPU Instancing に対応するシェーダが展開されるかの解説を行います。ただ、Unity がここで解説するような細かいことは覚えて置かなくても良いように設計してくれているので、途中の面倒なマクロの展開を追うコードを読むのが飽きたら、最後のまとめだけ読んでみてください。
環境
- Unity 2017.4.11f1
ベースとなるコード
以下の基本的なコードをもとにします。ここに色々手を加えてインスタンシング対応にします。
Shader "Test/Instancing" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } CGINCLUDE #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); UNITY_APPLY_FOG(i.fogCoord, col); return col; } ENDCG Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog ENDCG } } }
インスタンシングの設定
次のように Pass
ブロックに pragma 文を仕込みます。
Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #pragma multi_compile_instancing // 追加 ENDCG }
これを行うとマテリアルのインスペクタに Enable GPU Instance
が追加されます。
これをチェックすると INSTANCING_ON
というキーワードが ON になります。
このキーワードが ON になっていて且つプラットフォームがインスタンシングをサポートしていれば、UnityInstancing.cginc
で UNITY_INSTANCING_ENABLED
が定義される、という流れになっています。これがシェーダの分岐に使われるようになります。
#if defined(UNITY_SUPPORT_INSTANCING) && defined(INSTANCING_ON) #define UNITY_INSTANCING_ENABLED #endif
なお、この段階ではこのチェックを入れるとすべてのオブジェクトが 1 箇所に集まったような挙動になってしまいます。これは各インスタンス毎のモデル行列が区別されていないことによります。
頂点シェーダの変更
入力構造体
次にこれを修正するための頂点シェーダの変更を見ていきましょう。まず、入力する構造体の appdata
に次のように一行追加します。
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID // 追加 };
この UNITY_VERTEX_INPUT_INSTANCE_ID
は UnityInstancing.cginc
で 次のように展開されます(簡単のため必要な部分だけ抜き出して短くしています)。
#if defined(UNITY_INSTANCING_ENABLED) #define DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID uint instanceID : SV_InstanceID; #else #define DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID #endif #if !defined(UNITY_VERTEX_INPUT_INSTANCE_ID) #define UNITY_VERTEX_INPUT_INSTANCE_ID DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID #endif
インスタンシングが ON になっている場合は SV_InstanceID
セマンティクスをつけた instanceID
というインスタンス ID を格納する変数が入力構造体に追加されることになります。このインスタンス ID を利用して、適切なモデル行列を取り出すようにします。
頂点シェーダ
Unity ではこれを行うために便利なマクロを用意してくれています。 頂点シェーダに次のように 1 行追加します。
v2f vert(appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); // 追加 o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o; }
これで正常にインスタンスごとの座標が反映されるようになります。
中で何をやっているか見てみましょう。UNITY_SETUP_INSTANCE_ID
は UnityInstancing.cginc
で次のように定義されています。
#if defined(UNITY_INSTANCING_ENABLED) void UnitySetupInstanceID(uint inputInstanceID) { unity_InstanceID = inputInstanceID + unity_BaseInstanceID; } #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input)); #else #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) #endif #if !defined(UNITY_SETUP_INSTANCE_ID) #define UNITY_SETUP_INSTANCE_ID(input) DEFAULT_UNITY_SETUP_INSTANCE_ID(input) #endif
やっていることとしては、unity_InstanceID
という変数に与えられたインスタンス ID にベースの ID のオフセットを足して格納しているだけです。これら unity_
プレフィクスのついた変数は同様に UnityInstancing.cginc
で次のように定義されています。
#if defined(UNITY_INSTANCING_ENABLED) static uint unity_InstanceID; CBUFFER_START(UnityDrawCallInfo) int unity_BaseInstanceID; int unity_InstanceCount; CBUFFER_END #endif
unity_InstanceID
は static をつけて uint
で宣言されています。unity_BaseInstanceID
は CBUFFER_START
と CBUFFER_END
で囲まれていますが、これは定数バッファの cbuffer
へと展開されます。
#define CBUFFER_START(name) cbuffer name { #define CBUFFER_END };
定数バッファ内の変数は外から与えられ、unity_InstanceID
はここで計算する流れとなっています。しかしながら、ここだけでは単に unity_InstanceID
に値が入ったに過ぎません。ではどうやってインスタンスごとの座標が反映されているのでしょうか。
UnityObjectToClipPos の上書き
そのからくりは UnityObjectToClipPos
に隠されています。UNITY_INSTANCING_ENABLED
のフラグが立っているときは UnityInstancing.cginc
の中で、次のようなコードが走るようになります。
#if defined(UNITY_INSTANCING_ENABLED) UNITY_INSTANCING_CBUFFER_START(PerDraw0) float4x4 unity_ObjectToWorldArray[UNITY_INSTANCED_ARRAY_SIZE]; float4x4 unity_WorldToObjectArray[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_END #define unity_ObjectToWorld unity_ObjectToWorldArray[unity_InstanceID] #define unity_WorldToObject unity_WorldToObjectArray[unity_InstanceID] inline float4 UnityObjectToClipPosInstanced(in float3 pos) { return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorldArray[unity_InstanceID], float4(pos, 1.0))); } inline float4 UnityObjectToClipPosInstanced(float4 pos) { return UnityObjectToClipPosInstanced(pos.xyz); } #define UnityObjectToClipPos UnityObjectToClipPosInstanced #endif
一番下の行を見ると、UnityObjectToClipPos
が UnityObjectToClipPosInstanced
で置き換えられています。この中では unity_ObjectToWorld
の代わりにインスタンスごとのモデル行列が格納された unity_ObjectToWorldArray
を使用するようになっています。この配列に与えるインデックスが先ほど求めた unity_InstanceID
になっています。つまり、すり替えておいたのさ!と言う具合でいつも使っている UnityObjectToClipPos()
がインスタンシング対応版になることで、ユーザからは意識せずにシェーダをいつもどおり書けるようになっているわけです。
フラグメントシェーダ
コードの追加
フラグメントシェーダ内でも同様にインスタンス毎の変数を利用したい場合は 3 行ほど修正を行う必要があります。
struct v2f
{
...
UNITY_VERTEX_INPUT_INSTANCE_ID
};
v2f vert(appdata v)
{
...
UNITY_TRANSFER_INSTANCE_ID(v, o);
...
}
fixed4 frag(v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
...
}
UNITY_TRANSFER_INSTANCE_ID()
は、ただ代入しているだけです。
#if defined(UNITY_INSTANCING_ENABLED) #define UNITY_GET_INSTANCE_ID(input) input.instanceID #define UNITY_TRANSFER_INSTANCE_ID(input, output) output.instanceID = UNITY_GET_INSTANCE_ID(input) #else #define UNITY_TRANSFER_INSTANCE_ID(input, output) #endif
試しにワールド座標をフラグメントシェーダに送ってローカル座標に戻す、みたいな処理を書いてみましょう。何かしらの座標をオブジェクトローカルに戻して...みたいなケースはレイを飛ばしたりすると往々にしてあるので例としては役に立つと思います。
struct v2f { ... float4 worldPos : TEXROOD2; ... }; v2f vert(appdata v) { ... o.worldPos = mul(unity_ObjectToWorld, v.vertex); ... } fixed4 frag(v2f i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); ... return mul(unity_WorldToObject, i.worldPos); }
先の説明のように unity_WorldToObject
がインスタンス対応バージョンに差し替えられるので適切に座標変換が出来ています。なお、 UNITY_SETUP_INSTANCE_ID()
しない状態は次のようになります。
unity_InstanceID
がインスタンスごとに初期化されないのでどれか適当なオブジェクトのローカル座標へと変換されてしまいます。見る角度に応じてソート順が変わるのでキューブの色も変化します。
プロパティの追加
最後に、インスタンスごとに変数を与える方法を見てみましょう。次のように色を指定する変数を追加します。
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _Color) UNITY_INSTANCING_BUFFER_END(Props) fixed4 frag(v2f i) : SV_Target { ... fixed4 col = tex2D(_MainTex, i.uv); col *= UNITY_ACCESS_INSTANCED_PROP(_Color); ... }
また色々とマクロが出てきました。これらも UnityInstancing.cginc
で次のように定義されています。
#if defined(UNITY_INSTANCING_ENABLED) #define UNITY_INSTANCING_CBUFFER_START(name) CBUFFER_START(UnityInstancing_##name) #define UNITY_INSTANCING_CBUFFER_END CBUFFER_END #define UNITY_DEFINE_INSTANCED_PROP(type, name) type name[UNITY_INSTANCED_ARRAY_SIZE]; #define UNITY_ACCESS_INSTANCED_PROP(name) name[unity_InstanceID] #endif
UNITY_INSTANCED_ARRAY_SIZE
の大きさの配列が用意され、先程取得した unity_InstanceID
をインデックスとして対象の要素にアクセスする形です。この各々の変数には C# 側から MaterialPropertyBlock
を経由して行います。次のようなコンポーネントを書いて、それぞれのオブジェクトにアタッチしてみます。
using UnityEngine; [ExecuteInEditMode] public class InstanceColorSetter : MonoBehaviour { [SerializeField] Color color; Renderer renderer; MaterialPropertyBlock props; static readonly int id = Shader.PropertyToID("_Color"); void Start() { color = Random.ColorHSV(); renderer = GetComponent<Renderer>(); props = new MaterialPropertyBlock(); } void Update() { props.SetColor(id, color); renderer.SetPropertyBlock(props); } }
すると以下のようにオブジェクトごとに色が付くようになります。
まとめ
- インスタンシングで一度に描画されたそれぞれのインスタンスは固有の ID を持っている
- このインスタンス ID を使うには入力構造体に
UNITY_VERTEX_INPUT_INSTANCE_ID
を追加 - シェーダ冒頭で
UNITY_SETUP_INSTANCE_ID()
をするとunity_ObjectToWorld
やUnityObjectToClipPos()
がインスタンス毎のものに自動的に置き換えられる - フラグメントシェーダでも同様に
UNITY_SETUP_INSTANCE_ID()
をすれば ID を使える - その上で作法に従ってマクロ経由でを宣言・取得すれば、
MaterialPropertyBlock
で与えられたインスタンスごとの変数を使うことが出来る
おわりに
今回は、通常のインスタンシングだけに絞りましたが、これ以外にも Graphics.DrawMeshInstanced()
等で描画するときの分岐や、VR 向けのシングルパスステレオレンダリングによる分岐、その他 PS4 向けなどプラットフォームごとの分岐などが入っていてもう少し複雑です(VR 向けの分岐に関してだけは別途記事にまとめようと思います)。また、2018.x からはもう少しコードが変化しています(例えば、UNITY_ACCESS_INSTANCED_PROP(_Color)
は UNITY_ACCESS_INSTANCED_PROP(Props, _Color)
になります。今後も変化する可能性があるので、適宜ドキュメントを当たって最新の記法を使用するようにしてください。