Qt雷达图组件实现余晖扫描:QPixmap衰减缓冲与C++绘制实践 简介面向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里没有任何业务逻辑它只吃一个QVectorRadarTarget数据从哪来、怎么算都不关心。你后续接真实雷达数据只要把模拟器替换成网络接收端或者串口解析类显示组件一行都不用动。这也是我从这个项目里学到的最重要一点显示层和数据层之间用一个简单的结构体列表做接口比定义复杂的虚基类更实用。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内部维护了两张 QPixmapm_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_decayAlpha8 ~ 40余晖拖尾长度越小拖尾越长m_sweepSpeedDegSec30 ~ 120扫描线转速太快人眼跟不上m_beamWidth2 ~ 6 度波束宽度越大目标点亮时间越长m_halfBeamWidth1 ~ 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维护一个QVectorRadarTarget作为内部数据集。雷达站固定在东经 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 QVectorRadarTarget)接口外部数据源每次更新时调用它内部在tick里不再做目标推演只做坐标换算。更值得做的一步是把显示层组件化。项目里的RadarWidget目前直接挂在MainWindow下要做多雷达组网界面需要把RadarWidget放进一个可复用的容器每个RadarWRadarWidget配置独立的m_maxRangeKm和雷达站坐标。验证方式很简单创建一个QGridLayout放四个RadarWidget分别给不同数据源喂不同的目标模拟列表启动后四个雷达应该各自独立旋转互不干扰。最后一步是给RadarTarget增加目标轨迹历史字段每次点亮时记录最近 20 个位置点用QPainterPath连起来画就能实现目标的运动轨迹尾部效果比单点余晖更直观。这个项目让我印象最深的一次翻车是在接入真实轨迹数据时发现所有目标的位置都偏了一个固定角度查了很久才发现是polarToCartesian里的方向修正出了问题模拟数据我用的是 0 度从正北开始而外部数据源给的是笛卡尔直角坐标系的角度。从那以后我每次接外部数据源都会强制在边界处打印首帧的方位角人工核对一遍换算结果再继续写下去。希望这个拆解能帮你的雷达图项目少走一段弯路。本文还有配套的精品资源点击获取