我们曾在四年前对于 Unity 的主流模块的性能优化知识点逐一做过讲解,俗称 “小白版”。随着这几年引擎本身、硬件设备、制作标准等等的升级,UWA 也不断更新优化规则和方法并持续输出给广大开发者。作为"升级版"的性能优化手册,【Unity 性能优化系列】将力图以浅显易懂的表达,让更多开发者受用。本期就将分享加载与资源管理相关的知识点。
经常会有一些团队询问:为什么我的游戏加载这么缓慢呢,能否做到像 *** 游戏一样秒切场景呢?游戏发烫又是什么原因呢?为了防止大家辛苦做出来的游戏在真机上卡成翔或者秒变暖手宝,我们要充分利用好 UWA 报告中的加载模块和资源管理模块,下文我们将来逐步分析。
这里我们先来科普一些加载相关的关注指标。下图是 Overview 报告中的加载模块的页签,我们看到左边有这么几项重要参数指标:
1、Loading.UpdatePreloading
这是 Unity 引擎最主要的加载函数。该项一般在切换场景时或异步加载资源时开销较大。一般来说,加载资源越多越复杂,则其 Load.UpdatePreloading 的耗时也越大。
该函数优化前,建议先定位其耗时占用瓶颈。通过报告的 CPU 调用堆栈即可查看该函数在运行过程中的详细堆栈走势,对函数的耗时分配一目了然,从而有的放矢的进行优化。
2、Resources.UnloadUnusedAssets
该函数指卸载未使用的资源,开销主要取决于场景中的 Assets 和 Object 的数量,数量越多,则耗时越高。在性能优化时,除了耗时峰值之外,我们还需关注该函数的调用次数。
一般情况下,场景切换过程中, 引擎会自动调用一次,UWA 建议在 10~15 分钟的时候手动调用一次。
同时,研发团队可尝试在游戏运行时,通过 Resources.UnloadAsset 来去除已经确定不再使用的某一资源,该 API 对于去除单一资源的效率很高,同时也可以降低 Resources.UnloadUnusedAssets 统一处理时的压力。
下图为报告中加载相关函数的堆栈信息,在堆栈中 GarbageCollectAssetsProfile 是由于调用了 Resources.UnloadAssetsUnused 导致的,如果此项占用过高,则需要关注是否主动调用 Resrouces.UnloadUnusedAssets 过于频繁。
3、GC.Collect
GC 调用频率主要受堆内存影响,当函数的堆内存分配量越多、越频繁,GC 就会越快到来。所以当我们的 GC.Collect 函数的调用频率较为频繁(如下图所示),特别是随着游戏运行时间增加,越来越频繁时,就需要我们留意是否存在高分配、频繁分配堆内存的函数操作了,这部分就可以借助 GOT Online 的 Mono 模式排查是否有 Mono 分配过快或过高的现象。
4、Instantiate
这里统计的是资源实例化的耗时,当项目的资源越复杂、实例化数量越多,卡顿感就越明显,但这部分往往是被大家容易忽略的,那 UWA 是如何处理好这部分的问题呢?下文我们将结合 UWA 真人真机测试报告中【资源管理】模块来进行具体讲解。
这里的资源管理讲的是资源的调用频率、耗时等策略,因为影响加载体验的无非两个角度:加载的频率和每加载一次的耗时。在真人真机测试的报告中,我们可以看到【资源管理】标签后,包含以下检测项:
这么多功能,我们要关注哪些细节呢?说下几个核心点:
1、关注耗时较高的加载
无论是 AssetBundle 还是资源加载,耗时较高的都需要重点关注。这里我们打开一个资源加载的页签,可以看到下方是整个运行过程中的资源调用详情,最后一栏是耗时。
在资源具体信息中,勾选某个资源,就可以看到它在运行过程中的调用细节。对应上面的截图,我们可以进一步排查下这个 AssetBundle 加载是否需要那么多耗时。
2、短期时间内调用次数密集的重点关注
无论是 AssetBundle 还是资源加载,都要关注加载的频率。通常对于频繁加载的对象,我们可以通过建立缓存池的方法,先加载一次后将其加入缓存,后续就无须进行加载了。
如下图中,这些频繁加载的 AssetBundle,可能原来有每次 5ms 或者 50ms 的耗时,后面可直接为 0。
这里再提下,我们也需要留意一帧内相同资源被多次加载的问题。
如下图,这一帧里调用 5 次,这个是不对的。
3、留意不存在资源
在资源加载的列表中,有的项目会出现【不存在】资源的情况,说明这些资源都是由于不在指定路径下导致加载失败的资源。一般情况下,这类资源是伴随着版本迭代,进行删除/迁移后,没有修改/注释对应的代码导致的。
加载这些【不存在】资源仅导致了一小部分 CPU 开销,但更重要是,排查这些【不存在】可以避免逻辑上的问题导致闪退和卡死等现象。
4、频繁实例化/Destroy
操作次数较高或耗时较高的资源。频繁的 Instantiate 会造成一定的堆内存分配,从而会加快系统调用 GC 的频率。更重要的是,频繁的实例化会造成 CPU 耗时产生一定的峰值,导致游戏的流畅性受到影响,所以这部分也是我们需要关注的。
对于这种频繁实例化的资源,通过缓存池复用实例化次数过多的 GameObject,进而减少 GameObject 实例化的耗时。
5、Activate 和 Deactivate
这个排查方式与实例化是类似的,主要关注调用频率和耗时。
对比 Activate 和 Deactivate 的调用次数,因为如两者相差过大,说明存在无用的 Activate/Deactivate 操作。
例如,某个资源的 Activate 操作次数非常多(如下图中的 Gold_2 和 Gold_4),为什么次数那么高?是否有必要呢?我们可复制该资源名称,在 Deactivate 资源列表中进行搜索查看是否确实需要这么多次状态的激活。
Gold_2 的 Deactivate
Gold_4 的 Deactivate
这说明相差的 1 万多次的 Deactivate 操作都是无意义的。
对于以上这种资源,我们可以通过在 C# 端创建一个特例缓存,记录这个对象的 Active 的状态(True or False),在调用 SetActive 之前,先判断一下当前的状态是否已经是想要切换到的状态,如果不是才调用。这是因为 SetActive 的操作是会从 C# 走到 C++ 层的,所以我们在 C# 进行状态判断可以减少这种跨语言的操作,从而避免不必要的耗时。
6、AssetBunde 驻留优化
之所以关注这个参数,是因为它影响了项目运行过程中的内存占用,要知道 Unity 内存一部分是由 AssetBundle 驻留导致的 Serializedfile 相关的,一般来说我们建议控制的 AssetBundle 资源数量在 1000 以下。考虑到这个指标和项目本身的复杂度有关,所以大家需要自身做些实验,好权衡 CPU 和内存之间的天平。
资源的加载可以使用缓存池的方式来进行优化,AssetBundle 的加载也是类似的。对于同一个 AssetBundle 进行频繁的加载通常是不合理的(如下图所示),对于频繁加载卸载的 AssetBundle,建议将其加入缓存,常驻于内存中。
Shader 资源如果解析加载策略不当,也会造成 CPU 开销较大。由于 Shader 的内存占用很小,但是加载的耗时又比较高,所以我们建议在理想情况下是在项目开始运行时就把所有的 Shader 资源全部加载完成,然后缓存。
1、Shader.Parse
该函数的耗时主要是由于 Shader 的加载和解析,通常是由于 Shader 的重复加载导致的,在优化时要看一下具体的 Shader 加载情况,具体可以从以下三点着手:
(1)避免使用 Standard,使用其他 Shader 代替 Standard Shader。注意排查是否因为模型导入而导致 Standard Shader 被加载进入 AssetBundle 中;
(2)解决 Shader 冗余问题,这部分可以结合 Shader 的内存走势查看,如下图所示。
如果大家的 Shader 资源并不是缓存在内存中的,切出场景时则会释放 Shader,切入场景会加载 Shader,导致了大量的重复开销。解决这个问题,只需要把 Shader 进行剥离,通过依赖关系将其做成单独的 AB,然后加载后就缓存住不卸载,那么后续就不需要再对此 Shader 进行加载了。
(3)减少 Shader 的 Keyword。
研发团队可以参考下面的资料:
《一种 Shader 变体收集和打包编译优化的思路》
https://answer.uwa4d.com/question/5da86670e84db43d6efbda72
2、Shader.CreateGPUProgram
该 API 的 CPU 占用是 Shader 第一次渲染时创建 GPU 程序的耗时,其耗时与渲染 Shader 的复杂程度相关。对此,建议研发团队将 Shader 通过 ShaderVariantCollection 进行加载,并在加载后并 Warmup,从而避免 Shader 在游戏运行时产生 Shader.CreateGPUProgram 的耗时。
以上就是加载在优化时需要关注的一些问题,如何操作还需要大家结合项目实际情况,同时结合 UWA 的线上测评服务可以快速地帮助大家定位到性能瓶颈。