
Unity渲染优化减少状态切换的秘密有朋友跟我抱怨过一件事场景里明明只有百来号物体跑起来却卡得不行Profiler一开发现SetPassCall直接飙到800多。这问题听起来像是物体太多其实很多时候跟物体数量半毛钱关系都没有问题出在状态切换上。今天这篇就围绕Unity渲染优化中的状态切换展开聊聊它到底是什么、怎么定位、怎么压下去。适合以下几种人看刚入行但被Profiler绕晕的初级开发、项目上线前为发热发愁的移动端主程、以及想搞清楚合批和状态切换到底是什么关系的渲染爱好者。看完你会明白很多时候性能问题不是画得太多而是换状态换得太勤。1. 状态切换到底在切什么想优化一个问题先得知道它到底发生在哪一环。我们可以把一次完整的渲染调用拆成CPU端和GPU端两个视角来看。1.1 CPU视角从GameObject到DrawCallUnity的渲染工作可以分为三大步剔除Culling、排序Sorting、提交Submit。每一帧CPU都会拿摄像机做一次视锥与遮挡剔除然后把活下来的物体按照渲染队列排好序再逐个交给底层图形API画出来。这里有个新手容易忽略的点Unity提交给GPU时底层API不是一次性画完所有东西而是按材质、按Shader、按渲染状态分成一段一段地提交。每切一次材质或Shader就会多一次所谓的状态切换成本。体现在Profiler里就是SetPassCall的数量。你可以把SetPassCall理解为CPU打包好一批活儿交给GPU干的过程中被强制打断重来的次数。每一次打断CPU都要重新设置一遍Shader、混合模式、深度测试开关、各种RenderState字节。这些设置动作本身没那么贵但架不住频繁而且它是串行的——GPU没法在这个设置过程中干活管线就被堵住了。1.2 状态切换的三个层次我把状态切换按代价分成三档方便你心里有数Batch切换从一个材质球切到另一个材质球。哪怕两个材质球用的Shader完全一样只要不是同一个材质球实例Unity也会切换。代价中等。Pass切换同一Shader内从Pass 0切到Pass 1。比如阴影Pass、描边Pass、透明度Pass之间的切换。每个Pass都意味着GPU重新走一遍渲染状态设置。代价较高多Pass Shader是合批杀手。RenderState切换同材质但需要改混合模式、深度写、Cull模式等。这种切换最隐蔽通常发生在你写了带Tags变体的Shader时。代价高且不好查。很多项目卡了就是卡在第三层——你看到DrawCall不高但GPU片段着色器负载莫名其妙地高其实就是状态切换导致GPU没法长时间保持同一管线流水线被打断后一直处于冷启动状态。1.3 为什么状态切换是隐藏的性能黑洞举个最直观的例子。假设场景里有100个相同Shader的物体理论上它们可以合批成1个DrawCallSetPassCall也只需1次。但如果你不小心把它们拆成了50个材质球——哪怕这50个材质球的参数完全一样——Unity也没法合批因为材质球是引用类型引擎按实例判断合批条件。这样一来DrawCall变成50SetPassCall变成50。美术写了一个Shader变体很多的功能节点你以为是同一个Shader可运行时因为关键字组合不同编译出了几个甚至几十个变体每个变体都可能带来Pass切换和RenderState切换。你看Profiler时变体现在已经悄悄把你的合批掐断了。所以我跟人聊性能问题时总是先问一句你的SetPassCall和Batch差多少如果两者相差很大说明每个Batch内部有过多Pass切换或RenderState切换这种情况比单纯DrawCall高一倍更让人头疼。2. 减少状态切换的经典手段与实现逻辑理解了状态切换的代价之后接下来就是落地优化。这一节我按移动端和PC端都适用的顺序把这几年最有效的手段一个个说清楚。2.1 排序让状态切换次数降到最低很多开发者不知道其实Unity自身的排序逻辑已经帮你做了最基本的优化它会把同材质的物体排在一起减少材质切换次数。但如果场景里有穿插的不同材质物体比如A材质物体和B材质物体交错摆放Unity也没办法因为要保证前后面顺序正确它只能来回切换。所以从项目初期就养成一个习惯同材质的物体尽量放在一起特别是前景与背景混排时。这不是玄学是排序算法决定了顺序。你可以在Inspector面板里勾选Sorting Layer和Order in Layer来影响同一层级内的渲染顺序也可以用透明的Renderer Sorting调整不同层级的整体顺序。实际操作时我一般会把环境模型拆成几组地面一组、墙体一组、装饰物一组每组内部尽量保证材质种类少、分布集中。2.2 静态合批与动态合批什么时候用哪个Unity提供两套自动合批方案但它们有明确的条件限制用对了能减掉大量SetPassCall用错了反而增加内存。静态合批Static Batching适合完全不动、且顶点数不太多的物体。它的原理是把这些物体合并成一个共享网格一次性上传给GPU然后通过合并后的网格批次渲染。代价是内存开销大——每个参与静态合批的物体都会多一份顶点数据拷贝所以大型地形或者超多顶点模型慎用。动态合批Dynamic Batching则适合移动且Share同一材质的小物体比如小石头、子弹、掉落物。它会在CPU上每帧做顶点变换合并顶点数超过900移动端会更低的物体直接失效。如果你在Profiler里看到Dynamic Batching已开但动态合批数量还是0多半是因为模型顶点数超了或者Shader不满足合批条件。我的习惯是大面积复用且静止的用静态合批小体积且移动的用动态合批大批量复用的用GPU Instancing。2.3 GPU Instancing大规模复用物体的最优解GPU Instancing的原理是一次提交一个网格引用然后让GPU使用不同的变换矩阵和材质属性批量绘制多个实例。相比动态合批它能支持更大的顶点数量而且减少了CPU侧的每次变换计算。最典型的场景是一片草、一堆石头、一群敌人。用起来非常简单在材质球Inspector面板勾选Enable GPU Instancing。Shader中要显式声明#pragma multi_compile_instancing并且在顶点/片元着色器中使用实例化相关的宏。在C#里用RenderMeshInstanced或者直接用Graphics.DrawMeshInstanced提交大量Mesh和矩阵列表。这里有个大坑如果你自定义Shader忘了第一步合批就永远生效不了。另外要注意Instancing只对同一个Mesh、同一个材质有效换了材质球就废了。实操中我还发现有些项目的Shader带顶点色或UV2Lightmap用的UV这会让Instancing无法触发——因为Instancing只支持固定的属性数组额外属性需要手动声明UNITY_INSTANCING_BUFFER_START之类的宏。别以为官网文档没提就没事我踩过两次。2.4 SRP BatcherURP/HDRP下的终极利器如果你用的是URP或HDRP那就必须把SRP Batcher当成必选项。它和上面三种合批路径都不一样——它的思路是缓存材质属性数据让每次绘制只要提交少量变化数据就可以复用同一套RenderState设置。官方对它的描述是减少状态切换的直球优化它把每个材质球的Shader属性颜色、向量、浮点数提前打包上传到GPU内存中当切换同一条管线同一个Shader变体组合时只需要更新变化的属性块而不需要重新设置整条Shader管线。启用方式很简单Project Settings → Graphics → URP Asset → 勾选SRP Batcher。只要Shader兼容SRP Batcher它就会自动接管合批。不兼容的情况主要有三种Shader用了多Pass、材质球属性使用了SRP不支持的属性类型、或者Shader我们手动写了大量自定义结构体但不满足SRP Batcher的布局要求。怎么确认是否生效打开Frame Debugger查看DrawCall列表里出现了SRP Batch字样并且数量在100以上说明生效了。3. 一套实操流程定位和分析状态切换问题光讲理论不落地等于没讲。这里我拿一个我经手的实际项目来复盘——一个三人开发的2.5D塔防小游戏Unity 2022.3 LTSURP管线目标平台是iOS和Android中等画质。项目初期Profiler数据惨不忍睹DrawCall约400SetPassCall约520帧率在骁龙778G上只有35帧左右。3.1 第一步用Profiler和Frame Debugger找瓶颈打开Window → Analysis → Profiler切到Rendering模块查看SetPassCall、DrawCall、Batches三个指标。如果SetPassCall明显高于DrawCall就得深挖。我习惯配合Frame Debugger一起看它能记录整帧渲染的所有事件并显示每个事件对应的Shader、状态、以及是什么物体。这里有个小提示Frame Debugger在移动设备上不好抓我一般先在Editor里跑一遍再拿到真机上细看。Editor的显卡和移动端差很远但状态切换的相对比例是接近的——如果一个材质在Editor里占用的SetPassCall数量最多真机上也大概率是它。我当时的排查结果场景里有三组高密度的重复模型——小兵、障碍物、地面装饰它们分别使用了不同材质球而且每个材质球还做了颜色微调。这直接导致整帧无法合批同一网格却因为材质球差异被拆成了几十个Batch。3.2 第二步用脚本统计和归类材质球除了Profiler我还写了个小编辑器脚本扫描场景中的所有Renderer组件统计每个Mesh使用了多少个不同的材质球。这个脚本价值巨大因为很多项目美术规范不严格同一个型号的树散落在场景里时被赋予了七八个材质球光看Profiler很难意识到这点。以下是脚本的核心思路using UnityEngine; using UnityEditor; using System.Collections.Generic; public class MaterialDupFinder : EditorWindow { [MenuItem(Tools/Material Dup Finder)] public static void OpenWindow() { GetWindowMaterialDupFinder(材质重复扫描); } private Vector2 scrollPos; private DictionaryMesh, Liststring meshMaterials new DictionaryMesh, Liststring(); private void OnGUI() { if (GUILayout.Button(扫描当前场景)) { ScanScene(); } scrollPos GUILayout.BeginScrollView(scrollPos); foreach (var pair in meshMaterials) { if (pair.Value.Count 1) { EditorGUILayout.LabelField(pair.Key.name → pair.Value.Count 个材质); } } GUILayout.EndScrollView(); } private void ScanScene() { meshMaterials.Clear(); var allRenderers FindObjectsOfTypeMeshRenderer(); foreach (var r in allRenderers) { var mesh r.GetComponentMeshFilter() ? r.GetComponentMeshFilter().sharedMesh : null; if (mesh null) continue; if (!meshMaterials.ContainsKey(mesh)) meshMaterials[mesh] new Liststring(); foreach (var mat in r.sharedMaterials) { string matName mat ? mat.name : null; if (!meshMaterials[mesh].Contains(matName)) meshMaterials[mesh].Add(matName); } } Debug.Log(扫描完成共发现 meshMaterials.Count 个Mesh其中 CountDuplicateMesh() 个Mesh存在多个材质球。); } private int CountDuplicateMesh() { int count 0; foreach (var pair in meshMaterials) { if (pair.Value.Count 1) count; } return count; } }扫描结果很扎心场景里一共180个MeshRenderer其中约60个Mesh被分配了超过8个不同材质球。而这些材质球之间的差异大多只是颜色或金属度调了一点点对渲染结果的影响可以忽略不计。3.3 第三步合并材质球和优化Shader变体接下来就是动手。优化原则很简单同一Mesh尽量只保留1个材质球颜色差异通过顶点色或者MaterialPropertyBlock解决。对于需要微调颜色的物体我用MaterialPropertyBlock来实现——它允许你在不创建新材质球的情况下为每个实例覆盖颜色等属性。配合GPU Instancing这种做法不会破坏合批。Shader这边我裁剪了一堆用不到的关键字。打开Inspector里的Shader面板可以看到当前Shader启用的变体关键字列表。很多项目从商店买的Shader自带几十种效果开关实际运行时只用到其中三四种。用#pragma multi_compile拆出来的变体不参与实际计算但会导致Shader内容变大、切换变慢。我一般保留主版本和阴影两个变体其余的通过shader_feature控制。3.4 第四步改造后的效果对比经过上面三个步骤最终效果指标优化前优化后变化比例DrawCall41092-77.6%SetPassCall520143-72.5%Batches460110-76.1%帧率(骁龙778G)355865.7%发热程度非常明显轻微温热明显改善最大的感受是发热问题好太多了。之前的项目跑到5分钟左右边框温度直接上到42度优化后基本维持在38度左右。这在移动端项目里绝对是体验分水岭级别的提升。4. 常见问题与排查技巧实录做渲染优化这种活儿一半时间是跟意料之外较劲。这里我整理几个高频问题和对应的排查思路帮你少走弯路。4.1 为什么开了合批SetPassCall反而更高了这类情况我遇到不下三次。排查后发现基本都是同一个原因合批后Batch数量下降但材质球内部悄悄开启了多Pass。比如某个Shader为了实现描边写了一个额外的Outline Pass合批后一个Batch内部的那两次Pass切换全被SetPassCall记下来了。解决办法打开Frame Debugger选中一个Batch查看它包含的Pass列表。如果同一Draw Calls里出现了多次Pass那就得考虑能不能把多Pass合并成单Pass。比如描边效果完全可以在片元着色器里用几何计算实现没必要多开一个Pass。4.2 静态合批后内存暴涨静态合批并不是零成本——它对每个参与物体都会额外拷贝一份顶点数据。如果你把一个5000面的大树模型设为Static相当于瞬间多出5000面的显存占用。10棵树就是5万面内存直接炸。建议大型模型不要开Static Batching。可以在Project Settings里把Static Batching关闭或者对大物体单独关闭Renderer的Static属性。我的经验值是单物体顶点数超过1500的就不适合静态合批。4.3 为什么动态合批一直没生效动态合批的条件很严格顶点数包括Position、Normal、UV0在内不超过900且Shader中不允许使用某些顶点变换比如UnityObjectToClipPos之外的手动变换、不允许使用多个贴图等。更坑的是一旦模型附带蒙皮动画或者使用了带MorphTarget的Mesh动态合批自动失效。排查思路在Profiler的Rendering模块里展开合批详细信息看看Dynamic Batching计数是否为0。如果是就要检查模型顶点数和Shader兼容性。我见过一个项目动态合批不生效居然是因为美术把模型做了一个Scale为(1.01, 1.01, 1.01)的缩放Unity认为这个缩放在运行时不可合并。把这些细微的Scale改成1.0后合批立刻生效。4.4 同一个材质球合批率却上不去这种情况十有八九是材质球引用的Shader内部有带Tags{DisableBatching true}的光照变体或者材质球开启了某些per-renderer数据比如CustomRenderQueue、InstancedColor属性。还有一种可能是阴影选项——ShadowCastingMode不是Off导致阴影Pass和主Pass穿插执行改变了渲染顺序。处理方式分两步。第一步检查ShadowCastingMode把不需要阴影的小物件改成Off第二步如果必须保留阴影尝试把带阴影的物件放到单独的Layer中减少阴影Pass和主Pass的相互穿插。4.5 用MaterialPropertyBlock后GPU Instancing直接失效这是另一个容易踩的坑。MaterialPropertyBlock本身不会杀死Instancing但如果你在材质球上设置了不兼容的属性和Shader变体就会导致合批中断。比如你给某个实例设置了_Color但Shader内部没有声明实例化属性Unity就会认为这个MaterialPropertyBlock数据无法与公共属性合批。解决办法在Shader中显式声明实例化支持的属性。在CGPROGRAM里加上#pragma multi_compile_instancing UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _Color) UNITY_INSTANCING_BUFFER_END(Props)然后在顶点/片元着色器里用UNITY_ACCESS_INSTANCED_PROP(Props, _Color)访问颜色。这样材质球属性就变成实例化属性Instancing就可以正常运作了。4.6 SpriteRenderer、粒子系统与UI的SetPassCall也很高很多项目只盯着3D模型却忽视了SpriteRenderer、ParticleSystem和UGUI。它们同样遵循状态切换的规律不同图集、不同材质球都会产生额外切换。对于粒子系统尽量让所有粒子使用同一个材质球并且不要用带多个Pass的Shader。UGUI这边Text的字体、图集、描边效果也是合批杀手。我的经验是UI界面保持单图集策略——所有图标尽量打到一个图集里减少Font材质与Image材质的交替切换。5. 一个容易被忽视的环节Shader变体与渲染队列讲真很多优化做了一半就停手是因为没有把Shader变体和渲染队列一起处理。这两个东西跟状态切换的关系太过密切所以我放在最后单开一节讲。5.1 Shader变体裁剪的正确姿势Unity官方提供了Shader Variant Collection可以在Build时剔除掉永远不会用到的变体。项目里有大量multi_compile关键字时这一步能把Shader编译后的体积降一个量级同时减少运行时Shader切换时的查找开销。但变体裁剪要非常小心因为你很难确保某种极端光照环境下某个变体不会被激活。我的做法是在Project Settings里开启Shader Loading日志把开发期所有Shader加载的变体记录下来再对照变体列表确认哪些是高频的。只保留高频使用的变体组合其余全部禁用。如果后期发现漏掉了某个变体再加回来就行。这样既省了包体也降低了运行时状态切换的总次数。5.2 渲染队列和排序是合批的隐形杀器Unity内置的透明物体排序非常简单粗暴Transparent队列内按距离从远到近排序。这意味着你无法把透明物体固定放在某个渲染顺序上一旦场景里多个透明物体相互穿插排序就会频繁变合批直接失效。所以在场景设计时就要尽量减少透明物体穿插尤其是粒子特效与半透明水面的重叠。另外还有个容易踩的坑同一个材质球如果你在代码里动态改了它的renderQueue值Unity会把它当成独立的渲染项和同材质球的其他实例分到不同批次。我在做新手引导时经常看到有人为了让某个物体画在前面去改renderQueue结果一个场景里renderQueue被改成好几种值SetPassCall瞬间翻倍。5.3 LayerMask与RendererLayerMask别搞混有些项目会通过LayerMask来控制相机渲染或光照Mask这本身没问题。但注意LayerMask是GameObject的逻辑分组Renderer LayerMask才是渲染器级别的渲染分组。在URP中Renderer LayerMask允许你控制某个Renderer是否参与特定相机的渲染而相机还有个AdditionalLights的Mask设置——这些配置一旦交错很容易让Unity在每一帧做额外的渲染项过滤间接导致合批判定混乱。我的建议是核心场景Layer统一规划不要为了省事给每个物体随便设Layer否则后期排查性能问题会多绕很多弯。写在最后一次状态切换优化的真实体会再回到开头那个800多SetPassCall的项目。后来我把那面墙的30个材质球合并成了3个用MaterialPropertyBlock区分颜色场景里的灌木丛从静态合批改为GPU InstancingUI图集重新打了一版人物描边Shader从双Pass改成了单Pass边缘光。结果SetPassCall从800降到130帧率直接翻倍。优化做多了你会发现渲染性能并不是靠某一招制敌而是把每个环节里的浪费一点点挤干净。状态切换优化也是如此——它不一定能瞬间解决所有卡顿但一定是性价比最高、副作用最小的一步。如果你在项目里也遇到了物体明明不多却卡得厉害的问题建议先用Frame Debugger抓一帧数一数SetPassCall看看是不是状态切换惹的祸。数据会告诉你答案。