做无人机项目、搞路径规划研究的同学,对A星这个名字应该再熟悉不过了。不过大多数人接触到的都是二维寻路,真正把它扩展到三维空间,和无人机飞行结合,这里面的门道就多了。这篇文章把“基于A星算法的无人机三维路径规划”这件事从头到尾拆开讲清楚,包括算法原理、MATLAB代码实现、参数怎么调、有哪些坑要避开,属于那种“看完就能自己上手写”的实战型分享。
这个项目要解决的核心问题很直接:给定一个三维环境模型(比如山区地形、城市楼宇),规划出一条从起点到目标点的安全路径,让无人机在飞行过程中不撞障碍物,同时路径尽量短、尽量平滑。A星算法作为经典的启发式搜索算法,在静态地图的路径规划中表现非常稳定,代码逻辑清晰、结果可复现,特别适合作为无人机三维路径规划的研究基础和工程实现起点。无论是研究生做课题、数学建模竞赛备赛,还是本科毕设做无人机仿真,这套MATLAB实现都能直接拿来用。
1. 项目概述与整体思路拆解
1.1 无人机三维路径规划到底难在哪
很多人觉得路径规划不就是“找条路”嘛,二维游戏里到处都是,有什么难的?真把无人机放进去,三维空间的问题就完全不一样了。
首先是搜索空间爆炸。二维迷宫寻路是在一个平面上,从某个格子考虑上下左右四个或者八个方向就够了。三维空间里,每个节点可以往周围26个方向扩展(三个方向各取-1、0、1组合,去掉原地不动),节点数量随分辨率呈立方级增长。举个直观的例子:一个1公里×1公里×300米的飞行区域,如果按10米分辨率栅格化,就是100×100×30=30万个节点;如果按1米分辨率,直接就是3亿个节点。这种规模差异会直接导致算法跑不动。
其次是约束条件复杂。无人机不能像游戏角色那样随便瞬移或者穿墙,它有最大爬升角、最小转弯半径、飞行高度限制,甚至还要考虑风速和能耗。三维路径规划不能只求“能走”,还得考虑“飞得起来、飞得稳、飞得省”。
还有一个问题是实时性。有些应用场景比如自动巡检、灾害应急物资运输,无人机需要尽快拿到路径并开始执行,规划算法跑几十分钟肯定不行,几秒钟内给出结果是基本要求。这就对算法的效率和参数设置提出了很高要求。
1.2 为什么选择A星而不是RRT、蚁群或遗传算法
做路径规划可选的算法非常多,快速搜索随机树(RRT)、概率路图(PRM)、蚁群算法、遗传算法都有自己的拥趸。为什么在这里选择A星?我个人的判断基于三点。
第一,A星在静态地图上具有最优性和完备性。所谓完备性,就是只要起点和终点之间存在可行路径,A星就一定能找到;所谓最优性,在启发函数满足一致性条件时,A星找到的路径是最优的。这一点在学术研究和竞赛中有非常重要的意义——你的结果是可以论证和复现的,而不是一句“差不多能到就行”。
第二,A星的确定性强。RRT类算法有随机采样过程,每次运行结果都不一样,调参数全靠缘分;蚁群和遗传算法更是有一大堆参数要调,迭代次数、种群大小、信息素挥发系数,调起来二十组参数打底。A星的逻辑非常清晰,f(n)=g(n)+h(n),一个公式撑起全部,容易理解和调试。
第三,MATLAB环境下A星非常友好。三维矩阵天然可以表示栅格地图,矩阵索引天然对应栅格坐标,plot3一键可视化路径。你不需要搭建复杂的仿真环境,几百行代码就能把整个算法流程跑通,对入门者极其友好。
2. 核心原理:A星算法在三维空间的完整展开
2.1 A星的核心公式
A星算法的核心就一个公式:f(n) = g(n) + h(n)。其中:
- g(n) 表示从起点到当前节点n已经花费的实际代价;
- h(n) 表示从当前节点n到目标点的启发式估计代价;
- f(n) 就是经过节点n的完整路径的总代价估计。
算法的过程说白了就是:每次从待考察队列中取出f值最小的节点进行扩展,直到扩展到目标点为止。
这里有个很重要的点:启发函数h(n)的选择直接决定算法行为。如果h(n)始终为0,A星退化成Dijkstra算法,会一圈一圈向外扩散,效率极低;如果h(n)严重高估真实代价,算法会快速收敛但得到的不是最优路径;只有h(n)始终小于等于真实代价,A星才能保证找到最优路径,这叫“可采纳性”。
在三维空间里,最常用的启发函数是三维欧氏距离:
$$h(n) = \sqrt{(x_n - x_{goal})^2 + (y_n - y_{goal})^2 + (z_n - z_{goal})^2}$$
欧氏距离在几何上满足三角不等式,所以它不仅可采纳,而且是一致性的,这意味着A星第一次把终点从开放列表中取出来时,就可以放心地结束循环,不需要再检查其他可能更优的路径。
2.2 从二维8邻域到三维26邻域的扩展逻辑
二维A星中,当前节点的邻域通常取8个方向(上下左右加四个对角)。到了三维空间,平面的8邻域加上高度层的上下移动,就变成了26邻域。
用MATLAB代码生成这26个方向偏移量非常直观:
% 生成三维26邻域的偏移量集合 neighbors = []; for dx = -1:1 for dy = -1:1 for dz = -1:1 if ~(dx == 0 && dy == 0 && dz == 0) neighbors(end+1, :) = [dx, dy, dz]; end end end end26邻域意味着每扩展一个节点,最多需要检查26个相邻节点是否可通行、是否越界。相比二维的8邻域,计算量陡增,但换来的是更灵活的路径搜索能力。26邻域允许无人机斜向运动,路径更短,也更能体现三维空间飞行的特点。
如果实际场景中无人机有最大爬升角限制,可以在生成邻域时做一个巧妙的剪枝——当dz不为0且爬升角度超过阈值时,直接跳过这个方向。这样既减少了搜索空间,又能保证规划出的路径符合无人机的飞行能力,算是个实用的小技巧。
2.3 栅格地图与代价设置
路径规划之前,先要把连续的三维空间离散化为栅格地图。MATLAB里就是一个三维矩阵,0表示可通行,1表示障碍物。这种表示方式最直观,也最容易做碰撞检测——只要判断目标栅格是否为0即可。
障碍物不是孤立的点,无人机本身有体积,飞行时不能贴着障碍物表面擦过去。实际操作中一定要对障碍物做膨胀处理,就是把障碍物所在的栅格以及周围一圈或多个栅格全部标记为不可通行。比如一架翼展1.2米的无人机,如果栅格分辨率是5米,膨胀一圈就够了;如果定位误差或者气流扰动比较大,建议膨胀两圈。这个经验是我在仿真中试出来的,很多第一次搞三维路径规划的同学都忘了这步,结果规划出来的路径看着能过,实际飞的时候机翼就撞上了。
关于代价g(n)的具体数值,我推荐这样设置:直线相邻的节点(即只有一个坐标变化)移动代价为1;面对角线(两个坐标变化)代价为√2;体对角线(三个坐标变化)代价为√3。这样做的好处是代价与真实空间距离成正比,配合欧氏距离启发函数,算法搜索出来的路径更准确。还可以在g值中叠加高度变化惩罚,比如爬升或下降一步额外加0.2的代价,这样路径会优先保持平缓,更符合无人机省电的飞行习惯。
3. 基于MATLAB的具体实现与实操细节
3.1 程序整体框架
整个MATLAB程序可以分成四个模块:环境建模、A星搜索主循环、路径回溯、可视化。模块之间用清晰的数据接口串起来,方便单独调试。
环境建模模块的输入是地图参数(长宽高、分辨率)和障碍物分布,输出是三维栅格矩阵、起点索引和终点索引。A星主循环负责执行算法核心逻辑,输入开放列表、关闭列表、代价矩阵,输出每个节点的父节点信息。路径回溯从终点一路找父节点到起点,得到路径点序列。可视化模块把路径、障碍物、地形画在同一张图上,方便检查效果。
这种模块化设计看起来简单,但非常实用。比如你想换个启发函数,只需要改一个地方;想从静态地图换到带威胁区域的代价地图,也只需要改环境建模模块。代码的可读性和可维护性都很好。
3.2 环境建模与可视化代码实现
这里给一份可以直接运行的三维环境建模代码。首先创建一个60×60×30的栅格地图,随机生成一些长方体障碍物:
% 创建三维栅格地图:0=可通行,1=障碍物 map = zeros(60, 60, 30); % 随机生成30个长方体障碍物 rng(2024); for k = 1:30 x1 = randi([5, 50]); y1 = randi([5, 50]); z1 = randi([3, 25]); w = randi([3, 8]); d = randi([3, 8]); h = randi([2, 5]); map(x1:min(x1+w, 60), y1:min(y1+d, 60), z1:min(z1+h, 30)) = 1; end % 设定起点和终点(栅格索引,注意MATLAB索引从1开始) startIdx = [2, 2, 2]; goalIdx = [58, 58, 6]; % 可视化环境 [xg, yg, zg] = ind2sub(size(map), find(map == 1)); scatter3(xg, yg, zg, 3, 'k', 'filled'); hold on; grid on; plot3(startIdx(1), startIdx(2), startIdx(3), 'go', 'MarkerSize', 10, 'LineWidth', 2); plot3(goalIdx(1), goalIdx(2), goalIdx(3), 'r^', 'MarkerSize', 10, 'LineWidth', 2); xlabel('X (栅格)'); ylabel('Y (栅格)'); zlabel('Z (栅格)');如果你的项目有真实地形数据,比如数字高程模型(DEM),把DEM读进来后按阈值处理成障碍物即可。需要注意坐标系统一的问题:无论是从倾斜摄影建模、地形图还是其他来源获取的地图数据,都要先转换到同一个坐标系下,再按分辨率重采样成栅格矩阵,否则位置对不上会出现“路径在地图外”的诡异错误。
3.3 A星主循环:关键数据结构的MATLAB实现
A星主循环是整个程序的核心,数据结构设计直接决定代码的性能和可调试性。我推荐的做法是用三个三维矩阵来存储信息:gScore存储从起点到每个节点的实际代价,初始化为无穷大;fScore存储总代价估计;parent存储每个节点的父节点线性索引,用于最后回溯路径。另外还需要一个三维逻辑矩阵closed标记已经处理过的节点。
开放列表用一个N×3的矩阵保存待考察节点的坐标。MATLAB没有原生的优先队列,最简单的做法是每次从开放列表中取f值最小的节点——虽然遍历找最小值是线性复杂度,但在节点规模5万以下的栅格地图中,实测完全可以接受,而且代码逻辑特别清晰:
% 初始化 gScore = inf(size(map)); fScore = inf(size(map)); parent = zeros(size(map), 'int32'); closed = false(size(map)); sx = startIdx(1); sy = startIdx(2); sz = startIdx(3); gx = goalIdx(1); gy = goalIdx(2); gz = goalIdx(3); gScore(sx, sy, sz) = 0; fScore(sx, sy, sz) = heuristic(startIdx, goalIdx); openList = [sx, sy, sz]; maxIterations = 1000000; iterCount = 0; while ~isempty(openList) && iterCount < maxIterations iterCount = iterCount + 1; % 找到开放列表中f值最小的节点 openIds = sub2ind(size(map), openList(:,1), openList(:,2), openList(:,3)); [~, minPos] = min(fScore(openIds)); current = openList(minPos, :); openList(minPos, :) = []; % 到达终点则结束 if isequal(current, goalIdx) fprintf('路径规划成功,迭代次数:%d\n', iterCount); break; end cx = current(1); cy = current(2); cz = current(3); closed(cx, cy, cz) = true; % 遍历26邻域 for k = 1:size(neighbors, 1) nx = cx + neighbors(k, 1); ny = cy + neighbors(k, 2); nz = cz + neighbors(k, 3); % 越界检查 if nx < 1 || nx > size(map,1) || ny < 1 || ny > size(map,2) || nz < 1 || nz > size(map,3) continue; end % 障碍物检查 if map(nx, ny, nz) == 1 || closed(nx, ny, nz) continue; end % 计算移动代价(欧氏距离) moveCost = norm(neighbors(k, :)); % 可选:增加高度变化惩罚 if neighbors(k, 3) ~= 0 moveCost = moveCost + 0.2; end tentativeG = gScore(cx, cy, cz) + moveCost; % 如果新路径更优,更新节点信息 if tentativeG < gScore(nx, ny, nz) gScore(nx, ny, nz) = tentativeG; fScore(nx, ny, nz) = tentativeG + heuristic([nx, ny, nz], goalIdx); parent(nx, ny, nz) = sub2ind(size(map), cx, cy, cz); % 如果不在开放列表中,加入 if ~ismember([nx, ny, nz], openList, 'rows') openList(end+1, :) = [nx, ny, nz]; end end end end启发函数h(n)的实现就一行:
function h = heuristic(nodeIdx, goalIdx) h = sqrt(sum((nodeIdx - goalIdx).^2)); end这里有一个非常重要的细节:当发现更优路径但节点已经在closed列表中时,理论上应该把该节点重新放回开放列表。上述代码因为加入了closed检查,不会出现这种情况——但这是基于启发函数的一致性假设。如果后续你改了代价函数导致启发函数不再一致,一定要重新考虑这个逻辑,否则可能得到次优路径。
3.4 路径回溯与三维可视化
路径回溯从终点开始,沿着parent指针一路回溯到起点,再把索引序列转成路径坐标:
% 路径回溯 pathIdx = []; curIdx = sub2ind(size(map), goalIdx(1), goalIdx(2), goalIdx(3)); startLin = sub2ind(size(map), startIdx(1), startIdx(2), startIdx(3)); while curIdx ~= startLin if parent(curIdx) == 0 error('路径回溯失败:父节点缺失,请检查连通性'); end pathIdx = [curIdx; pathIdx]; curIdx = parent(curIdx); end pathIdx = [startLin; pathIdx]; % 索引转坐标 [xPath, yPath, zPath] = ind2sub(size(map), pathIdx); % 绘制路径 plot3(xPath, yPath, zPath, 'r-', 'LineWidth', 2.5);可视化呈现非常重要。直接把路径和障碍物画在一张三维图里,一眼就能看出规划结果合不合理。红色路径是否贴着黑色障碍物飞?有没有绕大远路?还是干脆直接穿墙了?这些视觉检查能在几秒钟内发现代码里的逻辑漏洞,比调试无数个print语句都高效。
4. 参数调优与实际运行经验
4.1 栅格分辨率:计算量与精度的生死线
栅格分辨率是整个算法最关键的参数。分辨率太高,路径更精确但计算量指数上涨;分辨率太低,路径粗劣甚至直接找不到可行通道。
我这里给一个参考经验:在MATLAB环境下,节点规模在5万以下(比如50×50×20的栅格地图)时,简单遍历开放列表的A星运行流畅,规划时间在1秒以内;节点规模到20万(比如100×100×20)时,遍历找最小值的方式会明显变慢,规划时间可能涨到几十秒;超过50万节点,普通MATLAB代码基本就跑不动了。
如果确实需要高分辨率,我建议采用“分层规划”思路:先用低分辨率地图快速规划一条粗略路径,确定关键的飞行走廊,再在高分辨率地图上、只在走廊范围内做精细规划。这种方法在实践中能把计算量降低一个数量级,而且规划出的路径质量和一次性高分辨率规划几乎没有差别。
4.2 启发权重与高度惩罚的调节
标准A星中,启发函数权重是1.0,这保证了算法的最优性。但在工程上,我们有时候并不需要理论最优,只需要一条“足够好且能快速算出来”的路径。
常见的做法是在启发函数前加一个权重系数w:
$$f(n) = g(n) + w \cdot h(n)$$
当w从1.0增加到1.5甚至2.0时,算法会更激进地朝目标方向搜索,扩展节点数明显减少,规划速度大幅提升,代价是有可能错过真正的最优路径。实测下来,在静态地图中w=1.2到1.5是一个很好的折中区间,路径长度相比最优只会增加几个百分点,但规划时间可以减少一半以上。
高度惩罚系数也要根据场景来定。山区地形飞行时,如果完全不考虑高度代价,A星会倾向于走直线,穿过山体或者贴着山坡走,非常危险;如果惩罚太大,无人机又会选择绕行极大的弯路。我常用的做法是把爬升惩罚系数设为0.2到0.3,这样路径会在绕行和爬升之间自动平衡,飞起来更像一个老练的飞行员而不是莽撞的新手。
4.3 MATLAB代码性能优化的几个手段
如果跑大场景觉得慢,有几个性价比很高的优化方法值得试。
第一个是预分配和避免动态数组增长。上面的示例代码中,openList用的是openList(end+1, :) = [nx, ny, nz]这种动态增长方式,在小场景无所谓,但在大场景会反复触发内存分配,拖慢速度。可以先用一个足够大的矩阵预分配空间,再用一个计数器记录实际元素个数。
第二个是用线性索引代替多维索引。MATLAB对线性索引访问三维矩阵的速度比用两个逗号分隔的下标索引更快。上面的代码里,parent矩阵存的就是线性索引,这就是有这个考虑。如果你后续做大规模优化,可以把openList直接存成线性索引,进一步减少sub2ind的调用次数。
第三个是换用真正的优先队列。MATLAB中可以用java.util.PriorityQueue(纯Java实现,在MATLAB中直接可用),或者自己写一个二叉堆,将开放列表f值最小的节点查找从O(n)降到O(logn)。我实测在小规模地图中瓶颈不明显,但在50万节点规模时,优先队列能把整个规划时间从一分钟以上压缩到几秒钟,提升非常可观。
5. 常见问题与排查技巧实录
5.1 路径穿墙或贴着障碍物飞
这是做三维A星最常遇到的问题。出现这个现象的典型原因是障碍物没有膨胀,或者代价函数设计让路径宁可贴着墙走也不愿意绕路。
排查方法很简单:把路径点和障碍物画在同一张图里,观察路径上每个栅格的地图值。可以用一行代码快速验证:
pathMapValue = map(sub2ind(size(map), xPath, yPath, zPath)); fprintf('路径经过的栅格值:\n'); disp(unique(pathMapValue));如果unique结果中有1,说明路径确实穿越了障碍物栅格,需要回头检查闭集逻辑。如果结果全是0但路径肉眼可见贴着障碍物表面,那风险预警的意义就足够了——实际飞行时风一吹就可能撞上,赶紧加一层膨胀。
我在自己的代码里会加一个断言语句,每一步回溯到的路径节点都检查一次地图值,只要发现异常就直接报错,宁可程序崩溃也不让带着错误的结果跑下去。
5.2 算法长时间运行不结束或死循环
A星跑不出来的原因通常有这么几个:障碍物把起点和终点的通路完全堵死了;closed标记的逻辑有bug导致节点被永久丢弃;启发函数在某种地图形态下出现不一致性导致节点反复进出开放列表;或者开放列表的ismember判断写错导致重复节点无限堆积。
实用的排查步骤:第一步,在程序里加一个迭代次数上限,比如最多扩展10万次节点,超过直接退出并打印提示信息。这能防止程序挂死。第二步,每次从开放列表中取出节点时打印长度,观察它是趋于减小还是持续增大。第三步,检查起点终点是否真的可达——最简单的方法是把所有障碍物去掉跑一遍,如果无障碍时能出结果,有障碍时出不来,多半是障碍物把通道堵死了,而不是代码逻辑的bug。
还有一个很容易被忽略的问题:MATLAB的ismember(x, y, 'rows')在大开放列表中非常耗时。如果节点规模大,建议单独维护一个开放列表的三维逻辑矩阵openFlag,用openFlag(nx, ny, nz)判断是否已经加入,入队和出队时同步更新这个矩阵,速度会快很多。
5.3 路径锯齿严重,无人机根本没法飞
A星在离散栅格中搜索,天然会产生大量45度或斜向的折线,路径看起来一截一截的像锯齿。这种路径在仿真里看看还行,真要导入飞控让无人机飞,每个转折点都会产生剧烈的姿态变化,不但耗电而且危险。
处理办法分两步。第一步是后处理简化和共线合并:遍历路径点,如果三个连续点共线(即方向向量成正比),就把中间那个点删掉;更进一步可以采用“拉直”策略,如果删除中间点后新路径段不穿过障碍物,就果断删除。这一步能把路径点数量降低50%以上。
第二步是平滑处理,常用三次样条插值或B样条曲线对路径做光滑。需要注意的是,平滑后的路径点可能偏移到障碍物内部,所以平滑之后必须做一次碰撞检测,对违规的区间段做局部修正。我之前就在这一步吃过亏——路径画出来非常圆润漂亮,一检查发现直接穿过了障碍物,血泪教训。
5.4 A星三维路径规划避坑速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 路径穿墙 | 障碍物未膨胀或膨胀不够 | 对障碍物做膨胀处理,至少膨胀一圈 |
| 路径穿墙 | 启发函数权重过大导致丢失最优解 | 检查权重w,保持在1.2以下 |
| 运行超时 | 栅格分辨度过高 | 降低分辨率,或用分层规划 |
| 运行超时 | 开放列表遍历找最小值过慢 | 使用二叉堆或PriorityQueue |
| 死循环 | 启发函数不一致 | 检查h(n)是否满足三角不等式 |
| 死循环 | 开放列表去重逻辑错误 | 用openFlag三维矩阵维护入队状态 |
| 死循环 | 起点或终点被障碍物包围 | 检查地图连通性,换个起终点 |
| 路径锯齿 | 离散栅格的固有特性 | 共线删除 + 样条平滑 + 碰撞重检 |
| 路径不合理绕路 | 高度惩罚系数过大 | 调低爬升惩罚系数 |
| 路径贴着障碍物 | 移动代价设置不当 | 引入安全距离代价项 |
说到最后,我个人做这个项目的最大体会是:A星这种基础算法,表面上原理一页纸就能讲完,但真正把它用到三维空间、放到无人机这个载体上,拼的全是工程细节。索引转换搞错一位就会穿墙,启发函数权重差一点路径形态就完全不同,平滑处理不配合碰撞检测就是花架子。用MATLAB做这件事最大的优势就是可视化直观、矩阵操作省心,非常适合把算法逻辑和参数影响搞清楚。但如果你将来要把这套东西跑到实际飞行器上,我建议把核心搜索逻辑换成C++,同时把地图分辨率、启发权重、障碍物膨胀半径这些设计成配置文件——这样调参再也不用改代码了。
如果你还想继续做深,可以考虑给A星加上最大爬升角约束和转弯代价,或者用混合A星去处理无人机的动力学可行性;再进一步,把静态地图换成实时更新的代价地图,加上局部重规划,就能应对动态障碍物的场景了。这个方向确实是越做越有味道的。