大规模单位实时游戏寻路的构建

本文发布于游戏程序员刘宇的个人博客,欢迎转载,请注明来源https://www.cnblogs.com/xiaohutu/p/10504586.html 

某个神秘的时间,我接到了一项神秘的任务,最核心的难度是要求实现:引擎是Unity3D,在手机端可以流畅运行为前提,在一个实时战斗的过程里,地图有地形(而且是会被动态改变的地形),数百个单位独立AI寻路、要实现忽略掉部分单位的筛选寻路、动态避障、满足帧同步需求并可以被服务器验证。可以说这个需求是集合了各种难点于一身。在这个任务的过程里,发现网上这样的文章比较少,所以想总结分享一下。着重于算法和思路这一块,不涉及图形上的问题。 

一. 通常怎么做

需要寻路,又需要避障,先说一些常规的解决思路:

1.1 寻路

寻路就是基于既有的数据寻找到符合条件的一条路线:

1. 拿来主义类:用unity3d自己的NavMesh、自己的A* project,包含了寻路数据的生成和计算。

2. 进阶类:格子寻路可以用:

  自己写A* 算法,进行常见的优化(二叉堆优化、HOT优化等等等等), 分层A*

  JPS以及各种优化(位运算,剪枝,预处理等)

  Dijkstra(扩展Dynamic A*)

  DFS, BFS

  。。。

  (后续开文详解)

1.2 避障部分

合理的通过改变自己的行为(速度,方向)来避免穿插:

0. 真实物理

1. 在引擎里使用射线判定是否碰撞,并等待/重新寻路(耗时)

2. 根据距离判断是否碰撞,并等待/重新寻路,最简单的是直接用距离计算(耗时)

3. 在2的基础上使用算法优化距离判断,减少计算量,一般来说可以:

  3.1 九宫格,分区查找计算目标

  3.2 根据需求四叉树/八叉树来对目标列表进行分区分块,提高查询的速度

  3.3 十字链表来存储格子对应的单位,提高查询速度

  3.4 双向链表的视野管理思路

4. 其他思路:

  4.1 Steer类、使用作用力思路计算

 

 

  4.2 VO、RVO、ORCA等避障算法、通过速度与距离整体计算

 

  4.3 其他群体行为算法 

  4.4 路径冲突、管理类算法

这一部分的很多思路和做法和技能AI里的目标筛选类似,可以公用数据结构来快速获得计算结果。

二. 项目的特点分析

在我的例子里: 

1.单位要可以实时寻找看起来合理的路线行走,路线要避开行人和地形障碍 

2.动态避障,不能穿人、地形 

3.要实现有筛选的寻路,寻路时要忽略掉部分单位 

4.有地形玩法,且会被技能动态改变 

5.单位数量巨大 

6.满足帧同步需求 

7.可以被服务器验证

三. 具体思路

冷静思考,发现几百个这个数量级有点大,必须从底层到上层都很优化才可以,不然光是一些算法乘以单位数量运行的时间都是天文数字。

而且封装好的东西就没办法考虑了:没有办法在服务器同步运行校验、浮点数没有办法满足帧同步的要求、动态改变障碍数据的支持不够。

 所以必须得自定义的去实现这些东西,而且首先的有一定的原则:

1. 必须有简单、可靠、快速、通用的数据结构来存储寻路数据和障碍数据

2. 简单可靠的服务器客户端都可以运行的算法代码

3. 必须用有损服务/分层服务的思想来剪裁算法的时间

接下来我们一步步的来分析:

3.1 数据结构部分

避免使用大量的容器,避免容器操作的消耗,尽量使用数组、变量、常量。提前缓存各种开方、定点数三角函数、距离计算等的查表值。 

3.1.1 地图数据

我们这个项目大量的战斗地图可以抽象成数组,但是地表类型不一样,高度也不一样,首先写了一个unity3d的插件,一键导出战斗地图的数据,包含N个2的幂为宽高的数组,包含了一些基本信息:

地形的障碍数据、地形数据(用来计算寻路和保存地形玩法),高度数据(避免其他更耗时的方式计算单位的y高度)。

3.1.2 角色数据

我们这个项目并不是一个角色使用一个格子,而是多个格子(逻辑比较复杂),所以缓存了很多数组,包含角色的半径图(每一个格子里都存储了最大可以允许占位的角色半径,这样可以快速判断一个格子是否可以站人)、格子与多个单位的映射关系等。

如下如,无穷代表谁都可以站,其他人在靠近时根据黄色的半径值来判自己的半径是否低于数值。如果大家的游戏没那么复杂,那么更好了,根据游戏情况来,甚至最简单一个格子存储一个人都可以。

 

3.1.2 运行时数据

存储了两层数据

第一层,N份内容不同的,可以直接在寻路中被使用的障碍数据数组。(每个游戏情况不一样)

第二层,根据逻辑存储的,可以二次生成第一层数据的逻辑数据。

* 在单位行走时,会实时更新各种地图的缓存数据

* 在寻路需要检索时,大部分情况直接使用特定的数组缓存。

* 当特殊寻路需求时(如筛选),使用数组基于第二层数据进行修改的特殊数据。

 

在这个转换的过程中,通过对每一个玩家的半径图的汇总,形成新的全地图半径图。

既然是大规模单位的算法,那么这个计算难免最终必须优化成为位操作,于是这里涉及到一个通过位操作快速计算最低位的方法。

1. 位运算 v & -v,直接保留最低位,足够进行通过性判断。

2. DeBruijn序列的移位寄存器算法,理论上一个2的幂都可以乘以神奇数字来获得在DeBruijn序列里的值,可以直接获得可以站的半径。

unsigned int v;   
int r;           
static const int MultiplyDeBruijnBitPosition[32] = 
{
  0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 
  31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9
};
r = MultiplyDeBruijnBitPosition[((uint32_t)((v & -v) * 0x077CB531U)) >> 27];

理论上2的幂数位都可以有神奇数字,详情可以看这里https://en.wikipedia.org/wiki/De_Bruijn_sequence

3.1.3 数据结构其他

其他还林林总总存储了各种缓存数据,这一块建议大家结合自己的项目仔细思考,目的是提高寻路系统和避障系统的各种效率:

1. 自定义寻路格子数据读取的效率

2. 避障系统检索碰撞单位的效率

3. 避障系统和寻路系统检索可站点的效率

这些东西大家可能细节都不太一样,总体就是这些。

3.2 寻路算法的选择

寻路数据基于半径图、地图数据、技能地形数据等形成了可以快速读取应用的障碍数据。算法方面,根据情况最后采用了自定义的格子算法。优点是纯逻辑,服务器也可以运行来校验。在这个过程里,算法的耗时是结结实实的跑不掉的,所以我们是不可能采用单一耗时的算法去计算所有情况了。我使用了多种算法的组合来应对性能问题,并且根据情况逐步使用更耗时的算法:

1. 根据障碍数据快速检索直线阻挡,采用直线寻路。(这就是为什么障碍数据必须设计的很好,可以快速的计算直线是否有阻挡)

2. 在很短距离范围内使用一些非最优解路线的算法, 类似贪心算法等,因为距离短,所以耗时会很少,这种使用次数最多

3. 实现最优版的类A*寻路算法(JPS等),在必须使用时使用用它,首先是大多数情况下根据逻辑都使用部分路径搜索,先逼近,这样路径很短,耗时低。

4. 部分情况下使用完全的路径搜索。

1234步步递进,这一部分大家肯定是根据自己项目的需求来选择使用,键是一定要有不同耗时的方案来选择。

一些优化: 

1. 算法本身的优化,必须是非常极限,到每一个细节。这点C/C++语言的优势凸显,这个项目的引擎是unity3d,也可以写然后用dll调用

2. 对使用频度进行优化,寻路失败的单位进行等待,耗时寻路的操作每一帧次数上限进行控制

3. 游戏逻辑层面对AI的耗时进行错峰

4. 帧同步层面对逻辑帧层面的频率进行控制

 

3.3 避障算法1

大家要根据项目的需求来进行,在我们这个例子里不可避免的是要自己实现算法,最终我采取了下面的方式,主要是可以结合前面的数据结构来使用:

1.使用与寻路公用的部分数据,通过直接使用大图应用实现了快速严格的障碍数据判定,即不能走的地方绝对不能走

2.部分自定义的VO(某些情况下使用)来控制是否停止,Steer里的SlowRadius来控制挤碰和松散程度,要碰撞的单位停下来,在AI层选择行为是等待一会还是重新寻找目标。因为我们还是要很多单位执行AI,所以这一部分实现的时候非常简单,实现一小部分最需要的逻辑。同时还是注意这类算法的采样数量和计算的次数。

 

最后整体的效果还可以,基本上(走-->等待(避让)-->继续走-->停) 这样的一个流程基本满足需求,可以实现类似物理挤碰的效果。

3.4 避障算法2

还有一个也很好但是我没有使用的避障方法,大家可以学习探讨。这个方法的普通版适用于不使用半径图或者不需要到物理级表现挤碰逻辑、但是需要避免穿插的情况。请看下图:

假设一个人从A走到B点(为了表达方便,简化成直线),那么从A到B的路线可以根据A的速度计算到达的时间来标记出优先级等级,也可以称之为灰度等级。

那么:

1. 在设定在时间片内(即误差允许的时间片内)可以到达的路径上,标记好需要占用的路径的优先级等级。

2. 不同路径交叉时,根据时间的这个灰度等级来计算当前是否可以穿过,还是应该等待。

3. 移动时,通过设计高效的数据结构来维护更新整个数据结构

这个思路有很多东西可以进阶:

1. 当速度不同时,要根据速度计算灰度等级级。

2. 当半径不同时,需要更复杂的系数来表达我在某个时间是不是阻挡了一个格子的通过性,即占用了很多个格子的灰度等级。

 

比如灰色系的朋友,速度变快了,t1时间可以到达遥远的X了,那么他相对的在更短的时间片里可以飞速通过。

蓝色系的朋友,变胖了,还是从C到D,但是占用灰度等级的格子变多了。

通过设计高效的算法,这个方法一样可以较好的实现等待避让。 

 

3.5 单位搜索相关

除了寻路和避障外,游戏AI里最耗时的莫过于各种技能和攻击的范围搜索了,在几百个单位的时候,这种搜索需求的消耗也非常可观。基于上面的数据结构,可以设计出一些很有意思的,快速搜索制定范围内单位列表的算法。

比如放技能使用的,我们项目里使用了一些数据和链表的结合,可以较高效率的搜索到指定格子周围的单位列表。还有可以基于障碍数据,等到很多地方是否有人这样的信息,这一块大家可以根据自己项目的情况来看。

 

四. 小结

先看一下之前提出的项目要求:

1.单位要可以实时寻找看起来合理的路线行走,路线要避开行人和地形障碍

2.动态避障过程,不能穿人、地形 

3.要实现有筛选的寻路,寻路时要忽略掉部分单位

4.有地形玩法,且会被技能动态改变 

5.单位数量巨大 

6.满足帧同步需求 

7.可以被服务器验证

其中1-5都已经满足需求,6通过整个实现过程中全部采用自定义的定点数来完成,7因为我们全部使用平台无关的逻辑代码,不使用插件,所以也满足需求。

 

通过以上的设计,AI运算已经占用很低,基本上达到了性能需求。经过项目实际运行,在3D角色200个左右的情况下,AI的运算依旧非常低。现在ECS逐渐开始流行,结合引擎层面的ECS管理,使用类似的思路来构建极限的AI和算法,我相信还有很大的潜力可以挖掘。

总体来说,这类问题最关键的地方还是在于前期数据结构的设计,然后根据性能和项目功能需求来设计、实现算法。根据项目的实际需求,当性能需求没那么高时,可以采取一些表现更好的方式,如真实物理等。

本文提到的一些算法等,后续陆续开文详解。

谢谢大家费时阅读。

 

posted @ 2019-03-10 10:50  游戏程序员刘宇  阅读(6769)  评论(5编辑  收藏  举报