看到这个标题,你多半是在做船舶智能避碰相关的仿真,或者正酝酿一艘无人船的毕设开题。人工势场法(APF)我在三年前第一次接触的时候,总觉得这是机器人路径规划里最简单粗暴的算法,直到真把它搬上船才发现:船有船的脾气,它不是车,更不是无人机。势场法给的只是一个“方向”,而船怎么转弯、怎么减速、怎么绕行,才是真正要花心思的工程问题。
这篇文章我会把从零搭建一套基于人工势场法的船舶自动避碰MATLAB仿真的完整过程拆开讲,包括方案设计、核心公式、参数标定思路、完整可跑的代码骨架,以及我实际调试中踩过的坑和对应的排查方法。如果你是刚接触路径规划的学生,或者想快速验证避碰算法的开发者,这套思路可以直接“抄作业”;如果你已经有经验,也可以重点看看最后两节的局部极小值处理和调参心得,这些细节通常论文里不会写。
1. 整体设计思路拆解:船不是质点,势场也不能硬搬
1.1 人工势场法到底在做什么
人工势场法的思想其实一句话就能讲清楚:在环境里布置一个“虚拟力场”,目标点产生引力,障碍物产生斥力,船舶(或者其他运动体)在这两个力的叠加作用下往合力的方向走。引力让船趋向目标,斥力把船从危险区推走,所以路径天然带有避障属性。
这个概念用在轮式机器人上非常顺手,因为小车几乎可以原地转向,速度控制也灵活。但船不一样。船有较大的惯性,它的运动自由度本质上是欠驱动的——你不能直接命令它“横向移动”,只能通过改变艏向角和水流推进力来间接控制位置。所以直接把APF算出来的合力当作加速度叠加到速度上,船会“飘”得很厉害,甚至原地打转。
在船舶场景里我更推荐的做法是:把APF算出的合力方向转成一个“期望艏向角”,再用操纵运动模型去跟踪这个角度,而不是直接去改船的速度向量。合力的模长则用来决定是否减速,或者干脆保持航速、只转向。这个设计决定了整个仿真框架的走向。
1.2 为什么用MATLAB而不是C++或Python
我个人的习惯是:算法验证阶段一定用MATLAB,理由很朴素——矩阵运算写起来快,quiver、plot、animatedline这些可视化工具比任何绘图库都顺手。避碰算法迭代很快,可能一个下午要改动十几次参数,MATLAB脚本敲完就能跑,变量存在工作区里随时可以检查,这个效率优势非常明显。等算法跑通了,再考虑把核心函数用MATLAB Coder转成C++部署到实船上,那是后话。
另外,船舶运动模型用到大量矩阵转置、状态向量更新,用MATLAB写循环里的代码会很接近伪代码,别人读起你的仿真逻辑也容易跟上。做毕设或者发论文,评审大概率会关注仿真平台的可复现性,MATLAB的绘图导出的矢量图也方便直接放到论文里。
1.3 总体仿真框架的四个模块
我搭的仿真结构分四块:
- 环境模块:定义水域大小、障碍物位置与半径、目标点坐标。
- 势场计算模块:输入本船位置、障碍物位置、目标点,输出合力向量。
- 船舶运动模块:把期望艏向角输入一个简化的舵响应模型,更新船的位置与艏向。
- 可视化与数据记录模块:实时显示船的轨迹、周围障碍物、合力箭头。
四个模块分开写,最大的好处是后面对比不同障碍物布局、不同参数时,不用动主干逻辑,改一个支路就好。后面我给代码的时候也会按这个模块来组织。
2. 关键公式与参数设计:势场力的计算不能凭感觉
2.1 引力场与斥力场的标准形式
传统APF的核心就是两组势场。我先把常用公式列出来,后面所有的实现都基于这套定义。
引力场函数:
U_att = 0.5 * k_att * ||p_goal - p_boat||^2对应引力:
F_att = k_att * (p_goal - p_boat)这里k_att是引力增益系数,p_goal是目标点坐标,p_boat是本船坐标。引力方向永远指向目标点,模长与距离成正比——离目标越远拉力越大,这样船不会因为引力太弱停在半路。
斥力场函数,我采用传统形式:
如果 rho <= rho0: U_rep = 0.5 * k_rep * (1/rho - 1/rho0)^2 否则: U_rep = 0其中rho是本船到障碍物表面的距离,rho0是斥力的作用半径。对应的斥力表达式:
如果 rho <= rho0: F_rep = k_rep * (1/rho - 1/rho0) * (1/rho^2) 否则: F_rep = 0注意这个斥力的方向指向从障碍物指向本船的方向(也就是推离障碍物),需要在代码里用向量差计算并归一化。
2.2 参数之间如何配合
很多人一上来就卡在参数上,觉得k_att、k_rep、rho0这些是玄学。其实它们之间有明确的关系可以推导。
先说rho0。这个参数决定了船“什么时候开始感知到危险”。rho0太大,船在离障碍物还有很远的地方就开始大幅绕行,路径非常丑陋;rho0太小,船要贴近障碍物才会有反应,但船有惯性、舵效有延迟,等你反应过来可能已经来不及转向了。
一个经验做法:rho0取船自身长度的一个倍数。比如仿真里船长 50 米,rho0设在 100 米上下,也就是两个船长左右。这样有充足的时间做转向机动。
再说k_att和k_rep的比值。我先算一个临界场景:当船位于目标点和障碍物连线的垂直方向上,并且刚好在rho0边界处,如果斥力刚好压过引力,船才会开始避让。但问题在于,船和目标点的距离会越来越近、引力线性减小;船和障碍物的距离越小、斥力按(1/rho - 1/rho0)/rho^2急剧增大。所以实操中不需要特别精确地配平两者,我一般先把k_rep设为k_att的 3 到 5 倍,再通过仿真观察路径调整。调参的最终目标是:船能在rho0距离内形成明显转向,又不至于“一碰到斥力圈就弹开”。
2.3 从合力到期望艏向角
合力的计算就是把所有斥力和引力向量相加:
F_total = F_att + sum(F_rep_i)然后期望艏向角为:
psi_des = atan2(F_total_y, F_total_x)这里有个要点:船沿合力方向走不等于直接对速度向量做代数运算,而是把这个角度作为期望艏向输入给操纵响应模型。仿真里最常用的是简单的“一阶舵效”模型,也就是艏向变化率正比于期望艏向与当前艏向的差:
psi_dot = (1/T) * (psi_des - psi)T是操纵惯性时间常数,物理意义是船对转向指令的响应快慢。T越小,船转向越敏捷,但机动幅度容易过大;T越大,船转弯越笨拙,可能来不及避开障碍物。我常用的取值范围是 5 到 15 秒,普通水面船取 8 秒左右,无人艇取 3 到 5 秒,根据你想模拟的对象选。
速度处理上,我默认船维持恒定航速。因为在开阔水面的避碰场景里,减速意味着失去控制力,很多时候保速转向更符合实际操船习惯。如果要做更精细的模型,可以把合力模长映射为速度修正系数,但那是后续扩展,不放进基础版本里。
3. MATLAB仿真实现:从初始化到单船避碰
3.1 环境与参数初始化
我先把仿真空间定在 500 米乘 500 米的水域里,本船从左侧中间出发,目标点在右侧,中间放两个圆形障碍物模拟孤岛或者静止的碍航物。以下是一段完整的初始化代码,我直接把注释写在里面,方便对行修改。
% 仿真基础参数 dt = 0.2; % 仿真采样周期,单位s total_time = 300; % 最大仿真时间,单位s step_num = total_time/dt; % 本船初始状态 p_boat = [50, 250]; % 初始位置 (x, y) psi = 0; % 初始艏向角,单位rad v = 5; % 航速,m/s,换算成节大约是10kn T_steer = 8; % 舵效惯性时间常数 % 目标点 p_goal = [450, 250]; % 障碍物(圆心坐标和半径) obs = [ 200, 200, 40; 300, 300, 35 ]; % 势场参数 k_att = 1.0; % 引力增益 k_rep = 5.0; % 斥力增益 rho0 = 100; % 斥力作用半径,单位m这几组参数我实测下来能跑出比较正常的路径,但你要记得根据自己的场景调整。障碍物越多,k_rep就该适当增大,因为多个障碍物的斥力会相互削弱。
3.2 势场计算函数
势场计算应当独立成一个函数。推荐用局部变量存船的位置向量,逐障碍物计算斥力,再把结果返回:
function F_total = potentialField(p_boat, p_goal, obs, k_att, k_rep, rho0) % 引力分量 F_att = k_att * (p_goal - p_boat); % 斥力分量 F_rep = zeros(1,2); for i = 1:size(obs, 1) obs_center = obs(i, 1:2); obs_r = obs(i, 3); rho_vec = p_boat - obs_center; rho = norm(rho_vec) - obs_r; if rho <= rho0 % 斥力方向:障碍物指向本船 direction = rho_vec / norm(rho_vec); magnitude = k_rep * (1/rho - 1/rho0) / (rho^2); F_rep = F_rep + magnitude * direction; end end F_total = F_att + F_rep; end这里有一个容易被忽略的细节:rho的计算一定要减去障碍物的半径。如果不减,船会把这个障碍物圆心当成“障碍表面”,导致船实际上离障碍物边缘还有几十米时就开始产生斥力,路径提前偏转。减去半径之后,斥力起作用的边界才是真实的危险边界。
3.3 主仿真循环
主循环的核心流程是:算合力、转期望艏向角、更新船的状态、记录轨迹。代码如下:
% 存储轨迹 trajectory = zeros(step_num, 2); traj_count = 0; for t = 1:step_num % 1. 计算合力 F_total = potentialField(p_boat, p_goal, obs, k_att, k_rep, rho0); % 2. 转为期望艏向 psi_des = atan2(F_total(2), F_total(1)); % 3. 一阶舵效模型更新艏向(注意角度wrap到[-pi, pi]) psi_delta = wrapToPi(psi_des - psi); psi = psi + (psi_delta / T_steer) * dt; % 4. 更新位置 p_boat = p_boat + v * [cos(psi), sin(psi)] * dt; % 5. 记录轨迹 traj_count = traj_count + 1; trajectory(traj_count, :) = p_boat; % 6. 判断是否到达目标或者超时 if norm(p_goal - p_boat) < 10 disp('到达目标点'); break; end end注意第 3 步的wrapToPi非常关键。如果不把角度差值转换到[-pi, pi],当艏向角从 179 度变到 -179 度时,系统会误判为需要转 358 度而不是 2 度,船会绕一个大圈甚至原地转圈。这是我见过新手上手最容易踩的坑。
3.4 可视化与调试辅助
仿真跑完,用两行代码就能画出轨迹和障碍物:
figure; hold on; axis equal; grid on; for i = 1:size(obs,1) viscircles(obs(i,1:2), obs(i,3), 'Color', 'k'); end plot(trajectory(1:traj_count,1), trajectory(1:traj_count,2), 'b-', 'LineWidth', 1.5); plot(p_goal(1), p_goal(2), 'r*', 'MarkerSize', 15); plot(50, 250, 'gs', 'MarkerSize', 10); legend('障碍物', '本船轨迹', '目标点', '起点');但静态轨迹图只能看最终效果,看不出动态调整过程。所以我建议调试阶段用animatedline实时画轨迹,同时在每个采样周期用quiver画合力箭头。箭头和船舶航向的夹角会告诉你很多信息:如果夹角很大,说明船在勉强转向,说明参数别扭。
4. 实操过程:跑通第一个多船避碰场景
4.1 场景设计
单船避障跑通之后,我把场景升级成稍微复杂的“多动船相遇”测试。我在水域里加了一条和目标航向垂直的匀速运动船,充当动态障碍物。它的运动很简单,就是固定航向、固定速度直线走。真实情况当然复杂得多,但作为验证 APF 动态避碰的初始场景,已经比静态障碍物有意义多了。
然后重点解释动态障碍物怎么引入:它的位置随时间变化,斥力函数不用改,只要每步传最新的位置进去就行。但要考虑一点:动态障碍物的运动趋势。如果船已经接近,而障碍物还在朝船的路径上运动,斥力场得“提前量”,否则等船反应过来,对方已经贴脸了。一个简单有效的方法是预测几步后的障碍物位置,用预测位置算斥力,而不是用当前时刻的位置。 ### 4.2 多船场景的关键改动 动态障碍物部署的代码大概是这样的: ```matlab % 动态障碍物初始参数 dyn_obs_pos = [300, 150]; dyn_obs_vel = [0, 3]; % 沿y轴向上移动,3m/s dyn_obs_r = 25; % 在循环里,先更新动态障碍物位置再计算势场 dyn_obs_pos = dyn_obs_pos + dyn_obs_vel * dt; obs_combined = [obs(:,1:2); dyn_obs_pos]; obs_radius = [obs(:,3); dyn_obs_r];在使用势场函数时,把静态障碍物和动态障碍物的圆心、半径合并成一个矩阵传给函数即可。注意动态障碍物的半径可以设得比实际稍大一点,这是给本船留出安全余量,也补偿了预测不准确带来的误差。
这里我强烈建议你在实时图窗里同时画出动态障碍物的圆心和它的“影响圈”,也就是以rho0为半径的圆。这样一来你就能直观看到:当影响圈开始和本船轨迹“相交”时,船就必须转向了。影响圈画法用viscircles设置透明度就能做到,别嫌丑,调试期效率很高。
4.3 我实际跑通的一个结果
我首次跑通时用的是静态双障碍物场景,k_rep=5,rho0=100。船从起点出发后很快向左偏去,因为它感知到第一个障碍物的斥力,然后回到目标方向。大概在 x=180 附近轨迹有一个明显的外凸弧线,这是斥力主导时的机动段;绕过之后船又切回直线,奔向目标。整个路径看起来是平滑的,没有锯齿,说明一阶舵效模型起到了平滑作用。
然后我把场景换成双障碍物加一条动态船,问题就来了:船明显“犹豫”了一下,轨迹出现一小段近乎停滞的弧线。这个现象多半是局部极小值,下一节专门讲。但总体来讲,仿真出来的航迹已经能体现避碰意图:路径贴着障碍物影响圈的边缘切过去,没有硬闯危险区,也没有绕远路。
5. 局部极小值与动态障碍的两大硬骨头
5.1 局部极小值为什么在船载APF里特别明显
经典APF有一个被吐槽无数次的缺陷:当引力与斥力大小相等、方向相反时,合力为零,运动体就卡住了。在机器人场景里,常常可以通过左右抖动走出来。但船不行,因为船的欠驱动特性导致它无法“横向逃逸”,而且艏向变化需要时间,一旦陷入极小值,它就真的悬停在那,直到你改参数或者加扰动。
一种工程解是“附加扰动法”:在检测到速度低于阈值且长时间未抵达目标时,人为给期望艏向叠加一个固定偏置,比如 45 度,强行破坏力的平衡。代码实现就是在主循环里加个计数器:
if speed < 0.5 && stuck_time > 5 psi_des = psi_des + pi/4; stuck_time = 0; end这个办法简单粗暴,但要注意扰动幅度不能大且持续时间要短,否则会矫枉过正,让船在障碍物附近“弹射”出去。
另一种更有工程味道的解法是“虚拟子目标法”。检测到极小值后,在距离当前点最近的障碍物旁边找一个安全的过渡点,强迫船先到那个过渡点,再恢复奔向原目标。优点是路径可控,缺点是过渡点规划本身要写一小段逻辑,增加复杂度。对毕设场景,扰动法足够用了。
5.2 动态障碍物要加预测,否则反应永远慢半拍
动态避碰的本质是:你必须预测对方几秒后会到哪,而不是它现在在哪。最简单的预测方式就是匀速外推:
p_pred_obs = p_obs + v_obs * t_horizont_horizon是预测时间。这个值怎么选?和船的操纵性直接相关。假设船T_steer=8、速度 5m/s,从感知到开始转向到真正产生横向位移,至少要3*T_steer秒,也就是 24 秒左右。所以预测时间至少取 20 秒才有意义,再短的话,船转向还没到位,障碍物已经撞上了。我把t_horizon设置在 20 到 30 秒之间,效果比较理想。
预测位置参与到斥力计算之后,船会在障碍物还没真正“占领”某个区域之前,就提前绕开。代价是路径可能比不预测更保守,但安全性明显上升。
还有一个更细的优化:预测时同时考虑本船的运动方向,使用“相对位置 + 相对速度”的斥力表达。也就是斥力不只取决于当前两点距离,还取决于本船逼近障碍物的速率。比如:
rho_rel = rho - dot(v_boat - v_obs, rho_vec/norm(rho_vec)) * t_horizon用这个修正距离替代原来的rho去算斥力,船会对着“正在靠近的障碍物”产生更强反应。这个改法在真实水域测试里效果很好,但因为改动偏多,我这里只提示思路,基础版本不需要引入。
5.3 避碰规则的引入思路
如果希望仿真更贴近真实海域的避碰场景,可以把基本的会遇局面规则做成规则层,叠加在势场法之上。比如“对遇局面”要求各自向右转向,那在检测到目标船位于本船船艏方向 ±5 度范围内且距离在阈值以内时,把期望艏向强制偏到右舷一定角度;再比如本船与目标船交叉相遇时,要给过路船让路。这部分属于上层规则,势场仍然负责底层的“躲避控制”,两者结合是当前工程界最常见的技术路线。
6. 常见问题与排查技巧实录
我实际调试这套仿真,踩过的坑能列一个目录。下面把最典型的几个问题整理成排查速查表,附上我自己用的解决方式。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 船在目标点附近来回摆动,始终停不下来 | 目标点附近引力趋近零,但斥力还在作用,合力方向来回跳 | 加目标到达阈值,进入阈值后不再计算斥力,直接关停仿真 |
| 路径剧烈锯齿,船左右横摆 | 采样步长过大或合力方向突变 | 减小dt到 0.1s,或对期望艏向做一阶低通滤波 |
| 船绕行非常远,路径丑 | rho0过大,斥力过早作用 | 把rho0缩小到船长 2 倍左右,重新观察 |
| 船在障碍物附近明显停滞 | 局部极小值 | 加扰动法或虚拟子目标法 |
| 动态障碍物场景中船总是“擦边”撞上 | 预测时间太短或动态障碍物影响圈太小 | 把t_horizon增大到 20s 以上,同时把动态障碍物半径加大 10-20% |
| 仿真运行很卡,实时绘图严重影响速度 | 每步都重新创建图形对象 | 只在第一次循环创建句柄,后续用set更新数据,或者隔 N 步一画 |
| 艏向角在接近 ±180 度时突然旋转一大圈 | 角度差没有 wrap | 每次算角度差都用wrapToPi处理 |
关于绘图卡顿的问题,我给个优化模板。旧代码里每步都plot一次轨迹,MATLAB 会被反复创建 Graphics 对象拖垮。正确做法是:
% 初始化时创建一次 traj_line = animatedline('Color', 'b', 'LineWidth', 1.5); % 循环中每步只做 addpoints addpoints(traj_line, p_boat(1), p_boat(2)); drawnow limitrate;drawnow limitrate的意思是限制刷新率,没必要每一帧都重绘界面。通常 5 到 10Hz 的刷新就够观察了,仿真本身的数值计算不会被拖累。
还有一种调试技巧:在势场计算函数里临时返回单个障碍物的斥力向量,用quiver画出来。调参的时候你会直观看见每个障碍物到底对船施加了多大“推力”,而不是只盯着最终轨迹猜参数。这个技巧帮我筛掉过至少两个看似合理但实际路径很糟糕的参数组合。
7. 进一步扩展方向
我做完这套基础仿真之后,顺手做了两个扩展,都非常值得尝试。
第一个是把速度也纳入控制。基础版里船是恒定航速,但真实避碰应当允许减速。做法很简单:用合力模长和目标距离距离联合决定速度指令,比如当检测到前方障碍物距离小于 1.5 倍rho0时,把速度线性降到 30%。但要注意减速会引起舵效下降,所以减速和转向的脚步要配合好,不要一下子刹得方向都转不动。
第二个是用 MMG 模型替换这个一阶舵效模型。MMG 是更精细的船舶操纵运动模型,考虑了船体水动力、螺旋桨、舵的综合作用。替换后仿真会更贴近真实船舶运动学,尤其在高海况或低速机动场景下,优势非常明显。缺点是模型参数多、标定困难,而且状态量从两三个变成十几个,代码量会翻几倍。基础验证用一阶模型足够,做深入研究再上 MMG。
如果要从仿真走向实船部署,还可以尝试 MATLAB Coder 把避碰核心函数转成 C++。我转过一次,最麻烦的不是代码本身,而是矩阵库和动态内存管理的适配。建议整个工程从一开始就保证所有函数输入输出都是定长的数组或者结构体,避免在生成代码时踩坑。
最后再分享一个调参的小经验:我调k_rep和rho0永远是一个一个动的,绝不同时改两个参数。先固定rho0,调k_rep观察弹开程度;再固定k_rep,调rho0观察起转时机。这样改坏了也知道是谁的问题,不至于把参数全部打乱了重来。这套朴素的方法我用了很多年,效率比盲目试凑高太多。希望这篇文章能帮你少走一些弯路,快速跑通你自己的避碰仿真。