前言
“卡顿?那肯定是 DrawCall 太多,合批一下就好了。”——这句话我在无数教程评论区和面试复盘里见过,但它坑了不少人。顺着这句话往下,还衍生出两个常见的”常识”:一是”用了对象池,DrawCall 自然就降了”;二是”我 SRP Batcher 明明开了,没半点效果,Unity 这破引擎垃圾”。
这三句话,每句都只说对了一半,而且错的那一半正好挡在真正动手之前。这篇文章不罗列概念,我想把这三句话逐个拆穿,重点放在工程上真正卡人的地方:状态切换为什么贵、SRP Batcher 凭什么能跳过它、以及那个”开了等于没开”的现场长什么样。
下面的内容来自我自己整理的原理笔记,加上一份公开场景的实测数据做量化参考,偏原理和取舍,不绑定你某个项目的落地数字——你项目的具体瓶颈,得自己用 Profiler 量。
动手前,先对齐两个概念
如果你刚接触 Unity 或渲染,下面两个词后面会反复出现,先花一分钟建立直觉:
- DrawCall:CPU 向 GPU 提交的一个”绘制命令”——“把这批几何体画上去”。场景里每多一个需要独立绘制的物体,CPU 就多发一次这样的命令。
- SetPass Call(常简称 SetPass):比 DrawCall 更贵的东西,指”切换渲染状态”——换 Shader、换材质参数、换绑定的纹理。每次切换,GPU 和驱动都要重新准备一整套状态。
打个比方:DrawCall 像”让工人去搬一批货”,SetPass 像”每搬一批就换一套完全不同的工具和流程”。搬货本身不慢,慢的是反复换工具、重新热身——当然这比喻不严谨,搬货不会”重新热身”得那么贵,但渲染里”换状态”的代价,量级上确实接近这个感觉。
所以渲染优化的核心口诀是:减少状态切换(SetPass),能合批则合批。 但”合批”有很多种方式,搞混了就会踩进前面说的那些坑。
为什么 DrawCall 多就慢——状态切换才是真凶
每次 DrawCall 都有固定开销:CPU 要准备好命令、绑定 Shader、绑定常量缓冲、绑定纹理,再提交给 GPU。物体一多,这些”准备动作”本身就把 CPU 渲染线程吃满了,GPU 反而可能在摸鱼。
关键在于:这个”准备”主要花在状态切换上,而不是”画”上。于是 Unity 里有了四种合批技术,它们优化的其实是不同的东西:
| 技术 | 适合什么 | 代价 / 限制 |
|---|---|---|
| 静态合批 | 建筑、地形等不动的物体 | 构建时合并成大网格,占更多内存,物体不能再动 |
| 动态合批 | 很小的动态物体(≤300 顶点、同材质) | 条件苛刻,CPU 每帧合并顶点有成本,现代项目基本不用 |
| GPU Instancing | 大量相同的草、树、子弹 | 必须同 Mesh + 同材质,变换走 Instance Buffer |
| SRP Batcher | URP/HDRP 下 Shader 相同、材质参数不同的物体 | 不减少 DrawCall 数,但大幅降 SetPass;需 Shader 用 CBUFFER 包裹属性 |
注意最后一行那个”不减少 DrawCall 数”——这是最多人误解的地方,也是下一节要拆的。回到前言第一句话:”合批一下就好了”。合批确实能救一部分场景,但它不是只有一种,而且 DrawCall 数本身经常只是表象;盲目合批之前,得先知道瓶颈到底在 SetPass 还是 GPU 填充率,否则就是对着错误的敌人开枪。
SRP Batcher:它解决的不是你以为的问题
先分清两个容易混的词。SRP(Scriptable Render Pipeline,可编程渲染管线) 是 Unity 2018 引入的渲染架构,简单说就是把”这一帧怎么画”从引擎写死的逻辑,变成你能用 C# 自定义的一套渲染流程;URP(通用渲染管线)是手游和独立游戏最常用的那套现成流程。而 SRP Batcher 是跑在 SRP/URP 之上的一项优化——它不改变管线本身,只优化”同 Shader、异参数”物体之间的绘制开销。下文的 SRP Batcher,指的就是这项优化。
很多人以为 SRP Batcher 是”把多个 DrawCall 合并成一个”。不对。 它不减少 DrawCall 的数量,它减少的是 DrawCall 之间的状态切换成本。
为什么能减?得从 GPU 怎么处理一次绘制说起。正常情况下,连续画两个”用同一个 Shader、但颜色不同”的物体,GPU 要在两次 DrawCall 之间重新绑定整套材质参数(常量缓冲),哪怕它们只差一个颜色值——因为常量是按”这次绘制用哪份状态”绑定的。
SRP Batcher 的巧思是:把每个物体的材质参数常驻在 GPU 的一块持久常量缓冲里,按物体编号排好。这样连续画多个同 Shader、异参数的物体时,DrawCall 之间只需要改一个”指向哪份参数”的偏移指针,不用重新绑定整套状态——SetPass 那套重准备被整体跳过了。(这块持久缓冲业内正式叫 Constant Buffer,缩写 CBUFFER;前面说的”常量缓冲”和这里的 CBUFFER 是同一个东西,下文统一用 CBUFFER。)
一个真实量化例子(室内 500+ 同 Shader、异参数材质的场景实测):
- 关闭 SRP Batcher:CPU 渲染线程约 8.2 ms,SetPassCall = 497,平均 Batch 大小 ≈ 1(基本没合上)。
- 开启且全链路合规:渲染线程降到 3.1 ms,SetPassCall = 12,平均 Batch 大小 ≈ 41.4。
- 仅开启但 Shader 违规(没用 CBUFFER 包裹属性):≈ 7.9 ms,毫无收益(Frame Debugger 里 Batch Size 还是 1)。
注意上面”平均 Batch 大小 ≈ 41.4”不是“41 个 DrawCall 被合并成一个”。Batch Size 指的是”一次状态切换之后,引擎连续提交了多少个 DrawCall”——DrawCall 的总数并没有减少(还是那 500 多个),减少的是它们之间的 SetPass:因为参数常驻缓冲,这 41 个 DrawCall 彼此之间不再重新绑定状态,于是 SetPass 从 497 掉到 12。
这几个数字出自一份公开项目的实测,我手上没专门跑过基准。你项目里到底合没合上,别拿这组数去对照,开 Frame Debugger 看 Batch Size 最直观。
你开了 Batcher 却没效果?先看这里
回到前言第三句话:”我 SRP Batcher 明明开了,没半点效果,Unity 垃圾。”
那个”毫无收益”的第三行,就是现场。常见剧本是这样的:你按教程把 SRPBatcher 勾上,满心欢喜重新跑,渲染线程还是 8ms 没动,你以为引擎骗你。打开 Frame Debugger,看任意一帧的 Batch Size——它写着 1。
Batch Size = 1 意味着:引擎一个批次只塞进了一个物体,等于 SRP Batcher 完全没接管。原因几乎总是同一个——你的 Shader 没把材质属性交进那块持久缓冲,引擎没法跳过状态重绑定,只能退回”逐物体老老实实绑一遍”的原始流程。这跟 Unity 没关系,跟你的 Shader 写法有关系。
所以”开了没效果”第一步不是骂引擎,是看 Batch Size。
一个经典陷阱:Shader 得把属性交进 CBUFFER
上面那个现场的根因在这。SRP Batcher 要生效,Shader 必须用 CBUFFER_START(UnityPerMaterial) 把材质属性包起来。否则引擎没法把你的常量放进那块持久 GPU 缓冲,它开了等于没开,Frame Debugger 会诚实告诉你 Batch Size = 1。
// ✅ 合规写法:属性进 UnityPerMaterial 这个 CBUFFER
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float _Metallic;
CBUFFER_END
// ❌ 违规写法:裸 uniform,引擎无法把它放进持久缓冲
// float4 _BaseColor;
这跟”你代码逻辑写没写对”无关,是 Shader 侧的约定。很多人项目里明明开了 SRP Batcher,性能却没变化,八成是卡在这——材质属性没包进 CBUFFER,引擎只能退回逐物体绑定。
好消息是:URP 内置的 Lit、Unlit 等 Shader 已经按这个约定写好了,你一般不用改;只有自己手写自定义 HLSL Shader 时,才需要手动把材质属性包进 UnityPerMaterial 这个 CBUFFER。
破一个迷思:对象池不降 DrawCall
前言第二句话:”用了对象池,DrawCall 自然就降了。” 这是两码事,得掰开:
- 对象池(Object Pool) 解决的是”频繁 new / 销毁对象带来的 CPU 开销和 GC(垃圾回收)”——它管的是”对象的生死”。
- 合批技术(包括 SRP Batcher) 解决的是”提交绘制时的状态切换开销”——它管的是”怎么画”。
一个用对象池管理的 500 发子弹,如果每发都是独立材质、Shader 又没走 CBUFFER,DrawCall 和 SetPass 该多少还是多少。池化不会自动帮你合批。把这两件事当成一回事,是新手最常栽的跟头——对象池该用还得用(它救的是 GC,不是 DrawCall)。
三句话的错那一半,其实是同一件事的三面:合批不是万能药、对象池不降 DrawCall、开了 SRP 没效果是 Shader 没接 CBUFFER 而不是引擎烂。大家都把”画得多”当成了瓶颈,而真正的瓶颈在”切状态切得有多频繁”。
还有一头:剔除,和合批同等重要的另一根支柱
合批是”画的时候省状态”,还有一类优化是”根本不画那么多”,它和合批是并列的两大支柱,不该被当成补充:
- 视锥剔除(Frustum Culling):摄像机看不到的物体直接跳过。Unity 默认就做了,所以单纯
renderer.enabled = false往往没明显收益——真正浪费的是物体虽然看不见、但它的 Update / 动画 / AI 还在跑。 - 遮挡剔除(Occlusion Culling):被墙挡住的也不画,室内场景效果明显。
- LOD(细节层次):远处用低精度模型,最远直接不画。
- GPU Instancing / Indirect:几万棵草、树,CPU 逐个提交会崩,让 GPU 一次画几千个相同实例;更进一步用 Compute Shader 在 GPU 上做剔除后 Indirect 提交。角色类(带 Animator、复杂材质)不适合这套,优先用 CullingGroup + 距离逻辑降级。
手机 GPU 多是 TBDR(分块延迟渲染) 架构:它把一帧切成小块,在芯片上那块很小的内存里算(这块叫 Tile 缓存),**最怕”切渲染状态把这块缓存冲刷掉”**。而 SRP Batcher 恰好把状态切换降到最低——两者天生互补。这也是为什么手游默认 URP 前向 + SRP Batcher + 限制额外光源数(比如 ≤4)是性价比最优解,移动端 DrawCall 预算通常压在几百以内。
工程上的几个注意点
- 怀疑合批问题,第一反应是开 Frame Debugger 看
Batch Size,不是猜。 - 老 Built-in 管线没有 SRP Batcher 这套机制,得走 CommandBuffer / OnRenderImage,这也是为什么新项目基本都迁 URP——不是追新,是这层优化白送。
- 远程 / 动态内容走 GPU Instancing 要注意:角色类(Animator、复杂材质)不适合,优先 CullingGroup + 距离逻辑降级,别硬套。
写在最后
说到底,DrawCall 这东西被”越少越好”四个字耽误了。它真正的杠杆在 SetPass——状态切得多勤,比画了多少次更决定卡不卡。SRP Batcher 是解法,但得 Shader 把属性交进 CBUFFER 才接得住,光勾开关没用。这些都不难,难的是每次动手前真的去量,而不是背一句”合批万能”就上。
如果手头项目正卡在渲染,下一步很具体:打开 Frame Debugger 切到 Rendering 面板,盯两个数——Batch Size 和 SetPassCall。Batch Size 长期停在 1,回头查 Shader 有没有走 CBUFFER;SetPassCall 高但 Batch Size 不算低,说明 SRP Batcher 已经接住了状态切换,瓶颈在”合批本身”——比如你材质 / Shader 种类太多,引擎还是不得不频繁切换,合批也救不了,得从美术规范或资源管理侧收口。先量这两个数,再决定动哪。
下次谁再跟你说”DrawCall 太多,合批一下就好了”,你现在应该知道这句话漏了什么。