凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity の GPU Instancing に対応するシェーダのコードを調べてみた

はじめに

GPU Instancing を使うと同一メッシュ・マテリアルを使用したオブジェクトを一度に大量に描画することが出来ます。GPU Instancing そのものについてはテラシュールブログさんや公式ドキュメントをご参照ください。

tsubakit1.hateblo.jp

docs.unity3d.com

本エントリでは、シェーダ内でどのようにこの 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 が追加されます。

f:id:hecomi:20180923235701p:plain

これをチェックすると INSTANCING_ON というキーワードが ON になります。

f:id:hecomi:20180924000134p:plain

このキーワードが ON になっていて且つプラットフォームがインスタンシングをサポートしていれば、UnityInstancing.cgincUNITY_INSTANCING_ENABLED が定義される、という流れになっています。これがシェーダの分岐に使われるようになります。

#if defined(UNITY_SUPPORT_INSTANCING) && defined(INSTANCING_ON)
    #define UNITY_INSTANCING_ENABLED
#endif

なお、この段階ではこのチェックを入れるとすべてのオブジェクトが 1 箇所に集まったような挙動になってしまいます。これは各インスタンス毎のモデル行列が区別されていないことによります。

f:id:hecomi:20180924121311g:plain

頂点シェーダの変更

入力構造体

次にこれを修正するための頂点シェーダの変更を見ていきましょう。まず、入力する構造体の appdata に次のように一行追加します。

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID // 追加
};

この UNITY_VERTEX_INPUT_INSTANCE_IDUnityInstancing.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;
}

これで正常にインスタンスごとの座標が反映されるようになります。

f:id:hecomi:20180924202409p:plain

中で何をやっているか見てみましょう。UNITY_SETUP_INSTANCE_IDUnityInstancing.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_InstanceIDstatic をつけて uint で宣言されています。unity_BaseInstanceIDCBUFFER_STARTCBUFFER_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

一番下の行を見ると、UnityObjectToClipPosUnityObjectToClipPosInstanced で置き換えられています。この中では 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);
}

f:id:hecomi:20180924214617p:plain

先の説明のように unity_WorldToObjectインスタンス対応バージョンに差し替えられるので適切に座標変換が出来ています。なお、 UNITY_SETUP_INSTANCE_ID() しない状態は次のようになります。

f:id:hecomi:20180924214326p:plain

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);
    }
}

すると以下のようにオブジェクトごとに色が付くようになります。

f:id:hecomi:20180924232006p:plain

まとめ

  • インスタンシングで一度に描画されたそれぞれのインスタンスは固有の ID を持っている
  • このインスタンス ID を使うには入力構造体に UNITY_VERTEX_INPUT_INSTANCE_ID を追加
  • シェーダ冒頭で UNITY_SETUP_INSTANCE_ID() をすると unity_ObjectToWorldUnityObjectToClipPos()インスタンス毎のものに自動的に置き換えられる
  • フラグメントシェーダでも同様に UNITY_SETUP_INSTANCE_ID() をすれば ID を使える
  • その上で作法に従ってマクロ経由でを宣言・取得すれば、MaterialPropertyBlock で与えられたインスタンスごとの変数を使うことが出来る

おわりに

今回は、通常のインスタンシングだけに絞りましたが、これ以外にも Graphics.DrawMeshInstanced() 等で描画するときの分岐や、VR 向けのシングルパスステレオレンダリングによる分岐、その他 PS4 向けなどプラットフォームごとの分岐などが入っていてもう少し複雑です(VR 向けの分岐に関してだけは別途記事にまとめようと思います)。また、2018.x からはもう少しコードが変化しています(例えば、UNITY_ACCESS_INSTANCED_PROP(_Color)UNITY_ACCESS_INSTANCED_PROP(Props, _Color) になります。今後も変化する可能性があるので、適宜ドキュメントを当たって最新の記法を使用するようにしてください。