简介:这是一份面向C++与Qt开发者的自绘控件示例工程,聚焦雷达图与摇杆图两类可视化组件的实现,方向数量可按需扩展,适合正在学习Qt绘图、自定义控件或需要为工控、车载、游戏等界面补充交互图形的中初级开发者参考。资源包共10个文件,包含5个cpp源文件、4个头文件及1个txt说明,压缩包约13KB,采用VS2019与Qt5.15.2编译,未引入第三方库,并以CMake组织工程,便于直接构建与二次修改。目前已有521人学习下载。代码中附带注释,雷达图与摇杆图分别独立成模块,读者可从中了解坐标换算、角度映射、鼠标事件响应与QPainter绘制流程,并据此扩展方向数量或替换样式,快速嵌入自己的项目;若编译环境存在差异,作者也提供了私信排查的沟通渠道。
1. 雷达图与摇杆图:一个 C++ 可视化控件的落地拆解
如果你正在做地面站、机器人遥控或者游戏外设的调试工具,大概率绕不开两个控件:一个用来显示多传感器融合后的态势,一个用来把摇杆的原始坐标实时画出来。我最近拆的这个 C++ 项目,核心就干这两件事——雷达图负责把距离和方位映射成极坐标下的扫描点,摇杆图负责把二维输入设备的 X/Y 轴数据实时渲染成可交互的圆盘。它不依赖重型 GUI 框架,纯 C++ 实现,适合嵌入到已有工程里做二次开发。如果你手头正好有摇杆或雷达数据,想找个轻量的可视化方案,这个项目的结构值得一看。
2. 项目架构与目录结构:从数据源到渲染层的分层设计
2.1 整体分层思路
这个项目没有把绘图逻辑和数据处理揉在一起,而是分了三层:数据层、坐标变换层、渲染层。数据层负责接收原始输入,比如摇杆的 ADC 采样值或者雷达的极坐标数据;坐标变换层把原始值归一化到屏幕坐标系;渲染层只负责画线和填充。这种分法在 C++ 小工具里很常见,好处是换数据源的时候不用动渲染代码。
常见做法是定义一个IDataSource接口,摇杆和雷达各自实现自己的数据读取逻辑。渲染层持有一个IDataSource指针,通过多态拿到归一化后的点集。这样你后面想加一个“方向根据自己需要增加”的新图,只需要再实现一个数据源和对应的渲染器。
2.2 目录结构
项目目录大致长这样,我按功能做了归类:
radar_joystick_demo/ ├── include/ │ ├── core/ │ │ ├── DataSource.h // 数据源接口 │ │ ├── Coordinate.h // 坐标变换工具 │ │ └── Renderer.h // 渲染器基类 │ ├── widgets/ │ │ ├── RadarWidget.h // 雷达图控件 │ │ └── JoystickWidget.h // 摇杆图控件 │ └── utils/ │ └── MathUtils.h // 角度、插值等工具函数 ├── src/ │ ├── core/ │ │ ├── Coordinate.cpp │ │ └── Renderer.cpp │ ├── widgets/ │ │ ├── RadarWidget.cpp │ │ └── JoystickWidget.cpp │ └── main.cpp // 演示入口 ├── assets/ │ └── shaders/ // 如果用 OpenGL 后端,着色器放这里 ├── CMakeLists.txt └── README.mdinclude和src分离是 C++ 项目的常规操作,方便外部工程只包含头文件。widgets目录下每个控件独立成对,新增图形时直接加文件,不用改核心层。
2.3 核心类关系
Renderer是抽象基类,定义了draw()纯虚函数。RadarWidget和JoystickWidget都继承它,各自实现绘制逻辑。DataSource负责提供归一化到[-1, 1]区间的点或向量。Coordinate提供极坐标转笛卡尔、屏幕坐标映射等静态方法。
这种设计下,主循环只需要持有Renderer*列表,每帧调用draw()。数据更新和渲染解耦,摇杆的采样频率和屏幕刷新率不一致时也不会互相卡死。
3. 核心模块拆解:雷达图与摇杆图的实现细节
3.1 雷达图的极坐标映射与扫描线绘制
雷达图的核心是把(距离, 方位角)映射到屏幕上的(x, y)。假设屏幕中心是(cx, cy),最大显示距离是maxRange,那么一个点的屏幕坐标可以这样算:
// RadarWidget.cpp void RadarWidget::drawPoint(float distance, float angleDeg) { // 归一化距离,限制在 [0, 1] float normDist = std::min(distance / maxRange_, 1.0f); // 角度转弧度 float angleRad = angleDeg * M_PI / 180.0f; // 极坐标转笛卡尔,注意屏幕 y 轴向下,所以 sin 取负 float x = center_.x + normDist * radius_ * std::cos(angleRad); float y = center_.y - normDist * radius_ * std::sin(angleRad); // 画点或小圆 drawCircle(x, y, 3.0f, pointColor_); }maxRange_是雷达的最大探测距离,radius_是屏幕上雷达圆的像素半径。center_是圆心。角度用度数传入,内部转弧度。注意屏幕坐标系 y 轴向下,所以sin前面加负号,否则上下会颠倒。
扫描线是雷达图的视觉特征,通常用一条从圆心到边缘的线段表示当前扫描角度。实现时维护一个sweepAngle_,每帧递增,到 360 度归零。绘制时从圆心画到(center_.x + radius_ * cos, center_.y - radius_ * sin),颜色用渐变色或者半透明填充扇形区域。
注意:如果雷达数据点很多,每帧全量重绘会掉帧。常见做法是用一个
std::vector<Point>缓存最近 N 个点,只重绘变化区域,或者用双缓冲。
3.2 摇杆图的坐标归一化与死区处理
摇杆图要处理的是原始 ADC 值,比如 12 位采样范围[0, 4095],中心值约 2048。第一步是归一化到[-1, 1]:
// JoystickWidget.cpp void JoystickWidget::updateRaw(int rawX, int rawY) { // 原始范围 [0, 4095],中心 2048 float nx = (rawX - 2048) / 2048.0f; float ny = (rawY - 2048) / 2048.0f; // 死区处理:小于阈值的归零,避免抖动 const float deadzone = 0.05f; if (std::abs(nx) < deadzone) nx = 0.0f; if (std::abs(ny) < deadzone) ny = 0.0f; // 限制在 [-1, 1] nx = std::clamp(nx, -1.0f, 1.0f); ny = std::clamp(ny, -1.0f, 1.0f); // 更新摇杆位置 stickPos_ = {nx, ny}; }rawX和rawY是原始采样值,2048是中心点。死区deadzone根据硬件噪声调整,一般 0.03 到 0.1 之间。std::clamp防止超出范围。
绘制时,摇杆图通常是一个大圆加一个小圆。大圆是活动范围,小圆是当前摇杆位置。小圆坐标是center_ + stickPos_ * radius_。如果要做方向指示,可以在小圆上画一条从圆心到小圆的线,或者用不同颜色表示八个方向。
3.3 渲染循环与双缓冲
主循环用std::chrono控制帧率,每帧先更新数据,再调用渲染器的draw()。如果用 OpenGL 或 SDL,双缓冲是标配,避免闪烁。
// main.cpp int main() { initWindow(800, 600); RadarWidget radar; JoystickWidget joystick; std::vector<Renderer*> renderers = {&radar, &joystick}; auto last = std::chrono::steady_clock::now(); while (!shouldClose()) { auto now = std::chrono::steady_clock::now(); float dt = std::chrono::duration<float>(now - last).count(); last = now; // 模拟数据更新 radar.update(dt); joystick.update(dt); beginFrame(); for (auto* r : renderers) { r->draw(); } endFrame(); // 交换缓冲 } return 0; }dt是帧间隔,用于动画和扫描线旋转。beginFrame和endFrame封装了清屏和缓冲交换。renderers列表让新增控件只需要 push 进去,不用改循环。
4. 编译与运行:从 CMake 配置到可执行文件
4.1 依赖与 CMake 配置
项目依赖 CMake 3.10 以上,C++17 标准。如果渲染后端用 OpenGL,需要链接GL和GLEW;如果用 SDL,链接SDL2。CMakeLists.txt 关键部分:
cmake_minimum_required(VERSION 3.10) project(RadarJoystickDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include_directories(include) file(GLOB SOURCES "src/*.cpp" "src/core/*.cpp" "src/widgets/*.cpp") add_executable(demo ${SOURCES}) # 按需链接 find_package(OpenGL REQUIRED) target_link_libraries(demo OpenGL::GL)file(GLOB ...)自动收集源文件,新增 cpp 不用改 CMake。include_directories让头文件路径生效。链接库根据实际后端调整。
4.2 编译命令与常见错误
在项目根目录执行:
mkdir build && cd build cmake .. make -j4 ./demo如果报undefined reference to glBegin,说明没链接 OpenGL 库,检查target_link_libraries。如果报GL/gl.h not found,安装libgl-dev或对应平台的开发包。Windows 下用 MinGW 或 MSVC 时,库名可能不同,常见做法是用find_package让 CMake 自己找。
提示:第一次编译建议先跑通空窗口,再逐步加控件。这样出问题时容易定位是环境问题还是代码问题。
4.3 运行效果与参数调整
运行后应该看到两个并排的窗口或一个窗口内两个区域:左边雷达图有扫描线和随机点,右边摇杆图有一个可动的小圆。如果摇杆不动,检查数据源是否在更新;如果雷达点位置不对,检查角度单位和 y 轴方向。
参数调整集中在头文件里的常量:maxRange_改雷达量程,radius_改显示大小,deadzone改摇杆死区。改完重新编译即可。
5. 避坑与常见问题:坐标、性能和跨平台
5.1 角度单位混用导致点位置错乱
现象:雷达点全部挤在一条线上,或者方向完全反了。原因:sin/cos需要弧度,但数据源给的是度数,没转换。解决:在drawPoint入口统一转弧度,或者约定数据源输出弧度。我一般会在Coordinate里加一个deg2rad函数,所有角度入口都过一遍。
5.2 摇杆中心值漂移
现象:摇杆回中后小圆不在圆心,偏了几个像素。原因:ADC 中心值不是精确的 2048,可能是 2035 或 2060。解决:上电时采样多次取平均作为中心值,或者提供校准接口。常见做法是启动时让用户把摇杆放在中位,记录centerX和centerY。
5.3 高频重绘导致 CPU 占用高
现象:帧率跑满但 CPU 占用 30% 以上。原因:每帧全量重绘所有点,没有脏矩形或双缓冲。解决:用双缓冲避免闪烁,用std::vector缓存点并限制数量,或者只在数据变化时重绘。如果后端支持,用 VBO 把静态部分上传到 GPU。
5.4 跨平台编译时找不到 GL/gl.h
现象:Linux 下编译通过,Windows 下报头文件缺失。原因:Windows 的 OpenGL 头文件路径不同,且需要手动加载扩展。解决:用 GLEW 或 GLAD 管理扩展,CMake 里用find_package(GLEW REQUIRED)。或者换 SDL 的渲染 API,跨平台更省心。
5.5 新增图形时忘记注册渲染器
现象:加了新控件类,但窗口里不显示。原因:main.cpp的renderers列表没加进去。解决:把注册逻辑抽成一个registerRenderer函数,或者用工厂模式自动注册。我习惯在main里显式 push,虽然土但不容易漏。
6. 二次开发:增加新图形与数据源的改造思路
6.1 新增一个“方向图”的步骤
假设你要加一个显示八个方向状态的图。第一步,在widgets下新建DirectionWidget.h和.cpp,继承Renderer。第二步,实现draw(),用八个扇形或箭头表示方向。第三步,在main.cpp里实例化并加入renderers。第四步,如果需要数据驱动,实现一个DirectionDataSource,继承DataSource,在update里拉取数据。
关键点是保持接口一致:draw()不接收参数,所有数据通过成员变量或数据源获取。这样主循环不用改。
6.2 替换数据源为真实设备
演示用的随机数据换成真实摇杆或雷达,只需要改数据源的read()实现。比如摇杆用hidapi读 USB 报告,雷达用串口读 NMEA 语句。数据源内部做协议解析,输出归一化后的值。渲染层完全不用动。
// RealJoystickSource.cpp bool RealJoystickSource::read(float& nx, float& ny) { // 从 HID 设备读取原始报告 uint8_t buf[64]; int res = hid_read(handle_, buf, sizeof(buf)); if (res < 0) return false; // 解析 X/Y 轴,假设小端 16 位 int rawX = buf[0] | (buf[1] << 8); int rawY = buf[2] | (buf[3] << 8); // 归一化 nx = (rawX - centerX_) / 32768.0f; ny = (rawY - centerY_) / 32768.0f; return true; }hid_read是非阻塞的,返回负值表示无数据。centerX_和centerY_是校准值。归一化分母根据实际位数调整。
6.3 验证改造是否成功
改完后,先跑单元测试验证坐标变换,再用真实设备跑一遍。我一般会加一个--debug参数,把原始值和归一化值打印到控制台,对比预期。如果摇杆方向反了,交换nx或ny的符号;如果雷达点旋转方向不对,调整角度偏移。
注意:真实设备的数据频率可能远高于屏幕刷新率,数据源里要做缓冲或降采样,否则渲染线程会被拖慢。
6.4 一个具体技巧:用配置表管理图形参数
新增图形多了以后,参数散落在各个头文件里不好维护。我习惯用一个简单的 JSON 或 ini 配置表,启动时读取,把maxRange、radius、deadzone等做成可配置项。这样改参数不用重新编译,也方便不同场景切换。
// Config.h struct WidgetConfig { float maxRange = 100.0f; float radius = 200.0f; float deadzone = 0.05f; // 其他参数 };读取配置的代码可以用nlohmann/json或自己写个简单的解析。配置表放在assets/config.json,启动时加载。如果文件不存在,用默认值。
从那以后我每次加新控件,都强制走一遍“接口对齐 → 数据源替换 → 配置外置”的流程,避免改一处崩三处。希望帮到你。
本文还有配套的精品资源,点击获取