「游戏」游戏服务器中AOI的原理及四叉树实现

前言

要不是想起来这篇文章想写一个关于游戏服务器开发过程中关于AOI相关的文章,我都差不点忘了我是一个游戏服务器开发人员😓。

之前一直写的都是关于Golang相关的源代码解析内容,今天也说一说关于游戏服务器开发中常用到的一些算法,以及相关的一些原理、实现等等。

因为我是个应届生,也处于学习阶段,对这个东西的理解不够深,如果有问题,请各位看官给予指正。在此表示感谢。

什么是AOI

AOI(Area Of Interest)翻译过来就是“感兴趣的区域”,这个玩意儿在很多的游戏中都会出现,比如在MMORPG游戏中,玩家走到某个场景的坐标(x,y)处,就需要通过AOI来获取到当前坐标处,一定范围内的所有玩家以及NPC、怪物(其实也算NPC的一种)的相关位置信息,交由客户端生成对应的模型渲染并且进行其之间的交互。

当玩家进行移动时,同时也会对玩家范围内的玩家进行广播位置同步,使得其他玩家知晓当前玩家进行的移动位置和移动范围。

当玩家进入一个游戏场景(地图、副本等)时,玩家所看到的各种各样的Entity(玩家,NPC,怪物皆算做Entity),都是通过服务器端的AOI系统在进行处理。显然,AOI实现算法的好坏,直接影响了当前游戏场景内所有玩家的游戏体验以及人数上限,如果AOI算法选择不好,那么可能会导致玩家在场景之中的一些游戏玩法产生不良效果,因此,选择一个优秀的AOI实现算法,是制作AOI系统的首要任务。

MMO类型的游戏都会有野外和主城场景,一般来说,服务器只会同步你周边多少半径以内的玩家给你,太远的一是玩家屏幕看不到没有意义,二是同步太多的玩家对于服务器压力成倍数上涨。

想象一下,如果一个区域内有100个人,这些人可能都在不停的走路,如果是广播形式的话,那每一个人移动都要向另外的100个人进行位置同步。假如服务器每50ms同步一次玩家位置,那么服务器处理100个人位置就需要
100 ∗ 100 ∗ 20 = 200000 100*100*20 = 200000 10010020=200000
次广播。这显然是无法承受的。那么为了降低这部分的服务器性能,就需要降低服务器同步的量级,一种解决方案就是AOI。核心概念就是只对那些感兴趣的观察者发送数据。

那么为了降低这部分的服务器性能,就需要降低服务器同步的量级,一种解决方案就是AOI。核心概念就是只对那些感兴趣的观察者发送数据。基于上面的例子而言,如果我们能把这个区域细分为10个,假定平均分配的情况下,每个区域里则只有10个人。那么此时每个人移动的时候,他就只要同步给同区域的10个人,大大减少需要同步的次数。同时,如果我们能充分利用现代CPU的核数,使用多线程来处理,则这个部分的性能损耗会大大的降低。

但这个方案同时也会带来额外的问题,比如a这个人,之前在A区域,现在移动到了B区域,那么就需要一个管理器来协调A和B两个AOI区域的数据更新。虽然区域划分的越多,需要同步的次数越少,但是同样的,管理的复杂度就越高。所以,AOI需要根据实际的游戏场景做到一个合理的平衡1

常见的AOI算法

在目前的游戏服务器开发领域,针对AOI的算法常见的有以下几种(本文基于2D游戏):暴力法、灯塔算法、十字链表算法以及本文要重点介绍和实现的四叉树算法,上面几个算法各有不同,也各有优势,接下来我来介绍一下各个算法的基本原理以及其优势和缺陷。

暴力法

望文生义,所谓暴力法,就是不使用任何算法以及数据结构进行管理和组织,当玩家需要某个坐标点以及对应范围内的玩家列表时,暴力检索整个场景中的所有存在对象,然后进行坐标判断,随后返回给调用方的一种AOI实现算法,其检索的时间复杂度为O(n),该算法的优缺点如下:

优点
  • 实现简单,不需要多余的复杂数据结构,每个场景保存一个数组作为存储玩家对象列表的数据结构即可
  • 在少量Entity的地图场景之中(如小队副本,团队副本等),效率很高,且无需要复杂的数据结构
缺点
  • 当场景中Entity数据量巨大,遍历整个数组会有很大的性能损耗
  • Entity的场景进入、场景退出等相关操作需要频繁的操纵数组,数组本身对这种随机性的插入与删除的性能支持不佳
  • 每次搜索单一Entity时,需要遍历整个数组
总结

暴力法的实现简单, 无需多余复杂数据结构,并且在少量Entity的场景中有着优秀的性能体现(这个有些歧义,因为相对于后续的某些*O(logn)*时间复杂度的算法来讲,*O(n)*的确不算优秀,但是综合时间复杂度和实现难度来讲,的确算一个性能平衡的算法),比如说LOL、王者荣耀等Moba游戏来说,双方队伍里仅有10名玩家,整个场景内的Entity不会过多,此时,暴力法的综合表现可能是比较好的(个人见解)。

灯塔算法

灯塔算法就是是把整个场景通过不同的粒度,利用网格划分成一个一个的大小相等的小区域, 在每个区域里树立灯塔。在Entity进入或退出格子时,维护每个灯塔上的Entity列表。灯塔好在哪?假设我们想知道某点周围10格内有哪些Entity,在没有灯塔的情况下,我们需要遍历所有的Entity计算其是否在范围内,随着地图内的Entity越来越多,查找的效率也会越来越差,所以我们需要一种方法来过滤那些明显不需要参与计算的Entity,所以我们将地图分割成一个个区域,在其中心放置一个假想的"灯塔",每个"灯塔"都会保存区域内的Entity,这样当我们需要知道某点周围10格内有哪些Entity时,我们只需要计算出范围内有哪些"灯塔",然后获取这些"灯塔"保存的Entity列表,针对这些Entity进行计算就能节省大量计算2

优点
  • 实现简单
  • 相较于暴力法,灯塔法将大量Entity分散到了多个灯塔中,对于每个灯塔还是 *O(n2)*的复杂度,但由于把Entity数据量大量降了下来,所以性能要好的多
缺点
  • 存储空间不仅和Entity数量有关,还和场景大小有关
  • 浪费内存
  • 且当场景规模大过对象数量规模时,性能还会下降。因为要遍历整个场景。对大地图不太合适
总结

灯塔法相较于暴力法进行了一些优化,使其场景内区分成不同的区域,每个区域的Entity数量就有了减少,也更快了,但是由于某些区域可能没有Entity存在,但是仍需要对其申请固定的内存,对内存有所浪费,且当场景规模大过对象数量规模时,性能还会下降。因为要遍历整个场景。对大地图不太合适。

十字链表算法

十字链表算法是根据二维地图,将其分成x轴和y轴两个链表。如果是三维地图,则还需要维护多一个z轴的链表。将对象的坐标值按照大小相应的排列在相应的坐标轴上面。所谓十字链表,即把地图坐标轴中的 X 和 Y 轴看成是2个链表,将玩家的 X 坐标按照从小到大插入 X 链表,将玩家的 Y 坐标按照从小到大插入 Y 链表,查询时根据玩家的坐标分别从2个链表中取出范围内的所有玩家,对两个玩家列表做交集,即为我们需要发送消息的玩家列表3

优点
  • 节省内存空间,没有Entity那么就不会占用内存空间
  • 由于是有序链表,可以采用二分法进行快速搜索
  • 由于链表特性插入和删除不会那么麻烦
缺点
  • 大数据量的搜索性能还是有待提高、但是可以通过跳表等进行优化

四叉树算法

接下来我们讲解一下本篇文章最重要的部分,就是关于游戏AOI中的四叉树算法,四叉树其实在游戏AOI中不太常用(网上相关信息太少),经过查找一般都适用于地图的地形数据或者碰撞检测之类的地方,首先说一下什么是四叉树

四叉树

四元树又称四叉树是一种树状数据结构,在每一个节点上会有四个子区块。四元树常应用于二维空间数据的分析与分类。 它将数据区分成为四个象限。数据范围可以是方形或矩形或其他任意形状。

四叉树(quad-tree)是一种数据结构,是一种每个节点最多有四个子树的数据结构。

四叉树是在二维图片中定位像素的唯一适合的算法。因为二维空间(图经常被描述的方式)中,平面像素可以重复的被分为四部分,树的深度由图片、计算机内存和图形的复杂度决定。4

我们都知道二叉树的变体二叉查找树,非常适合用来进行对一维数列的存储和查找,可以达到 O(logn) 的时间复杂度。当使用二叉树进行增删查时,只需要跟每个节点对比之后选择一条路线依次向下递归就可以找到所需插入或删除或者查找的对象。

但是由于二叉树只支持一维数据的问题,当面对游戏场景中需要X、Y两种方向的坐标时,显得并不是那么够用,因此,根据四叉树的特性,我们就可以使用四叉树来代替二叉树对X、Y两种方向坐标内容进行存储管理,同时还拥有着 O(logn) 的效率

四叉树在AOI中的使用

通过四叉树的特性,我们可以把其想象成一个正方形(矩形也行、本文按照正方形讲解), 在最开始的树中,是只有一个根节点,没有任何子节点的,在树中的每个节点都会存储一个当前节点代表在地图中所覆盖的范围,当当前的跟节点中所存储的Entity数量大于N(一般来说取值128或64)且整个树的层次未超过D(一般取值为5)时触发扩容操作,此时,会根据公式将该节点所覆盖的范围均等的分为四分,分别为左上、右上、左下、右下四个格子,如图所示:
在这里插入图片描述
分割时,需要计算出四个子节点的覆盖范围, 而后根据覆盖范围,将上一层节点中存储的Entity根据其所处的坐标位置,分配到四个节点中的其中一个,这样之后,整棵树只有叶子节点存在Entity列表。当四个节点中的某个节点装满时并且深度也尚未达到最深,也就继续发生扩容操作:
在这里插入图片描述
之后的操作以此类推,直到深度达到设定的最大上限后,叶子节点的Entity的容量将会不设限,否则会存不进去新的Entity。

在查找时,只需要根据要查找的节点(范围)一层一层的向下递归即可。

接下来我们随着代码以及图片来看一下具体的插入、删除、修改、和范围查询的过程原理及实现。首先看一下四叉树莓个结构体的节点的表示。

四叉树节点的表示
const (
  // 最大子树的个数
   maxChild        = 4
  // 每个子树的最大容量
   maxRoleCapacity = 128
  // 四叉树的最大深度
   maxDeep         = 5
)
// 记录根节点的变量
var root *Node

type Node struct {
   
   // 子节点 4叉树,所以4个子节点
   ChildNode [maxChild]*Node
   // 当前节点存储的数据
   Data *data
   // 当前节点包含范围
   currentArea
   // 当前深度
   Deep int
  // 是否为叶子节点
   Leaf bool
}
// 表示方位
// 这个比较适合调试使用 
// 实际没什么大用处
type azimuth int

const (
  // 当前子树的方位
  // 根为-1
  // 在数组中表示为0,1,2,3
	leftUp azimuth = iota
	rightUp
	leftDown
	rightDown
)
// 当前节点覆盖范围
type currentArea struct {
   
  // x的起始范围 这个点是这个格子的左上角
	XStart    float64
  // y的起始范围 这个点是这个格子的左上角
	YStart    float64
  // 当前区域宽度,因为是正方形 所以长==宽
	AreaWidth float64
### 四叉树 AOI 算法原理 四叉树是一种递归地将二维空间划分为四个子区域的数据结构,在游戏中常被用来管理大量稀疏分布的对象[^1]。它通过不断细分空间来减少不必要的计算,从而提高效率。具体来说,四叉树会根据当前节点内的对象数量或空间大小决定是否继续分裂成更小的子节点。 #### 四叉树的特点 - **优点**: - 非常适合处理大规模、稀疏分布的数据集合[^3]。 - 能够有效降低全局范围内的碰撞检测或其他交互操作的成本。 - **缺点**: - 初始构建成本较高,尤其是在动态环境中频繁插入和删除对象时,维护代价较大[^3]。 - 对于密集分布的小型区域可能表现不佳。 --- ### 四叉树 AOI 算法实现方法 以下是基于 Golang 的四叉树 AOI 算法的一个简化版本: ```go package main import ( "fmt" ) type QuadTreeNode struct { Boundary Rectangle // 当前节点的空间边界 Capacity int // 子节点的最大容量 Points []Point // 当前节点存储的对象列表 NorthEast *QuadTreeNode NorthWest *QuadTreeNode SouthEast *QuadTreeNode SouthWest *QuadTreeNode } type Point struct { X, Y float64 Data interface{} } type Rectangle struct { X, Y, W, H float64 } func NewQuadTree(boundary Rectangle, capacity int) *QuadTreeNode { return &QuadTreeNode{ Boundary: boundary, Capacity: capacity, } } // 插入新点到四叉树中 func (q *QuadTreeNode) Insert(point Point) bool { if !q.Boundary.Contains(point.X, point.Y) { // 如果不在范围内,则不插入 return false } if len(q.Points) < q.Capacity && q.NorthEast == nil { // 小于最大容量则直接加入 q.Points = append(q.Points, point) return true } else { if q.NorthEast == nil { // 达到容量上限后拆分 q.Subdivide() } // 向对应的子节点插入 if q.NorthEast.Insert(point) || q.NorthWest.Insert(point) || q.SouthEast.Insert(point) || q.SouthWest.Insert(point) { return true } } return false } // 拆分子节点 func (q *QuadTreeNode) Subdivide() { x := q.Boundary.X y := q.Boundary.Y w := q.Boundary.W / 2 h := q.Boundary.H / 2 nwBoundary := Rectangle{x, y, w, h} neBoundary := Rectangle{x + w, y, w, h} swBoundary := Rectangle{x, y + h, w, h} seBoundary := Rectangle{x + w, y + h, w, h} q.NorthWest = NewQuadTree(nwBoundary, q.Capacity) q.NorthEast = NewQuadTree(neBoundary, q.Capacity) q.SouthWest = NewQuadTree(swBoundary, q.Capacity) q.SouthEast = NewQuadTree(seBoundary, q.Capacity) } // 查询某个范围内的所有点 func (q *QuadTreeNode) QueryRange(rangeRect Rectangle, foundPoints *[]Point) { if !rangeRect.Intersects(&q.Boundary) { // 不相交则返回 return } for _, p := range q.Points { if rangeRect.Contains(p.X, p.Y) { // 若在查询范围内则记录 *foundPoints = append(*foundPoints, p) } } if q.NorthEast != nil { // 继续向子节点查询 q.NorthEast.QueryRange(rangeRect, foundPoints) q.NorthWest.QueryRange(rangeRect, foundPoints) q.SouthEast.QueryRange(rangeRect, foundPoints) q.SouthWest.QueryRange(rangeRect, foundPoints) } } func main() { boundary := Rectangle{0, 0, 100, 100} root := NewQuadTree(boundary, 4) points := []Point{{10, 15}, {20, 30}, {40, 50}, {60, 70}} for _, p := range points { root.Insert(p) } var result []Point queryRange := Rectangle{15, 15, 30, 30} root.QueryRange(queryRange, &result) fmt.Println("Query Result:", result) } ``` 此代码展示了如何创建一个简单的四叉树并执行范围查询功能。其中 `Insert` 方法负责将新的对象插入到合适的节点位置,而 `QueryRange` 方法允许检索指定范围内的所有对象。 --- ### 总结 四叉树作为一种经典的分区算法,非常适合应用于游戏中的 AOI(感兴趣区域)机制[^5]。尽管它的初始构建较为耗时,但在处理大范围稀疏数据时表现出色。然而需要注意的是,如果目标场景具有高度动态变化特性,那么应考虑其他更适合的替代方案,例如 R 树 或者改进版网格算法[^4]。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值