ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ Qt6 雷达模拟器实战(四):使用 QPainter 绘制 PPI 雷达界面

C++ Qt6 雷达模拟器实战(四):使用 QPainter 绘制 PPI 雷达界面 前面三篇分别介绍了 RadarSimulator 的项目架构、TCP 通信以及雷达目标和坐标模型。到了这一篇前面的数据终于要真正“画”到屏幕上了。整个显示链路可以概括为RadarTarget / RadarTrack ↓ Range / Azimuth ↓ 雷达坐标 ↓ Qt 屏幕坐标 ↓ RadarWidget::paintEvent() ↓ QPainter ↓ PPI 雷达界面这一篇主要介绍为什么单独设计RadarWidgetpaintEvent()和QPainterPPI 圆盘、距离环和方位刻度扫描线与QConicalGradient目标和航迹绘制雷达坐标到屏幕坐标的映射动态刷新和绘制顺序一、为什么单独设计 RadarWidget如果把所有绘图代码都直接写进MainWindow小项目当然也能运行。但随着功能增加MainWindow很容易同时承担按钮事件 TCP 状态 表格刷新 参数控制 雷达绘图这样界面逻辑会越来越混乱。因此 RadarSimulator 把 PPI 显示区域独立封装成RadarWidget它的职责非常明确读取当前雷达数据并负责 PPI 图形绘制。这样项目结构变成MainWindow ↓ 负责整个界面组织 RadarWidget ↓ 负责雷达可视化图1 MainWindow 负责界面组织RadarWidget 专门负责 PPI 绘制二、Qt 自定义绘图的核心paintEvent() 与 QPainterQt 自定义控件绘图的入口通常是voidRadarWidget::paintEvent(QPaintEvent*event)当控件需要重新显示时Qt 会自动调用paintEvent()。例如窗口第一次显示窗口尺寸变化控件重新暴露程序调用update()RadarSimulator 的动态扫描本质上就是更新 scanAngle 更新目标 / 航迹数据 ↓ 调用 update() ↓ Qt 触发 paintEvent() ↓ 重新绘制一帧而真正负责绘图的是QPainterpainter(this);常用函数包括drawEllipse()drawLine()drawText()drawPoint()drawPath()所以整个 PPI 本质上并不是某个“雷达专用控件”而是由大量基础二维图元组合出来的。三、PPI 界面实际上是多个图层叠加一个完整的 PPI 可以拆成几个绘制层背景 ↓ 雷达圆盘 ↓ 距离环 ↓ 十字线与方位刻度 ↓ 扫描波束 ↓ 历史航迹 ↓ 当前目标 ↓ 文字信息绘制顺序很重要因为QPainter后画的内容会覆盖先画的内容。例如扫描波束通常带半透明效果如果目标先画、扫描波束后画目标可能会被渐变遮住。因此更加合理的顺序是先画背景和网格再画扫描效果最后画航迹、目标和文字。图2 PPI 画面由背景、网格、扫描波束、航迹和目标等多个图层组成四、雷达圆盘、中心和距离环绘制前首先需要确定 PPI 的中心和半径。假设控件大小为宽度 W 高度 H雷达中心可以写成cxW2c_x\frac{W}{2}cx​2W​cyH2c_y\frac{H}{2}cy​2H​PPI 半径可以根据控件较短的一边计算R≈min⁡(W,H)2R\approx\frac{\min(W,H)}{2}R≈2min(W,H)​实际绘图时一般还会预留一定边距。然后通过painter.drawEllipse(...)绘制最外层雷达圆盘。距离环为了表达不同目标距离PPI 内部通常还会绘制多个同心圆例如25% 50% 75% 100%假设maxRange 5000 m那么可以对应1250 m 2500 m 3750 m 5000 m目标真实距离和屏幕半径之间采用线性映射rscreenrangemaxRange×Rr_{screen}\frac{range}{maxRange}\times Rrscreen​maxRangerange​×R例如maxRange 5000 m range 2500 m R 300 px那么rscreen25005000×300150 pxr_{screen}\frac{2500}{5000}\times300150\text{ px}rscreen​50002500​×300150px所以一个距离雷达 2500 m 的目标会出现在 PPI 半径的一半位置。图3 真实雷达距离按照比例映射到 PPI 圆盘半径五、方位刻度和雷达坐标RadarSimulator 采用的雷达方位角约定是0° 上方 90° 右方 180° 下方 270° 左方因此 PPI 最基础的参考线是0° | 270° ------------ 90° | 180°十字线可以直接使用drawLine()绘制。进一步还可以在圆周上增加大刻度小刻度方位角数字如果某个角度为θ那么圆周上的位置可以通过xcxRsin⁡θxc_xR\sin\thetaxcx​Rsinθycy−Rcos⁡θyc_y-R\cos\thetaycy​−Rcosθ计算。这里 Y 坐标使用减号是因为 Qt 的屏幕坐标系中x 向右 y 向下和普通数学坐标系的 Y 轴方向相反。六、从雷达坐标转换到屏幕坐标RadarTarget中保存的通常是Range Azimuth而QPainter最终需要的是screenX screenY因此首先需要计算目标在 PPI 中的径向距离rsrangemaxRange×Rr_s\frac{range}{maxRange}\times Rrs​maxRangerange​×R然后把极坐标转换为屏幕坐标xscxrssin⁡θx_sc_xr_s\sin\thetaxs​cx​rs​sinθyscy−rscos⁡θy_sc_y-r_s\cos\thetays​cy​−rs​cosθ例如Azimuth 90°因为sin 90° 1 cos 90° 0所以目标位于 PPI 正右方。如果Azimuth 0°则目标位于 PPI 正上方。这套关系不仅可以用于目标也可以用于航迹点扫描线终点刻度文字位置七、扫描线和扫描波束雷达扫描方向由scanAngle表示。扫描线终点可以通过xcxRsin⁡(scanAngle)xc_xR\sin(scanAngle)xcx​Rsin(scanAngle)ycy−Rcos⁡(scanAngle)yc_y-R\cos(scanAngle)ycy​−Rcos(scanAngle)得到。随后使用painter.drawLine(center,endPoint);即可绘制扫描线。随着scanAngle不断变化扫描线会围绕 PPI 中心旋转。为什么不能只画一根线如果只画一根很细的绿色线视觉效果会比较生硬。所以 RadarSimulator 还加入了带透明度变化的扫描扇区扫描方向最亮 ↓ 向后逐渐变暗 ↓ 最终完全透明Qt 中非常适合完成这种效果的类是QConicalGradient它可以围绕某个中心点产生角度渐变。基本思路如下PPI 中心 ↓ 建立 QConicalGradient ↓ 设置多个颜色节点 ↓ 构造 QBrush ↓ 填充雷达圆盘图4 QConicalGradient 可以生成围绕雷达中心旋转的渐变扫描扇区这里需要注意雷达方位角定义和 Qt 渐变角度定义不一定完全一致。因此实际绘制时经常需要一个角度偏移甚至进行正负方向转换才能让扫描线与扫描渐变完全对齐。调试时最简单的方法是先只画扫描线确认方向正确再调整渐变方向。八、目标和航迹怎么绘制完成坐标转换以后一个目标最终就变成了屏幕上的(x, y)可以直接使用painter.drawEllipse(...)绘制一个目标点。同时可以通过drawText()显示T1 T2 T3或者进一步显示Range Azimuth Speed不过 PPI 信息过多会影响可读性因此通常只保留最重要的信息。航迹单个目标点只能表示目标当前在哪里。如果保存最近一段时间的位置例如P1 P2 P3 P4 当前位置就可以依次连接P1 → P2 → P3 → P4 → Target形成航迹。实现上可以循环调用drawLine()也可以构建QPainterPath统一绘制。图5 历史目标位置连接后形成目标航迹九、为什么历史轨迹不能无限保存如果每次刷新都保存目标位置而且永远不删除运行时间越长 ↓ history 越大 ↓ 绘制数据越来越多最终既会增加内存使用也会提高绘制负担。因此 RadarSimulator 对历史轨迹长度进行了限制。例如只保留最近20个位置点。也就是新位置加入 ↓ 超过上限 ↓ 删除最旧位置这样既可以保留目标运动趋势又不会让航迹无限增长。十、动态刷新QTimer、update() 和 paintEvent()PPI 要动起来需要不断更新状态。RadarSimulator 的基本机制可以理解为QTimer ↓ 定时触发 ↓ 更新 scanAngle / 目标状态 ↓ 调用 update() ↓ Qt 安排重绘 ↓ paintEvent()这里需要注意update();并不是直接手工调用paintEvent();而是告诉 Qt这个控件需要重新绘制。Qt 会在合适的时机统一处理。刷新频率和扫描速度不是一回事例如刷新频率 50 Hz表示每秒大约绘制 50 帧。而scanSpeed表示扫描角度变化得有多快。两者分别控制刷新频率 → 动画平滑程度 scanSpeed → 扫描线旋转速度如果刷新频率过低扫描线会明显跳跃如果刷新频率过高又会产生不必要的 CPU 开销。对于桌面雷达模拟器来说几十 Hz 的刷新通常已经足够形成连续动画。十一、paintEvent() 只负责显示不负责业务这是整个绘图模块里一个非常重要的原则。paintEvent()最好只负责读取当前状态并绘制。而不是在里面大量修改雷达目标航迹TCP 状态扫描状态否则就可能出现只有界面重绘 ↓ 目标状态才变化这样业务逻辑就会错误地依赖 GUI 刷新频率。更加合理的结构是QTimer / RadarServer ↓ 更新业务数据 ↓ update() ↓ paintEvent() ↓ 只负责绘图同时PPI 和目标信息表格也应该读取同一份目标数据RadarTarget / RadarTrack │ ├── RadarWidget → PPI │ └── MainWindow → Table这样可以避免PPI 显示一个位置而表格显示另一个位置。十二、完整 PPI 绘制流程到这里整个过程就可以串起来了RadarServer ↓ 更新 RadarTarget / RadarTrack ↓ update() ↓ RadarWidget::paintEvent() ↓ QPainter ↓ 背景 ↓ 距离环 ↓ 十字线和刻度 ↓ 扫描波束 ↓ 历史航迹 ↓ 目标 ↓ 文字 ↓ 最终 PPI图6 雷达数据经过坐标映射和多层绘制最终形成 PPI最后再看实际程序运行效果。图7 RadarSimulator 实际运行时的 PPI 雷达界面十三、本文小结这一篇主要解决了一个问题前面已经计算好的雷达数据最终怎么变成屏幕上的 PPI。整个核心流程是Range / Azimuth ↓ 比例缩放 ↓ 雷达坐标 ↓ Qt 屏幕坐标 ↓ QPainter ↓ 圆盘 / 网格 / 扫描 / 目标 / 航迹其中最重要的数学关系是目标距离映射到屏幕半径rsrangemaxRange×Rr_s\frac{range}{maxRange}\times Rrs​maxRangerange​×R目标屏幕 X 坐标xscxrssin⁡θx_sc_xr_s\sin\thetaxs​cx​rs​sinθ目标屏幕 Y 坐标yscy−rscos⁡θy_sc_y-r_s\cos\thetays​cy​−rs​cosθ在绘图层面drawEllipse()负责雷达圆盘、距离环和目标drawLine()负责网格、刻度和扫描线QConicalGradient负责扫描波束渐变QPainterPath / drawLine()负责历史航迹update()负责让不断变化的数据变成连续动画。最终得到数据更新 ↓ 请求重绘 ↓ paintEvent() ↓ 新的 PPI 画面十四、下一篇预告下一篇进入整个 RadarSimulator 系列的最后一部分《C Qt6 雷达模拟器实战五TCP、雷达数据与 Qt 图形界面的完整整合》前四篇已经分别解决了项目怎么组织 ↓ TCP 命令怎么进入 ↓ 雷达数据怎么算 ↓ PPI 怎么绘制第五篇会把这些模块重新连接起来。从客户端发送START_SCAN MOVE STOP_SCAN开始一直追踪到QTcpSocket ↓ LineBuffer ↓ LineProtocol ↓ RadarServer ↓ RadarTarget ↓ RadarWidget ↓ QPainter ↓ PPI最终回答一条 TCP 命令是怎样一步一步变成雷达界面变化的。
返回列表