AOI服务器的实现

前期游戏世界物件不多的时候用个 O(N^2) 的算法就可以了:即定时两两检查物件的相对距离。

既然是独立进程,设计协议是最重要的。经过一番考虑,大约需要五条协议,四条是场景服务器到 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服务器的优化

 

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

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

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

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

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

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

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


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

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

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

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

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

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