1. 性能优化的整体认知与基本思路
做Unity开发的朋友应该都有这样的体验:项目跑到后期,功能越堆越多,帧率却越来越难看。真机一跑,卡顿、发热、闪退轮着来。这时候才想起性能优化,往往已经被动挨打了。
所以我想把这几年在Unity性能优化上踩过的坑、总结出的方法系统梳理一遍。这套东西不针对某个具体项目,而是通用的排查和优化思路,无论你做的是休闲小游戏、AR应用还是重度3D项目,核心逻辑都逃不开这几块。
先说一个基本认知:性能优化不是某一天集中做的事,而是贯穿项目开发周期的持续行为。越早建立性能意识,后期返工越少。但如果你已经错过了早期,也别慌,从今天开始按正确方法排查,一样能救回来。
性能优化的本质就一句话:在保证视觉效果和玩法体验的前提下,尽量减少硬件资源的消耗。这里的硬件资源包括CPU、GPU、内存、显存、带宽、电量等。不同项目瓶颈不同,有的卡在CPU,有的卡在GPU,有的纯粹是内存爆了,还有的是加载卡顿。优化的第一步不是上来就改代码,而是先搞清楚瓶颈到底在哪儿。
很多人容易陷入一个误区:看到某个优化技巧就全项目套用。比如听说对象池好,就把所有物体都塞进对象池;听说不用Update舒服,就把所有逻辑全部改成协程或事件驱动。这样做往往事倍功半,甚至引入新的Bug。正确的姿势是带着问题去找方案,而不是拿着方案去找问题。
第二个误区是凭感觉优化。有人觉得“这个界面肯定耗性能”,于是大改一通,结果帧率没变化;有人觉得“这段代码很简单啊”,结果它就是帧率杀手。没有数据支撑的优化,基本等于瞎猜。这也是我反复强调Profile先行的重要原因。
性能优化还有个容易被忽略的维度:收益与成本的权衡。优化是要花人力时间的,有些优化收益极高、改动极小,属于性价比之王;有些优化付出巨大、收益甚微,甚至只是心理安慰。打个比方,两个方案的收益都是10分,A方案改一行代码,B方案要重构整个架构,你选哪个?聪明人当然选A。所以优化时要学会给方案排优先级,先把最肥的肉吃到嘴。
2. CPU端的性能优化:让最贵的那颗核喘口气
2.1 渲染调用的隐藏成本
CPU端的性能大头,通常不是你的游戏逻辑代码,而是渲染管线的开销。很多人以为Draw Call只有GPU关注,其实每次Draw Call都是CPU发起、GPU响应的协作过程,CPU要先准备好顶点数据、纹理绑定、Shader参数等一堆东西,然后才能通知GPU去画。这个过程是有固定成本的,Draw Call越多,CPU用在渲染准备上的时间就越长。
关于数字,以前大家习惯说“移动端尽量控制在300以下”,这个数现在不太固定了,和机型、渲染管线都有关系。但大方向不变:Draw Call越少越好。减少Draw Call的常见手段有静态合批、动态合批、GPU Instancing和SRP Batcher,这几种机制各有适用场景和限制。
静态合批适合不动的物体,运行时把这些物体的网格合并成一个,省掉的Draw Call最多,但代价是内存占用上升,因为合并后的网格是独立的一份副本。动态合批适合小且移动的物体,它是在CPU侧每帧动态合并顶点,所以对顶点数和材质数量有要求——顶点太多、材质不同都会导致合批失败。而且动态合批本身也有CPU消耗,物体特别多的时候,合批运算的代价可能超过省下来的Draw Call收益,适得其反。
我在实际项目里最常用的是SRP Batcher,前提是项目使用SRP(可编程渲染管线)。它的思路是把材质属性缓存起来,同一个Shader变体下的物体,即使材质参数不同也能合批,而且合批对CPU的占用比动态合批低得多。这个特性在URP下几乎是零成本开启的,强烈建议把项目迁到URP后顺手打开。
GPU Instancing则是处理大量相同模型的利器,比如草、树、粒子这类重复度极高的物体。它的原理是给GPU一次性传一堆实例数据,让它循环绘制,CPU压力极小。需要注意的是,Instancing要求物体的Mesh和材质必须一致,材质参数不同的物体需要把参数合进Instance数据里,用UNITY_INSTANCING_BUFFER宏来声明。
2.2 脚本层原子级优化
脚本层优化,最经典也最容易被无视的坑就是Mono内存分配。Unity的Mono垃圾回收机制是分代管理加增量回收,每帧分配太多托管内存,就会触发GC,GC一来,主线程直接卡住,表现就是帧率曲线突然掉到谷底然后又弹回来。
我见过很多项目,一进战斗界面就疯狂new对象,三秒一小卡,五秒一大卡。把这些分配都去掉之后,帧率立刻稳定。所以写代码时要养成一个习惯:高频路径上坚决不产生垃圾。高频路径指的是每帧都会执行到的地方,比如Update、FixedUpdate、协程内部、OnGUI等。
具体来说,以下几类操作容易无意中产生内存分配,要格外小心:
- 字符串拼接,尤其是每帧都在Update里拼字符串来更新UI文本
foreach循环,Unity的旧版Mono对某些容器的foreach会产生迭代器对象- 装箱拆箱,把值类型转成
object的操作,比如把int塞进ArrayList - LINQ表达式,里面会生成一堆临时对象和委托
- 闭包捕获,Lambda表达式里捕获了外部变量就会产生闭包对象
- 频繁调用
GetComponent - 属性索引器,比如
List[i]本身不分配,但某些容器的索引器会
其中字符串和LINQ是重灾区。字符串可以用StringBuilder复用,或者干脆用int直接拼UI文本;LINQ能用循环替代的就用循环,实在要用也别用在每帧执行的地方。有一个简单的自查办法:在Profiler里看每帧GC Alloc的数值,如果恒定不为零,就说明有代码在产生垃圾,用抓帧的方式一层层定位。
关于GetComponent,还有一个更隐蔽的问题。频繁调用它除了分配开销(Unity 2019以上的版本GetComponent不会分配GC),更重要的是查找成本。在能确定组件必然存在的情况下,缓存到成员变量里是基本操作。我自己习惯在Awake里把一个GameObject上常用的组件全部取出来存好,后面就再也不碰GetComponent。
2.3 MonoBehaviour的合理用法
每个激活的MonoBehaviour每帧都要调用空方法,这个开销不是编译器能帮你消掉的,引擎层面要走消息派发流程。一个场景挂几百上千个脚本,哪怕这些方法体都是空的,CPU也要白白烧掉一部分时间。所以对Update这块要动刀。
在不改变现有架构的前提下,有几件事可以做。第一,能不能让脚本自己决定要不要更新。比如给每个需要更新的组件维护一个开关,在OnEnable和OnDisable的时候注册到管理器里。第二,把同一个系统内所有组件的Update合并成一个管理器统一驱动,这样每帧的空调用就没了。第三,如果帧率要求不高,还可以降低更新的频率。比如某些表现类组件,明明是每10帧变化一次,却每帧都在跑Update,纯属浪费。
物理系统也一样。项目里的物理计算每帧都在跑,如果场景里根本没有那么多物理物体,为什么不调低物理帧率?Time.fixedDeltaTime默认是0.02秒,某些项目完全可以把固定时间步长调大一点,物理精度稍微降一点,换来的是CPU释放出一大块余量。当然,如果物理是核心玩法,比如跑酷、赛车、格斗,那就要谨慎调整,不能拿手感开玩笑。
协程也容易埋雷。协程本质是迭代器状态机,不是线程,它依然跑在主线程上。大量协程同时挂着,每帧还要驱动它们前进,同样消耗CPU。另外,协程的WaitForSeconds会有一个缓存的引用池,你每次new一个WaitForSeconds其实大概率复用同一个对象,这个倒是不必过度担心。真正该注意的是协程里别做耗时的同步操作,也别开几百个协程只为了改个变量。
3. GPU端的性能优化:移动平台上的实战选择
3.1 移动端GPU的思维转变
如果在PC上做过图形开发,转做移动端后,会发现GPU优化的思路几乎要推倒重来。移动GPU普遍采用Tile-based架构,屏幕被划分成一个个小方块(Tile),GPU先在小块内完成所有渲染,再把结果写回显存。这和桌面GPU的Immediate Mode(即时渲染)逻辑完全不同。
Tile-based架构下,最忌讳的就是Overdraw,也就是同一个像素被反复绘制多次。比如一张全屏半透明面板叠了三层,每个像素要混合三次,GPU的填充率就白白浪费了。移动端的填充率本来就不高,稍微来点大面积半透明特效,立刻从60帧掉到30帧。在预览界面直接看Overdraw指标,过高的区域要重点处理。
常见的Overdraw来源有:大面积半透明UI、全屏后处理、粒子系统、特效面片等。UI的话尽量用不透明图,或者同一区域只保留一层半透明;后处理能用URP Volumetric的话注意档位,像泛光这种吃填充率的特效,宁可做轻一点也别把整个屏幕糊满;粒子的Overdraw就更直接了,把粒子数量大的发射器拆成好几个源头,或者干脆把粒子纹理换成更小的尺寸,效果差异用肉眼几乎看不出。
3.2 带宽是移动端的命脉
移动设备的显存带宽非常有限,而影响带宽的最大因素就是纹理和Shader。纹理贴图越大,从显存里读取的数据越多,带宽消耗就越大。我做移动端项目时定了一条铁律:能用压缩纹理绝不用RGBA32,能用小图绝不用大图。
纹理压缩在移动端主要分ASTC和ETC2两个阵营,都是苹果和安卓主流的格式。ASTC压缩比可以自由选择,6x6和8x8的压缩率在日常项目里基本够用,而且画质损失不大。iOS是从A8之后支持ASTC的,老设备就要退到ETC2。这个问题在开发时就要定好目标机型,否则发布后出现纹理花屏会非常被动。
Shader端的带宽消耗主要来自纹理采样次数。一个半透明的玻璃材质可能要采样反射、折射、法线好几张图,每个像素都这么做,带宽可想而知。移动端Shader的优化原则是:能少采样就少采样,能离线算好的就别在运行时算。比如法线效果,可以用一张预烘焙的法线贴图替代逐像素计算;环境光遮蔽那些花哨效果,在移动端就不要开那些逐像素的光照模型。
还有一个很多人容易忽略的点:RenderTexture的分辨率。不管是相机看到的画面、阴影贴图还是反射探针,RenderTexture的分辨率直接决定了GPU每帧要处理的像素量。做UI的相机用1:1分辨率是浪费,阴影贴图给256就够用了的项目非要开1024,那是给自己埋坑。实际项目里可以用0.5倍分辨率的RenderTexture做画面缩放,视觉上几乎感觉不到差异,但帧率能肉眼可见地提升。
3.3 后处理与特效的取舍
URP的后处理栈做起来非常方便,加个Volume、勾几个选项,画面质感立刻上一个档次。但方便归方便,移动端上每个后处理效果都要钱。Bloom、泛光、景深、AO这些,每一个都是全屏操作,相当于给每帧画面额外做几次全屏Shader处理。
我做过的移动端项目里,开启的常见后处理组合是:色调映射 + 轻微泛光 + 抗锯齿,这三个是性价比最高的,画面质感提升明显,性能损耗可控。景深和动态模糊这种要看使用频率,如果只是剧情特写用一下,可以接受临时开启;如果是常驻效果,建议做LOD——离远了自动降级,或者干脆不要。
粒子系统我也多说一句。用URP的VFX Graph做粒子虽然功能强大,但移动端上还是谨慎些。VFX Graph计算量大,节点一多就是GPU杀手。传统Particle System反而是移动端最稳的选择,配合图集、低分辨率纹理、限制发射数量,性能远好过花哨的高级粒子方案。
4. 内存与加载:稳定性的另一半
4.1 Unity内存模型的几个关键数字
性能优化不能只看帧率,内存更是决定应用生死的那条红线。iOS上有低内存崩溃机制,安卓后台内存紧张时会杀进程,Unity的项目配了内存不足给的错误提示,都是这个原因。做Unity开发必须对内存模型有清晰认知:Unity的内存分成托管堆、原生堆、图形资源(显存+内存双重占用)三块。
托管堆就是Mono的堆,游戏逻辑里new的C#对象都在这儿。它的特点是只增不减,托管堆一旦扩到某个大小,即使里面的对象都被回收了,堆也不会缩回去,这也就是常说的堆碎片化。所以项目里最怕的不是对象数量多,而是分配大小差别特别大。一会儿分配几MB,一会儿分配几百字节,撑开的堆永远缩不回来。
图形资源这块很多人的认知有误区,以为纹理加载进内存后在游戏里只是显存的事。不是的,纹理要同时占一份CPU侧的内存,尤其是没有GPU端直接读写能力时,Unity会把纹理数据完整保留一份在内存里。这也是为什么同样分辨率下,压缩纹理格式对内存占用也有决定性影响。
AssetBundle加载导致的资源冗余更是内存大户。同一个模型,A场景加载了一份,B场景又加载了一份,如果两份引用没管理好,内存直接翻倍。这类问题用Memory Profiler一抓一个准,所以内存优化里我最推荐的做法就是:定期用Memory Profiler做一次全量体检,把所有大资源按大小排序,逐一确认是否可以卸载。
4.2 Asset管理与对象池的配合
Asset生命周期管理的核心原则是一句话:谁加载,谁负责卸载。最忌讳的是代码里到处加载资源,却没有任何人去卸载,结果是场景切了十几遍,内存越占越大,最终卡死或闪退。
落地的方式有很多,入口是Resources或AssetBundle无所谓,关键是建立一套统一的加载接口。我用过几套方案,比较顺手的做法是做一个静态的异步加载管理器,内部维护引用计数,每加载一个资源就加一,每卸载一个就减一,减到零才真正释放资源。这样即使代码路径复杂,也能保证每个资源都不会被误释放,也不会被长期占用。
对象池和Asset体系是两码事但经常被放在一起用。对象池解决的是短时间内反复创建销毁物体的问题,典型场景是弹幕、技能特效、敌人刷怪。对象池的核心价值是减少实例化开销和GC压力,而不是把所有的物体都装进去。一个对象池该注意的点:池子要有上限,否则空闲对象堆积反而吃内存;物体进出池时要把状态重置干净,否则下一次复用会出现残留数据鬼影。
4.3 包体与加载流程的优化思路
加载卡顿是另一种形式的性能问题,它不影响FPS,但直接影响用户体验。3A游戏点击按钮,转半天圈,玩家还能忍;休闲游戏加载5秒,玩家可能已经划走了。
加载优化的方向有两个:一是减少加载的数据量,二是错开加载时间。减少数据量,就是把资源做瘦身——纹理压缩、模型减面、音频压成占空间更小的格式、Shader给成更小的变体集合。错开加载时间,就是分帧加载、异步加载、预加载等策略的组合。实际上很多项目首帧卡顿的根源是同步加载了太多东西,改成异步之后,首帧就能正常渲染出来,后面的内容在玩家感知不到的时候慢慢填充进去。
资源加载分帧处理有个挺实用的小技巧:用一个协程分批加载,每帧只处理一定数量的资源,然后让出控制权。这样加载过程里渲染线程每帧都有余力去处理画面,不会出现加载时白屏或动画卡住的现象。
5. 常见问题与排查技巧实录
5.1 Profiler的正确打开方式
我曾见过有人抱怨Profiler看不懂,说数据太多太杂。其实不是Profiler的问题,是打开方式不对。排查性能问题要有个系统性的流程,而不是盯着满屏数据发呆。
第一步,看总帧耗时。先在设备上跑一遍真实场景,打开Profiler的CPU板块,看哪一帧超过目标时间,点击那一帧,查看耗时Top项。一般来说,渲染、脚本、物理这三大块加起来占了压倒性的比例,拖延时间最长的就是主瓶颈。
第二步,按模块下钻。如果脚本耗时占比高,就展开脚本调用树,看具体是哪个函数在耗时;如果渲染耗时高,就看Draw Call的数量和Batch数据。Profiler里有个Hierarchy模式,能直接把最耗时的函数列出来,比在云里雾里的TimeLine里瞎找强得多。
第三步,结合设备实际。编辑器里跑的性能数据只能做参考,真机上的CPU架构、GPU型号、屏幕分辨率都会导致数据跳变。有些问题在编辑器里完全复现不了,一上真机就现形,所以Profile的设备一定以目标真机为主,编辑器数据为辅。
推荐一个习惯:建立项目的基准帧数据表。隔三差五跑一次Profile,把每帧的耗时、GC Alloc、Draw Call数记录下来,形成趋势曲线。这样版本迭代哪里引入问题,一眼就能看出来,而不是等QA反馈了才满项目找Bug。
5.2 帧率卡顿案例复盘
去年有次项目战斗场景卡顿严重,我接手排查。初步观察,战斗刚开始不卡,过一会儿渐变卡,然后持续掉帧。这种“逐渐变卡”的模式,通常指向内存泄漏或者对象无限增长。
打开Profiler抓了一帧,GC Alloc从几千飙升到几十万,Draw Call稳定在几百没有异常。再看Runtime API,发现一个Boss的AI类里有字符串拼接,每帧都往一个StringBuilder和ArrayList里塞数据。Boss召唤的每只小怪都会注册一个回调,回调里又有字符串操作,召唤的怪越多,AIData文件里的对象就越多,最后整个战斗被垃圾回收拖垮。
排查时的关键是看Object Count,我直接数了一下场景里的GameObject数量,发现从初始的150个涨到了几千个,小怪死了但对象没销毁。回去看代码,小怪死亡时只是把模型隐藏了,没做销毁和引用清理。把这个漏洞补上,再顺手把字符串操作换成int缓存和预拼接,帧率就稳定了。
这类场景在项目里特别典型,排查思路基本都是:先确认是CPU、GPU还是内存问题,再根据模式定位到具体代码。有了正确的方向,优化就不是玄学,变成了按图索骥。
5.3 性能优化自查清单
根据个人经验整理一份自查清单,可以当作项目的例行体检项,每一项都是实际踩坑总结出来的:
- 高频Update里是否存在字符串拼接、LINQ、装箱、反复GetComponent
- 是否每帧都在new对象(TMP、Grid等容器类也是如此)
- 场景物体是否合理合并,抛弃Level的物体是否能减少Draw Call
- 移动端纹理是否用了ASTC/ETC2压缩,像素占用是否合理
- Shader是否有过多的纹理采样、逐像素光照或半透明混叠
- 后处理栈是否开了常驻高消耗效果
- 物理碰撞体和刚体数量是否远超实际需求
- Scene的加载是同步还是异步,资源是否有统一的卸载策略
- 对象池是否设置了上限并能正确重置状态
- 音频资源是否有压缩格式,加载方式是流式还是完整加载
- UI是否有过多重叠半透明层
- Profiler里GC Alloc是否恒不为零
- 真机Profile时,发热和掉电是否正常范围
这份清单不是让你一次性全做完,而是每次做性能迭代时,逐项对照,一边查一边修,慢慢就会形成肌肉记忆,写代码时主动避开这些坑。
Unity性能优化这条路,没有一劳永逸,只有持续迭代。性能问题往往是组合拳,场景复杂度、机型差异、玩法逻辑全搅在一起,唯一的出路是保持Profile、保持记录、保持对数据的敏感度。踩过的坑越多,你离瓶颈的直觉就越准。希望这篇归纳对正在做Unity性能优化的你有用,少走几步弯路。