分布式场景
无缝大世界的需求
在常规的游戏服务器设计中,场景服务器一般采取的是多进程模式,然后每个进程内部负责承载一个或者多个场景实例。为了方便的去控制场景进程的CPU消耗上限,一般会采取单线程模式。这样物理机器上有多少个物理线程就开启多少个场景进程,完全的一对一映射,保证服务器不会因为某个进程出现了高CPU消耗的故障引发其他进程的执行变慢问题。目前见到的Unreal Engine的Dedicated Server基本全都是这种单线程模式,即其启动命令行中加入-nothreading。不过单线程模式下,玩家数量的承载比较有限,所以很多游戏服务器会开启多线程模式,主线程中只处理Entity系统所承载的业务逻辑,将网络、AOI、日志、AI、寻路等不需要与Entity系统执行高频交互的模块独立为各自单独的一个线程。主线程与其他线程之间明确好相关的多线程数据交换接口,并做好多线程互斥锁的封装,这样就可以将单进程的Entity承载数量提升很多,纯CPP服务器的话应该可以达到单进程上千玩家,而在网易使用的基于Python的Messiah Server也是两三百玩家轻松拿捏。典型例子就是网易天谕手游介绍的游戏服务端高性能框架:来看《天谕》手游千人团战实例,以及网易倩女手游介绍的《新倩女幽魂》迈向千人同屏:Avatar系统,这里倩女手游做的更加激进,将战斗结算都拆出去了。
单进程成百上千的玩家承载量基本可以覆盖所有的玩法场景需求,但是架不住策划想整个热闹氛围要求全服所有玩家都可以进入同一个大地图进行交互。此时的所有玩家的数量级一般是几万到几十万,主线程已经无法处理这个数量级的Entity业务逻辑了。此时有一种取巧的方法来假装所有玩家在同一个场景里,对于这个大场景创建数十个进程实例,每个进程都有一个各自独立的大场景,玩家选择进入这个场景的时候自动选择其中负载最低的进程,这就是MMO服务器中常见的分线。
分线以分区隔离的方式解决了大量玩家在指定场景里的负载问题,但是由于不同分线之间的玩家是互相不可见的,所以仍然没有解决这些玩家之间交互问题。特别是组队系统里要求一个队伍里的玩家要尽可能的互相可见,而这个分线的选择机制经常在组队切换场景的时候将同一个队伍中的玩家进行打散,导致不可见。想对这种情况进行补救的话,需要做一些特殊逻辑。先让队长执行场景切换,此时分线逻辑会随机指定一个分线。在队长切换过去之后,队长会将自身的分线数据广播到其他队员中,此时其他队员再以跟随的方式进入指定的分线。
这种方式可以在很大程度上缓解这个队伍内成员分线不一致的问题,不过在场景里玩家数量有硬上限的情况下这种方法有些时候会失败。例如场景设计最大承载100人,超过100人则不允许后续人员进入。某个情况下当队长随机进入一个分线的时候,场景内还有足够的余量来容纳队伍内的其他成员。但是当其他成员接收到队长切换分线的消息之后,其发送的切换场景请求达到场景服务器可能已经是队长切换分线的0.2秒之后的。在这个短暂的时间窗口内可能会有其他的玩家进入这个分线,从而导致分线的玩家数量达到上限,进而导致此队伍内的其他成员切换到指定分线场景执行失败。为了应对这个场景人员上限的问题,更好的处理方式是场景服务在给队长指定分线的时候,预先从当前分线的人员数量中临时加上当前队伍的大小。同时当队伍内其他成员进入这个分线时,不再对当前分线的人员数量进行累加。实际中的实现其实比这两句复杂的很多,要处理队伍解散,玩家进出队伍,玩家终止跟随等各种异常情况,所以这个方案对于原始的队伍内成员分线不一致的问题只能做到很大程度上的缓解,不能达到根治。
而且这还只是队伍,实际的玩法活动中有很多的类似于队伍的临时性玩家集合会有这种聚合在同一个分线场景中的需求,不过照着队伍相关的代码查找替换一下也将就能满足。整体来说,分线系统缝缝补补之后基本可以伪装成一个单一大世界,新来的同一个分线要求继续缝缝补补就可以了。不过让分线系统彻底崩塌的最后一根稻草一般来自于策划,策划突发奇想学习魔兽世界无缝大地图,要求抹掉分线这个概念,场景中的上万个玩家都要求近距离互相可见。
没办法,策划是大爷,做吧。但是怎么做呢?这就要引入BigWorld分布式场景的技术了。在服务端架构那一章简单的描述了Bigworld分布式场景的大致概念,接下来我们开始探究其具体实现细节了。
Real-Ghost的引入
对于常见的非分布式游戏场景实例,它肯定是被单一进程托管的,玩家、怪物等actor_entity只需要考虑进入场景和离开场景这两个操作。但是对于分布式大世界场景,他是由分布在多个进程中的cell_space拼接而成的,每个cell_space负责一个与其他cell_space不重叠的矩形区域,每个actor_entity都根据其位置坐标绑定到覆盖了这个位置的cell_space。但是由于actor_entity的位置坐标是动态的,同时cell_space的覆盖区域也会被负载均衡所调整,所以一个actor_entity所归属的cell_space并不是固定的,而是不断的变化之中。当一个actor_entity所属的cell_space发生改变时,这个actor_entity就需要从之前的cell_space移动到新的cell_space,这个移动的过程就叫做迁移Migration。整个迁移过程其实跟切换场景有很大的相似之处,都是需要在迁移之前打包好当前actor_entity的所有数据,然后从当前cell_space移除此actor_entity,然后通过RPC将这个actor_entity的数据发送到新cell_space,利用打包数据对这个actor_entity进行重建。
上述用来适配分布式大世界的actor_entity移动方案有一个非常大的问题:不同的cell_space之间所管理的actor_entity集合是没有交集的,每个cell_space各自处理actor_entity可见性会引发客户端的actor_entity瞬间销毁或者创建。以下图为例,在开始情况下全场景只有一个cell_space,在AOI半径为10的情况下, A,B,C,D,E五个玩家相互之间都是可见的,其客户端都会存在这五个actor_entity:

在经过负载均衡之后,原来的单个cell_space被切分为了上下两个cell_space,A,E需要迁移到新的上半部分的cell_space中,而B,C,D则保留在原来的cell_space中:

此时A,E两个actor_entity相互之间可见,同时B,C,D三个actor_entity相互之间可见, 但是A,E中任一actor_entity与B,C,D中任一actor_entity都缺乏可见性,因为每个cell_space都有其各自AOI系统,两个AOI系统管理的actor_entity集合完全无交集。在A,E玩家的客户端中, B,C,D三个原本可见的actor_entity将会被立即销毁,同时在B,C,D玩家的客户端中,原本可见的A,E两个actor_entity也会被立即销毁。
这种突发性的变化会与玩家的期望大相径庭,会给客户端带来明显的actor_entity集合变化,所以这种分布式场景的actor_entity移动方案也叫做有缝迁移。与有缝迁移相反的迁移方案就叫做无缝迁移:在迁移前后不会给客户端带来actor_entity集合变化。在主流的无缝迁移实现中,一般都使用了Real-Ghost系统。在Real-Ghost系统中,原来的actor_entity会有两类载体:real_entity和ghost_entity。real_entity与ghost_entity有如下性质:
- 对于一个
actor_entity,一定会有一个real_entity,同时会有零个或者多个ghost_entity; - 每个
ghost_entity和real_entity都会有唯一的actor_entity与之相关联; - 一个
actor_entity在一个cell_space中的关联real_entity与ghost_entity的总数不超过1; - 对于一个
actor_entity,其real_entity拥有其所有的属性数据,而ghost_entity则拥有其所有对客户端可见的属性数据 ghost_entity上的属性数据需要尽可能快的与real_entity上的属性进行同步,以保持一致
综上,real_entity承载了原有的actor_entity的所有数据与逻辑,而ghost_entity就是原来的actor_entity创建的用来参与cell_space的AOI计算的数据同步代理。
有了这个ghost_entity之后,无缝迁移的最简实现就是对于每一个actor_entity,在除了所属cell_space之外的每个cell_space都创建一个ghost_entity,这样每个cell_space都相当于有全场景actor_entity的real_entity或者ghost_entity,即AOI计算的actor_entity集合都是当前分布式大世界内的所有actor_entity。此时cell_space的任何调整都不会影响AOI计算的结果,因此这些cell_space的调整对于所有的客户端来说都是透明的,分布式大世界与单cell_space世界没有任何差别,从而实现了无缝迁移。下图中就是这种方案的一个例子,real_entity的颜色我们用蓝色来表示,ghost_entity的颜色我们用灰色来表示:

Ghost创建半径
前述的Real-Ghost维护方案虽然能够实现无缝迁移,但是其代价非常大:每个cell_space都需要获取当前大世界所有actor_entity的客户端可见属性的全量副本。为了实现这个目标,任何一个real_entity上的客户端可见属性的改变都需要推送到所有的cell_space上的对应ghost_entity,其带来的CPU和流量的压力是M*N的,其中M为cell_space的数量, N为actor_entity的数量。 实际上我们并不需要给每个actor_entity在其非所属cell_space上都创建一个ghost_entity,因为每个cell_space的AOI系统只会处理在这个cell_space内的real_entity的客户端同步。所以对于一个actor_entity来说,在离其非常远的cell_space中创建ghost_entity是纯粹的浪费资源,因为同步过去的ghost_entity在远方cell_space计算客户端同步列表的时候总是会被AOI半径所裁剪掉。基于这个同步半径裁剪的考量,演化出了一种更加高效的Real-Ghost管理机制,即只在当前actor_entity一定范围内的cell_space中创建ghost_entity,下图就是一个以20M为ghost_entity创建半径的示意图,图示展示创建范围是正方形,其实圆形可是可以的:

下图就是在创建半径为1.5的情况下,双cell_space的Real-Ghost格局:

下方的cell_space只需要创建A对应的ghost_entity,上方的cell_space只需要创建D对应的ghost_entity。相对于原来的全创建设计,ghost_entity的数量从5个降低到了2个。这个优化数据看上去不怎么显眼,但是如果我们把每个cell_space的长宽都设置为这个创建ghost_entity区域的四倍,则我们可以保证一个actor_entity在整个分布式大世界的所有cell_space中,最多只会有3个ghost_entity,每个real_entity的属性修改最多需要向周围的三个cell_space进行推送。这样整个分布式大世界中的ghost_entity数量就从(M-1)*N降低到了3*N,这里M代表cell_space的数量,N代表actor_entity的数量,复杂度降低为了actor_entity数量的线性,且不受cell_space数量的影响。因此BigWorld与MosaicGame都采取了这种带ghost_entity创建范围限制的Real-Ghost管理方案。

在这个ghost_entity创建半径GhostRadius为20M的规则下,一个cell_space的边界可以演化为下图所示的三个矩形区域:

- 浅蓝色矩形区域
cell_rect,这个代表这个cell_space的自身负责区域。cell_space尽可能的将其承载的real_entity限制在这个区域中。 - 深紫色矩形区域
real_rect,相当于将cell_rect四个方向都缩小GhostRadius。一个real_entity在这个区域的话,则不需要在周围的其他cell_space去创建ghost_entity,这个real_entity对应的已有的在周围cell_space里创建的ghost_entity可以执行删除。反之一个real_entity在这个区域外的话,则需要向周围的cell_space创建ghost_entity。 - 浅灰色区域
ghost_rect,相当于将cell_rect四个方向都扩大GhostRadius,如果当前cell_space的某个real_entity在这个ghost_rect范围之外,则需要将这个real_entity迁移到周围的cell_space。
理论上来说,当一个cell_space内的某个real_entity离开其real_rect范围时,就可以考虑将其迁移到周围的cell_space中。这里设计为只有在ghost_rect之外才处理迁移,是为了避免某些real_entity在边界上往返移动时发生的频繁迁移。因为real_entity的迁移相对于维护一个ghost_entity来说是一个非常重的操作,降低迁移频率就可以有效的降低CPU负载。
当考虑到多cell_space时,每个cell_space三个矩形交织在一起就显得有点乱了,下图就是相邻两个cell_space构成的六个矩形的格局:

下面我再用一个在两个相邻cell_space移动中的real_entity作为例子,来展示整个Real-Ghost的在上图中的管理过程。在初始情况下,编号为A的real_entity在左侧cell_space的real_rect中,

此时这个real_entity将不会创建任何ghost_entity,所以这两个cell_space的独立视图是这样的:

随着A逐渐的往右侧移动,离开了左侧cell_space的real_rect,同时进入了右侧cell_space的ghost_rect:

此时在左侧cell_space的real_entity(A)需要在右侧cell_space中创建一个ghost_entity(A), 因此这两个cell_space的独立视图就会演化成这样:

当A继续向右侧移动,脱离了左侧cell_space的cell_rect,进入了右侧cell_space的cell_rect:

此时在左侧cell_space的real_entity(A)需要在右侧cell_space中继续维持之前创建的ghost_entity(A), 因此这两个cell_space的独立视图就会演化成这样:

如果A继续向右侧移动,脱离了左侧cell_space的ghost_rect,进入了右侧cell_space的real_rect,此时就有必要将real_entity(A)从左侧的cell_space切换到右侧的cell_space:

在迁移之后,左侧cell_space的real_entity(A)将转换为ghost_entity(A),同时右侧的cell_space中的ghost_entity(A)将转化成real_entity(A):

当迁移彻底完成之后,左侧cell_space的ghost_entity(A)将不再被需要,因此将在后续的处理中被删除,最终的独立视图将成为这样:

综上,我们用多张详细的示意图展示了一个actor_entity从一个cell_space移动到另外一个cell_space触发无缝迁移的整体过程,接下来我们再来看这个无缝迁移过程是在BigWorld和mosaic_game中如何实现的。