Unity手游性能优化的经验总结

一、定位游戏性能瓶颈

1.1 游戏循环

基本循环:游戏逻辑-渲染提交-等待渲染完成(注意:游戏逻辑指的是除去渲染之外所有的CPU运算)。
基本的游戏循环可以理解为先执行游戏逻辑,比如获得输入,然后更新玩家位置,播放动画,物理碰撞等,然后渲染引擎会将要渲染的游戏画面信息提交到GPU,CPU则等待GPU完成该一帧的渲染结果。

1.1.1 单线程渲染


上图可以看到主线程直接提交渲染命令GCMD给渲染设备。主线程需要等待图形设备渲染完成。

1.1.2 多线程渲染


主线程:游戏逻辑-提交渲染到渲染线程-等待渲染完成。
渲染线程:提交渲染命令给GPU-等待渲染完成。
GPU:等待渲染命令-执行渲染。

从上图可以看到,主线程将渲染命令封装后提交给渲染线程,渲染再提交给图形设备渲染画面。因此,渲染线程会比主线程延迟一帧。虽然新增了渲染线程来减少CPU等待渲染完成的时间,但是每一帧主线程还是会等待渲染线程完成上一帧的渲染。因此,还是可以按照基本的游戏循环来分析每一帧的运行过程。

1.2 利用工具确定执行时间

我们需要使用工具,比如UWA分析报告或者Unity的Profile等来确定主线程的游戏逻辑、渲染提交、渲染等待,这三个部分的整体执行时间。

  1. 或者利用工具获得GPU每帧耗时是多少,是否超过帧率的要求,比如30帧的话,那么一帧是33.3ms,GPU的每帧耗时就不能就不能超过33.3ms,或者不能比CPU的耗时多,否则就是GPU的性能瓶颈。
  2. 如果GPU不存在瓶颈,那么问题就在CPU上,这个时候要进一步分析CPU的瓶颈是游戏逻辑还是渲染提交上,这个UWA的性能分析报告和Unity的Profile都可以查看。
  3. 如果渲染等待时间过长,说明渲染指令过多,这个时候就需要减少渲染指令的提交,一般就是采样各种合批策略,比如SRP合批、静态合批等,或者是合并网格、减少摄像机距离或者层级Culling等。
  4. 如果渲染等待时间比较小,而游戏逻辑占用时间过长,那么要去分析CPU的耗时,具体是哪一块耗时过多,比如物理、动作、逻辑等。

二、GPU性能优化

当GPU遇到性能瓶颈时候就要着重进行GPU的性能优化,下面介绍一些常用的优化策略。

2.1 降低分辨率

很多手机的显示器分辨率过高,GPU性能却跟不上。因此,降低分辨率或者对分辨率做限制也是常见的优化手段。比如,最高档设置可以限制1080屏幕高度,依次递减,最低档的设备720就可以。

2.2 减少OverDraw

OverDraw的意思是一个像素被重复绘制的次数,也就是该像素位置重复执行像素着色器(片元着色器)的次数。我们可以利用工具,比如固定管线的Unity编辑器是可以显示场景的OverDraw的,Urp的需要做一些扩展支持,来可视化游戏的OverDraw。对OverDraw特别高的部分要想办法优化,下面介绍一些Urp管线下的优化策略。

2.2.1 自定义渲染Pass

Urp的渲染Pass是渲染顺序中优先级最高的,因此同一个Pass对应的物体都会在一起按照一定的顺序渲染。那么,为了减少OverDraw或者提高合批的命中,要合理的设计相关的渲染Pass。比如,天空盒是最远的物体,会一直被遮挡,这种就没必要在场景或者角色之前渲染,否则会有没必要的OverDraw。那么,就可以对天空盒单独一个Pass,在场景和角色之后执行。同理,如果角色一直靠近摄像机,也可以强制角色在场景之前的Pass渲染。

2.2.2 渲染队列

渲染队列是在Shader中设置的,属于同一个Pass内的渲染顺序微调。同样的,还可以设置Render的sortingOrder等。类似的,在默认管线下,通常会调整天空盒的渲染队列为不透明物体之后来避免OverDraw。

2.2.3 合理的渲染顺序

Urp渲染管线对不透明物体会按照从前到后渲染(不支持GPU隐藏面消除),透明物体按照从后到前渲染。Shader的RenderType定义是半透明还是不透明物体。另外前述的渲染Pass和渲染队列等也会影响渲染顺序。合理的渲染顺序能够提高Early-Z的命中率,减少OverDraw。

2.2.4 减少大范围或者全屏特效

大范围的特效是OverDraw的杀手,尤其特效反复重叠的情况,项目中最好从设计层面规避这种情况的出现,实在没办法的再想办法优化特效本身的执行效率,比如特效的粒子数或者面片数、Shader复杂度等。

2.2.5 减少UI的重叠层数

由于UI是按照半透明物体渲染的,因此要尽可能减少UI的重叠。下面的UI要隐藏而不是被覆盖。不过,这一点通常要在UI框架中做好解决,因为不仅仅是有OverDraw,UI的网格计算等CPU消耗也很大。

2.3 提高Early-Z Test的命中率

理论上来说,从前到后渲染就是尽可能的提高Early-Z的命中率,从而降低OverDraw。所以,这一项优化通常是和优化OverDraw是一起进行的。

2.3.1 减少Alpha测试

但是Alpha-Test,也就是在Shader中丢弃像素,即在片元着色器中执行Discard指令,可能会破坏Early-Z Test,因为硬件不执行片元着色器就无法预测最终的深度。因此,要尽量避免大范围的Early-Z Test,除非特殊的渲染要求,比如溶解、植物、头发等,不要使用Alpha-Test。

2.4 减少半透明物体

半透明物体一个是渲染顺序必须从后到前,因此OverDraw严重,性能肯定比不上不透明物体。按照虚幻的官方文档,不透明最快,蒙版(Alpha测试)其次,半透明最慢(OverDraw严重)。

2.5 降低GPU带宽

2.5.1 压缩纹理

  1. 纹理压缩格式:比如现今基本都支持ASTC纹理压缩,通过合理设置不同资源的压缩率,尽可能压缩纹理大小。
  2. 纹理大小:一些贴图根本不需要过大的尺寸,但是美术导入的原始资源通常过大,因此可以在资源导入脚本中强制压缩到一定的尺寸或者写工具扫描压缩一遍。
  3. 去除不必要的纹理通道:比如Alpha通道或者灰度图改成单通道

2.5.2 减少纹理采样次数

  1. 尽可能关闭各向异性纹理
  2. 降低纹理质量
  3. UI或者角色展示场景关闭Mipmap

其中1和2都可以在工程设置的Quality中选择,可以根据不同的设备等级来选择不同的设置。3的话需要在贴图中设置,可以通过贴图导入脚本来设置。

2.5.3 提高纹理缓存命中率

  1. 减少贴图尺寸
  2. 开启Mipmap
    GPU的片内缓冲大小是有限的,因此尽可能小的贴图或者Mipmap才更可能被缓存命中。纹理被缓存命中,那么读取速度会比从内存中读取快一个数量级。

2.5.4 压缩网格

  1. 尽可能减少网格大小:比如限制面数、顶点数
  2. 开启顶点压缩
    网格的大小也会影响带宽,但是更多的是会影响渲染面数,从而增大GPU的负担。

2.5.5 减少全屏Blit

尽可能减少全屏特效,或者合并全屏特效的计算,减少全屏Blit的次数。

2.6 Shader优化

2.6.1 降低Shader中的数值精度

现在版本的Shader中已经不需要使用fixed类型,实际上Urp的Shader中也会编译失败。尽可能使用half类型,减少float类型的使用,float类型主要是用在postion和uv上,对应颜色等属性值尽量都用half。精度更大的话,计算时间就更多。

2.6.2 减少Shader中的分支和循环

尽可能避免分支出现,尽量不要使用循环。分支会破坏Shader的并行,严重影响Shader的执行效率。尽可能用其它方式替换,比如条件操作符、乘法等。

2.6.3 降低Shader的计算复杂度

分析一些OverDraw高或者屏幕占比高的Shader,尽可能或者根据项目要求来简化其计算复杂度,或者利用Shader LOD,写几个简化版本的Shader来对应中低端机器。

2.6.4 减少纹理的读取次数

在Shader中尽量减少纹理的读取次数,比如控制贴图对应的纹元可以一次性读取到变量中,不要反复读取。

2.6.5 Shader LOD

Unity的Shader支持LOD,可以针对中低端机器编写简化版本的SubShader,然后针对性的运行时切换到简化Shader运行。

2.7 尽可能剔除不必要的物体渲染

  1. 合理设置摄像机远平面距离,不渲染远处的物体
  2. 给物体设置不同的Layer,给不同的Layer设置不同的摄像机裁剪距离
  3. 使用Unity自带的遮挡剔除(要烘焙数据,占用额外的包体和内存,可能占用额外的CPU,一般不建议使用)
  4. 自定义的剔除算法,比如检测到物体超过摄像机多远,不渲染或者只渲染部分效果(可以参考项目内的WorldCullingManager,这个是利用Unity自带的CullingGroup来检测角色的距离变化,从而缩放角色的渲染效果)。

2.8 LOD

2.8.1 模型LOD

模型也可以使用Lod,默认情况下是随着摄像机距离变大切换到更简单的Lod。不过,也可以根据任意条件来切换Lod,比如机型匹配,帧率下降等。

2.8.2 动画LOD

可以在切换模型LOD时候,选择更简单的动画状态机,这个Unity是支持分层动画和Mask的,具体参考相关文档。

2.8.3 渲染LOD

  1. 不同的距离开启不一样的渲染效果,类似模型LOD,远处的物体采用更简单的渲染方式。
  2. 不同档次设置采用不同级别的渲染效果,比如切换Shader Lod,关闭一些Pass等。
  3. 不同重要度的物体,比如小怪等,可以关闭一些渲染特性或者效果,或者使用更低的Shader Lod等
  4. 同理,类似的其它一些缩放方式。

2.9 Fake渲染

2.9.1 假阴影

比如角色下面的影子是一个黑色面片,或者使用平面阴影(Planar Shadow)。

2.9.2 公告板

比如公告板技术实现的远处面片树木、面片房子等

三、CPU性能优化

按照第一节的游戏循环的说法,CPU的性能优化主要包括:游戏逻辑和渲染提交。或者更准确的说,应该把CPU性能优化分为渲染优化和其它的优化;其它的优化,主要指的是游戏逻辑相关的优化,具体包括:物体、动画、粒子、UI、资源加载、游戏逻辑(游戏层脚本)、GC等。下面,我们来一个个的做一些经验介绍。

3.1 渲染优化

CPU上的渲染主要包括两个部分,一个是计算需要渲染的物体,另一个是提交渲染和等待渲染完成。

3.1.1 Culling优化

Culling也可以叫做裁剪,实质上裁剪有很多种算法或者方式。从现今的游戏引擎来说,一般是使用层次包围盒(Bounding Volume Hierarchy),来粗略的和摄像机的可视范围做交集来进行裁剪。这一部分通常是在游戏引擎内的,因此我们不能控制。但是,SRP渲染管线提供了一些可能性来做优化。

  1. 隔帧Culling:如果游戏场景更新不频繁,那么可以隔帧或者过几帧才计算一次Culling。
  2. 修改Urp的Renderer中的SetupCullingParameters:该函数控制渲染引擎的裁剪参数,可以尝试修改该函数以提高特定项目的裁剪效率。
  3. 在主线程中自定义Culling逻辑:和2.6的第四点意思一样。

3.1.2 渲染批次优化

本质上是优化主线程提交给渲染线程的指令数目,从而减少渲染线程提交给图形接口的渲染指令数目。对应到Unity上则是各种合批策略的体现。

  1. 自定义Pass:对于要合批的物体,尤其是动态物体,比如特效,最好是定义一个专门的Pass,就不会被其它不相干物体打断动态合批(对于SRP Batcher没发现这种限制)。
  2. SRP Batcher:尽可能所有的Shader兼容SRP Batcher,除非特别低端的机器,这种合批方式对性能的提高很大。
  3. 静态合批:静态合批通常用于场景(SRP Batcher实际上也兼容静态合批),不过静态合批需要打开网格读写,静态合批只合并材质一样的物体,因此静态合批需要美术那边尽量提高材质的重用度后使用的意义才大;同时因为静态合批会合并网格(比如,同样的网格和材质出现多次,会将网格复制多次合并成一个更大的网格)可能会导致包体和内存显著增长;因此,静态合批最适合材质重复度高,网格重复很少的场景。
  4. 动态合批:静态合批的运行时版本,因为有运行时的CPU消耗,类似静态合批也可能会增大内存消耗;有限制,比如要求顶点属性之和不超过900等;适合于动态的小物体的合批,比如粒子特效、小道具等。
  5. Draw Instancing:也叫实例化渲染,适合的场景是网格重复多次(只是朝向、缩放等不一样),材质一样(或者材质属性基本一致的)的情况,比如大规模渲染树木和草地。
  6. 手动合并网格和材质:用软件来离线合并场景内的模型和材质;理论上来说,最自由但是最繁琐,如果场景小的话可以这样试试;合理控制的话,不会显著增大内存和包体,也没有运行时消耗,美术乐意的话,何乐而不为?

3.2 物理优化

3.2.1 降低Unity的物理更新频率

可以在工程设置的Physics选项中关闭Auto Simulation,然后选择在框架更新的时候降低频率(比如2倍的Fixed Timestep)来调用Physics.Simulate来更新物理。同样可以直接设置Fixed Timestep来降低更新频率。

3.2.2 少用或者不用MeshCollider

3.2.3 减少频繁射线检测的使用

可以缓存计算结果或者用更快速的检测方式替代,比如boxcast。

3.2.4 关闭碰撞矩阵中没必要的部分

3.3 动画优化

3.3.1 限制骨骼数目

要求美术制作时候在规定的骨骼数目范围内,骨骼数目会影响动画大小也会影响执行效率。通常80-100已经非常足够了。

3.3.3 动画的CPU性能优化

参考UWA的文章:Unity性能优化 — 动画模块
参考Unity文档:性能和优化

3.4 粒子优化

可以参考UWA的文章:粒子系统优化——如何优化你的技能特效
特效的优化一般在项目的中后期,快上线的时候,针对性的对战斗这种特效集中度很高的场景进行测试和优化。

3.4.1 限制特效的最大粒子数

通常会限制普通特效只能有5-10个粒子或者更小。

3.4.2 限制特效的批次

最好是一个特效能在几个批次或者1个批次内渲染完成。

3.4.3 限制特效使用的贴图尺寸

特效尽量使用小贴图,比如不超256或者512的,尽量都是128或者更细的贴图或者贴图合集。

3.4.3 限制特效的重叠层数和范围

这一部分应该算GPU的优化,可以减少OverDraw和GPU的计算。

3.5 UI优化

参考UWA的文章:Unity性能优化 — UI模块
优化UI的基本原则是:

  1. 减少UI变化重新生成网格:因为UI本质上也是网格加贴图绘制出来的,因此要避免各种操作或者设置到UI频繁变化导致网格重复生成。
  2. 减少UI的射线检测
  3. UI的图集控制:比如一个界面最多2个图集等
  4. 战斗这种3D场景可以不使用UGUI来绘制3D的UI,而是直接用3D网格来绘制,避免UGUI的各种性能消耗。

3.6 资源加载优化

参考UWA的文章:Unity性能优化系列—加载与资源管理

3.6.1 Shader变体的预热

Shader变体的预热比较耗时,可能需要拆分处理。Shader需要优化关键字数目,尤其是全局关键字数目,这个会显著影响Shader的包体和加载进来的内存。

3.6.2 游戏对象池

尽量使用对象池,对象池回收时候可以隐藏GO,也可以选择移动到远处(关闭组件)。

3.6.3 资源管理方案推荐YooAsset

3.7 游戏脚本优化

这里主要讲的是游戏框架和游戏逻辑的代码优化,包括C#和Lua。

3.7.1 游戏框架优化

框架应该尽可能优化,尽可能减少对使用者(游戏逻辑层)带来的性能损耗。

  1. 框架代码尽量不要有GC
  2. 框架代码尽量不要占用额外的大内存
  3. 框架代码尽量不要消耗过多的CPU,使用者不用担心性能消耗

    3.7.2 游戏逻辑优化

    具体的游戏逻辑优化,跟实际的游戏类型有关,需要针对性优化。

3.7.3 常见的脚本优化策略

  1. 缓存计算结果:中间结果或者初始参数尽量预计算好
  2. 不产生GC:任何引用类型的对象都尽量使用缓存池内的
  3. 对缓存友好的存储方式:比如尽量使用小数组存储数据,而不用链表或者字典
  4. 限帧法:限制部分逻辑的更新频率,比如2-3帧更新一次
  5. 多线程:部分独立性很强的逻辑,可以考虑多线程处理
  6. 主次法:比如非关键的角色或者物体,使用更少的计算逻辑
  7. 减少项目的MonoBehavior的更新函数入口,游戏逻辑尽量保持一个更新入口。

3.7.4 Lua代码优化

参考UWA的文章:Unity性能优化系列—Lua代码优化

3.8 GC优化

由于Unity的Mono堆在超过最大值或者一定数值后会自动扩容,而且扩容后无法往回缩,因此必须非常关注Mono堆的峰值。

  1. 降低Mono堆的峰值:这样可以避免Mono堆一直增长或者过大
  2. 降低GC的频率:减少不必要的CPU消耗,尽量使用缓存池中的引用对象

四、资源优化

4.1 纹理优化

这个在优化GPU代码有提到。

  1. 合理降低纹理大小
  2. 尽量使用更高的压缩格式(ASTC更高压缩率)
  3. UI或立绘关闭Mipmap
  4. 减少纹理通道
  5. 提高纹理复用(单色图复用,重复图案复用)

4.2 网格优化

  1. 关闭网格读写:除了特效外的网格关闭读写
  2. 开启项目的顶点压缩:会降低内存和GPU消耗,应该不会影响资源大小
  3. MeshCompression:开启据说会降低网格的资源占用,但是不影响内存占用
  4. 尽量减少面数和顶点数:和美术制作规范,正式资源要符合要求

4.3 动画优化

  1. 压缩方式选择Optimal:官方推荐的方式
  2. 如果没有缩放,去除Scale曲线。
  3. 网上的一些剔除动画原始数据的方法以实测为准,可能剔除后动画文件变小,包体变小了,但是内存中大小不变。

五、内存优化

5.1 优化资源

资源本身都会加载进入内存,因此优化资源本身大小对优化内存大小非常关键,第四节已经讲到。

5.2 优化打包和资源管理

减少打AB包时候的重复,以及智能的资源加载管理方案,可以减少AB包加载后的内存占用,以及去除没必要的资源常驻现象,同时也可以优化资源加载的CPU消耗。

5.3 优化Mono堆

前述已经提到Mono堆只增不涨,因此优化C#的Mono堆内存非常必要。

5.4 优化Native内存

一些插件包括引擎都会占用Native的内存,因此合理使用插件或者检测插件占用的Native内存在某些时候也有意义。如果插件造成的Native内存占用过多,是否可以考虑更换插件?比如音频插件等。

5.5 优化Lua内存

Lua同样有虚拟机有自己管理的堆内存,同样是不能无限增长的。因此,Lua代码也要避免频繁创建新的对象造成GC严重或者导致堆内存一直上升。
UWA和UPR都有检测Lua内存的选项,可以试试。

5.6 减少包体二进制大小

应用都会加载到内存中才会运行,因此更小的二进制包体自然会占用更小的内存。可以尝试剔除一些没有使用的代码(引擎代码或者C#脚本代码),这个Unity打包时候有相关设置。

5.7 配置优化

游戏项目到中后期配置可能会增大非常严重,如果一次性加载可能会造成加载时间过长,同时造成Mono内存增长过大。

  1. 避免一次性加载全部配置到C#中
  2. 如果内存占用过高,考虑其它压缩存储方式,比如二进制存储,不要使用Json
  3. Lua加载配置速度更快,但是配置过大同样内存占用高
  4. 实在占用过高,可以考虑小型数据库存储配置
  5. 是不是该让策划清理或者使用工具清理重复配置,配置本身是否严重冗余?

六、二进制包体优化

6.1 代码裁剪

Unity可以设置裁剪引擎代码,和脚本层代码。经过测试开启引擎代码裁剪问题不大,但是脚本层代码裁剪设置过高可能引起代码丢失问题,可能可以通过link.xml中的设置解决。

6.2 安卓架构

通常ARMv7和ARM64只需要打一个架构,当前ARM64的性能更好但是兼容性不够,关闭一个架构能减少包体。

6.3 其它跟App打包相关的方法

具体请查阅相关文档,参考并且实验是否有效。

七、其它

性能优化是一个迭代的长期工作,关键是底子打好,后期优化压力就小很多;或者明白优化的思路,能够快速定位关键的性能瓶颈。