カスタムシェーダーとGPU Resident Drawer — DOTSインスタンシング完全攻略

GPU Resident Drawerを有効化したときに、コンソールにこのエラーが表示されたなら、まさにこの記事のために来てくださった方です。

A BatchDrawCommand is using the pass [PassName] from the shader [ShaderName]
which does not define a DOTS_INSTANCING_ON variant.

「何かがおかしいのはわかるが、何がおかしいのかわからない」という状況です。カスタムシェーダーを使うプロジェクトでGPU Resident Drawerを導入するには、必ず乗り越えなければならない関門があります。DOTSインスタンシングのサポートです。

名前にDOTSが付いているのでEntitiesパッケージが必要と誤解しがちですが、まったくそんなことはありません。DOTSインスタンシングはシェーダーレベルのインスタンスデータ受け渡し方式であり、通常のGameObjectベースのプロジェクトにもそのまま適用できます。


目次


DOTSインスタンシングとは何か

両者は混同しやすいですが、本質的な違いはデータがどこにあるかです。

従来のGPUインスタンシング(UNITY_INSTANCING_BUFFER_START方式)では、CPUが毎フレーム、インスタンスデータ(トランスフォーム、マテリアルプロパティなど)をConstant Bufferにパックして、GPUに転送します。バッチあたり最大1,023個(Unityの内部上限。インスタンスあたりのプロパティ数によって64KB cbuffer範囲内でさらに少なくなる場合があります)という制約があり、データ転送自体がCPU負荷となります。

DOTSインスタンシングは異なります。インスタンスデータをGPUメモリのGraphicsBuffer(ByteAddressBuffer)に常駐(Resident)させておき、シェーダーにはそのバッファのどこを読めばいいかを示す32ビットのメタデータ値を1つだけ渡します。CPUはデータが変わったときだけアップロードすればよく、毎フレーム繰り返す必要はありません。

項目 従来のGPUインスタンシング DOTSインスタンシング
インスタンスデータの場所 CPU → Constant Buffer(毎フレーム) GPU常駐 GraphicsBuffer
シェーダーへ渡すもの プロパティ配列全体 32ビットメタデータ値1つ
バッチサイズ上限 〜500個(64KB制限) 数千個以上、実質無制限
SRP Batcher互換 不可 可能(同時サポート)
宣言マクロ UNITY_INSTANCING_BUFFER_START UNITY_DOTS_INSTANCING_START

GPU Resident DrawerはBatchRendererGroup(BRG)APIを内部で使用しており、BRGはDOTSインスタンシングのみをサポートしています。従来のGPUインスタンシング方式のシェーダーはBRGでは動作しません。

もうひとつ覚えておくべき重要な点があります。DOTSインスタンシングシェーダーはSRP Batcherと同時に互換性を持つ必要があります。 SRP Batcherの互換条件(UnityPerMaterial cbufferの宣言)も同時に満たさなければ正常に動作しません。この両方の条件を同時に満たすパターンがこの記事の核心です。


カスタムシェーダーにDOTSインスタンシングを追加する

ステップ1:必須のpragma宣言

すべてのPass(パス)に以下の3行が必要です。

#pragma exclude_renderers gles gles3 glcore  // SM 4.5非対応プラットフォームを除外
#pragma target 4.5                            // DOTSインスタンシングの最低要求シェーダーモデル
#pragma multi_compile _ DOTS_INSTANCING_ON   // DOTSインスタンシングバリアントを生成

#pragma multi_compile_instancingも合わせて宣言することで、従来のGPUインスタンシングバリアントも同時に生成されます。両方のパスをサポートするシェーダーを作ることが一般的です。

#pragma multi_compile_instancing
#pragma instancing_options renderinglayer
#pragma multi_compile _ DOTS_INSTANCING_ON

注意: このpragmaはシェーダーのすべてのPassに追加する必要があります。 ForwardパスにはあるのにShadowCaster、DepthOnlyパスに漏れると、そのパスでエラーが発生します。エラーメッセージにパス名が明記されるので、どのパスが問題かはすぐにわかります。

ステップ2:DOTSインスタンシングプロパティブロックの宣言

#ifdef UNITY_DOTS_INSTANCING_ENABLEDガードの中にプロパティブロックを宣言します。

#ifdef UNITY_DOTS_INSTANCING_ENABLED

UNITY_DOTS_INSTANCING_START(UserPropertyMetadata)
    UNITY_DOTS_INSTANCED_PROP(float4, _BaseColor)
    UNITY_DOTS_INSTANCED_PROP(float,  _Smoothness)
UNITY_DOTS_INSTANCING_END(UserPropertyMetadata)

#endif

ブロック名として使用できる値は3種類あります:
BuiltinPropertyMetadata — Unity内部専用
MaterialPropertyMetadata — URP/HRDPビルトインシェーダーが使用
UserPropertyMetadataカスタムシェーダーで使用する名前。これを使います。

UserPropertyMetadataを使用することで、Unityの内部シェーダーコードと名前の衝突なく安全にプロパティを宣言できます。

アクセスマクロは3種類ありますが、実際にはWITH_DEFAULTをデフォルトとして使います。BRGはバッファの最初の64バイトを0で予約しているため、プロパティに値が設定されていない場合は0が返されます。_BaseColorで0が返ると黒になりますが、意図しないゴミ値よりもはるかに診断しやすいです。

マクロ 動作 いつ使うか
UNITY_ACCESS_DOTS_INSTANCED_PROP インスタンスデータを直接読む 常にデータがあると保証される場合
UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT データがなければ0を返す ほとんどの場合、これをデフォルトに
UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_CUSTOM_DEFAULT データがなければ指定値を返す 0以外のデフォルト値が必要な場合

ステップ3:SRP Batcher互換性の維持とアクセス方法の統一

ここが最も重要な部分です。DOTSパスと非DOTSパス(SRP Batcher)の両方をサポートするには、UnityPerMaterial cbufferとDOTSブロックを同時に宣言し、#defineトリックでシェーダーコードを統一します。

// SRP Batcher用cbuffer — SRP Batcher互換のために必須
CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    float4 _BaseColor;
    float  _Smoothness;
CBUFFER_END

// DOTSインスタンシングパスでは、cbufferの代わりにGPUバッファから読み込むようにオーバーライド
#ifdef UNITY_DOTS_INSTANCING_ENABLED
UNITY_DOTS_INSTANCING_START(UserPropertyMetadata)
    UNITY_DOTS_INSTANCED_PROP(float4, _BaseColor)
    UNITY_DOTS_INSTANCED_PROP(float,  _Smoothness)
UNITY_DOTS_INSTANCING_END(UserPropertyMetadata)

// シェーダー本体のコードはそのままにして、プリプロセッサが読み込みパスを切り替える
#define _BaseColor    UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4, _BaseColor)
#define _Smoothness   UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float,  _Smoothness)
#endif

#defineによる再定義のおかげで、頂点/フラグメントシェーダーの本体では_BaseColorをそのまま使えます。どちらのパスでコンパイルされるかに応じて、プリプロセッサが自動的にcbuffer読み込みまたはGPUバッファ読み込みに切り替えます。

ステップ4:Instance IDの伝播

構造体と関数に以下のマクロを追加します。

struct Attributes
{
    float4 positionOS : POSITION;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID   // 頂点入力にInstance IDを追加
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID   // フラグメントでもDOTSプロパティアクセス時に必要
};

Varyings Vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);            // unity_InstanceIDを初期化
    UNITY_TRANSFER_INSTANCE_ID(input, output); // フラグメントへ転送
    // ...
}

half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input); // フラグメントでDOTSプロパティを読む前に必須
    return _BaseColor;              // #defineによりDOTSバッファまたはcbufferから読まれる
}

完成した最小構成のURP Unlitシェーダー

上記のすべてをまとめた実際に動作するシェーダーです。

Shader "Custom/UnlitDotsInstanced"
{
    Properties
    {
        _BaseMap   ("Base Texture", 2D)    = "white" {}
        _BaseColor ("Base Color",   Color) = (1, 1, 1, 1)
    }

    SubShader
    {
        Tags { "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        Pass
        {
            Name "Forward"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM

            #pragma exclude_renderers gles gles3 glcore
            #pragma target 4.5

            #pragma vertex   Vert
            #pragma fragment Frag

            #pragma multi_compile_instancing
            #pragma instancing_options renderinglayer
            #pragma multi_compile _ DOTS_INSTANCING_ON

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv         : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            CBUFFER_START(UnityPerMaterial)
                float4 _BaseMap_ST;
                float4 _BaseColor;
            CBUFFER_END

            #ifdef UNITY_DOTS_INSTANCING_ENABLED
            UNITY_DOTS_INSTANCING_START(UserPropertyMetadata)
                UNITY_DOTS_INSTANCED_PROP(float4, _BaseColor)
            UNITY_DOTS_INSTANCING_END(UserPropertyMetadata)
            #define _BaseColor UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4, _BaseColor)
            #endif

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);

            Varyings Vert(Attributes input)
            {
                Varyings output;
                UNITY_SETUP_INSTANCE_ID(input);
                UNITY_TRANSFER_INSTANCE_ID(input, output);
                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                return output;
            }

            half4 Frag(Varyings input) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(input);
                half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
                return tex * _BaseColor;
            }

            ENDHLSL
        }
    }
}

Project Settingsで必ず確認すべき項目

実装が完了しているのにビルドだけで問題が発生する場合、ほぼ確実にこの設定が原因です。

Edit > Project Settings > Graphics > Shader Stripping > BatchRendererGroup Variants

この値がStrip AllまたはStrip Unusedになっていると、ビルド時にDOTS_INSTANCING_ONバリアントがすべて削除されます。エディターでは問題ないのにビルドだけでエラーになる典型的なパターンがここで発生します。

必ずKeep Allに設定してください。


互換性の確認方法

Frame Debuggerでバッチングを確認

Window > Analysis > Frame Debuggerを開いてドローコールの一覧を確認します。GPU Resident Drawerが正常に動作しているオブジェクトのドローコールは「Hybrid Batch Group」という名前でまとめて表示されます。カスタムシェーダーを使ったオブジェクトがここに含まれていなければ、BRG処理から除外されています。

Rendering DebuggerでGPUオクルージョンカリングを確認

Window > Analysis > Rendering Debugger > GPU Resident Drawerタブ

Occlusion Test Overlayを有効にすると、シーン/ゲームビューにオーバーレイが描画され、どのオブジェクトがGPUオクルージョンカリングの対象かを視覚的に確認できます。カスタムシェーダーのオブジェクトがこのオーバーレイに表示されていなければ、BRGから除外された状態です。

InspectorのSRP Batcher互換パネル

InspectorでMaterialを選択すると、下部に「SRP Batcher: compatible」または「SRP Batcher: not compatible」と表示されます。DOTSインスタンシングシェーダーはSRP Batcher互換も同時に満たす必要があるため、「not compatible」が表示された場合はUnityPerMaterial cbufferの宣言を再確認してください。


実務でよくはまる落とし穴

長年カスタムシェーダーを扱ってきて、またクライアントプロジェクトでGPU Resident Drawer導入を支援する中で繰り返し目撃してきたパターンです。

pragmaを一部のパスにのみ追加する
Forwardパスには追加したのに、ShadowCaster、DepthOnlyパスに漏れるケースが多いです。エラーメッセージにパス名が明記されているので、エラーが出たらまずどのパスか確認してください。

MaterialPropertyBlockの使用
スクリプトからrenderer.SetPropertyBlock(...)を呼び出した瞬間に、そのRendererはGPU Resident Drawerから丸ごと除外されます。個別のプロパティ1つを変えるだけでも同様です。インスタンスごとのプロパティが必要な場合は、DOTSインスタンシングバッファを通じて渡す必要があります。

ShaderGraphの使用
ShaderGraphのSprite Lit / Sprite UnlitターゲットはDOTS_INSTANCING_ONバリアントを生成しません。必ずURP LitまたはURP Unlitターゲットを使用してください。

UNITY_INSTANCING_BUFFER_STARTによるプロパティ宣言
#pragma multi_compile_instancing#pragma multi_compile _ DOTS_INSTANCING_ONは同じシェーダーに共存できます。問題はpragmaではなく、UNITY_INSTANCING_BUFFER_STARTでインスタンシングプロパティを宣言するときです。こうするとUnityPerMaterial cbuffer構造が壊れ、SRP Batcher互換性がなくなります。プロパティ宣言は必ずDOTS方式(UNITY_DOTS_INSTANCING_START)のみで行う必要があります。

Light Probe Proxy Volumeの使用
LPPVを使用するオブジェクトはGPU Resident Drawerの対象から除外されます。その理由は前の記事、GPU Resident Drawer 動作原理完全分析で解説しています。


まとめ

シンプルなカスタムシェーダーなのにGPU Resident Drawerが適用されず、かなり時間を費やした記憶があります。構造自体は単純です。pragma宣言、プロパティブロック、#defineによる再定義 — この流れを一度理解してしまえば、既存シェーダーへの追加作業は思ったより速く進みます。

ただし、MaterialPropertyBlockへの依存度が高いプロジェクトでは、この部分を先に整理する必要があります。GPU Resident Drawerの性能上のメリットを十分に得るには、インスタンスデータをDOTSインスタンシング方式で渡すフロー全体を理解している必要があります。

GPU Resident Drawerの動作原理が気になる方は、前の記事GPU Resident Drawer 動作原理完全分析を先に読まれることをお勧めします。


← 前の記事: GPU Resident Drawerの動作原理を徹底解説 — Unity 6はどのようにドローコールを削減するのか


さらに深く学ぶために

この記事を書くにあたって直接確認した資料です。

コメントする