Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优(上)
- 作者:admin
- /
- 时间:2022年06月20日
- /
- 浏览:7413 次
- /
- 分类:厚积薄发
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发,例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题,并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题,希望能提供给Unity开发者更多高效的研发方法和实战经验。
今天向大家介绍文章第三部分:以引擎模块为划分的CPU耗时调优,共10小节,包含了渲染模块、UI模块、物理模块、动画模块、粒子系统、加载模块、逻辑代码、Lua等多个模块等常见的游戏CPU耗时调优讲解。
本章包含1-5小节,6-10小节请点击《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优(下)》。
(全文长约14115字,预计阅读时间约30分钟)
文章第一部分《Unity移动端游戏性能优化简谱之 前言》、第二部分《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(上)》、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(下)》可戳此回顾,完整内容可前往UWA学堂查看。
1. 总览
1.1 模块划分
UWA将CPU中工作内容明确、耗时占比一般较高的函数整理划分为:渲染、UI、物理、动画、粒子、加载、逻辑等模块。但这并不意味着模块之间的工作互相独立毫无关联。举例而言,渲染模块的性能压力势必受到复杂的UI和粒子影响,而加载模块的很多操作实际上都是在逻辑中调用并完成的。
划分模块有利于我们确认问题、找到重点。与此同时,也要建立起模块之间的关联,有助于更高效地解决问题。
1.2 耗时瓶颈
当一个项目由于CPU端性能瓶颈而产生帧率偏低、卡顿明显的现象时,如何提炼出哪个模块的哪个问题是造成性能瓶颈的主要问题就成了关键。尽管我们已经对引擎中主要模块做了整理,各个模块间会出现的问题还是会千奇百怪不可一以概之,而且它们对CPU性能压力的贡献也不尽相同。那么我们就需要对什么样的耗时可以认为是潜在的性能瓶颈有准确的认知。
在移动端项目中,我们CPU端性能优化的目标是能够在中低端机型上大部分时间跑满30帧的流畅游戏过程。为了达成这一目标,简单做一下除法就得到我们的CPU耗时均值应控制在33ms以下。当然,这并不意味着CPU均值已经在33ms以下的项目就已经把CPU耗时控制的很好了。游戏运行过程中性能压力点是不同的,可能一系列UI界面中压力很小、但反过来游戏中最重要的战斗场景中帧率很低、又或者是存在大量几百毫秒甚至几秒的卡顿,而最终平均下来仍然低于33ms。
为此,UWA认为,在一次测试中,当33ms及以上耗时的帧数占总帧数的10%以下时,可以认为项目CPU性能整体控制在正常范围内。而这个占比越高,说明当前项目的CPU性能瓶颈越严重。
以上的讨论内容主要是围绕着我们对CPU性能的宏观的优化目标,和内存一样,我们仍要结合具体模块的具体数据来排查和解决项目中实际存在的问题。
2. 渲染模块
围绕渲染模块相关优化更全面的内容可以参考《Unity性能优化系列—渲染模块》。
2.1 多线程渲染
一般情况下,在单线程渲染的流程中,在游戏每一帧运行过程中,主线程(CPU1)先执行Update,在这里做大量的逻辑更新,例如游戏AI、碰撞检测和动画更新等;然后执行Render,在这里做渲染相关的指令调用。在渲染时,主线程需要调用图形API更新渲染状态,例如设置Shader、纹理、矩阵和Alpha融合等,然后再执行DrawCall,所有的这些图形API调用都是与驱动层交互的,而驱动层维护着所有的渲染状态,这些API的调用有可能会触发驱动层的渲染状态地改变,从而发生卡顿。由于驱动层的状态对于上层调用是透明的,因此卡顿是否会发生以及卡顿发生的时间长短对于API的调用者(CPU1)来说都是未知的。而此时其它CPU有可能处于空闲等待的状态,从而造成浪费。因此可以将渲染部分抽离出来,放到其它的CPU中,形成单独的渲染线程,与逻辑线程同时进行,以减少主线程卡顿。
其大致的实现流程是,在主线程中调用的图形API被封装成命令,提交到渲染队列,这样就可以节省在主线程中调用图形API的开销,从而提高帧率;渲染线程从渲染队列获取渲染指令并执行调用图形API与驱动层交互,这部分交互耗时从主线程转到渲染线程。
而Unity在Project Settings中支持且默认开启了Multithreaded Rendering,一般建议保持开启。在UWA的大量测试数据中,还是发现有部分项目关闭了多线程渲染。开启多线程渲染时,CPU等待GPU完成工作的耗时会被统计到Gfx.WaitForPresent函数中,而关闭多线程渲染时这一部分耗时则被主要统计到Graphics.PresentAndSync中。所以,项目中是否统计到Gfx.WaitForPresent函数耗时是判断是否开启了多线程渲染的一个依据。特别地,在项目开发和测试阶段可以考虑暂时性地关闭多线程渲染并打包测试,从而更直观地反映出渲染模块存在的性能瓶颈。
对于正常开启了多线程渲染的项目,Gfx.WaitForPresent的耗时走向也有相当的参考意义。测试中局部的GPU压力越大,CPU等待GPU完成工作的时间也就越长,Gfx.WaitForPresent的耗时也就越高。所以,当Gfx.WaitForPresent存在数十甚至上百毫秒地持续耗时时,说明对应场景的GPU压力较大。

另外,根据UWA的大量项目和测试经验,GPU压力过大也会使得渲染模块CPU端的主函数耗时(Camera.Render和RenderPipelineManager.DoRenderLoop_Internal)整体相应上升。我们会在最后专门讨论GPU部分的优化。
2.2 同屏渲染面片数
影响渲染效率的两个最基本的参数无疑就是Triangle和DrawCall。
通常情况下,Triangle面片数和GPU渲染耗时是成正比的,而对于大部分项目来说,不透明Triangle数量又往往远比半透明Triangle要多,尤其需要关注。UWA一般建议在低端机型(4G内存的)上将同屏渲染面片数控制在25万面以内,即便是高端机也不建议超过60万面。当使用工具发现局部同屏渲染面片数过高后,可以结合Frame Debugger对重点帧的渲染物体进行排查。

常见的优化方案是,在制作上需要严格控制网格资源的面片数,尤其是一些角色和地形的模型,应严格警惕数万面及以上的网格;另外,一个很好的方法是一通过LOD工具减少场景中的面片数——比如在低端机上使用低模、减少场景中相对不重要的小物件的展示——进而降低渲染的开销。
需要指出的是,UWA工具所关注和统计的面片数量并不是当前帧场景模型的面片数,而是当前帧所渲染的面片数,其数值不仅与模型面片数有关,也和渲染次数相关,更加直观地反映出同屏渲染面片数造成的渲染压力。例如:场景中的网格模型面片数为1万,而其使用的Shader拥有2个渲染Pass,或者有2个相机对其同时渲染;又或者使用了SSAO、Reflection等后处理效果中的一个,那么此处所显示的Triangle数值将为2万。所以,在低端机上应严格警惕这些一下就会使同屏渲染面片数加倍的操作,即便对于高端机也应做好权衡,三思而后用。
2.3 Batch(DrawCall)
在Unity中,我们需要区分DrawCall和Batch。在一个Batch中会存在有多个DrawCall,出现这种情况时我们往往更关心Batch的数量,因为它才是把渲染数据提交给GPU的单位,也是我们需要优化和控制数量的真正对象。
降低Batch的方式通常有动态合批、静态合批、SRP Batcher和GPU Instancing这四种,围绕Batch优化的讨论较为复杂,再写一篇文章也不为过,所以本文不再展开来讨论,但在UWA DAY 2020中我们详细讨论和分享了DrawCall与Batch的关系以及这4种Batching的使用详解,供大家参考:《Unity移动游戏项目优化案例分析(上)》。
下面简单总结静态合批、SRP Batcher和GPU Instancing的合批条件和优缺点。
(1)静态合批
条件:不同Mesh,只要使用相同的材质球即可。
优点:节省顶点信息地绑定;节省几何信息地传递;相邻材质相同时, ,节省材质地传递。
缺点:离线合并时,若合并的Mesh中存在重复资源,则容易使得合并后包体变大;运行时合并,则生成Combine Mesh的过程会造成CPU短时间峰值;同样的,若合并的Mesh中存在重复资源,则会使得合并后内存占用变大。
(2)SRP Batcher
条件:不同Mesh,只要使用相同的Shader且变体一样即可。
优点:节省Uniform Buffer的写入操作;按Shader分Batch,预先生成Uniform Buffer,Batch内部无CPU Write。
缺点:Constant Buffer(CBuffer)的显存固定开销;不支持MaterialPropertyBlock。
(3)GPU Instancing
条件:相同的Mesh,且使用相同的材质球。
优点:适用于渲染同种大量怪物的需求,合批的同时能够降低动画模块的耗时。
缺点:可能存在负优化,反而使DrawCall上升;Instancing有时候被打乱,可以自己分组用API渲染。
2.4 粒子系统渲染开销
一般来说,在渲染模块的堆栈中出现的ParticleSystem.Draw,其调用次数代表了测试过程中粒子系统DrawCall的变化情况。粒子系统DrawCall高,会造成较高的半透明渲染耗时。

针对粒子系统DrawCall的常见优化手段如下:
1.从策略上分级减少粒子在中低端机型上的播放量本身就是一种思路。就像MMO游戏作最大同屏人数上限一样,也可以为粒子系统设置不同机型上的最大播放上限。比如多人游戏中,场景中的粒子用缓存池管理,非玩家主视角的技能产生的粒子系统到达一定数量后就不允许实例化和入池、同种粒子也设置缓存池数量上限,从而控制整体数量;
2.使用TextureSheetAnimation来降低粒子系统的DrawCall,从TextureSheetAnimation设置中Mode下拉选单中选择Sprites选项,可以定义要为每个粒子显示的精灵列表,而不是使用纹理上的一组常规帧。使用此模式可以利用精灵的许多功能,例如精灵打包器(Sprite Packer)、自定义轴心和每个精灵帧的不同大小。Sprite Packer可帮助在不同粒子系统之间共享材质,方法是将纹理整理成图集,从而通过动态批处理(Dynamic Batching)提高性能。
3.还有一种非常常见的粒子DrawCall过高的原因是穿插,比如某个预制体下有1-5,5个不同的粒子构成一整个特效,场景中有100个这个预制体。默认情况下总计500个粒子渲染顺序是相同的,此时在Frame Debugger中很有可能会发现此时有500个DrawCall,且呈现为123451234512345......但显然,理想状况下只需要5个DrawCall就够了。可以考虑通过控制Render Queue、Sorting Layer、Order in Layer和Sorting Fudge来重新排布它们的渲染顺序,从而增加粒子系统的DrawCall合批,从而降低粒子系统的渲染Batch数量(具体需要结合项目中粒子的具体制作情况)。
2.5 UGUI UI 渲染开销
通常战斗场景中其他模块耗时压力大,此时UI模块更要仔细控制性能开销。其中,不仅是UI更新的开销,还有渲染层面的开销。一般而言,战斗场景中的UI DrawCall控制到40-50左右为最佳。
在不减少UI元素的前提下,控制DrawCall的问题,其实也就是如何使得UI元素尽量合批的问题。一般的合批要求材质相同,而在UI中却常常会发生明明是使用同一材质、同一图集制作的UI元素却无法合批的现象。这其实和UGUI DrawCall的计算原理有关。详细的原理介绍可以在UWA学堂中学习有关课程《详解UGUI DrawCall计算和Rebuild操作优化》。
在UGUI的制作过程中,建议关注以下几点:
1.同一Canvas下的UI元素才能合批。不同Canvas即使Order in Layer相同也不合批,所以UI的合理规划和制作非常重要;
2.尽量整合并制作图集,从而使得不同UI元素的材质图集一致。图集中的按钮、图标等需要使用图片的比较小的UI元素,完全可以整合并制作图集。当它们密集地同时出现时,就有效降低了DrawCall;
3.在同一Canvas下、且材质和图集一致的前提下,避免层级穿插。笼统地说,应使得符合合批条件的UI元素的“层级深度”相同;
4.将相关UI的Pos Z尽量统一设置为0。Z值不为0的UI元素只能与Hierarchy中相邻元素尝试合批,所以容易打断合批;
5.对于Alpha为0的Image,需要勾选其CanvasRender组件上的Cull Transparent Mesh选项,否则依然会产生DrawCall且容易打断合批。
还有一部分的渲染开销是UI可能对GPU层面Overdraw产生的压力,这里见最后的GPU章节中相关讨论。
2.6 Shader.CreateGPUProgram
该API常常在渲染模块主函数的堆栈中出现,并造成渲染模块中的大多数函数峰值。它是Shader第一次渲染时产生的耗时,其耗时与渲染Shader的复杂程度相关。当它在游戏过程中被调用并且造成较高的耗时峰值时应引起注意。

对此,我们可以将Shader通过ShaderVariantCollection收集要用到的变体并进行AssetBundle打包。在将该ShaderVariantCollection资源加载进内存后,通过在游戏前期场景调用ShaderVariantCollection.WarmUp来触发Shader.CreateGPUProgram,并将此SVC进行缓存,从而避免在游戏运行时触发此API的调用、避免局部的CPU高耗时。
然而即便是已经做过以上操作的项目也常会检测到运行时偶尔的该API耗时峰值,说明存在一些“漏网之鱼”。开发者可以结合Profiler的Timeline模式,选中触发调用Shader.CreateGPUProgram的帧来查看具体是哪些Shader触发了该API,可以参考《一种Shader变体收集和打包编译优化的思路》。
2.7 Culling
绝大多数情况下,Culling本身耗时并不显眼,它的意义在于反映一些与渲染相关的问题。
(1)相机数量多
当渲染模块主函数的堆栈中Culling耗时的占比比较高(一般项目中在10%-20%左右)。
(2)场景中小物件多
Culling耗时与场景中的GameObject小物件数量的相关性比较大。这种情况建议研发团队优化场景制作方式 ,关注场景中是否存在过多小物件,导致Culling耗时增高。可以考虑采用动态加载、分块显示,或者Culling Group、Culling Distance等方法优化Culling的耗时。
(3)Occlusion Culling
如果项目使用了多线程渲染且开启了Occlusion Culling,通常会导致子线程的压力过大而使整体Culling过高。
由于Occlusion Culling需要根据场景中的物体计算遮挡关系,因此开启Occlusion Culling虽然降低了渲染消耗,其本身的性能开销却也是值得注意的,往往不适用于绝大部分移动端游戏场景。这种情况建议开发者选择性地关闭一部分Occlusion Culling去测试一下渲染数据的整体消耗进行对比,再决定是否需要开启这个功能。
(4)包围盒更新
Culling的堆栈中有时出现的FinalizeUpdateRendererBoundingVolumes为包围盒更新耗时。一般常见于Skinned Mesh和粒子系统的包围盒更新上。如果该API出现很频繁,则要通过截图去排查此时是否有较大量的Skinned Mesh更新,或者较为复杂的粒子系统更新。
(5)PostProcessingLayer.OnPreCull/WaterReflection.OnWillRenderObject
PostProcessLayer.OnPreCull这一方法和项目中使用的PostProcessing Stack相关。可以在PostProcessManager.cs中添加静态变量GlobalNeedUpdateSettings,在切场景的时候通过设置PostProcessManager.GlobalNeedUpdateSettings为true来UpdateSettings。这样就可以避免每帧都做UpdateSettings操作,从而减少一部分耗时。

2.8 阴影
阴影是提升游戏画面层次感与真实感的核心渲染效果,但移动端硬件算力有限,不合理的阴影设置会导致同屏渲染面片数翻倍、GPU带宽占用激增,同时增加CPU、GPU端的计算开销,成为性能瓶颈。因此,移动端项目需围绕“分级策略+精准控制”制定阴影优化方案,在表现与性能间找到平衡。
首先,阴影的性能损耗贯穿CPU与GPU两端,主要集中在三个环节:
- CPU端开销。阴影投射物的边界计算(用于生成Shadowmap)、视锥体裁剪、多光源阴影的叠加计算,以及Shadowmap的内存分配与管理;
- GPU端开销。Shadowmap的渲染(本质是额外的全屏绘制)、阴影采样(增加纹理带宽占用)、阴影过滤(如PCF软阴影的多采样计算);
- 资源开销。Shadowmap作为Render Texture,其分辨率直接决定内存占用,例如1024*1024分辨率的Shadowmap,在RGBA32格式下内存占用约4MB,若开启级联阴影(Cascade),内存开销会按级叠加。
基于此,常见问题或关键优化策略如下:
分级策略
阴影是分级优化的核心场景之一,需根据目标机型的GPU算力、内存大小,制定差异化方案:
低端机型上,考虑直接关闭所有动态阴影或仅保留主角等核心对象的简单阴影,或者使用烘焙、平面阴影等低开销策略。优先保证帧率流畅,牺牲非必要的表现细节;
中端机型上,启用较为基础的阴影设置,限制Shadowmap分辨率,关闭软阴影,仅保留主光源的阴影,且Shadow Distance(阴影绘制距离)控制在20-30米;
高端机型上,仍然需要警惕使用软阴影与级联阴影,即便要用也要严格限制相关设置,并可适当提升Shadowmap、阴影距离等设置的规格。控制阴影投射与接收范围
并非所有对象都需要投射或接收阴影,精准筛选能大幅降低开销:
关闭非关键对象的阴影,场景中的小物件(如道具、装饰植物)、远距离对象、半透明物体(如粒子特效),可在Inspector面板中取消Cast Shadows和Receive Shadows;
分层控制阴影交互,通过Project Settings→Graphics→Layer Collision Matrix,配置不同层级对象间的阴影投射规则,减少无效计算;
动态调整阴影距离,结合场景相机的视野范围,在战斗、跑图等不同场景动态修改Shadow Distance。例如,近战场景将阴影距离缩短至15米,大世界跑图时延长至30米,避免远距离对象的阴影浪费性能。优化Shadowmap设置
Shadowmap的参数直接决定阴影的性能开销,需重点把控以下几点:
分辨率控制,移动端Shadowmap分辨率建议控制在10241024,最高不超过20482048;
减少阴影级联数,Unity的级联阴影会将视锥体分为多个层级,每个层级生成独立的Shadowmap,级联数越多开销越大。移动端建议级联数≤2,低端机直接使用1级;
关闭不必要的阴影过滤,软阴影(如PCF4x4过滤)的采样计算量是硬阴影的4至8倍,中端以下机型建议使用硬阴影;
限制阴影投射光源数量,单场景中开启阴影的光源数量建议≤1,优先保留主光源的阴影,点光源、聚光灯等辅助光源直接关闭阴影,或用烘焙阴影替代。烘焙阴影,这里可以说是又一处用内存换性能的范例
对于静态场景(如地形、建筑、静态装饰),烘焙阴影是移动端的最优选择——将阴影信息预先烘焙到光照贴图中,运行时无需实时计算,大幅降低阴影的CPU/GPU开销,其中还可关注:
静态对象标记。将场景中不移动的对象标记为Contribute GI和Receive GI,确保烘焙时包含阴影信息;
光照贴图优化。降低光照贴图的分辨率,启用压缩格式,减少内存占用;
混合烘焙策略。静态场景用烘焙阴影,动态对象(如角色、怪物)用实时阴影,通过光照探针让动态对象与烘焙阴影自然融合,避免画面割裂。技术替代方案
若部分场景必须保留动态阴影,但硬件性能不足,可采用轻量化替代方案,降低开销。
比如平面阴影(Planar Shadows),仅在地面等平面上绘制阴影,通过简单的矩阵计算模拟阴影投射,无需生成Shadowmap,CPU/GPU开销极低,适合风格上偏2.5D的游戏或角色阴影;
又比如投射阴影简化,为动态对象创建低模(LOD 0的1/5面片数),仅用低模投射阴影,不影响视觉效果但减少阴影渲染的面片数;
阴影距离衰减:通过Shader控制阴影的透明度,距离越远阴影越淡,最终在Shadow Distance边界处完全消失,避免远距离阴影的无效渲染。
综上,移动端阴影优化的核心很多时候是“取舍”——通过分级策略放弃低端机的非必要阴影效果,用烘焙阴影等低开销方案替代高开销的实时阴影和精良的阴影效果(结合实际项目需要,可能的方案远不止上文讨论的内容,可参考UWA社区中更多文章),在保证画面可接受的前提下,将性能开销降至最低。
3. UI模块
在Unity引擎中,主流的UI框架有UGUI、NGUI以及使用越来越多的FairyGUI。本文主要从使用最多的UGUI来进行说明。围绕UGUI相关优化更全面的内容可以参考《Unity性能优化 — UI模块》。
3.1 UGUI EventSystem.Update
EventSystem.Update函数为UGUI的事件系统耗时,其耗时偏高时主要关注以下两个因素:
(1)触发调用耗时高
作为UGUI事件系统的主函数,该函数主要是在触摸释放时触发,当本身有较高的CPU开销时,通常都是因为调用了其它较为耗时的函数引起。因此需要通过添加Profiler.BeginSample/EndSample打点或者GOT Online服务+UWA API打点来对所触发的逻辑进行进一步地检测,从而排查出具体是哪一个子函数或者代码段造成的高耗时。

(2)轮询耗时高
所有UGUI组件在创建时都默认开启了Raycast Target这一选项,实际上是为接受事件响应做好了准备。事实上,大部分比如Image、Text类型的UI组件是不会参与事件响应的,但仍然会在鼠标/手指划过或悬停时参与轮询,所以通过模拟射线检测判断UI组件是否被划过或悬停,造成不必要的耗时。尤其在项目中UI组件比较多时,关闭不参与事件响应的组件的Raycast Target设置,可以有效降低EventSystem.Update()耗时。

3.2 UGUI Canvas.SendWillRenderCanvases
Canvas.SendWillRenderCanvases函数的耗时代表的是UI元素自身变化带来的更新耗时,这是需要和Canvas.BuildBatch(见下文)的网格重建的耗时所区分的。

持续的高耗时往往是由于UI元素过于复杂且更新过于频繁造成。UI元素的自身更新包括:替换图片、文本或颜色发生变化等等。UI元素发生位移、旋转或者缩放并不会引起该函数有开销。该函数的耗时取决于UI元素发生更新的数量以及UI元素的复杂度,因此要优化此函数的开销通常可以从如下几点着手:
(1)降低频繁更新的UI元素的频率
比如小地图的怪物标记、角色或者怪物的血条等,可以控制逻辑在变动超过某个阈值时才更新UI的显示,再比如技能CD效果,伤害飘字等控制隔帧更新。
那么,如何定位哪些UI元素在进行频繁更新?这里推荐在Canvas.SendWillRenderCanvases源码中插桩的方式,就可以在运行时输出再进行更新的UI元素有哪些。可以参考雨松大佬的博客的做法:《UGUI研究院之找到具体某个引起了网格重建的UI元素(三十二)》。
(2)尽量让复杂的UI不要发生变动
如某些字符串特别多且又使用了Rich Text、Outline或者Shadow效果的Text,Image Type为Tiled的Image等。这些UI元素因为顶点数量非常多,一旦更新便会有较高的耗时。如果某些效果需要使用Outline或者Shadow,但是却又频繁的变动,如飘动的伤害数字,可以考虑将其做成固定的美术字,这样顶点数量就不会翻N倍。
判断UI元素是否复杂,其实就是看它的UI Vertex数量。使用Unity Profiler自带的UI模块,可以看到相应帧中相应Canvas路径下各个UI Batch绘制的UI元素数量和Vertex数量,快速定位到复杂元素。

(3)关注Font.CacheFontForText
该函数往往会造成一些耗时峰值。该API主要是生成动态字体Font Texture的开销,在运行时突发高耗时,很有可能是一次性写入很多新的字符,导致Font Texture纹理扩容。可以从减少字体种类、减少字体字号、提前显示常用字以扩充动态字体FontTexture等方式去优化这一项的耗时。

(4)使用TMP的情况
使用TMP的项目在出现如过场剧情、聊天框等存在大量文本的功能时都可能出现Canvas.SendWillRenderCanvases的持续高耗时或卡顿耗时。此时在堆栈中往往会出现TMP.Generate等相关节点。这是因为TMP在高频接受大量新的字符并填入动态字体图集时会产生较高的耗时。经UWA实验发现,在调换为静态字体后,相关UI耗时可能会有近一半的大幅度耗时下降。因此前文在内存中讨论的TMP静态图集方案对于耗时也有一定优化效果。
(5)使用UI Particle的情况
在UWA遇到的多数大面积使用UI Particle的项目中,其性能表现都比较糟糕。这是因为UI Particle是继承于UGUI的MaskableGraphic类写的,因此除了会有本身的粒子更新开销外,还会作为UGUI的元素进行更新,而往往其UI Vertex复杂且更新非常频繁,因此容易造成很高的耗时。因此一般建议尽量少用、简化或改为其他方案来呈现类似的效果。
3.3 UGUI Canvas.BuildBatch
Canvas.BuildBatch为UI元素合并的Mesh需要改变时所产生的调用。通常之前所提到的Canvas.SendWillRenderCanvases()的调用都会引起Canvas.BuildBatch的调用。另外,Canvas中的UI元素发生移动也会引起Canvas.BuildBatch的调用。

Canvas.BuildBatch是在主线程发起UI网格合并,具体的合并过程是在子线程中处理的,当子线程压力过大,或者合并的UI网格过于复杂的时候,会在主线程产生等待,等待的耗时会被统计到EmitWorldScreenspaceCameraGeometry中。
这两个函数产生高耗时,说明发生重建的Canvas非常复杂,此时需要将Canvas进行细分处理,通常是将静态的元素放在一个Canvas中,将发生更新的UI元素放入一个Canvas中,这样静态的Canvas由于缓存不会发生网格更新,从而降低网格更新的复杂度,减少网格重建的耗时。
3.4 UGUI CanvasRenderer.SyncTransform
我们常注意到有些项目的部分帧中CanvasRenderer.SyncTransform调用频繁。如下图,CanvasRenderer.SyncTransform调用次数多达1017次。当Canvas.SyncTransform触发次数非常频繁时,会导致它的父节点UGUI.Rendering.UpdateBatches产生非常高的耗时。

而该节点调用次数过高时,应考虑以下四种可能,并采取相应的策略控制调用次数、降低开销:
1.任何UI元素Transform信息变化(如位移、旋转、拉伸,例如飘字这类UI动画)都会导致自身触发一次CanvasRenderer.SyncTransform,若同时发生Transform信息变化的UI元素多,则显然会导致调用次数高(如多人游戏中频繁移动的其他玩家的HUD、战斗场景中频繁弹跳而得出的伤害数字位移动画等)。此时,可以考虑适当限制降低相关变化的更新频率。
2.调用SetActive(True)激活UI元素时,会使当前Canvas下和其父Canvas下所有UI元素都触发CanvasRenderer.SyncTransform,特别地,调用SetActive(False)隐藏UI元素时,不会触发。因此,应考虑将需要频繁激活隐藏的UI元素与其他静态元素进行动静分离。
3.Instantiate实例化UI元素时,会使当前Canvas下和其父Canvas下所有UI元素都触发CanvasRenderer.SyncTransform。
4.Destroy销毁UI元素时,会仅使当前Canvas下所有UI元素都触发CanvasRenderer.SyncTransform,综合第3、4点,应考虑将需要频繁实例化和销毁的UI元素与其他静态元素进行动静分离。
PS:以上API未实际生效时(如对一个已经被激活的对象调用SetActive(True)),则不会触发CanvasRenderer.SyncTransform。
3.5 UI界面切换卡顿
UI界面切换耗时过长也是很多开发者都头疼的问题之一。其实从玩家视角来看UI切换时画面变化幅度大,因此体感上200ms以下的耗时往往并不会造成明显的卡顿感。但尽管如此,实际项目中,如通行证、背包、角色展示界面等较为复杂的UI界面的切换时间超过200ms甚至超过1s的情况并不少见,这就会使得玩家的体验出现明显的打断和不适。
当然,实际上UI界面切换的耗时大多来源于UI元素的实例化和显隐开销,以及一些相应的逻辑耗时。这些本身并不归类到UI模块的函数耗时中,但一些策略就可以很大程度解决这方面的问题。
一种思路是对UI的归类和缓存。一些游戏过程中最常用的UI界面是可以考虑予以缓存的,具体哪些UI值得缓存则需要根据游戏类型或者热点统计数据判断。比如一个养成游戏的角色展示页面、一个Moba游戏的商店页面等等。
还有则是UI的分帧加载。很多项目的通行证页面或者背包页面打开速度很慢,因为其中的图标非常之多。但实际上,可以对其中的UI元素进行重要性梳理后作分帧加载,比如优先加载按钮、重要数值、物品名称等元素,而装饰、图标、分页中的内容都可以放到之后的帧再加载。这样呈现出的效果是,玩家点击时,界面立刻产生了相应,并且重要的交互和信息都可以直接操作,体验将大幅上升。
4. 物理模块
围绕物理模块相关优化更全面的内容可以参考《Unity性能优化 — 物理模块》。
4.1 Auto Simulation
在Unity 2017.4版本之后,物理模拟的设置选项Auto Simulation被开放并且默认开启,即项目过程中总是默认进行着物理模拟。但在一些情况下,这部分的耗时是浪费的。
判断物理模拟耗时是否被浪费的一个标准就是Contacts数量,即游戏运行时碰撞对数量。一般来说,碰撞对的数量越多,则物理系统的CPU耗时越大。但在很多移动端项目中,我们都检测到在整个游戏过程中Contacts数量始终为0。
在这种情况下,开发者可以关闭物理的自动模拟来进行测试。如果关闭Auto Simulation并不会对游戏逻辑产生任何影响,在游戏过程中依然可以进行很好地对话、战斗等,则说明可以节省这方面的耗时。同时也需要说明的是,如果项目需要使用射线检测,那么在关闭Auto Simulation后需要开启Auto Sync Transforms,来保证射线检测可以正常作用。

4.2 Contacts
就像上面提到的,若Contacts始终为0,一定程度上说明项目大概率用不到物理模拟,可以考虑关闭。
而如果我们确实用到物理模拟,则一般碰撞对的数量越多,物理系统的CPU耗时也就越大。所以,严格控制碰撞对数量对于降低物理模块耗时非常重要。
在GOT Online Overview的物理模块中,统计了测试过程中Overlap事件的数量,本质上就是Contacts碰撞次数。

很多项目中可能存在一些不必要的Rigidbody组件,在开发者不知情的地方造成了不必要的碰撞,从而产生了耗时浪费;另外,可以检查修改Project Settings的Physics设置中的Layer Collision Matrix,取消不必要的层之间的碰撞检测,将Contacts数量尽可能降低。
报告中也会有Rigidbody、静态碰撞体数量曲线,以辅助开发者判断物理模块的制作导致的性能问题。
4.3 OnTrigger替换
还一种需要物理模拟的情况,即需要触发的回调逻辑(如OnTrigger,OnCollision等)。但多数情况下,如用来判断角色和场景坐标、人物的交互等,是可以考虑通过其它方式实现的,如Bounds.Contain、Physics.OverlapSphere等API。它们本质也是计算某一点是否在某包围盒范围内,属于较为简单的几何运算,不需要走物理模拟,因此完全替换后最终也可以考虑关闭物理的自动模拟。以下是将OnTriggerEnter的逻辑改为C#脚本逻辑实现的一种参考方式。

4.4 Collider碰撞体
除了碰撞体的数量外,碰撞体的复杂度也会影响物理模块的耗时。默认为模型生成碰撞体,会是接近于模型本体的MeshCollider。但精细度非常高的MeshCollider,即便是在竞技射击类这种对受击判定有着极高要求的项目也可能是完全没有必要的。
多数项目中利用Collider进行相关逻辑判定时,使用复杂MeshCollider并不会有什么实际区别,还会造成非常高的额外开销。
因此一般要尽量少使用MeshCollider,可以用简单Collider代替,即使用多个简单Collider组合代替也要比复杂的MeshCollider来的高效。
MeshCollider是基于三角形面的碰撞,三角形面数越多,运算成本越高;而球体、箱体、胶囊这类简单碰撞体本身形状单一,Unity内部会用更快捷的几何算法进行碰撞运算。
MeshCollider生成的碰撞体网格占用内存也较高;而简单碰撞体重复度高,因而内存中只有几个很小的元碰撞体。
4.5 物理更新次数
Unity物理模拟过程的主要耗时函数是在FixedUpdate中的,也就是说,当每帧该函数调用次数越高、物理更新次数也就越频繁,每帧的耗时也就相应地高。
物理更新次数,或者说FixedUpdate的每帧调用次数,是和Unity Project Settings的Time设置中最小更新间隔(Fixed Timestep)以及最大允许时间(Maximum Allowed Timestep)相关的。这里我们需要先知道物理系统本身的特性,即当游戏上一帧卡顿时,Unity会在当前帧非常靠前的阶段连续调用N次FixedUpdate.PhysicsFixedUpdate,Maximum Allowed Timestep的意义就在于限制物理更新的次数。它决定了单帧物理最大调用次数,该值越小,单帧物理最大调用次数越少。现在设置这两个值分别为20ms和100ms,那么当某一帧耗时30ms时,物理更新只会执行1次;耗时200ms时也只会执行5次。

所以一个行之有效的方法是调整这两个参数的设置,尤其是控制更新次数的上限(默认为17次,最好控制到5次以下),物理模块的耗时就不会过高;另一方面则是先优化其它模块的CPU耗时,当项目运行过程中耗时过高的帧很少,则FixedUpdate也不会总是达到每帧更新次数的上限。这对于其它FixedUpdate中的函数是同理的,也是基于这种原因,我们一般不建议在FixedUpdate中写过多游戏逻辑。
5. 动画模块
围绕动画模块相关优化更全面的内容可以参考《Unity性能优化 — 动画模块》。
5.1 Mecanim动画系统
Mechanic动画系统是Unity公司从Unity 4.0之后开始引入的新版动画系统(使用Animator控制动画),相比于Legacy的Animation控制系统,在功能上,Mecanim动画系统主要有以下几点优势:
(1)针对人形角色提供了一套特殊的工作流,包括Avatar的创建以及Muscles肌肉的调节;
(2)动画重定向(Retarting)的能力,可以非常方便地把一个动画从一个角色模型应用到其他角色模型上;
(3)提供了可视化的Animator编辑器,可以快捷预览和创建动画片段;
(4)更加方便地创建状态机以及状态之间Transition的转换;
(5)便于操作的混合树功能。
在性能上,对于骨骼动画且曲线较多的动画,使用Animator的性能是要比Animation要好的,因为Animator是支持多线程计算的,而且Animator可以通过开启Optimized GameObjects进行优化,具体细节可以参考UWA学堂的课程《Unity移动游戏中动画系统的性能优化》。相反,对于比较简单的类似于移动旋转这样的动画,使用Animation控制则比Animator要高效一些。
5.2 BakeMesh
对于一两千面这样面数较少且动画时长较短的对象,如MOBA、SLG中的小兵等,可考虑用SkinnedMeshRenderer.BakeMesh的方案,用内存换CPU耗时。其原理是将一个蒙皮动画的某个时间点上的动作,Bake成一个不带蒙皮的Mesh,从而可以通过自定义的采样间隔,将一段动画转成一组Mesh序列帧。而后在播放动画时只需选择最近的采样点(即一个Mesh)进行赋值即可,从而省去了骨骼更新与蒙皮计算的时间(几乎没有动画,只是赋值的动作)。整个操作比较适合于面片数小的人物,因为此举省去了蒙皮计算。其作用在于:用内存换取计算时间,在场景中大量出现同一个带动画的模型时,效果会非常明显。该方法的缺点是内存的占用极大地受到模型顶点数、动画总时长及采样间隔的限制。因此,该方法只适用于顶点数较少,且动画总时长较短的模型。同时,Bake的时间较长,需要在加载场景时完成。
5.3 Active Animator数量
Active状态的Animator个数会极大地影响动画模块的耗时,而且是一个可量化的重要标准,控制其数量到一个相对合理的值是我们优化动画模块的重要手段。需要开发者结合画面排查对应的数量是否合理。

(1)Animator Culling Mode
控制Active Animator的一个方法是针对每个动画组件调整合理的Animator.CullingMode设置。该项设置一共有三个选项:AlwaysAnimate、CullUpdateTransforms和CullComplete。
默认的AlwaysAnimate使得当前物体不管是不是在视域体内,或者在视域体被LOD Culling掉了,Animator的所有东西都仍然更新;其中,UI动画一定要选AlwaysAnimate,不然会出现异常表现。
而设置为CullUpdateTransforms时,当物体不在视域体内,或者被LOD Culling掉后,逻辑继续更新,就表示状态机是更新的,动画资源中连线的条件等等也都是会更新和判断的;但是Retarget、IK和从C++回传Transform这些显示层的更新就不做了。所以,在不影响表现的前提下把部分动画组件尝试设置成CullUpdateTransforms可以节省物体不可见时动画模块的显示层耗时。
最后,CullComplete就是完全不更新了,适用于场景中相对不重要的动画效果,在低端机上需要保留显示但可以考虑让其静止的物体,分级地选用该设置。
(2)DOTween插件
很多时候,UI动画也会贡献大量的Active Animator。针对一些简单的UI动画,如改变颜色、缩放、移动等效果,UWA建议改用DOTween制作。经测试,性能比原生的UI动画要好得多。
5.4 开启Apply Root Motion的Animator数量
在Animators.Update的堆栈中,有时会看到Animator.ApplyBuiltinRootMotion占比过高,这一项通常和项目中开启了Apply Root Motion的模型动画相关。如果其动画不需要产生位移,则不必开启此选项。

5.5 Animator.Initialize
Animator.Initialize API会在含有Animator组件的GameObject被Active和Instantiate时触发,耗时较高。因此尤其是在战斗场景中不建议过于频繁地对含有Animator的GameObject进行Deactive/Active GameObject操作。对于频繁实例化的角色,可尝试通过缓冲池的方式进行处理,在需要隐藏角色时,不直接Deactive角色的GameObject,而是Disable Animator组件,并把GameObject移到屏幕外。
5.6 Meshskinning.Update和Animators.WriteJob
网格资源对于动画模块耗时的影响是十分显著的。
一方面,Meshskinning.Update耗时较高时。主要因素为蒙皮网格的骨骼数和面片数偏高,所以可以针对网格资源进行减面和LOD分级。
另一方面,默认设置下,我们经常发现很多项目中角色的骨骼节点的Transform一直都是在场景中存在的,这样在Native层计算完它们的Transform后,会回传给C#层,从而产生一定的耗时。
在场景中角色数量较多,骨骼节点的回传会产生一定的开销,体现在动画模块的主函数之一PreLateUpdate.DirectorUpdateAnimationEnd的Animators.WriteJob子函数上。
对此开发者可以考虑勾选FBX资源中Rig页签下的Optimize Game Objects设置项,将骨骼节点“隐藏”,从而减少这部分的耗时。
5.7 GPU Skinning/Compute Skinning
特别地,对于Unity引擎原生的GPU Skinning设置项(新版Unity中为Compute Skinning),理论上会在一定程度上改变网格和动画的更新方法以优化对骨骼动画的处理,但从针对移动平台的多项测试结果来看,无论是在iOS还是安卓平台上,多个Unity版本提供的GPU Skinning对性能的提升效果都不明显,甚至存在负优化的现象。在Unity的迭代中已对其逐步优化,将相关操作放到渲染线程中进行,但其实用性还需要进一步考察。
对于大量同种怪物的需求,可以考虑使用自己实现的《GPU Skinning 加速骨骼动画》,和UWA开源库中的GPU Instancing来进行渲染,这样既可以降低Animator.Update耗时,又能达到合批的效果。
5.8 Spine
Spine作为一种动画方案,在移动游戏中使用率也相当高。但它本身的性能表现比较一般,在实际使用过程中可能会造成意想不到的极高开销。
在运行时,Spine的耗时主要体现在SkeletonAnimation.Update和SkeletonAnimation.LateUpdate、或SkeletonGraphic.Update和SkeletonGraphic.LateUpdate这几个函数中。差别是用一般的组件则是前者,用UI上的Spine组件则是后者。Spine作为频繁更新的复杂动画方案,在UI上可能造成额外的更新耗时,因此一般能用一般组件制作的效果尽量不要用UI组件。Spine的耗时高,一般是上述函数单次耗时高的同时调用次数还高导致的,而这两个因素受到本身美术资源制作的复杂度、和运行时同时播放更新的Spine组件数量影响。此外,Spine还可能对渲染压力、堆内存占用等性能问题也有影响。
以下讨论几个常见的Spine性能问题和优化方法:
(1)Spine的美术制作。Spine作为一种动画方案,同样会因为本身Timeline复杂、骨骼节点多而导致在Runtime时更新耗时高。因此,在编辑器阶段,应请美术同学清理用不到的Timeline信息,移除没有用的骨骼、插槽,对每个动作尝试用Spine引擎自带的Clear进行清理,尽量少用Clipping等等。当然,一定要使用二进制方式导出Spine资源,其耗时和堆内存表现要远比Json好的多。
(2)Spine组件数量。也需要监控和控制处于更新状态的Spine数量,关注高压场景的Spine数量是否符合设计预期。一些简单的动画不应用Spine制作,应尽量考虑其他方案。
(3)Update When Invisible设置。SkeletonAnimation组件上存在和Animator的Culling Mode类似的设置Advanced-Update When Invisible。设置为Nothing后,视域体外的Spine动画都停止更新,耗时将得到大幅降低。SkeletonGraphic组件上也有该设置,但需要配合父结点上的RectMask2D才能生效。
(4)Freezing设置。SkeletonGraphic组件上还有Freeze属性,勾选后相关更新会直接Return,不造成任何开销的同时Spine动画也静止了。这个属性适用于某些Spine动画需要条件才触发,其他时候是静止的。则可以在代码中设计为平时是Freeze的,条件出发后才更新,能够节省平时的耗时。该设置在SkeletonAnimation中没有,但完全可以自己调整Spine的Runtime源码实现类似效果。
(5)Spine通过SRP Batcher合批。只要在官网下载替换支持SRP Batcher的Shader,Spine在URP项目中也能通过SRP Batcher合批,降低渲染开销。具体可参考:https://answer.uwa4d.com/question/6100d3094f8c177460171597。
(6)Overdraw。Spine一般作为一种半透明渲染物体,当制作较为复杂时,也可能使得叠层过多,对GPU渲染也造成压力,同样需要在美术层面就予以关注。
(7)通过按需加载降低Spine堆内存占用。一些游戏中,随着不断迭代,部分角色的Spine资源可能包含了大量的皮肤、动作等信息,若这些信息进入游戏则一次性全部加载,则会对堆内存产生严重负担,相关的加载耗时也会很高。做拆分确实可以一定程度上解决该问题,但是需要重新导出资源、调整相关代码,且增加了管理维护这些资源的成本。其实也可以考虑一种按需加载的方式,即游戏过程中需要哪些信息则自动加载相应部分进内存,具体做法可参考:《Spine动画加载优化思路》。
本文内容就介绍到这里啦,更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。


