前面三篇分别介绍了 RadarSimulator 的项目架构、TCP 通信,以及雷达目标和坐标模型。
到了这一篇,前面的数据终于要真正“画”到屏幕上了。
整个显示链路可以概括为:
RadarTarget / RadarTrack ↓ Range / Azimuth ↓ 雷达坐标 ↓ Qt 屏幕坐标 ↓ RadarWidget::paintEvent() ↓ QPainter ↓ PPI 雷达界面这一篇主要介绍:
- 为什么单独设计
RadarWidget paintEvent()和QPainter- PPI 圆盘、距离环和方位刻度
- 扫描线与
QConicalGradient - 目标和航迹绘制
- 雷达坐标到屏幕坐标的映射
- 动态刷新和绘制顺序
一、为什么单独设计 RadarWidget
如果把所有绘图代码都直接写进MainWindow,小项目当然也能运行。
但随着功能增加,MainWindow很容易同时承担:
按钮事件 TCP 状态 表格刷新 参数控制 雷达绘图这样界面逻辑会越来越混乱。
因此 RadarSimulator 把 PPI 显示区域独立封装成:
RadarWidget它的职责非常明确:
读取当前雷达数据,并负责 PPI 图形绘制。
这样项目结构变成:
MainWindow ↓ 负责整个界面组织 RadarWidget ↓ 负责雷达可视化二、Qt 自定义绘图的核心:paintEvent() 与 QPainter
Qt 自定义控件绘图的入口通常是:
voidRadarWidget::paintEvent(QPaintEvent*event)当控件需要重新显示时,Qt 会自动调用paintEvent()。
例如:
- 窗口第一次显示
- 窗口尺寸变化
- 控件重新暴露
- 程序调用
update()
RadarSimulator 的动态扫描,本质上就是:
更新 scanAngle 更新目标 / 航迹数据 ↓ 调用 update() ↓ Qt 触发 paintEvent() ↓ 重新绘制一帧而真正负责绘图的是:
QPainterpainter(this);常用函数包括:
drawEllipse()drawLine()drawText()drawPoint()drawPath()所以整个 PPI 本质上并不是某个“雷达专用控件”,而是由大量基础二维图元组合出来的。
三、PPI 界面实际上是多个图层叠加
一个完整的 PPI 可以拆成几个绘制层:
背景 ↓ 雷达圆盘 ↓ 距离环 ↓ 十字线与方位刻度 ↓ 扫描波束 ↓ 历史航迹 ↓ 当前目标 ↓ 文字信息绘制顺序很重要,因为QPainter后画的内容会覆盖先画的内容。
例如扫描波束通常带半透明效果,如果目标先画、扫描波束后画,目标可能会被渐变遮住。
因此更加合理的顺序是:
先画背景和网格,再画扫描效果,最后画航迹、目标和文字。
四、雷达圆盘、中心和距离环
绘制前首先需要确定 PPI 的中心和半径。
假设控件大小为:
宽度 W 高度 H雷达中心可以写成:
cx=W2c_x=\frac{W}{2}cx=2W
cy=H2c_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目标真实距离和屏幕半径之间采用线性映射:
rscreen=rangemaxRange×Rr_{screen}=\frac{range}{maxRange}\times Rrscreen=maxRangerange×R
例如:
maxRange = 5000 m range = 2500 m R = 300 px那么:
rscreen=25005000×300=150 pxr_{screen}=\frac{2500}{5000}\times300=150\text{ px}rscreen=50002500×300=150px
所以一个距离雷达 2500 m 的目标,会出现在 PPI 半径的一半位置。
五、方位刻度和雷达坐标
RadarSimulator 采用的雷达方位角约定是:
0° = 上方 90° = 右方 180° = 下方 270° = 左方因此 PPI 最基础的参考线是:
0° | 270° ------+------ 90° | 180°十字线可以直接使用:
drawLine()绘制。
进一步还可以在圆周上增加:
- 大刻度
- 小刻度
- 方位角数字
如果某个角度为θ,那么圆周上的位置可以通过:
x=cx+Rsinθx=c_x+R\sin\thetax=cx+Rsinθ
y=cy−Rcosθy=c_y-R\cos\thetay=cy−Rcosθ
计算。
这里 Y 坐标使用减号,是因为 Qt 的屏幕坐标系中:
x 向右 y 向下和普通数学坐标系的 Y 轴方向相反。
六、从雷达坐标转换到屏幕坐标
RadarTarget中保存的通常是:
Range Azimuth而QPainter最终需要的是:
screenX screenY因此首先需要计算目标在 PPI 中的径向距离:
rs=rangemaxRange×Rr_s=\frac{range}{maxRange}\times Rrs=maxRangerange×R
然后把极坐标转换为屏幕坐标:
xs=cx+rssinθx_s=c_x+r_s\sin\thetaxs=cx+rssinθ
ys=cy−rscosθy_s=c_y-r_s\cos\thetays=cy−rscosθ
例如:
Azimuth = 90°因为:
sin 90° = 1 cos 90° = 0所以目标位于 PPI 正右方。
如果:
Azimuth = 0°则目标位于 PPI 正上方。
这套关系不仅可以用于目标,也可以用于:
- 航迹点
- 扫描线终点
- 刻度
- 文字位置
七、扫描线和扫描波束
雷达扫描方向由:
scanAngle表示。
扫描线终点可以通过:
x=cx+Rsin(scanAngle)x=c_x+R\sin(scanAngle)x=cx+Rsin(scanAngle)
y=cy−Rcos(scanAngle)y=c_y-R\cos(scanAngle)y=cy−Rcos(scanAngle)
得到。
随后使用:
painter.drawLine(center,endPoint);即可绘制扫描线。
随着scanAngle不断变化,扫描线会围绕 PPI 中心旋转。
为什么不能只画一根线
如果只画一根很细的绿色线,视觉效果会比较生硬。
所以 RadarSimulator 还加入了带透明度变化的扫描扇区:
扫描方向最亮 ↓ 向后逐渐变暗 ↓ 最终完全透明Qt 中非常适合完成这种效果的类是:
QConicalGradient它可以围绕某个中心点产生角度渐变。
基本思路如下:
PPI 中心 ↓ 建立 QConicalGradient ↓ 设置多个颜色节点 ↓ 构造 QBrush ↓ 填充雷达圆盘这里需要注意:
雷达方位角定义和 Qt 渐变角度定义不一定完全一致。
因此实际绘制时经常需要一个角度偏移,甚至进行正负方向转换,才能让:
扫描线与:
扫描渐变完全对齐。
调试时最简单的方法是:
先只画扫描线,确认方向正确,再调整渐变方向。
八、目标和航迹怎么绘制
完成坐标转换以后,一个目标最终就变成了屏幕上的:
(x, y)可以直接使用:
painter.drawEllipse(...)绘制一个目标点。
同时可以通过:
drawText()显示:
T1 T2 T3或者进一步显示:
Range Azimuth Speed不过 PPI 信息过多会影响可读性,因此通常只保留最重要的信息。
航迹
单个目标点只能表示:
目标当前在哪里。
如果保存最近一段时间的位置,例如:
P1 P2 P3 P4 当前位置就可以依次连接:
P1 → P2 → P3 → P4 → Target形成航迹。
实现上可以循环调用:
drawLine()也可以构建:
QPainterPath统一绘制。
九、为什么历史轨迹不能无限保存
如果每次刷新都保存目标位置,而且永远不删除:
运行时间越长 ↓ 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最后再看实际程序运行效果。
十三、本文小结
这一篇主要解决了一个问题:
前面已经计算好的雷达数据,最终怎么变成屏幕上的 PPI。
整个核心流程是:
Range / Azimuth ↓ 比例缩放 ↓ 雷达坐标 ↓ Qt 屏幕坐标 ↓ QPainter ↓ 圆盘 / 网格 / 扫描 / 目标 / 航迹其中最重要的数学关系是:
目标距离映射到屏幕半径:
rs=rangemaxRange×Rr_s=\frac{range}{maxRange}\times Rrs=maxRangerange×R
目标屏幕 X 坐标:
xs=cx+rssinθx_s=c_x+r_s\sin\thetaxs=cx+rssinθ
目标屏幕 Y 坐标:
ys=cy−rscosθy_s=c_y-r_s\cos\thetays=cy−rscosθ
在绘图层面:
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 命令,是怎样一步一步变成雷达界面变化的。