【转】利用跳点搜索算法为A*寻路提速【2】
寻路在游戏中可谓无处不在。因此当运用诸如A*之类的算法时,对其含义进行理解就至关重要。在本教程中,我们将介绍一种相对较新的基于网格世界搜索方法:跳点搜索[Jump Point Search],它可以将A*提速一个数量级。
注意:虽然本教程使用AS3和Flash,你也同样可以将此技术与概念运用到其他游戏开发环境。
本实现参考的JPS相关的论文从这里找到:跳点搜索。基于Lua的实现,Jumper,在本实现中部分用到。
跳点搜索Demo
(鉴于坛子无法切换wmode,请移驾原页面查看swf效果)或点此查看 <ignore_js_op>
Pathfind.swf
单击SWF给它焦点,然后把鼠标移动到地图的非阻塞区域,NPC会自发向此点行进。敲击空格可以在A*,跳点搜索还有二者兼备的状态切换。
搭建
以上demo的实现,GPU加速的渲染部分使用AS3和Starling框架,以及数据结构部分用到polygonal-ds库。
寻路
寻路在视频游戏中很常用,如果你从事游戏开发那么总有一天会接触到。它的主要用途是赋予NPC智能搜索移动的行为,以避免阻塞(但还是时有发生)。
某些游戏中,玩家avatar也受限于寻路规则(策略游戏,许多第三称RPG游戏以及冒险游戏)。长久以来,并没有什么寻路方法能够完美解决阻塞问题,即便是在大型AAA级游戏中,你也仍会发现有阻塞路径的情况存在。
即便这样,寻路算法中的A*(A星)算法也还是值得研究。本教程中我们将简略地了解下A*并且看看如何利用另一个算法,跳点搜索,来为其提速。
首先,我们需要用寻路算法可以使用的方式来表示游戏世界。
世界的表示方式
当思考游戏中的寻路的时候,最先需要考虑的是世界表示方式。可通行区域和障碍的数据如何组织结构?
最简单的表示方式是使用基于网格的结构,它的路径节点用网格的形式组织,可用二维数组表示,在本教程中我们使用的就是这种表示。比较特别的是,它是八方向网格表示:允许向直线和对角方向运动。
<ignore_js_op>
图像中的黑色像素代表阻塞单位。
不同需求所需要的实现也会不同,因此本结构不一定适合你。不过稍稍做些处理(通常离线操作),你就可以将寻路的表示方式改成其他格式。如果不采用基于网格的方法,也可以用包含多边形(障碍为多边形表示)或导航网格(导航领域为多边形表示)的方法,这些方式只要用更少的节点就可以表示相同数据。
地图表示方式中存储的另一种数据是cost:它代表从一节点移动到另一节点所需的权值,可用于AI确定路径,如,倾向选择道路而非自然地形(道路的权值小于地形的)。
跳点搜索专为基于八方向的网格地图表示方式所设计。简洁形式下不支持加权地图。(在最后一节,我将就此讨论一个可能的补救办法)
A *寻路复习
现在我们已经有了世界的表示方式,那么接下来就快速浏览A*的实现。A*是加权图搜索算法,对开始节点到结束节点的区域进行启发式搜索。
我强烈建议你看看这个寻路算法的直观表示:
PathFinding.js – visual。玩玩看可以提升对算法行为的了解——同时其本身也挺好玩的。
在矩形网格中使用A*寻路,我们执行以下步骤:
- 1.获取最接近你的位置的节点,将它声明为开始节点并添加到开放列表中。2.当开放列表存在节点: 3.从开放列表中选择具有最小F值的节点。将它添加到封闭列表中(不再对其进行考虑)。 4.对于每个不在封闭列表的邻居(相邻单位): 5.将它们的父节点设为当前节点。 6.计算G值(起始节点至邻居的距离)并添加到开放列表中 7.综合考虑G值来计算F值。
相关文章
初学A*寻路(深入解释F和G值的文章)。
启发式搜索本质上是猜测所正在评估的节点能够到达目标的几率。启发式搜索会导致寻路算法的效率随着所需访问的节点数量变化而产生巨大波动。在此我们会用到曼哈顿距离[Manhattan distance](若节点越接近目标,这个值越小):
- private function manhattanDistance(start:Node, end:Node):int {
- return Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
- }
大概就是这么表示的。当我们找到目标节点的时候,就可以终止算法然后用父节点回溯构建路径。
搜索算法也可用于其它东西。A*为常规加权图的搜索算法,也可以用于其他类似的图。可以涵盖到AI的其他领域,比如寻找达到特定目的的最优方法:选择丢炸弹还是寻找掩护抑或试图偷偷潜到敌后方?
游戏开发中,我们需要快速达成任务,尤其是以60帧每秒来刷新游戏的情况更是分秒必争。虽然使用A*效率还不错,但有时候还是需要对它再压榨压榨,提高效率或减少内存使用。
优化
从一开始的表示方式的选择就会对寻路性能及算法的可选范围产生影响。所要进行搜索的图的尺寸,对寻路性能的优劣将会产生很大影响(这合情合理,在同个房间里寻路明显比在整个城市里要来得容易)。
接着,你就可以考虑更高级别的优化,这一般会涉及将数据归并为较小区域,随后再通过搜索细化区域来改良路径。打个比方,如果你想去邻市的某餐厅,首先考虑的是如何从你现在所在的城市到达邻市,一旦你到达了邻市,就将“搜索”的区域限制在餐馆所在的分区,而不用再管其他的东西了。可能会涉及到的内容有swamps,dead end elimination与HPA*。
选定了数据表示和可行的抽象化方式之后,接入用于寻找节点的算法,就可以四处游移寻找目标。这种算法通常是基于A *搜索而略有修改的算法,但如果情况比较简单,你也可以直接使用简易的A*。源码下载中我提供了一个基于网格的实现。
跳点搜索
本教程主要关于实现跳点搜索的,因此寻路图将用网格来表示。与其他不同的是,它需要八方向网格以便算法使用。
跳点搜索其实是用来消除某类网格聚合的众多中间节点。跳过大量可能会被添加到开放列表和封闭列表中的中间节点以及其他计算,而对下一个节点的选择过程则做更多处理。
在A*里,我们会从开放列表中选择具有最小F值的节点。但跳点搜索算法则不然,我们不是对相邻节点进行选择,而是调用以下函数:
- function identifySuccessors(current:Node, start:Node, end:Node):Vector.<Node> {
- var successors:Vector.<Node> = new Vector.<Node>();
- var neighbours:Vector.<Node> = nodeNeighbours(current);
- for each (var neighbour:Node in neighbours) {
- // Direction from current node to neighbor:
- var dX:int = clamp(neighbour.x - current.x, -1, 1);
- var dY:int = clamp(neighbour.y - current.y, -1, 1);
- // Try to find a node to jump to:
- var jumpPoint:Node = jump(current.x, current.y, dX, dY, start, end);
- // If found add it to the list:
- if (jumpPoint) successors.push(jumpPoint);
- }
- return successors;
- }
以上所实现的是为路径消除累赘节点。我们使用父节点的方向作为主要引导。以下例子为剔除忽略的水平与竖直方向上的节点:
<ignore_js_op>
水平剔除情况的一个例子
代码利用一系列if语句对相关情况进行检查。以下为例,描述图中右侧的情况:
- if(directionY == 0) {
- if (!_world.isBlocked(current.x + directionX, current.y)) {
- if (_world.isBlocked(current.x, current.y + 1)) {
- // create a node with the new position
- neighbours.push(
- Node.pooledNode(current.x + directionX, current.y + 1));
- }
- }
- }
<ignore_js_op>
对角线方向剔除的例子
选完相邻节点之后我们需要继续寻找跳点,即当前节点可到达但不必要的一个节点。也就是说,JPS主要是用于消除路径的对称性—相同移动方案但是排序不同:
<ignore_js_op>
路径对称性的例子。
对于大型开放空间就有巨大优势。以下为jump方法:
- function jump(cX:int, cY:int, dX:int, dY:int, start:Node, end:Node):Node {
- // cX, cY - Current Node Position, dX, dY - Direction
- // Position of new node we are going to consider:
- var nextX:int = cX + dX;
- var nextY:int = cY + dY;
- // If it's blocked we can't jump here
- if (_world.isBlocked(nextX, nextY)) return null;
- // If the node is the goal return it
- if (nextX == end.x && nextY == end.y) return new Node(nextX, nextY);
- // Diagonal Case
- if (dX != 0 && dY != 0) {
- if (/*... Diagonal Forced Neighbor Check ...*/) {
- return Node.pooledNode(nextX, nextY);
- }
- // Check in horizontal and vertical directions for forced neighbors
- // This is a special case for diagonal direction
- if (jump(nextX, nextY, dX, 0, start, end) != null ||
- jump(nextX, nextY, 0, dY, start, end) != null)
- {
- return Node.pooledNode(nextX, nextY);
- }
- } else {
- // Horizontal case
- if (dX != 0) {
- if (/*... Horizontal Forced Neighbor Check ...*/) {
- return Node.pooledNode(nextX, nextY);
- }
- /// Vertical case
- } else {
- if (/*... Vertical Forced Neighbor Check ...*/) {
- return Node.pooledNode(nextX, nextY);
- }
- }
- }
- /// If forced neighbor was not found try next jump point
- return jump(nextX, nextY, dX, dY, start, end);
- }
由于if语句里对于相邻节点的检查篇幅较大,所以这里省略了。这部分主要有一系列检查组成,与一开始对相邻节点的选择的做法一致(通过诸多检查来判断单位是否于阻塞)。
<ignore_js_op>
junp函数行为的例子。
对角线的情况比较特殊,我们不只要检查对角线方向上的邻点,还需要检查水平与竖直方向上的邻点。若检查不通过,那么就得将这个点归为跳点。另外,还需要考虑目标节点的特例,这种情况下jump方法就可以终止了。
若未找到目标节点,就在指定方向递归调用jump函数。
以上就是JPS所实现的,最终提供给A*检查的新节点,然后多次执行该算法。当目标节点已找到,就可以重构路径并返回了。
属性
JPS在搜索时可以跳过诸多节点,对速度有很大改进(在我的项目中大约比A*快30倍),但它也同样有耗费。
在统一的网格中,其性能最佳;但通过一些调整也可以用在非统一情况,在转换到不同权值(最好使用离散权值)的节点的地方将邻点标记为forced。
我做过一个游戏,除了道路部分,其他地方的网格都一样,而在道路上行走的开销会远低于自然地形。(这样可以更接近现实)。最后我已经解决了这个由precomputing一些值的道路位置。最终我的方案为,预先计算道路位置的值,寻路开始的时候,算法会搜寻道路上距离起点与目标节点最近的那个点,然后再在特殊处理的高级道路图上搜索(预计算过),然后再使用JPS方法对自然地形区域进行搜索。
调试
关于调试需要明白的一点,这类算法的调试可以说非常困难,并且可以说找bug也相当有难度。你可以为自己创建某种形式的可视化功能,将算法运行的结果绘制出来以便直观分析。
若发现bug,那可以将区域(网格尺寸)减至最小,然后重现问题就可以直接找出出错的地方。

浙公网安备 33010602011771号