2D 网格上基于四叉树的分层 A* 寻路

相关项目 repo 地址: https://github.com/hit9/quadtree-pathfinding

主体思路

我们知道,在一个 2D 网格上进行 A* 寻路的话,需要把网格建模成双向有向图,然后对这个图应用 A* 算法。 如果网格比较大,是比较费时的,所以我开始考虑如何优化。

注:我曾写了一个小项目,用来可视化几种常见的寻路算法 https://github.com/hit9/path-finding-visualizer

A* 算法的主要优化点之一是:减少下一步扫描的邻居节点的数量,也就是常说的 open_set (也有的叫 open_list)。我觉得关键还是要把扫描到的点的数量降低下去。

由于前面学习了 四叉树 的知识点,我有一个有趣的思路:

  1. 把整个 2D 网格用四叉树进行划分,划分的条件是,每个小矩形要么不包含障碍物、要么只包含障碍物
  2. 对于不包含障碍物的小矩形,其中任意两点直线最短
  3. 对于小矩形之间,建立连接的出入口关卡 (gate) 进行连接,所有的关卡构成一个有向图。

其中,所谓「小矩形」就是四叉树的叶子节点。

这样,一张 2D 网格地图的建模情况如下图所示,其中:

  1. 整个网格被四叉树不断分割,红色的是障碍物。每个四叉树叶子节点中要么全是障碍物、要么全不是障碍物。
  2. 两个相邻的叶子节点之间,会选取一些紧邻的点,作为关卡,在图中就是紫色的方格。

那么,在寻路的时候,主体思路十分简单:

  1. 先把起始点和终点加入到关卡的有向图中。
  2. 然后对所有关卡的单元格组成的有向图进行 A* 寻路。
  3. 最后填充小矩形内的直线。

这里面有两步:先找关卡路由点、最后再填充细节路径点

下图中,我们加入起点 Start 和 目标 Target 后,A* 算法可以找出来所有路过的关卡点:

最后,由于任何两个关卡点之间的最短路径一定是直线,只需要直接相连就可以了:

连直线的算法,直接采用著名的 Bresenham 算法,测试下来这一步速度非常快。 此外,推荐一个网页,对于 Bresenham 算法 有很简洁的实现:https://members.chello.at/easyfilter/bresenham.html

这样的思路下:

  1. 路径不再是最优,但是接近最优。
  2. 寻路分为了两层:先在 Gate 层寻路,最后再填充节点内的路径。

可以看到,在第一层寻路的时候,参与寻路的点,最多只是所有紫色的关卡,而其余白色的区域,是不参与寻路的。这也就是优化点。 而画直线是非常非常快的,是一个线性时间复杂度的过程。

进一步优化

事实上,还可以进一步优化,代价就是进一步牺牲最优性

我又加入了一层抽象图,作为一种可选的过程,可以先对所有四叉树节点进行寻路。展开来说:

  1. 相邻的四叉树叶子节点之间建立有向边,取各自的中心作为代表。
  2. 寻路时,先在节点构成的图上寻路,得到一条有效的 NodePath .
  3. 然后再挑选 NodePath 上可能涉及的关卡,再进行关卡层的寻路 。
  4. 最后填充关卡之间的直线。

因为空旷节点的数量,相比关卡的数量,要少的多,所以会更快。

另外,加入这一层之后,还有两个好处:

  1. 更快的判断可达性
  2. 第二层参与寻路的关卡大大减少。

下图中,黄色的是第一层节点图中的寻路结果:

在实际实现中,这一步是可选的。 测试下来,带有节点层寻路优化的情况下,对于比较大的地图,速度还可以。

值得说明的是,在实际的实现中,都是支持动态新增和删除障碍物的。 这根本上源自我做的那个 quadtree 带来的支持。动态新增或删除障碍物会增量化地更新四叉树, 而寻路这里已经订阅了叶子节点的动态变化情况,进而增量化的修改节点和关卡这两层抽象的有向图的结构。

带大小的寻路智能体

实际寻路中,通常会有几种不同大小的智能体,而我们之前讨论的都是假定 1x1 大小的 agent 进行寻路。

对我帮助很大的一篇文章是这个(原文已经不见,但是 web archive 上还有):Clearance-based Pathfinding and Hierarchical Annotated A* Search。 这篇文章中介绍了一种 Clearance-based 的寻路方法,也讨论了一些不同地形的支持。

所谓 Clearance-based 的方法,就是要计算一个「最小障碍物距离」的场,通俗地说,就是做一个 2D 数组,这个数组中的每一项是一个数字,表示距离它最近的障碍物的距离。 文章中也说了,常用的是一种叫做 brushfire 的算法,但是它并非真正的 clearance,存在反例。作者介绍了一种新的所谓 “true-clearance” 的思路,我对这个方法做了改进实现。

主要的改进点是,支持地形的动态变化。就好比游戏中,我们建造一个房屋建筑的时候,会影响寻路的地图。

在 github 上我写了一个很小的项目: https://github.com/hit9/true-clearance-field

比如说下图中,参与寻路的智能体大小是 2x2 的,始终以智能体的左上角单元格作为其位置,所谓 “true-clearance” 就是说一个单元格距离右下方的最近的障碍物的距离。 下图中的红色的是障碍物,方格中的数字就是要计算的 “true-clearance” 的值。

下图是一个动态维护的示意图,一个细节是,寻路的智能体的大小通常会有一个上限,并不会太大。所以这个最小障碍物距离的场的维护是有限传播的。 也就是说,修改一个地形后,并不会刷新全局,而是只会刷新附近的有限区域。

细节上,实现这个 true-clearance-field 算法是参考了 LPA* 算法, 这是一种在起点和终点不变的情况下,可以在地形变化时增量计算 A* 寻路的算法。

回到我们的四叉树寻路上来,有了这个「最小障碍物距离场」的支持,那么 任何一个带有大小的智能体寻路问题,其实都可以等效为一个 1x1 大小智能体寻路的问题。 因为,对于大小为 S 的智能体而言,如果一个方格的 true-clearance 的值是小于 S 的,代表这个方格是不可通过的。那么就相当于这里是一个「障碍物」。 所以,问题如此简化:

  1. 四叉树优化的寻路算法,只专注解决 1x1 大小的智能体寻路问题。保持简单。
  2. 通过 true-clearance 场,把多规格大小的智能体寻路问题,简化到 1x1 规格的问题上去。

如此简化的代价就是,我们需要对每一种会用到的智能体规格,都建一张四叉树地图与之对应。 这样的缺点有:

  1. 内存占用会更高。
  2. 维护地形的时候,需要把每一张四叉树地形都维护一遍。

我想了很多其他思路,但是都非常复杂。最终我还是选择了这样的方式,也就是保持基础算法的简单。

不同的地形

对于不同的地形,处理上如法炮制,但是会相对简单一些。

因为智能体可行走的地形类型不同,不能行走的地形全部视作障碍物,简化到 01 二值地形(也就是只有障碍物和非障碍物)的基础寻路模型上去

思路仍然是,对于可能出现的地形,建立与之对应的四叉树地图

  • 下图分别是单元格大小是 10x10,智能体大小是 20x20可行走区域是 白色的陆地 的寻路情况

  • 下图分别是单元格大小是 10x10,智能体大小是 20x20可行走区域是 白色的陆地 和 蓝色的水 的寻路情况

优化其实并没有多少,好在实际中和寻路相干的地形分类的并没有那么复杂,吧~。

代码实现中,每一种地形用一个 2 的幂来表示,就是说不同的比特位标识不同的地形。然后用比特的 OR 或操作来表示选用多种地形。

enum Terrain {
  Land = 0b001,      // 1
  Water = 0b010,     // 2
  Building = 0b100,  // 4
};

qdpf::QuadtreeMapXSettings settings{
    {10, Terrain::Land},                   // e.g. soldiers
    {20, Terrain::Land},                   // e.g. tanks
    {10, Terrain::Land | Terrain::Water},  // e.g. seals
    {20, Terrain::Water},                  // e.g. boats
};

综合两种因素

综合「大小」和「地形」两种因素:

  1. 对于地形因素来说,每种地形组合内,不论智能体的大小,可以共享一张 true-clearance 场。
  2. 对于智能体规格因素来说,每种 { 规格 + 地形 } 的组合,都要对应一张四叉树地图。

每一张距离场关于「障碍物」的定义都与它所支持的地形有关,比如说:

true_clearance_fields[Land] => true_clearance_field1

此时这个 true_clearance_field1 内陆地是可行走的,但是 Water 区域算作障碍物。

对于四叉树地图也是类似的,比如说:

quadtree_maps[3][Land | Water] => quadtree_map1

此时这个 quadtree_map1 内陆地和水的区域都是可行走的,障碍物只有 Building 一种,而且 clearance 值小于 3 的区域也会算作障碍物。

对于同一种对地形组合,一张距离场和一批不同智能体规格的四叉树地图对于「障碍物」的理解是一致的。 当地形变化时,距离场会带动这一批四叉树地图的更新,具体来说是:

  1. 首先更新每一张距离场,更新变化区域的 clearance 值。
  2. 对于距离场传播到的附近区域,触发相应的一批四叉树的维护。
  3. 四叉树维护进一步带动关卡图和节点图的维护。

反过来,如果地形的变化,不会影响到距离场关于障碍物的理解,那么 clearance 值就不会变化。此时也不应该、也不会影响到相关的四叉树地图的更新。

遗留问题

目前有一些遗留问题:

  1. 路径看起来有的时候有点奇怪。
  2. 如何实现动态权重 A* (这一步主要是测试下来并没有太大优化效果)。
  3. 如何用四叉树的好处支持流场寻路?
  4. 实际上,跨节点的空白区域还是有的(但是这个优化起来可能会打破设计的基本思路、引入复杂点)。
  5. 既然 A* 已经支持了,那么 LPA* 增量寻路也可以看一下是否可以支持。

最后,欢迎给 star : https://github.com/hit9/quadtree-pathfinding

本文原始链接地址: https://writings.sh/post/pathfinding-quadtree-astar

王超 ·
微信扫码赞赏
评论 首页 | 归档 | 算法 | 订阅