1. 项目概述:从“卡顿”到“流畅”的必经之路
如果你是一名Unity开发者,无论是刚入门的新手还是摸爬滚打多年的老手,一定都经历过或正在经历一个共同的“噩梦”:游戏在编辑器里跑得好好的,一到真机,特别是中低端移动设备上,画面就开始掉帧、卡顿,甚至直接闪退。你可能会一头扎进代码里,优化算法、减少循环,但收效甚微。这时,一个在Unity性能优化领域如雷贯耳的名词就该登场了——DrawCall。它就像游戏渲染流水线上的一个“收费站”,每一次车辆(图形指令)通过都需要消耗时间和资源。当你的游戏场景过于复杂,车辆排起长队时,拥堵和卡顿就不可避免了。
简单来说,DrawCall是CPU向图形API(如OpenGL ES, Vulkan, DirectX)发起的一次绘制命令,请求GPU绘制一个或多个图元(通常是三角形)。在Unity的渲染流程中,每一次材质切换、每一次网格状态改变,都可能触发一次新的DrawCall。因此,DrawCall的数量直接反映了CPU在准备渲染数据上的负担。对于移动平台,尤其是Android设备上海量的中低端芯片,CPU性能往往是瓶颈,过高的DrawCall会迅速榨干CPU资源,导致帧率下降。所以,优化DrawCall,本质上是优化CPU与GPU之间的协作效率,减少指令排队等待的时间,是提升游戏流畅度的最直接、最有效的手段之一,没有之一。
这篇文章,我们就来彻底拆解Unity中的DrawCall。我不会只告诉你“要合批”,我会带你弄明白为什么合批能生效,合批有哪些“门派”和“规矩”,以及在实际项目中,面对成千上万的物体,我们该如何系统性地分析和降低DrawCall。无论你是在做一款轻量级的休闲手游,还是一个开放世界的大作,理解并掌握DrawCall优化,都是你从“能运行”迈向“流畅运行”的必修课。
2. DrawCall的本质与性能影响深度解析
2.1 渲染流水线中的“指令集”
要理解DrawCall为什么消耗性能,我们必须把它放到整个图形渲染的上下文中去看。你可以把GPU想象成一个极其高效的“画图工厂”,而CPU则是这个工厂的“调度中心”。DrawCall就是调度中心(CPU)向画图工厂(GPU)下发的一份详细的生产工单。
这份工单里包含了什么?绝不仅仅是“画这个三角形”这么简单。它是一整套完整的渲染状态指令集,至少包括:
- 使用哪个着色器程序:告诉GPU用哪套“算法”来处理顶点和像素。
- 着色器所需的全部参数:包括纹理、颜色、浮点数等,即Material的Property。
- 要绘制的几何数据:顶点缓冲区(Vertex Buffer)和索引缓冲区(Index Buffer)的位置,即Mesh信息。
- 渲染状态:深度测试、混合模式、面剔除等开关设置。
CPU准备这份“工单”是需要时间的。它需要收集所有数据,绑定到对应的API槽位,最后调用一个类似glDrawElements或CommandBuffer.DrawMesh的命令。这个过程本身就有开销。而更关键的是,GPU喜欢批量处理。如果调度中心(CPU)频繁地切换不同的工单(频繁切换材质、网格),工厂(GPU)就不得不频繁地更换生产线设置,大部分时间都花在了准备和切换上,实际绘画的效率就低了。
因此,DrawCall的性能开销主要体现在两方面:
- CPU开销:准备和提交DrawCall命令本身所需的计算量。每次DrawCall都涉及驱动层的工作,这是一个不可忽视的固定成本。
- GPU的潜在闲置:如果CPU准备命令太慢(DrawCall太多),GPU画完了上一帧,却迟迟等不到下一帧的指令,就会空闲,导致帧率下降。这在移动端CPU性能相对较弱的环境下尤为突出。
一个经验性的指标是,对于复杂的移动端游戏,建议将每帧的DrawCall数量控制在100-200以内,对于追求极致性能的竞技类手游,可能要求压到50以下。这只是一个粗略的参考,实际瓶颈还与每个DrawCall的复杂度(顶点数、像素填充率)有关,但控制数量永远是第一要务。
2.2 影响DrawCall数量的核心因素
是什么决定了我们场景中DrawCall的数量?它不是一个简单的物体计数,而是由渲染顺序和物体属性动态决定的。主要因素包括:
- 材质(Material):这是最重要的因素,没有之一。Unity的渲染引擎会按照材质对物体进行排序。任何材质属性的不同(即使是同一Shader,但某个颜色值不同),都会导致引擎认为这是两个不同的材质实例,从而打断合批。更不用说使用不同Shader的材质了。
- 渲染队列(Render Queue):Unity使用渲染队列来决定绘制顺序(例如先画不透明物体,再画半透明物体)。处于不同渲染队列的物体无法被合批。
- 网格(Mesh):动态合批对网格的顶点属性有严格限制(后文详述)。静态合批则要求网格是静态的。
- 变换(Transform):物体的位置、旋转、缩放信息需要传递到Shader中。动态合批可以处理相同的缩放值,但静态合批会直接将变换“烘焙”进顶点数据。
- 光照与阴影:接受实时光照、投射或接收阴影,可能会引入额外的Pass(渲染遍数),从而增加DrawCall。例如,一个物体如果同时要绘制自身颜色和投射阴影,就可能需要至少两个DrawCall。
注意:很多人会混淆“材质”和“纹理”。使用同一张纹理(Texture)但不同材质的两个物体,不会被合批。合批的关键在于材质实例是否完全相同。你可以让多个物体共享同一个材质实例,并通过脚本修改材质的属性(如
material.SetColor)来实现“不同外观”,但请注意,修改共享材质的属性会影响所有使用该材质的物体。对于需要独立属性的物体,正确的做法是使用MaterialPropertyBlock,这不会打断合批。
3. Unity的合批技术:静态与动态的博弈
理解了DrawCall的成因,优化的核心思路就是“合并”。Unity为我们提供了两种主要的自动化合批机制:静态合批(Static Batching)和动态合批(Dynamic Batching)。它们原理不同,适用场景也不同。
3.1 静态合批:一劳永逸的“预制件”
原理:静态合批发生在运行前(构建时或运行时初始化阶段)。它将标记为“Static”(且参与合批)的多个物体的网格数据,合并成一个(或少数几个)更大的网格,并创建一个对应的材质列表。在运行时,渲染这个大网格只需要一次或很少几次DrawCall。
如何启用:
- 在Inspector面板中,勾选物体右上角的“Static”复选框。这通常用于场景中永远不会移动、旋转或缩放的物体,如地形、建筑、静态装饰物。
- 更精细的控制可以在
Edit -> Project Settings -> Player中找到,在对应平台的设置中,确保Static Batching选项是勾选的。
优点:
- 性能收益高:合批发生在初始化时,运行时渲染开销极低,是减少DrawCall最有效的手段。
- 支持复杂网格:对顶点数量、属性数量几乎没有限制。
缺点与代价:
- 内存占用增加:这是最大的代价。合并后的网格会存储在内存中。如果大量静态物体共享同一个网格(如1000个相同的石头),静态合批会创建1000份该网格的变换后副本,导致内存暴增。而动态合批或GPU Instancing则不会。
- 失去个体控制:合并后的物体无法再单独设置显隐、接收动态光照(光照贴图除外)或进行剔除(但Unity会进行子网格级别的裁剪)。
- 构建时间变长:在构建项目时,静态合批需要预处理,会增加构建时间。
实操心得: 对于独一无二的、复杂的静态场景物件(如一栋独特的房子),使用静态合批效果极佳。但对于大量重复的预制件(如草地上的花朵、碎石),务必谨慎。你需要用Profiler工具对比DrawCall减少带来的性能提升与内存增长带来的风险。一个常见的策略是:对中大型、独特的静态模型使用静态合批;对小而多的重复物体,使用动态合批或GPU Instancing。
3.2 动态合批:灵活机动的“轻骑兵”
原理:动态合批发生在运行时,每一帧进行。Unity的渲染引擎会在CPU端,将满足条件的小型网格的顶点数据动态地合并到一个顶点缓冲区中,然后一次性提交绘制。这相当于在每帧渲染前,临时组装一个“合批网格”。
启用条件(条件苛刻,且因Unity版本和平台而异,以下是常见核心限制):
- 网格顶点数:通常要求网格顶点数少于300个(在移动平台上,这个限制可能更低,如150-200)。这是为了控制每帧CPU端合并顶点数据的开销。
- 使用相同的材质实例:必须是完全相同的材质球引用。
- 统一的缩放:所有物体的缩放值必须完全相同(例如,都是
(1,1,1)或都是(2,2,2))。非统一缩放(如(1,2,1))会破坏合批。 - 光照:对于Forward Rendering路径,动态合批通常要求物体不接受实时光照(即使用Lightmap或Unlit Shader)。在URP/HDRP中,规则可能有所不同。
- 其他限制:使用多Pass的Shader、GPU Skinning的物体通常无法动态合批。
优点:
- 适用于移动物体:这是它相对于静态合批的最大优势,可以合批那些位置、旋转会变化的物体(只要缩放一致)。
- 内存友好:不会像静态合批那样显著增加内存占用。
缺点:
- CPU开销:每帧都需要进行顶点数据合并,如果合批的物体很多,这个CPU开销本身可能成为新的性能瓶颈。这就是为什么它只适用于“小型”网格。
- 条件苛刻:上述任何一条不满足,合批就会失败。在复杂的项目中,很难保证大量物体同时满足所有条件。
如何查看:在Game视图的Stats面板中,可以看到“Batches”和“Saved by batching”信息。如果“Saved by batching”数值很高,说明动态合批效果显著。
提示:对于大量相同的小型物体(如子弹、金币、树叶),如果它们需要移动,动态合批是首选方案。确保你的模型师提供的这些资产面数足够低(例如低于200个三角形),并在代码中确保它们的缩放值不会被意外修改。
4. 超越内置合批:GPU Instancing与SRP Batcher
当静态合批太耗内存、动态合批条件又太苛刻时,我们就需要更先进的武器。这就是GPU Instancing和SRP Batcher(在URP/HDRP中)。
4.1 GPU Instancing:一次提交,万次绘制
原理:这是现代图形API(OpenGL ES 3.0+, Metal, Vulkan)支持的特性。它的核心思想是,CPU只提交一次网格和材质数据,同时提供一个包含每个实例不同属性(如位置、颜色)的数组(Instance Data Buffer)。GPU在单个DrawCall中,就能使用这些数据绘制出该网格的多个实例。
如何启用:
- Shader支持:需要编写支持GPU Instancing的Shader。幸运的是,Unity的大部分标准Shader和URP/Lit Shader默认都支持。你可以在Shader代码中看到
#pragma multi_compile_instancing指令。 - 材质球启用:在材质的Inspector面板上,勾选“Enable GPU Instancing”。
- 代码驱动:通过
Graphics.DrawMeshInstanced或MaterialPropertyBlock来传递每个实例的独特属性。
优点:
- 极高的性能:DrawCall数量降至1,无论实例有多少(有上限,通常为1023或更少)。CPU开销极低。
- 内存效率高:只存储一份网格和材质数据。
- 支持动态数据:实例的位置、颜色等属性可以每帧变化。
限制:
- 硬件要求:需要较新的GPU和图形API支持。
- 实例数量上限:单次调用有最大实例数限制(如1023),超过需要拆分。
- 属性限制:通过MaterialPropertyBlock传递的实例属性数量和类型有限制。
- 剔除(Culling):需要正确处理视锥体裁剪,否则会浪费性能绘制屏幕外的实例。通常需要自己实现或使用
CullingGroup。
适用场景:海量重复的、简单的物体。这是GPU Instancing的绝对主场。例如:
- 草地、树木、岩石等自然景物。
- 建筑群中重复的窗户、砖块。
- 弹幕游戏中的子弹。
- 粒子系统(URP的VFX Graph底层就使用了Instancing)。
4.2 SRP Batcher(URP/HDRP):基于Shader变体的高级合批
如果你在使用Universal Render Pipeline (URP) 或 High Definition Render Pipeline (HDRP),那么SRP Batcher是你必须了解的特性。
原理:SRP Batcher不再专注于合并网格,而是优化渲染状态的切换。它会将所有使用同一Shader变体的物体的常量缓冲区(CBUFFER)数据(如物体的变换矩阵unity_ObjectToWorld)进行缓存和批量上传。即使这些物体材质属性不同(只要Shader变体相同),也能极大地减少DrawCall之间的状态设置开销。
如何工作:
- 你的Shader必须遵循SRP Batcher的代码规范(通常使用
CBUFFER_START(UnityPerMaterial)和CBUFFER_END来声明材质属性)。 - 在URP项目中,SRP Batcher默认是开启的(在URP Asset中检查)。
- 当渲染时,SRP Batcher会将同Shader变体的DrawCall进行排序和批量提交。
优点:
- 对动态物体友好:非常适合场景中有大量使用相同Shader但不同材质参数的动态物体。
- 减少CPU渲染线程负担:优化了驱动调用,提升了CPU效率。
与GPU Instancing的关系:SRP Batcher和GPU Instancing可以协同工作!如果一个Shader同时启用了GPU Instancing并且符合SRP Batcher规范,Unity会优先尝试使用GPU Instancing(因为它更高效),如果失败(如实例数超过上限),则会回退到SRP Batcher路径。这为性能提供了双重保障。
实操心得: 在URP项目中,确保你的自定义Shader都适配了SRP Batcher规范。你可以通过Frame Debugger查看DrawCall的提交方式,如果显示为“SRP Batcher”,说明它正在生效。对于大量相同的动态物体,优先考虑GPU Instancing;对于大量相似但材质参数各异的动态物体,SRP Batcher是你的救星。
5. 实战:系统化分析与优化DrawCall
理论说再多,不如实战一遍。下面我们以一个典型的移动端3D游戏场景为例,演示如何系统性地分析和优化DrawCall。
5.1 诊断工具:你的性能“听诊器”
工欲善其事,必先利其器。优化前,必须精确测量。
Stats 面板:在Game视图左上角点击Stats按钮。重点关注:
- Batches:这就是本帧的DrawCall数量(在SRP中更准确的说法是渲染批次)。
- Saved by batching:被合批机制节省下来的Batches数量。这个数字越高,说明合批效果越好。
- SetPass calls:材质切换的次数。即使Batches被合批了,SetPass calls过高也会影响性能,它反映了渲染状态切换的频繁程度。
Frame Debugger (窗口 -> 分析 -> Frame Debugger):这是最强大的DrawCall分析工具,没有之一。它可以让你“暂停”某一帧,逐条查看每一个DrawCall的详细调用信息。
- 你可以看到每个DrawCall绘制了哪个物体、使用了哪个材质、属于哪个渲染队列。
- 你可以清晰地看到合批在哪里被打断(通常是材质切换)。
- 操作步骤:打开Frame Debugger -> 在Game视图播放游戏 -> 点击Frame Debugger中的“Enable” -> 使用左右箭头逐条查看DrawCall。
Profiler (窗口 -> 分析 -> 分析器):用于定位CPU端的性能瓶颈。在CPU使用率模块中,你可以看到
RenderLoop.Draw等函数的耗时,这直接反映了渲染(包括提交DrawCall)的CPU开销。
5.2 优化流程与策略
假设我们有一个场景,Stats面板显示Batches为350,Saved by batching只有20,目标是将Batches优化到150以下。
第一步:静态物体静态化
- 遍历场景,将所有确定不会移动、旋转、缩放的环境物体(地面、墙壁、大型建筑)标记为Static。
- 在Player Settings中确保静态合批开启。
- 优化后,使用Frame Debugger查看,这些物体的DrawCall应该被合并为少数几个大的批次。
第二步:共享材质
- 这是减少DrawCall最立竿见影的方法。检查场景中所有渲染器(MeshRenderer)。
- 将使用相同Shader和相同纹理/颜色参数的物体的材质,拖拽成同一个材质实例。避免为每个物体都创建一个新的材质实例。
- 对于需要不同颜色等属性的物体,不要创建新材质,而是使用MaterialPropertyBlock。
// 示例:使用MaterialPropertyBlock修改颜色而不打断合批 MaterialPropertyBlock props = new MaterialPropertyBlock(); renderer.GetPropertyBlock(props); // 获取现有属性(如果有) props.SetColor("_BaseColor", Color.red); // 设置颜色属性 renderer.SetPropertyBlock(props); - 优化后,Frame Debugger中连续使用同一材质的DrawCall应该被合并。
第三步:处理动态小物体
- 对于大量重复且需要移动的小物体(如金币),确保它们:
- 使用同一个材质实例。
- 网格顶点数低于动态合批限制(如300)。
- 缩放值完全相同(通常都是
(1,1,1))。
- 如果它们数量巨大(超过1000),考虑使用GPU Instancing。为它们的材质勾选“Enable GPU Instancing”,并使用
Graphics.DrawMeshInstanced进行绘制。
第四步:纹理图集(Atlas)
- 对于UI(uGUI)和2D精灵(Sprite),DrawCall优化主要靠纹理图集。将多个小图片打包到一张大纹理中。
- Unity的Sprite Atlas功能可以自动完成此工作。确保相关的Sprite都分配到了同一个Sprite Atlas中。
- 对于3D模型,也可以使用纹理图集,将多个模型的漫反射贴图、法线贴图等合并到一张大图上,这样它们就可以共享材质,从而合批。这需要美术在制作模型UV时进行规划。
第五步:层级细节(LOD)与剔除
- LOD (Level of Detail):为远处的模型设置低面数版本。当相机远离时,切换到低模。这虽然不直接减少DrawCall(如果材质相同,DrawCall数不变),但极大地减少了每个DrawCall需要处理的顶点和片元数量,提升了GPU效率,间接为CPU提交更多DrawCall腾出了时间。
- 遮挡剔除(Occlusion Culling):对于室内或结构复杂的场景,使用遮挡剔除可以防止相机看不到的物体被提交渲染,从而直接减少DrawCall。需要在Occlusion窗口中进行烘焙。
第六步:检查光照与阴影
- 实时光照和实时阴影是DrawCall杀手。每个受光源影响的物体,每个投射阴影的物体,都可能增加额外的渲染Pass。
- 策略:
- 尽可能使用烘焙光照(Lightmapping)代替实时光照。
- 减少动态光源的数量,特别是影响范围大的点光源和聚光灯。
- 谨慎使用阴影,考虑使用“烘焙阴影”或简单的投影贴图(Projector)来模拟。
- 在URP中,合理配置每个光源的渲染层级(Render Layer),避免光源影响不必要的物体。
5.3 常见问题排查与避坑指南
即使遵循了所有策略,你可能还是会遇到DrawCall居高不下的情况。下面是一些常见的“坑”和排查思路:
问题1:明明材质看起来一样,为什么没合批?
- 检查材质实例:在Frame Debugger中点击DrawCall,查看使用的材质。确认两个物体引用的是内存地址完全相同的材质实例,而不是两个内容相同但实例不同的材质。
- 检查Shader关键字:动态合批和SRP Batcher对Shader变体敏感。如果通过
EnableKeyword或#if编译指令启用了不同的关键字,即使同一个Shader也会被视为不同变体,导致合批中断。 - 检查渲染队列:确保物体的渲染队列值一致。自定义的Queue值可能导致排序分离。
问题2:使用了GPU Instancing,但DrawCall没减少?
- 检查硬件支持:在低端设备或某些GLES2.0环境下,GPU Instancing可能不被支持。可以通过
SystemInfo.supportsInstancing来检查。 - 检查绘制调用方式:你是通过
Graphics.DrawMeshInstanced绘制的吗?或者渲染器上的材质是否确实勾选了“Enable GPU Instancing”?在Frame Debugger中,成功的GPU Instancing DrawCall通常会显示“Draw Mesh (Instanced)”。 - 检查实例数量上限:单次
DrawMeshInstanced调用有数量限制(如1023)。如果你有2000个实例,需要拆分成两次调用,这就会产生2个DrawCall。
问题3:移动端动态合批无效?
- 移动平台(尤其是GLES2.0/3.0)的动态合批限制可能比编辑器更严格。最常见的是顶点数限制更低(可能只有150),以及对顶点属性的限制(例如,不能包含切线、多套UV等)。简化模型,或考虑使用GPU Instancing。
问题4:UI DrawCall爆炸
- UI是DrawCall的重灾区。确保所有Image、RawImage使用的纹理都在同一个Sprite Atlas中。
- UI元素的层级(Hierarchy)顺序直接影响合批。Unity会尝试对相邻的、使用相同图集材料的UI元素进行合批。尽量避免不同图集的UI元素在层级中交错排列。
- 使用
Canvas的 “Additional Shader Channels” 确保传递了必要的顶点属性(如UV),避免Canvas被迫拆分批次。
问题5:粒子系统(Particle System)DrawCall高
- 每个粒子系统默认会产生至少一个DrawCall。如果场景中有上百个粒子系统,DrawCall就会很高。
- 优化:
- 合并粒子效果:将多个小型的、同时播放的粒子系统,在美术设计阶段就合并成一个系统。
- 使用URP的VFX Graph:VFX Graph底层使用Compute Shader和GPU Instancing,效率远高于传统的CPU粒子系统,能极大降低DrawCall。
- 对于必须使用传统粒子系统的情况,确保它们使用的材质尽可能相同。
避坑终极心法: 优化是一个权衡的过程。静态合批省DrawCall但吃内存,动态合批省内存但有CPU开销和条件限制。没有银弹。永远依靠数据(Profiler, Frame Debugger)做决策,而不是猜测。在目标平台上进行性能分析,找到真正的瓶颈。有时,减少一个过于复杂的Shader计算,比费尽心思合并几个DrawCall带来的收益更大。DrawCall优化是性能优化的关键入口,但绝不是终点。它需要你与美术、策划紧密合作,从资源制作规范、场景搭建规则到代码架构,进行全流程的管控。