今年7月,朝夕光年自研的卡渲风格MMOARPG《晶核》获得了版号,并在9月开启了第一次测试。
晶核
723万人下载 2G
游戏采用了立体箱庭式的关卡结构,每个大章节由多个线性副本连通组合而成,当玩家攻略整个章节后,关卡还会以一个大范围探索区域呈现,并增加新的隐藏道路和探索内容。
据制作组称,他们想要结合开放世界和传统副本推图游戏的各自优势,既保留传统副本游戏的战斗连贯性和目标感,也可以融入一些场景的沉浸感、探索感。
不过,制作组表示《晶核》也遇到了不少挑战。比如为了保证美术效果具备市场竞争力,游戏的大型箱庭从设计案到最终呈现,即便不考虑手机端的性能优化,也至少需要8个月的制作周期,这期间会存在很多不确定性和返工。而在卡通渲染、战斗设计、同步机制这些技术难度较高的环节,他们也经历了长时间探索。
在上周的2022虚幻引擎技术开放日上,《晶核》的服务器架构师刘豪就分享了项目遇到的一些技术难题和解决方案。
同时,他还表示目前团队保持着200人以上的规模,正在制作多人团队副本玩法和大型公会玩法,这些玩法将会是玩家后期游玩的核心内容。而在这些玩法中,因为玩家的数量众多,游戏也面临着不小的性能压力。
刘豪说,他们的目标是尽量在不降低画面表现的前提下,让玩家能够体验到一个高帧率、高流畅度的多人ARPG。
以下是葡萄君整理的演讲内容:
大家好,我叫刘豪,是《晶核》项目的服务器架构师,今天我的分享主题是《虚幻DS的机遇与挑战》。
分享主要分三个部分,虚幻DS(Dedicated Server,专用服务器)带来的机遇、《晶核》项目碰到的一些技术挑战以及应对方案,最后会给出一些使用虚幻DS的建议。
对于我们来说,虚幻DS有很多优势,其一是它依托于虚幻引擎,能够提供丰富的组件和插件,包括地图、寻路、同步、AI、技能等等,这些部分的内容都不需要我们再去自己实现了。
其二就是虚幻拥有非常好用的编辑器,而且功能强大,可以直接启动多个服务器和客户端,方便我们进行调试。策划同学也能通过编辑器更方便地创建资产、增加技能等等,避免了我们需要从头实现配置,等编辑器。
第三个就是因为虚幻引擎是代码开源的,在我们遇到一些引擎中尚未支持的功能时,也可以直接在源码上进行修改,这进一步增加了我们使用虚幻引擎开发的灵活性。这些优势帮助我们在项目初期很快就搭建起了多人在线的游戏模式,帮我们节约了很多的时间。
但是在《晶核》的后续开发过程当中,我们仍然遇到了很多的技术挑战,需要一一攻克。接下来就介绍一下《晶核》开发过程中碰到的一些技术挑战,以及我们的解决方案。碰到的技术挑战主要包含了业务功能、系统承载、性能优化以及更新维护相关的挑战。
首先说一下业务功能碰到的挑战。《晶核》是一个MMOARPG类型的游戏,因此它同时存在着MMO中繁杂的业务功能和ARPG中复杂的技能机制。
说到MMO的业务功能繁杂,这是我们简单列出来的一些游戏中需要实现的业务功能。既有单服的登陆、创角、任务、装备、强化、商会等等,也有跨服的交易行、排行榜、PVE、PVP等等,还有场景、副本、移动、战斗这些功能。所有这些业务都在DS中实现显然是不现实的,所以我们的做法是让DS只做它擅长的场景、战斗相关的业务,剩下的这些业务由我们自己写的逻辑服务器来实现。
最终达到的效果就是这样:一组服务器中既存在负责外围系统的逻辑服,也存在负责场景战斗的场景服,客户端与逻辑服和场景服保持了双线连接,同时逻辑服和场景服之间也要保持链接以进行数据交换。在这种结构下,我们就可以专注于在DS上做它擅长的内容,这样可以降低场景服开发的复杂度,也能够更好地把控场景服的质量。
《晶核》的ARPG属性导致我们需要开发的技能机制非常的复杂,我们现在有8个转职之后的职业,每个职业有二十多个各具特色的技能。而且还需要考虑移动、跳跃和位移技能、空中战斗结合在一起的情况,在开发的时候碰到了和位移相关的很多问题。接下来通过一个视频给大家简单展示一下《晶核》中的技能机制。
视频见公众号原文
通过视频大家可以看到,游戏中远程、近战职业都有很多复杂的技能机制,还存在很多技能期间的位移和受击表现。像追击、浮空连击,空中技能、下砸等情况都是需要考虑的。
最初我们使用的是移动和战斗都带预测的方式实现,但是因为受击以及其它复杂状态的存在,会经常出现客户端释放技能,服务器认为无法释放的情况,最终给玩家看到的表现就是抬手了又收回来了,这是我们的策划同学不能接受的。
经过讨论,我们最后决定移动带预测、技能不带预测。这就带来了另一个问题,因为移动带预测是客户端先行,技能又是服务器先行,在移动和技能衔接的时候很容易出现表现上的BUG,为了解决这个问题,我们需要对原有的技能组件做很多侵入式的修改,很容易引发别的问题。
最后,经过调研,我们决定使用UE中的NetworkPrediction框架来重构整个移动模块。重构完成后,我们就可以根据我们自己的业务需求对移动逻辑进行定制化开发,大大减少了移动和释放技能期间存在的一些问题。
说完业务功能当中碰到的挑战,我们接下来看一下系统承载带来的挑战。虚幻引擎DS一个进程只能承载一个场景。而且因为它要模拟的内容很多,单个进程的资源开销也是偏大的,因为MMO中场景的数量很多,最终也导致进程数量也会很多。
说到资源开销大,大家可以看一下这张图,在这个视野里,就已经能看到20多个玩家和四五个NPC了,试想如果有更多的人在这个场景中,随着人数的增加,同步的数据量是呈平方级别增加的。
为了解决这个问题,我们采用了分线的机制。在一条线里最多允许进入100个玩家,超过100个之后就会新开线路,并把玩家分配到新的线路中去。通过这种方式,我们就可以把单个进程的资源开销控制在一个可以接受的范围,而且也避免了大量玩家在一张地图的时候,同步开销呈n^2的方式一直上升的问题。
分线了之后,进程数量就会更多了,原本一张地图只需要一个进程,现在就需要每100人一个进程了。
这里可以做一个简单的计算,现在一组服务器有一万人在线的时候,所有人都在大地图,每100个人需要一个进程,就需要100个进程。但是随着游戏的进行,玩家都需要进入副本进行体验,假设现在同时有20%的人在打副本,就有2000个人。每个人一个副本就需要2000个进程,4个人一个副本也需要500个进程。而且这只是一组服,当我们开了成百上千组服的时候,进程数量就会非常多了。
为了解决这个问题,我们选择把游戏初期的推图副本做成离线的副本,所有的战斗计算都在客户端进行,由逻辑服进行校验掉落、结算等,这就减少了项目初期大量的场景服进程数量。
对于必须在线进行的玩法,包括大地图、在线本等等,我们会把场景服做成集群,所有的逻辑服共享这个集群中的场景服资源,当某一个逻辑服需要场景服时,会向集群申请,然后连接上场景服使用。
有一些刚开的服,大量的玩家都在大地图或者离线本中,它需要的场景服就比较少,开服一段时间之后的服,大部分玩家都在在线本中游玩,需要的场景服的就比较多,通过这种方法,我们可以把不同阶段的逻辑服对场景服的需求量整合起来,大大提高了场景服的使用效率,避免了大量场景服空置的情况。而且这种做法也有利于我们后续跨服玩法的开发。
除了业务功能和系统承载两方面的挑战,在性能优化的时候也碰到了很大的挑战。接下来给大家分享一下我们都进行了哪些性能优化。
性能优化主要包含了CPU优化和内存占用优化两大方面。 先说CPU的优化,我们在分析的时候,发现场景服上CPU的开销主要集中在属性同步、物理计算、移动计算和同步三个方面。 针对于属性同步,我们使用了包括前期只同步自己,服务器剥离可以不创建的Actor,网格同步,视距裁剪,降频等多种优化方式。
做属性同步的优化的时候,我们第一步是尝试减少需要同步的Actor数量。我们策划同学需要在玩家刚创角的时候体验到的是静谧的山村的感觉,然后随着等级的提升,能够看到越来越多的人。
我们就可以在玩家等级较低的时候,让玩家只同步他自己,别的玩家的数据直接不向客户端同步。这就减少了开服初期场景服同步的Actor数量。
在下面这张图里面可以看到,场景中是存在很多NPC供玩家交互的,而且每个玩家在宠物系统开放之后可以携带一个宠物,还有一些职业,比如图中的魔偶师,会随身携带一个魔偶。
如果所有这些都同步,需要同步的Actor数量也是很多的。我们分析后发现,在大地图中的NPC提供的交互功能其实都是由逻辑服提供的功能,场景服并不需要保留这些NPC,而且在大地图中并没有战斗相关的内容,宠物和魔偶也是不需要的。所以我们就在场景服上把NPC、宠物和魔偶都进行了剔除,只让客户端创建进行表现,这进一步减少了需要同步的Actor的数量。
随后我们又借助于ReplicationGraph插件进行了定制化开发,把所有的Actor根据需要划分成了需要全图同步的、需要根据距离同步的、需要向某条链接同步的、需要向队伍同步的等等很多组,每一组就只向需要的客户端进行同步,也减少了需要同步的Actor数量。
针对于需要根据可视距离同步的Actor,我们根据不同的游戏玩法也提供了不同的可视化距离。大地图中人员密集,我们就把可视距离设的相对较小,副本中玩家和怪物相对较少,而且战斗中的位移和技能可能影响的范围也更大,我们就会把可视距离设的比较大,甚至到全图都同步。
在剔除了那些不需要同步的Actor之后,对于必须要同步的Actor,我们也会尝试去降低他们的同步频率。有些Actor可能在我的视野范围内,但是他在我的摄像机背后,我如果不转镜头其实是看不到他的,这时候就可以把他的同步频率降低,从每秒10次降低到每秒3次甚至一次,也可以减少每帧平均需要同步的数据量。
游戏当中,玩家经常要跑到某一个NPC处进行任务交互、装备打造、换装等等操作,这时玩家是完全没有位移的,如果我们仍然以很高的同步频率把他的数据同步给周围的玩家,显然是没有必要的。我们会把这类不移动的玩家的同步频率降低,当他开始移动的时候再恢复满帧率同步。
在进行了减少需要同步的Actor,和降低可以降频的Actor的同步频率之后,我们也在项目后期对Actor中我们用不到的属性进行了清理。减少每一次Actor同步真正要同步的数据量。在UE4.25版本更新后,我们也借助于PushModel降低了同步前属性比较的开销。
在使用PushModel之前,每一次同步之前,都需要把所有的属性和已经同步的数据进行比较,看看是否修改,来决定是否需要同步这一条属性。
但是实际开发过程中,一帧时间里我们可能只会修改其中几条数据,大部分的比较是没有意义的。使用PushModel之后,我们可以把修改了的数据标脏,在比较的时候只需要比较标脏了的数据是否修改了,大大降低了比较的开销。
以上就是我们在属性同步方面进行的优化,接下来介绍一下我们在物理计算相关的优化。
首先我们在服务器上把角色、怪物身上的所有装饰性组件都进行了移除,因为服务器上是不需要这些组件的,服务器上的这些组件更新、计算的开销都是完全没有必要的。之后我们又在服务器关闭了动画计算,只有在业务需要具体坐标时才计算。下面用一个视频给大家做一个简单的展示。
当角色正常移动时,可以看到服务器上的骨骼一直保持TPos的状态,并不需要真的播放跑动的动画。当角色释放技能的时候,为了保证子弹从正确的位置被创建,我们会在创建子弹之前更新一次骨骼的正确位置,随后保持这个状态,直到下一次需要更新骨骼位置。这样可以大大减少服务器上动画计算的开销。
然后我们还对移动同步的开销进行了优化。
前面提到我们使用了 NetworkPrediction 去重构了我们整个的移动组件。重构完成之后,我们的处理方式是由客户主控端去进行移动的模拟,然后通过 ServerMove 包的方式去发送给服务器,由服务器进行校验。 当客户端主控端它完全没有移动的时候,如果我也在不断地去发 ServerMove 包,它显然是没有必要的,也增大了服务器去进行校验的开销压力。
所以在玩家不移动的时候可以把ServerMove包的发送频率进行降低,这样一来减少了协议的数据量,另外还减少了服务器上校验的开销。
在移动模拟的时候,我们之前默认的情况下是每一帧都会进行 FindFloor 的操作,这需要一次物理的检测,它的开销也是比较大的。我们在不移动的时候,其实这个检测是完全没有必要的。所以我们就在玩家不移动的时候,降低了FindFloor 的频率,也能减少很多不必要的开销。
最后我们又对 CPU 的卡顿进行了保底性的优化。因为一旦出现服务器上因为某一帧的运行时长特别长,导致服务器的帧率出现了大的波动,它可能会导致后续逻辑的计算,甚至对前端的展示都有一定的影响。我们参考客户端的平滑帧率,给服务器也增加了平滑帧率的机制。当 CPU 使用超过 80% 的时候,我们会对它进行逐渐降帧,以控制 CPU 的使用率。我们默认情况下是在 30 帧的模式下运行,如果超过了80%,我们就会让它争取控制在 80% 的情况下计算出来一个它当前的帧率。当然我们也会设置一个下限,以避免帧率过低。
说完这CPU 相关的优化,然后再讲一下我们内存相关的优化。
我们内存的优化主要是基于 Linux 本身 fork 接口提供的 copy on write 的机制去进行的优化。我们可以在父进程中预先加载地图、配置、怪物、玩家等等这些资源相关的内容,然后由它 fork 出来一些子进程,这些子进程就可以和父进程共享它们之间所有配置的物理内存。只有当子进程需要对某一些内容进行修改的时候,才会复制出来一份,然后对它进行修改。
我们现有做法是通过一个父进程去 for 出来 100 个子进程,这样就可以大大降低这一组也即这 100 个场景服所占用的内存的量。通过实际测试,通过这种方式能降低 50% 以上的内存占用。
对刚才分享的性能优化相关的内容做一些简单的总结。我们主要对 CPU 和内存相关的东西进行了优化,这些优化能够优化 50% 以上的 CPU 开销和内存开销。
但是在优化过程当中还是要优先满足业务相关的需求。不能因为我要优化,然后策划提了一个需求,说这个东西不行,你这个开销太大了,这件事是肯定不行的。因为我们第一个目的是要给玩家带来更好的体验,在这个基础上我们再进行相应的优化。所有的这些优化都是以降低资源开销,节约最终的运维成本为目的的。
接下来再给大家讲一下更新维护中遇到的挑战。在更新维护过程当中,因为毕竟是一款游戏,你开发了这么多的内容,你在线上的时候总有一些可能没考虑到的情况,或者是因为配置错误导致的一些 bug,在这个时候我们就需要考虑怎么样去解决它。
首先我们支持了在线更新的机制,我们可以在场景服完全不需要关闭的情况下去更新它的配置、资源以及我们通过 Lua 脚本实现的这些功能,这样就可以让玩家在完全无感的情况下把这个服务器上的大部分 bug 给修复掉。同时为了防止这个场景服中可能会出现一些内存泄露,或者是一些逻辑没有清理干净的情况。我们会在一个副本的场景服的逻辑执行完毕之后,让它自行关闭,然后再由父进程重新启动一个,这样就避免了前面的逻辑没清理干净,影响后续逻辑,以及可能出现内存泄露导致场景服内存持续增长的情况。
对于没办法通过在线更新解决的这些问题,比如说像C++当中出现的一些逻辑中的错误,在线更新的情况下没有办法解决。在这时我们又引入了版本号的机制,比如我们最开始的时候会先部署一个场景服的集群,它的版本号假设是10001,现在出现了有一些用在线更新解决不了的 bug,我们就可以先修复它,然后再打出来一个更高版本的,比如 10002 的版本的场景服,然后为新版本的场景服部署一个新的集群。逻辑服会优先选择版本号比较高的场景服,这样我们就可以在玩家切图的时候,把他优先调度到更新的场景服当中,去解决那些在线更新没办法解决的问题。
通过这种方式我们也可以实现滚动更新,就可以在玩家在线的时候,我直接起更高版本的场景服,让玩家通过切图等等方式进入更新的场景服,而不需要采取强制地把玩家踢下线,然后再让他们登进来的方式。 当然在后续版本启动的时候,我们尽量也让前面的版本继续存在,这样有一些玩家长时间待在大地图当中,也不会出现掉线的情况,直到旧版本场景服当中的玩家都离开了,我们再把它进行回收就可以了。
除了以上四类挑战以外,我们还碰到了一些其他的挑战。比如说在大地图长期存在一段时间之后,我们发现玩家走动的时候总会有一些拉拽的情况。通过分析之后发现是因为float类型的时间戳在描述时间的时候,连续运行超过4个多小时之后,它的时间精度已经描述不了毫秒级别的精度了,会出现时间精度上的损失。最终反映在移动计算的时候,就会出现计算出来的移动位移有偏差,最终导致拉拽的情况。发现这个问题之后,我们就对时间精度进行了扩展。把游戏当中所有时间精度相关的字段都从 float 扩展到了 double, 彻底解决了这个问题。
还有一个是同步顺序相关的问题。给大家举一个例子。
像这张图当中一个玩家,每一个角色是由Pawn, PlayerController, PlayerState 三个部分共同组成的,他每一部分都有一些自己的数据,也都有自己的功能。当他们同步到客户端的时候,客户端写逻辑的时候,可能需要三个数据都到了才能去做某一些逻辑。比如说Pawn里边要展示一些什么样的外观,外观数据是在PlayerState 当中存的。
但是当我去执行判断逻辑的时候,PlayerState 可能还没有同步下来。我如果正常去写,我就需要两头堵:Pawn同步下来了,我要怎么样?PlayerState 同步下来了,我要怎么样,但是这样写起来就会很容易遗漏,也很容易出现问题。 为了解决这个问题,我们提供了一个代理中间层,我们会提供一个接口叫FutureCall 当你需要你所有依赖的Actor都同步下来再去执行业务逻辑的时候,就可以调FutureCall 接口。
此时如果依赖的所有Actor都已经下来了,这一段逻辑就会直接被执行,否则就会等到依赖的Actor都同步下来之后再去执行对应的业务逻辑。这样能减少我们代码编写时候的复杂度,而且也降低了很多 bug 的出现概率。 接下来是无缝切图。因为我们有一些玩法是需要从某一个副本切换到另外一个副本进行连续挑战的。通常的情况,需要让客户端从副本 A 先断开连接,然后再去连接副本 B,但是在这种情况下会出现一些问题,可能会因为网络波动,我断开副本 A 的连接之后连不到副本 B了。
而且很多情况下我连续挑战的时候,我需要把副本 A 当中的一些逻辑,一些数据也带到副本 B 当中,这就需要走逻辑服做一次战斗数据的中转,有些没有必要。 我们基于虚幻引擎(UE)当中本身提供的一个无缝切图的机制,支持了在一个进程当中直接从副本 A 切换到副本 B,同时带着客户端也从副本 A 切换到副本 B 的逻辑。当然在使用无缝切图的过程当中,我们也发现了一些问题,我们进行了很多的修改进行解决。
以上就是《晶核》在开发过程当中碰到的一些技术挑战以及我们的处理方式。最后给大家一些使用虚幻 DS 的建议。
第一个建议就是要使用分布式编译系统,同时开启unit build。这两个做法能够大大降低我们的编译时长,避免出现一天开发有半天都在编译的情况,能够大大提高我们的开发效率。
第二个就是组件转移。因为为了维持引擎的稳定性,一般来说都是把引擎和自己项目的代码分成两个仓库去管理的。为了稳定性,引擎的代码仓库,可能会隔一段时间才进行一次更新。但是有时候我们修改逻辑的时候可能会要修改引擎当中的某一些组件,而且要频繁地修改。但如果我半个月才更新一次引擎,肯定是不满足频繁地修改和测试的需求的。在这种情况下就可以尝试把这个组件或者是插件,转移到项目的代码仓库当中,更快地进行修改测试。
第三个就是要前期做好代码规划,我们在做优化过程中发现很多不需要在服务器上执行的逻辑也在服务器上执行了。其实这个在前期规划的过程中是可以解决的。因为前后端代码是写在一起的,在最开始写代码的时候,一定要做好规划,说哪一部分代码是在前端要跑的,哪一部分代码是要在后端跑的,哪一部分是前后端都要跑的,这样不仅可以减少后续优化的工作量,而且也减少了因为前后端代码混写在一起导致的可能会出现的一些安全性或者是逻辑上的问题。最后一个建议就是一定要做好服务器相关的监控。
包括我们在开发过程当中编译打包失败相关的监控。因为UE 开发的时候,前端后端同学都会修改同一个仓库里的代码,包括美术同学、策划同学也都会提交相关的内容。这时一旦有上传的错误的东西,它可能影响的范围面会比较广。
这个时候我们使用了自动打包的机制,一旦有打包失败,我们就会马上在自己的工作群报警,然后快速地去解决,以减少问题影响的时长。同时在项目早期就要早早地开始关注内存和 CPU 的情况,当发现有内存占用上升或者 CPU 开销上升,甚至出现了内存泄露或者CPU 死循环的情况,都需要早发现早解决。可以每一周进行一次检测,这样的话如果发现了类似的情况,也可以快速地定位到这一周当中,我修改了什么,快速地去解决它。
最后因为虚幻 DS 它是由 C++开发的,服务器的崩溃有的时候是不可避免的,我们怎么样在这个崩溃了之后去尽快地解决它,一个是做好监控。另外一个是在我们线上的时候,包括测试的时候也都要关注服务器崩溃产生的core文件,然后去进行分析解决,引以为戒,最终解决并避免后续服务器出现崩溃问题。
文本作者:橘子说游戏
游戏葡萄