简介:在OpenGL三维可视化开发中,带刻度标签的坐标系能更直观定位图形位置,而OpenGL本身不支持文字渲染,常需借助Qt的QOpenGLWidget解决。该资源面向具备一定Qt与OpenGL基础的中高级开发者,提供一套完整的三维坐标系绘制方案:先在OpenGL中绘制网格,再将目标3D坐标投影到2D屏幕,使用QPainter绘制清晰刻度标签,思路清晰、易于移植。压缩包共9个文件,含cpp源文件、h头文件、ui界面文件与pro工程文件,并附带gif演示动图,便于对照运行效果,整体仅1.74MB。已有1419人浏览学习,适合需要在Qt中快速实现三维标注、自定义坐标系的读者参考改造,也可作为三维可视化项目的基础框架。 做三维可视化的时候,最容易被忽略但又最绕不开的东西就是坐标系。没有它,你在场景里拖来拖去,根本分不清东西南北;有了它,数据才有锚点,旋转缩放才有参照。基于Qt和OpenGL自己动手画一个带刻度标签的三维坐标系,做完之后你会发现自己对OpenGL的理解会上一个台阶,而且这类代码可以复用到三维曲线、散点图、模型预览等项目里。
我自己在做信号分析相关的桌面工具时经常需要把时域波形、频域谱线放到三维场景里看,最早直接用QCustomPlot顶个二维,后来发现需求一旦变成“围绕多个坐标轴观察数据”,就还是得自己写。用Qt的OpenGL模块(QOpenGLWidget + QOpenGLFunctions)画一个坐标系,从零到能转起来、带刻度文字可读、可缩放,整个流程梳理清楚之后,你会发现它没有想象中难。
1. 整体设计与方案选型
1.1 为什么不用现成的第三方库
做坐标系这件事,Qt其实给了不少现成选择。QCustomPlot支持简单的三维坐标轴,但说到底它还是以二维绘图为主的工具箱,三维能力很基础,坐标轴的自定义程度不够高。Q3DScatter这类Qt Data Visualization模块的控件也能开箱即用,但轴标签、刻度间隔、颜色风格想完全捏在自己手里,反而要绕不少弯子。更关键的是,这类高层封装会掩盖OpenGL底层的绘制流程,一旦你需要在坐标系里加入自定义图元、动态特效或者大数据量渲染,就会变得束手束脚。
选QOpenGLWidget自己画,看起来工作量大了点,实际上在这个场景里是“一劳永逸”的方案。QOpenGLWidget是Qt对OpenGL和窗口系统绑定的封装,免去了手动创建上下文、处理平台相关代码的麻烦,同时保留了对OpenGL渲染管线的完整控制权。这个坐标系做完之后,后续往场景里加波形、加曲面、加交互,都会顺畅很多。
1.2 三维坐标系的组成要素
拆开看,一个能用的三维坐标系主要由四部分组成:坐标轴本体、原点和轴端点标记、刻度线、刻度标签。坐标轴本体包括正负半轴,但实际绘制时通常只突出正半轴,负半轴用虚线或淡色表示,这样视觉上主次分明。刻度线和刻度标签是区分“能看的坐标系”和“能用的坐标系”的关键,标签的位置必须跟随视角变化,永远面向观察者,否则旋转到背面就看不清了。
交互上至少需要支持旋转和缩放。旋转让用户可以从任意角度观察数据,缩放则帮助用户聚焦细节。这两项看似基础,却是所有后续三维功能的交互底座。
1.3 文字标签的实现路线选择
OpenGL里显示文字一直是个经典话题。最直接的办法是用纹理贴图:预先用QImage画好每个字符,生成纹理,绘制时用带纹理的四边形把字“贴”到指定位置。但这样做每次绘制都要绑定纹理、变换坐标,文字一多代码就比较杂。
另一个思路是“先画三维,再叠二维”。场景里所有三维几何体用OpenGL渲染,到最后一帧时把投影后的坐标转换回屏幕坐标,再用QPainter把刻度标签画在QOpenGLWidget上。这种做法的好处太明显了:文字渲染完全交给Qt/QPainter,清晰度、抗锯齿、字体选择都直接可用,不需要自己管理字形图集。唯一的要求是拿到“三维点投影到屏幕”的变换矩阵,而这个矩阵正好就是OpenGL绘制时用的模型视图投影矩阵。
2. 核心细节解析与关键技术准备
2.1 固定的管线思维与现代管线的选择
写这套坐标系之前,要先决定用固定管线还是可编程管线。老一代的OpenGL用glBegin/glEnd,代码很短,但被标记为废弃且不能跨平台保证可用。Qt 5.15.2及以后版本里,很多平台对固定管线的兼容已经越来越差,所以建议直接用现代管线的思路,Qt的QOpenGLShaderProgram封装了着色器编译链接的整个过程,用起来效率不错。
坐标系里的几何体包含点和线段,并不需要复杂的三角形网格,但这不代表可以用glBegin。现代管线的线段绘制本质上是把一组顶点交给GPU,用GL_LINES图元来渲染,所以思路是:把每个轴的线段顶点组织成数组,一次性上传VBO,再用一条绘制调用画完。
2.2 坐标轴长度的归一化处理
坐标系大小不能写死。我习惯把坐标轴长度归一化为1,也就是五轴范围0~1,再配合一个loosenScale变量去控制实际显示长度。这样单位刻度、网格间距的计算会统一很多,后续要构建模型矩阵把坐标系缩放、平移到任意位置,只要改loosenScale或者model矩阵即可。
举例说,轴长设为1.0f,范围0到1之间画主刻度,每0.1一个刻度,总共有10个主刻度。缩放场景时只改变视图矩阵里的距离值,不需要改动刻度坐标,逻辑上完全分离。
2.3 深度测试与标签遮挡问题
三维场景默认应该开启深度测试,否则坐标轴和标签的前后遮挡关系会乱掉。但叠加文字的时候就有个矛盾:文字是最后用QPainter画的,如果按照深度测试严格来判断,被轴遮挡的标签就会消失;而QPainter绘制阶段又不走OpenGL深度缓存,会出现标签永远在轴的上面的情况。
实际工程里有两种处理方式。简单粗暴的方案是关闭深度测试,让所有标签都可见,缺点是背面的标签会和正面的叠在一起,看起来很乱。更合理的方案是对标签做“可见性过滤”:画文字之前先判断该刻度点是否朝向观察者,比如通过法向量和视线方向的点积判断,如果点积为负,说明该面背对相机,直接跳过。我采用的是后一种方案,虽然要额外存一点方向向量,但视觉效果干净很多。
2.4 刻度标签的朝向与位置计算
刻度标签的位置不能简单放在刻度线端点,因为三维旋转之后,文字可能会被拉伸或压扁。既然选了QPainter叠加的方案,那文字的朝向永远是水平的,问题就简化为:把三维刻度点投影到屏幕坐标,然后在屏幕坐标上画字符串,位置做一点偏移即可。
偏移量可以基于屏幕坐标方向估算:把该刻度点在三维空间沿坐标轴方向偏移一小段距离,再投影,得到两个屏幕点,就能知道屏幕空间里“轴的方向”指向哪里,让标签沿着这个方向偏移,看起来就像是浮在刻度旁边。
3. 实操过程与核心功能实现
3.1 搭建QOpenGLWidget基础框架
新建一个类继承QOpenGLWidget,构造函数里设置好默认视角参数。核心代码如下:
class AxesWidget : public QOpenGLWidget, protected QOpenGLFunctions { Q_OBJECT public: explicit AxesWidget(QWidget *parent = nullptr); protected: void initializeGL() override; void resizeGL(int w, int h) override; void paintGL() override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void wheelEvent(QWheelEvent *event) override; private: void drawAxes(); void drawTicks(); void drawTickLabels(); QVector3D unproject(const QPoint &pos); QOpenGLShaderProgram m_program; QOpenGLVertexArrayObject m_vao; QOpenGLBuffer m_vbo; QMatrix4x4 m_projMatrix; QMatrix4x4 m_viewMatrix; float m_distance = 4.0f; float m_yaw = 30.0f; float m_pitch = 30.0f; };initializeGL里调用initializeOpenGLFunctions(),然后编译着色器、创建VBO。注意着色器版本用330 core还是300 es,取决于目标平台。桌面平台建议用330 core,配合Qt的QOpenGLShaderProgram会自动处理兼容问题。
3.2 坐标轴的顶点数据与绘制
坐标轴本质上是线段集合。三条正半轴从原点出发,分别指向X、Y、Z正方向。为了视觉层次,我还会画淡色的负半轴。这里的关键是把所有线段塞进一个VBO,启动着色器后一次glDrawArrays搞定。
顶点数组的结构很简单,每两个顶点构成一条线段:
// 轴的长度设为1.0,负半轴淡灰色,正半轴分别用红绿蓝 std::vector<float> axisVertices = { // X 负半轴 -1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // X 正半轴 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // Y 负半轴 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f, // Y 正半轴 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, // Z 负半轴 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, // Z 正半轴 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f };给每条线段赋不同的颜色,需要在顶点数据里带上RGB。负半轴用灰色淡化,正半轴X红、Y绿、Z蓝,这是三维可视化的通用配色。
着色器非常简单,vertex shader接收位置和颜色,乘上model-view-projection矩阵,fragment shader原样输出颜色。
3.3 刻度线绘制与刻度值计算
刻度线的位置基于一个可配置的step变量。默认step=0.1f,每段0.1一个刻度,总共10个刻度。刻度线的长度设置为轴长的3%左右,太短看不清楚,太长会显得很乱。
刻度线的顶点生成逻辑是从轴端点向垂直方向延伸两条短线段。X轴上的刻度线在YZ平面内延伸,Y轴刻度线在XZ平面内延伸,Z轴刻度线在XY平面内延伸。全面具体来说:
void AxesWidget::appendTick(QVector<QVector3D> &vertices, const QVector3D &axisDir, const QVector3D &normalDir, float t) { QVector3D base = axisDir * t; QVector3D offset = normalDir * 0.015f; // 刻度线半长度 vertices.push_back(base - offset); vertices.push_back(base + offset); }角点还要注意:每个轴正负半轴都要画刻度,而且负半轴的刻度值用负号。这样用户才能从负方向读到数据。
3.4 相机旋转与缩放交互
旋转和缩放使用的是典型的轨道相机模型。把相机位置理解成在一个球面上,yaw控制经度方向旋转,pitch控制纬度方向旋转,distance控制相机与原点距离。鼠标拖动时更新yaw和pitch,滚轮更新distance。
视图矩阵用QMatrix4x4的lookAt函数计算,相机位置通过球坐标转直角坐标得到:
float x = m_distance * cos(qDegreesToRadians(m_pitch)) * cos(qDegreesToRadians(m_yaw)); float y = m_distance * sin(qDegreesToRadians(m_pitch)); float z = m_distance * cos(qDegreesToRadians(m_pitch)) * sin(qDegreesToRadians(m_yaw)); m_viewMatrix.setToIdentity(); m_viewMatrix.lookAt(QVector3D(x, y, z), QVector3D(0, 0, 0), QVector3D(0, 1, 0));交互的关键在于鼠标事件里要计算x轴和y轴的相对位移。水平拖动改变yaw,垂直拖动改变pitch,同时给pitch设置上下限,比如-89.5度到89.5度,避免相机翻转到极点导致画面抖动。
3.5 用QPainter绘制刻度标签
paintGL的最后一步是调用QPainter在QOpenGLWidget上绘制文字。这一步的核心是先关闭深度测试,因为QPainter绘制文字阶段不再需要深度判断。
绘制每个刻度标签时,把三维坐标通过投影矩阵变换到屏幕坐标:
QPointF labelPos = project(axisPoint); // 计算该点在屏幕空间里的偏移方向 QPointF dir = project(axisPoint + axisDir * 0.05f) - labelPos; dir = dir / (dir.manhattanLength() + 1e-6f); labelPos += QPointF(dir.x() * 10, dir.y() * 10); painter.drawText(labelPos, labelText);这里有个容易踩的坑:直接用project函数时,要保证输入的三维坐标在NDC空间内,也就是齐次坐标的w分量要为正。如果照相机到了坐标轴后面,投影结果会翻转。这个问题的典型表现是:旋转坐标系180度后,标签的位置忽左忽右,方向完全不对。解决办法是判断齐次坐标w分量,如果为负就跳过该标签的绘制。
3.6 基线代码整合
完整paintGL骨架如下:
void AxesWidget::paintGL() { glClearColor(0.2f, 0.2f, 0.2f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glEnable(GL_DEPTH_TEST); glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio()); QMatrix4x4 mvp = m_projMatrix * m_viewMatrix; // 画坐标轴和刻度线 m_program.bind(); m_program.setUniformValue("mvp", mvp); drawAxesWithTicks(); m_program.release(); // 绘制标签 QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(Qt::white); QFont font = painter.font(); font.setPointSize(9); painter.setFont(font); drawTickLabels(painter); painter.end(); glDisable(GL_DEPTH_TEST); }4. 常见问题与排查技巧实录
4.1 标签文字闪烁或错位
这是我调试时间最长的问题。表现为旋转场景时文字会闪烁,或者位置不对。后来定位到是投影坐标没有做正确的像素映射。QOpenGLWidget在高DPI屏幕上,逻辑宽度和物理像素宽度不一样,必须用devicePixelRatio做缩放。
另外一个细节是,绘制QPainter之前必须先调用glDisable(GL_DEPTH_TEST)。如果深度测试开着,QPainter绘制的文字虽然能显示,但偶尔会在旋转过程中被深度缓存干扰,看起来像闪烁。
4.2 坐标轴虚线效果实现
负半轴我习惯用虚线区别于正半轴。现代OpenGL里实现虚线可以通过glEnable(GL_LINE_STIPPLE),但这是固定管线的特性,可编程管线下不一定支持。一种可靠的做法是拆线段,把虚线当成一系列短线段来画:从0到1遍历,每段画30%,跳过70%,例如在0.0~0.3、0.5~0.8之间绘制线段。
代码上生成顶点时提前计算好,不做运行时间断,效率几乎无损耗。效果上比固定管线更可控,线宽、间距、密度可以任意调整。
4.3 投影矩阵的宽高比适配
投影矩阵如果不适配宽高比,坐标系旋转后会变成“椭圆”,直观感觉等长的轴在不同方向上长度不一样。需要在resizeGL里根据窗口宽高比重建透视投影矩阵:
void AxesWidget::resizeGL(int w, int h) { float aspect = float(w) / float(h); m_projMatrix.setToIdentity(); m_projMatrix.perspective(45.0f, aspect, 0.01f, 100.0f); }我用的是透视投影。如果想做工程图纸类视图,可以换成正交投影,只需要把perspective替换成ortho即可。这时候效果要灵活很多,但刻度标签的绘制逻辑完全一致。
4.4 坐标系和数据的拓展衔接
坐标系做完之后,往里加东西就顺理成章了。之前我在这个坐标系里集成过三维Lissajous曲线、散点云和频域幅值曲面。把这些数据以线段或点集方式填充VBO,绘制时放在坐标轴之后,颜色和坐标轴区分开,视觉层次就很清晰。
特别提一下,如果是为了展示时域转频域的波形数据,可以像QCustomPlot那样在三维坐标系里把FFT之后的频谱曲线作为Y轴数值绘制,坐标系本身是一个极好的承载底座。搜索热词里频繁出现的“qt时域图转换为频域图、qcustomplot”这类需求,本质上就是把一维数组映射到坐标系中的一个维度,有了这套三维坐标系,做出来只是时间问题。
4.5 性能优化:一帧内减少状态切换
很多人在paintGL里频繁绑定和释放着色器、切换VAO,这样在数据量小的时候无感,数据量大时帧率明显下降。坐标轴和刻度线最好合并到同一个VBO里,一次性上传,一次绘制调用完成。刻度标签用QPainter绘制,本身走CPU路径,几十个标签不会成为瓶颈,但如果标签数量上百,就要考虑文字纹理缓存方案。
我个人测试下来,一颗普通桌面CPU,3000个刻度点标签的绘制场景里,QPainter叠加画文字大约占到帧时间的1到2毫秒,完全可接受。真正拖低性能的是把标签投影计算放到了绘图线程之外或者做了一些不必要的矩阵求逆。
5. 坐标系的无限玩法与扩展思路
做完整套坐标系之后,你拥有的不只是一个可视化的“标配零件”,而是一个可以反复复用的三维场景基础。坐标轴、刻度、标签、交互这四件套装在几乎任何三维数据可视化项目里都能直接开工。
我最常用的扩展方向有三个:第一是加网格平面,在XZ平面生成一组平行线,视觉上像铺了一张“地板”,辅助判断数据高度;第二是自定义轴范围,把0~1刻度改成实际工程单位,比如-500~500,代码里只需要引入scale向量;第三是加轴标题,用同样的QPainter叠加逻辑在轴的端点处绘制“X”、“Y”、“Z”或者单位字符串。
这几样东西不会改变底层数据结构,却能让坐标系从“能看”变成“好用”。我这套代码后来被同事用到他们的机器人运动学仿真项目里,当时只花了半天就适配好了,那是因为坐标抽离得足够干净,所有的轴长、刻度间隔、颜色、标签字体全部都是可配置的。
如果你也打算做类似的东西,我的建议是先动手把最基础的轴画出来,让它能转起来,然后逐步加入刻度、标签、网格,每一步都能看得到效果,比起一上来就追求完美架构,效率反而更高。
本文还有配套的精品资源,点击获取