GRAPHICS × PRODUCTION
中EN

皮了个牛

↓

计算机图形学

Unity SRP 01:Pre-Z + GBuffer 基础管线搭建

从创建 RenderPipeline 和 RenderPipelineAsset 开始,实现 RenderContent、LightMode Tag、Pre-Z、GBuffer 通道与 Full Screen Blit,并以 SRP Batcher、Static Batching 混合机制和性能对比收束基础 SRP 管线。

2019-12-2735 分钟阅读
Unity SRP 01:Pre-Z + GBuffer 基础管线搭建

一、背景

基于 Unity 2019.1 的 Scriptable Render Pipeline(SRP),从零搭建一个包含 Pre-Z 和 GBuffer 绘制的延迟渲染管线。

SRP 把 Unity 的全部渲染细节以脚本形式暴露给开发者。配合 CommandBuffer(类似 DirectX 的 Command List),可以完全控制每一帧的绘制流程,引擎内部不再有黑盒行为。相比直接啃 DX12 那套底层接口,Unity 的封装让管线开发的工程复杂度低了很多。

SRP 的抽象层级介于引擎预制管线(Built-in Pipeline)和原生图形 API 之间。它把 Descriptor Set、Root Signature 或 Pipeline State Object 这类底层概念收拢在 ShaderPassName、DrawingSettings、FilteringSettings 等高层结构中。开发者关注的粒度是"画哪些对象、用哪个 Pass、输出到哪个 RT",不需要关心"绑定哪个 Descriptor Heap、设置哪个 Barrier"。这个抽象粒度对原型迭代非常友好:一个完整的 Deferred Pipeline 原型可以在数百行 C# 内完成。

二、创建 RenderPipeline

SRP 的入口是 RenderPipeline 类。开发者需要继承该类并重写两个虚函数:

  • Render() — 每帧渲染循环的入口
  • Dispose() — 管线销毁时的清理逻辑

Render() 接收一个 ScriptableRenderContext 和 Camera[] 数组。在该函数内对每个 Camera 执行 foreach 循环:先设置渲染参数,再执行 Culling,最后通过 CommandBuffer 完成绘制命令录制。

2.1 ScriptableRenderContext 的 API 设计

ScriptableRenderContext 是 SRP 与引擎 Graphics Backend 之间的唯一通道。它采用延迟提交(Deferred Submission)模式:所有通过 context.ExecuteCommandBuffer(cmd) 录制的命令和通过 context.DrawRenderers() 发起的绘制调用都会先暂存在内部队列中,调用时并不立即提交给 GPU。只有调用 context.Submit() 时,队列中的全部命令才会被一次性序列化为底层图形 API 调用并提交给驱动。

这个设计有两个关键收益:

  1. 命令重排:Context 在 Submit 前可以对命令序列做有限的重排和合并优化(如 Barrier 合并、连续 SetRenderTarget 去重),减少冗余的 GPU 状态切换。
  2. 验证窗口:在 Submit 之前,Unity 的验证层可以检查资源绑定完整性、RT 格式兼容性等问题,并在 Editor 下给出诊断信息,而非等到 GPU Fault 后才报错。

CommandBuffer 与 ScriptableRenderContext 的关系是"录制器"与"提交器"。CommandBuffer 负责录制 GPU 命令(SetRenderTarget、DrawMesh、Dispatch 等),录制完成后通过 context.ExecuteCommandBuffer(cmd) 将其内容追加到 Context 的内部队列。一个 CommandBuffer 可以多次 Execute,每次 Execute 将当前 buffer 中的命令拷贝到 Context 队列,之后 buffer 可以 Clear 并复用。一帧内可以用同一个 CommandBuffer 实例分段录制不同 Pass 的命令:

// Pre-Z Pass
CommandBuffer cmdPrePass = CommandBufferPool.Get(InfinityPassIDs.PreDepthPass.name);
cmdPrePass.GetTemporaryRT(InfinityShaderIDs.RT_DepthBuffer,
    camera.pixelWidth, camera.pixelHeight, 24, FilterMode.Point, RenderTextureFormat.Depth);
cmdPrePass.SetRenderTarget(InfinityShaderIDs.ID_SceneDepth);
cmdPrePass.ClearRenderTarget(true, true, camera.backgroundColor);
context.ExecuteCommandBuffer(cmdPrePass);
CommandBufferPool.Release(cmdPrePass);
context.DrawRenderers(cullingData, ref drawSettings_PrePass, ref filterSettings_PrePass);

// GBuffer Pass
CommandBuffer cmdGBuffer = CommandBufferPool.Get(InfinityPassIDs.GBufferPass.name);
cmdGBuffer.SetRenderTarget(InfinityShaderIDs.ID_GBuffer, InfinityShaderIDs.ID_SceneDepth);
cmdGBuffer.ClearRenderTarget(false, true, camera.backgroundColor);
context.ExecuteCommandBuffer(cmdGBuffer);
CommandBufferPool.Release(cmdGBuffer);
context.DrawRenderers(cullingData, ref drawSettings_GBuffer, ref filterSettings_GBuffer);

context.Submit();  // 两个 Pass 的命令一起提交

context.DrawRenderers() 是独立于 CommandBuffer 的另一条绘制路径。它直接接收 CullingResults 和各种 Settings 结构,由引擎内部生成 Draw Command。这条路径的好处是引擎可以在内部应用 SRP Batcher 的合批逻辑;如果改用 CommandBuffer 手动录制 cmd.DrawMesh(),就绕过了 Batcher,无法享受合批优化。因此实际工程中,对场景物体的绘制应优先使用 context.DrawRenderers(),只有 Full Screen Pass、Compute Dispatch 等非场景绘制才使用 CommandBuffer 直接录制。

基本框架的伪代码:

public class MyRenderPipeline : RenderPipeline
{
    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach (var camera in cameras)
        {
            context.SetupCameraProperties(camera);
            var cullingResult = context.Cull(ref cullParams);
            RenderContent(context, camera, cullingResult);
            context.Submit();
        }
    }

    protected override void Dispose(bool disposing) { /* cleanup */ }
}

context.SetupCameraProperties(camera) 在内部完成 View/Projection/VP Matrix 的设置,以及摄像机相关的全局 Shader 属性(_WorldSpaceCameraPos、unity_CameraProjection 等)的绑定。这一步必须在任何 DrawRenderers 调用之前执行,否则 Shader 中读到的矩阵是上一帧的残留值。

三、RenderContent 实现

核心绘制逻辑封装在 RenderContent() 函数中,该函数接收当前循环的 Camera、CullingResults 和 RenderContext。绘制通过几个 SRP API 的配合完成:FilteringSettings 从场景对象中过滤出当前 Pass 需要的对象(如仅 Opaque),DrawingSettings 根据 Shader Pass 名称确定使用哪个 Pass 绘制,并指定绘制排序以减少 Overdraw;具体的 GPU 命令由 CommandBuffer 录制,最终经 DrawRenderers() 执行实际绘制。

CommandBuffer 不使用 new 创建,改从 CommandBufferPool.Get() 获取池中的可用实例,避免频繁创建销毁带来的 GC 开销。

3.1 Shader Pass 的 LightMode Tag 机制

DrawingSettings 通过 ShaderPassName 指定当前 Draw Call 使用 Shader 中的哪个 Pass。而 Shader 端通过 Pass 内的 Tags { "LightMode" = "xxx" } 声明自己的身份标识。两端通过字符串匹配完成绑定:

Pass
{
    Tags { "LightMode" = "DepthOnly" }
    // ...
}
// 工程中通过 InfinityPassIDs 集中管理 Pass 名称
var drawSettings = new DrawingSettings(
    InfinityPassIDs.PreDepthPass,  // 匹配 LightMode = "My_PrePass" 的 Pass
    new SortingSettings(camera) { criteria = SortingCriteria.RenderQueue | SortingCriteria.OptimizeStateChanges }
)
{
    enableInstancing = true,
    enableDynamicBatching = true,
    perObjectData = PerObjectData.Lightmaps
};

LightMode Tag 的匹配规则:

  • 每个 Shader 的 SubShader 内可以有多个 Pass,每个 Pass 通过 LightMode 标记其用途
  • DrawRenderers 调用时指定的 ShaderPassName 会遍历物体 Shader 的所有 Pass,选中第一个 LightMode 匹配的 Pass 执行
  • 如果没有匹配的 Pass,该物体在此 DrawCall 中被跳过(不绘制)
  • 可以通过 DrawingSettings.SetShaderPassName(index, name) 指定多个候选 Pass Name,按 index 顺序尝试匹配

这个机制使得同一个 Shader 可以服务于管线中的多个阶段。例如一个标准 PBR Shader 可以同时包含 DepthOnly、GBuffer、ShadowCaster、Meta 等多个 Pass,管线的不同阶段各取所需。Material 无需为不同的管线阶段配置不同的 Shader:单个 Shader 文件内组织所有 Pass 即可。

SortingSettings 控制 Draw Call 的提交顺序。对于 Pre-Z Pass,设置 SortingCriteria.CommonOpaque(由近到远排序)可以最大化 Early-Z 的剔除率。对于透明物体,则使用 SortingCriteria.CommonTransparent(由远到近排序)保证混合正确性。

四、Shader 结构

Shader 包含两个 Pass:

// Pass 0: Depth Only
Pass
{
    Name "DepthOnly"
    Tags { "LightMode" = "DepthOnly" }
    ColorMask 0
    ZWrite On
    ZTest LEqual

    HLSLPROGRAM
    #pragma vertex DepthOnlyVert
    #pragma fragment DepthOnlyFrag
    // Vertex Shader 仅做 MVP 变换,Fragment Shader 为空(ColorMask 0 下不写入颜色)
    ENDHLSL
}

// Pass 1: GBuffer
Pass
{
    Name "GBuffer"
    Tags { "LightMode" = "GBuffer" }
    ZWrite Off
    ZTest Equal   // 利用 Pre-Z 结果,仅对 Equal 的像素着色

    HLSLPROGRAM
    #pragma vertex GBufferVert
    #pragma fragment GBufferFrag
    // 输出 Albedo / Normal / Roughness-Metallic 等 GBuffer 数据
    ENDHLSL
}

4.1 Pre-Z Pass 的 Overdraw 消除原理

Pre-Z(也称 Z-Prepass 或 Depth Prepass)的目标是提前建立深度缓冲,使后续 GBuffer Pass 能利用 ZTest Equal 精确跳过被遮挡像素的 Fragment Shader 执行。

具体流程:

  1. Pre-Z Pass:以 ZWrite On + ZTest LEqual + ColorMask 0 绘制所有 Opaque 物体。此 Pass 只执行 Vertex Shader 和深度写入,Fragment Shader 基本为空(不输出颜色),开销极低。完成后 Depth Buffer 中存有每个像素距摄像机最近的深度值。
  2. GBuffer Pass:以 ZWrite Off + ZTest Equal 绘制。只有深度与 Depth Buffer 中已有值精确相等的像素才能通过深度测试,进入 Fragment Shader 执行。所有被遮挡像素在 Early-Z 阶段即被硬件剔除。

Early-Z 是 GPU 硬件层面的优化:在 Fragment Shader 执行之前,硬件先比较当前片元的深度与 Depth Buffer 中的值。如果深度测试失败,该片元被直接丢弃,不进入 Fragment Shader。这避免了昂贵的纹理采样和光照计算。

Hi-Z(Hierarchical Z-Buffer)是 Early-Z 的加速结构。GPU 维护 Depth Buffer 的 Mipmap 金字塔,高层 Mip 存储对应区域的最近/最远深度。光栅化阶段在将 Triangle 拆分为片元前,先用 Triangle 的 Bounding Box 在 Hi-Z 金字塔的粗粒度层级做快速剔除:如果 Triangle 的最远深度已经大于该区域的最近深度,则整个 Triangle 可以被跳过。Pre-Z 后 Depth Buffer 已经是完整的,Hi-Z 金字塔精度最高,后续 GBuffer Pass 的 Hi-Z 剔除率最大化。

Pre-Z 的代价:

  • 全场景多执行一次 Vertex Shader(无 Fragment 开销)
  • 额外的 Draw Call 提交(但通过 SRP Batcher 合批后开销很小)
  • Depth Buffer 写入的带宽

在 Overdraw 严重的场景(大量前后遮挡、复杂 GBuffer Fragment Shader)中,Pre-Z 的收益远大于代价。对于简单场景(Overdraw 低、Fragment Shader 轻量),Pre-Z 反而可能是净负收益,需要根据实际 Profiling 决定是否启用。

4.2 GBuffer 通道规格设计

延迟渲染的 GBuffer Layout 决定了管线能支持的材质复杂度和带宽开销。4 张 RT 的布局如下:

RT 索引格式内容说明
RT0ARGB32 (sRGB)RGB: Albedo, A: 保留sRGB 存储,采样时硬件自动做 Gamma→Linear
RT1ARGB2101010RG: Octahedral Normal XY, B: 保留, A: 2bit ShadingModelID10bit 精度的法线编码,避免 8bit 的 Banding
RT2ARGB32 (Linear)R: Roughness, G: Metallic, B: AO, A: 保留PBR 参数打包在一张 RT 内
RT3 (Depth)D32_SFloatDepth32bit 浮点深度,Pre-Z 和 GBuffer 共用

设计要点:

法线编码:使用 Octahedral Mapping 将单位法线压缩为 2 个分量存储在 10bit 通道中。相比 Spheremap Transform 和 Stereographic Projection,Octahedral 在精度/解码复杂度/边界连续性上取得最佳平衡。10bit 精度(1024 级)在法线变化平缓的区域足够,但对镜面反射的法线精度敏感场景可能出现 Specular Aliasing,此时可考虑升级到 RGB10A2 的三分量存储。

ShadingModelID:用 2bit(4 种模型)存储当前像素使用的着色模型。Lighting Pass 根据此 ID 分支到不同的 BRDF 评估路径(如 Standard PBR / Skin / Hair / Eye)。4 种够覆盖多数情况,如需更多可挤占其他通道的保留位。

带宽开销:4 张 RT × 1080p 分辨率 × 4Byte/pixel = 约 33MB/帧的写入带宽。加上 Depth Buffer 的 4Byte 开销,总计约 41MB。Mobile 平台需格外关注这个数字:如果带宽预算紧张,可考虑压缩到 3 张 RT(Roughness/Metallic 各 4bit 打包)或使用 RGBA16F 的半精度方案。

// GBuffer Fragment Shader 输出结构
struct GBufferOutput
{
    half4 RT0 : SV_Target0;  // Albedo.rgb + reserved
    half4 RT1 : SV_Target1;  // OctNormal.xy + reserved + ShadingModelID
    half4 RT2 : SV_Target2;  // Roughness + Metallic + AO + reserved
};

GBufferOutput GBufferFrag(Varyings input)
{
    GBufferOutput output;
    
    half3 albedo = tex2D(_MainTex, input.uv).rgb;
    half3 normal = normalize(input.worldNormal);
    half2 octNormal = OctahedralEncode(normal);
    half roughness = tex2D(_RoughnessTex, input.uv).r;
    half metallic = _Metallic;
    
    output.RT0 = half4(albedo, 0);
    output.RT1 = half4(octNormal, 0, _ShadingModelID / 3.0);
    output.RT2 = half4(roughness, metallic, input.ao, 0);
    
    return output;
}

SRP Batcher 要求矩阵参数必须包裹在固定名称的 CBuffer 中:

CBUFFER_START(UnityPerMaterial)
    float4 _BaseColor;
    float4 _MainTex_ST;
    half _Metallic;
    half _Roughness;
    half _ShadingModelID;
CBUFFER_END

CBUFFER_START(UnityPerDraw)
    float4x4 unity_ObjectToWorld;
    float4x4 unity_WorldToObject;
    float4 unity_LODFade;
    real4 unity_WorldTransformParams;
CBUFFER_END

SRP Batcher 通过固定 CBuffer 布局消除材质属性上传开销,Shader 端和逻辑端必须配合使用。

五、Full Screen Blit 的 Y 轴翻转

DrawFullScreen() 函数用于将 RT 内容 Blit 到最终输出。由于 DirectX 平台下 Game View 和 Scene View 的 Y 轴方向相反,需要进行矫正:判断当前 Camera Target 是否为 null(null 表示 Game View),若是则在 DrawMesh 时翻转 UV 的 Y 坐标。

实际工程中通过构建两套 FullScreen Mesh 来处理翻转:Game View 和 Scene View 各对应一套 UV,DrawFullScreen 根据当前 View 选择对应 Mesh:

public static Mesh FullScreenMeshOrigin_Game;
public static Mesh FullScreenMeshOrigin_Scene;

public static Mesh FullScreenMesh_Game
{
    get
    {
        if (FullScreenMeshOrigin_Game != null) return FullScreenMeshOrigin_Game;

        FullScreenMeshOrigin_Game = new Mesh { name = "FullScreen Mesh" };
        FullScreenMeshOrigin_Game.vertices = new Vector3[] {
            new Vector3(-1, -1, 0),
            new Vector3(-1,  1, 0),
            new Vector3( 1,  1, 0),
            new Vector3( 1, -1, 0)
        };
        // Game View: UV.y 从 0→1(底→顶),正常映射
        FullScreenMeshOrigin_Game.uv = new Vector2[] {
            new Vector2(0, 0),
            new Vector2(0, 1),
            new Vector2(1, 1),
            new Vector2(1, 0)
        };
        FullScreenMeshOrigin_Game.SetIndices(new int[] { 0, 1, 2, 3 }, MeshTopology.Quads, 0, false);
        FullScreenMeshOrigin_Game.UploadMeshData(false);
        return FullScreenMeshOrigin_Game;
    }
}

public static Mesh FullScreenMesh_Scene
{
    get
    {
        if (FullScreenMeshOrigin_Scene != null) return FullScreenMeshOrigin_Scene;

        FullScreenMeshOrigin_Scene = new Mesh { name = "FullScreen Mesh" };
        FullScreenMeshOrigin_Scene.vertices = new Vector3[] {
            new Vector3(-1, -1, 0),
            new Vector3(-1,  1, 0),
            new Vector3( 1,  1, 0),
            new Vector3( 1, -1, 0)
        };
        // Scene View: UV.y 翻转(1→0),补偿 Unity 内部的 Scene View 翻转
        FullScreenMeshOrigin_Scene.uv = new Vector2[] {
            new Vector2(0, 1),
            new Vector2(0, 0),
            new Vector2(1, 0),
            new Vector2(1, 1)
        };
        FullScreenMeshOrigin_Scene.SetIndices(new int[] { 0, 1, 2, 3 }, MeshTopology.Quads, 0, false);
        FullScreenMeshOrigin_Scene.UploadMeshData(false);
        return FullScreenMeshOrigin_Scene;
    }
}

public static void DrawFullScreen(this CommandBuffer cmd, bool isGameView,
    RenderTargetIdentifier source, RenderTargetIdentifier dest)
{
    cmd.SetRenderTarget(dest);
    cmd.SetGlobalTexture(InfinityShaderIDs.RT_MainTexture, source);
    cmd.DrawMesh(isGameView ? FullScreenMesh_Game : FullScreenMesh_Scene,
        Matrix4x4.identity, BlitMaterial, 0, 0);
}

两套 Mesh 的顶点位置完全相同(NDC 空间的全屏 Quad),区别仅在 UV.y 方向:Game View 的 UV 是标准映射 (0,0)→(1,1);Scene View 的 UV 在 Y 轴做了翻转 (0,1)→(1,0)。通过 Mesh 级别的 UV 翻转代替 Shader 内的动态判断,可以避免分支指令开销。

这个问题的根源是 DirectX 的 RenderTarget 坐标系与 OpenGL 系相反:DX 的纹理原点在左上角,GL 在左下角。Unity 在 Scene View 中已经做了翻转处理,但 Game View 保持了 DX 的原始行为。

具体来说,当渲染到中间 RT 再 Blit 到屏幕时,DX 后端的 RT 内容相对最终屏幕是 Y 轴翻转的。Unity 对 Scene View 内部做了一次翻转补偿,而 Game View 不做这个补偿,所以自定义管线必须自行处理。OpenGL/Vulkan 后端则不存在此问题,因为它们的纹理原点与屏幕坐标系一致。可以通过 SystemInfo.graphicsUVStartsAtTop 查询当前后端是否需要翻转。

六、RenderPipelineAsset

RenderPipeline 类本身不会自动实例化,需要通过 RenderPipelineAsset 的 CreatePipeline() 方法来创建实例。在该 Asset 中同时启用 SRP Batcher 设置。

创建流程:在 Project 窗口右键 Create → MyRenderPipeline 生成 Asset 文件,将其拖入 Graphics Settings 的 SRP Asset 引用位置即可激活自定义管线。

[CreateAssetMenu(menuName = "Rendering/MyRenderPipeline")]
public class MyRenderPipelineAsset : RenderPipelineAsset
{
    public bool enableSRPBatcher = true;
    
    protected override RenderPipeline CreatePipeline()
    {
        GraphicsSettings.useScriptableRenderPipelineBatching = enableSRPBatcher;
        return new MyRenderPipeline();
    }
}

通过 CreateAssetMenu 属性声明后,可以在 Project 窗口右键创建。Asset 持有管线配置参数,每次进入 Play Mode 或编辑器加载时调用 CreatePipeline() 实例化管线对象。

Asset 的另一个作用是作为 Serialized 数据的容器:管线的可配置参数(如 GBuffer 格式、Shadow Map 分辨率,或 Pre-Z 的开关)可以声明为 Asset 的 public 字段,通过 Inspector 暴露给用户调整。管线对象在构造时读取这些参数。修改 Asset 参数后 Unity 会自动销毁当前管线实例并重新调用 CreatePipeline(),无需手动重启。

七、SRP Batcher 性能测试

对场景进行 Batching 性能测试,对比三种配置:

配置Batch 数Draw Call 数
纯 SRP Batcher较低中等
无 SRP Batcher最高最高
SRP Batcher + Static Batching 混合最低最低

7.1 SRP Batcher 的 CBuffer 持久化机制

SRP Batcher 的核心机制是持久化 CBuffer 绑定。传统模式下每次 DrawCall 都需要重新上传材质 CBuffer 数据到 GPU;SRP Batcher 识别到相同 CBuffer Layout 的材质后,将数据持久化在 GPU 侧,仅更新发生变化的 Per-Draw 数据(如 ObjectToWorld 矩阵)。

GPU 侧的具体机制:

  1. CBuffer 分配:SRP Batcher 在 GPU 内存中为每个 Material 实例分配一块持久化的 Constant Buffer 空间。这块空间在 Material 首次使用时分配,Material 销毁时释放,中间不会每帧重新分配。

  2. 数据更新策略:每帧开始时,引擎遍历所有 Material,检查其属性是否发生变化(通过 dirty flag)。只有 dirty 的 Material 才需要将数据从 CPU 上传到 GPU 的持久 CBuffer 中。未变化的 Material 直接复用 GPU 侧已有数据。

  3. Per-Draw CBuffer:ObjectToWorld 等逐对象数据存储在另一块 CBuffer 中。SRP Batcher 将场景中所有 Renderer 的 Per-Draw 数据打包到一个大的 GPU Buffer 中,以 offset 区分不同对象。Draw Call 执行时只需设置 offset 而非重新上传整块数据。

  4. 绑定流程优化:传统流程是 SetCBuffer → Draw → SetCBuffer → Draw → ...,每个 Draw 前都有一次完整的 CBuffer 绑定。SRP Batcher 将其优化为:对于 CBuffer Layout 相同的连续 Draw Call,只在序列起始处绑定一次 Material CBuffer,后续 Draw 只更新 Per-Draw offset。

// 传统模式(无 SRP Batcher)
SetMaterialCBuffer(matA)    // 上传 matA 的属性
SetPerDrawCBuffer(obj1)     // 上传 obj1 的矩阵
Draw(obj1)
SetMaterialCBuffer(matA)    // 再次上传 matA(即使相同)
SetPerDrawCBuffer(obj2)     // 上传 obj2 的矩阵
Draw(obj2)

// SRP Batcher 模式
BindPersistentMaterialCBuffer(matA)  // 绑定一次,GPU 侧已有数据
SetPerDrawOffset(obj1)               // 仅设置 offset
Draw(obj1)
SetPerDrawOffset(obj2)               // 仅设置 offset
Draw(obj2)

节省的开销主要在两处:(a) CPU→GPU 的数据传输带宽(Material 属性不再每帧重传);(b) Driver 层面的 CBuffer 绑定指令数量。在材质种类有限但实例数量大的场景(如城市建筑、植被)中,这两项开销的消除非常可观。

生效条件:

  • Shader 必须使用固定名称的 CBUFFER(UnityPerMaterial / UnityPerDraw)
  • 材质的 CBuffer Layout 必须相同(属性数量、类型、顺序一致)
  • 不同材质的属性值可以不同,只要 Layout 一致就可以合批
  • Shader 不能使用 MaterialPropertyBlock(MPB 会打断 SRP Batcher)

即把"上传材质数据"的开销从 per-draw 摊销为 per-material-change,在材质种类有限的场景下效果尤为突出。

7.2 Static Batching 与 SRP Batcher 的混合机制

混合配置在 Batch 和 Draw Call 两项指标上均优于单独使用任一方案。两者的优化层级不同,因此可以叠加:

  • Static Batching 把共享相同 Material 的静态物体的 Mesh 合并为一个大 Mesh,以单次 Draw Call 绘制,压低 Draw Call 总量。代价是增加内存占用(合并后的 Mesh 数据是各子 Mesh 的拷贝之和)和构建时的预处理时间。
  • SRP Batcher 降低的是 Draw Call 之间的状态切换开销:即使 Draw Call 数量不变,每个 Draw Call 的 CPU 开销也会下降。

两者混合时的内部行为:

  1. Unity 先执行 Static Batching 的 Mesh 合并,将可合批的物体组合为 Combined Mesh
  2. SRP Batcher 在 Combined Mesh 基础上进一步消除状态切换:如果多个 Combined Mesh 使用相同 CBuffer Layout 的 Material,它们之间的 Draw Call 切换开销依然会被优化掉

冲突点:

  • Static Batching 要求物体使用完全相同的 Material Instance(同一个引用),而 SRP Batcher 允许不同 Material Instance(只要 Layout 相同)
  • 如果物体已经被 Static Batching 合并,SRP Batcher 看到的是 Combined Mesh 的 Material,不再需要逐对象分析
  • GPU Instancing 与 SRP Batcher 互斥:启用 SRP Batcher 时,GPU Instancing 的 Keyword(INSTANCING_ON)不生效

此外在 Splatoon 风格的简单场景测试中,SRP Batcher 可以将全场景合并为单个 Batch,体现了其在材质属性一致场景下的极端效率。

纯 SRP Batcher:

无 SRP Batcher:

SRP Batcher + Static Batching 混合:

7.3 性能对比参考

实测环境为 Unity 2019.1,场景包含约 500 个 Opaque 物体,3 种 Material(CBuffer Layout 相同):

指标无优化仅 SRP Batcher仅 Static Batching混合
SetPass Calls~500~3~50~3
Draw Calls~500~500~50~50
CPU 渲染耗时基准约 -40%约 -60%约 -70%

SRP Batcher 作用于单个 Draw Call 的 CPU 代价(SetPass 次数锐减),Static Batching 则压缩 Draw Call 总量。两者叠加时,CPU 侧渲染开销的削减最为显著。GPU 侧的变化相对有限:Draw Call 合并减少了少量的 GPU Command Processor 开销,但 Pixel Shader 的工作量不变。