拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity渲染优化:看懂状态切换,把SetPass Calls压下去

Unity渲染优化:看懂状态切换,把SetPass Calls压下去

你有过这种经历吗?项目做到中后期,功能不增不减,场景也谈不上多豪华,突然一夜之间帧率掉了一半。我遇过最典型的一次:一辆拖车,上面堆了六十多个“长得一模一样”的货箱,美术同学为了调色方便,每个箱子都点了“Instantiate”生成了一份独立材质。运行时Profiler一看,Draw Call不多不少,但“SetPass Calls”飙到了每帧一百多次,GPU有一半时间花在了“换笔”而不是“画画”上。

这里说的“换笔”,就是Unity渲染优化里常被一笔带过、实际却极其昂贵的状态切换(State Change)。GPU画一个物体,不光是“往屏幕上扔几百个三角形”那么简单,它必须按照当前设定的完整渲染状态来执行:用哪一段顶点数据、配哪个Shader、采样哪几张纹理、按什么方式混合、要不要写深度、模板值是多少……这些状态构成了一组“当前画笔”,只要其中任何一项发生变动,GPU就必须放下手里还没排完的活,先把新的状态装配好。

这篇文章,我想把这件很多人听了一百遍、却很少真正搞懂的事说透:状态切换到底是什么、为什么它比想象的贵、是谁在一帧里不断制造切换,以及怎么用Static Batching、GPU Instancing、SRP Batcher三套主流方案把它压下去。整个过程会配合Frame Debugger和Profiler的实操排查,适合正在做移动端优化、PC大世界搭建,以及被Unity批处理问题反复折磨的开发者、技术美术和经验不算长的项目主程阅读。

1. 我们到底在优化什么:先把渲染状态说清楚

1.1 一次Draw Call不只是一次“绘制”

状态切换听着抽象,我先举个更贴近生活的类比。

把GPU想象成一条全自动的流水线厨房。厨师能同时做好几口锅(并行),但每一道菜都有自己的配方:放多少油(混合模式)、用哪把刀(Shader)、用哪种食材(纹理)、按什么火候(深度/模板状态)。一道菜做完、换下一道菜的时候,如果配方完全一样,厨师只需要换个食材丢进去,灶台都是热的,动作非常快。可一旦配方变了,比如从红烧变成清蒸,他可能要换锅、洗锅、重新调火候、换一套调料罐,这段“换配置”的时间,就是状态切换的开销。

在Unity和底层图形API的语境里,GPU的渲染状态通常分为“通用状态”和“专用状态”。

状态类别示例影响面
通用状态渲染目标(RenderTarget)、深度缓冲、视口切换渲染目标时会触发较大的管线排空
专用状态当前Shader、顶点布局、纹理绑定、混合/深度/模板设置每次切换都会影响后续Draw Call的执行方式

很多老教程会跟你说,优化就是“减少Draw Call”,这句话不能说是错的,但它把因果关系搞反了。我们真正想减少的并不是“调用次数”这个数字,而是因为每次绘制前改变状态而被迫付出的“装配成本”。如果十个物体用同一个材质、同一段纹理,Draw Call再多,GPU也能像流水线一样连续吞下去;反过来,哪怕每帧只有二三十次Draw Call,只要每个物体都绑定了不同的材质和纹理,GPU每画一个就要拆一次管线,帧率照样会被拖死。

1.2 “SetPass Calls”才是核心指标

所以在整个渲染优化体系里,我从来不看裸的Draw Call数量,而是看两个衍生指标:Batches和SetPass Calls。

  • Batches:引擎交给底层API时按合批情况计算的绘制批次,可以粗略理解成“最终提交的Draw Call”数量。
  • SetPass Calls:每一帧里GPU真正切换渲染状态(主要是Shader/Pass切换)的次数。

这两个数字的关系,在项目优化的不同阶段差别很大。早期场景杂乱,Batches和SetPass Calls几乎同步上涨;等做了合批之后,Batches可能掉得很快,但SetPass Calls不一定同步下降——因为有些合批只是把顶点缓冲合并了,切换Shader/材质的次数并没有减少。只有SetPass Calls也压下来了,才说明状态切换带来的开销真正被控制住了。

用Frame Debugger或Profiler的Rendering模块看这两个数字的时候,我希望大家聊的不是“我降了多少Batches”,而是“我降了多少SetPass Calls”。这就像做性能预算,面数当然重要,但真正决定腰包的是每帧的装配次数。

2. 一次状态切换的隐性成本:CPU、驱动层和GPU管线重建

2.1 三层视角理解切换开销

状态切换的昂贵之处,要分三层看:Unity引擎层、图形API驱动层、GPU硬件层。

Unity每帧渲染时,会先把一堆渲染命令整理进命令缓冲,再批量提交到底层API。如果一个物体需要切换材质,引擎就得在命令流里插入一次“Shader变更”和“状态绑定”的指令。这些指令本身不重,但它会打断一部分批处理逻辑——比如原本连续提交的材质相同的命令,中间插进另一套状态后,后续相同材质的命令也不一定能合并了。也就是说,一次状态切换不只是“切换那一下”的费用,还会在排序和合批阶段产生连锁反应,把本来能合批的批次拆碎。

到了驱动层,开销更明显。现代图形API普遍采用状态机模型,驱动内部要做“差异比较”,确认哪些状态确实发生了变化、哪些还能复用。一部分驱动还在CPU侧维护着一套状态缓存,状态变更时先查缓存,命中则跳过验证,不命中就要做一遍完整的参数校验。再往上,如果发生渲染目标切换、纹理重新绑定,驱动还可能请求CPU和GPU同步,等待GPU排完当前的流水线才能继续往外发指令。这一步一旦发生,CPU会像高速路上突然踩刹车一样,出现明显的stall。

GPU端的代价最容易被忽视。GPU拿到一个Draw Call之后,要先确认管线上所有阶段是否就绪:顶点着色器、光栅化、片段着色器各自的绑定对不对,纹理是否已上传、缓存是否命中。状态一变,管线里正在执行的并行阶段可能要做无效化处理,等于才热好的锅又凉了一半。尤其在不同Shader之间切换时,GPU往往需要重新填充着色器单元,这段停顿通常比连续绘制几个同材质的物体还要耗时。

2.2 为什么“现代API不鼓励频繁切换”

这也是为什么DirectX 12 / Vulkan这类现代底层API把很多状态打包成了PSO(管线状态对象),要求你尽可能把一组完整状态“预编译”好,然后用很小的句柄切换。直接的好处是:驱动层不再需要在绘制时逐项校验上百个参数,GPU硬件也能更快地锁定目标管线状态。你去看VR或者高性能端游的优化清单,里面几乎都有一条:避免在热渲染路径里创建或切换PSO。

Unity的SRP Batcher可以说就是把这种思路搬到了引擎层。它把材质数据集中到一个统一的缓冲区,切换材质时不再重配整条管线,只更新缓冲区里的数据,从而大幅降低每次材质切换的装配开销。这是后面会讲到的重点。

3. 谁在制造状态切换:材质、纹理、Shader变体和绘制顺序

3.1 材质:最容易被忽视的“实例爆炸”

绝大多数状态切换,罪魁祸首不是Mesh,而是材质。我翻了太多项目的Frame Debugger,发现合批失败的常见原因都是同一个:运行时有太多“同一个长相、不同内核”的材质实例。

典型场景就是美术在编辑器里给某个Prefab换色,右键改了参数后顺手把整个Prefab复制了五六十份。每个实例都持有一份自己的材质,其实它们原本是同一个Material Asset。运行时你看到的会是五六十份材质,每份还可能连纹理都不同。GPU每画一个箱子都要重新装配,请问怎么顶得住?

避免材质实例爆炸的第一原则:不要到处改renderer.material。在Unity里,拿材质推荐用.sharedMaterial获取共享材质;如果你只是想让某个实例偏色、换金属度,正确的做法是MaterialPropertyBlock。它能让同材质下不同实例的渲染参数保持差异,而不用真正克隆出新的材质资源。

如果你能用代码给个示例,会更直观。通常我会在最开始写一个公共的初始化方法,把差异数据的传递全部收敛到PropertyBlock上:

private static readonly int ColorProperty = Shader.PropertyToID("_Color"); var block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block); block.SetColor(ColorProperty, targetColor); renderer.SetPropertyBlock(block);

千万不要在循环里写renderer.material.color = ...。material属性每次访问都会让引擎复制一份材质实例,循环五十次,场景里就多五十个状态元凶。这个坑我见过太多团队掉进去,而且往往发生在完全不自知的情况下。

3.2 纹理:换一张图,可能比你想的贵

很多人以为“纹理切换”就是换一张图片那么轻巧。实际上,纹理绑定包括几件并不轻的事:上传纹理数据、绑定采样器状态、设置索引/层/Mip Bias、挂到对应着色器阶段。绑定一旦切换,GPU的纹理缓存命中率也会受影响,尤其当两批物体分别使用两张完全不同、在内存中不连续的贴图时,缓存基本等于重新热身。

这也是“图集(Atlas)”和“纹理合批”的核心用意:让几十个小对象共享一张大图,这样它们即使每个都有自己的UV区域,仍能挂到同一条纹理状态上,整批绘制的过程中根本不需要切图。

我见过一个很典型的移动端项目,UI上每个按钮都单独一张贴图,一个界面一百多个控件,每帧产生上百次纹理切换。后来美术把同屏控件全部打到一个1024图集里,Draw Call没怎么变,但SetPass Calls从一百多降到了二十几,帧率立刻稳了下来。类似的事放到3D场景也一样:不要把同一区域内风格相近的物件,拆到七八张完全不同的Albedo贴图里。

3.3 Shader变体和Pass:状态切换的“开关”

材质一样不代表状态一样。同一个Shader里的关键词(Keyword)、多个Pass、不同LightMode都可能在底层解开成完全不同的管线状态。

先说Pass。通常我们写Shader会写多个Pass,比如描边、镜面反射、后处理效果。每个Pass对GPU来说都是一套独立的管线状态。而且Unity会在渲染流程中穿插不同LightMode的Pass(如ShadowCaster、DepthOnly、ForwardBase等),这些Pass之间切换同样算状态切换。一个物体哪怕材质完全相同,只要跑两个Pass,GPU也要停两次。

再说关键词。我们用#pragma multi_compile开启雾效、描边、溶解这类开关时,如果没做变体剔除(Variant Stripping),最终生成的Shader变体数量会非常可观。每切一个变体,GPU可能就要重新装配一份状态。常规做法是:把多个变体尽量拆成运行时统一判断的常数,减少真正需要在同帧里切换的关键词维度;然后在Project Settings里剔除掉用不到的组合。

3.4 绘制顺序:你手里的排序权

大部分新手不知道,Unity的批处理对“顺序”特别敏感。同一个材质的一批物体,中间只要插进一个不同材质的物体,就会断成两批。因此,渲染顺序的规划是减少状态切换里成本最低,也最容易立刻生效的手段。

在Editor里,Unity默认按渲染队列(Render Queue)、深度排序、材质ID、距离排序决定绘制顺序。到了自写渲染管线,你可以把Renderer.SortingLayer和SortingOrder用在特定层级的控制上;更彻底的办法是拿Camera的opaqueSortMode来控制不透明物体的排序优先级,比如按材质优先、距离次之,让同材质的物件尽可能连续绘制。

一个在强耦合项目里很实用的经验:把同屏物体的渲染顺序分成“材质组”——先画同一套材质的大地面,再画同一套材质的墙体,再画同一套材质的角色。只要每组内部别乱插,即使不是同材质,组间切换次数也相对可控。这块看起来没有技术含量,收益却往往立竿见影,属于典型的“先排序再合批”。

4. 减少状态切换的三种武器:Static Batching、GPU Instancing与SRP Batcher

4.1 Static Batching:把同一状态焊死

静态合批是解决“不可移动物体”状态切换的办法之一。原理不复杂:把场景里所有标记为Static且材质相同的Mesh,在构建时合并进一个大Mesh,绘制时只需提交一次Draw Call和一次状态绑定。

启用方式很简单:选中GameObject后,在Inspector右上把Static开关打开;也可以代码里设置gameObject.isStatic = true。要注意的是,编辑器里运行和真机发布的合批效果并不完全一样:编辑器下Unity会在内存里动态组合,游戏包里则会在构建阶段预合并。两者的渲染结果基本一致,但内存占用和预处理时间可能有差异。

Static Batching的代价是内存。为每个标记静态的物体做一个合并网格的副本,意味着其顶点数据会被复制一份到合并结果中,而且合并后的网格不能做逐物体的局部变换。所以别把“所有东西都勾Static”当成万能解药。我的经验是:地形、墙体、道路这类永远不会动的物件适合静态合批;可以破坏倒塌的箱子、能开合的柜门这类动态物体就不应该参与,否则一动它,整个合并组就要切出来重画,反而更亏。

4.2 GPU Instancing:动态重复物体的最优解

如果物体很多且重复度高,比如草、石头、人群,靠静态合批是不现实的——它们往往需要动,或者数量大到根本无法合并成一个渲染单位。这时候用GPU Instancing。

GPU Instancing的核心是把“相同Mesh + 相同材质 + 相同Shader”的多个实例合并进一次Draw Call,每个实例之间的差异(位置、旋转、缩放、颜色等)作为实例数据传给GPU,由GPU根据实例ID分别执行。由于管线状态完全一致,它在减少状态切换方面的收益非常高。

实操时需要注意,GPU Instancing不是一个勾选就能自动起效的功能:

  • 首先,需要在材质Inspector的顶部勾选Enable GPU Instancing。如果Shader不支持实例化,这个按钮会变灰。
  • 其次,写自定义Shader时要引入UNITY_INSTANCING_BUFFER_START/END之类的宏,把每实例属性(如_Color)打包到实例化常量块里。
  • 第三,如果实例之间确实有不同的颜色、缩放,不要直接改共享材质,用MaterialPropertyBlock给渲染器传差异数据。

为什么这里反复强调MaterialPropertyBlock?因为如果直接复制材质,你就又回到了“材质实例爆炸”的怪圈,Instancing层面的合批也会被打断。我自己踩过一回:一个跑酷项目的路面和护栏,材质是共享的,代码却给每个预制体set了一个_Color属性的克隆材质,结果GPU Instancing根本没生效,Frame Debugger全部碎成了单Draw Call。改成PropertyBlock之后,Batch数立刻降了一个数量级。

4.3 SRP Batcher:URP/HDRP下的主流强援

SRP Batcher是Unity在Scriptable Render Pipeline时代提供的批处理机制,目标是直接降低“材质切换”时最重的那些开销。它的实现思路跟PSO类似,把材质属性集中到一块名为UnityPerMaterial的CBUFFER里。在切换共享Shader但参数不同的材质时,SRP Batcher不需要重新装配几乎所有状态,只需要更新数据缓冲即可,管线主体保留着原样。

启用很简单:选中URP/HDRP的Asset,打开SRP Batcher开关。之后在Frame Debugger里你能看到合批条目被标注成“SRP Batch”。但要注意几个常见坑:

  • 自定义Shader如果不遵守CBUFFER约定,SRP Batcher会自动跳过它,且不报错。
  • 如果你在Shader里用了内置管线的_MainTex/_Color这类变量而不放在CBUFFER_START(UnityPerMaterial)内,合批可能失效。
  • 出现在Properties里但没进CBUFFER的变量,会变成单独的新UV/属性通道,状态照样要切换。
  • 如果你的场景里本来就有大量不同Shader,SRP Batcher并不会帮你解决它们之间的切换,它擅长的是“同Shader不同参数”这一维度的优化。

所以一套下来,优先级一般是这样:先保证共享材质,再让共享Shader的数量尽量少,然后用PropertyBlock做实例差异,最后才用SRP Batcher收尾。在URP项目里,SRP Batcher和GPU Instancing是可以叠加的,前者管“材质间切换”,后者管“同材质多实例”,两者合在一起效果最好。

4.4 三个方案怎么选:一张表说话

方案解决的切换类型适用对象主要代价/限制
Static Batching同材质物体的“多次装配”合并永不移动的静态物体,如地形、墙体内存翻倍、不能拖动、构建时间增加
GPU Instancing同材质同Mesh多实例重复出现的动态物体,如草、石头、人群Shader需支持、网格不能混合、PropertyBlock注意写法
SRP Batcher同Shader不同材质时的高开销切换URP/HDRP项目中的材质内部属性切换仅适配SRP、Shader要符合CBUFFER约定

在做一个大型场景时,我不建议一开始就三个同时上。正确的步骤是:先看看哪段路径上状态切换最密集,然后按切换类型选一个主方案。大多数项目的瓶颈集中在动态小物件重复度高,或静态烘焙物体的材质杂乱,你只要找准主因,往往一个方案就能解决大半。

5. 用Frame Debugger和Profiler定位真凶:一组完整排查链路

5.1 上手:打开Frame Debugger,逐层拆解

状态切换的问题光靠猜是永远猜不出来的。我最常用的工具组合是Frame Debugger + Profiler的Rendering模块。

  • 在编辑器里:Window > Analysis > Frame Debugger,点Enable。
  • 左侧出现每一帧的绘制事件树。逐级往下点,可以看每个Draw Call之前的“绑定”记录,以及它合批前的位置。
  • 关键是右侧的“Batch Break Cause”(合批中断原因)。它会直接告诉你:Different Material、Different Mesh、Different Shader、Transform change等,每一句都是状态切换的直接证据。

5.2 实测案例:如何从45次SetPass压到9次

我放一个最近在优化中实际跑过的案例,参数可以给你做参考。

场景很简单:一片铺满草地的野外地块,上面有三种箱子、两种地面、若干角色、一堆可拾取的小药瓶。

第一版Frame Debugger数据:

  • Batches:约420
  • SetPass Calls:约45
  • 帧耗时:平均14ms,肉眼可见的卡顿

排查的第一刀,是从Frame Debugger里把所有Different Material的断点数了一遍,发现其中大量是同一份“箱子材质”被实例化出的几十个副本。回去翻代码,确认是某段初始化逻辑里用GetComponent<Renderer>().material.color = ...改颜色造成的。把这里的.material改成MaterialPropertyBlock之后:

  • Batches:约210
  • SetPass Calls:约31

第二刀,场景里的小药瓶有六十多个,网格一样、材质一样,但没有任何合批痕迹。检查后发现它们的Shader没启用GPU Instancing的关键词,且材质上的Enable GPU Instancing没勾。勾上、清理掉多余材质后:

  • Batches:约60
  • SetPass Calls:约12

第三刀,把整个渲染管线切到URP,开启SRP Batcher,再把地块静态物件的Static勾选补上:

  • Batches:约25
  • SetPass Calls:约6
  • 帧耗时降到大约5ms,30帧稳定毫无压力。

这个例子不是说每个人都能复现同样的数字,而是想告诉你:状态切换是可以被逐层拆掉的,且拆的顺序非常重要。先查材质实例爆炸,再查Shader变体和Instancing开关,最后考虑静态合批和SRP Batcher——一个萝卜一个坑,按这个顺序走最不容易做无用功。

5.3 别被编辑器骗了:真机数据才可信

Frame Debugger在编辑器里是逐帧回放的利器,但它并不代表真机实际运行的数字。编辑器下的GPU和内存行为与真机差得远,尤其是移动端,CPU侧的状态切换开销、GPU PSO缓存行为都不一致。所以我的习惯是:先用Frame Debugger在编辑器里定“原因类型”,再真机上用Profiler抓Rendering.SetPass Calls、Draw Calls和GPU时间线,做二次确认。

真机采样时还要留意一个问题:Unity的Profiler默认以脚本生命周期为分帧单位,GPU时间并不总是和脚本帧完全对齐。如果需要更精确的数据,建议打开Deep Profiling,或者在Player Settings里把Profiler连接和录制选项调整干净,保证采样数据尽可能稳定,避免误判。

6. 把优化固化到项目规范:材质、纹理、Shader与团队习惯

6.1 材质规范:从源头掐死实例爆炸

优化的尽头是规范,没有规范的技术优化早晚会被新加的需求打回原形。我在团队里最常强调的三条材质铁律是:

  1. 运行时禁止直接给renderer.material赋值或修改属性,统一走MaterialPropertyBlock。
  2. 每个资源接入时,检查材质引用是否落在公共材质库内,不在库内的材质必须写明理由。
  3. 同一资源的所有变体,尽量合并成一张材质并使用参数区分,而不是复制出三五个Material Asset。

这三条听着机械,却是最容易在半年后救你于水火的东西。一个项目到后期,性能问题有一半以上不是开发时的技术选型错误,而是平时看似无害的“临时改一下”积累出来的。

6.2 纹理与图集:同局面料,能共则共

纹理层面的规范,核心是“同屏同组共图集”:

  • 把同场景、同区域、同风格的小物件贴图尽量打进同一张图集,避免同一批合批对象里出现两张完全不相关的贴图。
  • 图集不是越大越好,还要考虑内存占用、Mipmap开销和UV边界问题。移动端常用1024~2048上限,特殊大纹理单独评估。
  • 不要为了省事把UI和3D物件混进同一张图集,它们的过滤、压缩和渲染队列要求完全不同。

顺带提一句,带透明通道的图集要特别留意Alpha slice和padding问题。图集打得太满,相邻两张子图在某些GPU的采样插值下会出现半透明的“出血边”,反而让你不得不为个别物件单独拆图,状态切换又回来了。留足pad区域,一次做对,后面省心。

6.3 Shader规范:少关键字,少Pass,兼容SRP

Shader层面的关键动作包括:

  • 在上线前用Shader Variant Stripping把不需要的multi_compile组合剔除掉,能明显减少运行时的变体选择成本。
  • 自定义Shader统一按URP/HDRP的CBUFFER规范书写,确保SRP Batcher能覆盖到你手上的每一个材质。
  • 少用“为了某个小功能而多写一个Pass”的方案,能合并到主Pass的尽量合并。

这里我想多提醒一句:很多人写自定义Shader时只关心画面效果,不看Frame Debugger里这个材质有没有被打上“NOT SRP BATCHABLE”的标签。这个标签一旦出现,说明你前面的所有批处理努力对这个材质全部失效。养成写完Shader就打开Frame Debugger看一眼的习惯,比事后追责省十倍时间。

6.4 团队机制:性能预算不是口号

最后从团队角度说说怎样让状态切换优化长期有效。

  • 像做内存预算一样做渲染预算:定出每帧SetPass Calls的目标值(比如移动端要求60FPS时SetPass Calls不高于15次,可放宽场景除外),然后写进项目文档,每周检查一次。
  • 真机性能测试不要只在发布前做,建议在CI里或用一个定时任务跑固定关卡,采集Batches、SetPass Calls、帧耗时,异常即报警。
  • 新场景评审时,技术负责人至少要看一眼Frame Debugger,确认所有预制体没有共享材质被复制、没有私自启用多Pass、没有明显冗余的纹理切换。

这些机制都不难,难的是坚持。但经历过一两次“上线前两周被逼着重写材质管理”的团队,自然会明白规范的价值。

最后说一点个人的体会。大概三年前,我接手一个中大型模拟经营项目时,对状态切换还没有现在这么敏感。后来有次接了个2D UI渲染混乱的任务,在Frame Debugger里连续翻了三天,突然意识到:很多时候我们不是缺优化技巧,而是缺一种“把问题按状态归类”的直觉。现在只要进入任何一个Unity项目,我第一件事先打开Frame Debugger看Batches和SetPass Calls的比值,一旦SetPass Calls明显高于材质数量,基本就能断定存在某种状态闪变——再来回定位,往往两三刀就能解决。希望这篇内容也能帮你少走一段弯路,把自己项目里那些“看不见的换笔”真正拎出来。

返回列表