AOI服务器的实现

来源:互联网 发布:咱们穿越吧第一季 知乎 编辑:程序博客网 时间:2024/04/28 03:22

此处纯粹收藏,如有再转,请按如下方式标明出处,以示尊重。

 

>> 作者博客:云风的BLOG

 

把AOI的部分独立出来

 

应该是在 blog 上第 2 次讨论 AOI 的问题了,另外还要算上一篇写完没有公开的。

昨天刚出差回来,晚上跟前大唐的服务器主程聊了一下。扯到了 AOI 的问题,我提了个分离这个模块的方案,在此记录一下。

AOI 主要有两个作用,一个是在服务器上的角色(玩家或 NPC )做出动作时,把消息广播到游戏地理上附近的玩家。当进程中负责的玩家不是很多的时候,可以直接对整个进程内的连接广播。这样的处理最简单,随着硬件的提高,可能是未来的主流方法。但是目前,为了减少处理的数据量,尤其是带宽,我们通常需要 AOI 模块裁减一些数据。

第二,当玩家接近 NPC 时,一旦进入 NPC 的警戒区域,AOI 模块将给 NPC 发送消息通知,以适合做一些 AI 的反应。

AOI 的实现算法很多,这里不展开来谈。我目前关心的是,如果 AOI 在游戏中非常重要(尤其对于即时战斗模式的大场景 MMO )能否把这个模块抽离到独立进程中。

首先我们来看第一个需求。其实在游戏中,需要广播的消息大多次序不敏感。比如移动,是 A 先移动还是 B 先移动并不重要;又比如攻击,是先攻击再移动,还是先移动再攻击也不重要。我指的先后是指 client 接收次序。攻击是否启动虽然跟距离有关,但是发出攻击指令前,服务器逻辑已经校验过合法性。也就是说,服务器发过来的信息都是合法的,client 就不用太在意位置改变在攻击前还是攻击后了。

那么,我们把 AOI 广播的这个职责拆分到独立进程做,就不需要跟逻辑进程协调发包的次序。这是个好消息。

设计一个 AOI 进程,让逻辑进程在任何一个对象改变位置时发一个包通知 AOI 进程,这样等于复制一份对象位置状态在 AOI 进程中。以后,当对象需要广播它的状态变化,只需要把需要广播的消息发到 AOI 进程里,然后由 AOI 进程过滤一下(筛出附近的对象,做一个组播)。

需要注意的是,由于消息都是异步发生。我们在 client 接收对象 id 时,应该过滤掉一些过期的 id 号。因为有可能逻辑服务器已经删除了某对象,但是 AOI 服务器迟一些发送了跟这个对象相关的消息。(把对象删除的消息交给 AOI 服务器转发是一个糟糕的想法,因为这增加了 AOI 服务器的职责,增加了复杂度)

对于 AOI 消息通知的问题,可以让 AOI 服务器在检测到对象侵入别人的警戒区时,向逻辑服务器发会一条消息。这个消息不需要实时反应。比如,策划要求一个 NPC 有 20 米的警戒区,其实不必在玩家刚好 20 米的边界上触发,稍微延迟半秒是无所谓的。而且警戒区越大,允许的延迟时间就越长。

单独的 AOI 服务,可以根据警戒区的半径,进行优先级排序。然后有条不紊的通知消息一个个发回。

对比单独的 AOI 模块,独立 AOI 进程有更低的耦合度。通过协议耦合而不是接口耦合。更容易维护和日后的优化。没有和逻辑部分的共享状态,减少了 bug 滋生的可能。


ps. 最近跟新同事讲了一节课。其实没讲太多技术上的东西。只是介绍了游戏行业,以及计算机行业的硬件发展历程,以及游戏的进化,还有一点点软件开发上的进步。

我想,一切的历史都在见证,只有把东西设计的足够简洁,才能够顺利的向前发展。如果不够简洁,有一天我们会抛弃它们,或者为之付出代价。希望可以给刚从学习毕业的同学们一些启示。

前天开会前抽空读了一小时新运营的大唐二的服务器代码。追查出一个隐藏很深的 bug 。据说这个项目刚对外开放的这几天情况不太好,老是当机。程序员应该明白,其实也不一定是很多 bug ,但是一个就够你受。

大唐 2 的服务器写的挺“精巧”,C++ 和 lua 之间绕来绕去的,差点没把我绕晕。太复杂了……

 

AOI服务器的实现

 

以前谈过多次 AOI (Area of Interest) 的实现,因为我们的游戏尚在开发,模块需要一个个的做。前期游戏世界物件不多的时候用个 O(N^2) 的算法就可以了:即定时两两检查物件的相对距离。这个只是权益之计,这几天,我着手开始实现前段时间在 blog 上谈过的 独立的 AOI 服务器。

既然是独立进程,设计协议是最重要的。经过一番考虑,大约需要五条协议,四条是场景服务器到 AOI 服务器的(列在下面),一条由 AOI 服务器发送消息回场景服务器。

  1. 创建一个 AOI 对象,同时设置其默认 AOI 半径。注:每个对象都有一个默认的 AOI 半径,凡第一次进入半径范围的其它物体,都会触发 AOI 消息。

  2. 删除一个 AOI 对象,同时有可能触发它相对其它 AOI 对象的离开消息。

  3. 移动一个 AOI 对象,设置新的 (2D / 3D) 坐标,并给出线速度的建议值。

  4. 设置一个 AOI 对象相对另一个的 AOI 半径,覆盖其默认设置。注:AOI 半径可分两种,一为进入半径,二而离开半径。通常一开始,每个 AOI 对象为其它对象均设置一个进入半径;当消息触发后,由场景逻辑重新设置一个离开半径。例如,一个 AOI 对象的默认半径是 10 米,当它被创建并指定坐标后,任何物体进入它的 10 米范围内,都会立刻由 AOI 服务器发送出一个 AOI 消息;而后两者之间不会再自动触发消息。场景服务器收到消息后,可主动向 AOI 服务器设置新的 AOI 离开半径 12 米,当此物体远离到 12 米远后,离开消息触发;下一步再由场景服务器重置进入半径。

这套协议相对简单,可以满足游戏的一般需要,并隐藏 AOI 服务的实现细节。对象全部由 handle 方式传递,由场景服务器自己保证 handle 的唯一性。在我这次的实现中,每个 AOI 对象同时只能拥有一个 AOI 半径触发器,但是协议本身无此限制。

下面,我们再来看一下实现细节。

一般的 AOI 模块有两种实现方式,最常用且最简洁的方式是打格子。无论是小格子也好(一格只能占一个对象)还是大格子也好(一格是一个较大区域,在区域内再使用 O(N^2) 算法逐一比较),实现起来都很清晰明了。

按 KISS 原则,我建议没有特殊需求的情况下都使用格子的算法。当然,格子算法也有一些不足,比如格子本身的内存消耗,跟场景规模有关,却与对象实现无关,有时候,会浪费大量内存(独立进程可以一定程度回避这个问题);对于变化不定的 AOI 半径,固定单位长的格子方案在效率上也略有缺陷。

另一个思路是几年前我在和天下组的同事聊天时了解的。为每个对象创建两或三个维度上的线段,并对线段端点做插入排序。我本身对这个算法不太感兴趣,就不展开谈了。

昨天晚上躺下比较早,翻来覆去睡不着,想到一个新的思路来实现 AOI 模块。

最 KISS 的方案是每个心跳一一比较 AOI 对象的距离。时间复杂度是 O(N^2) ,在 N 比较小时,其实是最佳方案。因为其实现非常简单。注意这里,对于 A 和 B 两个对象, A B 和 B A 是两组。这是因为 A 的 AOI 半径和 B 的 AOI 半径很可能不同。那么对于 N 个对象,要比较 N * (N-1) 次。除非游戏中玩家都在小副本中,且副本里的 NPC 数量不多(或者 NPC 之间不需要做 AOI )不然在大规模场景中难以接受。

btw, 所谓被动怪,就是不需要做 AOI 处理的 NPC 。在很多游戏中大量放置,除了帮助脑残玩家快速升级外,也是为了节省服务器资源。wow 中那些 NPC 之间也会野外碰见并交战,NPC 间会呼朋结友一起上的设计,其实是很考验 AOI 模块性能的。

应该如何提高 N 比较大时的性能?

我们主要,相隔较远的物体,是不需要时时检测它们之间的距离的。而大部分物体间隔都远超双方的 AOI 半径。我们只要从这里着手改进算法就可能得到很大的性能提高。

比较容易想到的是使用一个 timer 。我们可以根据两个物体间的距离,以及移动速度,估算出一个最短相遇时间。这个时间往往远超一个心跳。按这个时间,把 AOI 检查放到 timer 队列里即可。

我在实现时是这样做的:要求场景服务器在发送物体坐标时,同时发送一个线速度的建议值。我按这个值的两倍计算物体两两间的相遇最短时间,并注册 timer 。以后只要不超过这个速度的两倍就可以忽略它。否则,重新计算跟这个物体相关的所有目标距离,调整对应的所有 timer 在 timer 队列中的位置(这需要特制一个 timer 模块来提供这个功能)。

这个优化依旧有一个问题。它需要为每对物体创建一个 timer 节点。所以空间复杂度是 O(N^2) ,这可能大大超过内存预算(虽然独立进程也可以缓解这个问题,但只要想一下,N 可能上万,就知道问题有多严重)。

我们需要进一步的优化。

在游戏中,大部分对象是老死不相往来的,由于是那些身处各地的 NPC 。只有极少数出生地不同的 NPC 会由于脚本设计游走各方。而玩家,那些有可能大范围活动的对象,相对 NPC 的数量又是少了一个数量级。我们只要在内存中除掉这些无谓的数据就好了。

假设半径 100 米在游戏中是一个比较大范围,NPC 没事不会超过这个活动范围,而玩家一般情况下跑过 100 米也需要一段时间(这要花上好几秒,秒对于 CPU 是个很长的时间单位)。

我们可以给所有对象设置一个 100 米为单位的活动范围。如果两个对象之间的直线距离大于 200 米加上他们的最大 AOI 半径,我们就可以不记录这两个对象之间的关系(认为它们不可能相遇)。而一旦一个对象离开它原来的记录原点超过 100 米,就认为它发生了迁徙,立刻把它对场景中所有的对象重新做一次比较(时间复杂度 O(N) ),这个操作固然慢,但是还可以接受,而且并不常发生。

综上,是我这次实现的 AOI 服务的一个框架了。实现它们还需要两三天时间。

 

关于AOI服务器的优化

 

去年写过几篇过于 AOI 模块的设计。将 AOI 服务独立出来的设计早就确定,并且一定程度上得到了其他项目组的认可。(在公司内部交流中,已经有几个项目打算做此改进)

最近一段时间,我在这方面做进一步的工作。(主要是实现上的)

首先,基于 KISS 的考虑,删除了原有协议中的一些不必要的细节。比如,不再通知 AOI 模块,对象的移动速度。移动速度虽然对优化很有意义,但毕竟是一冗余数据。考虑这些,容易掉入提前优化的陷阱。

其次,增加了一条设置默认关心区域的协议。这是出于实用性考虑。

原本的考虑中,我希望应用者按需为每对实体设置 AOI 消息。但是,当场景中实体过多时,将浪费大量的进程间通讯带宽(内存和处理速度上倒是可以优化)。往往,一个 AOI 实体不会关于过远的其它实体,所以增加一条协议,可以缩减大量的模块间交互。即,那些老死不相往来的实体间的消息就直接过滤掉了。

在实现上,我采用的最简单的了望塔方案。如果应用层需要让每个实体最多关心半径为 100 米内的其它实体,那么就按合适的间隔(比如也是 100 米)设置一个了望塔对象,由了望塔通知它,附近有哪些东西。

了望塔对应用层是不可见的,它只是一个具体的实现方案。而这个通知协议也只保证大略的信息。只是通知实体,附近(不一定是严格的设定半径,但一定大于应用层设定的范围)实体的增删。

应用层根据这条协议维持自己私有的一个可见集合,再结合其它 AOI 协议得到精确的 AOI 消息。


了望塔的分布应该怎样设置?

一开始我首先想到的是简单的按 2D 网格分布。但是这个方案不到 20 秒就被否定,因为直觉告诉我它不是最优的。然后我试图寻找更好的方法。比如交错开排布。

      __    __    __     /  /__/  /__/  /     /__/  /__/  /__/     /  /__/  /__/  /     /__/  /__/  /__/

画出来后,同事说,这不就是移动通讯中用的蜂窝网吗?

是啊,很容易证明这就是最优的分布方案。因为每个实体只需要被三个了望塔监控就可以了,而不是 2D 矩形网格方案的四个。六边形的外接圆之间的覆盖面积比例(重复区域)也比正方形的小。

实现时,我们可以生成多级的蜂窝网应付不同的应用层需求。

原创粉丝点击