资讯动态

Unity游戏性能优化实战:从Profiler定位到脚本GC与DrawCall调优

发布时间:2026/9/8 16:38:44 来源:尧图企业网站定制
1. 项目蓝图Unity脚本优化到底在优化什么做Unity项目久了你会发现性能问题不是等到项目跑不动了才去解决而是从第一行脚本写下去就应该有意识。很多团队到了提测阶段才火烧眉毛地开Profiler一帧一帧地找瓶颈最后发现大部分问题都是早期写代码时埋下的坑——比如Update里不停地GetComponent、字符串用加号拼了一路、对象Instantiate了从不回收、协程开了几千个也不停。这个项目其实没有什么玄乎的新技术核心思路就是一套通用的性能优化方法论用Profiler定位瓶颈把CPU、GPU、内存三条线捋清楚再把脚本层面的坏习惯一个个改掉。整个过程做下来项目帧率从25帧左右提升到稳定60帧内存占用下降了30%以上最关键的是卡顿感几乎消失了。这套优化流程适合谁不只是技术负责人或者性能专项的同事只要是写过Unity脚本、遇到过卡顿、被Profiler吓到过的开发者都值得把这套思路沉淀下来。我尽量把每一步都讲清楚为什么这样做、怎么做、中间踩过什么坑、实测出来效果如何。2. 先搞清楚性能瓶颈到底在哪里2.1 不要迷信经验用Profiler数据说话很多同学一谈优化就想到降低画质、关闭阴影、减粒子。但实际项目里真正让帧率崩掉的往往是脚本逻辑而不是GPU渲染。我以前带过的一个项目美术把场景压得非常狠动态阴影全关光照烘焙全上结果帧率还是不达标。开Profiler一看CPU耗时里脚本逻辑占了将近50%大部分时间都耗在Transform的频繁修改、List的增删、还有每帧创建临时对象上。所以优化的第一步不是凭感觉而是先抓数据。Unity自带的Profiler就是最趁手的工具。运行项目后打开Window Analysis Profiler重点看CPU Usage模块下的耗时排行。你要关注几个指标Self某个函数自身花了多少毫秒不包含子函数GC Alloc这个函数每帧分配了多少内存只要大于0B就要警惕Calls一帧里被调用了多少次次数异常高的函数往往是问题点还有一个容易被忽略的细节Profiler要连真机看不要只在编辑器里看。编辑器本身的CPU开销、脚本编译开销和真机差异很大很多性能问题在编辑器里完全暴露不出来。安卓机连上ADB后在Profiler的Target选择对应设备数据才有参考价值。2.2 CPU、GPU和内存先分清是哪一类瓶颈有人一上来就说游戏卡但卡其实分了三种情况处理方式完全不同CPU瓶颈帧率整体偏低Profiler里CPU耗时接近16.6ms60帧标准。原因多是脚本逻辑复杂、物理计算太多、合批计算压力大。GPU瓶颈CPU耗时不高但RenderDoc或Profiler的GPU模块显示GPU耗时爆表。这时候要去看Shader复杂度、OverDraw、后处理特效。内存瓶颈表现为长时间运行后越来越卡切场景时明显卡顿。典型的如资源泄漏、GC频繁触发、纹理内存爆掉。我自己的做法是先把Profiler切到Timeline视图看一帧里哪个模块颜色最厚。Timeline视图非常直观它把每一帧的CPU、GPU、渲染、脚本、物理都按时间轴铺开哪个环节占了半帧一眼就能看出来。之后再切回Hierarchy视图针对性地往下钻。2.3 制定一套可量化的优化目标没有目标的优化就是在瞎忙。我一般把性能目标拆成三层目标帧率确定项目跑多少帧是30帧还是60帧对应每帧预算33.3ms还是16.6ms分模块预算渲染占多少、脚本占多少、物理占多少先定个粗粒度指标内存上限在目标设备上峰值内存不能超过多少低端机和高端机分别定不同标准这套目标要在项目初期就定下来最好是写进开发规范。不然美术加一个特效、程序加一个系统性能就悄悄恶化一点三周后再看已经无从下手了。3. 脚本优化先从消灭坏习惯开始3.1 GetComponent为什么是隐形杀手Unity里最常见的性能杀手就是反复调用GetComponent。这个问题我几乎在每个项目里都能看到。很多人习惯在Update里写void Update() { var rigidbody GetComponentRigidbody(); rigidbody.velocity ...; }表面上看没什么问题但GetComponent本身是一个遍历组件列表的过程内部还会产生一定开销。每帧都做一次即使只有几百个物体累加起来就是几千上万次组件查找。遇到复杂点的组件类型这个消耗还更高。正确的方式是缓存。要么在Awake或者Start里缓存一次要么用[SerializeField]直接拖拽赋值private Rigidbody _rigidbody; void Awake() { _rigidbody GetComponentRigidbody(); }如果组件是动态添加的可以用TryGetComponent代替GetComponent它在组件不存在时不会产生花销还省掉了判空操作。C#里写起来也很简洁if (TryGetComponentRigidbody(out var rb)) { rb.AddForce(...); }这里还想补充一个细节不要在Update里用Find、FindObjectOfType这类API。它们在场景里遍历所有对象来找目标每帧执行一遍性能直接崩。之前接手过一个项目发现某个角色的AI每帧调用一次FindObjectOfTypePlayer()场景里几千个物体肉眼可见地方系卡成PPT。改成在角色初始化时把Player引用注入进去速度立竿见影。3.2 空Update和协程滥用看似无害其实很浪费另外Null条件判断也是一个很容易被忽略的问题。还有一个常见问题就是Update导致的GC问题。3.3 字符串拼接与GC Alloc陷阱场景里有几千个物体每个上面挂一个带Update的脚本哪怕里面什么都不写Unity每帧也要遍历这些脚本并执行方法调用。如果物体数量多这个开销并非可以忽略不计。更坑的是团队协作者经常会在空Update里加一行调试代码然后忘记删掉性能就在不知不觉中流失掉了。我的习惯是能用事件驱动就不用轮询。比如某个状态变化时才需要执行的逻辑可以做成事件或者委托由触发方去调用而不是每帧去检测条件。举个典型例子玩家血量变化时UI需要更新如果UI每帧去读血量值不仅浪费还容易出现UI刷新不及时的问题。改为血量变化时派发一个事件UI只监听事件并刷新性能和数据一致性都能保证。协程也是个容易踩坑的地方。协程本身不贵但StartCoroutine、yield return null都会产生GC分配。如果一个对象频繁启动协程或者场景里有上千个协程同时在跑GC压力就会显现出来。更讨厌的是协程的生命周期不好控制对象销毁了协程可能还在跑引用了已销毁的对象还会报MissingReferenceException。我自己用协程有几个原则少量间歇性逻辑用协程没问题但不建议把协程当定时器用来做高频轮询长时间的循环逻辑优先考虑用UniTask或C#的async/await替代性能和可取消性都更好协程启动和结束时务必有对应的停止逻辑StopAllCoroutines该用就用这里顺便提一句UniTask。如果你没接触过可以把它理解成Unity场景下的Task不依赖MonoBehaviour没有协程的Update开销性能上比协程优越很多。特别是做异步流程控制的时候代码写起来比协程还舒服。不过UniTask有个前提它需要你在代码里管理生命周期不要拿到结果后就不管了该Cancel的时候要Cancel。字符串拼接的问题比大多数人想象得严重。C#里的string是不可变类型每次用拼接字符串都会创建一个新的字符串对象旧的对象等着被GC回收。如果这个拼接发生在Update里比如实时显示得分1000、坐标信息、调试日志那就是在每帧制造垃圾内存。string msg Player HP: hp / maxHp;这一行看起来人畜无害实际上每一次执行都会产生至少两到三个字符串临时对象。优化方式很简单用StringBuilder或者直接让Unity的UI组件接收多个参数。TextMeshPro的SetText方法就做了这种优化textMeshPro.SetText(Player HP: {0} / {1}, hp, maxHp);SetText的格式化参数不会产生GC Alloc它是Unity专门为高频UI刷新做的优化。调试日志Debug.Log也是个隐藏的GC制造机。日志内容即使不会被输出字符串拼接的开销也实实在在。更麻烦的是堆栈信息里还会带上调用者信息这些内存分配也不小。建议正式版本发布时用宏把Debug.Log全部过滤掉留存一个日志系统的接口需要排查线上问题时方便按需开启。说到Debug.Log这里就牵出一个比较大的话题如何用宏定义来区分开发版和发布版。3.4 用好宏定义给代码减负C#里有一组非常实用的预处理指令Unity也支持自定义的Scripting Define Symbols。项目里用得最多的就是把调试代码和正式逻辑分开[System.Diagnostics.Conditional(ENABLE_LOG)] public static void Log(string message) { UnityEngine.Debug.Log(message); }加了Conditional特性后如果编译时没有定义ENABLE_LOG这个宏调用点会被C#编译器自动去掉不产生任何开销。这样比在代码里写#if UNITY_EDITOR要干净得多调用方什么都不用感知。我见过很多团队惯用的一套做法是建一个自己的Log类然后所有日志都走这个类而不是直接调Debug.Log。好处除了可以统一控制日志开关还可以把日志输出到文件的模块也挂到这个类上。那样的话线上问题排查效率能高不少否则线上发布一个不带日志的版本出了问题只能靠用户描述体验极差。宏定义还有一个典型的应用场景区分平台差异。比如有的功能只在iOS上开放有的代码只在编辑器里跑这些都可以用宏包起来。但要注意宏用太多代码会变得很难读建议把宏判断尽量收敛在一个集中的地方不要让它们散落在业务逻辑里。4. 再往深处走渲染与内存的联动优化4.1 对象池Instantiate和Destroy不是免费的场景里频繁地生成和销毁对象比如子弹、敌人、飘字特效每执行一次Instantiate或Destroy都会触发一次内存分配和GC。尤其是Instantiate它的开销远不止复制一个对象那么简单Unity内部要做序列化、组件初始化、层级遍历等一系列操作。如果角色的血量显示每次受伤就Instantiate一个新Text一秒钟内产生几十上百个对象性能想好都不可能。对象池的思路很简单预先准备好一批对象用的时候从池里取用完还回去避免重复创建和销毁。核心代码可以自己写也可以用Unity官方维护的Unity Technologies的ObjectPool。如果是老项目不方便引包自己写一个泛型对象池也很容易public class ObjectPoolT where T : Component { private StackT _pool new StackT(); private T _prefab; private Transform _parent; public ObjectPool(T prefab, int preloadCount, Transform parent null) { _prefab prefab; _parent parent; for (int i 0; i preloadCount; i) { var obj Create(); obj.gameObject.SetActive(false); _pool.Push(obj); } } public T Get() { var obj _pool.Count 0 ? _pool.Pop() : Create(); obj.gameObject.SetActive(true); return obj; } public void Release(T obj) { obj.gameObject.SetActive(false); _pool.Push(obj); } private T Create() { return Object.Instantiate(_prefab, _parent); } }这里有个细节为什么Release的时候要用SetActive(false)而不是直接放回因为Unity的SetActive操作本身有额外开销频繁开关对象也不是完全免费的。所以在设计对象池时最好也让业务层注意一下比如子弹这类对象飞出边界后原地等几帧再回收不要立即SetActive(false)。对象池用好的效益非常大。我曾经给一个弹幕型射击游戏做过优化原先频繁Instantiate导致GC触发频繁、帧率波动严重换成对象池后GC Alloc直接降了90%帧率曲线也稳了。这个改动几乎没有改业务逻辑只是把生成和销毁换成了取池和还池性价比极高。4.2 Struct和Class内存布局影响性能Unity脚本优化的一个很容易被忽略的点是C#层面的值类型与引用类型使用不当。Class实例存储在堆上创建和回收都会牵扯到GCStruct存储在栈上或者内联在容器中不单独触发GC。如果一个数据结构会被大量创建比如每帧生成的坐标点数组、粒子初始化数据、寻路节点数据优先考虑Struct。举个实际例子一个寻路系统每帧要处理几千个节点如果节点是Class每帧都会有一批对象等待GC回收产生明显的性能尖峰。改成Struct后配合数组使用内存连续分布遍历速度快很多不说还没有GC Alloc。C#里还有一个很有用的特性就是ref struct和SpanT可以用来做高性能数据处理。比如大量顶点数据的变换用数组加循环配合ref就能大幅减少内存分配。注意这些写法对代码可读性有一定影响适合在明确需要性能的模块里用不建议全套代码都上这种写法。再补充一点List的扩容机制也会产生GC。List内部是数组实现容量不够时会自动申请一块更大的数组然后把旧数据拷贝过去这个过程中旧的数组变成垃圾内存。如果能提前知道数据量创建List时直接指定初始容量var list new ListEnemy(100);避免频繁扩容GC Alloc能少不少。4.3 DrawCall与合批脚本能影响到渲染侧DrawCall是Unity渲染侧的经典话题。场景里每个使用独立材质的物体渲染时都对应一次CPU向GPU提交绘制命令的开销。DrawCall太高时CPU会被这些提交操作占满哪怕GPU性能再好也白搭。Unity的合批机制就是用来合并相同材质物体的绘制调用的。Static Batching静态合批对不动的物体非常有效。条件很简单物体是静态的、使用相同材质、开启了Static Batching选项。Unity会在构建时把它们合并成一个大的网格一个DrawCall就能画完。需要注意的是合批后的网格会驻留在内存中大量使用静态合批会增加内存开销需要在场景规模和内存占用之间做权衡。Dynamic Batching动态合批则针对动态物体。Unity会把符合条件的动态物体合在一起提交但限制比较多顶点数要小于一定数量工程设置里可配置默认是300顶点左右材质要相同且合批过程本身也有CPU开销。所以动态合批并不是越多越好尤其是物体数量很大时合批计算本身可能比逐个提交还慢。在实际项目里我更建议从美术资源层面去减少DrawCall多个小物体尽量合并成一个模型不同物体共享同一套材质球而不是各建一个UI上优先使用图集配合Canvas的合批机制模型贴图用同一种Shader变体减少切换开销UI方面还有一个常见问题频繁修改UI元素的Text、Image、位置会导致Canvas的脏区域重建产生额外的重建开销。尤其是TextMeshPro的文本频繁变化性能损耗可能会超出你的想象。优化手段是尽量合并UI更新——把同一帧内对同一个UI元素的多次修改合并成一次或者干脆用TextMeshPro的SetText方法替代直接改text属性。4.4 LOD与遮挡剔除渲染侧的性价比之选渲染优化里LODLevel of Detail是投入产出比非常高的一个方案。简单说让远处的物体用低精度的模型代替高精度模型减少顶点数和渲染压力。Unity的LOD Group组件包好各层级模型后运行时会根据相机距离自动切换。这个方案几乎是白捡的性能尤其适合地形、建筑这类大模型多的场景。遮挡剔除Occlusion Culling也是渲染优化里的常客。它的原理是运行时烘焙出一套空间遮挡数据当相机看不到某个物体时即使它在视锥体内也不渲染。Unity里操作路径是Window Rendering Occlusion Culling。一般步骤是先给场景里的物体标记好Occluder Static和Occludee Static然后烘焙数据最后相机上挂Occlusion Culling组件。关于这两个方案的取舍有一个容易被忽略的点LOD和遮挡剔除省的是GPU侧的渲染开销但对CPU侧的脚本消耗没有任何帮助。如果你的项目瓶颈在脚本逻辑这俩做再多也救不了帧率。所以要先定位问题再动手不然就是白干。5. 内存管理GC是Unity性能的隐形炸弹5.1 摸清GC的脾性才能和它和平相处Unity的Mono和IL2CPP都使用Boehm GC老版本或SGen GC新版特点是非分代或简单分代没有像Java那样精细的GC调优。这意味着它在应用程序主线程上执行垃圾回收时会暂停线程暂停时间可能达到几十甚至几百毫秒。表现就是游戏突然卡一下切个场景卡一下或者打怪爆装备时卡一下。GC是Unity性能优化里非常关键的一环因为它是脚本侧最容易产生的非预期开销。解决问题的核心思路不是优化GC本身而是减少垃圾的产生。垃圾少了GC就不需要频繁执行自然不会卡。先量化一下问题。Unity的Profiler里GC Alloc字段显示的是每一帧新分配的内存大小。注意这里显示的是分配不是垃圾。但分配得越多后续回收的压力就越大。一般来说正式项目在核心玩法场景里平均每帧的GC Alloc应该控制在几百字节以内如果你发现每帧几KB甚至几十KB就要去排查了。5.2 常见GC产生源不只是new对象很多人以为只要不在代码里写new就不产生GC实际上GC来源比这广得多装箱Boxing把值类型转换为object或接口时会产生一次堆分配。比如object obj 123;、string.Concat传了值类型参数等。这个在字典遍历、ArrayList操作时尤其常见。最简单的排查方式是用ILSpy或IDE自带的提示看哪些地方有隐含的装箱。字符串操作前面说过的拼接、ToString()调用、字符串替换、格式化等都会产生新对象。委托和事件每次或-如果委托的Target不是同一个对象内部可能会产生委托实例。lambda表达式和闭包在Update里用lambda捕获外部变量每次调用都会创建闭包对象。LINQWhere、Select这类操作非常方便但它们内部会创建迭代器对象和委托GC开销不容小觑。性能敏感的循环里建议用传统的for循环替换。我也不是要完全禁用这些东西但要分场景。在游戏主循环、频繁刷新的UI逻辑、物理回调里这些方便都要悠着点。在一帧只执行一次的逻辑里用一点点LINQ其实问题不大。5.3 场景切换与资源卸载Unity里切换场景如果用的是SceneManager.LoadSceneUnity默认不会立刻卸载旧场景的资源而是等一段时间后在后台执行卸载。这个一会儿才卸载的机制常常造成一个现象切场景时内存峰值很高如果新场景资源多很容易在低端机上闪退。推荐的做法是切场景前主动做一次资源清理Resources.UnloadUnusedAssets(); System.GC.Collect();这两个API不要滥用但切场景时调用一次非常值得。尤其Resources.UnloadUnusedAssets会卸载未被引用的Resources资源能显著降低切换时的内存峰值。注意这个API是异步的调用后不会立即生效不过通常等一帧后内存就能降下来。关于AssetBundle的使用有一条铁律加载的资源必须对应释放。AssetBundle.Unload(false)只卸载Bundle本体保留已加载的AssetAssetBundle.Unload(true)则会把所有相关Asset一起卸载。具体用哪个取决于你是想整体卸载加载的资源全不要了还是部分卸载保留部分Asset。但不管用哪个都要做好资源引用计数防止资源被错误释放后出现紫红色模型或者加载不到贴图。5.4 数组和容器的复用技巧如果有一段代码每帧都会产生一个新数组比如把某个列表转换成数组、拼接多个数组那GC Alloc必然很高。这种情况可以复用数组预先分配一块足够大的数组每次写入后只用一个长度字段来标记有效区域。Unity里NativeArray也有类似的池化方案但在纯C#侧数组复用足够应对大部分场景。再进一步Unity的Collections包里有一个NativeList、NativeArray系列它们分配在非托管内存上不经过GC管。从性能角度讲这些数据结构在频繁增删的场景下优势明显只是使用时要记得Dispose否则会内存泄漏。Job System和Burst Compiler配套使用时这些数据结构几乎是必需品。我建议在游戏项目里建立一套容器复用规范明确哪些高频函数只能使用复用容器哪些允许新建List。比如每帧要计算附近敌人列表这个列表应该提前分配好不要每帧new一个List出来。6. 实操提效工具链把优化做在开发流程里6.1 在Profiler里开一扇真相之窗Profiler本身有很多高级用法不只是看个总耗时。我最常用的三个功能Deep Profile模式勾选后每个方法都会被插桩能看到完整的调用链和耗时分布。但Deep Profile会大幅降低运行速度只适合在定位问题时临时开不适合常规使用。Memory ProfilerUnity的Memory Profiler包可以快照内存比较不同时间点的快照差异迅速定位是谁在涨内存。排查泄漏时非常好用。Profile Analyzer可以同时分析多次Profile记录自动对比不同版本之间的性能差异。发布新版本前后各抓一份数据用这个工具对比就能立刻看出有没有性能回退。每次性能优化开始前先抓一份基准数据存档优化过程中每个小改动都重新抓一份最后对比优化前后的差异。这样不仅能量化你的成果还能快速定位是哪次改动导致了性能下降。6.2 写一个性能监控面板让问题提前暴露依赖Profiler手动发现问题有点落后。更好的方式是在项目里内置一个运行时的性能监控面板实时显示帧率、内存、GC Alloc、DrawCall等关键指标。游戏开发过程中开着它跑一局性能有没有恶化一目了然不用等测试报告出来才知道问题。一个简单的做法是在UI角落挂一个Text组件每秒刷新一次private float _deltaTime; private float _fps; private int _frameCount; void Update() { _deltaTime (Time.unscaledDeltaTime - _deltaTime) * 0.1f; _frameCount; if (_frameCount 30) { _fps 1.0f / _deltaTime; _frameCount 0; } }帧率之外统计信息也可以整合进来。Unity自带的Stats面板Game视图右上角的Stats能看到DrawCall和SetPass Call等数据但运行时面板里如果能自带一个每秒DrawCall数的读数盯性能的效率会高很多。接入项目后建议放在测试包和开发包里正式发布包通过宏控制关闭免得被用户看到或产生额外消耗。6.3 把性能预算写进开发规范性能优化最好的状态是在写代码时就不犯错而不是等在后期去修。建议在团队里定一份性能预算清单内容大致包括Update和FixedUpdate里严禁GetComponent、Find、FindObjectOfType每帧GC Alloc不得超过xxx字节根据项目硬件目标定场景内激活物体数量、粒子系统数量、动态阴影数量设置上限手机上Shader只使用Mobile/SimpleLit级别禁用复杂后处理频繁生成的对象必须走对象池这份清单不要只是挂在Wiki里最好配合自动化手段。Git提交前挂一个检查脚本扫描Update里的GetComponent调用跑自动化测试时带上Profiler超过阈值直接失败。如果真的能把这套流程跑起来性能问题基本能拦在源头等到项目后期就不会手忙脚乱再去救火了。7. 常见问题与坑位避雷速查表以下这些坑是我在不同项目里反反复复看到的有些甚至是自己踩过然后才长记性的。现象可能原因解决方案帧率在某个区域突然暴跌大量DrawCall堆积、动态合批失败检查该区域材质数量减少材质种类启用合批游戏运行时间越长越卡资源泄漏、List无限增长、协程持续运行用Memory Profiler快照对比查泄漏点切场景卡顿严重旧场景资源未及时卸载切场景前调用UnloadUnusedAssetsUI刷新卡顿TextMeshPro频繁改text、Canvas脏区域重建改用SetText批量更新UI属性打怪/爆装备时卡顿Instantiate高峰导致GC使用对象池管理物品和特效Profiler里看到函数耗时不正常Deep Profile未关闭或者真机和编辑器差异关闭Deep Profile换真机测试再补充两个我自己踩过比较深的坑。第一个是协程引用已销毁对象的问题。场景卸载后如果协程还在等一个永远不会到达的yield它的引用悬空在场景对象上虽然未必报错但内存已经泄漏了。项目里后来统一规定MonoBehaviour里开的协程在OnDisable和OnDestroy里必须StopAllCoroutines开局简单踩坑后才知道值钱。第二个坑是TextMeshPro的一次字体动态加载。项目里集成了几十种语言遇到用户切换到没有预加载的语言时TMP会动态创建字体资源卡顿非常明显。后来改成在启动时预加载所有可能会用到的字体或者用SubMesh按需异步加载才把这个问题压下去。还有一个常见的疑惑吐个槽为什么场景里东西不多Profiler一开却显示几千个DrawCall很多时候是UI的每一张小图都单独成一个Canvas或者每个物体的Material都是独立实例。Unity的Canvas合批要求同一层级、相同材质、相同纹理而独立Material实例会让合批失效。遇到这种情况优先检查是不是有人偷懒给每个UI对象都创建了一个新Material。8. 修行在个人脚本优化不只是技巧更是习惯启动本项目时我给自己立过一个规矩每提交一次代码之前先打开Profiler跑一分钟确认没有引入新的性能问题。这个习惯坚持了几个月到后面已经不需要刻意检查了因为写的每行代码都会下意识地想一下这个会不会每帧分配内存这个调用频率是不是太高。性能优化是一个滚雪球的过程优化的越好系统越稳定你就有更多时间花在功能和体验上而不是天天救火。优化的起点是数据和工具终点则是把这些约束变成团队的默认行为。希望这篇基于实战总结的内容能帮你少走一些弯路。如果后面你们在项目里也遇到有意思的性能瓶颈案例欢迎交流各自的排查思路和踩坑记录。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价