游戏思考18:AOI视野同步算法介绍和简单实现(未完待续8/3)

一、玩家交互分类(构成游戏世界的三大基本要素)

1)玩家和npc的交互(异步)

例如格斗游戏,对即时响应的要求非常高,攻击方需要立即看到攻击结果,防守方也需要立即看到防守的结果,因此需要采用异步的通信方式但是这样就势必会导致攻击与防守的异步冲突,目前行业内普遍采用如下解决方案:

如果是不利的结果,采用承受方的结果,如果是有利的结果,采用施与方的结果(或者根据双方数据到达中央服务器的时序进行
判断)

2)玩家和环境的交互(同步)

拿炸弹举例,玩家A和玩家B同时走到炸弹面前进行拾取,假设玩家A在捡到炸弹后立即使用的炸弹,炸死了玩家B,甚至在地上炸出一个大坑,但后续服务器判断玩家A并没有捡到炸弹,而是玩家B捡到了,这时候就会出现一个很复杂的难题:重新复活玩家B?把地上炸出的坑重新填回去?这样就会有无数的不可预期的情况需要去做回档处理,因此采用异步会有很大的不一致问题,所以这种情况应采用同步的方式判断炸弹是否已经被别人捡走,同时播放一个捡到东西的动画,在动画期间去服务器同步数据

除了炸弹,还有门,开关等环境因素需要采用同步的方式

3)npc和环境的交互(异步)

大多数情况下,玩家并不关心npc与环境的交互,可以选择性的进行通信及渲染,同时可以降低通信的频率,因此可以采用异步通信方式

二、AOI介绍(定义、作用、压力预估、对应解决方法)

  • 定义
    AOI(Area Of Interest)翻译过来就是“感兴趣的区域”,这个玩意儿在很多的游戏中都会出现,比如在MMORPG游戏中,玩家走到某个场景的坐标(x,y)处,就需要通过AOI来获取到当前坐标处,一定范围内的所有玩家以及NPC、怪物(其实也算NPC的一种)的相关位置信息。

  • 作用
    当玩家进行移动时,同时也会对玩家范围内的玩家进行广播位置同步,使得其他玩家知晓当前玩家进行的移动位置和移动范围。当玩家进入一个游戏场景(地图、副本等)时,玩家所看到的各种各样的Entity(玩家,NPC,怪物皆算做Entity),都是通过服务器端的AOI系统在进行处理。

  • mmorpg做法
    MMO类型的游戏都会有野外和主城场景,一般来说,服务器只会同步你周边多少半径以内的玩家给你,太远的

原因:
一是玩家屏幕看不到没有意义
二是同步太多的玩家对于服务器压力成倍数上涨

  • 压力预估
    如果一个区域内有100个人,这些人可能都在不停的走路,如果是广播形式的话,那每一个人移动都要向另外的100个人进行位置同步。假如服务器每50ms同步一次玩家位置,那么服务器处理100个人位置就需要
100 * 100 * 20 = 200000 //20W次消息分发
  • 减少同步思考
    降低这部分的服务器性能,就需要降低服务器同步的量级,一种解决方案就是AOI。核心概念就是只对那些感兴趣的观察者发送数据。

  • 对应方法
    如果我们能把这个区域细分为10个,假定平均分配的情况下,每个区域里则只有10个人。那么此时每个人移动的时候,他就只要同步给同区域的10个人,大大减少需要同步的次数。同时,如果我们能充分利用现代CPU的核数,使用多线程来处理,则这个部分的性能损耗会大大的降低。

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

三、实现分类

1)暴力法

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

1、实现简单,不需要多余的复杂数据结构,每个场景保存一个数组作为存储玩家对象列表的数据结构即可
2、在少量Entity的地图场景之中(如小队副本,团队副本等),效率很高,且无需要复杂的数据结构

(2)缺点

1、当场景中Entity数据量巨大,遍历整个数组会有很大的性能损耗
2、Entity的场景进入、场景退出等相关操作需要频繁的操纵数组,数组本身对这种随机性的插入与删除的性能支持不佳
3、每次搜索单一Entity时,需要遍历整个数组

(3)代码实现(略)
(4)总结

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

2)九宫格

  • 准备工作
    1、服务器加载场景地图会将场景按照固定大小格子进行划分,并做好编号,
    2、通常有三个操作
1)EnterZone
2)LeaveZone
3)ChangeZone
(1)综合评价

1、网格大小尽量合理,网格划分得太小,对内存开销较大;网格划分得太大,对CPU开销较大,因为由矩形B可以看出,需要将B的信息发到不在B的视野内的其它的Obj(注意这里说的是视野,AOI是那9个格子),大大增加了开销。
2、一般网格的大小是大于玩家屏幕视野(玩家手机看到的场景范围),所以会存在消息庸余的情况。

(2)伪代码
//场景格子
class Zone
{
public:
    Zone();
    ~Zone();
    //获取格子四个角
    float YMax();
    float XMax();
    float YMin();
    float XMin();
 
private:
    Vector3 m_centor;  //地图(x,y,z) 格子中心点
    int32_t m_nZoneSize{0}; //方格大小,初始化时为0
    std::set<Player *> m_pPlayerSet; //格子内玩家
    int32_t m_nZoneId{0};  //格子编号,初始化时为0
};
 
 
//一个场景格子管理器
class ZoneManager{
    typedef std::set<Player *> players_type;
    typedef std::vector<ZoneId> zone_ids_type;
public:
    bool Init(float fMapX, float fMapY, int nZoneSize); //将地图划分成格子
    bool EnterZone();//玩家进入场景格子(出生、在地图上初始化-进入场景) 通知格子周围玩家
    bool LeaveZone();//玩家从格子删除(离开场景、死亡) 通知格子周围玩家
    bool ChangeZone();
    //玩家在地图上走动,移动到另一个格子(看不到你的玩家,你离开了他的视野,通知新玩家你进入视野)
    //计算原来格子周围玩家 oldset
    //计算现在格子周围玩家 newset
    //计算交集(在移动过程中始终看到你的玩家) xset
    // oldset - xset 你从他们视野中消息
    // new - xset   你进入他们视野
    // xset 你在移动 
    players_type& GetSurrondPlayers(ZoneId id);
    zone_ids_type &GetSurrondZoneIds(ZoneId id);
 
private:
    std::vector<Zone> m_vecZone;
};
  • 接口说明
    1)EnterZone()
    根据玩家坐标,加入到所属的格子中,通过计算以这个格子的为中心的九个格子,这九个格子内的玩家就要被通知有新玩家初始化,同时这个新玩家初始化九个格子内的所有玩家。
    2)ChangeZone()
    根据移动前位置的格子,计算出移动前的oldaoi集合,根据当前位置的格子,计算出当前的curaoi集合,如果oldaoi, curaoi为同一个格子,则通知格子内的所有玩家该玩家在移动。如果oldaoi,curaoi不是同一个格子,即发生了跨格子的操作,那么要将该玩家从旧格子移除,同时加入新格子。同时分别遍历oldaoi,curaoi,计算出需要通知玩家消失的格子集合,通知玩家出生的格子集合,以及通知玩家移动的格子集合。
    3)LeaveZone()
    玩家离开地图,将玩家从对应的格子里面删除,同时通知aoi集合有玩家离开。
(3)使用和优化点

1、玩家身上保存一个oldzoneid, 心跳里检查玩家现在的zoneid 与oldzoneid 是否一致,不一致,就执行changezone逻辑。(我感觉不该是行走的时候判断再去转发消息吗??)
2、可以优化的点

1)在将地图划分成格子时,每个格子对象也保存周边8个格子id,不用在每次计算。
2)数据保存,每个格子对玩家对象保存使用双链表,每个格子对象保存链表的头节点、尾节点。玩家身上保存这个链表的
前驱节点和后驱节点,这样玩家的删除操作很容易实现,同时玩家移动到下一个格子,只需要插入到另一个格子尾节点即
可。

3、详细代码链接
4、更加对AOI优化的方法

1)一种是对单个场景分线,复制多个相同的场景副本,玩家随机进入某个场景的副本,以此来达到分流的目的。
2)从梦幻西游手游服务器AOI设计方案中看到的,设计一个分层AOI概念,也就是单个场景创建多个AOI对象。把玩家分通过某个规则,分在不同的层次。当人数增多时,可以动态进行分层,人数少的时候可以把层数合并回来,然后就是让玩家在人少的时候也能看到几个人,人多的时候还是看到几个人。这种优化放过来后,这是同一台服务器,效果大概是这样的,你在同一层里看到的都是你关心的人。这个感受就很好了。(这个我不懂,这是啥?

3)灯塔(九宫格优化版)

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

(1)优点

1、实现简单
2、相较于暴力法,灯塔法将大量Entity分散到了多个灯塔中,对于每个灯塔还是 O(n*n)的复杂度,但由于把Entity数据量大量降了下来,所以性能要好的多

(2)缺点

1、存储空间不仅和Entity数量有关,还和场景大小有关
2、浪费内存(由于某些区域可能没有Entity存在,但是仍需要对其申请固定的内存,对内存有所浪费)
3、且当场景规模大过对象数量规模时,性能还会下降。因为要遍历整个场景。对大地图不太合适

(3)代码实现(略)
  • 代码说明
    1、在每个小格子中间放一个灯塔,这个灯塔管理两个队列:一个是本格子内所有的对象集合,另一个是对本灯塔感兴趣的对象集合(简称观察者)。
    2、而地图上的每个对象,维护一个视野队列:该队列为其视野范围内的所有对象,即自身感兴趣的所有对象。
    3、一个对象在地图上面运动:分为三个操作:enter,move,leave.

1)enter:当对象进入地图的时候,根据对象的当前位置和对象的感知距离,可以获取到该对象能观察到的所有灯塔,遍历这些灯塔,将该对象添加为其观察者。同时将这些对象添加到自己的视野队列中。
2)move:当对象开始移动的时候,对象从一个点到另一个店,那么视野范围必然发生变化。此刻需要将对象从老的灯塔的观察者列表移除,同时将对象添加进新的灯塔的观察者列表。此外,还需要跟新玩家的视野队列,因为视野范围变化,视野内的对象也相应变化。
3)leave:当对象离开的时候,将自身从附近灯塔的观察者队列中移除。
通过灯塔法,每当物体发生变化,我们能马上根据其当前位置,定位到他的所在的灯塔,同时找到它视野范围内相关联的物体。这样避免了遍历地图上所有玩家进行处理的方式。

(4)总结

1、灯塔法相较于暴力法进行了一些优化,使其场景内区分成不同的区域,每个区域的Entity数量就有了减少,也更快了;
2、但是由于某些区域可能没有Entity存在,但是仍需要对其申请固定的内存,对内存有所浪费
3、且当场景规模大过对象数量规模时,性能还会下降。因为要遍历整个场景。对大地图不太合适。
4、格子越大,消耗内存越大,计算量也变得越大
5、通过灯塔法,每当物体发生变化,我们能马上根据其当前位置定位到他的所在的灯塔,同时找到它视野范围内相关联的物体。这样避免了遍历地图上所有玩家进行处理的方式。

4)十字链表法

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

(1)优点

1、节省内存空间,没有Entity那么就不会占用内存空间
2、由于是有序链表,可以采用二分法进行快速搜索
3、由于链表特性插入和删除不会那么麻烦

(2)缺点

大数据量的搜索性能还是有待提高、但是可以通过跳表等进行优化

(3)代码实现(暂时只有说明)

1、维护两条链表(双向链表):
①一条根据地图上所有物体的x坐标从小到大依次插入链表,
②一条根据地图上所有物体的y坐标从小到大依次插入链表
(可以想象成一个十字架。这样便把地图上的所有对象按序分配到了x,y链表上)
2、双向链表的好处
双向链表的好处是,获取到链表中的一个节点,便可以向前和向后遍历。这样,当我们拿到一个对象时,要获取该对象的视野范围就变得非常简单。避免了从头到尾遍历所有对象。
3、同步流程顺序介绍:
(获取该对象的视野范围就变得非常简单。可以向前和向后遍历,避免了从头到尾遍历所有对象。)
1)首先根据x坐标,在x链表上找到该节点,然后从该节点向前和向后遍历,根据x方向的视野范围找出需要识别的对象。
2)然后根据y坐标,在y链表上找到该节点,然后从该节点向前和向后遍历,根据y方向的视野范围找出需要识别的对象。
3)拿到x,y链表上需要关注的对象,然后取他们的交集,这便是玩家视野范围内的对象。

(4)总结

1、优化点:为了保证链表元素的查找速度,链表使用跳表实现。
2、需要xlist,ylist两条链表,需要将玩家的x,y坐标分别有序的存储到这两个链表上。(设计基于二维)

5)四叉树算法

传送门
四叉树其实在游戏AOI中不太常用(网上相关信息太少),经过查找一般都适用于地图的地形数据或者碰撞检测之类的地方,首先说一下什么是四叉树

(1)优点
(2)缺点
(3)代码实现
(4)总结
  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值