news 2026/10/2 2:32:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ Qt6 雷达模拟器实战(四):使用 QPainter 绘制 PPI 雷达界面

前面三篇分别介绍了 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 ↓ 负责雷达可视化

图1 MainWindow 负责界面组织,RadarWidget 专门负责 PPI 绘制

二、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后画的内容会覆盖先画的内容。

例如扫描波束通常带半透明效果,如果目标先画、扫描波束后画,目标可能会被渐变遮住。

因此更加合理的顺序是:

先画背景和网格,再画扫描效果,最后画航迹、目标和文字。

图2 PPI 画面由背景、网格、扫描波束、航迹和目标等多个图层组成

四、雷达圆盘、中心和距离环

绘制前首先需要确定 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 半径的一半位置。

图3 真实雷达距离按照比例映射到 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​+rs​sinθ

ys=cy−rscos⁡θy_s=c_y-r_s\cos\thetays​=cy​−rs​cosθ

例如:

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 ↓ 填充雷达圆盘

图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 ↓ 圆盘 / 网格 / 扫描 / 目标 / 航迹

其中最重要的数学关系是:

目标距离映射到屏幕半径:

rs=rangemaxRange×Rr_s=\frac{range}{maxRange}\times Rrs​=maxRangerange​×R

目标屏幕 X 坐标:

xs=cx+rssin⁡θx_s=c_x+r_s\sin\thetaxs​=cx​+rs​sinθ

目标屏幕 Y 坐标:

ys=cy−rscos⁡θy_s=c_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 命令,是怎样一步一步变成雷达界面变化的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 2:32:42

DeepSeek LeetCode 139.单词拆分 Rust实现

LeetCode 139「单词拆分」的 Rust 实现。提供动态规划(推荐)和记忆化 DFS 两种写法。思路:动态规划 定义 dp[i] 表示字符串 s 的前 i 个字符(即 s[0…i])能否被拆分成字典中的单词。初始状态:dp[0] true…

作者头像 李华
网站建设 2026/10/2 2:30:35

从NAS自带笔记迁移到Markdown:踩坑记录与完整实操指南

用 NAS 的人,十有八九都动过“自带笔记应用”的念头。群晖叫 Note Station,威联通叫 Notes Station,这两年流行的绿联、飞牛也都有类似套件。我也是从 Note Station 用起的,身边还有朋友为了笔记功能专门选了某个品牌的 NAS。但用…

作者头像 李华
网站建设 2026/10/2 2:29:01

Linux驱动阻塞与非阻塞访问的理解

一、阻塞I/O应用程序的阻塞访问方式int fd; int data 0; fd open("/dev/xxx_dev", O_RDWR); ret read(fd, &data, sizeof(data));阻塞操作是指在执行设备操作时,若不能获得资源,则挂起进程直到满足可操作的条件后再进行操作。被挂起的进…

作者头像 李华
网站建设 2026/10/2 2:27:58

Windows 11 上安装配置 Claude Code 完整指南

最近我把主力开发机从 macOS 换到 Windows 11,第一件事就是把 Claude Code 装起来。这工具是 Anthropic 官方的命令行编程助手,直接在终端里跑 Claude,能读项目文件、改代码、执行终端命令,甚至能帮你完成 Git 提交。之前我在 Mac…

作者头像 李华
网站建设 2026/10/2 2:25:45

商务谈判后怎么快速找到会议内容?日历备忘场景用法

商务谈判结束后,多数职场人都会遇到同一个棘手问题:大量谈判录音、会议纪要零散堆积,时隔1-2天就难以精准定位对应场次的会议内容,复盘谈判细节、核对合作条款需要耗费大量时间翻找文件。市面上多数会议录音工具的检索功能存在明显…

作者头像 李华
网站建设 2026/10/2 2:25:37

JavaWeb获取HTTP请求体数据全解析

在如今做Java Web开发的时候, 大家经常会碰到那么一项任务, 就是去取那个HTTP请求里头的那个请求体数据这个东西。因为这个请求体的位置, 通常都会藏着客户端提交过来的各种数据, 比如说表单里面的内容, JSON格式的东西, 或者XML结构的数据之类的。对于咱们用Java写代码的同学来…

作者头像 李华