When developing games for mobile or console platforms using Unity's URP (Universal Render Pipeline), rendering performance hinges heavily on the SRP Batcher (Scriptable Render Pipeline Batcher). The SRP Batcher is a powerful optimization feature that groups rendering operations for different materials using the same shader, drastically reducing CPU-to-GPU overhead (such as SetPass calls and Draw Calls).
However, during project optimization, you may frequently encounter the warning "SRP Batcher: Compatible: No" in the Frame Debugger or Material Inspector. When this happens, batching fails, causing your draw call count to spike.
In this article, we will break down the root causes of SRP Batcher incompatibility at the code level and explain how to write and configure custom HLSL shaders and Shader Graphs to maintain SRP Batcher compatibility.
1. Background: How the SRP Batcher Works vs. Traditional Batching
Traditional batching methods like GPU Instancing and Dynamic Batching require objects to share the exact same mesh and material. If even a single parameter (such as color or texture tiling) differs between materials, the batch breaks, leading to multiple draw calls.
In contrast, the SRP Batcher takes a different approach. Even if objects use different meshes and materials, as long as they share the same shader variant, Unity pre-uploads their material parameters to GPU memory inside Constant Buffers (CBUFFERs).
[Without SRP Batcher] DrawCall(Material A) ──> SetPassCall(Bind Params) ──> DrawMesh DrawCall(Material B) ──> SetPassCall(Bind Params) ──> DrawMesh *Heavy binding overhead each time [With SRP Batcher] Allocates constant buffers in GPU memory storing parameters for both Material A and B CBUFFER: [ Material A Params ][ Material B Params ] └─> The CPU only needs to send an offset value to switch materials, minimizing binding overhead!
Figure: How the SRP Batcher optimizes material parameter transitions using GPU constant buffers.
This allows projects with numerous distinct models and materials to keep CPU-to-GPU overhead low. However, to leverage this behavior, your shaders must follow the strict coding rules required for SRP Batcher Compatibility.
2. Three Main Causes of SRP Batcher Incompatibility (Compatible: No)
If the SRP Batcher is not working for a particular shader, it is typically due to one of these three reasons:
(1) Material Properties are Declared Outside the UnityPerMaterial Constant Buffer
This is the most common issue in custom HLSL shaders. The SRP Batcher requires all material-specific properties (like colors, range values, and vectors) to be declared inside a CBUFFER (Constant Buffer) block named UnityPerMaterial.
If variables are declared in the global scope outside this buffer, Unity cannot pack the material parameters together, breaking compatibility.
(2) Textures or Sampler States are Declared Inside the Constant Buffer
Constant Buffers (CBUFFERs) can only contain numerical data types like float, float4, or matrices. Resource objects such as Texture2D and SamplerState cannot reside within a CBUFFER due to hardware limitations. Declaring them inside a CBUFFER will cause shader compilation errors or break SRP Batcher compatibility.
(3) Using MaterialPropertyBlock (MPB) for Dynamic Parameter Control
In the Built-in Render Pipeline, using MaterialPropertyBlock to dynamically adjust individual object properties (such as color) was the gold standard for preventing heap allocations.
However, in URP/SRP, applying a MaterialPropertyBlock excludes that object from the SRP Batcher, forcing a separate draw call. This happens because the custom property block overrides conflict with the constant buffer packing mechanism.
3. Code Refactoring: Making a Custom HLSL Shader Compatible
Below is an example of an incompatible shader and how to refactor it to restore compatibility.
Incompatible Code (Anti-Pattern)
Shader "Custom/UnoptimizedShader"
{
Properties
{
_BaseColor ("Base Color", Color) = (1,1,1,1)
_Metallic ("Metallic", Range(0,1)) = 0.0
_BaseMap ("Base Map", 2D) = "white" {}
}
SubShader
{
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// Variables declared globally outside any constant buffer
float4 _BaseColor;
float _Metallic;
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
// (Vertex & Fragment logic omitted)
ENDHLSL
}
}
}
In the shader above, _BaseColor and _Metallic are declared globally, making the shader incompatible with the SRP Batcher.
Compatible Code (Recommended)
Shader "Custom/OptimizedShader"
{
Properties
{
_BaseColor ("Base Color", Color) = (1,1,1,1)
_Metallic ("Metallic", Range(0,1)) = 0.0
_BaseMap ("Base Map", 2D) = "white" {}
}
SubShader
{
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// 1. Declare textures and samplers outside the CBUFFER
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
// 2. Wrap all non-texture shader properties inside the UnityPerMaterial CBUFFER
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float _Metallic;
float4 _BaseMap_ST; // Tiling and offset vectors must also be in the CBUFFER
CBUFFER_END
// (Vertex & Fragment logic omitted)
ENDHLSL
}
}
}
By wrapping properties in the CBUFFER_START(UnityPerMaterial) and CBUFFER_END macros, the shader immediately becomes SRP Batcher compatible, allowing multiple materials to render in a single batch.
4. Keeping Shader Graph Compatible with the SRP Batcher
Shaders generated by Unity's Shader Graph are generally SRP Batcher compatible by default. However, be aware of the following pitfalls that can break compatibility:
- Direct Global References in Custom Function Nodes: Accessing global variables or custom constant buffers that are not declared in the Graph's Properties section can break compatibility. Always pass external values via node Input Ports.
- Custom Shaders configured for Hybrid Renderer (DOTS/ECS): When working in an ECS environment and enabling `DOTS Instancing`, Unity uses DOTS instancing buffers instead of the SRP Batcher, which requires different compatibility rules.
5. Alternatives to MaterialPropertyBlock for Dynamic Property Modification
To dynamically modify material parameters at runtime without breaking the SRP Batcher, consider these approaches:
Approach 1: Instantiating Material Copies (If object count is low)
Modifying parameters directly via renderer.material.color = color creates a unique material copy. While this breaks batching for that specific object, it is acceptable if only a few objects are modified. However, doing this for thousands of instances will fragment memory and cause a draw call explosion.
Approach 2: Combine with GPU Instancing (For identical meshes)
If you have thousands of identical meshes (e.g., grass blades, simple enemies) that only differ in color, GPU Instancing is often more efficient than the SRP Batcher. Add #pragma multi_compile_instancing to your shader and use MaterialPropertyBlock. The GPU will draw them in a single instanced draw call. The SRP Batcher will bypass these instances, but overall performance will improve.
Approach 3: Use Vertex Colors or Texture Arrays
By writing dynamic colors to mesh Vertex Colors, or packaging multiple color palettes into a Texture2DArray and sampling based on UV index, you can vary appearances using a single material. This allows you to reap the full benefits of the SRP Batcher without modifying material parameters at all.
6. Summary
- SRP Batcher groups materials sharing the same shader: It bypasses traditional batching limits and minimizes binding overhead.
- Always wrap numeric properties in the
UnityPerMaterialCBUFFER: Ensure custom HLSL shaders group colors, range values, and vectors inside the proper macros. - Avoid MaterialPropertyBlock when targeting the SRP Batcher: In URP, using MPBs breaks batching. Use Vertex Colors or Texture Arrays for dynamic variants, or transition to GPU Instancing when appropriate.