Custom Shaders and GPU Resident Drawer — A Complete Guide to DOTS Instancing

If you turned on GPU Resident Drawer and saw this error in the console, you’re in the right place.

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

“Something’s wrong, but I have no idea what” — that’s exactly where you are. To use GPU Resident Drawer with custom shaders, there’s one mandatory hurdle: DOTS Instancing support.

The name might suggest you need the Entities package, but that’s a common misconception. DOTS Instancing is a shader-level mechanism for passing per-instance data, and it works perfectly with standard GameObject-based projects.


Table of Contents


What Is DOTS Instancing?

The two approaches are easy to confuse, but the fundamental difference is where the data lives.

With traditional GPU instancing (UNITY_INSTANCING_BUFFER_START), the CPU packs per-instance data (transforms, material properties, etc.) into a Constant Buffer every frame and uploads it to the GPU. There’s a hard limit of 1,023 instances per batch (Unity’s internal cap — this can be lower depending on the number of properties per instance within the 64KB cbuffer limit), and the data transfer itself is CPU overhead.

DOTS Instancing works differently. Instance data resides in a GraphicsBuffer (ByteAddressBuffer) in GPU memory, and the shader only receives a single 32-bit metadata value that tells it where to read in that buffer. The CPU only needs to upload data when it actually changes — no per-frame re-upload required.

Traditional GPU Instancing DOTS Instancing
Instance data location CPU → Constant Buffer (per frame) GPU-resident GraphicsBuffer
What gets sent to the shader Full property array One 32-bit metadata value
Batch size limit ~500 (64KB cbuffer limit) Thousands, effectively unlimited
SRP Batcher compatible No Yes (simultaneously)
Declaration macro UNITY_INSTANCING_BUFFER_START UNITY_DOTS_INSTANCING_START

GPU Resident Drawer internally uses the BatchRendererGroup (BRG) API, and BRG only supports DOTS Instancing. Shaders written for traditional GPU instancing simply won’t work with BRG.

One more thing to keep in mind: DOTS Instancing shaders must also be SRP Batcher compatible. You need to satisfy the SRP Batcher requirement (UnityPerMaterial cbuffer declaration) at the same time. The pattern that satisfies both simultaneously is the core of this post.


Adding DOTS Instancing to a Custom Shader

Step 1: Required pragma Declarations

Every Pass must include these three lines.

#pragma exclude_renderers gles gles3 glcore  // Exclude platforms without SM 4.5
#pragma target 4.5                            // Minimum shader model for DOTS Instancing
#pragma multi_compile _ DOTS_INSTANCING_ON   // Generate DOTS Instancing variant

Adding #pragma multi_compile_instancing alongside generates traditional GPU instancing variants too. Supporting both paths in one shader is the standard approach.

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

Important: These pragmas must be added to every Pass in the shader. If you add them to the Forward pass but forget ShadowCaster or DepthOnly, those passes will throw errors. The error message specifies the pass name, so it’s easy to identify.

Step 2: Declare the DOTS Instancing Property Block

Declare the property block inside an #ifdef UNITY_DOTS_INSTANCING_ENABLED guard.

#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

Three block names are available:
BuiltinPropertyMetadata — Unity internal use only
MaterialPropertyMetadata — used by URP/HDRP built-in shaders
UserPropertyMetadatause this for custom shaders

Using UserPropertyMetadata avoids any naming conflicts with Unity’s internal shader code.

There are three accessor macro variants; in practice, WITH_DEFAULT is the safe default. BRG reserves the first 64 bytes of the buffer as zero, so if a property hasn’t been set, it returns 0. Getting black from _BaseColor is much easier to diagnose than garbage values.

Macro Behavior When to Use
UNITY_ACCESS_DOTS_INSTANCED_PROP Read instance data directly When data is guaranteed to exist
UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT Returns 0 if no data Most cases — use this as default
UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_CUSTOM_DEFAULT Returns specified value if no data When you need a non-zero default

Step 3: Maintain SRP Batcher Compatibility + Unify Access

This is the most important part. To support both the DOTS path and the non-DOTS path (SRP Batcher), declare both the UnityPerMaterial cbuffer and the DOTS block simultaneously, then use the #define trick to unify the shader body.

// cbuffer for SRP Batcher — required for SRP Batcher compatibility
CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    float4 _BaseColor;
    float  _Smoothness;
CBUFFER_END

// In DOTS Instancing path, override to read from GPU buffer instead of cbuffer
#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)

// Leave shader body as-is; the preprocessor swaps the read path
#define _BaseColor    UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4, _BaseColor)
#define _Smoothness   UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float,  _Smoothness)
#endif

Thanks to the #define overrides, the vertex/fragment shader body can use _BaseColor as-is. The preprocessor automatically switches between cbuffer reads and GPU buffer reads depending on which path is being compiled.

Step 4: Propagate Instance ID

Add these macros to your structs and functions.

struct Attributes
{
    float4 positionOS : POSITION;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID   // Add instance ID to vertex input
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID   // Required for DOTS property access in fragment
};

Varyings Vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);            // Initialize unity_InstanceID
    UNITY_TRANSFER_INSTANCE_ID(input, output); // Pass to fragment
    // ...
}

half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input); // Required before reading DOTS properties in fragment
    return _BaseColor;              // Reads from DOTS buffer or cbuffer via #define
}

Complete Minimal URP Unlit Shader

Here’s a fully working shader combining everything above.

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 You Must Verify

If everything works in the Editor but breaks in a build, this setting is almost certainly the culprit.

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

If this is set to Strip All or Strip Unused, all DOTS_INSTANCING_ON variants will be removed at build time. This is the classic pattern where everything looks fine in the Editor but fails in a build.

Set it to Keep All.


Verifying Compatibility

Checking Batching with Frame Debugger

Open Window > Analysis > Frame Debugger and look at the draw call list. Objects correctly processed by GPU Resident Drawer will have their draw calls grouped under “Hybrid Batch Group”. If your custom shader objects aren’t there, they’ve been excluded from BRG processing.

Checking GPU Occlusion Culling with Rendering Debugger

Window > Analysis > Rendering Debugger > GPU Resident Drawer tab

Enabling the Occlusion Test Overlay draws an overlay in the Scene/Game view showing which objects are subject to GPU occlusion culling. If your custom shader objects don’t appear in this overlay, they’ve been excluded from BRG.

SRP Batcher Compatibility in the Inspector

Select a Material in the Inspector and check the bottom of the panel for “SRP Batcher: compatible” or “SRP Batcher: not compatible”. DOTS Instancing shaders must simultaneously satisfy SRP Batcher requirements, so “not compatible” means you need to review your UnityPerMaterial cbuffer declaration.


Common Pitfalls

These are patterns I’ve repeatedly seen — both in my own work with custom shaders and while helping client projects adopt GPU Resident Drawer.

Adding pragmas to only some passes
A common mistake is adding the pragmas to the Forward pass but missing ShadowCaster and DepthOnly. The error message specifies the pass name, so check that first.

Using MaterialPropertyBlock
The moment you call renderer.SetPropertyBlock(...) from a script, that Renderer is entirely excluded from GPU Resident Drawer — even changing a single property has this effect. If you need per-instance properties, you must pass them through the DOTS Instancing buffer.

Using ShaderGraph
ShaderGraph’s Sprite Lit / Sprite Unlit targets don’t generate DOTS_INSTANCING_ON variants. You must use URP Lit or URP Unlit targets.

Declaring properties with UNITY_INSTANCING_BUFFER_START
#pragma multi_compile_instancing and #pragma multi_compile _ DOTS_INSTANCING_ON can coexist in the same shader. The problem isn’t the pragmas — it’s declaring instancing properties with UNITY_INSTANCING_BUFFER_START. This breaks the UnityPerMaterial cbuffer structure and eliminates SRP Batcher compatibility. Property declarations must use the DOTS approach (UNITY_DOTS_INSTANCING_START) exclusively.

Using Light Probe Proxy Volumes
Objects using LPPV are excluded from GPU Resident Drawer. I covered the reason in detail in the previous post, GPU Resident Drawer Deep Dive.


Closing

I remember spending a frustrating amount of time debugging GPU Resident Drawer compatibility on what seemed like a simple custom shader. The structure itself is straightforward: pragma declarations, property block, #define overrides — once you internalize this flow, retrofitting existing shaders is much faster than you’d expect.

That said, if your project has a heavy dependency on MaterialPropertyBlock, that needs to be addressed first. Getting the full performance benefit of GPU Resident Drawer requires understanding the complete flow of passing instance data through the DOTS Instancing buffer.

If you want to understand how GPU Resident Drawer works under the hood, I’d recommend reading the previous post, GPU Resident Drawer Deep Dive, first.


← Previous: GPU Resident Drawer Internals — How Unity 6 Cuts Draw Calls


Further Reading

Resources I personally verified while writing this post.

Leave a Comment