干过游戏性能优化的人应该都有过这种经历:拿Unity Profile一看,当前帧DrawCall飙到了900,心里咯噔一下——完了,这帧不得卡死?结果切到Game视图一看,帧率纹丝不动,帧时间也就七八毫秒。我当时第一次遇到这情况也挺懵的,翻了一晚上资料才把背后的逻辑理顺。今天就把这件事彻底讲明白,顺便聊聊什么情况下900 DrawCall才真正需要紧张,以及一套我自己常用的定位手段。
咱们先把结论放在前面:DrawCall数量和渲染耗时之间不是简单的线性关系。它是一个“现象”,不是根本原因。真正决定耗时的,是这批DrawCall具体怎么被提交、被谁处理、卡在了哪一级管线上。你只盯着数字看,反而容易被误导。
1. 先搞清楚一件事:900个DrawCall是怎么数出来的
在聊“为什么不卡”之前,要先把测量口径统一。很多人在这一步就已经产生误会了。
1.1 统计窗口里的Batches、SetPass与真DrawCall
Unity编辑器里最常见的统计入口是Game视图右上角的Stats窗口。这里显示的数字叫Batches,而不是纯粹的DrawCall。它统计的是每一帧提交上屏的“渲染批次”总数,里面包含了被合批后的批次数量。也就是说,如果100个物体被静态合批合并成了5个批次,Stats里只会显示这5个批次,而不是100个DrawCall。
Frame Debugger里看到的DrawCall更接近真实情况,但它也区分了“实际执行绘制”和“被合批吸收的绘制”。至于Profiler里的SetPass Calls,这个数字代表的是“切换渲染状态并执行一次绘制”的次数,它才是跟CPU耗时关系最大的那个指标。
所以当你看到“DrawCall 900”的时候,先得确认这个900到底来自哪里:
- Stats里的Batches = 900,说明合批之后还有900个批次;
- Frame Debugger的DrawCall = 900,说明逻辑上有900次绘制请求;
- Profiler的SetPass Calls = 900,说明每帧切换了900次渲染管线状态。
测量的工具不同,结论会完全不一样。我自己一般以Profiler的SetPass Calls和Frame Debugger互相印证为准,Stats的数字只做参考,因为它会把合批的成果隐藏掉,容易让你误判风险。
1.2 900这个量级在什么平台算高、什么平台算低
很多人习惯把“DrawCall不能超过XXX”当成铁律,其实这个阈值分平台差异巨大。
- PC(DX11):驱动模型老,CPU提交开销偏高,900个DrawCall就已经能让主线程明显吃紧,但也分场景,如果都是同Shader同Pass的简单批次,其实还能撑住;
- PC(DX12 / Vulkan):API层开销大幅下降,900个DrawCall在多数显卡上根本不是事,真正需要关注的是GPU侧压力;
- 移动端(Vulkan / Metal):这两代API比GLES好很多,但GPU频率和带宽有限。900个DrawCall在高端机上或许没问题,在中端机上往往伴随其他隐患,比如Overdraw和CPU合批开销;
- 主机平台:CPU相对固定,驱动逻辑可控,900个DrawCall通常不是过不去,但要小心跨平台兼容性。
也就是说,900这个数字在今天的技术栈里属于“需要检查但没必要恐慌”的位置。真正的问题往往不在数量本身,而在于这批调用是不是造成了管线上某一环的拥堵。
2. 渲染耗时不高的真正原因:瓶颈根本不在DrawCall这件事上
渲染一帧画面,本质是一条流水线:CPU收集数据、提交命令,GPU执行这些命令,最后输出画面。DrawCall只是流水线里的一道“指令”,它影响的是CPU侧的开销,但整条流水线的吞吐量取决于最慢的那个环节。
2.1 帧时间的组成:CPU提交、GPU执行、同步等待
一帧的耗时可以粗分成三段:
- CPU提交时间:从脚本Update开始,碰撞、动画、粒子、UI布局,一直到最后把渲染命令塞进图形API的CommandBuffer,这段是CPU的工作量;
- GPU执行时间:顶点处理、裁剪、光栅化、片元着色、混合,这些计算发生在GPU内部;
- CPU等待时间:常见于VSync、GPU追赶不上CPU导致的隐式同步。
你可以想象一个外卖柜台。CPU是点菜员,负责把菜名写在单子上;GPU是后厨,照着单子做菜。DrawCall就是单子上的行数。行数变多,点菜员写字的时间变长;但如果后厨出菜很慢,那即使只有一行菜,整体耗时依然被后厨卡住。反过来,如果后厨出菜飞快,点菜员写900行也就多花了几毫秒,整体耗时自然不明显。
很多人只盯DrawCall,忽略了后厨这个变量。当你的画面复杂度很低、材质简单、填充率要求不高时,GPU可能大半时间都在干等,CPU多写几百行单子根本造成不了瓶颈。
2.2 CPU瓶颈和GPU瓶颈的判断方法
拿到一个帧耗时数据,先别管DrawCall,直接判断是CPU站出来还是GPU站出来。
拿Unity Profiler为例,最简单的做法是看Profiler里的两个模块:
- CPU Usage区域里,如果主线程的Render部分占用时间极长,比如单帧4ms里Render占了2ms,那CPU提交侧就是大头;
- GPU Usage(需要真机Profile或者接入RenderDoc这类工具),如果显示GPU执行时间接近或超过CPU帧时间,说明GPU已经满负荷了,此时降低DrawCall几乎无效。
实际操作中,我最常干的一件事是:在场景里临时禁用某个批次和DrawCall无关的Shader功能(比如关掉阴影、关掉后处理),看帧时间变化比例有多大。如果关了阴影帧率立刻暴涨,说明瓶颈在阴影渲染的GPU侧,而不是DrawCall数量。
这个判断逻辑很重要,因为它决定了后面的优化方向。你要是把900个DrawCall里头全都合批了,结果发现瓶颈在GPU的填充率或后处理,那是白忙活一场。
2.3 为什么很多场景里900 DrawCall搭上显卡很强,就不卡了
当你用的是比较新的独立显卡,或手机SoC里的GPU算力强、带宽充足,那么900个DrawCall确实可以做到不痛不痒。
原因在于,现代GPU执行绘制指令的单位“批次”成本很低。真正昂贵的不是“画一笔”,而是“换画笔”——也就是切换Shader、切换纹理、切换混合状态等。如果你的900个DrawCall全部使用同一份材质和Shader,驱动可以预先缓存对应的管线状态,GPU拿到这批命令只需要不断重复执行同一个流程,这时候DrawCall就是“便宜货”。
直观类比:让复印机连续复印900张同一份文件,和每复印一张就换一种纸、换一种墨粉,这两者的效率差别是数量级的。你的900个DrawCall如果属于前一种,那渲染耗时不高太正常了。
3. 什么样的900 DrawCall是安全的:不伤性能的批次特征
既然900个DrawCall可以很安全,那我们需要定义清楚“安全”的标准。否则下次你同事跑过来说“我的场景900 DrawCall都不卡”,你以为套用同样方案就行,结果在自己项目上一测就卡成PPT,那种幻觉最坑人。
3.1 同材质同Shader同Pass:低成本DrawCall的核心特征
一个友好的DrawCall,最好具备以下特征:
- 使用同一个Shader变体,尽量不触发Shader.GetPass切换;
- 纹理绑定基本稳定,避免每帧频繁换贴图;
- 网格数据量小且集中,DrawCall本身的顶点数据搬运不是压力源;
- 关闭了不必要的ShadowCaster Pass,或者开启了合适的合批策略。
我可以举个例子。一个包含数百个箱子的场景,所有箱子共享同一份材质、同一张纹理、同一个不透明Shader。那这900个DrawCall即便不合并,CPU提交开销也会很小。因为从驱动层来看,这些DrawCall的管线状态相似、状态切换少,CommandBuffer里大部分是重复数据,显卡几乎可以无障碍地批量执行。
如果换成900个互不相同的材质球、五花八门的Shader变体,或者夹杂大量透明物体,那即便是300个DrawCall也足以让CPU苦不堪言。所以,数字是900还是300并不重要,重要的是你这些DrawCall里“换画笔”的次数有多少。
3.2 隐藏合批机制:静态合批、动态合批、SRP Batcher还在帮你兜底
很多情况下你以为的“900个DrawCall”,其实已经被引擎偷偷合掉了一部分。
- SRP Batcher(可编程渲染管线批量器):只要物体使用SRP Batcher支持的材质(内置管线以外的URP/HDRP尤其受益),引擎可以把不同物体但同Shader的绘制请求批量提交,大幅降低SetPass次数。900个逻辑DrawCall在URP下实际提交给GPU的批次可能不到100;
- 静态合批(Static Batching):把静态物体合并成一个大网格提交,运行时DrawCall数量被显著削减,代价是内存占用变大;
- 动态合批(Dynamic Batching):针对小网格物体,引擎动态合并顶点数据,虽然合批条件苛刻(顶点数、缩放、材质都有限制),但它确实在替你降低批次。
也就是说,你看到的“900”很可能是引擎经过合批计算之后剩余的批次。这个前提下渲染耗时不高,更合理了。
我经常建议团队:收到统计数字时,先用Frame Debugger逐帧看一遍,区分真正执行了多少次GPU绘制,而不是直接拿Batches当DrawCall开始优化。
3.3 低开销驱动的红利:DX12/Vulkan/Metal让900不再吓人
现代图形API的设计目标之一,就是降低CPU侧的DrawCall开销。在DX11里,一次DrawCall要触发的驱动逻辑校验非常多,API的线程安全模型也限制了并行度。而DX12和Vulkan把很多校验交给了开发者,通过CommandList并行录制和间接绘制,CPU侧的花费被大幅压缩。
这意味着同一批900个DrawCall,在DX11和Vulkan下,CPU耗时差距可以做到两倍以上。这也是为什么很多PC游戏在DX12模式里DrawCall破千依然流畅,而切回DX11模式就变得卡顿。
移动端Metal和Vulkan情况类似。前几年的中低端Android手机上,GLES驱动对DrawCall特别敏感,超过300就会明显掉帧;换成支持Vulkan并做过优化的设备,800~1000的DrawCall甚至能跑到60帧。这不是玄学,是API和驱动实现决定的。
如果你在写Unity项目,确认一下当前的Graphics API设置。Build Settings里如果选了Auto,移动端很多情况会选Vulkan,PC上则要看编辑器偏好设置。这个因素直接影响“900是不是问题”的判断基准。
4. 什么时候900 DrawCall会拖垮帧率:常见翻车现场
反过来说,900个DrawCall在某些情况下绝对是灾难。下面列几个我真实遇到过并排过查的翻车场景,都是可以复现的。
4.1 每个物体一个材质球:状态切换带来的隐性开销爆炸
团队里最常见的问题:美术为了方便调色,给场景里每个物体单独建了材质实例。引擎确实支持这么做,但在渲染侧,这意味着每个物体都要单独切换渲染状态。
这时候你看到的DrawCall可能也是900,但Profiler里SetPass Calls却异常高,帧时间主要耗在RenderState设置上。解决办法不是忙着做合批,而是先做材质资源合并,把同Shader的材质球归拢成几个核心材质,用Texture数组或MaterialPropertyBlock做差异化。
这里有我自己的一个经验:MaterialPropertyBlock(MPB)是处理同材质但颜色/缩放差异的好工具。它允许你在不改材质球的情况下为每个渲染器提供专属属性,且不影响合批。比起创建一堆材质实例,MPB的开销小得多。
4.2 大量透明物体和Overdraw:DrawCall不高但GPU被片元榨干
透明物体的渲染需要排序、需要开启混合。这意味着GPU的Overdraw会增加——同一个像素会被绘制多次。如果你的900个DrawCall里混着大量半透明面片、粒子特效、地面透明草,那么整体渲染耗时会被片元着色和带宽消耗拉高。
这个场景下,你就算把DrawCall从900降到100,帧率可能依然没有改善。原因很简单:瓶颈已经不在CPU提交侧,而在GPU的像素处理侧。降DrawCall只是治标,真正要做的是减少透明物体面积、降低粒子发射量、或合并同层级的透明对象减少混合次数。
4.3 阴影Pass额外提交:看似900其实是1800次有效调用
Unity的实时阴影对于每个阴影投射物,会额外生成一张ShadowMap绘制。这个绘制过程也算DrawCall。也就是说,假设物体数量不变,每个物体都要先往ShadowMap画一遍,再往主相机画一遍,那表面上的900个DrawCall实际上接近1800个有效调用。
阴影Pass往往以另一种Shader变体执行,切换状态的代价也不低。所以排查时千万别漏掉这一层。最直接的验证方法是临时把主光源阴影关掉,看帧时间掉多少。如果掉得明显,说明你的阴影提交是隐藏的大头。
4.4 每帧动态创建/销毁批次:CPU的合批计算反而拖累帧率
还有一个很隐蔽的问题:某些写得很随意的逻辑,比如每帧修改大量物体的缩放、旋转、材质颜色,会迫使Unity每帧重新计算动态合批,甚至导致批次被打断。这时候你看到DrawCall不高,但CPU一直忙于重新生成批次数据,帧时间照样爆表。
我排查过一个场景,物体数量不到200个,DrawCall只有几十,但帧时间高达20ms。最终定位到罪魁祸首是一个脚本每帧遍历场景里所有贴花物体,调用了SetPropertyBlock,破坏了动态合批,并在每次循环时重新上传GPU缓冲。改成只在属性变化时更新后,帧时间立刻降到6ms。
这种情况说明一个规律:优化的核心永远是找到真正消耗帧时间的根因,而不是对着某个数字死磕。
5. 用Profiler和Frame Debugger定位真实瓶颈的实操流程
说了这么多判断逻辑,该给一套能直接上手的定位方法了。以下是我自己项目里经常用的一套排查流程,从拿到“900 DrawCall”这个信息到定位根因,大约只需要一刻钟。
5.1 第一步:Quick Profiler扫一遍主线程时间线,判断CPU侧压力
先在Unity里开Development Build,或者用真机连接Profiler,盯几个关键节点:
- 看主线程的PlayerLoop各大系统耗时,特别是
Rendering和Scripts; - 看Renderer.SetPass和Renderer.Draw的花费;
- 看Gfx.WaitForPresent或者Gfx.PresentFrame是否占了很大比例——如果有,大概率是VSync或GPU追赶不上导致的。
这一轮的目的是先回答“CPU是不是瓶颈”。如果CPU整体占用很短,比如主线程帧时间只有4ms,那就没必要再做合批优化,直接转去查GPU。
5.2 第二步:Frame Debugger逐项查看,区分Batches与真实DrawCall
打开Window > Analysis > Frame Debugger,启用后逐帧暂停,点击左侧的每个DrawCall,右侧会显示这个批次渲染的对象、使用的Shader和Pass。这一步能直接告诉你:
- 900个所谓DrawCall里,有多少是同Shader同Pass的连续批次;
- 有多少个是Shadow Pass推进来的额外调用;
- 有多少被SRP Batcher合并,有多少是被合批机制吸收后剩下来的;
- Texture、Material切换频繁程度,也就是状态切换开销是否夸张。
Frame Debugger的另一个好处是能看到SkinnedMeshRenderer、粒子系统、UI各自占了多少批次。很多时候你以为场景里只有几百个物体,结果粒子和UI才是批次数量的隐藏大头。
5.3 第三步:切API对比,验证是驱动开销还是场景问题
这个方法很少有人用,但特别有效。把项目的Graphics API从DX11切换成Vulkan(PC端),或者从GLES切到Vulkan(移动端),对比同一个场景的帧时间。
如果切换API后帧时间大幅度下降,说明你的瓶颈高度依赖CPU侧提交开销;如果帧时间没变化,说明瓶颈更多在GPU侧,因为GPU做的活并没有减少。
同理,也可以在不开任何合批优化的情况下,单独把阴影质量调低一档,看DrawCall不变时帧时间是否变了。这个对比能快速把瓶颈归因到具体环节。
5.4 第四步:用脚本量化瓶颈的辅助工具
Unity里可以用ProfilerMarker给关键代码块插桩,把自定义逻辑的耗时加进Profiler里。下面是我常用的模板:
using Unity.Profiling; static readonly ProfilerMarker sampleMarker = new ProfilerMarker("MyCustom.SampleLogic"); void Update() { sampleMarker.Begin(); // 你要排查的逻辑 sampleMarker.End(); }如果是构建后真机测试,可以用下面的脚本在运行时把Profiler数据保存下来:
UnityEngine.Profiling.Profiler.logFile = Application.persistentDataPath + "/frame.prof"; UnityEngine.Profiling.Profiler.enableBinaryLog = true; UnityEngine.Profiling.Profiler.enabled = true;把数据导出来之后用Unity Editor的Profiler窗口导入,能直接看到每一帧每个函数的详细耗时。这一步对于定位“脚本逻辑和渲染调用纠缠”的场景特别有用。
5.5 第五步:GPU端数据怎么看
CPU侧的工具覆盖不到GPU真实执行时间,移动端建议用以下任一方式:
- Unity的Profiler里的GPU模块:真机Profiling时勾选GPU Profiling,部分设备支持;
- Xcode的Metal System Trace / Android的GPU Inspector:可以直接读出GPU每帧的执行时间、着色器利用率、带宽消耗,是定位GPU瓶颈的关键工具;
- RenderDoc:PC端DX12/Vulkan抓帧神器,可以逐DrawCall看GPU执行耗时。
实际经验里,当你怀疑是填充率或者GPU瓶颈时,用GPU Inspector看一下Fragment Shader的耗时占比和带宽数据,比在Unity里瞎猜快得多。
6. 合批的代价与边界:什么时候该优化DrawCall,什么时候该去优化别的
如果确认了CPU侧提交开销确实是瓶颈,再考虑降DrawCall才有意义。但降DrawCall的方式也有讲究,有些方案看起来数字好看了,代价却藏在别处。
6.1 各合批手段的隐性代价对比
| 合批方式 | 优势 | 隐性代价 | 适用场景 |
|---|---|---|---|
| SRP Batcher | 大幅降低SetPass切换,代码改动极小 | 需要项目使用URP/HDRP,材质属性需兼容CBUFFER机制 | 通用场景首选,尤其URP |
| 静态合批 | 批次合并效果好,一次合批永久受益 | 增加内存占用,合并网格切换开销高,动态物体无效 | 建筑、地面、固定道具等 |
| 动态合批 | 不需要美术配合,自动生效 | 条件苛刻,顶点数/材质限制多;每帧可能重新合批,CPU开销不稳定 | 小面片、小物件、UI图集内元素 |
| GPU Instancing | 单DrawCall渲染大量同网格实例 | 要求网格+材质相同,属性差异只能用Instanced Property | 植被、粒子、大量相同单位 |
| MaterialPropertyBlock | 保留合批的同时差异化属性 | 属性更新时机错误会打断合批 | 同材质不同颜色的物体 |
拿最后一个例子来说,MPB要是每帧调用SetFloat之类的方法,依然会触发批次打断和GPU缓冲刷新,反效果比不用MPB还糟。所以技战术上,你得给美术和程序设定好“属性变更才调用API”的规则。
6.2 降DrawCall优化中容易掉进去的坑
第一个坑是盲目合并材质。有些项目为了降DrawCall把几百个不同的贴图硬塞进一张图集,合批倒是上去了,但UV被改、分辨率下降、内存也没省,美术效果退步明显。材质合并的思路应该是先做“同Shader变体归并”,再考虑贴图合并。
第二个坑是静态合批包住动态物体。物体只要被标记为Static Batching,之后哪怕它在运行时被移动、缩放,也是不生效的,甚至可能造成每帧更新合并网格的额外开销。
第三个坑是忽略内存和加载时间。高面数的静态合批会把原本分散的网格合成一份巨大网格,加载时间变长,内存占用升高。对于移动端小内存设备,多几百MB内存可能比多几百个DrawCall更致命。
第四个坑是只看到CPU侧下降了,GPU侧开销反而变大。比如把所有小物体的碎片合批成一个大网格,虽然DrawCall少了,但整体提交给GPU的顶点数可能成倍上升,GPU的顶点处理压力反而变大。
所以优化DrawCall的正确姿势是:先确认瓶颈在CPU提交侧,再做针对性的批次合并;合并之后必须拿真机数据回测,看帧时间是否真的下降,而不是盯着Stats窗口的数字自我感动。
6.3 当900 DrawCall是正常生态:优化的优先级排序
如果一个项目的900 DrawCall对应的是大量哨塔、墙体、地面这类静态建筑,那么静态合批或者SRP Batcher是最合适的方案,降批次的收益明显,且副作用可控。
如果一个项目的900 DrawCall主要来自UI,那就别动场景合批的思路,先去优化UI面板层级、让图集在同一Atlas内,控制活动Canvas数量。UGUI的批次机制跟场景合批完全不同,错误方案会越改越糟。
如果一个项目的900 DrawCall里,一大半来自粒子和特效系统,那优化的关键反而是“特效生命周期管理”和“最大粒子数量预算”。粒子的DrawCall往往和粒子数量直接挂钩,限制特效数量比做任何合批都有效。
换句话说,900这个数字就是一个健康检查指标,而不是一刀切的KPI。真要判断它要不要优化,必须落到批次构成分析上。
我自己在实践里还踩过另一个坑,就是拿PC端的帧率去推移动端的表现。PC上的显卡对DrawCall的宽容度太高,900个批次在PC上完全没感觉,到了手机上就是另一个世界。每次优化验证我都坚持用目标最低端设备来跑,不然做出来的结论一点参考价值都没有。
最后分享一个习惯:每次性能问题排查之后,我会顺手把当时的帧时间、DrawCall、SetPass Calls、API类型、设备型号、场景缩略图这些信息存成一份CSV。积累几十条之后,你就会发现自己项目的真实“安全区间”在哪里,而不是依赖网上的通用经验值。实际经验中,我测过的中端手机上,纯不透明同材质场景,URP下每帧600~800个批次,帧时间依然能稳定在12ms左右;但一旦混入透明材质和阴影pass,同样的批次数量就直接翻车。
技术优化的味道就在这些真实数据里,某行代码的执行顺序、某个资产的处理逻辑,都能决定你的GPU是闲得冒泡还是忙到冒烟。与其盯着900这个数字焦虑,不如沉下心来测量,找到项目中真正吃掉帧时间的那个环节。