前言
在使用UE4开发游戏避免不了涉及UMG开发游戏的UI界面,但是UMG实在是有点慢,稍微复杂一些就让人受不了,因此特此记录在自己优化UMG的一些方案和技巧。
后面另起一篇来说硬件和软件结合的优化思路以及优化的本质和分析工具的使用
性能优化的基本思路(wwh)
- 定位耗时(哪里耗时)
- 分析耗时(为什么耗时)
- 解决耗时(如何解决)
第一步:定位耗时
在做优化工作中,这一部分是尤为重要的,能够定位耗时是在什么地方,抓住核心耗时位置来做优化,才会效果显著,事半功倍。
首先,定位耗时可能需要一些工具来帮助我们,而UE4也提供了不少有用的工具
- UnrealFrontEnd
- UnrealInsights
- CommandLine
当然,也能在需要分析代码的前后埋上点,把耗时打印到Log中,以下是常用的时间函数
- UE4:FPlatformTime::Seconds()
- Lua:os.time(), os.clock()
接下来就是定位具体耗时,在UMG+UnLua这套UI框架中,这里总结有几个方面
- Load Asset耗时
所有的游戏都不可能一开始把全部资产都加载到内存中,往往是按需加载,而UI界面就是在需要显示的时候再去加载,而排查加载耗时,就是在调用加载方法的前后埋点。
主要是使用的同步加载方法:LoadObject<>()、UAssetStreamingMgr::SyncLoadAsset()等 - Create Instance耗时
将资产的信息都加载到内存后,下一步就需要创建实例了。
创建UMG的方法:UWidgetBlueprintLibrary::Create() - Add耗时
在创建好实例,就需要将其显示到屏幕空间中,往往会使用AddToViewPort()或在已有的面板下调用AddChild()来展示,在这类方法调用前后埋点进行测量 - Init耗时
最后当然还有一个UI的控制逻辑,这里包含了C++/蓝图/Lua三者共同初始化的耗时。
第二步和第三步往往是紧密结合的,具体问题具体分析,以下会通过举例说明
第二步与第三步:分析与解决耗时
1. 是否Asset引用关联合理
在UE4中加载一个Asset,会提前把这个Asset关联的“硬”引用全部加载。可想而知当一个Asset有大量引用时,在加载这个Asset时就会有大量的关联加载从而造成卡顿
在相同条件下,计算机处理更大的文件就是要更加耗时
- 是否引用复杂和庞大的类信息
主要是子蓝图类,如果子蓝图过多- 解决方案一:“软”引用加载
一般我们首次加载的蓝图中有不可见的子蓝图就可以采用软引用加载,这个是十分有效的减少加载耗时的方法。
需要合理分析那些子蓝图初始可见,条件可见和不可见。
胡乱使用反而会增加首次加载的耗时。
- 解决方案一:“软”引用加载
蓝图GoodCell是由子蓝图IconStatus+子蓝图OperateStatus组成而成,如果首次使用"硬"引用加载,则在加载蓝图GoodCell时,会同时加载子蓝图IconStatus和OperateStatus。但通过分析发现,子蓝图OperateStatus在操作时才会显示,因此初始不可见,这时将子蓝图OperateStatus设置为软引用加载,在首次加载蓝图GoodCell时,只会加载子蓝图IconStatus,此时加载蓝图GoodCell(子蓝图IconStatus)的时间肯定小于未优化的加载蓝图GoodCell(子蓝图IconStatus+OperateStatus)。
在上述基础上,还有一种情形,子蓝图IconStatus是在物品有时间限制时条件显示的,属于是初始条件可见,这个时候如果在条件满足的情况下加载蓝图GoodCell(创建时立即软加载子蓝图IconStatus),此时的加载耗时反而会超越加载蓝图GoodCell(硬加载子蓝图IconStatus)的耗时。因此还可能造成负优化。
所有我们往往要考虑软引用加载子蓝图或子控件的条件和概率,如果一开始初始化肯定不可见,那么毫不犹豫的使用软引用加载,否则还需要斟酌一下可见与不可见的概率。
附懒加载方法
解决方案二:合并子蓝图或拆分子蓝图为基础控件到父蓝图
如果子蓝图不能采用软引用加载,则可以考虑将子蓝图合并,当然合并条件比较苛刻,主要是将分类不合理的合并。
其次是直接将子蓝图的控件移植到父蓝图上,以此来减少类信息和WidgetTree的深度,也是很有效的。
举个例子:子蓝图中就是几张图片,且共用性不强,就能直接把这几张图片移植到父蓝图,而不用封装成一个子蓝图。
反之,如果父蓝图中有较多基础控件,也可判定是否封装成单独Widget用于懒加载。
- 是否引用过多美术资源
在开发过程中,游戏的UI往往为了更好的表现效果,可能会使用大量的不同资源和动画表现,但毫无节制的使用肯定是会引发性能问题的,因此项目初期就需要制定可靠的美术规范。
回到正题,UI方面的美术资源主要是图集,纹理,字体,动画资源,音频等,当引用过多美术资源时,就会造成耗时过高。
解决方案一:预加载,打包图集
预加载是一个比较常见的解决方案,其只是把耗时位置前移了,在性能不敏感的地方进行了提前加载,本质没有减少加载耗时。
举个例子:一款游戏中多少都有几种字体,而字体可能遍布整个游戏的各个地方,但是加载一种字体资源是比较耗时的,如果在需要的时候再加载,就可能造成某个系统产生一定的性能问题,所以字体资源的加载往往会放在游戏初始读条处进行预加载处理。
同样游戏中往往网络发包和回包之间有一个时间间隔,而网络框架是异步的,因此可以使用这个时间间隔进行某些资源加载,也是比较不错的预加载时机。
打包图集也是一个常见优化DrawCall的和耗时的方案。
举个例子:一个界面用到了很多细碎的小图片,如果没有将这些小图片打包成一张大的图集,就会出现用到每一张小图片时都会去加载一次,并且每一张小的图片都会引发一次DrawCall,在一定数量下这个耗时还是非常可观的。 如果将这些小图片打包成一张大图集,则首次会把正常大图加载进内存,后续每次使用都有一次内存访问,但是这样DrawCall就合并成1个了。
而且游戏中都会有一些非常常用的细小纹理,常常会把这些细小纹理(有的也称为精灵体)打成一张大的Common图集一直常驻内存中,在制作系统界面时,满足美术设计需求条件下,尽可能复用公共图集中的精灵体。
这里提一句:单张纹理大小也有一定限制,一般会是2048*2048,4096*4096。甚至更加严苛一点,每个小纹理的大小尺寸都是2的指数。
解决方案二:异步加载,分帧加载
异步加载往往可以大大减少加载对主线程的压力,但是可能会造成不好的表现,需要在设计上下点功夫
而分帧加载可以说是在切蛋糕,将一帧做的事情,平摊到几帧来做,就会明显的感到卡顿感下降,但往往需要在逻辑设计上做好数据/表现层分离。
举个例子:游戏中一般有物品系统,每个物品都有一个图标,而往往会有类似于仓库或者背包系统可以让玩家操作这些物品,但是当打开背包系统的一瞬间,如果同屏物品很多并且物品图标也不尽相同时,就会有大量的加载从而产生比较可观的卡顿感。当然这里就有很多“障眼法”了
- 障眼法1:打开背包的时候,有一个整体打开动画盖在上层,此时物品图标的准备都在非主线程去进行加载,当都加载完毕后,在异步回调里关闭打开动画再显示背包内容。
- 障眼法2:为每一个物品图标准备一个默认的图标,例如?,转圈等,每个物品图标进去之后都进行异步加载,在异步线程回调后重新设置对应图标。
- 障眼法3:不要一开始在一帧内就创建所有的物品控件,而是分在接下来的几帧内再创建完毕。
以上都可以明显的减少玩家的卡顿感。
解决方案三:合并纹理
有点类似于打包图集,但是不同在于,这可能是美术设计不合理。
举个例子:一个背景框是由五张Image拼装而成,分别如下图BGWidget所示,其中涉及到了两个纹理,但这两张纹理复用性不高甚至不会复用,这种情况就是设计不合理,应该合并成一张纹理。
上述的图示中,主要通过将多张Image封装成的BGWidget替换成了单张Image。不仅减少了WidgetTree的深度,类信息量,基础控件的数量,还减少了对资源引用的数量。
2. 是否Asset中控件使用合理
- 是否有废弃节点
因为游戏开发中会快速迭代,面临一些需求和功能废弃,就会产生一些历史遗留不再使用的节点,就需要耐心一些把这些节点一一删除,从而减少资源大小,当然也就减少了加载的耗时了。
注意:UMG动画中的废弃节点也需要一并删除。
- 是否能合并节点
合并节点,有效的减少节点的数量,可以很直观的减少加载压力。
举个例子:一个Switcher控件下有多个图片控件,但其所有图片控件只是颜色值不同,其他参数完全一致,此时我们便可以去掉Switcher控件,只留一个图片控件,颜色值采用代码来动态设置。
Switcher组件是一个可以快速完成功能的组件,但是他的性能往往时不好的,性能要好始终应该遵循按需加载,而这个组件违背了这个原理,因此在使用UMG时要慎用。
当然不仅是Switcher组件可以这样合并,还有很多原理相似的组件的可以通过合并来减少控件数量。
举个例子:游戏中常用的各种列表也是通过合并来解决大量相同Item复制了多份存在,甚至在一屏无法展示下时使用性能更优的循环列表等。
- 是否布局控件可删减
能否使用更少的布局控件达成相同的结果
使用过多的布局控件,很可能加大整个WidgetTree的深度,WidgetTree的收集是递归收集的,如果深度太深,会造成load,create,add等方面的耗时增加。因此在使用UMG时,尽量保证整个WidgetTree扁平结构,必要时甚至可以使用逻辑代码替代布局控件来以此减少嵌套深度的影响。
举个例子:下图是相同结果,但是后者使用的控件数和WidgetTree的深度得到大大的优化,整个WidgetTree保持一个非常扁平的结构。
注意:布局控件的优化不是没有代价的,代价是需要更多的书写代码来控制逻辑和排版,带来了维护困难性和可视化的不便性。因此在性能不是特别敏感的地方可以适当使用,需要多加权衡。
笔者曾遇到过Canvas嵌套Overlay,Overlay嵌套Canvas,以及一些基础组件夹杂其中,足足嵌套了10~20层,导致最后性能非常的糟糕。因此存在比较复杂的嵌套情况,一定要大胆的尝试删减节点深度。
3. 是否逻辑中有重复刷新
在对蓝图控件初始化的时候,每一个控件只需要初始化一次,就算重复进行初始化,也只有最后一次刷新逻辑有效,因此避免重复初始化是减少初始化时间的有效方法
解决方案:有优秀的生命周期管理方法。
4. 是否逻辑中有复杂计算
在逻辑代码中可能很少有纯逻辑计算,但是也可能出现某一部分逻辑比较耗时。
如果出现单纯的计算耗时。
解决方案一: 离线计算
举个例子:在物品系统中,可能通过一个规则计算一类组合物品的Gid,以供识别这一类的组合物品,这类计算可能会有一个比较复杂的规则来保证他的唯一性,此时可采用制作离线工具来将这些Gid离线计算到配置表中,需要时直接取值,以此可避免实时计算的性能浪费。
5. 是否能够合批处理
在逻辑中会有很多的方法,这都是提取出来可复用部分逻辑。因为使用UnLua,就存在Lua与蓝图/C++之间的互相调用,此时这之间的跳转或者背后做的工作往往会有一定耗时,在成一定量级就会展现出来。
当频繁调用耗时函数时出现了性能瓶颈
解决方案一:合并反射调用
在使用UnLua解决方案时,往往会面临,UnrealCallLua和LuaCallUnreal,这之间的内存映射Unlua插件封装好了,但是这可能会有一定量的耗时,受到合批减少DrawCall的启发,因此也能合并反射调用,在Lua侧准备好了所有数据再统一调用一次Unreal,反之亦然。
解决方案二:内联优化(尽量少进行方法跳转,跳转也是会耗时的)受到C++内联优化的启发
在进行方法跳转时,会多进行两次jump指令,虽然可能平时有成千上万的方法,也不见得有什么性能影响,但是规范封装方法的规则就是能复用的才封装,防微杜渐。
笔者曾遇到在Lua中将复杂通用Cell的SetData逻辑从3-4ms优化到0.5ms-1.7ms的经历,需要考量的任何细微的耗时。 (巴不得所有语句都是赋值语句或index语句)
6.能否减少耗时方法的调用(另辟蹊径)
在开发过程中可能我们定位到了某个方法比较耗时,这时我们可以尝试思考是否能避免对其的调用,或换一条路达到相同的效果
举个例子:在UMG中,如果重复的对一个Widget进行AddChild和RemoveChild会比较耗时,因为Add和Remove函数中做了很多工作。但是换一种方法,只第一次进行一次Add,后面不使用时将其SetVisibility为不可见,或者SetPosition一个很大的位置,需要时再设置回来,这样就避免了耗时方法的调用,而达到了满足需求。
7.是否资源过大,能否能对其进行压缩
资源过大还可能是数据量太大了引起,对于硬件来说,加载2M的文件就是要比加载1M的文件更久。此时需要检查数据结构组织是否合理,是否能进行删减,或进行一定的算法压缩。也能最直观的减少加载的耗时。
举个例子:笔者某次优化加载某个Actor的时候,发现这个Actor引用链很简单,也就两张图片,但是加载却要耗时500ms以上,觉得十分诧异,此时首先就想到了去看这个Actor文件的大小。果不其然,一个Actor居然有2070KB大小,分析发现,原来其中存了一个2048x1024的uint8数组,导致直接占用了2M大小,删除之后只有22KB,加载速度一下就提升了很多。
部分测量方法
1. ReferenceViewer
UE4中能直接对一个蓝图查看其引用(右键一个蓝图资源,选择Reference Viewer),并且一般需要关闭深度和宽度限制
官方文档链接:https://docs.unrealengine.com/4.27/zh-CN/Basics/ContentBrowser/ReferenceViewer/
在Reference Viewer中:
- 分左右:当前Asset在中间,左边是那些Asset引用了当前Asset,右边是当前Asset引用那些Asset
- 分颜色:白色线条链接表示“硬”引用,粉色线条链接表示“软”引用。
通过上述对Reference Viewer的观察后,需要考察
2. UE4中加载资源时加载了哪些依赖
通过上述在UE4的源码中添加一行Log日志,能打印出加载一个Widget真正加载了哪些依赖。因为存在复用资源的情况,可能在ReferenceViewer中看到的依赖,并不一定在加载的时候就全部去加载,可能有的依赖已经在之前就加载入内存了。
总结
性能优化说到最后就是: “不卡”。
怎么不卡呢?
- 按需加载/减少浪费:用多少拿多少,不多也不少拿一分
- 合并处理(合批处理):做一件也是做,做多件也是做,为啥不一起做呢
- 合理设计:用更省力,更少的物件拿到相同的结果,何乐而不为呢
- 预加载/预处理/离线计算:现场发挥不理想,线下预先准备好
- 障眼法/切蛋糕:先扔个瓜你先看,回头才是重头戏
- 另辟蹊径:这条路很难走,那换一条试试?
...