三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity渲染优化:从DrawCall到Batches与SetPass Calls的实战指南

Unity渲染优化:从DrawCall到Batches与SetPass Calls的实战指南

1. 项目概述:从“唯DrawCall论”到实战优化思维的转变

在Unity开发圈子里,尤其是涉及性能优化时,“DrawCall”这个词几乎成了口头禅。很多开发者,特别是刚入行不久的朋友,一遇到渲染卡顿,第一反应就是“DrawCall太高了,得合批”。这个思路没错,但它只是庞大渲染优化体系中的一个环节,甚至可以说是一个相对表面的指标。我见过太多项目,开发者费尽心思把静态合批、动态合批都做足了,UI也拼命打图集,Profiler里DrawCall数字降得很漂亮,但游戏运行时帧率依然不稳,GPU耗时依然很高,问题出在哪?这就是典型的“优化近视”,只盯着一个指标,而忽略了渲染管线中其他更关键、更消耗资源的环节。

今天要聊的,就是两个比DrawCall更值得你关注的“性能杀手”:BatchesSetPass Calls。它们和DrawCall密切相关,但含义和影响层面截然不同。简单来说,你可以把一次渲染过程想象成去餐厅点餐。DrawCall相当于你“点了一道菜”这个动作本身。而Batches(批处理)更像是厨师一次性能处理多份相同菜品的“批量烹饪”能力,它能减少“点菜”的次数。SetPass Calls则相当于更换烹饪工具和配方,比如从炒锅换成烤箱,从川菜调料换成粤菜调料——这个“切换”动作本身开销巨大。如果你的游戏里频繁切换材质(Shader)、渲染状态(如混合模式、深度测试),即使DrawCall不高,SetPass Calls也会爆表,导致GPU频繁“换挡”,性能急剧下降。

这篇文章,就是带你跳出“唯DrawCall论”的陷阱,通过实战案例,深入理解Batches和SetPass Calls的本质,并分享一套从项目初期到后期调优都适用的避坑指南。无论你是正在为上线项目卡顿而焦头烂尾的主程,还是希望从一开始就搭建稳健渲染架构的TA,相信这些从实际项目里踩坑填坑总结出的经验,都能给你带来直接的帮助。

2. 核心概念深度解析:DrawCall、Batches与SetPass Calls

在深入实战之前,我们必须把这三个核心概念的定义、关联与区别彻底掰扯清楚。很多误解和无效优化都源于概念混淆。

2.1 DrawCall:图形API的绘制指令

DrawCall,直译为“绘制调用”,它是我们向图形API(如OpenGL, Direct3D)发起的一次最基础的绘制请求。一次DrawCall告诉GPU:“请用当前设置好的状态(材质、纹理、缓冲区等),把这些顶点数据画出来。” 在Unity的渲染统计窗口或Frame Debugger里,我们看到的“Draw Calls”通常指的就是这个数量。

为什么大家如此关注DrawCall?因为在早期的固定功能管线以及驱动开销较大的时代,每一次DrawCall的CPU侧准备工作和API调用开销都相对显著。减少DrawCall数量,能有效降低CPU在渲染上的负担,这是早期移动端和性能敏感项目优化的金科玉律。Unity提供的静态合批(Static Batching)、动态合批(Dynamic Batching)以及后来的SRP Batcher、GPU Instancing等技术,核心目标之一就是减少DrawCall。

但是,DrawCall的局限性:它仅仅衡量了“绘制指令”的次数,而没有考虑指令背后的“成本”。一个绘制10个顶点的DrawCall,和一个绘制10万个顶点的DrawCall,其GPU执行时间天差地别。同样,两个DrawCall,如果它们之间不需要切换渲染状态(即属于同一个Batch),其开销也远小于两个需要切换状态的DrawCall。因此,单纯看DrawCall数字下降,并不等同于渲染性能提升。

2.2 Batches:Unity的合批成果

Batches(批处理)是Unity在更高层级对绘制进行优化后的结果。它的目标是:将多个符合条件的DrawCall合并成一个或更少的DrawCall提交给GPU。在Unity的Stats窗口或Profiler的Rendering区域,你看到的“Batches”数量,才是经过Unity合批系统处理后的、实际提交的绘制请求数量。这个数字通常小于或等于DrawCall数。

Batches的种类与原理:

  1. 静态合批(Static Batching):针对标记为Static且共享同一材质的非移动物体。Unity在运行前(或运行时首次)将这些物体的顶点数据变换到世界空间并合并到一个大的顶点缓冲区中。之后绘制它们只需要一次或很少几次DrawCall。优点是合批效果好,CPU开销低。缺点是增加内存和磁盘空间(存储合并后的网格数据),且物体无法再移动。
  2. 动态合批(Dynamic Batching):Unity在每帧为满足条件(顶点数少于300、使用相同材质等)的动态物体动态合并网格。它省内存,但CPU开销较大(每帧都需要进行顶点变换和合并计算),且限制严格,在稍复杂的场景中作用有限。
  3. GPU Instancing:针对大量使用相同网格和材质的物体(如草、树、子弹)。它只上传一份网格和材质数据,通过一个实例缓冲区传递每个实例的变换信息,由GPU一次性绘制所有实例。这是处理海量相同对象最高效的方式,但对Shader有要求(需支持#pragma multi_compile_instancing)。
  4. SRP Batcher(可编程渲染管线批处理器):这是URP/HDRP等SRP管线中的高级批处理技术。它的核心思想不是合并网格,而是持久化材质属性在GPU内存中,并大幅减少每帧提交给GPU的常量缓冲区数据量。只要物体使用同一个Shader变体(即使材质参数不同),SRP Batcher就能让它们在一个批次内快速渲染,极大降低了SetPass Calls。这是现代Unity项目必须利用起来的利器。

关键理解:Batches是“结果”,是Unity帮你优化后的“打包好的绘制包”。优化Batches,就是让Unity能更高效地“打包”。而DrawCall是“原料”,是打包前的单个物品。我们的目标是减少需要打包的“原料”种类和增加每包的“容量”。

2.3 SetPass Calls:渲染状态的切换成本之王

如果说Batches是优化目标,那么SetPass Calls就是你需要时刻监控的“性能血压计”。在Stats窗口中,它通常紧挨着Batches。

什么是SetPass?“SetPass”字面意思是“设置通道”。在这里,它指的是切换一次渲染状态(Render State)的开销。渲染状态包括但不限于:

  • Shader/材质切换:从材质A切换到材质B。
  • 纹理切换:绑定不同的纹理到着色器。
  • 混合模式切换:从Alpha Blend切换到Additive。
  • 深度测试/写入状态切换
  • 其他GPU管线状态变更

每一次SetPass Call,都意味着GPU需要中断当前的工作流水线,重新配置一系列内部寄存器,这会产生一个显著的固定开销。即使这个新的状态只绘制一个三角形,这个开销也几乎不变。

SetPass Calls与Batches的关系:

  • 理想情况:一个Batch对应一个SetPass Call。这意味着这个批次内的所有绘制都使用完全相同的渲染状态,GPU可以流畅执行。
  • 糟糕情况:多个Batches共享同一个SetPass Call(可能通过SRP Batcher实现),或者更糟,一个Batch都没形成,每个DrawCall都导致一次SetPass Call。后者的性能是最差的。

实战经验:在移动平台或低端设备上,一次SetPass Call的开销可能相当于几十甚至上百个简单顶点的绘制时间。我曾优化过一个UI界面,DrawCall只有40多,但SetPass Calls高达35,导致界面卡顿。通过合并材质、调整渲染顺序,将SetPass Calls降到5以下,帧率立刻提升了15帧。监控和优化SetPass Calls的优先级,在很多时候应该高于单纯优化DrawCall或Batches。

3. 实战避坑指南:从项目配置到深度优化

理解了概念,我们进入实战。这部分将按照项目开发流程,从设置到具体操作,逐一拆解如何有效管理Batches和SetPass Calls。

3.1 项目初期设置与资产规范

很多性能问题是“先天”的,在项目初期定好规矩,能省去后期大量的重构成本。

1. 渲染管线(Render Pipeline)选择:

  • 内置渲染管线(Built-in):合批能力较弱,主要依赖静态/动态合批,对SetPass Calls优化手段有限。除非项目有特殊历史原因,否则新项目不推荐。
  • 通用渲染管线(URP)强烈推荐用于绝大多数移动端和PC端项目。它内置了强大的SRP Batcher,能极大优化使用相同Shader变体的物体的渲染,显著降低SetPass Calls。URP还提供了更清晰的渲染器特性(Renderer Features)来管理渲染顺序和状态。
  • 高清渲染管线(HDRP):为高端PC和主机设计,功能强大但开销也大。如果你的项目不是追求电影级画质,谨慎选择。

操作:创建项目时或项目早期,通过Package Manager安装URP,并创建URP Asset和Renderer Asset。将项目中的所有Shader逐步迁移到URP兼容的Shader(或使用URP自带的Lit/Unlit Shader Graph)。

2. 材质与着色器管理规范:

  • 最小化材质种类:这是降低SetPass Calls最根本的方法。鼓励美术同学在制作模型时,一个模型尽量使用1-2个材质。对于大量使用的小道具,可以建立“共用材质库”,比如一套金属材质、一套木头材质、一套布料材质,通过纹理和材质球实例的微调来区分。
  • 善用材质属性块(MaterialPropertyBlock):对于需要频繁改变颜色、浮点参数(如_Dissolve)但Shader相同的物体(如大量受击变红的敌人),不要创建成百上千个材质实例。使用MaterialPropertyBlock来覆盖材质属性,这样它们仍然可以被合批(在SRP Batcher或GPU Instancing支持下)。
  • Shader变体控制:一个Shader根据不同的关键字(如#pragma multi_compile)会编译出多个变体。过多的变体会导致合批中断。在URP中,合理使用Shader Variant Collection来预加载和剥离不需要的变体。

3. 纹理与图集规划:

  • UI纹理必须打图集:Unity的UI系统(uGUI)默认会为相同图集的元素合批。确保所有UI精灵(Sprite)都被正确打包到Sprite Atlas中。注意图集的大小限制(如2048x2048),避免溢出。
  • 3D模型纹理规划:对于风格化或低多边形项目,可以考虑使用纹理集(Texture Atlas)或虚拟纹理(Virtual Texturing)技术,将多个模型的贴图合并到一张大图上,从而让它们共享材质,促进合批。

3.2 场景制作与对象摆放策略

场景是渲染压力的主要来源,美术和地编的工作方式直接影响性能。

1. 静态物体处理:

  • 果断标记Static:对于场景中永远不会移动、旋转、缩放的环境物体(建筑、道路、山体),务必在Inspector右上角勾选Static复选框。这是启用静态合批的前提。你可以批量选择物体,在右键菜单或Static下拉框中统一设置。
  • 检查静态合批结果:在Game视图的Stats窗口中,开启静态合批后,观察Saved by batching的数量。也可以使用Frame Debugger(窗口->分析->帧调试器)逐帧查看哪些物体被静态合批了。

2. 动态物体优化:

  • 识别合批破坏者:动态合批条件苛刻。确保动态小物体(顶点数<300)使用相同的材质。注意,实时阴影、光照贴图、不同的缩放负值(镜像)都会破坏动态合批。
  • GPU Instancing是首选:对于大量重复的动态物体,如飞舞的树叶、子弹、金币,一定要用GPU Instancing。确保模型的MeshRenderer组件上勾选了Enable GPU Instancing,并且使用的Shader支持实例化。在URP中,标准着色器默认支持。

3. 渲染顺序(Render Queue)管理:

  • 原理:Unity按物体的渲染队列(Render Queue)值从小到大进行渲染。相同队列的物体,通常按距离相机远近(不透明)或由远及近(透明)排序。
  • 技巧:通过手动设置材质或Shader的RenderQueue,可以将使用相同或相似材质的物体“聚集”到连续的渲染队列段中。这可以减少因渲染队列跳跃导致的SetPass Calls。例如,你所有使用“场景岩石”材质的物体都可以设为2000,所有使用“场景植被”材质的设为2001
  • 透明物体警告:透明物体(Queue>=2500)无法进行深度测试优化,且通常无法合批(因为需要从后往前渲染)。应尽量减少透明物体的重叠和数量。对于粒子系统,考虑使用Alpha Test(Cutout)代替Alpha Blend,因为Alpha Test的物体可以写入深度缓冲区,可能参与合批。

3.3 代码层面的优化控制

程序可以通过代码更精细地控制渲染行为。

1. 控制Renderer的开关而非GameObject的Active:如果需要频繁显示/隐藏一个物体,考虑控制其MeshRenderer.enabledSkinnedMeshRenderer.enabled,而不是SetActive(false)。因为禁用GameObject会触发一系列生命周期函数,而禁用Renderer只影响渲染,物体仍在场景中,可能更有利于合批逻辑(特别是静态物体)。

2. 使用OnBecameVisible/OnBecameInvisible:对于大量不在屏幕内的物体(如开放世界中的远处建筑),可以挂载脚本,在OnBecameInvisible时禁用Renderer,在OnBecameVisible时启用。这能直接减少提交给GPU的Batches数量。注意,这个回调基于视锥体剔除,对于被遮挡但仍在视锥体内的物体无效。

3. 分层剔除与LOD(多层次细节):

  • 分层剔除(Layer Culling Distance):在相机组件上,可以为不同的Layer设置最大剔除距离。例如,将“远景装饰”层的剔除距离设小一些,超出距离的物体根本不会进入渲染流程,自然也没有Batches。
  • LOD Group:为复杂的模型设置LOD。确保不同LOD级别的模型使用相同的材质。如果LOD0和LOD1用了不同材质,那么当LOD切换时,就会产生一次额外的SetPass Call。

4. 针对UI的优化:

  • Canvas拆分策略:Unity UI的合批是以Canvas为单位的。一个Canvas下的所有元素会一起被合批。但Canvas的任何一点变化(如一个Text的文本改变)都会导致整个Canvas重建(Rebuild),开销大。因此,合理的策略是:静态UI元素(背景、边框)放在一个Canvas下;频繁变化的元素(血量数字、滚动列表)放在另一个甚至多个Canvas下。通过Canvas组件的Additional Shader Channels属性,确保它包含了你的UI Shader所需的所有顶点数据(如TexCoord1, Normal),避免合批中断。
  • 避免Raycast Target滥用:UI Image和Text组件默认开启Raycast Target,这会产生额外的射线检测开销。对于不需要交互的纯显示元素,务必取消勾选。

4. 性能分析与调试工具实战

优化不能靠猜,必须靠数据。Unity提供了强大的工具来定位Batches和SetPass Calls的问题。

4.1 Stats窗口与Frame Debugger

Stats窗口(Game视图下点击Stats按钮):这是第一道性能快照。重点关注:

  • Batches:经过合批后的绘制调用数。优化目标是在不影响画面效果的前提下,尽可能降低此数值。
  • SetPass calls:渲染状态切换次数。这是需要重点打压的对象。理想情况下,它应该接近或等于Batches(在SRP Batcher生效时,可能小于Batches)。
  • Saved by batching:静态和动态合批为你节省的DrawCall数量。这个数字越高,说明你的合批策略越有效。

Frame Debugger(窗口 -> 分析 -> 帧调试器):这是分析渲染问题的终极利器。它可以让你“暂停”某一帧,并逐步骤(Step)地查看每一个DrawCall/Batch是如何产生的。

  1. 打开Frame Debugger,点击Enable
  2. 在Game视图进行操作,触发你想要分析的卡顿帧。
  3. 回到Frame Debugger,你可以看到一列详细的渲染事件列表。
  4. 点击任何一个事件(如Draw Mesh),右侧会显示该次绘制的详细信息:使用了哪个材质、哪个Shader、渲染队列、合批情况等。最关键的是,你可以看到为什么这次绘制没有和上一次合批。常见原因会高亮显示,例如:“Different material”、“Different shader keywords”、“Different render state”。

实战案例:我曾用Frame Debugger分析一个场景,发现SetPass Calls异常高。逐条查看发现,场景中有几十个不同的“石头”物体,它们纹理相同但材质球是分开创建的(Material (Instance))。Frame Debugger在每个石头绘制前都提示“Different material”。解决方案就是创建一个共享的材质球,所有石头都引用它,SetPass Calls瞬间从50+降到了2。

4.2 Profiler深度剖析

Profiler(窗口 -> 分析 -> 分析器)用于进行时间维度的性能分析。

  1. 切换到Rendering区域。
  2. 关注SetPass CallsBatches的曲线。如果它们在某帧出现尖峰,结合CPU区域的调用栈,可以定位是哪个逻辑(如瞬间生成大量物体、UI刷新)导致了渲染负载激增。
  3. 使用Deep Profile模式或手动添加Profiler.BeginSample/EndSample标记,可以精确定位到具体函数导致的渲染开销。

4.3 自定义性能监控

对于大型项目,可以编写简单的运行时监控脚本,在开发版本中持续输出或记录关键渲染指标,便于在真机上进行长时间测试时发现问题。

using UnityEngine; public class RenderMetricsMonitor : MonoBehaviour { public float logInterval = 5.0f; // 每5秒输出一次 private float timer = 0f; void Update() { timer += Time.deltaTime; if (timer >= logInterval) { timer = 0f; int batches = UnityEngine.Rendering.RenderStats.batches; int setPassCalls = UnityEngine.Rendering.RenderStats.setPassCalls; float gpuTime = UnityEngine.Rendering.RenderStats.gpuTime; // 注意单位 Debug.Log($"Render Metrics - Batches: {batches}, SetPassCalls: {setPassCalls}, GPU Time: {gpuTime:F2}ms"); // 可以添加阈值判断,发出警告 if (setPassCalls > 100) { Debug.LogWarning("SetPassCalls过高,请检查材质合并与渲染顺序!"); } } } }

5. 高级技巧与疑难杂症排查

掌握了基础方法和工具后,我们来看一些进阶场景和常见“坑点”。

5.1 SRP Batcher的最佳实践与失效场景

URP的SRP Batcher是降低SetPass Calls的大杀器,但它有生效条件:

  • Shader必须兼容SRP Batcher:Shader中需要包含CBUFFER_START(UnityPerMaterial)CBUFFER_END来声明材质属性。URP内置的Lit/Unlit Shader都支持。
  • 使用相同的Shader变体:即使使用同一个Shader文件,如果通过#pragma multi_compileshader_feature启用了不同的关键字(如_NORMALMAP),也会被视为不同变体,中断合批。
  • 渲染器特性(Renderer Features):某些Renderer Features(如渲染特定Layer到一张RT)可能会打断主渲染过程的SRP Batcher。需要仔细设计渲染流程。

检查SRP Batcher是否生效:在Frame Debugger中,查看绘制事件。如果被SRP Batcher优化,通常会显示为Draw Mesh (SRP Batcher),并且多个使用不同材质但相同Shader变体的物体会被合并到一个事件块中。

5.2 粒子系统与线渲染器的陷阱

  • 粒子系统(Particle System):每个粒子系统通常是一个独立的渲染器。大量的小型粒子系统会产生大量Batches。解决方案:
    • 对于不需要独立模拟的静态粒子效果(如星光、尘埃),考虑合并到一个大的粒子系统中,或使用一个MeshRenderer配合顶点动画Shader来实现。
    • 确保粒子材质尽可能相同,并使用MaterialPropertyBlock来修改颜色等属性。
  • 线渲染器(LineRenderer)与轨迹渲染器(TrailRenderer):它们通常难以合批,且每帧需要更新顶点缓冲区,开销较大。应严格控制其数量和使用时长。

5.3 后处理与全屏特效的影响

屏幕后处理(如Bloom, Color Grading)和全屏特效(如全屏扭曲、雨滴)虽然不直接增加物体渲染的Batches,但它们本身是额外的全屏绘制Pass,会增加GPU的总体负载。在移动端,应谨慎使用或使用性能开销更低的简化版本。在URP中,可以通过调整后处理渲染器的分辨率或禁用某些效果来平衡画质与性能。

5.4 阴影与光照的合批考量

  • 实时阴影:投射或接收实时阴影的物体,可能会因为阴影绘制所需的额外Pass而无法与不参与阴影的物体合批。在移动端,应优先使用光照贴图(Baked Lightmap)来替代实时阴影。
  • 光照探针(Light Probes):动态物体使用不同的光照探针组合(Light Probe Proxy Volume)也可能影响合批。尽量让需要合批的动态物体处于相似的光照环境中。

5.5 常见问题排查清单

当你发现Batches或SetPass Calls异常高时,可以按以下清单排查:

问题现象可能原因排查工具与解决方法
Batches高,SetPass Calls同样高几乎没有发生任何合批,每个物体都是单独绘制。Frame Debugger:查看相邻绘制事件,确认中断原因(不同材质、Shader变体、渲染状态)。解决:合并材质,统一Shader,检查缩放是否为负值。
Batches低,但SetPass Calls依然高合批有效,但批次之间的渲染状态切换频繁。Frame Debugger:查看每个Batch之间的状态变化。解决:调整物体或材质的渲染队列(Render Queue),让使用相同状态的物体连续渲染。检查透明物体渲染顺序。
静态物体合批无效物体未标记为Static,或标记为Static但材质不同。Inspector:确认Static勾选。Frame Debugger:查看静态物体绘制事件,确认是否显示为“Static Batching”。解决:标记Static,合并材质。
GPU Instancing不生效MeshRenderer未启用GPU Instancing,或Shader不支持。Inspector:勾选Enable GPU InstancingShader:检查是否包含#pragma multi_compile_instancingUNITY_INSTANCING_BUFFER_START等相关代码。
UI卡顿,Batches不高Canvas重建(Rebuild)开销大,或存在大量透明重叠。Profiler:在CPU性能分析中查看Canvas.SendWillRenderCanvases耗时。解决:拆分Canvas,静态和动态部分分离。禁用不必要的Raycast Target。检查UI元素是否过度重叠导致重绘。
移动端帧率波动大除了渲染,可能还有GC(垃圾回收)、物理、脚本逻辑等原因。Profiler:连接真机进行深度分析,查看CPU和GPU各区域的耗时峰值。关注GC.Collect的调用。解决:对象池化,避免每帧分配内存;优化复杂脚本逻辑。

优化是一个持续的过程,没有一劳永逸的银弹。核心思想是建立数据驱动的优化习惯:先测量(Stats, Frame Debugger, Profiler),再定位(找到最大的开销源),最后实施有针对性的优化(合并、剔除、简化)。忘掉对单一数字的迷信,建立起对渲染管线全局的理解,你才能真正驾驭Unity的渲染性能,让项目在各种设备上都能流畅运行。

← 返回列表