2024 年 3 月 31 日,在 Unity User Group 广州站活动上,广州乐牛工作室客户端技术总监-林康亮带来了《重度 MMO 转小游戏》内容分享,介绍了小游戏项目概况、Lua & 资源优化以及管理的推进优化。
本文为演讲全文记录,请点击文末阅读原文下载 PPT。林康亮:大家下午好!我是来自广州乐牛的林康亮,我们主要做 MMO 类游戏。首先今天分享的主要有当前游戏的基础概况和一些 Lua 技术方案。我们今天要讲的游戏是《九灵神域》,属于仙侠类的 MMO,本身也有一些比较重度的表现,比如说全视角等等。这类游戏有个非常大的特点,就是它有巨多的 UI 系统,也有些比较复杂的玩法可以看到非常复杂的 UI 表现,还有非常夸张的技能特效,基本上都是按全屏算的,一屏已经摆不下了。关于当前游戏的资源概况,3D 场景有近 100 个,特效 2 万多个,动作对象 1 万多个,UI 3 万多张,音效 1 千多个,Lua 文件大概是 150 多 M。小游戏第一个大的难关就是 iOS 内存占用,这也是我们当初刚开始转时遇到的最大问题。我们大概是从 2022 年下半年开始介入,那时候市面上没有任何一个重度 Unity 游戏转小游戏,当时跟微信那边磨了很久,探讨各种方案,Unity 还没正式介入这一块。大概 2023 年之后我负责其他游戏,没有再跟进。差不多 2024 年初,又开始重新做回这个小游戏,公司层面重视起来。讲一下基础的数据情况当前选服到选角场景,iOS 的内存占用大概是在 440-450MB 左右,已经接近 APP 端的内存占用了,普通直接转的话,一般的游戏进到这里加上 WASM 内存代码,要到八九百兆,我们当初统计进到游戏大概要到 1G 以上,差不多是优化了几百兆。新手场景当前是在 600M 左右,主城场景是 800M 左右,iOS 的闪退率是在百分之零点多到 2% 左右,运行时长大概是在 40 到 50 多分钟之间。这个数据目前在微信那边是比较 OK 的。关于 Lua 资源优化,我们目前 Lua 的配置未优化前大概是 150M,优化后大概是 100M 左右的文件大小。这可能让人比较惊讶,事实上游戏就是有这么大的一个配置量。内存优化前全部配置大概是 600M,内存优化后大概是在 280M,所以提前预加载所有配置这种方案对我们来说是完全不可行的,最大单表配置物理文件大概是 28M。我们的目标就是控制 Lua 的峰值在 200M 以内,之前没有优化过,分分钟上到四五百,非常恐怖的量。下面讲一下普通的常规优化,第一步就是默认 Key 的提取和 Table 的字符串化。Table 字符串就是把这个 Table 转成一个字符串,这样就不用展开 Table 的占用,处理完单表 190M,就是一个表,比如故事表,大概占用单表的内存是 190M,优化完大概是 61M。第二步就是行依赖,相当于通过递归的方式将重复信息进行提取,配套自动生成的一个解析代码。这里比较简单,相当于每一行生成它的序列号,类似 A 依赖 B,B 依赖 C,C 依赖 D,一层一层递归往上走,然后把那些重复的全部抽出来,这样 10 行只保留一行的数据就行了。通过这样序列化 Table 加抽取行依赖后,这张表就到了 31M。下图是一些配套解析,整个过程是自动生成的。第三步的优化相当于在行依赖的基础上,剥离生成一个二进制,Lua 这边只保留一个 Key 的信息,下图右边这个 value 相当于它的一个偏移值,偏移值读取到的是我们内存里面二进制的偏移长度,然后把整块内容读出来,这样不用在 Lua 里面 request 所有的结构,只需要保留二进制文件就可以。同时上层逻辑基本上不用改太多,就可以直接用起来了。下图可以看到对应的解析代码,内存从 31M 又下降到 13M,这张表进一步优化了。下一步就是配置的代码化公式,这是在技术讨论会时同事提出来的方案,大概意思是,我们把那些没法行依赖的数据,通过自定义代码的方式把它公式化,生成这张表的时候,再去返解回来,去校验这张表是不是完整的。再进一步就是协议化,这个应该是当前传统 H5 的通用做法,把所有大的配置往后端走,根据需要再从后端通过协议去返回。这样优化基本上是目前最优、最极致的,前端也不需要加载一个存储的大表,按需从后端返回,性能基本上可以实现大幅度提升。比如我们之前要去编译一张表,像报表之类的要取一些数字可能要单次遍历,甚至多次遍历,这个消耗实际是非常恐怖的,特别是在转小游戏之后,代码的执行效率其实非常低,远远低于三分之一的数值。因为 Lua 本身就是在虚拟机的基础上再套一层虚拟机,所以 Lua 的解决方案在小游戏这块天然是一个劣势,但是它的好处就是比较方便开发,仅此而已。这种方式缺点很明显,就是你的逻辑代码要做大量的改动,本来之前有很多同步代码,要把一处一处的地方全部找出来,然后通过协议定制的方式改掉。我们当前已经改掉了一部分代码,耗时比较大,但是带来的收益也很高,这个可以作为一个最终手段来尝试。还有就是 Lua 代码的优化,大概是通过 NativeArray 本地层的宿主,将 Lua Buffer 直接传递到 C 层,省略 C# 的中转,直接转进去,这样能稍微提高一点速度。对于 WASM 代码优化,是一小部分的内容。第一点是通过一些工具匹配 Lua 代码,将多余的 Wrap 文件剔除,这样这一块导出的文件数也会大幅下降,因为导出的文件实际上大部分是用不上的,所以这一块的内存可以把它干掉。
第二点是定制 link,只保留一些必须的库,必要时自己写那些库代替 Unity。
第三点是通过一些第三方工具进行代码排序,把 1234567 占用比最大的代码全部找出来,自定义剔除。
第四点是要比较熟练地使用 WASM 工具链查看转化出来的代码。比如上周我们查一个团结引擎导出代码的问题,发现团结引擎导出来的代码比国际版要大很多,内存占用也大很多,差不多一个文件大了 5M。后面查到其实就是团结优化代码的选项跟国际版本内置的指令不一样。WASM 代码的 OPT 工具参数传递的是不一样的,所以它导出来的代码就相当于把内联的函数全部展开了。好处是运行的链路非常短,并且效率很高,但是带来的问题是它的包体加大了,也就是内存加大了。这一块现在优化过的代码,转微信小游戏 BR 后的 Code 代码,全部用最极致的优化大概是 3.98M,加速度 Speed 的话大概是 4.34M,再通过代码分包,这块整体的内存占用就非常低了。
再讲一下资源相关的内容。第一个是慎用微信小游戏带的纹理拆分工具。它的好处是可以在不同平台生成不同的纹理,然后去适配不同平台的显示。但问题是它是会生成非常多的文件,比如说像 UI 有 1000 个,1000 张图片,平台就要下载 2000 份文件,AB 是一份,它拆分出来的纹理是一份,这样会带来大量的 I/O 问题。还有就是它要占据大量文件下载的带宽,所以这一块我们最开始是把整个游戏的纹理全部进行拆分,但是后面发现 I/O 跟文件下载的占用太大了,就把它去掉了,现在基本就是保留了部分 UI 的分离,所以这一块有好处也有坏处,大家根据自己的项目进行取舍。
还有一个问题是要保持 AB 结构,它天然比传统的 H5 多一个文件。传统 laya 等等可能直接一张图片转成 HDC,转成 EDC、PNG 一张图片就搞定了,Unity 这块就要下载两个文件,小游戏无所谓,但是数据量大到一定程度的话,这个是非常恐怖的事情。
我们可以自定义抽取一些 Mesh,比如说一些场景,或者是我的场景的对象等等,把 Mesh 单独抽出来,然后在运行时下载,再进行赋值。好处是可以快速显示内容。这么做的目标不仅仅是 Mesh,包括动画和其他的一些文件也可以按这样的思路去制作,就相当于逐步剥离 AB 的依赖,这是我们当前研究的一个方向。
我不太想继续去接入下一个 AB,然后又要去下一个纹理,下一个 Mesh,这种方案其实对下载非常不友好。这块也问过团结引擎的方案,目前给我的答复是还不能彻底解决这个问题。
加载优化我们也可以尝试一下,当前转小游戏实际上是所有的接口都要转,比如以前用同步的接口,现在就要把所有加载相关的全部改成异步,但实际上微信给我们提供了这些接口能力。下图截取了一些代码,实际上就是取到它的文件系统,通过同步接口读出来,然后用 catch 的方式把它缓存起来,再通过 wasm 转到 C# 这边,去实现同步接口。好处是直接跳过下载去读它的文件缓存,这样就不用走那套下载流程。
但这样的问题是在中低端的机型上有比较大的耗时,我这里截的应该是荣耀 9X,这个文件大概是五六兆的场景文件,它单个文件的耗时大概是 17.7 毫秒,这个问题就很大了,基本上每一帧也就是 30 毫秒左右,那这台机就卡的不行了。这还只是场景,特别是 UI 有大量的对象、大量的图片等等,这块的耗时跑起来也很大,所以同步接口也要慎用,在启动的时候加快启动速度没问题,但是如果想要在游戏内大量使用,就要谨慎一点,自行根据机型判断决定是否启用。
第二是脱离 UI 预制件跟图片的关联,独立进行文件依赖和打包。因为用 Unity 要去做一个 UI,里面有图片,这样就会和图片进行强绑定,所以我们可以在打包的时候把强绑定的依赖全部剥离掉,独立管理这个依赖文件,这样 UI 在打开预制件时就不用管依赖的图片是 A 是 B 还是 C 了,自己去动态管理,这样的生命周期也很好管理,各方面都会更好一点。第三是图集部分,并不是所有图集都应该去打包,我们可以通过 AB 的方式把它包起来。图片光 UI 量就 3 万多张,我们的图片大概有十几张 2048,全部打图集在小游戏完全受不了,现在是采取部分重要的去打图集。绝大部分我们通过单独打包,还有组织一个 AB 的方式把它打包,但它不是一个图集的存在。 第四是尽量控制 Update 机制,控制它的更新频率,比如不同的逻辑实际应该有不同的更新频率。在小游戏里目前有两个最大的问题,第一个是内存,第二个就是 CPU 耗时,CPU 目前不是最大的瓶颈所在,CPU 耗时才是最大的瓶颈,当你解决了内存问题,CPU 就是最头疼的问题,所以一定要去控制好每一个逻辑的 CPU 耗时。大家也可以使用这套工具每一帧去看它的耗时是什么,基本都可以解决。团结引擎有推出自己的一套解决方案,就是尽量单独去抽出一些资源进行打包。第一个是缓存的坑,安卓机目前在微信上当缓存数量达到一定程度时,缓存时间会大幅度提高,需要自己去控制游戏首次缓存的数量。这块微信说要在 2024 年解决,目前不太清楚。第二个是微信压缩工具的坑,文件数量达到一定程度时开始有各种 bug,这块我们准备彻底弃用了,也不太建议大家使用。当初这个工具刚做出来的时候,我也跟微信沟通,互相讨论到底怎么去做,除了解决纹理拆分的问题,也是为了解决 AB 文件的占用、内存的占用,但是后面推出了一些接口之后,有管理内存的销毁,显然不需要用这套工具了,大家可以选择忽略,直接用团结引擎更好一点。第三个是初始化的坑,在 games.js-startGame 这个函数之前调用 SDK 初始化,会直接阻碍微信 JS 主线程的启用,导致启动时有一段较长时间的黑屏。因为我们这边 SDK 初始化的时候占用了 JS 主线程,所以后面把它延到 startGame 之后处理就解决了,这个实际上对游戏的伤害非常大,因为它会极大影响转化率。特别是我们买量从抖音、快手跳转到微信时,要经历一个比较长的链路,首先要打开微信,还要验证,输入密码,再打开小游戏,最终启动再进入游戏,如果你的黑屏时间太长,用户可能觉得是 bug 就直接走了,所以大家要非常重视转化率的问题。最后是 iOS 的坑,iOS 是所有刚转小游戏的团队最大的一个问题,这里除了内存问题,还有就是长时间 1.2G 或以上内存占用,会导致内存释放不了,引发重复闪退,因为内存一直没被卸载,这时关掉微信也没用,最终方案只能重启手机。这点目前还没有一个好的解决方法,唯一的方法就是把你的游戏保持在 1.2G 以下的内存占用。下面主要是一些平时管理的推进优化,比如技术管理。特别像《九灵神域》实际上还有主版本的开发工作,怎么在这么多版本情况下还要去优化小游戏,我们小游戏团队跟 APP 团队是同一批人,没有去分额外的团队出来,版本也是同一周发布。相当于每周有两个版本,周一一次版更,周三一次 APP 端版更,两种版更并行开发,在人员使用上面非常极致。我们也会去制定一些制作小游戏的基础原则。尽量减量不减质。比如打开 UI 时有很多的特效,我们会尽量把额外展现的东西去掉,但是尽可能保留 UI 本身的质量,不去太大降低。第二个是不再兼容 APP 版本内容。在最开始做小游戏版本时,我们想从 APP 端制作完的一些新内容直接带过来,但是后面实践了一段时间后发现这个非常艰难,因为我们在小游戏上面做了非常多的优化,包括逻辑代码等等,完整覆盖过来的工作量反而更大,所以后面就将 APP 端版本跟小游戏版本完全独立开来,不去兼容了,确实有 APP 端的内容要转过来就单独去搞。后面是一些基础的制作原则,团队里面每个人都要注意,大家制作的时候相当于一个协议,协商好按这个原则去制作就行了。目前版本管理的推进优化,包括问题拆解,大概每两周开一个内部的技术研讨会,主要是针对当前版本的小游戏还有哪些可以优化的点,每个技术人员都要提出自己的制作思路。我们有很多优化的点就是项目成员自己独立思考找到的解决方法,单靠一两个人搞不定,因为它涉及到的东西太多了,一定要发挥每个人的主观能动性,调动大家的积极性去完成这么一项大任务。每周版本都会有各种各样的任务,包括一些临时的思路,把它放在取消、延后还有持续进行里面等等。