type
Post
status
Published
date
Jun 8, 2026
slug
srp02
summary
Catlike Coding Custom SRP 第二章 Draw Calls 学习笔记,按 Shader、Batching、Transparency 三部分整理 Unlit Shader、SRP Batcher、GPU Instancing、Dynamic/Static Batching、贴图、透明混合和 Alpha Clipping。
tags
Unity
SRP
category
学习笔记
icon
password
Draw Calls
这篇是 Catlike Coding Custom SRP 系列第二章
Draw Calls 的学习笔记。第一章我们先把一条最小的 SRP 跑了起来:创建管线资源、遍历相机、剔除、清屏、画天空盒、画不透明和透明物体。到了这一章,重点从“怎么调 Unity 的绘制接口”进一步推进到“这些对象到底是怎么被 Shader 画出来的,以及怎样减少 Draw Call 的成本”。这一章可以分成三块:
- 第一部分写一个真正属于 Custom RP 的 Unlit Shader。
- 第二部分支持 SRP Batcher、GPU Instancing、Dynamic Batching,并理解它们的区别。
- 第三部分让同一个 Unlit Shader 支持透明、贴图和 Alpha Clipping。
第二章开始,我们要自己决定 Shader 的结构、材质属性怎么进 GPU、哪些对象能合批、透明材质应该怎么混合。
1. Shaders:写一个自己的 Unlit Shader
一次 Draw Call 里,CPU 不只是告诉 GPU “画哪个 Mesh”,还要告诉 GPU “怎么画”。Mesh 提供顶点和三角形,Shader 决定顶点怎么变换、片元输出什么颜色,以及材质属性、矩阵、贴图这些数据怎么被使用。
Unity 的 URP / HDRP 可以用 Shader Graph 生成 Shader,但我们这个 Custom RP 暂时不支持 Shader Graph,所以这一章从手写 HLSL 开始。
Unlit Shader:最小 ShaderLab 结构
先创建一个
Custom RP/Unlit Shader。Shader 文件外层是 ShaderLab 语法,不是 HLSL。最小结构是:这里的层级可以这么理解:
Shader "Custom RP/Unlit"决定材质面板 Shader 下拉菜单里的路径。
Properties用来暴露材质属性,比如颜色、贴图、透明参数。
SubShader是一套具体实现。
Pass是一次具体绘制方式。
一开始 Pass 为空也能编译,默认会把物体画成白色。材质面板里能看到 Render Queue 默认是 2000,也就是 Opaque 队列。
HLSL Programs:顶点程序和片元程序
真正的 GPU 程序写在
Pass 里的 HLSLPROGRAM 和 ENDHLSL 中间。一次最基础的 Mesh 绘制至少要有两个程序:
- Vertex Shader:处理顶点,把对象空间顶点变换到裁剪空间。
- Fragment Shader:处理片元,决定最后输出什么颜色。
用
#pragma 指定这两个函数:为了让
.shader 文件只负责 ShaderLab 框架,把 HLSL 函数拆到单独的 UnlitPass.hlsl 文件里:这样后面代码变多之后比较清楚:
.shader 负责声明材质属性、Pass 状态和编译指令;.hlsl 负责顶点和片元逻辑。Include Guard:避免 HLSL 重复包含
HLSL 的
#include 不是 C# 的 using,它更像是把另一个文件的内容直接复制到当前位置。如果同一个文件被重复 include,就可能出现重复定义。所以
UnlitPass.hlsl 里需要 include guard:这里的意思是:如果
CUSTOM_UNLIT_PASS_INCLUDED 还没有定义,就定义它并编译中间的代码;如果已经定义过,就跳过。Shader Functions:先让 Shader 有合法输出
一开始可以先写两个空函数,但它们不能真正画出正确结果。片元函数需要返回颜色,顶点函数需要返回裁剪空间位置。
片元函数返回 Render Target 颜色,用
SV_TARGET 语义:float4 的四个分量是 RGBA。这里 return 0.0 会扩展成四维 0,也就是黑色。顶点函数返回位置,用
SV_POSITION 语义:语义不是注释,而是在告诉 GPU 这个值要接到渲染管线的哪个位置。
SV_POSITION 表示这是裁剪空间顶点位置SV_TARGET 表示这是要写入 Render Target 的颜色Space Transformation:对象空间到裁剪空间
Mesh 顶点原本在对象空间里,所以顶点函数需要接收对象空间坐标:
但这样还不够。
SV_POSITION 需要的是齐次裁剪空间位置,而不是对象空间位置。正确的变换链路是:最开始可以自己在
ShaderLibrary/UnityInput.hlsl 里声明 Unity 提供的矩阵:然后在自己的
Common.hlsl 里封装空间变换:顶点函数就变成:
这里和第一章的 C# 流程刚好接上。第一章里
context.SetupCameraProperties(camera) 会设置相机矩阵;这一章 Shader 端真正使用这些矩阵,把每个顶点送到正确的位置。Core RP Library:使用 Unity 的通用函数
空间变换这种函数太常用,Unity Core RP Library 已经提供了
SpaceTransforms.hlsl。后面更推荐 include 它,而不是一直手写:不过 Core Library 并不知道我们自定义管线里矩阵变量叫什么,所以需要先把自己的变量映射给它:
Unity 2022 里还需要补一些当前看起来没直接用到的变量,比如
unity_WorldToObject、unity_MatrixV、unity_MatrixInvV、glstate_matrix_projection、unity_prev_MatrixM、unity_prev_MatrixIM 和 unity_WorldTransformParams。这点一开始容易困惑:明明只想把对象空间转裁剪空间,为什么要补这么多东西?原因是 Core Library 是通用库,它内部的宏和函数会引用这些变量。我们用它,就要给它准备它期望的输入。
unity_WorldTransformParams 用到了 real4,所以自己的 Common.hlsl 还要先 include Unity Core 的 Common.hlsl:Color:把颜色做成材质属性
现在 Shader 已经能把物体画到正确位置了,但片元颜色还是硬编码的。比如:
这样所有材质都会是同一个颜色。更合理的做法是用材质属性。
ShaderLab 的
Properties 里添加:HLSL 里声明并使用:
这样就能用同一个 Shader 创建多个材质,每个材质有不同颜色。这里也能看出 Shader 和 Material 的关系:Shader 定义怎么画,Material 保存这次要用哪些参数画。
2. Batching:减少 Draw Call 成本
到这里,每个物体都能正常画出来了。但如果场景里放 76 个球,每个球都单独提交一次 Draw Call,就会看到大量 draw。即使每次发送的数据不多,这仍然是最直接、也最低效的方式。
Batching 的目的就是减少 CPU 和 GPU 之间的通信成本。这里要注意一点:不同 Batching 技术优化的问题不一样,有些真的减少 Draw Call 数量,有些只是让每个 Draw Call 更轻。
SRP Batcher:让 Draw Call 更轻
SRP Batcher 是 SRP 里的基础 CPU 优化。它不一定减少 Draw Call 数量,而是减少每次提交 Draw Call 时 CPU 需要设置的 Shader 数据。
普通情况下,每画一个物体,CPU 都要设置材质属性、对象矩阵、Shader 常量等状态。SRP Batcher 会把兼容 Shader 的材质数据缓存在 GPU 上,后续 Draw Call 只需要引用对应数据的位置,不用每次完整重传。
所以它可以理解成:
要让 Shader 兼容 SRP Batcher,所有材质属性必须放在
UnityPerMaterial constant buffer 里。原来的全局 _BaseColor 要改成:和每个 draw 相关的 Unity 输入要放到
UnityPerDraw:unity_LODFade 当前没有直接用,但 Unity 要求同一组 per-draw 数据保持完整布局,所以也要放进去。管线侧还要开启 SRP Batcher:
开启后,Frame Debugger 里可能会看到一个
SRP Batch。这不代表真的只有一个 Draw Call,而是一串经过 SRP Batcher 优化的 Draw Call。Many Colors:MaterialPropertyBlock 的便利和代价
如果只是四种颜色,可以做四个材质。SRP Batcher 仍然可以工作,因为这些材质使用的是同一个 Shader Variant,材质数据布局一致。
但如果想让每个球都有不同颜色,给每个球都创建一个材质就不太方便。教程里创建了
PerObjectMaterialProperties 组件,用 MaterialPropertyBlock 给 Renderer 设置 per-object 的 _BaseColor。核心代码是:
OnValidate 会在编辑器里加载或修改组件时调用,所以颜色能立刻更新。Awake 是为了 build 里也能生效,因为 OnValidate 只属于编辑器行为。但这里有一个重要结论:
MaterialPropertyBlock 会让这些对象退出 SRP Batcher。因为 SRP Batcher 依赖稳定的材质数据缓存,而 per-object material property 会让每个对象都带一份不同属性。所以这一步得到的经验是:
如果大量相同物体需要不同颜色,更适合用 GPU Instancing。
GPU Instancing:大量相同物体一次画
GPU Instancing 是另一种减少 Draw Call 的方式。它适合大量相同 Mesh、相同 Material 的对象。CPU 把每个实例的 transform 和实例属性整理成数组发给 GPU,GPU 根据 instance id 画出多个对象。
它和 SRP Batcher 的区别很关键:
先在 Shader Pass 里打开 instancing variant:
这会让 Unity 编译两个版本:一个支持 instancing,一个不支持。材质面板也会出现 Enable GPU Instancing 开关。
然后在自己的
Common.hlsl 里 include:UnityInstancing.hlsl 会让相关矩阵和属性在 instancing variant 下从数组里读取。但它需要知道当前正在画第几个实例,所以顶点输入要改成结构体,并加入 instance id。顶点函数里取出 instance id:
如果片元函数也要访问实例属性,比如每个实例不同颜色,就要把 instance id 从顶点阶段传到片元阶段。所以顶点输出也改成结构体:
顶点函数大致变成:
材质属性也要改成 instancing buffer。这里不是和
CBUFFER_START(UnityPerMaterial) 同时写,而是替换成:访问时用:
这里我之前最容易搞混的一点是:
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial) 替换掉原本的 CBUFFER_START(UnityPerMaterial) 后,非 instancing variant 仍然可以保持 SRP Batcher 需要的布局;instancing variant 才会走实例数组。不能在同一个 HLSL 里把 _BaseColor 同时写进普通 CBUFFER 和 instancing buffer,否则会重定义。管线侧还要允许 instancing:
GPU Instancing 只会合并相同 Mesh 和相同 Material 的对象。如果 24 个球用了四个材质,就可能变成四个 instanced draw;如果它们都用同一个材质,通过 instance property 改颜色,就能合得更好。
Drawing Many Instanced Meshes:手动绘制大量实例
当对象数量上百上千时,GPU Instancing 的优势更明显。教程里创建了
MeshBall,不真的生成 1023 个 GameObject,而是准备 1023 个矩阵和颜色数组,然后直接调用 Graphics.DrawMeshInstanced。字段大概是:
Awake 里随机生成位置和颜色:Update 里创建 property block,把颜色数组传给 GPU,然后画实例:这里有几个注意点:
Graphics.DrawMeshInstanced不需要场景里真的有 1023 个 Renderer。
- 每批最多实例数量受平台和数据量限制,教程里使用 1023。
- 这一批整体可以被视锥剔除,但内部每个实例不会单独排序和单独剔除。
- 实例绘制顺序就是数组顺序。
Dynamic Batching:运行时合并小 Mesh
Dynamic Batching 是更老的合批技术。它会在运行时把多个使用相同材质的小 Mesh 合并成一个更大的 Mesh,再提交给 GPU。
它确实能减少 Draw Call,但代价是 CPU 每帧需要处理顶点数据。所以它只适合非常小的 Mesh。球体通常顶点太多,不适合;cube 这种简单网格更容易看到效果。
在
DrawingSettings 里可以控制:如果想观察 Dynamic Batching,还要注意 SRP Batcher 会优先生效,所以可以临时关掉:
Dynamic Batching 的限制比较多:
- 只适合小 Mesh。
- 通常要求相同 Material。
- 不适合 per-object material properties。
- 不同缩放可能让合并后的法线不再保证单位长度。
- 多个对象变成一个大 Mesh 后,绘制顺序也会发生变化。
所以实际项目里,我会把它看成补充手段,而不是主要优化手段。
Static Batching:提前合并静态物体
Static Batching 和 Dynamic Batching 思路类似,但它不是每帧临时合并,而是针对标记为 batching static 的静态物体提前处理。
它适合建筑、地面、墙面、固定道具这种不会移动的对象。代价是需要更多内存和存储,因为 Unity 可能会复制和重组 Mesh 数据。
这一点对 Custom RP 来说不需要额外写代码。教程里也提到,RP 本身不用关心 Static Batching,它是 Unity 在更外层处理好的。
Configuring Batching:把开关暴露到管线资源
不同项目、不同平台、不同场景下,哪种合批方式更适合并不固定。所以教程最后把这些选项做成配置,而不是写死。
CameraRenderer.DrawVisibleGeometry 接收两个参数:CustomRenderPipeline 保存配置,并控制 SRP Batcher:CustomRenderPipelineAsset 里暴露 Inspector 开关:到这里,Batching 部分的理解可以总结成:
3. Transparency:透明、贴图和裁剪
前面写出来的 Unlit Shader 只能稳定处理不透明材质。即使把
_BaseColor 的 alpha 改成 0.5,物体也不会自动变透明;即使把 Render Queue 改成 Transparent,也只是改变绘制顺序,不会改变混合方式。透明材质至少涉及三件事:
- Blend:当前片元如何和已经画好的颜色混合。
- ZWrite:透明物体要不要写入深度。
- Render Queue / Sorting:透明物体什么时候画、按什么顺序画。
Blend Modes:控制颜色混合
不透明渲染的默认行为是:当前片元直接覆盖之前的颜色。透明渲染则需要把当前颜色和目标颜色混合。
先在 Shader Properties 里加入源和目标 Blend 模式:
默认值
One 和 Zero 表示普通不透明:标准半透明通常用:
也就是:
在 Pass 里用 ShaderLab 的旧语法引用属性:
这里能看出来,透明不是单纯改 alpha。alpha 只是参与混合的参数,真正让它生效的是 Pass 的
Blend 状态。Not Writing Depth:透明通常不写深度
透明物体通常不写深度,因为它后面的东西仍然需要被看到。如果一个半透明物体写了深度,后面再画的透明物体或背景可能会被深度测试挡掉,结果就不对。
所以再加入
_ZWrite 属性:Pass 里使用:
Opaque 材质通常是:
Transparent 材质通常是:
这也和第一章里分开绘制 opaque 和 transparent 接上了。透明物体要放在不透明和天空盒之后画,并且通常按从后往前排序。
Texturing:加入 Base Map
只有纯色还不够。为了支持带 alpha 的贴图,加入
_BaseMap:纹理属性后面的
{} 看起来多余,但 Unity ShaderLab 里最好保留,避免一些旧格式相关的问题。HLSL 里纹理和采样器要声明在全局作用域,因为纹理资源不能做成 per-instance property:
Unity 会自动提供
_BaseMap_ST,里面存的是贴图 tiling 和 offset。它属于材质属性,所以放进 instancing buffer:顶点输入需要 UV:
顶点输出也要把 UV 传给片元函数:
在顶点阶段应用 tiling 和 offset:
片元阶段采样贴图,并和颜色相乘:
这样
_BaseColor 就像 tint 一样影响贴图颜色。如果贴图的 alpha 不是均匀的,透明也会变成非均匀透明。Alpha Clipping:用 clip 丢弃片元
透明混合是一种“半透明”。但很多材质并不需要半透明,而是需要镂空,比如树叶、草、铁丝网、栅栏。这种更适合 Alpha Clipping。
Alpha Clipping 的逻辑是:
先加
_Cutoff 属性:HLSL 里加入 instanced property:
片元函数里先算最终颜色,再裁剪:
clip(x) 的规则是:如果 x <= 0,当前片元被丢弃。所以 base.a - cutoff 正好表达 alpha 是否达到阈值。Alpha Clipping 和透明混合通常不是一起用。Alpha Clip 材质一般仍然是“不透明”逻辑:保留下来的片元写深度,被丢弃的片元完全不存在。它通常使用
AlphaTest Render Queue,在完全不透明物体之后绘制。Shader Features:只在需要时启用 clip
clip 会影响一些 GPU 优化,所以不应该让所有材质都执行裁剪逻辑。教程里用 shader keyword 做开关。Properties 里添加:
Pass 里告诉 Unity 编译 keyword variant:
片元函数里条件编译:
这样关闭 Alpha Clipping 的材质不会执行
clip。打开材质面板里的 Alpha Clipping toggle 后,材质会启用 _CLIPPING keyword,Unity 使用对应的 shader variant。Cutoff Per Object:每个对象不同裁剪阈值
因为
_Cutoff 也在 UnityPerMaterial instancing buffer 里,所以它也可以被 MaterialPropertyBlock 或 instancing 数据覆盖。PerObjectMaterialProperties 里可以加:设置时多写一个 float:
这样不同对象可以有不同颜色和不同裁剪阈值。
Ball of Alpha-Clipped Spheres:让实例球更随机
最后教程把
MeshBall 的随机数据再丰富一点。位置仍然在球形范围内,但旋转和缩放也随机,同时让颜色 alpha 在 0.5 到 1 之间变化:这里没有给每个实例单独设置
_Cutoff,而是改变每个实例颜色的 alpha,让 base.a - cutoff 的结果产生差异。材质上的 _Cutoff 仍然能统一控制洞的大小。总结
这一章的主线是:从“能画 Renderer”推进到“能控制 Shader、材质属性、合批和透明材质”。
Shader 部分先搭出
Custom RP/Unlit,理解 ShaderLab 的 Properties -> SubShader -> Pass,再用 HLSL 写顶点和片元函数。顶点阶段负责从 Object Space 转到 Clip Space,片元阶段先输出纯色,再变成材质颜色,后面又加入贴图、UV、透明和裁剪。Batching 部分主要解决 Draw Call 成本。SRP Batcher 是基础优化,让使用兼容 Shader 的普通 Draw Call 更轻;GPU Instancing 适合大量相同 Mesh 和 Material 的重复物体;Dynamic Batching 是运行时把小 Mesh 临时合并;Static Batching 是提前处理不会动的静态物体。
Transparency 部分说明透明不是只改 alpha。半透明需要
Blend SrcAlpha OneMinusSrcAlpha 这类混合状态,通常还要 ZWrite Off 并放到 Transparent Queue;Alpha Clipping 则是用 clip(alpha - cutoff) 丢弃片元,更适合树叶、草、栅栏这类镂空材质,并通过 _CLIPPING shader feature 控制是否启用。学完这一章之后,Frame Debugger 里看的东西也会更具体:不只是“画了几个物体”,而是要看这些对象是普通 Draw、SRP Batch、Instanced Draw,还是因为透明、裁剪、材质属性而被拆成了不同的绘制路径。

