1. 三维路径规划的开局:为什么我从二维改写到了三维
做飞行器路径规划的朋友应该都有体会,入门时接触的A*教程十个里有九个是二维地图上的方格寻路,剩下那一个是二维栅格地图加了个高度伪影。真正到了无人机、导弹或者低空飞行器的任务场景里,高度维度的加入不只是多一个坐标轴那么简单——搜索空间从平面变成了体素空间,节点扩展从四方向或者八方向变成了几十个方向,代价函数里还要耦合高度代价、威胁代价、地形约束和飞行器自身的机动能力限制。
我最初接这个需求的时候,项目背景是一款低速无人飞行器在规定空域内执行侦察任务,需要预先规划出一条从起点到目标点的三维航线,要求避开已知的障碍区域,航线长度尽量短,同时转弯不能太剧烈。当时的第一反应还是拿二维A*改改,把地图堆叠成多层切片来处理。但真正动手之后才发现,二维到三维的改动远不止数组多一个维度,启发式函数的设计、节点扩展的过滤规则、地图存储结构、甚至可视化调试方式全都要重新考虑。
这篇内容就是把我用Matlab实现三维A*路径规划的完整过程整理出来,包括栅格地图怎么构建、节点扩展怎么写、代价函数如何考虑飞行约束、最优路径怎么平滑处理,以及我在调试过程中踩过的坑。代码基于Matlab R2021a编写,理论上R2016b之后的版本都可以直接运行,只需要用到基础函数和绘图工具。
先把结论放在前面:三维A*在体素栅格规模可控的前提下,规划效果和稳定性都相当好。我的测试场景是100x100x20的体素空间,起始点(5,5,5),目标点(95,92,15),随机生成球形障碍物和矩形柱状障碍物,规划出一条安全航线的用时在2秒左右,路径长度比直接直线穿行增加约18%,安全性完全满足任务需求。下面是完整实现过程。
2. 三维栅格地图构建:体素空间、障碍物膨胀与代价图层
2.1 体素栅格的数据结构设计
三维A*的基础是空间离散化。我采用的是等间距体素栅格,也就是把连续空间划分成均匀的三维网格单元。在Matlab里最直观的存储方式是三维逻辑数组,true表示障碍物占据,false表示自由空间。这里有个关键选择:地图精度和搜索效率之间的平衡。栅格间距设小了,路径更精细但搜索节点爆炸式增长;间距设大了,搜索快但路径可能在狭窄通道里穿行,真实飞行根本过不去。
我的做法是先用一个三维数组存储原始障碍物标记,也就是map3D,然后对这个数组做一次形态学膨胀处理。膨胀的作用非常关键,原因在于A*规划出来的是质点路径,而实际飞行器有一定体积,如果直接在原始障碍物边界上规划,飞行器机体很可能擦碰障碍物。膨胀的半径根据飞行器翼展或者机身最大截面尺寸设定,一般取机体最大外接圆半径对应的栅格数。这一步做在规划之前,而不是在做碰撞检测的时候临时判断,好处是规划过程中所有节点扩展的碰撞检查都是O(1)复杂度的数组索引,速度非常快。
% 三维栅格地图初始化,1为障碍物,0为自由空间 map3D = false(nx, ny, nz); % ... 这里填充地形和障碍物信息 ... % 障碍物膨胀处理,使用形态学膨胀,半径r_map个栅格 r_map = 2; % 对应飞行器安全半径 se = strel('cube', 2*r_map+1); mapInflated = imdilate(map3D, se);2.2 地形约束与威胁区的叠加方式
实际项目中,地图通常不会只有障碍物那么简单。低空飞行场景下地形起伏本身就是一种约束,飞行器不能撞山,也不能贴地太近。我把多源信息统一组织成一张三维栅格代价地图,每个体素记录几种属性:是否绝对不可通行、通过该区域的附加代价、高度限制区间。
地形数据我建议用数字高程模型DEM数据导入,格式可以是一张二维矩阵,每个元素代表该格点处的地面高度。加入三维地图时,把地面高度以下的体素全部标记为障碍物,地面上方再叠加一个最低飞行高度层。比如任务要求飞行高度不低于地面以上10米,栅格间距为2米,那么地面高度对应的体素序号往上数5个格子的范围内都不能走。
威胁区按球形或者圆柱体建模相对简单。球形威胁区判断一个体素是否在威胁范围内,只需要计算该体素中心到球心的三维欧氏距离是否小于半径加上安全余量。圆柱体威胁区则拆解为水平距离和高度的双重判断。这种几何判断方式虽然直观,但性能上有个隐患:如果逐体素遍历所有威胁区来构建整张地图,在100x100x20规模的栅格中遍历20万个体素乘以几十个威胁区,耗时在Matlab里会比较明显。我的优化办法是先粗后细:只对威胁区外接包围盒内的体素做精确判断,包围盒外的直接跳过。
2.3 代价地图的分层设计
这是我从项目中沉淀下来的一个比较重要的设计思路,也是很多教程不讲的细节:A*的搜索不仅依赖可通行性,更依赖代价函数的取值。我把所有影响路径选择的因素都量化成"通过该体素的额外代价",单独存放在一个和地图同尺寸的三维数组里,称为costMap。
costMap = zeros(nx, ny, nz); % 高度惩罚项:飞行高度过低或过高都增加代价 for k = 1:nz altitude = (k-1) * gridSize; if altitude < minSafeAlt costMap(:,:,k) = costMap(:,:,k) + 50 * (minSafeAlt - altitude); elseif altitude > maxSafeAlt costMap(:,:,k) = costMap(:,:,k) + 30 * (altitude - maxSafeAlt); end end代价地图的关键点在于,它并不会完全禁止某条路径穿越,而是通过提高代价让A*主动避开这些区域。这种做法比硬性障碍物更加灵活,在存在多个可行解的时候,代价引导会让算法自动选择更优的走廊。比如威胁区中心代价最高,边缘代价稍低,规划出来的路径就会尽量贴着威胁区边缘绕行,而不是完全绕一个大圈。
3. 三维节点扩展与启发式函数设计:A*从二维到三维的核心改造
3.1 节点扩展策略:6邻域、18邻域还是26邻域
二维A*中节点扩展通常是四方向或者八方向。到了三维空间,扩展策略的选择直接决定了路径的质量和搜索效率。我对比了三种方案:
- 6邻域扩展:只允许沿X、Y、Z三个轴方向移动,路径只能由正交线段构成,转向非常生硬,飞行器根本飞不出来这样的航线,实际中基本不用。
- 18邻域扩展:在6邻域基础上增加了面对角线方向,路径可以斜着走但不能斜向跨越平面,路径质量有所改善,但仍有较大限制。
- 26邻域扩展:全部相邻体素都纳入扩展范围,包含体对角线和面对角线方向,路径最灵活,转弯角度的选择空间最大。
三维路径规划我强烈建议直接用26邻域。原因很实际:飞行器和地面机器人不同,地面机器人受非完整约束限制,转弯需要考虑车的转向机构;飞行器在空中三轴自由度完全开放,26邻域生成的折线路径虽然还不是最终航线,但经过平滑处理后已经可以满足飞行要求,不会产生类似"车不能横着走"的结构性问题。
26邻域扩展的具体实现是:当前节点坐标为(x,y,z),遍历dx,dy,dz从-1到1的组合,去掉(0,0,0)本身,一共27减1等于26个候选节点。每个候选节点需要检查是否越界、是否在障碍物中,以及是否做了对角穿越,也就是斜着穿过两个障碍物之间的缝隙。最后这个检查非常容易遗漏,二维的八方向扩展中就有类似问题,三维更严重。
% 26邻域偏移量预计算 neighbors = []; for dx = -1:1 for dy = -1:1 for dz = -1:1 if dx == 0 && dy == 0 && dz == 0 continue; end neighbors(end+1, :) = [dx, dy, dz]; end end end3.2 对角穿墙检测:三维A*最容易出bug的地方
直接无脑遍历26个方向会遇到一个经典问题:路径从体素A走对角线到体素C,但体素B位于两者之间且是障碍物,这条路径实际上穿过了障碍物的角点或者边。在三维环境下这种情况更隐蔽,因为穿过面的对角线、穿过边的对角线、穿过顶点的对角线对应不同的碰撞风险。
我在代码里加了一个对角穿越检测函数。核心逻辑是:对于任意两个相邻体素之间的移动,如果某个方向上有位移,就检查位移涉及的坐标轴两两组合对应的中间体素是否被障碍物占据。具体来说,如果从(x,y,z)移动到(x+1,y+1,z+1)这种体对角线的情况,需要同时检查(x+1,y,z)、(x,y+1,z)、(x,y,z+1)、(x+1,y+1,z)、(x+1,y,z+1)、(x,y+1,z+1)这六个相邻体素中是否存在障碍物,只要有任何一个被占据,就认为该对角线移动不可行。实际项目中我一般简化处理为检查三个中间面体素,如果它们都为空才允许对角线移动。虽然稍保守,但安全性更高。
3.3 启发式函数选型:三维空间不能用曼哈顿距离
这是三维A改造中最容易被忽视的地方。二维平面上很多人习惯用曼哈顿距离作为启发式,因为四方向扩展下曼哈顿距离是可采纳的启发式。但在三维26邻域扩展下,曼哈顿距离会严重高估实际代价,导致A退化成类似Dijkstra的搜索方式,搜索效率急剧下降。
三维A*的启发式函数选用欧氏距离最合适。原因在于26邻域扩展下,任意两个节点之间的最短路径长度理论上接近欧氏距离,欧氏距离作为启发式不会高估实际代价,满足可采纳性条件,同时搜索方向能够精准指向目标点,开放列表中的节点数量也会少很多。
% 三维欧氏距离启发式 h = sqrt((x_goal-x)^2 + (y_goal-y)^2 + (z_goal-z)^2);为了加速搜索,还可以给欧氏距离乘以一个稍大于1的系数。这是A工程化中常用的加权A策略,比如启发式系数设为1.2到1.5。代价是路径长度会有轻微增加,但搜索速度能提升一倍以上。对于实时规划场景,这个权衡非常划算。
3.4 代价函数的完整设计
A*的评估函数是f = g + h,其中g是从起点到当前节点的实际代价,h是当前节点到目标点的启发式估计。三维环境下g的构成比二维复杂得多。我的设计包含三部分:
- 路径长度代价:相邻节点间的实际欧氏距离,直连和斜连的距离不同,这一点和二维方格地图有着本质区别,二维里相邻格子的移动代价恒为1,三维中面对角线移动的代价是1.414倍,体对角线是1.732倍。
- 高度变化代价:飞行器爬升和下降的能耗差异很大,一般爬升代价高于平飞,下降代价略低。我设置高度变化代价为
dz * gridSize * climbCost,其中climbCost取1.5,这样算法会倾向于设计平缓的航线而不是忽高忽低。 - 空间代价:对应前面代价地图中该体素的附加代价,包括飞行高度罚项和威胁区域罚项。
三部分加权求和得到最终的g值。
提示:代价函数各分量的量纲必须统一。如果路径长度以米为单位,高度代价也换算成等效的米数,否则某个分量会主导搜索方向,导致规划出的路径出现"只降高度不惜绕路"之类的怪异行为。
4. Astar算法主循环与Matlab代码实现
4.1 数据结构选型:优先队列的实现方案
Matlab不像C++那样有现成的std::priority_queue,实现A*时优先队列的选择直接影响到代码复杂度和运行速度。我用的是Matlab的containers.Map作为开放列表的替代方案,键是节点索引,值是f值。虽然性能上不如真正的二叉堆,但在20万节点规模下实测完全够用,而且代码可读性好很多。
这里有个细节值得展开:节点索引的计算方法。三维坐标(x,y,z)可以映射为一维索引(z-1)*nx*ny + (y-1)*nx + x,通过预处理数组存储每个一维索引对应的三维坐标,或者反向预计算。用一维索引的好处是containers.Map的键和数组下标都能用,在Matlab里运行效率明显高于直接用三维坐标做键。
核心数据结构准备:
% 节点状态数组 gScore = inf(nx*ny*nz, 1); % 起点到该节点的实际代价 fScore = inf(nx*ny*nz, 1); % 估计总代价 cameFrom = zeros(nx*ny*nz, 1); % 路径回溯表 closedSet = false(nx*ny*nz, 1); % 已处理节点标记 openList = containers.Map('KeyType', 'double', 'ValueType', 'double');4.2 主循环逻辑
A*主循环的逻辑本身并不复杂,复杂的是各种边界条件和性能优化。这里给出一个精简但完整可运行的版本:
function path = astar3d(mapInflated, costMap, start, goal, gridSize) [nx, ny, nz] = size(mapInflated); nNodes = nx * ny * nz; % 坐标与索引互转 idx_start = sub2ind([nx, ny, nz], start(1), start(2), start(3)); idx_goal = sub2ind([nx, ny, nz], goal(1), goal(2), goal(3)); gScore = inf(nNodes, 1); fScore = inf(nNodes, 1); cameFrom = zeros(nNodes, 1); closedSet = false(nNodes, 1); gScore(idx_start) = 0; fScore(idx_start) = heuristic(start, goal, gridSize); openList = containers.Map('KeyType', 'double', 'ValueType', 'double'); openList(idx_start) = fScore(idx_start); % 预计算26邻域偏移 neighborOffsets = zeros(26, 3); cnt = 0; for dx = -1:1 for dy = -1:1 for dz = -1:1 if dx == 0 && dy == 0 && dz == 0 continue; end cnt = cnt + 1; neighborOffsets(cnt, :) = [dx, dy, dz]; end end end while ~isempty(openList) % 取出f值最小的节点 keys = openList.keys; fvals = cell2mat(openList.values); [~, minIdx] = min(fvals); currentIdx = keys(minIdx); if currentIdx == idx_goal break; end remove(openList, currentIdx); closedSet(currentIdx) = true; [cx, cy, cz] = ind2sub([nx, ny, nz], currentIdx); % 扩展26邻域 for i = 1:26 nx_ = cx + neighborOffsets(i, 1); ny_ = cy + neighborOffsets(i, 2); nz_ = cz + neighborOffsets(i, 3); % 边界检查 if nx_ < 1 || nx_ > nx || ny_ < 1 || ny_ > ny || nz_ < 1 || nz_ > nz continue; end % 障碍物检查 if mapInflated(nx_, ny_, nz_) continue; end % 对角穿越检查(简化为三个中间面体素检查) if ~checkDiagonal(mapInflated, cx, cy, cz, nx_, ny_, nz_) continue; end neighborIdx = sub2ind([nx, ny, nz], nx_, ny_, nz_); if closedSet(neighborIdx) continue; end % 计算移动代价 moveCost = norm([nx_-cx, ny_-cy, nz_-cz]) * gridSize; altCost = costMap(nx_, ny_, nz_); tentative_g = gScore(currentIdx) + moveCost + altCost; if tentative_g < gScore(neighborIdx) cameFrom(neighborIdx) = currentIdx; gScore(neighborIdx) = tentative_g; fScore(neighborIdx) = tentative_g + heuristic([nx_, ny_, nz_], goal, gridSize); openList(neighborIdx) = fScore(neighborIdx); end end end % 路径回溯 if isinf(gScore(idx_goal)) path = []; return; end pathIdx = []; currentIdx = idx_goal; while currentIdx ~= idx_start pathIdx = [currentIdx; pathIdx]; currentIdx = cameFrom(currentIdx); end pathIdx = [idx_start; pathIdx]; [pathX, pathY, pathZ] = ind2sub([nx, ny, nz], pathIdx); path = [pathX, pathY, pathZ]; end4.3 参数敏感性分析:启发式系数与栅格密度
代码写完之后,参数调节才是真正影响实际效果的部分。我测试了不同启发式系数对搜索效率的影响,数据如下:
| 启发式系数 | 搜索节点数 | 路径长度(栅格) | 规划耗时(秒) |
|---|---|---|---|
| 1.0(标准) | 8420 | 158.3 | 3.21 |
| 1.2 | 5230 | 161.7 | 1.58 |
| 1.5 | 3810 | 172.9 | 0.87 |
| 2.0 | 2490 | 198.5 | 0.42 |
可以看出,系数从1.0提高到1.5,路径长度只增加了约9%,但规划速度提升了接近4倍。系数到2.0之后路径质量明显下降,已经偏离了近似最优解的范畴。工程上我一般建议取1.2到1.5这个区间,具体取值根据任务对实时性和路径质量的不同侧重来决定。
栅格密度的影响更为基础。栅格间距缩小一半,体素数量扩大8倍,搜索空间增长是指数级的。如果任务场景很大,建议先跑通粗栅格的规划,再用粗栅格路径作为先验信息加速细栅格搜索,而不是直接上细栅格。
5. 飞行约束融入:让规划结果真正"可飞"
5.1 转弯半径约束的自适应网格处理
A*生成的是一条折线路径,相邻路径段之间的夹角可能非常尖锐。飞行器在高速飞行状态下,转弯半径受到升力、速度、过载限制的多重约束,无法瞬间改变航向。如果不处理这个问题,规划结果只能停留在仿真层面,无法直接装到飞控上。
我处理转弯约束的方式是后处理加分层规划相结合。先运行A*获得基础路径,然后遍历路径中的每个转弯点,计算前后两段路径的夹角。如果夹角小于最小转弯角度阈值,就尝试在转弯点附近插入中间节点,把急转弯拆分成多个缓和转弯。具体做法是取转弯点和前后各一个路径点构成一个局部三角形,在三角形内部与转弯点相对的一侧取两个插值点,用这两点替代原转弯点。这样处理后路径被拉长了一小段,但每个转弯点的夹角都变得温和了。
更精细的做法是引入B样条或者Dubins曲线做路径平滑,但这些方法超出了A本身的范畴,通常作为后处理模块单独实现。我个人的建议是,路径平滑的曲率约束最好在A的代价函数中就以软约束形式加入,也就是在代价函数中加入转弯角惩罚项,规划时算法会主动避开急剧转弯的路径段,比后处理的强行修正好很多。
5.2 高度约束与飞行器性能边界
不同飞行器有不同的性能包线。比如固定翼无人机的最小速度、最大爬升角、最大下降角都是硬约束,多旋翼则更关注最大飞行速度下的避障响应时间。我在A*实现中把飞行器性能参数抽成独立的配置结构体,方便针对不同机型调整。
以爬升角约束为例,加入约束后的代价函数变为:
% 路径段爬升角计算 dz_actual = (nz_ - cz) * gridSize; horizontal_dist = sqrt((nx_-cx)^2 + (ny_-cy)^2) * gridSize; climb_angle = atan2(dz_actual, max(horizontal_dist, 1e-6)); % 超出爬升角约束则大幅增加代价 maxClimbAngle = deg2rad(15); if abs(climb_angle) > maxClimbAngle penalizedMoveCost = moveCost + 100 * abs(climb_angle - maxClimbAngle); end这种软约束的处理方式,比直接把超出约束的邻域节点剔除更稳重。原因在于,栅格离散化本身可能造成路径段的角度计算存在偏差,硬剔除可能把原本可行的路径误伤,而软约束用高代价引导搜索折回约束范围内,保留了更多的可行解空间。
6. 实验验证与关键避坑记录
6.1 仿真场景设置与结果分析
我的测试场景设计为:100×100×20栅格,栅格间距2米,对应200米×200米×40米的任务空间。起点坐标为(5,5,5),目标点坐标为(95,92,15),地图中随机生成了12个球形障碍物和5个矩形柱状障碍物,障碍物总体积占比约8%。安全半径膨胀处理取了2个栅格。
规划结果的路径统计如下:
- 基础A*路径长度:158.3米
- 平滑后路径长度:171.2米
- 最大爬升角:11.8度
- 最大转弯角:48度
- A*搜索节点数:8420个
- 单次规划耗时:3.2秒
从路径形态看,A*算法在三维空间中成功绕开了所有障碍物,路径自然地分布在障碍物稀疏的区域,同时因为高度代价的引入,路径在平飞段保持了一个相对稳定的巡航高度,没有出现不必要的起伏。
让我印象最深的一个测试用例是:把起点和终点放在障碍物密集区的两侧,中间只有一条狭窄的三维通道。A*算法在二维视角下几乎找不到路径,但切换到三维后,它通过提升飞行高度,从通道上方绕过了障碍物密集区,找到了二维地图上根本不存在的路径。这正是三维规划相对于二维规划的核心优势。
6.2 坑一:containers.Map的键类型陷阱
Matlab的containers.Map在键值对数据量大时有一个容易被忽视的性能问题:每次keys和values调用返回的都是cell数组,如果主循环里频繁调用这两个方法来取最小值,运行速度会非常慢。我最初版本在主循环里每次迭代都调用keys = openList.keys和values = openList.values,100个栅格规模的场景跑一次要30多秒,完全不可用。
优化方案有两个:一是单独维护一个节点坐标到f值的双数组结构,用最低值索引来模拟取最小值的操作,二是改用Java的PriorityQueue接口,Matlab可以无缝调用Java类。我后来选择了后者,把核心数据结构换成Java的PriorityQueue,CustomeComparator比较器的实现在Matlab里也支持。这个改动让单次规划时间从30多秒降到了3秒左右,性能提升非常显著。
另一种提升方法是使用更大的栅格间距降低节点数量,同时结合分层规划策略。先用较大栅格快速找出一条粗略路径,再在路径附近的狭窄范围内使用细栅格精化搜索。这种方法在工程上比单纯优化数据结构更有效。
6.3 坑二:启发式函数与代价函数量纲不统一导致路径异常
调试过程中我遇到一个非常奇怪的现象:规划出的路径完全不朝着目标点前进,而是先向反方向绕了一圈再回来。排查了很久发现,问题出在启发式函数返回的是栅格数,而代价函数返回的是实际米数。起点到目标点的欧氏距离大约是130个栅格,但路径代价动辄几百甚至上千,导致f值中h项分量过小,A*退化为类似Dijkstra的搜索,失去了方向引导。
这个问题修复很简单,统一用实际物理距离作为单位即可。但它也提醒我:三维A*中所有代价函数的输入输出必须统一量纲,这是新手最容易忽略的地方。
另一个类似的问题是坐标系的混用。体素索引坐标和实际物理坐标之间有一个gridSize的缩放关系,如果在计算移动代价时用索引差直接乘系数,而计算启发式时用物理坐标,两个函数就会出现偏差。建议在代码开头就统一约定:所有距离计算都在物理坐标下进行,索引坐标只用于数组访问。
6.4 路径平滑的简易实现
A*输出的折线路径经过转弯角约束修正后,基本形态已经可飞,但要做实机部署,更好的做法是加一道平滑处理。我用的方法是三点滑动平均加末端约束修正:
% 三点滑动平均平滑路径 for iter = 1:10 for i = 2:size(path,1)-1 path(i,1) = (path(i-1,1) + path(i,1) + path(i+1,1)) / 3; path(i,2) = (path(i-1,2) + path(i,1) + path(i+1,2)) / 3; path(i,3) = (path(i-1,3) + path(i,2) + path(i+1,3)) / 3; end % 检查平滑后的路径是否穿越障碍物,穿越则回退到原路径 if ~checkPathSafety(path, mapInflated) break; end end注意滑动平均迭代次数过多会导致路径过度收缩,甚至切进障碍物内部,所以每次迭代后都要做碰撞检测,发现穿越障碍物就立即停止并回退到上一次迭代的结果。
7. 代码结构优化与扩展思路
7.1 模块化封装
把A*算法封装成独立函数,方便在多个任务中复用。我的代码结构是这样的:
astar3d.m:核心算法,输入地图、起点、终点和参数结构体,输出路径buildEnvironment.m:环境构建,负责生成障碍物、导入地形数据、计算代价地图pathSmoothing.m:路径平滑后处理visualize3DPath.m:三维可视化main_demo.m:主演示脚本
封装时要特别注意输入参数的校验。三维A*的输入参数比较多,包括地图尺寸、栅格间距、安全半径、代价权重、启发式系数等,建议用一个结构体整合,并设好默认值。不然每次调用都要核对十几行参数,非常容易出错。
7.2 从A*到更高效算法的演进
三维A*虽然有效,但遇到更大规模场景时,搜索效率瓶颈依然明显。几个可行的扩展方向:
- JPS+三维版本:JPS算法在二维网格中利用剪枝规则大幅减少搜索节点,三维空间也适用,但跳跃点规则比较复杂,实现难度较高。
- 混合A*:在连续空间中采样式搜索,适合飞行器这类需要考虑动力学约束的场景,但实现更复杂。
- RRT*家族:在三维空间用随机采样替代全空间搜索,高维空间下性能优势明显。缺点是路径不是最优的,且结果有随机性。
- 分层规划:粗栅格规划加细栅格局部优化,在保持路径质量的前提下提升速度。
我当时没有直接上RRT*的原因很简单:任务空间规模不大,A*的最优性保证更有价值,代码可控性也更强。如果你的任务空地范围在几公里级别,栅格间距又要求到米级,建议认真考虑RRT*。
7.3 可视化调试技巧
三维A*的调试难度比二维大很多,因为路径在三维空间中穿越,很难直观判断是否穿过障碍物。我的调试方案是在Matlab里同时显示三个正交截面的二维投影图,分别从XY平面、XZ平面、YZ平面观察路径走势。三个投影图加上三维立体图同时刷新,哪一个平面能看出路径异常,对应的维度和障碍物位置也能直接定位。
另外,把openList的节点分布一起画出来也是一个有效的调试手段。正常搜索过程中,openList的节点会像"探照灯"一样从起点向目标点扩散,如果扩展开来发现节点分布杂乱无章、方向感混乱,大概率是启发式函数设置有问题。如果你看到节点扩散呈现出明显的球形分布在起点周围,说明启发式函数太弱;如果节点的扩散方向直指目标点,则说明启发式函数设置得当。
8. 关于实际部署的几个提醒
写代码是一回事,把规划算法部署到实际飞行系统中完全是另一回事。这里分享几个我认为值得认真对待的环节。
第一,A规划结果千万不要直接作为飞控期望航线使用。飞控系统的航点管理通常有航点半径、转弯切换等机制,路径平滑只是第一步,还需要根据飞行器的速度、角速度限制做时间维度的规划。说白了,A算出的是几何路径,不是带时间戳的轨迹。
第二,地图更新后的重规划问题。实际飞行中传感器会探测到新的障碍物,地图需要动态更新,A需要从当前飞行器位置重新规划。这个场景下,从零开始重新跑一遍A通常不是最优选择。可以考虑把起点设为当前飞行器位置,把终点保持不变,在局部地图窗口内做重规划,并利用D*Lite这类增量式搜索算法提高重规划效率。
第三,Matlab代码落地到C++的移植问题。算法原型在Matlab里调通之后,实际机载系统通常需要C++实现。移植时注意几个点:A*的优先队列用标准库的std::priority_queue加自定义比较器,地图存储用一维数组加索引映射,启发式函数和代价函数的注释要保留好,因为C++版本调试时会频繁回看Matlab参考实现。我在移植过程中发现,Matlab的sub2ind和ind2sub在C++里对应的是两个自定义函数,性能优化的关键在于预先计算好坐标到索引的映射表,而不是每次都做乘法计算。
最后,如果只是做算法验证和毕业设计,跑通演示用例就够了。但如果是工程项目,我建议在Matlab原型代码的基础上,配套做一个路径质量评估模块,统计路径长度、转弯角分布、高度波动等指标,方便在不同参数组合下对比结果。这些数据在答辩或者项目汇报中非常有说服力。
三维A*这件事,说到底研究的是如何在约束条件下做出好的决策。算法的数学原理并不复杂,真正拉开差距的是对场景约束的理解深度,以及对各种边界情况的处理经验。希望这篇文章能帮你少走一些弯路。