目录html
在了解路径规划以前必须先了解基本的寻路算法。程序员
可参考A*寻路算法:A*寻路算法 - KillerAery - 博客园算法
大部分讨论A*算法使用的节点是网格点(也就是简单的二维网格),可是这种内存开销每每比较大。
实际上A*寻路算法,对于图也是适用的,实现只要稍微改一下。编程
所以咱们能够把地图看做一个图而不是一个网格,使用预先设好的路径点而不是网格来做为寻路节点,则能够减小大量节点数量。数据结构
(如图,使用了路径点做为节点,路径点之间的连线表示两点之间可直接移动穿过)函数
使用路径点的好处:性能
假若一个地图过大,开发人员手动预设好路径点+路径链接的工做就比较繁琐,并且很容易有错漏。
这时可使用洪水填充算法来自动生成路径点,并为它们连接。spa
算法步骤:
1.以任意一点为起始点,往周围八个方向扩展点(不能通行的位置则不扩展)翻译
2.已经扩展的点(在图中被标记成红色)不须要再次扩展,而扩展出来新的点继续扩展3d
3.直到全部的点都被扩展过,此时能获得一张导航图
//洪水填充法:从一个点开始自动生成导航图 void generateWayPoints(int beginx, int beginy, std::vector<WayPoint>& points) { //须要探索的点的列表 std::queue<WayPoint*> pointsToExplore; //生成起点,若受阻,不能生成路径点,则退出 if (!canGeneratePointIn(beginx, beginy))return; points.emplace_back(WayPoint(beginx, beginy)); //扩展距离 float distance = 2.3f; //预先写好8个方向的增值 int direction[8][2] = { {1,0}, {0,1}, {0,-1}, {-1,0}, {1,1}, {-1,1}, {-1,-1},{1,-1} }; //以起点开始探索 WayPoint* begin = &points.back(); pointsToExplore.emplace(begin); //重复探索直到探索点列表为空 while (!pointsToExplore.empty()) { //先取出一个点开始进行探索 WayPoint* point = pointsToExplore.front(); pointsToExplore.pop(); //往8个方向探索 for (int i = 0; i < 8; ++i) { //若当前点的目标方向连着点,则无需往这方向扩展 if (point->pointInDirection[i] == nullptr) { continue; } auto x = point->x + direction[i][0] * distance; auto y = point->y + direction[i][1] * distance; //若是目标位置受阻,则无需往这方向扩展 if (!canGeneratePointIn(x, y)) { continue; } points.emplace_back(WayPoint(x, y)); auto newPoint = &points.back(); pointsToExplore.emplace(newPoint); //若是当前点可以无障碍通向目标点,则链接当前点和目标点 if (canWalkTo(point, newPoint)) { point.connectToPoint(newPoint); } } } }
自动生成的导航图能够调整扩展的距离,从而获得合适的节点和边的数量。
导航网格将地图划分红若干个凸多边形,每一个凸多边形就是一个节点。
使用导航网格更加能够大大减小节点数量,从而减小搜寻所需的计算量,同时也使路径更加天然。
(使用凸多边形,是由于凸多边形有一个很好的特性:边上的一个点走到另一点,无论怎么走都不会走出这个多边形。而凹多边形可能走的出外面。)
然而该如何创建地图的导航网格,通常有两种方法:
导航网格是目前3D游戏的主流实现,例如《魔兽世界》就是典型使用导航网的游戏,Unity引擎也内置了基于导航网格的寻路系统。
若是你对如何将一个区域划分红多个凸多边形做为导航网格感兴趣,能够参考空间划分的数据结构(网格/四叉树/八叉树/BSP树/k-d树/BVH/自定义划分) - KillerAery - 博客园里面的BSP树部分,也许会给你一些启发。
主要方式是经过预先计算好的数据,而后运行时使用这些数据减小运算量。
能够根据本身的项目权衡运行速度和内存空间来选择预计算。
(以这副图为示例)
借助预先计算好的路径查询表,能够以O(|v|)的时间复杂度极快完成寻路,可是占用空间为O(|v|²)。
(|v|为顶点数量)
实现:对每一个顶点使用Dijkstra算法,求出该顶点到各顶点的路径,再经过对路径回溯获得前一个通过的点。
有时候,游戏AI须要考虑路径的成原本决定行为,
则能够预先计算好路径成本查询表,以O(1)的时间复杂度获取路径成本,可是占用空间为O(|v|²)。
实现:相似路径查询表,只不过记录的是路径成本开销,而不是路径点。
在寻路中,一个令游戏AI程序员头疼的问题是碰撞模型每每是一个几何形状而不是一个点。
这意味着在寻路时检测是否碰到障碍,得用几何形状与几何形状相交判断,而非几何形状包含点判断(毋庸置疑前者开销庞大)。
一个解决方案是根据碰撞模型的形状扩展障碍几何体,此时碰撞模型能够简化成一个点,这样能够将问题由几何形状与几何形状相交问题转换成几何形状包含点问题。
这里主要由两种扩展思路:
这些扩展障碍几何形状的计算彻底能够放到预计算(离线计算),不过要注意:
待更新
有时候,大量物体使用A*寻路时,CPU消耗比较大。
咱们能够没必要一帧运算一次寻路,而是在N帧内运算一次寻路。
(虽然有所缓慢,可是就几帧的东西,通常实际玩家的体验不会有大影响)
因此咱们能够经过每帧只搜索必定深度 = 深度限制 / N(N取决于本身定义多少帧内完成一次寻路)。
基于网格的寻路算法结果获得的路径每每是不平滑的。
(上图为一次基于网格的正常寻路算法结果获得的路径)
(上图为理想中的平滑路径)
很容易看出来,寻路算法的路径太过死板,只能上下左右+斜45度方向走。
这里提供两种平滑方式:
它检查相邻的边是否能够无障碍经过,若能够则删除中间的点,不能够则继续往下迭代。
它的复杂度是O(n),获得的路径是粗略的平滑,仍是稍微有些死板。
void fastSmooth(std::list<OpenPoint*>& path) { //先获取p1,p2,p3,分别表明顺序的第一/二/三个迭代元素。 auto p1 = path.begin(); auto p2 = p1; ++p2; auto p3 = p2; ++p2; while (p3 != path.end()) { //若p1能直接走到p3,则移除p2,并将p2,p3日后一位 // aa-bb-cc-dd-... => aa-cc-dd-... // p1 p2 p3 p1 p2 p3 if (CanWalkBetween(p1, p3)) { ++p3; p2 = path.erase(p2); } //若不能走到,则将p1,p2,p3都日后一位。 // aa-bb-cc-dd-... => aa-bb-cc-dd-... // p1 p2 p3 p1 p2 p3 else { ++p1; ++p2; ++p3; } } }
它每次推动一位都要遍历剩下全部的点,看是否能无障碍经过,推动完全部点后则获得精准平滑路径。
它的复杂度是O(n²),获得的路径是精确的平滑。
void preciseSmooth(std::list<OpenPoint*>& path) { auto p1 = path.begin(); while (p1 != path.end()) { auto p3 = p1; ++p3; ++p3; while (p3 != path.end()) { //若p1能直接走到p3,则移除p1和p3之间的全部点,并将p3日后一位 if (CanWalkBetween(p1, p3)) { auto deleteItr = p1; ++deleteItr; p3 = path.erase(deleteItr,p3); } //不然,p3日后一位 else { ++p3; } } //推动一位 ++p1; } }
与从开始点向目标点搜索不一样的是,你也能够并行地进行两个搜索:
一个从开始点向目标点,另外一个从目标点向开始点。当它们相遇时,你将获得一条路径。
双向搜索的思想是:单向搜索过程生成了一棵在地图上散开的大树,而双向搜索则生成了两颗散开的小树。
一棵大树比两棵小树所需搜索的节点更多,因此使用双向搜索性能更好。
(以BFS寻路为例,黄色部分是单向搜索所需搜索的范围,绿色部分则是双向搜索的,很容看出双向搜索的开启节点数量相对较少)
不过实验代表,在A*算法每每获得的不会是一棵像BFS算法那样散开的树。
所以不管你的路径有多长,A*算法只尝试搜索地图上小范围的区域,而不进行散开的搜索。
若地图是复杂交错多死路的(例如迷宫,不少往前的路实际上并不通往终点),A*算法便会容易产生散开的树,这时双向搜索会更有用。
游戏世界每每不少动态的障碍,当这些障碍挡在计算好的路径上时,咱们经常须要从新计算整个路径。可是这种简单粗暴的从新计算有些耗时,一个解决方法是用路径拼接替代从新计算路径。
首先咱们须要设置 拼接路径的频率K:
例如每K步检测K步范围内是否有障碍,如有障碍则该K步为阻塞路段。
接着,与从新计算整个路径不一样,咱们能够从新计算从阻塞路段首位置到阻塞路段尾的路径:
假设p[N]..P[N+K]为当前阻塞的路段。为p[N]到P[N+K]从新计算一条新的路径,并把这条新路径拼接(Splice)到旧路径:把p[N]..p[N+K]用新的路径值代替。
一个潜在的问题是新的路径也许不太理想,下图显示了这种状况(褐色为障碍物):
最初正常计算出的路径为红色路径(1 -> 2 -> 3 -> 4)。
若是咱们到达2而且发现从2到达3的路径被封锁了,路径拼接技术会把(2 -> 3)用(2 -> 5 -> 3)取代,结果是寻路体沿着路径(1 -> 2 -> 5 -> 3 -> 4)运动。
咱们能够看到这条路径不是这么好,由于蓝色路径(1 -> 2 -> 5 -> 4)是另外一条更理想的路径。
一个简单的解决方法是,设置一个阈值 最大拼接路径长度M:
若是实际拼接的路径长度大于M,算法则使用从新计算路径来代替路径拼接技术。
M不影响CPU时间,而影响了响应时间和路径质量的折衷:
路径拼接确实比重计算路径要快,但它可能算出不怎么理想的路径:
在A*寻路算法里,一个节点的预测函数最直观的莫过于欧几里得距离。
然而对于复杂的游戏世界来讲,特别是对于须要复杂决策的AI来讲,节点的预测值可不只仅就距离一个影响因素:
所以,咱们能够自定义寻路的预测函数,以调整成为适应复杂游戏世界的AI寻路。
游戏AI 系列文章:https://www.cnblogs.com/KillerAery/category/1229106.html