《合金弹头:觉醒》是由腾讯天美工作室开发的“合金弹头” IP 手游。在 Unite Shanghai 2024 游戏生态专场中,腾讯游戏高级工程师田亚涛先生带来演讲,介绍了《合金弹头:觉醒》团队以 Unity 引擎为基础的双端(前后台)方案及其优化过程。内容涵盖为提升 Unity DS 承载能力和前端性能所进行的多房间改造、逻辑多线程等工作,同时分享了团队在此过程中面临的挑战与积累的宝贵经验。
本文为演讲全程实录。
大家好!我叫田亚涛,来自腾讯天美 J6 工作室,2014 年加入腾讯,前后参与了《魂斗罗:归来》和《合金弹头:觉醒》这两款游戏,目前作为《合金弹头:觉醒》的客户端负责人,负责版本研发相关工作。
简单介绍一下《合金弹头:觉醒》这款游戏,它是去年 4 月份上线的一款横版动作射击手游,作为 2023 年腾讯首发战略级新品,上线之初取得了一些不错的成绩,包括上线后 app store 霸榜多日、谷歌热门推荐以及近期欧美日韩发布后的整体表现等等。
今天的分享包括三块内容:《合金弹头:觉醒》作为一款横版动作射击游戏最初的技术选型,和研发过程中所做的两次比较大的结构性改造。
先看技术背景。《合金弹头:觉醒》前作是《魂斗罗:归来》,所以最初的技术方案也是自然地继承。从上图可以看到前后端都是基于 Unity 实现的,在此基础上完成了网络和一些基础模块的搭建,游戏逻辑的部分也是前后端复用。从结构上讲是经典 C/S 架构,同步方面实现了一套基于 UDP/RUDP 的对象同步(包括插值、预测、回滚等)。我们被问到最多的是:为什么要用 Unity 做 DS?回答这问题前先看看当时面临的问题:首先我们是一款强联机的射击游戏,弹道同步实时性要求高且命中感知非常明显,对联机同步的要求很高,同时在反外挂方面要求也非常高。项目早期我们也做过一些方案对比,包括 Unity 只做客户端,服务器单独研发,但这带来的问题就是开发过程中前端和后端面向协议对接。如果要实现服务器跑完整逻辑,就需要两端在逻辑上分别开发,同时还要保证逻辑执行的一致性。另外效率层面也是我们考虑的重点,《合金弹头:觉醒》这款游戏 80% 是联机模式,所以日常开发过程中大量面向联机的内容制作。综合考虑之后决定实现 Unity 上的 DS 方案。来到如何用 Unity 做 DS 的话题,简单地来讲包括几步:构建 Unity 的 Linux 版本、组件裁减、逻辑表现分离、逻辑前后端复用,上图右侧是整个过程。值得一提的是逻辑表现分离,这个做完之后对于后面的两次比较大的改造帮助非常大。这个事情除了技术层面,还涉及研发过程中的规范问题,未来大家有类似的需求是可以提前启动的。前面聊到《合金》80% 的玩法需要联机,同时我们也是一款内容型的游戏,现在的关卡量级差不多接近 1000 关,所以在联机制作的维度最初目标是希望像做单机一样做联机。上面是我们的日常开发环境:客户端开发过程中可以实时查后台 DS 上的表现,调整和调试、暂停都是很方便的,在研发过程中大多数情况不需要关注联机。左边是我方便录屏用的模拟器,目的是我们在手机上体验版本时,遇到联机或 DS 问题的时候可以在编辑器上直接连上来,加快问题的定位和调试效率。用这样一套前后端 DS 的方案,另一个被问到最多的问题是性能。作为后端,性能影响的是体验也是承载,再进一步讲就是成本,所以这块是我们比较关注的。除了在 CPU 侧做了一些逻辑级别的优化如:分帧、降频、合包、逻辑分离等等,我们尝试把逻辑改造成支持 15 帧(这里也是因为前面做了逻辑表现分离,所以能单独控制更新频率),以及 AB part(把 1 帧拆成了上半帧和下半帧两次执行来缓解卡顿问题)。用原生 Unity 编译出来做 DS 的时候,它的线程数非常多,而后端更关注的是进程的使用率,对于线程来讲收益不大,反而会带来一些线程调度上的开销,所以我们做了裁减。最终就是右下图的样子。系统调用也是非常频繁,我们同样做了一些裁减。除了 CPU 方面,大家思考一个问题:承载优化除了 CPU 优化,还有没有其他的,或者我们优化 CPU 承载是不是就上去了?答案不是的。因为我们在算承载的时候要考虑服务器资源本身的 CPU 内存的比例,假设我以比较早的一个服务器 1260 举例(12 核 60G 的配置),拿比较早期的机型举例,按照一局 10% 的 CPU 占比算,12 核 120 局,但是一局占用 3G 的内存,60G 只能 20 局,所以它会卡在内存上的承载是 20 局。为了解决这个问题我们引入了多房间的改造。先看一下 Unity DS 进程默认的样子:构建完是一个进程拉起之后只跑一个实例,也就是一场战斗,就是一个客户端一个 DS 进程。如果一个陪玩 AI 就是一个进程,从资源占用上讲是非常奢侈的。第二就像右边看到的,原始是一个房间,除了游戏逻辑本身,还包括引擎基础消耗、预加载的一系列资源和池子、网络等公共资源都只能同时服务一场战斗,占用还是非常高的,它就会带来 CPU 和内存的不对等。并且前期做复用的时候更多是串行,一局结束重置,之后再开下一局的这种方式。我们希望做的是右边这样子,多个实例可以并行,在一个进程上解决多局并存的问题。这样还有一个好处我们可以降低进程拉起的频率和玩家等待时间。一开始也预研过一些方案,希望在同一个进程上并行多局战斗,即便站在同一个位置它们之间也要完全不影响。考虑过几个方案:第一是空间分割,通过坐标偏移来做这个事情,但问题是《合金弹头:觉醒》有一些大世界玩法本身就是通过坐标偏移、关卡拼接的方式,在此基础上叠加偏移会让复杂度提高的同时精准也会下降。第二是场景叠加,这是 Unity 本来有的能力,即便我们实现了这个能力,它在多局之间也没有办法完全隔离的,因为 Unity 最初的设计也是服务于一个客户端。之后是第三方案,把当前工程逻辑做了一个改造,把它变成了逻辑层的 room,我们要做的是对物理、逻辑和 Unity 组件等模块的隔离工作。Room 包括哪些东西呢,简单来讲就这几块:一个是 scene,包括各种 game object;第二是物理,包括 Physics Scene 和 Physics World;最大一块还是我们的游戏逻辑。除了这几个改造之外,我们一个重点的事情是在这个环节把 Physics 做了一些调整,停掉了全局 Physics,改为通过一个个 Physics world 进行模拟。更新也是一样,我们会放在 Room 里更新。最后我们把之前的调用接口做了替换。除此之外还有大量的 Unity 内置对象需要改造,默认是全部在一起的。之后是游戏状态、基建设施:如网络、Socket 复用、同步、日志系统等。改造完的效果如上方视频,我们可以在 DS 上做一个模拟创建多个房间,然后客户端是多个客户端连上来相互不受影响,这也是我们改造完之后的一个效果。两局连的是同一个 DS,因为我们做了多个房间的隔离,所以它们之间是完全不受影响的。最后聊一下收益的部分,这是一系列优化和改造完的结果:单局 CPU 优化提升了 41.6%,承载提升了 37.4%,提升最明显是内存部分也就是 10 倍。我们把单个进程的所有内存合在一起复用,我们每一局所占的内存也就是运行时的内存分配。我们在外网也做了一些数据的验证,最终上线的时候是以 15 个房间在并行战斗(也支持按玩法配置),这套方案目前应用于《合金》国内和海外正式环境。就在上周我们完成了进一步的优化,把每一个进程里的战斗合并在了一起。基于 Linux 的 fork 的机制把 Unity 进程的拉起方式支持了父子进程的形式,由原来的一个个进程拉起改为由父进程 fork 出子进程。这样好处是什么,首先进程之间只读内存的部分就只有一份,进程之间复用。其次在拉起时间上,我们原本拉起一个进程差不多在 40—50 秒,通过 fork 的方式拉起子进程在毫秒级别,对应进程拉起过程中 CPU 毛刺也明显减少。前面有聊到《合金弹头:觉醒》是一款横版射击游戏,玩法就是在弹幕和清怪,反复地创建和销毁,峰值的时候一屏有 100 多颗子弹、怪也有 50+,并且每一种子弹的伤害非常重,各种的被动事件,同时在玩法层面效果希望越来越酷炫,玩法上希望有一些割草体验,整体在性能面临巨大压力。合金是一款全球化的游戏,需要考虑海外市场和兼容低端设备。目前兼容到的 8 档机是 A73,它是 2017 年 Q3 出的一款设备,性能大概等于现在 8 Gen3 的十分之一,我们需要确保它的基础体验。接起来面临的挑战是性能空间耗尽。除了前面 DS 承载时的一些基于 CPU 优化(前后端复用的,所以前面的优化在客户端也是可以用到的)外,在客户端也做了一系列的优化工作,包括通过 Job/Thread 把一些独立的模块线程化,但随能单独拆出来做多线程的模块越来越少,每个版本对于性能的提升也就变得非常吃力,究其原因可以看图右侧:除了逻辑本身还有一些渲染前的准备工作在主线程完成,主线程负载非常重,而其他线程却很闲,所谓一核有难、多核围观。对于一款去年刚上线的游戏,希望面向长线找到解决方案,于是跟兄弟项目学习了一些过往的实践经验和可行性评估,之后启动了内部叫做开着飞机换引擎的计划,将逻辑从主线程拆出去。最终就是右图的样子,拆一个逻辑线程出来,把逻辑部分全部转移到子线程。这里涉及到的改动主要是几大块:第一是逻辑剥离,这步要做的是把原本依赖 Unity 的组件转移到子线程之后能够正常运行,我们做了一套虚拟引擎,目的是完成子线程后对原本 Unity 能力的承接。在原有研发管线的兼容方面,使用 Prefab、Scene 编辑配置,导出对应的 Component 信息配置,运行时组装对应的 Virtual Engine Object。对于 prefab 之类的序列化,额外做了一层到逻辑线程的映射,编辑器还是用 Unity,然后运行时会自动绑定到虚拟引擎中。在物理方面,我们在前面 DS 多房间改造的时候把物理从全局物理改成了多个 physics world,现在又面临一个问题是把主线程的物理支持多线程。这里考虑的不仅是客户端,还有 DS,在兼容 DS 的情况下解决 Unity physics 非主线程调用的问题。前期评估过几种方案:首先是重写物理实现,好处是不依赖任何三方库,但如何保证模拟结果跟 Unity physics 完全一致还挺难的。其次是直接用原生 PhysX,因为 Unity 引擎底层也是 PhysX,这种方案用于后台 DS 没问题,但是放在前台会出现两套物理并存,对于包体和运行时效率来讲不合适。为了解决这个问题,我们决定将 Unity physics 实现多线程能力,之后在引擎内部实现一套桥接(bridge),使得 Unity 引擎自带的 PhysX 与原生 PhysX 都能兼容。这样的好处是,逻辑层只需要面对这个中间层,对客户端来讲可以直接用原生 Unity 物理。而在 DS 端,我们可以多一种选择:可以直接用 Unity,同时因为逻辑子线程已经实现,我们也可以考虑脱离 Unity 运行,在逻辑子线程中直接使用原生 PhysX。通过这种方案,我们最终成功实现了这一目标。接下来,我们需要处理的是子线程的 Tick 逻辑,这与主线程的处理方式类似。关键的问题是,当我们将逻辑从主线程转移到子线程后,我们如何进行 Tick 操作,以及是否需要进行同步。在寻找解决方案时,我们参考了 Halo 5(光环)游戏在改造过程中的做法(图中模型一):这种做法有几个优点,首先,我可以在主线程开始时就发起一个或者多个线程,就像光环游戏那样。然后,当主线程结束时等待数据返回,整个过程在一帧内完成,既简单效果又好。但是放在《合金弹头:觉醒》来讲不太行,因为逻辑很重,即便放在子线程也很重,如果还采用第一种方案变成了主线程等逻辑线程,会产生画面卡顿。我们再看第二个方案,同样会在 input 之后唤醒逻辑线程,但过程中主线程不做等待,正常往后运行,主线程始终用的是逻辑线程上一帧的数据,这里会有一帧的延迟。当逻辑线程出现卡的时候会没有数据可拿,于是这里做了一些预测和平滑处理来缓解主线程卡顿。在某些情况下也会有问题,举例:移动过程中如果逻辑线程持续没有数据可拿(卡顿),这个时候主线程也不能一直预测,所以这里做了一个兜底的机制,我们会往前预测,但是只预测一个逻辑帧的长度。最终的收益从低端机来讲,本来设备能力有限,在高负载的模式下提升 25% 以上,同理逻辑消耗越重的模式提升越明显,测下来最重的玩法提升 40% - 50%,因为把主线程的逻辑转到了子线程。第二个比较大的收益是帧率稳定性,我们衡量帧率稳定性的标准是一局中大于某个帧率的比例,改造完之后整体地提升了 82%,对于稳定帧率的效果是非常好的。
回顾逻辑多线程改造前期,团队内部对于这个事还是有很多担忧,一方面是能不能做到,另一方面是即便做完了敢不敢上线的问题,毕竟对线上项目做如此体量的改造还是很有挑战的。现在看来第一步完成了,结果还符合预期。同时就在上周我们带着多线程的海外版本在欧、美、日、韩几个区域同步上线,整体运行平稳,预计 Q4 会把多线程版本在国内上线。
接下来是今天内容的回顾:前面跟大家分享了基于 Unity 客户端 DS 的联机方案,在此基础上做了针对 DS 的承载优化,一个是 CPU 侧的优化,另一块是通过 DS 多房间的改造大幅解决内存问题,解决了资源均衡带来的承载瓶颈。之后通过 linux fork 父子进程的方式进一步完成进程间内存复用和把拉起时间从 45s 降低到毫秒内。最后是在客户端维度完成了逻辑多线程改造,把主线程逻辑转移到子线程,解决了一直困扰我们的主线程负载重的问题,在线程同步模型上完成了《合金弹头:觉醒》本身逻辑比较重的解决方案,最终解决延迟和卡顿问题。在物理引擎方面做了两次改造,第一次从单 world 转成了多 world,第二次从单线程改成了多线程。长按关注
第一时间了解 Unity 社区动向,学习开发技巧