作者:runzhiwang,腾讯 TEG 后台开发工程师
本文介绍一种跳点搜索算法 JPS 以及其四个优化算法,其寻路速度最快可是 A*算法的 273 倍。文中的 JPS-Bit 和 JPS-BitPrune 都支持动态阻挡。
寻路算法用途众多,例如在游戏和地图中。A*算法已经众所周知,对于其优化也是层出不穷,然而性能并没有取得突破性进展。本文介绍 JPS 的效率、多线程、内存、路径优化算法。为了测试搜索算法的优化性能,实验中设置游戏场景使得起点和终点差距 200 个格子,需要寻路 10000 次。
结果发现,A*寻路总时间约 2.6074x1011 纳秒(一秒为 109 纳秒);基础版 JPS 寻路总时间 1.7037x1010 纳秒;利用位运算优化的 JPS(下文称 JPS-Bit)寻路总时间 3.2364x109 纳秒;利用位运算和剪枝优化的 JPS(下文称 JPS-BitPrune)寻路总时间 2.3703x109 纳秒;利用位运算和预处理的 JPS(下文称 JPS-BitPre)寻路总时间 2.0043x109 纳秒;利用位运算、剪枝和预处理三个优化的 JPS(下文称 JPS-BitPrunePre)寻路总时间 9.5434x108 纳秒。
其中的预处理在一些文章被称为 JPS+。本文的 JPS-Bit 和 JPS-BitPrune 都支持动态阻挡。本文解决了绝大部分开源 JPS 存在的潜在 BUG:穿越阻挡,在图 2.2.1 中,从 H 走到 K 时,穿越 H 右边阻挡。
上述结果表明,寻路 200 个格子的路径,JPS 的五个版本,平均消耗时间分别为 1.7 毫秒、0.32 毫秒、0.23 毫秒、0.02 毫秒、0.095 毫秒,寻路速度分别为 A*算法的 15 倍、81 倍、110 倍、130 倍、273 倍,大幅度超越 A*算法,标志着寻路已经不会成为性能的瓶颈。
事实上,在 2012 到 2014 年举办的三届(目前为止只有三届)基于 Grid 网格寻路的比赛 GPPC(The Grid-Based Path Planning Competition)中,JPS 已经被证明是基于无权重格子,在没有预处理的情况下寻路最快的算法。
本文接下来介绍 JPS 基础版本以及四个优化算法;然后解读 GPPC2014 的比赛,介绍 GPPC 的比赛地图数据,并从寻路时间、路径长度、消耗内存、失败率等方面比较 22 个参赛寻路算法的优劣。
JPS 又名跳点搜索算法(Jump Point Search),是由澳大利亚两位教授于 2011 年提出的基于 Grid 格子的寻路算法。JPS 算法在保留 A*算法的框架的同时,进一步优化了 A*算法寻找后继节点的操作。为了说明 JPS 在 A*基础上的具体优化策略,我们在图 2.1.1 中给出 A*和 JPS 的算法流程图对比。由图 2.1.1 看出,JPS 与 A*算法主要区别在后继节点拓展策略上,不同于 A*算法中直接获取当前节点所有非关闭的可达邻居节点来进行拓展的策略,JPS 根据当前结点 current 的方向、并基于搜索跳点的策略来扩展后继节点,遵循“两个定义、三个规则”。
定义一,强迫邻居(forced neighbour):如果节点 n 是 x 的邻居,并且节点 n 的邻居有阻挡(不可行走的格子),并且从 parent(x)、x、n 的路径长度比其他任何从 parent(x)到 n 且不经过 x 的路径短,其中 parent(x)为路径中 x 的前一个点,则 n 为 x 的强迫邻居,x 为 n 的跳点,例如图 2.2.1 中,寻找从 S 到 E 的路径时,K 为 I 的强迫邻居(I 为 K 的跳点)。这里不认为从 H 到 K 能走,因为对角线有阻挡(这点和论文不一致,但和代码一致,因为如果 H 到 K 能直接到达,会走进 H 右边的阻挡区,大部分的 JPS 开源代码根据论文都认为 H 到 K 能直接到达,所以存在穿越阻挡的情况),如果需要 H 到 K 可走,则 K 为 H 的强迫邻居(H 为 K 的跳点)。
定义二,跳点(jump point):(1)如果点 y 是起点或目标点,则 y 是跳点,例如图 2.2.1 中,S 是起点也是跳点,E 是目标点也是跳点;
(2)如果 y 有强迫邻居则 y 是跳点, 例如 I 是跳点,请注意此类跳点和强迫邻居是伴生关系,从定义一强迫邻居的定义来看 n 是强迫邻居,x 是跳点,二者的关系是伴生的,例如图 2.2.1 中 K 的邻居只有 I 是跳点,M 虽然也是 K 的邻居,但 M 不是跳点,因为 K 不是 M 的强迫邻居;
(3)如果 parent(y)到 y 是对角线移动,并且 y 经过水平或垂直方向移动可以到达跳点,则 y 是跳点,例如图 2.2.1 中 G 是跳点,因为 parent(G)为 S,S 到 G 为对角线移动,从 G 到跳点 I 为垂直方向移动,I 是跳点,所以 G 也是跳点。
规则一:JPS 搜索跳点的过程中,如果直线方向(为了和对角线区分,直线方向代表水平方向和垂直方向,且不包括对角线等斜线方向,下文所说的直线均为水平方向和垂直方向)、对角线方向都可以移动,则首先在直线方向搜索跳点,再在对角线方向搜索跳点。规则二:(1)如果从 parent(x)到 x 是直线移动,n 是 x 的邻居,若有从 parent(x)到 n 的路径不经过 x 且路径长度小于或等于从 parent(x)经过 x 到 n 的路径,则走到 x 后下一个点不会走到 n;
(2)如果从 parent(x)到 x 是对角线移动,n 是 x 的邻居,若有从 parent(x)到 n 的路径不经过 x 且路径长度小于从 parent(x)经过 x 到 n 的路径,则走到 x 后下一个点不会走到 n(相关证明见论文)。
规则三:只有跳点才会加入 openset,因为跳点会改变行走方向,而非跳点不会改变行走方向,最后寻找出来的路径点也都是跳点。寻路具体流程如下:
一,若 current 当前方向是直线方向:(1)如果 current 左后方不可走且左方可走(即左方是强迫邻居),则沿 current 左前方和左方寻找不在 closedset 的跳点;(2)如果 current 当前方向可走,则沿 current 当前方向寻找不在 closedset 的跳点;(3)如果 current 右后方不可走且右方可走(右方是强迫邻居),则沿 current 右前方和右方寻找不在 closedset 的跳点;
二,若 current 当前方向为对角线方向:(1)如果 current 当前方向的水平分量可走(例如 current 当前为东北方向,则水平分量为东,垂直分量为北),则沿 current 当前方向的水平分量寻找不在 closedset 的跳点;(2)如果 current 当前方向可走,则沿 current 当前方向寻找不在 closedset 的跳点;(3)如果 current 当前方向的垂直分量可走(例如 current 当前为东北方向,则水平分量为东,垂直分量为北),则沿 current 当前方向的垂直分量寻找不在 closedset 的跳点。JPS 寻找跳点的过程有三种优化:一,位运算;二;预处理;三;剪枝中间跳点。
图2.1.1 A*和JPS的算法流程图对比
表2.2.1 A*和JPS的寻路消耗对比
下面举例说明 JPS 具体的寻路流程。示例如图 2.2.1 所示,5*5 的网格,黑色代表阻挡区,S 为起点,E 为终点。
JPS 要寻找从 S 到 E 的最短路径,首先初始化将起点 S 加入 openset。从 openset 取出 F 值最小的点 S,并从 openset 删除,加入 closedset,S 的当前方向为空,则沿八个方向寻找跳点,在该图中从 S 出发只有下、右、右下三个方向可走,但向下搜索到 D 遇到边界,向右搜索到 F 遇到阻挡,因此都没有找到跳点,然后沿右下方向寻找跳点,在 G 点,根据上文定义二的第(3)条,parent(G)为 S,praent(G)到 S 为对角线移动,并且 G 经过垂直方向移动(向下移动)可以到达跳点 I,因此 G 为跳点 。
将 G 加入 openset。从 openset 取出 F 值最小的点 G,并从 openset 删除,加入 closedset,因为 G 当前方向为对角线方向(从 S 到 G 的方向),因此在右(当前方向水平分量)、下(当前方向垂直分量)、右下(当前方向)三个方向寻找跳点,在该图中从 G 出发只有向下可走,因此向下寻找跳点,根据上文定义二的第(2)条找到跳点 I,将 I 加入 openset。从 openset 取出 F 值最小的点 I,并从 openset 删除,加入 closedset,因为 I 的当前方向为直线方向(从 G 到 I 的方向),在 I 点时 I 的左后方不可走且左方、前方可走,因此沿左、左前、前寻找跳点,但左前、前都遇到边界,只有向左寻找到跳点 Q(根据上文定义二的第(2)条)),因此将 Q 加入 openset。
从 openset 取出 F 值最小的点 Q,并从 openset 删除,加入 closedset,因为 Q 的当前方向为直线方向,Q 的左后方不可走且左方、前方可走,因此沿左、左前、前寻找跳点,但左前、前都遇到边界,只有向左寻找到跳点 E(根据上文定义二的第(1)条)),因此将 E 加入 openset。从 openset 取出 F 值最小的点 E,因为 E 是目标点,因此寻路结束,路径是 S、G、I、Q、E。
注意,本文不考虑从 H 能走到 K 的情况,因为对角线有阻挡,如果需要 H 到 K 能直接到达,则路径是 S、G、H、K、M、P、E,修改跳点的计算方法即可,但在游戏中如果 H 到 K 能直接到达,则会穿越 H 右边的阻挡。
图2.2.1
上述的 JPS 寻路效率是明显快于 A*的,原因在于:在从 S 到 A 沿垂直方向寻路时,在 A 点,如果是 A*算法,会将 F、G、B、H 都加入 openset,但是在 JPS 中这四个点都不会加入 openset。对 F、G、H 三点而言,因为从 S、A、F 的路径长度比 S、F 长,所以从 S 到 F 的最短路径不是 S、A、F 路径,同理 S、A、G 也不是最短路径,根据上文规则二的第(1)条,走到 A 后不会走到 F、G,所以 F、G 不会加入 openset,虽然 S、A、H 是 S 到 H 的最短路径,但因为存在 S、G、H 的最短路径且不经过 A,据上文规则二的第(1)条,从 S 走到 A 后,下一个走的点不会是 H,因此 H 也不会加入 openset;对 B 点而言,根据上文规则三,B 不是跳点,也不会加入 openset,直接走到 C 即可。
表 2.2.1 所示为 A*和 JPS 在寻路消耗中的对比,D. Age: Origins、D. Age 2、StarCraft 为三个游戏龙腾世纪:起源、、龙腾世纪 2、星际争霸的场景图集合,M.Time 表示操作 openset 和 closedset 的时间,G.Time 表示搜索后继节点的时间。可见 A*大约有 58%的时间在操作 openset 和 closedset,42%时间在搜索后继节点;而 JPS 大约 14%时间在操作 openset 和 closedset,86%时间在搜索后继节点。避免在 openset 中加入太多点,从而避免过多的维护最小堆是 JPS 比 A*快的原因(最小堆插入新元素时间复杂度 log(n),删除最小元素后调整堆,时间复杂度也为 log(n)),实际上在从 S 到 E 的寻路过程中,进入 openset 的只有 S、G、I、Q、E。
利用位运算优化的 JPS-Bit 的关键优化思路在于利用位运算来优化 JPS 中节点拓展的效率。JPS-Bit 和下文介绍的 JPS-BitPrune 支持动态阻挡,当动态阻挡出现时,将格子标记为阻挡,当动态阻挡消失时,将格子标记为非阻挡,如果动态阻挡占据 k 个格子,则时间复杂度为 O(k)。下面以图 3.1.1.1 中的场景示例说明如何将位运算融合于 JPS 算法中,其中黑色部分为阻挡,假设当前位置为 I(标蓝位置),当前方向为右,位运算中使用 1 代表不可走,0 代表可走,则 I 当前行 B 的八位可以用八个 bit:00000100 表示,I 上一行 B-的八位可以用八个 bit:00000000 表示,I 的下一行 B+的八位可以用八个 bit:00110000 表示。在当前行寻找阻挡的位置可以用 CPU 的指令__builtin_clz(B)(返回前导 0 的个数),即当前阻挡在第 5 个位置(从 0 开始)。
寻找当前行的跳点可以用__builtin_clz(((B->>1) && !B-) ||((B+>>1) && !B+)) 寻找,例如本例中(B+>>1) && !B+为:(00110000 >> 1) && 11001111,即 00001000,而(B->>1) &&!B 为 00000000,所以__builtin_clz(((B->>1) && !B-) ||((B+>>1) && !B+))为__builtin_clz(00001000)为 4,所以跳点为第 4 个位置 M(从 0 开始)。注意论文中使用_builtin_ffs(((B-<<1) && !B-) ||((B+<<1) && !B+)),__builtin_ffs(x)返回 x 的最后一位 1 是从后向前第几位,比如 7368(1110011001000)返回 4,因为论文对格子的 bit 编码采用小端模式,而这里对格子的 bit 编码采用大端模式。
由于 JPS-Bit 使用运算效率更高的位运算和 CPU 指令运算来优化原始 JPS 节点扩展过程中的遍历操作,JPS-Bit 的算法效率高于原始的 JPS,实测中 JPS-Bit 的寻路时间比 JPS 缩短 5 倍左右。
图3.1.1
利用位运算和剪枝优化的 JPS-BitPrune 在 JPS-Bit 的基础上进一步进行剪枝优化,剪掉不必要的中间跳点(定义二第(3)条定义),根据定义二,中间跳点在节点拓展过程中只具有简单的“承接”作用,不具备拓展价值,将中间跳点放入 openset 会增大扩展的次数,因此 JPS-BitPrune 将中间跳点全部删除,将中间跳点后继跳点中的非中间跳点的父跳点改为中间跳点的父跳点,可以有效避免冗余的节点拓展运算。
拐点获取:值得一提的是,JPS-BitPrune 由于删除了中间跳点,因此 JPS-BitPrune 需要在搜索到完整的路径之后以一定的策略在最后寻得的路径中加入中间拐点,使得每两个相邻的路径节点之间都是垂直、水平、对角线方向可达的。对此,JPS-BitPrune 采用的具体方法如下:
假设目前搜索到的路径为 start(jp1)、jp2、jp3...jpk..end(jpn),对每两个相邻的跳点 jpi、jpi+1,一,如果 jpi、jpi+1 的 x 坐标或者 y 坐标相等,说明这两个跳点在同一个水平方向或垂直方向,可以直线到达,无需在这两个跳点之间加入拐点;二,如果 jpi、jpi+1 的 x 坐标和 y 坐标都不相等,(1)如果 x 坐标的差 dx(即 jpi 的 x 坐标减去 jpi+1 的 x 坐标)和 y 坐标的差 dy 的绝对值相等,说明这两个跳点在对角线方向,也可以直线到达,无需在这两个跳点之间加入拐点;(2)如果 x 坐标的差 dx 和 y 坐标的差 dy 的绝对值不相等,说明这两个跳点不在对角线方向,并且有可能不能直线到达(因为跳点附近有阻挡),此时 jpi、jpi+1 之间只需要加入一个从 jpi 出发离 jpi+1 最近的对角线上的点即可(jpi、jpi+1 不能水平、垂直、对角线到达,说明 jpi、jpi+1 之间一定存在被剪枝的中间跳点,只需要补上离 jpi+1 最近的一个中间跳点充当拐点即可,该拐点即为 jpi 沿对角线方向走 min(dx,dy)步到达的点)。
图3.1.2.1 JPS-BitPrune的剪枝优化示例
下面以图 3.1.2.1 的问题场景示例 JPS-BitPrune 如何在剪枝的同时进行寻路。起点为 S(坐标为(1,1),即 S(1,1)),节点 1、4、6 均为中间跳点:因为节点 2、3 是满足定义二第(2)条的跳点,所以节点 1 是为了到达节点 2、3 的中间跳点,同理节点 4、6 也为中间跳点。在剪枝中间跳点之前,要将中间跳点的后继节点的父节点调整为该中间跳点的父节点。例如图 3.1.2.1 中,节点 1 的后继跳点为节点 2、3、4,其中节点 4 也为中间跳点,删掉中间跳点中的节点 1 后,节点 2、3 的父跳点由节点 1 改为节点 S,删除中间跳点中的节点 4 后,节点 4 的后继跳点 5 的父跳点由节点 4 改为节点 S(节点 4 的父跳点为节点 1,但节点 1 已经被删掉,因此回溯到节点 S),删除中间跳点中的节点 6 后,节点 6 的后继跳点 7 的父跳点由节点 6 改为节点 S(节点 6 的父跳点为节点 4,但节点 4 被删,节点 4 的父跳点节点 1 也被删,因此回溯到节点 S)。
上述过程是简化的逻辑描述,实际运行中的做法是从节点 S 寻找跳点,首先找到中间跳点节点 1,然后在水平方向和垂直方向寻找到跳点节点 2、3,将节点 2、3 的父跳点设为节点 S;继续沿对角线方向寻找跳点,走到节点 4 后,沿水平方向和垂直方向寻找到跳点节点 5,将节点 5 的父跳点设为节点 S;继续沿对角线方向寻找跳点,走到节点 6 后,沿水平方向和垂直方向寻找到跳点 7,将跳点 7 的父跳点设为节点 S。因此 JPS-BitPrune 获得路径 S(1,1) 、节点 7(4,6)。因为路径中 S(1,1)无法垂直、水平、对角线方向走到节点 7(4,6),需要加入中间拐点,根据上述的拐点添加策略,有 dx 为 3,dy 为 5,需要从 S 沿对角线走 3 步,即节点 6(4,4)可作为中间拐点,因此,在图 3.1.2.1 的示例中,JPS-BitPrune 最后构建的完整路径为 S(1,1) 、节点 6(4,4) 、节点 7(4,6)。
下面通过对比剪枝前后的 JPS 节点拓展的情况来说明剪枝操作的优化效率:
场景一(无剪枝)如果不对中间跳点进行剪枝,那么从节点 S 寻路到节点 7 将经历如下过程:(1)从节点 S 搜索跳点,找到跳点节点 1,openset 此时只有节点 1;
(2)从 openset 取出 F 值最小跳点节点 1,并搜索节点 1 的后继跳点,水平方向和垂直方向找到跳点节点 2、3,对角线方向找到跳点节点 4,此时 openset 有节点 2、3、4;
(3)从 openset 取出 F 值最小跳点节点 4,并搜索节点 4 的后继跳点,水平和垂直方向找到跳点节点 5,对角线方向找到跳点 6,此时 openset 有节点 2、3、5、6;
(4)从 openset 取出 F 值最小跳点节点 6,垂直方向找到跳点 7,此时 openset 有节点 2、3、5、7;
(5)从 openset 取出 F 值最小的跳点节点 7,为目的点,搜索结束,因此完整路径为节点 S(1,1)、节点 1(2,2) 、节点 4(3,3) 、节点 6(4,4) 、节点 7(4,6)。JPS 在到达目的节点 7 之前,需要接连拓展中间跳点 1,4,6。
场景二(剪枝中间跳点)在剪枝中间跳点之后,从节点 S 寻路到节点 7 的流程得到了明显简化:
(1)从节点 S 寻找跳点,首先找到中间跳点节点 1,然后在水平方向和垂直方向寻找到跳点节点 2、3,将节点 2、3 的父跳点设为节点 S;继续沿对角线方向寻找跳点,走到节点 4 后,沿水平方向和垂直方向寻找到跳点节点 5,将节点 5 的父跳点设为节点 S;继续沿对角线方向寻找跳点,走到节点 6 后,沿水平方向和垂直方向寻找到跳点 7,将跳点 7 的父跳点设为节点 S;继续沿对角线方向寻找跳点,遇到阻挡,搜索终止,此时 openset 有节点 2、3、5、7;
(2)从 openset 取出 F 值最小的跳点节点 7,为目的点,搜索结束,此时获得的路径为 S(1,1)、节点 7(4,6)。不同于无剪枝的 JPS 需要拓展中间跳点 1、4、6,在 JPS-BitPrune 中,节点 1、4、6 作为中间跳点均被剪枝,有效避免了冗余的节点拓展,寻路效率得到大大提升。
本优化中的预处理在一些文章被称为 JPS+。JPS-BitPre 和 JPS-BitPrunePre 都不支持动态阻挡,因为动态阻挡的出现会导致八个方向最多能走的步数发生变化,从而导致预处理的结果不再准确。利用位运算和预处理的 JPS-BitPre 依旧采用 JPS-Bit 中的位运算,而其中的预处理则是对每个点存储八个方向最多能走的步数 step,这个 step 值将影响 JPS 中节点的拓展顺序和拓展“跨度”,加快寻路效率。
step 值由跳点、阻挡、边界等决定,如果遇到跳点,则 step 为走到跳点的步数;否则 step 为走到阻挡或边界的步数。例如对图 3.1.3.1 中的 N 点,向北最多走到节点 8,即 2 步;向南最多走到节点 4,即 4 步;向西最多走到节点 6,即 3 步;向东最多走到节点 2(节点 2 是满足定义二第(2)条的跳点),即 5 步;西北最多走到节点 7,即 2 步;东北最多走到节点 1(节点为 1 是上文定义二第(3)条定义的跳点),即 1 步;西南最多走到节点 5,即 3 步;东南最多走到节点 3(节点 3 是上文定义二第(3)条定义的跳点),即 3 步。
图3.1.3.1 JPS-BitPre寻路的场景示例
以图 3.1.3.1 中的场景为例,要寻找从节点 N 到节点 T 的路径,JPS-BitPre 的寻路流程如下:
(1)从 openset 取出节点 N, 从 N 沿八个方向寻找跳点,根据预处理得到的各方向最远可走的 step 值,可以快速确定八个方向最远能到达的节点{1,2,3,4,5,6,7,8},如图 3.1.3.1 所示,其中,节点 1、2、3 均为满足定义二的跳点,直接加入 openset,对于节点 4、5、6、7、8,首先判断终点 T 位于以 N 为中心的南、西南、西、西北、北的哪部分,因为 T 位于西南方向,只有节点 5 位于西南方向,因此节点 4、6、7、8 直接略过,在从 N 到 5 的方向上,N 可走 3 步,而 N 和 T 的 x 坐标差绝对值 dx 为 1,y 坐标差绝对值 dy 为 2,因此将从节点 N 到节点 5 方向上走 min(dx,dy)步即节点 11,加入 openset;
(2)从 openset 取出 F 值最小节点 11,垂直方向找到跳点 T,加入 openset;
(3)从 openset 取出 F 值最小节点 T,为目的点,搜索结束,此时获得的路径为 N(4,5)、节点 11(3,4) 、节点 T(3,3)。
为了说明 JPS-BitPre 寻路的准确性与高效率,这里给出原始 JPS-Bit 从 N 到 T 的寻路流程作为对比:
(1)从 openset 取出节点 N 后,需要沿八个方向寻找跳点,节点 1、3、11 为上文定义二第(3)条定义的跳点,加入 openset,节点 2 为上文定义二的第(2)条定义的跳点,加入 openset;
(2)从 openset 取出 F 值最小节点 11,垂直方向找到跳点 T,加入 openset;
(3)从 openset 取出 F 值最小跳点 T,为目的点,搜索结束,此时获得的路径也为 N(4,5)、节点 11(3,4) 、节点 T(3,3)。对比发现,经过预处理的 JPS-BitPre 和只使用位运算的 JPS-Bit 最后寻得的路径是一样的,然而,由于 JPS-BitPre 无需在每一步节点拓展过程中沿各方向寻找跳点,其可以根据预处理得到的 step 值快速确定 openset 的备选节点,从而大大提升寻路效率。
openset 采用最小堆实现,最小堆的底层数据结构是一个数组,从最小堆中插入、删除时间复杂度为 O(logn)。除了删除还需要查找操作,每次找到一个跳点,都需要判断在最小堆中是否有,如果有,则判断是否更新 G 值、F 值、父跳点等,如果没有,则加入 openset。在最小堆的中查找操作时间复杂度 O(n),因此需要优化。closedset 存的是已经从 openset 中弹出的跳点,实际只需要对每个跳点加个标记即可,如果跳点打上标记,则表示是 closedset 中跳点,否则不是。
综合上述需求,针对 1km*1km 的地图,构建 2k*2k 的二维数组 matrix,数组每个元素 pnode 均为一个指针,指针的对象类型包括节点 id、是否扩展过 expanded(即是否在 closedset 中)、G 值、F 值、父跳点指针 parent、在最小堆中的索引 index 等 12 个 byte。
如果地图(x,y)处是搜索到的跳点,首先检查在二维数组 matrix 对应的(x,y)处指针 pnode 是否为空,如果为空,表示该跳点之前未搜索过,从内存池 new 出一个跳点,将指针加到最小堆 openset 中,并在执行 shift up、shift down 操作之后,记录在最小堆中的索引 index;如果不为空,则表示该跳点之前搜索过,首先检查 expand 标记,如果为真,则表示在 closedset 中,直接跳过该跳点;否则表示在 openset 中,通过 matrix(x,y)记录的在 openset 中的索引 index 找到对应的指针,检查 matrix(x,y)和 openset(index)的指针是否相等进行二次确认,然后检查判断是否需要更新 G 值、F 值、父跳点等,采用空间换时间的方法可以将 openset 和 closedset 中查找操作降为 O(1)。游戏中有很多场景,无需为每个场景构建一个 matrix,以最大的场景的大小构建一个 matrix 即可。
游戏服务器普遍采用单进程多线程架构,多线程下,不能对 JPS 寻路加锁,否则寻路串行化,失去了多线程的优势,为了支持多线程 JPS 寻路,需要将一些变量声明为线程独有 thread_local,例如上文提到的为了优化 openset 和 closedset 的查找速度,构建的二维跳点指针数组 matrix。该数组必须为线程独有,否则,不同线程在寻路时,都修改 matrix 元素指向的跳点数据,例如 A 线程在扩展完跳点后,将 expanded 标记为真,B 线程再试图扩展该跳点时,发现已经扩展过,就直接跳过,导致寻路错误。
JPS 的地图格子粒度如果采用 0.5m*0.5m,每个格子占 1bit,则 1km*1km 的地图占用内存 2k*2k/8 个 byte,即 0.5M;为了向上、向下也能通过取 32 位数获得向上、向下的 32 个格子阻挡信息,需要存将地图旋转 90 度后的阻挡信息;上文 JPS 优化之四:不可达两点提前判断,需要存连通信息,假设连通区数目最多 15 个,则需内存 2k*2k/2 个 byte,即 2m,则内存为:原地图阻挡信息 0.5m、旋转地图阻挡信息 0.5m、连通区信息 2m,即 3m。另外,上文提到用空间换时间的方法,为了优化 openset 和 closedset 的查找速度,构建二维跳点指针数组 matrix。1km*1km 的地图,格子粒度为 0.5m*0.5m,构建出的 matrix 指针数组大小为 2k2k4byte 即为 8m,为了支持多线程,该 matrix 数组必须为 thread_local,即线程独有,16 个线程共需内存 16*8m 即为 128m,内存空间太大,因此需要优化这部分内存。
首先将 2k*2k 分成 100*100 的块,即 20*20 个块,20*20 个块为第一层数组 firLayerMatrix,100*100 为第二层数组 secLayerMatrix,firLayerMatrix 的 400 个元素为 400 个指针,每个指针初始化为空,当遍历到的跳点属于 firLayerMatrix 中(x,y)的块时,则从内存池 new 出 100*100*4byte 的 secLayerMatrix,secLayerMatrix 每个元素也是一个指针,指向一个从内存池 new 出来的跳点。例如,搜索 2k*2k 的地图时,在(231,671)位置找到一个跳点,首先检查 firLayerMatrix 的(2,6)位置指针是否为空,如果为空,则 new 出 100*100*4byte 的 secLayerMatrix,继续在 secLayerMatrix 查找(31,71)位置检查跳点的指针是否为空,如果为空,则从内存池 new 出来跳点,加入 openset,否则检查跳点的 expanded 标记,如果标记为真,表示在 closedset 中,直接跳过该点,否则表示在 openset 中,判断是否更新 G 值、F 值、父节点等。
因为游戏中 NPC 寻路均为短距离寻路,JPS 寻路区域最大为 80*80,一个 secLayerMatrix 是 100*100,因此只需要一个 secLayerMatrix,则两层 matrix 大小为:20*20*4byte+100*100*4byte 即为 0.04m。所以 16 个线程下,总内存为:原地图阻挡信息 0.5m、旋转地图阻挡信息 0.5m、连通区信息 2m、两层 matrix0.04m*16,共 3.64M,游戏中场景最多不到 20 个,所有场景 JPS 总内存为 72.8M。
在搜索过程中,每次将一个跳点加入 openset,都需要 new 出对应的节点对象,节点对象中存节点 id、父节点、寻路消耗等共 12 个 byte,为了减少内存碎片,以及频繁 new 的时间消耗,需要自行管理内存池,每次 new 节点对象时,均从内存池中申请,为了防止内存池增长过大,需要限制搜索步数。
内存池是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。
本文的内存池共有两个:一,跳点的内存池,初始大小为 800 个跳点,当 new 的跳点数目超出 800 个,即停止寻路,因为服务器用 JPS 进行 NPC 的寻路,NPC 不会进行长距离寻路,假设 NPC 寻路上限距离是 20m,则寻路区域面积是 40m40m,格子数 8080 即 6400,经统计跳点数目占所有格子数目的比例不到 1/10, 即跳点数目少于 640,因此 800 个跳点足够使用,800 个跳点共占内存 800byte*12,即为 9.6k,忽略不计;二,secLayerMatrix 指向的 100*100*4byte 的内存池,因为每次寻路都需要至少一个 secLayerMatrix,如果每次寻路都重新申请,寻路完后再释放,会造成开销,因此 secLayerMatrix 指向的 1001004byte 的空间也在内存池中,申请时,从内存池拿出,释放时,放回内存池即可,secLayerMatrix 内存池占内存 0.04m。
如图 3.4.1 所示,绿色格子为起点,红色格子为终点,灰色格子为跳点,蓝线为 JPS 搜出来的路径,灰色虚线为搜索过程。可以看出,从绿色格子到红色格子可以直线到达,而 JPS 搜索出来的路径却需要转折一次,在游戏表现上,会显得比较奇怪。因此在 JPS 搜索出来路径后,需要对路径进行后处理。
比如 JPS 搜出来的路径有 A、B、C、D、E、F、G、H 八个点,走到 A 时,需要采样检查 A、C 是否直线可达,如果 A、C 直线可达,再检查 A、D 是否直线可达,如果 A、D 直线可达,继续检查 A、E,如果 A、E 直线不可达,则路径优化为 A、D、E、F、G、H,走到 D 时,再检查 D、F 是否直线可达,如果 D、F 直线可达,继续检查 D、G,如果 D、G 直线不可达,则路径优化为 A、D、F、G、H。依此类推,直到走到 H。因为采样检查的速度很快,大约占 JPS 寻路时间的 1/5,而且只有当走到一个路点后,才采样检查该路点之后的路点是否可以合并,将采样的消耗平摊在行走的过程中,因此采样的消耗可以忽略。
图3.4.1
基于格子的寻路一直是被广泛研究的热点问题,也有很多已经发表的算法,但是这些算法没有相互比较过,因此也难辨优劣,使用者如何选择算法也有很大的困难。为了解决这个问题,美国丹佛大学的 Nathan R. Sturtevant 教授创办了基于 Grid 格子的寻路比赛:The Grid-Based Path Planning Competition,简称 GPPC,目前已经在 2012、2013、2014 举办过三次,下文主要讨论 GPPC2014。
GPPC 比赛用的地图集如表 4.1.1 所示,地图数据主要分为游戏场景图和人造地图。其中来自游戏场景图的数据有三类:Starcraft(星际争霸)、Dragon Age2(龙腾世纪 2)、Dragon Age:origins(龙腾世纪:起源),三个游戏分别提供地图 11、57、27 张,提供寻路问题 29970、54360、44414 个。来自人造地图有三类:迷宫、随机、房间,三类数据分别提供地图 18、18、18 张,提供寻路问题 145976、32228、27130 个。六类数据共提供地图 132 张,寻路问题 347868 个。图 4.1.1 中给出三个游戏的场景图示例,图 4.1.2 中给出三类人造地图示例,其中黑色代表阻挡区,白色代表可行走区。地图大小从 100*100 到 1550*1550,六个地图集的大小分布如图 4.1.3 所示,横坐标是格子数,纵坐标是地图数目,最小的地图来自 Dragon Age:origins(龙腾世纪:起源),最大的地图来自 Starcraft(星际争霸)和人造数据。
表4.1.1 GPPC比赛用的地图集
图4.1.1 GPPC的三类游戏场景地图示例
图4.1.2 GPPC的三类人造场景地图示例
图4.1.3 GPPC的六类地图大小分布
GPPC 在相同的配置下运行参赛算法,其中 CPU 的配置是 Xeon E5620,四核处理器、2.4Ghz 主频,12G 内存。为了消除误差,GPPC 要求对每个参赛的寻路方法在 34868 个寻路问题上运行 5 遍,共寻路 34868*5,即 174340 次,所以下文介绍的总运行时间等指标都是寻路 174340 次的结果汇总。其中运行时间包括:加载预处理数据和寻路时间,而预处理时间并不计算在运行时间内。
GPPC 定义如下 13 个指标来评价寻路算法(其中,路径表示从起点到终点的完整路径):
目前为止参加 GPPC 竞赛的算法共有 22 个,其中参加 GPPC2014 的有 14 个,可大致分为如下 4 类:一,对 A*的改进,例如 Relaxed A*(RA*)和 A* Bucket;二,利用格子特点的算法,例如 Jump Point Search(JPS)和 SubGoal Graphs;三,预先生成任意两点第一个路点的压缩数据库,例如 SRC;四,基于节点优先级的算法,例如 Contraction Hierarchy(CH)。
表 4.3.1 给出参加 GPPC2012、2013、2014 的共 22 个算法的结果对比,其中前 14 个为参与 GPPC2014 的算法。其中第一列(Entry 列)为算法名,其后 13 列给出每个算法在 13 个指标下的表现。第一列被黑体加粗的算法表示该算法在某些指标(帕累托最优的指标)达到帕累托最优,该算法所在的行被加粗的指标,表示帕累托最优的指标。帕累托最优表示:没有其他算法在帕累托最优的指标上均优于当前算法。例如 JPS(2012)帕累托最优的指标:第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage,达到帕累托最优,表示没有其他算法在 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 上均优于 JPS(2012)。22 种算法没有严格的优劣关系,只是在不同指标下的表现各有侧重,算法的使用者可基于对不同指标的具体需求来选择自己适用的算法。
表4.3.1 GPPC2014的比赛结果
下面给出所有在 GPPC 中获得帕累托最优的算法,本文介绍的 JPS 算法位列其中。
1.RA*(2014):第 10 个指标 RAM(before)和第 12 个指标 Storage 帕累托最优。
2.BLJPS(2014):第 2 个指标 Avg、第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 帕累托最优。
3.BLJPS2(2014):第 2 个指标 Avg、第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 帕累托最优。
4.RA-Subgoal(2014):第 2 个指标 Avg 和第 12 个指标 Storage 帕累托最优。
5.NSubgoal(2014):第 2 个指标 Avg、第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 帕累托最优。
6.CH(2014):第 2 个指标 Avg、第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 帕累托最优。
7.SRC-dfs-i(2014):第 3 个指标 20 Step 和第 4 个指标 Max Segment 帕累托最优。8.SRC-dfs(2014):第 2 个指标 Avg 和第 6 个指标 Avg Sub-Opt 帕累托最优。
9.JPS(2012):第 6 个指标 Avg Sub-Opt 和第 12 个指标 Storage 帕累托最优。本文的主角 JPS 在未使用预处理的算法中 Avg Sub-Opt 表现最优。
10.PDH(2012):第 3 个指标 20 Step 和第 12 个指标 Storage 帕累托最优。11.Tree(2013):第 2 个指标 Avg 帕累托最优。