[Unity]小游戏转换优化笔记
-
做Unity转换小游戏时的心情就像骑着公路自行车上高速:一会觉得它行、一会觉得它不行、还有时候觉得是自己不行。
23.06.03
最近一直在做优化,有些感触,很多方面很重要但又很容易被忽略:
- 小游戏与原生APP相比,就像是8051机之与PC机,内存和运算能力都有限,还不允许开线程,很多在编辑器、APP上能流畅运行的操作,到了小游戏上不一定能一样流畅,开发中不能轻信在编辑器中的运行效果
- 内存使用,官方推荐使用的内存不要超过1G,如果Unity预留托管堆内存超过768M,在有些机器上甚至都跑不起来,而纹理、音频、字体都是吃内存大户,需要注意压缩
- 游戏物体的实例化,在编辑器中只耗时几毫秒,实机运行则可能需要几十甚至上百毫秒,如果一帧内实例化大量物体,很容易造成长时间卡顿或触发单帧内存峰值
- WebGL不支持Addressables的同步加载方式,只支持异步加载方式,开发初期就要考虑到,不然后面改起来遭老罪
- 资源的加载与卸载,用时加载,不用时及时卸载避免占用内存
- 资源包合理分组,避免出现某个包过大、牵扯到很多功能模块的情况、避免包与包之间出现重复引用
23.08.05
断断续续做了快两个月了,继续小总结一下,主要是加载方面。
把官方文档中能做的都做了之后,接下来基本就是游戏自身的调整优化了,对于中轻度单机,影响性能的大概有以下几点(同时也是新手非常容易忽视的):
-
资源加载与卸载
-
复杂物体的实例化
-
单帧内耗时/耗内存逻辑(特别是Awake Start)
-
配置表的加载
这些对启动性能和游戏内动态加载一些物体时的影响较大。比起APP,小游戏对启动时间更为敏感,较长时间的加载很容易造成用户流失,如果设计上是先进入大厅的话加载的负担会少一些,而直接进入战斗的游戏对于加载优化的要求则更高。
对于后者,等所有资源加载完黄花菜都凉了,一个思路是仅集中加载极少部分战斗必要资源(显示上转个菊花),进入战斗后在游玩的同时,陆续加载其他功能模块、UI等等。
Unity转换微信小游戏必须有第一段Unity Loader的加载过程,根据项目情况(主要是C#代码的多少)耗时2~10s甚至更多,从用户体验上来说,第一段Unity Loader进度条走完后,不应该再展示游戏自身的加载进度条了。
这个在设计上优化体验也有很多解决办法,这里说回程序方面。
一招鲜吃遍天
分帧加载,分帧加载,还是™的分帧加载!配合UniTask食用更佳。
资源加载与卸载
启动时加载的资源涉及到的资源包应该尽量少,可以通过Addressables的Event Viewer查看启动时加载了哪些bundle,用Analyze分析引用关系,然后做拆分。
Addressables在加载较大资源时是有可能引起帧数波动的,严重时甚至会掉到10多帧,如果此时没有进度条的保护,而是处于游玩阶段,体验自然会非常糟糕。对于音频,可以考虑用流式传输,用平台提供的接口而不是Unity的接口来播放。
资源的卸载算是基本功,但不知道为什么很多人都做不好(更恶劣的是会做但偷懒不做),做一个功能但不做资源卸载在早期测试中不容易发现问题,但后期甚至线上出现爆内存再来改就为时已晚了。
卸载的原则也很简单就是当一个资源在未来不会用到,或者很长一段时间不会用到,并且有合适的卸载时机,则应该将其卸载以释放内存。卸载的时机可以参考物体销毁和主动GC的时机,比如出副本、切换关卡等等,尽量不要让玩家感知到卡顿。
由于Addressables内部使用引用计数,加载和卸载应该成对出现,LoadAssetAsync
与Release
成对,InstantiateAsync
与ReleaseInstance
成对,引用计数为零时才会真正地卸载。
下面是一个反面教材:private async void InstantiateSomeGo() { var go = await Addressables.InstantiateAsync("SomePrefab").ToUniTask(); // 然后不管了 }
一般来说会自己再封装一层,管理加载过的资源或实例化过的物体,需要时卸载。
复杂物体的实例化
掉帧的元凶之一,物体过于复杂时,实例化耗时越久、牵扯到的资源也可能越多。例如实例化一个含有100个子物体的预制体,首先Addressables要加载它所有直接和间接依赖资源所在的bundle,然后从bundle中加载出这些资源,这是第一个耗时阶段;随后将在1帧内执行100多个物体的实例化,包括所有组件的Awake OnEnable Start,这一趟下来CPU和内存都被搞得叫苦不迭,如果机器也有神,写出这种代码的程序员恐怕是第一批被降下神罚的。
解决办法就是分帧加载,将预制体下的子物体视情况拆分成其他预制体,例如少的话拆成2~3个,多则5~10个,在实例化完父物体后,分帧实例化子物体到父物体下,如果组件的Awake、Start中有耗时的初始化操作,也可将其挪到一个异步的实例化函数中,分帧调用。
对于分帧加载,不太推荐使用协程,书写繁琐可读性差并且容易产生回调地狱,用UniTask来async/await是更加优雅的做法。
一个示例:public class Test { private ParentObj m_ParentObj; public async void LoadParentObj() { // 加载父物体 var parentGo = await Addressables.InstantiateAsync("ParentObj").ToUniTask(); m_ParentObj = parentGo.GetComponent<ParentObj>(); // 延时1帧 await UniTask.DelayFrame(1); // 初始化父物体 await m_ParentObj.Init(); } public void Release() { // 卸载物体 } } public class ParentObj : MonoBehaviour { private GameObject m_ChildA; private GameObject m_ChildB; private GameObject m_ChildC; private GameObject m_ChildD; private bool m_Initiated; // 避免写Awake Start public async UniTask Init() { // 分帧加载子物体,每加载完一个等待一帧 m_ChildA = await Addressables.InstantiateAsync("ChildObjA", transform).ToUniTask(); await UniTask.DelayFrame(1); m_ChildB = await Addressables.InstantiateAsync("ChildObjB", transform).ToUniTask(); await UniTask.DelayFrame(1); m_ChildC = await Addressables.InstantiateAsync("ChildObjC", transform).ToUniTask(); await UniTask.DelayFrame(1); m_ChildD = await Addressables.InstantiateAsync("ChildObjD", transform).ToUniTask(); await m_ChildA.GetComponent<SomeComponent>().Init(); await UniTask.DelayFrame(1); await m_ChildB.GetComponent<SomeComponent>().Init(); m_Initiated = true; } public void Release() { // 卸载子物体 } } public class SomeComponent : MonoBehaviour { public UniTask Init() { //初始化操作 } }
用这种方式加载UI时,可以得到比较好的效果,例如加载一个商店页面,可以做到背景->货架->货品这样一个顺序加载显示,而不是卡住一段时间后再全部显示。
配置表的加载
因项目而异,不同项目有不同的表加载方式,这里只说下目前遇到的问题,之后再看看鲁班的加载是否会有更好的性能。
目前的表加载是自己造的轮子,加载过程大致为:加载JSON文件到内存 -> 转换为JSON对象 -> 将每行数据存到数组中 -> 构建 ID:下标 索引字典 -> 释放资源。
主要耗时在第四步构建索引,需要遍历一遍数据,构建一个Key为ID,Value为数组下标的字典,方便后续根据ID来检索表中某一行的数据。实际测试这一步在不同配置的机器上有很大的差异(应该跟CPU的计算能力有关),一个上万行的表,有些机器不到1s就能跑完,而有些机器则卡住10s之多。
解决办法可以分帧,也可以考虑拆表精简表,之后有空看看鲁班加载这种表的性能如何。接下来是运行性能方面的优化了。
-
23.09.08
已经...没有什么运行性能方面的优化了...
如果在项目之初就已经确定目标平台是移动端或小游戏,在基础框架选型、造轮子、后续的开发中都注意了上面提到的各种问题,每个开发人员都有对性能优化的基础理解,那么只要客户端逻辑不是非常重度,相信性能都不会太差;
但如果项目初期没有做这方面的考量,使用的基础框架未针对目标平台优化,后续开发时较少关注性能优化(或者没有认知),写完功能后缺乏真机环境下测试,连最核心的游戏逻辑单独拎出来都没法在云测试上跑到及格分,这种情况只能说是无力回天,还是多花点时间重构吧