简介:面向Qt开发者的雷达图与余晖扫描实现资源包,基于Qt 5.9完成,适合需要掌握C++自定义绘图、多轴图表与动态扫描交互的桌面应用开发者。压缩包共53个文件,体积约1.62MB,以11个cpp、9个h源码为主,另含pro工程、ui界面、makefile、可执行文件等,可直接打开工程查看或编译运行;目录结构也区分了调试与发布版本。已有3774人学习下载。资源提供完整工程示例与可运行工程文件,涵盖雷达图坐标轴设置、卫星数据点绘制、卫星增删操作、连线设置以及QTimer驱动的余晖扫描逻辑;配套readme和docx说明文档,介绍初始化步骤与操作方法。整个项目代码结构清晰、逻辑连贯,余晖扫描通过逐帧更新卫星位置和颜色来增强动态效果,适合初学者理解图形渲染核心思路,也可作为后续二次开发的基础。
1. 一个 Qt 雷达图组件,为什么值得拆开看
某开发者去年接了一个航管显示终端的模拟项目,甲方要求界面上必须有一圈带余晖扫描的雷达图,卫星目标和普通航迹都要在上面动起来。第一个版本他用 QPainter 画了静态圆环,画完自己都看不下去。后来在这个项目里把 Qt 雷达图、余晖扫描、C++ 绘制链路重新拆了一遍,才发现「扫描线扫过目标后留下慢慢变暗的痕迹」这个效果,技术上根本不涉及视频残影算法,也不需要 GPU 特效,用 QPixmap 做衰减缓冲就能实现,而且帧率稳定在 45 FPS 以上。这是一份 Qt Widgets + C++ 的雷达图实现资源,包含完整工程代码,适合正在做显示终端、监控大屏或者想学 QPainter 高级绘制的开发者。你要跑起来改一改,半小时就能上手。
2. 架构设计:两层画布加三阶段刷新
2.1 模块划分与类职责
整个项目不是单文件堆出来的 Demo,而是按「渲染、数据、控制」三块拆的。核心类有四个:MainWindow负责窗口和测试面板;RadarWidget继承 QWidget,是真正干活的雷达显示区;RadarTarget是目标数据模型,承载经纬度、高度、速度、类型这些字段;RadarDataSimulator负责生成模拟目标并做轨迹外推,让卫星和航班目标每帧都有新坐标可画。
这样的划分有一个直接好处:RadarWidget里没有任何业务逻辑,它只吃一个QVector<RadarTarget>,数据从哪来、怎么算都不关心。你后续接真实雷达数据,只要把模拟器替换成网络接收端或者串口解析类,显示组件一行都不用动。这也是我从这个项目里学到的最重要一点:显示层和数据层之间,用一个简单的结构体列表做接口,比定义复杂的虚基类更实用。
2.2 工程目录与资源组织
工程采用标准 qmake 结构,主目录下包含.pro工程文件、main.cpp、mainwindow.cpp/h、radarwidget.cpp/h、radartarget.h、radardatasimulator.cpp/h。如果你用 CMake,把radarwidget.cpp和radardatasimulator.cpp加进add_executable列表,再链接 Qt5::Widgets 和 Qt5::Gui 即可,这个工程没有第三方依赖,不需要额外安装库。
RadarWidget内部维护了两张 QPixmap:m_staticLayerPixmap存放雷达背景,包括量程圈、方位刻度、中心十字和距离标注,这类元素每一帧都不变;m_afterglowPixmap存放动态内容,包括扫描线、余晖痕迹和目标光点。两张画布叠加输出到屏幕,静态层在resizeEvent里只重画一次,动态层每帧都更新,CPU 开销被控制在一个合理的范围。
2.3 刷新链路:QTimer 怎么驱动三阶段
刷新逻辑不是靠 paintEvent 被动重绘,而是由一个 QTimer 主动触发。每帧走三个阶段:先调simulator->tick(dt)推进所有目标的位置和方位角,再调RadarWidget::rotateScan(dt)把扫描线角度往前推进一个增量,最后调update()触发一次系统重绘。
这里有个容易踩坑的点:QTimer 的时间精度并不准确,它只保证至少隔多少毫秒触发一次,不保证触发的间隔均匀。如果直接按「固定角度增量」推进扫描线,窗口卡顿的时候扫描线会跳变,看着非常不流畅。常见的做法是记录时间戳,用两次 tick 之间的真实时间差乘以角速度来计算新角度。项目里用了elapsedTimer.restart()返回的毫秒数,再除以 1000 转成秒,乘以每秒钟转动的度数。
3. 核心渲染实现:用 C++ 手写余晖扫描
3.1 paintEvent 主骨架与双画布合成
RadarWidget的绘制入口是重写后的paintEvent,代码不长,但结构值得细看。它在每次系统重绘时,先把这一帧要画的内容画到两张离屏 pixmap 上,再一次性drawPixmap合成到屏幕。
void RadarWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); // 第一步:更新动态层,内部会画扫描线、余晖和目标点 renderAfterglowLayer(); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 第二步:先贴动态层,再贴静态层 painter.drawPixmap(0, 0, m_afterglowPixmap); painter.drawPixmap(0, 0, m_staticLayerPixmap); }renderAfterglowLayer()是整个余晖效果的核心,它单独封装,方便你在里面调节衰减参数或者换成其他绘制策略。注意drawPixmap的顺序不能反,动态层先画、静态层后画,否则量程圈和刻度会被扫过的痕迹盖住。event在函数里用不到,把它Q_UNUSED掉即可,避免编译器报警告。
3.2 余晖层实现:QPixmap 上的指数衰减
余晖的数学原理其实特别简单。雷达屏幕上每一帧的像素灰度可以看成两部分:上一帧残留值乘以一个衰减系数,加上本帧新扫到的目标亮度。写成公式就是E_next = E_current * decay + E_new。在代码里,这个公式被翻译成了「在整张 pixmap 上盖一层半透明黑」。
void RadarWidget::renderAfterglowLayer() { QPainter painter(&m_afterglowPixmap); // 衰减:用低透明度黑色覆盖整张画布,旧痕迹亮度指数下降 QColor fadeColor(0, 0, 0, m_decayAlpha); painter.fillRect(m_afterglowPixmap.rect(), fadeColor); // 画当前扫描线和被扫到的目标点亮 drawSweep(painter); drawTargets(painter); }m_decayAlpha取值范围是 0 到 255,数值越大,每一次刷新后旧像素被压得越黑,余晖消失得越快。我用的是 18 这个值,实测扫描线转一圈大概需要 4 秒,目标拖尾能持续一整圈。想要更长的拖尾就调到 8 左右,想要接近实时光刷干净就调到 30 以上。注意fillRect用的是带 alpha 的颜色,而不是把整张图clear(),只有这样才能保留历史痕迹。
3.3 极坐标到直角坐标的换算
雷达图本质上是一个极坐标系:目标的位置由一个方位角(bearing)和一个距离(range)决定。屏幕上画点的时候,需要把极坐标换算成 QPainter 能用的直角坐标。这里最容易翻车的是角度方向问题,数学里的极坐标角是逆时针从 x 轴正方向开始算的,而雷达习惯上是顺时针从正北方向开始算。
QPointF RadarWidget::polarToCartesian(double bearingDeg, double rangeKm) const { const QRectF rect = radarRect(); const double cx = rect.center().x(); const double cy = rect.center().y(); const double maxRadius = rect.width() * 0.45; // 距离不能超过量程,超出部分压在最外圈 const double radius = qBound(0.0, rangeKm / m_maxRangeKm * maxRadius, maxRadius); // 正北是 0 度,顺时针,y 轴向下,需要做方向修正 const double angle = qDegreesToRadians(90.0 - bearingDeg); return QPointF(cx + radius * qCos(angle), cy - radius * qSin(angle)); }换算时的关键点是90.0 - bearingDeg。如果bearingDeg是 0 度,表示目标在正北,angle为 90 度,cos(90)=0、sin(90)=1,目标落在圆心正上方;如果方位角是 90 度,目标在正东,angle为 0 度,目标落在圆心正右方。象限就对了。radarRect()返回的是画布内切正方形的区域,保证雷达罩是正圆,而不是跟着窗口变成椭圆。
3.4 扫描线与目标点亮的联动
扫描线不是简单画一条直线,而是用一个细长的扇形表示雷达波束。波束扫过某个目标时,目标变亮;扫过去之后,目标的亮度交给余晖层去衰减。
void RadarWidget::drawSweep(QPainter &painter) { painter.save(); QConicalGradient gradient(center(), m_sweepAngle); gradient.setColorAt(0.0, QColor(46, 220, 100, 180)); gradient.setColorAt(0.02, QColor(46, 220, 100, 60)); gradient.setColorAt(0.05, QColor(46, 220, 100, 0)); painter.setPen(Qt::NoPen); painter.setBrush(gradient); painter.drawPie(radarRect(), int(m_sweepAngle * 16), int(m_beamWidth * 16)); painter.restore(); }QConicalGradient是锥形渐变,能画出波束中心亮、两侧暗的效果,视觉上比实心扇形自然得多。第 6 行的drawPie画的是扇形,角度参数用 1/16 度为单位,所以要把角度乘以 16,这是 Qt 里特别容易记错的细节。
目标点亮的判定是看扫描线角度落在目标方位角的多大范围内,我用了波束半宽的概念:
void RadarWidget::drawTargets(QPainter &painter) { for (const RadarTarget &t : m_targets) { double diff = qAbs(normalizeAngle(t.bearingDeg - m_sweepAngle)); if (diff < m_halfBeamWidth) { QPointF pt = polarToCartesian(t.bearingDeg, t.rangeKm); painter.setPen(Qt::NoPen); painter.setBrush(colorForType(t.type)); painter.drawEllipse(pt, t.sizePx, t.sizePx); } } }normalizeAngle把角度差值归一化到 -180 度到 180 度之间,否则扫描线从 355 度转到 5 度时,差值会被算成 350 度,目标就不会点亮了,这个函数是上屏效果不跳变的关键。
3.5 卫星图层区域:轨道弧线与卫星标识
卫星目标和普通航迹目标在显示上有一个区别:普通目标就是一个实心圆点加航向小尾巴,卫星目标则带有一条短划线表示其星下点轨迹方向。实现方式是在目标点周围画一小段按运动方向旋转的弧线,弧线半径固定,不随距离缩放。
void RadarWidget::drawSatelliteTrack(QPainter &painter, const RadarTarget &t) { double headingRad = qDegreesToRadians(t.headingDeg); double arcLen = qDegreesToRadians(25.0); QPointF pt = polarToCartesian(t.bearingDeg, t.rangeKm); painter.setPen(QPen(QColor(40, 220, 160), 2)); painter.setBrush(Qt::NoBrush); painter.drawArc(QRectF(pt.x() - s, pt.y() - s, 2 * s, 2 * s), int((90.0 - t.headingDeg - 12.5) * 16), int(arcLen * 16)); }这里的s是卫星标识的半径,我设置为 10 像素;drawArc同样是 1/16 度为单位。卫星目标本身用三角形标记,普通目标用圆形,代码里通过t.type分支切换画笔和形状。一眼就能区分当前关注的对象是跨大气层的卫星还是普通飞行器,这个设计在二次开发阶段做目标分类过滤时非常有用。
3.6 余晖层参数速查表
余晖扫描这套效果,可调的就几个参数,理解了它们各自的视觉影响,调参就不是玄学,而是按需取值:
| 参数名 | 取值区间 | 视觉影响 |
|---|---|---|
| m_decayAlpha | 8 ~ 40 | 余晖拖尾长度,越小拖尾越长 |
| m_sweepSpeedDegSec | 30 ~ 120 | 扫描线转速,太快人眼跟不上 |
| m_beamWidth | 2 ~ 6 度 | 波束宽度,越大目标点亮时间越长 |
| m_halfBeamWidth | 1 ~ 3 度 | 目标点亮判定的宽容度 |
| QTimer 间隔 | 20 ~ 50 ms | 帧率上限,越小越流畅 |
其中最容易让效果翻车的是m_decayAlpha。如果设置成 80,扫描线转完半圈,最早的目标就完全消失了;设置成 120,整个屏幕像蒙了一层灰,看起来非常脏。我建议先按参考值 18 起步,让拖尾长度覆盖一整圈,再按视觉效果微调。
4. 数据驱动设计:卫星目标从模拟到上屏
4.1 目标数据模型
目标的定义放在radartarget.h里,用结构体而不是类,这个项目场景下完全够用。结构体用起来方便,拷贝代价也低,QVector 里连续存储,遍历时缓存命中率比链表高得多。
struct RadarTarget { int id = 0; QString name; enum Type { AIR = 0, SATELLITE = 1, UNKNOWN = 2 } type = AIR; double latDeg = 0.0; double lonDeg = 0.0; double altKm = 0.0; double vxKmh = 0.0; double vyKmh = 0.0; double vzKmh = 0.0; double bearingDeg = 0.0; double rangeKm = 0.0; double headingDeg = 0.0; };bearingDeg和rangeKm这两个字段不是从外部输入的,而是由模拟器在每次 tick 时根据雷达站坐标和目标的经纬高实时计算出来。这样做的好处是网格化地图类数据可以直接填经纬高,显示层不需要感知地图坐标系。
4.2 模拟器:轨迹外推与坐标换算
RadarDataSimulator维护一个QVector<RadarTarget>作为内部数据集。雷达站固定在东经 121 度、北纬 31 度的一个模拟位置,模拟器每帧根据目标自身的速度向量做坐标外推。
void RadarDataSimulator::tick(double dtSec) { const double latFactor = 111.0; const double lonFactor = 111.0 * qCos(qDegreesToRadians(m_radarLatDeg)); for (RadarTarget &t : m_targets) { t.lonDeg += t.vxKmh / 3600.0 * dtSec / lonFactor; t.latDeg += t.vyKmh / 3600.0 * dtSec / latFactor; t.altKm += t.vzKmh / 3600.0 * dtSec; t.bearingDeg = calculateBearing(m_radarLatDeg, m_radarLonDeg, t.latDeg, t.lonDeg); t.rangeKm = calculateSlantRange(m_radarLatDeg, m_radarLonDeg, t.latDeg, t.lonDeg, t.altKm); t.headingDeg = qRadiansToDegrees(qAtan2(t.vyKmh, t.vxKmh)); } }lonFactor的处理是一个细节:地球经线在非赤道地区会收缩,1 度经度对应的地表距离要乘以纬度余弦,如果忽略它,目标在东西方向上的移动速度会偏大。模拟阶段这个误差不明显,但当你把模拟器替换成真实 GPS 轨迹数据时,这个换算错误会让目标位置偏移几公里。
4.3 线程边界与 UI 刷新节奏
这个 Demo 的模拟目标是 3 颗卫星加 8 个普通目标,数量不大,直接在 UI 线程里 tick 也没有压力。但真实环境下一旦接入上百个目标,每帧都要做三角函数计算和坐标外推,UI 线程可能被拖慢,导致绘制帧率不稳定。项目里给这个场景预留了接口:如果你要跑大数据量,把simulator->tick()放到QtConcurrent::run里,完成后通过信号槽把目标列表传回 UI 线程,而不是直接在线程里调update()。
5. 避坑指南:余晖扫描调试的五个高频问题
5.1 余晖一闪而过,完全看不到拖尾
刷新两秒后画面上只有一条干净的扫描线,没有任何历史痕迹残留。原因基本可以锁定为m_decayAlpha设置过大,颜色被刷成了接近纯黑,旧像素全被覆盖。解决方法是把衰减值降到 30 以下观察效果,如果还是不理想,检查是不是在renderAfterglowLayer里误调用了m_afterglowPixmap.fill(Qt::black),这会把整张画布彻底清空,余晖自然就没了。
5.2 扫描线转了好几圈,目标还是不亮
目标明明在量程范围内,但扫描线路过它的时候没有任何反应。排查路径有两条:第一,算一下目标方位角t.bearingDeg和扫描线角度m_sweepAngle的差值,打印出来看,问题多半出在角度差没有归一化,355 度和 5 度之间被算成了 350 度,大于判定阈值。第二,确认目标距离没有超出m_maxRangeKm,如果距离超出量程,qBound会把它压到最外圈,画出来的点不在雷达圆内,看起来就像没点亮。
5.3 目标点显示模糊,边缘像马赛克
m_afterglowPixmap和窗口尺寸不一致,或者分辨率没对齐。Qt 在高 DPI 屏幕上像素不是一个逻辑像素对应一个物理像素,QPixmap如果不乘设备像素比,贴回窗口时会被放大,边缘自然发虚。解决方法是重写resizeEvent,创建画布时用devicePixelRatioF()加倍:
void RadarWidget::resizeEvent(QResizeEvent *event) { Q_UNUSED(event); const qreal ratio = devicePixelRatioF(); m_afterglowPixmap = QPixmap(width() * ratio, height() * ratio); m_afterglowPixmap.setDevicePixelRatio(ratio); renderStaticLayer(); }5.4 定时器刷新导致界面卡顿,拖动窗口时尤其明显
把模拟器的tick、目标点亮判定和绘制全部堆在 UI 线程里,当目标数量增多或者窗口缓冲尺寸变大时,单帧执行时间可能超过定时器间隔,界面就开始掉帧。我一般会在定时器里打印每次tick的执行耗时,超过 15 毫秒就说明计算任务过重,需要把模拟外推放到工作线程。另外一个容易被忽略的点是:QTimer 的触发间隔不要低于 16 毫秒,因为显示器刷新率大部分是 60Hz,更短的时间间隔只会增加无效计算。
5.5 静态层重绘时整个屏幕闪烁
重绘静态层时直接调update(),导致静态背景和动态余晖层不同步。因为paintEvent里两张画布是分别贴的,如果其中一张在另一张贴完之后被修改,就会出现短暂的撕裂感。解决方法是维护一个脏标记m_staticDirty,只有量程变化或窗口尺寸变化时才重画静态层,平时只更新动态层,渲染时间差异能让肉眼感知到闪烁。
6. 二次开发:单雷达组件升级为多雷达组网终端
拿到这份源码,你可以先用模拟数据跑通效果,然后做一次彻底的数据层替换。把RadarDataSimulator替换成你自己的目标和卫星数据接收器,方法是在类内部加一个void setExternalTargets(const QVector<RadarTarget>&)接口,外部数据源每次更新时调用它,内部在tick里不再做目标推演,只做坐标换算。
更值得做的一步是把显示层组件化。项目里的RadarWidget目前直接挂在MainWindow下,要做多雷达组网界面,需要把RadarWidget放进一个可复用的容器,每个RadarWRadarWidget配置独立的m_maxRangeKm和雷达站坐标。验证方式很简单:创建一个QGridLayout,放四个RadarWidget,分别给不同数据源喂不同的目标模拟列表,启动后四个雷达应该各自独立旋转,互不干扰。最后一步是给RadarTarget增加目标轨迹历史字段,每次点亮时记录最近 20 个位置点,用QPainterPath连起来画,就能实现目标的运动轨迹尾部,效果比单点余晖更直观。
这个项目让我印象最深的一次翻车,是在接入真实轨迹数据时发现所有目标的位置都偏了一个固定角度,查了很久才发现是polarToCartesian里的方向修正出了问题,模拟数据我用的是 0 度从正北开始,而外部数据源给的是笛卡尔直角坐标系的角度。从那以后,我每次接外部数据源都会强制在边界处打印首帧的方位角,人工核对一遍换算结果再继续写下去。希望这个拆解能帮你的雷达图项目少走一段弯路。
本文还有配套的精品资源,点击获取