简介:面向船舶控制算法验证的3D运动仿真软件及完整C++工程,基于Qt开发,适用于船舶海洋工程、自动化、控制工程等专业学生与研发人员,用于动力定位、最优艏向控制、模型预测控制等多种策略的研究与对比验证。环境建模采用船舶统一模型,包含风浪流扰动影响,可直接模拟普通动力定位与最优动力定位工况。压缩包共41个文件,以18个C++源文件和18个头文件为核心,同时包含Qt界面文件(.ui)、工程配置(.pro)、船舶模型数据(s175.mat)以及项目结构说明表格(.xlsx),整体仅1.18MB,代码结构清晰,便于按模块阅读与二次开发。目前已有320人学习下载;代码已测试通过,可在Qt环境中编译运行,下载后可结合说明表格快速定位控制模块,适合在此基础上修改控制策略,用于毕设、课程设计或科研预研。
1. 为什么用 3D 仿真做船舶动力定位验证
一条动力定位船要在海上保持位置,靠的不是单个推进器,而是风、浪、流同时作用在船体上之后,控制器算出来的多推力器合成向量。真实海试要等天气窗口,水池实验按小时计费,所以研究阶段最常见的做法是在仿真环境里先把控制策略跑透。这个项目就是从这个痛点切入的:用 Qt/C++ 写的 3D 船舶运动仿真软件,工程名 ShipControl3D,内置普通动力定位、环境最优动力定位、最优艏向动力定位三条控制链路,控制器覆盖 PID 和模型预测控制(NMPC),环境模型按船舶统一模型组织,并附带 s175.mat 船模水动力参数。
它最直接的吸引力在于把算法和可视化放进同一个工程里。很多控制代码在 Simulink 里调得不错,换到 C++ 工程后还要重新处理数据结构、渲染循环和时间步调度。这个工程把这些都写在一个ShipControl3D.pro里,编译起来就能从界面切换控制模式、观察船在风浪中的姿态,很适合船舶控制研究、自动化专业毕设,以及刚接触动力定位的工程师拿来做控制器快速验证。
2. 模块拆分与数据流:从 ShipControl3D.pro 看工程组织
拿到源码第一件事,我一般会先打开ShipControl3D.pro,而不是mainwindow.cpp。qmake 工程文件把编译入口、源文件列表和依赖范围一次列全,从文件归属能直接看出作者对模块边界的想法。下面是这种工程里很典型的配置:
QT += core gui opengl greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = ShipControl3D TEMPLATE = app SOURCES += main.cpp mainwindow.cpp ShipControl.cpp \ ShipModel.cpp ShipGraph.cpp ShipParameter.cpp \ Wind.cpp Wave.cpp Current.cpp EnvObserver.cpp \ PIDController.cpp NMPCcontroller.cpp OptController.cpp WOPC.cpp \ PlotData.cpp Filter.cpp Tool.cpp HEADERS += mainwindow.h ShipControl.h ShipModel.h ShipGraph.h \ ShipParameter.h Wind.h Wave.h Current.h EnvObserver.h \ PIDController.h NMPCcontroller.h OptController.h WOPC.h \ PlotData.h Filter.h Tool.h DataStruct.h FORMS += OptionDialog.ui注意这里的分组逻辑:ShipControl是主控制循环,ShipModel负责船体运动学,ShipGraph负责绘制,EnvObserver及其下面的Wind/Wave/Current统一提供环境输入,PIDController/NMPCcontroller/OptController/WOPC一组是控制器,PlotData专做数据记录,OptionDialog.ui是参数面板。这种按职责划分的方式,比把所有模块塞进一个mainwindow.cpp好维护得多。后面改控制策略时,不用碰渲染代码。
2.1 数据结构:一个结构体走完一个控制周期
不同模块之间怎么传数据,是这个工程能否扩展的关键。DataStruct.h里定义的三个核心结构体,相当于整个仿真的“通讯协议”。我拿到这类工程时,会先看这三个结构体:
// DataStruct.h 中的核心数据定义 struct Environment { double wind_speed; // 风速 m/s double wind_direction; // 来流方向,北偏角 deg double wave_hs; // 有义波高 m double wave_tp; // 谱峰周期 s double current_speed; // 流速 m/s double current_direction;// 流向 deg }; struct ShipState { double x; // 北向位置 m double y; // 东向位置 m double psi; // 艏向角 rad double u; // 纵向速度 m/s double v; // 横向速度 m/s double r; // 艏摇角速度 rad/s }; struct ControlCmd { double fx; // 纵向推力 N double fy; // 横向推力 N double n; // 转艏力矩 Nm int mode; // 0: PID, 1: NMPC, 2: Opt };这三个结构体把所有关键信息压缩成了当前时刻的一张快照:环境观测器输出Environment,动力学模型更新ShipState,控制器返回ControlCmd。如果后续要引入多线程,把这几个类型改成std::shared_ptr<const T>就能减少锁竞争;当前工程是单线程循环,直接传引用没有问题。
2.2 UI 触发控制模式的调用链
交互入口在mainwindow.cpp和OptionDialog.ui里,核心逻辑是:用户在界面上选择控制模式、填入风浪流参数,然后按启动按钮,定时器开始周期性触发ShipControl::step()。在这个step里,先更新EnvObserver,再调用当前模式对应的控制器,最后把推力交给ShipModel推进船体状态。
这里我把各模块的调用关系整理成一张表,便于快速定位修改点:
| 文件组 | 职责 | 在仿真循环里的时机 |
|---|---|---|
mainwindow.cpp/OptionDialog.ui | 界面、参数录入、模式选择 | 启动前配置 |
ShipControl.cpp | 仿真循环主调度 | 每步最先调用 |
EnvObserver.cpp | 汇总风浪流环境输入 | 控制器解算前 |
Filter.cpp | 平滑传感器观测信号 | 状态反馈前 |
PID/NMPC/Opt/WOPC | 计算控制指令 | 环境输入之后 |
ShipGraph.cpp/PlotData.cpp | 渲染与数据记录 | 状态更新之后 |
如果你只是想快速跑起来,入口就在mainwindow.cpp的startSimulation槽函数里,它启动一个定时器,定时触发ShipControl::step()。所有控制参数都通过OptionDialog.ui传入,不需要去源码里翻硬编码。
3. 风、浪、流分项建模:从 Wind/Wave/Current 到 s175.mat
船舶统一模型的核心思想是:把风、浪、流产生的力统一折算到船体重心,在同一个坐标系下和推力器出力叠加,再代入运动方程求解。这个模型在工程里是否可靠,取决于环境分项能不能解耦清楚。Wind.cpp、Wave.cpp、Current.cpp三个模块就是干这件事的。
3.1 s175.mat 参数怎么读进 C++ 工程
s175.mat是 MATLAB 格式的船模数据,Qt 工程里一般不会直接链接 MATLAB 库。常见做法是先在 MATLAB 里把矩阵导成 CSV,再让ShipParameter.cpp读取。导出命令很简单:
load('s175.mat') % 假设变量名是 mass, izz, x_du, y_dv, n_dr 等 params = [mass, izz, x_du, y_dv, n_dr]; writematrix(params, 's175_params.csv');然后在 C++ 端读取:
// ShipParameter.cpp void LoadShipParameter(const char* csvPath) { std::ifstream ifs(csvPath); if (!ifs.is_open()) { qWarning() << "ship parameter file open failed"; return; } std::string line; while (std::getline(ifs, line)) { if (line.empty()) continue; std::stringstream ss(line); ss >> raw.mass >> raw.izz >> raw.x_du >> raw.y_dv >> raw.n_dr; // raw 内部字段含义: // mass -> 船体质量,izz -> 艏摇转动惯量 // x_du / y_dv / n_dr 分别是三个方向上的附加质量系数 } }注意这个文件一旦读取失败,后续ShipModel拿到的就是零矩阵。调试时如果发现船完全不动,先看控制台有没有ship parameter file open failed这一行。s175是 175k 载重吨级别船模的常用缩写,项目中把尺度效应相关的阻尼系数都放在了这个文件里,所以不要轻易替换成别的船型数据,除非你同步修改ShipParameter.h中的参数映射。
3.2 波浪模型简化:一阶滤掉,二阶留下
波浪力对船的影响分为两类:一阶波浪力频率高,会引起船体周期性振荡,动力定位控制器如果直接跟踪这种高频分量,推力器会被快速磨损;二阶波浪漂移力变化慢,才是真正把船推出目标位置的原因。Wave.cpp里通常会做一个低通处理。
// Wave.cpp 中常见的低频波浪漂移力简化表达 double LowFrequencyDrift(double Hs, double Tp, double wave_angle, double dt) { // Hs: 有义波高,单位 m;Tp: 谱峰周期,单位 s // 经验式:F_drift = 0.5 * rho * g * L * Hs^2 * Cw * cos(wave_angle) double Cw = 0.18; // 漂移力系数,船型相关 double F = 0.5 * 1025.0 * 9.81 * 175.0 * Hs * Hs * Cw; F *= cos(wave_angle * M_PI / 180.0); // 用 15s 时间常数滤掉一阶波浪振荡 lowPassState += (F - lowPassState) * dt / 15.0; return lowPassState; }这里Cw是波浪漂移力系数,s175 船模如果没有实验数据,初值取 0.15 到 0.22 之间问题不大。15 秒的低通时间常数也很关键:如果选得太小,滤波后的漂移力还有明显振荡;选得太大,船舶对风浪变化的响应会滞后。我一般会先把波周期设为 8s,观察推力曲线,如果出现高频抖振,就把时间常数往 20s 方向调。
3.3 风力和流力:相对速度必须重算
风力计算最容易犯的错误是把绝对风速直接带入公式。船在运动,控制器看到的是相对风速和相对风向。Wind.cpp里的处理方式是先把环境风速转换到船体坐标系,再乘以风载荷系数。
// Wind.cpp 部分实现 void WindForce(double windSpeed, double windDir, double psi, double& fx, double& fy, double& mn) { double alpha = (windDir - psi) * M_PI / 180.0; double Vwx = windSpeed * std::cos(alpha); double Vwy = windSpeed * std::sin(alpha); double Cx = WindCx(alpha); // 纵向风载荷系数 double Cy = WindCy(alpha); // 横向风载荷系数 double Cn = WindCn(alpha); // 艏摇风力矩系数 fx = 0.5 * 1.225 * Vwx * Vwx * 3200.0 * Cx; fy = 0.5 * 1.225 * Vwy * Vwy * 800.0 * Cy; mn = 0.5 * 1.225 * Vwy * Vwy * 175.0 * 800.0 * Cn; }3200和800在这里分别代表船的正面受风面积和侧面受风面积,175是船长。如果以后要换船型,这三个面积参数和风载荷系数表要一起换。流力的处理方式类似,只不过流体密度换成1025 kg/m^3,并且速度要用“船速相对流速”的差值。
三个模块最终在EnvObserver里汇总成一个Environment结构体,再交给控制器。我习惯把风浪流的输出单独存储成三维数组,方便在PlotData里回放对比,这样能直观看到某个控制器动作是由哪一种环境力触发的。
4. PID、最优控制和 NMPC:控制环怎么搭才不会漂
控制策略是这个仿真的核心,也是三个模式切换的关键。普通动力定位直接保持位置和艏向;环境最优动力定位会在保持位置的同时寻找一个使推力最小的艏向;最优艏向动力定位则可以理解为“不硬顶环境力,而是顺着环境力方向找平衡点”。源码里对应的PIDController、OptController、WOPC和NMPCcontroller各有分工。
4.1 三种控制策略的分工
在接算法之前,先看清这几种策略的目标差异,才不会搭错控制环:
| 控制策略 | 控制目标 | 输出变量 | 典型场景 |
|---|---|---|---|
| 普通动力定位 | 固定 (x, y, psi) | fx, fy, n | 定点定位、铺管、钻井 |
| 环境最优动力定位 | 位置固定,艏向可优化 | fx, fy, n, psi_ref | 强风强流海域节能 |
| 最优艏向动力定位 | 跟踪动态最优艏向 | fx, fy, n | 综合航行定位 |
OptController做的事,简单说就是不断计算当前环境合力方向,然后给出一个让合力尽可能少的艏向角。WOPC.cpp在这个基础上做了更细的约束,例如艏向变化率限制,避免船舶频繁转头。
4.2 PID 离散化:带抗积分饱和和微分滤波
PID 实现看起来简单,但直接照搬连续式 PID 会在数字仿真里抖得厉害。工程里更稳的写法是加入抗积分饱和,并对微分项做限制:
// PIDController.cpp double PIDController::Update(double setpoint, double y, double dt) { double err = setpoint - y; if (std::abs(err) < deadZone) err = 0.0; // 设置死区,减少推力器磨损 integral += err * dt; integral = std::clamp(integral, -integralLimit, integralLimit); // 抗积分饱和 double diff = (err - lastError) / dt; lastError = err; double out = kp * err + ki * integral + kd * diff; return std::clamp(out, -outLimit, outLimit); }关键在integralLimit。推力器的物理出力有限,积分项如果无限累积,会产生“先大幅超调再往回拉”的现象。我一般把积分限幅设为推力上限的百分之二十,outLimit则直接对应推进器的饱和推力。微分项在 3D 渲染帧率不稳定时容易引入噪声,如果发现输出的推力曲线毛刺明显,优先给微分项加低通滤波,而不是调小kd。
4.3 NMPC:预测模型、代价函数和约束
NMPC 比 PID 更适合处理多输入多输出和推力饱和约束。它每一次求解都要做三件事:预测未来 N 步的状态、计算代价函数、在约束下求最优控制序列。NMPCcontroller.cpp里的代价函数通常是这种形式:
// NMPCcontroller.cpp 代价函数片段 double CostFunction(const std::vector<ControlCmd>& u, const std::vector<ShipState>& y_pred, const ShipState& y_ref) { double cost = 0.0; for (int k = 0; k < N; ++k) { auto dx = y_pred[k].x - y_ref.x; auto dy = y_pred[k].y - y_ref.y; auto dpsi = y_pred[k].psi - y_ref.psi; // Q 矩阵加权位置偏差,R 矩阵惩罚推力,S 矩阵惩罚推力变化率 cost += dx * dx * Q(0, 0) + dy * dy * Q(1, 1) + dpsi * dpsi * Q(2, 2); cost += u[k].fx * u[k].fx * R(0, 0) + u[k].fy * u[k].fy * R(1, 1); cost += (u[k].fx - u[k-1].fx) * (u[k].fx - u[k-1].fx) * S(0, 0); cost += (u[k].fy - u[k-1].fy) * (u[k].fy - u[k-1].fy) * S(1, 1); } return cost; }参数调整顺序非常影响效果。常见做法是先把预测步数N设小一点,比如 10,确认实时性没问题后再增加到 20 或 30。Q矩阵决定控制器对位置误差的敏感度,R矩阵决定它对推力大小的在意程度。如果船在目标点附近反复振荡,往往是R太小导致推力变化太剧烈,而不是Q不够大。S矩阵则负责限制推力增量,数值过小时,NMPC 输出会像 PID 的微分项一样抖动。
4.4 从 PID 到 NMPC 的切换技巧
从纯控制理论切换过来时,我建议先别直接上 NMPC。先跑一遍 PID,把PlotData记录的位置和推力导出,再让 NMPC 的初始控制序列从 PID 输出的历史轨迹开始滚动优化。这样能避免 NMPC 在第一步就撞上“初始解不满足推力约束”的问题。实践中还有一个更省事的办法:先关闭 3D 渲染,只跑ShipControl::step()和PlotData::Record(),确定控制曲线曲线稳定后再打开ShipGraph。
OptController和WOPC的调试思路不同,它们不直接调推力权重,而是调环境力预测的平滑程度。因为最优艏向角是根据风浪流合力方向算出来的,如果环境观测没有滤波,艏向就会跟着波浪高频抖动。所以调试顺序是:先让EnvObserver输出平滑的环境合力,再观察OptController给出的艏向角是否稳定,最后才去调 NMPC 的权重矩阵。
5. 把 NMPC 和 3D 场景接在一起时,先做离线回放
3D 画面很有吸引力,但调试 NMPC 时,第一件事应该是关掉 3D,先看数据曲线。渲染窗口会掩盖数值发散:NMPC 某一步求解失败时,画面上可能只是船轻微抖了一下,而控制量曲线早就冲到了推力限幅。所以PlotData模块在这个工程里不是附属品,而是验证控制器是否正常的核心工具。
5.1 用固定时间步长驱动仿真和绘图
控制周期和渲染帧率必须解耦。我一般把仿真步长固定在 50ms 或 100ms,然后用QTimer触发每一步,ShipGraph只负责每帧重绘,不参与动力学计算。这样即使画面卡顿,仿真逻辑也不会丢步。
// mainwindow.cpp 仿真定时器 QTimer simTimer; connect(&simTimer, &QTimer::timeout, this, [&]() { ShipControl::Instance().Step(0.05); // 固定步长 50ms PlotData::Record(ShipControl::Instance().State()); shipGraph->update(); // 只是通知 Qt 重绘 }); simTimer.start(50);这段代码的关键在Step和update分离。Step里包含环境更新、控制求解、船体运动推进,必须精确按照固定步长走;shipGraph->update()则会被 Qt 合并到刷新周期里,与其卡住仿真,不如让画面掉帧。你可以用这种方式快速测出 NMPC 的求解耗时:如果Step本身的耗时超过 50ms,定时器会连续触发,画面开始卡顿,这时候第一步要减小 NMPC 的预测步数,而不是升级显卡。
5.2 在画面上叠加环境合力矢量线
等 NMPC 曲线基本稳定后,再打开 3D 场景验证控制效果。这里有个很实用的技巧:在ShipGraph的绘制函数里,用两条不同颜色的线段分别表示环境合力与推力合力。环境合力把风、浪、流三部分的力和力矩投影到同一个方向,推力合力则由当前控制指令算出来。两条线重合时,说明当前艏向正好处在“借环境力定位”的平衡点。
// ShipGraph.cpp 中的绘制片段 void ShipGraph::paintGL() { // 先画船体网格和波浪平面 DrawShip(); DrawEnvironment(); // 用红色画环境合力方向 DrawArrow(originX, originY, envForceX, envForceY, QColor(220, 80, 80)); // 用蓝色画推力器合力方向 DrawArrow(originX, originY, cmdFx, cmdFy, QColor(80, 130, 220)); }这个技巧不需要改控制器,就能让环境最优动力定位的“最优艏向”一眼可见。如果两条线夹角长期大于 30 度,说明OptController或WOPC给出的艏向仍不是最优,需要检查风力系数表和滤波时间常数。调试 NMPC 时,我还会把预测轨迹最近 5 秒的路径点也画成一条灰色曲线,和实际轨迹对比。如果预测轨迹和实际轨迹在 10 秒内就开始分离,说明船体模型参数或者外部扰动模型不一致。此时优先校准ShipParameter.cpp里的阻尼系数,而不是继续调 NMPC 的权重,校准后再回放PlotData,直到两条轨迹在动态变化中保持贴合。
本文还有配套的精品资源,点击获取