简介:这是一份基于Qt与OpenGL实现的鼠标选中3D模型示例工程,面向具备C++基础、希望掌握OpenGL拾取(Picking)技术的开发者,适用于计算机图形学学习、Qt三维可视化项目二次开发等场景。工程采用Mesh/Model分层结构,完整展示从模型加载、着色器编译、相机交互到鼠标射线拾取判定的核心流程,核心代码注释清晰,可直接在Qt环境中编译运行,便于对照理解选中高亮、深度检测等实现细节。压缩包共127个文件,约42.82MB,文件类型覆盖C++头文件与源文件(h/cpp/hpp)、GLSL着色器(vert/frag)、三维模型(obj/mtl)、Qt界面(ui/qrc)、依赖库(dll/lib)及工程配置文件(pro),资源结构完整,目录层次清楚。已有1810人学习下载,尤其适合作为毕业设计、课程设计或项目实战的参考,可快速复现鼠标点选模型并高亮的交互效果,也能帮助理解OpenGL拾取技术中的射线生成、碰撞判定与响应反馈等关键环节。
1. 用 OpenGL 和 Qt 在 QOpenGLWidget 里做鼠标选中,别直接渲染到屏幕
在三维仿真、医学体数据查看器和工业离线编程这类 Qt 应用里,“鼠标点了某个模型”是一个高频却总被放到最后才做的功能。很多人一开始会陷入一个误区:把鼠标坐标换算成世界坐标,再和每个三角形做交线计算。这条路理论上没问题,但在模型面数达到几十万甚至上亿时,逐三角形求交会让交互线程卡顿明显。另一个常见直觉是“把当前选中模型的颜色改掉”,可用方案往往要管理拾取状态、修改 VBO 数据,还得处理撤销逻辑。
标题里的“选中”本质上是三维拾取(Picking)。在 OpenGL 和 Qt 的组合里,最稳的落地路径不是求交,而是“利用 GPU 光栅化结果做颜色编码”:把模型 ID 写进颜色缓冲,点击时只读取点击位置对应的那一个像素,反查出模型索引。这套方案与 OpenGL 版本无关,在 QOpenGLWidget 的默认上下文中直接可用,也不需要额外引入数学库。本文将按照“硬件拾取原理 → 实现交互信号 → 射线拾取进阶 → 工程化排错”的顺序,给你一套看得见、可运行、能放进生产环境的写法。适合已经会用 QOpenGLWidget 搭出静态场景、想把交互补齐的工程师;如果你对 Qt 的事件循环和 OpenGL 的 FBO 还不够熟,中间章节也给了完整的可直接改用的代码。
2. 用颜色缓冲硬拾取:原理、FBO 设置和点击坐标读取
2.1 为什么颜色编码比射线求交更适合常见场景
射线拾取需要在 CPU 侧维护或构建加速结构,比如 BVH 树,并且每一帧都要做 CPU-GPU 的数据同步或者预计算的包围盒数据。当模型是动态加载的医疗体素网格或者外部导入的 STL 文件时,构建加速结构的时间往往比渲染本身更长。颜色编码拾取直接把问题抛给 GPU:渲染三角形时把模型索引写入颜色分量,GPU 光栅化会天然完成“像素属于哪个三角形”的判断。
提示:分清楚“命中模型”和“命中三角形”。颜色编码拾取默认最小粒度是“模型”或“图元组”。如果你需要精确到三角形表面点,就得给每个三角形分配唯一 ID。前者适合选中整个物体,后者常用来做画笔标注。本文先讲模型级,后面在射线拾取里补充精确到三角面的思路。
颜色编码拾取的精度只依赖帧缓冲的位数。默认的 RGBA8 帧缓冲里,R、G、B 三个通道各 8 位,能组合出 24 位 ID,也就是 16777216 个模型。这对绝大多数场景都足够了。如果你担心高亮模型时颜色冲突,可以把拾取结果定向渲染到纹理,而不是直接显示到屏幕。
2.2 创建离屏 FBO 作为拾取渲染目标
在 QOpenGLWidget 里,常规做法是重写 paintGL(),但拾取不能直接画在用户看到的颜色缓冲里,因为那会闪屏。正确姿势是先绑定一个私有 FBO,把场景渲染到纹理,再读像素。下面定义一个专门的拾取渲染器:
// PickRenderer.h #include <QOpenGLFunctions> #include <QOpenGLFramebufferObject> #include <QOffscreenSurface> class PickRenderer : protected QOpenGLFunctions { public: bool initialize(int width, int height); void beginPickFrame(); void endPickFrame(); unsigned int readIdAt(int x, int y); private: QOpenGLFramebufferObject* m_fbo; QOffscreenSurface* m_surface; QOpenGLContext* m_context; unsigned char m_pixel[4] = {0, 0, 0, 0}; };初始化部分的核心是创建和当前窗口共享的 OpenGL 上下文,并绑定 FBO 的纹理作为颜色附件。每个模型绘制前设置一个 uniform 变量代表它的 ID,绘制完成后调用 glReadPixels。
bool PickRenderer::initialize(int width, int height) { initializeOpenGLFunctions(); m_fbo = new QOpenGLFramebufferObject(width, height); return m_fbo->isValid(); }注意 QOpenGLFramebufferObject 默认颜色附件格式是 RGBA8,读出来的像素就是 4 字节。拾取时如果模型需要抗锯齿,可以给 FBO 配上 MSAA,但 glReadPixels 仍然只返回样本解析后的颜色,不影响 ID 读取。
2.3 绘制阶段给模型嵌入 ID 的 shader 写法
模型顶点着色器可以复用正常的投影和模型矩阵,但在片段着器里必须把 ID 展开成颜色。常见的做法是用位运算把整数 ID 拆到 RGB 通道:
// pick.vert #version 330 core layout(location = 0) in vec3 aPos; uniform mat4 uProj; uniform mat4 uView; uniform mat4 uModel; void main() { gl_Position = uProj * uView * uModel * vec4(aPos, 1.0); }// pick.frag #version 330 core uniform int uObjectId; uniform float uHighlightFactor; // 高亮时可以走 0.0 ~ 1.0,不用改 shader out vec4 FragColor; void main() { float r = float((uObjectId & 0xFF)) / 255.0; float g = float((uObjectId >> 8) & 0xFF) / 255.0; float b = float((uObjectId >> 16) & 0xFF) / 255.0; FragColor = vec4(r, g, b, 1.0); }参数说明:
- uObjectId 的位数决定了最大模型数。R 通道取低 8 位,G 取中 8 位,B 取高 8 位,所以 ID 区间是 0 到 16777215。如果 ID 超过这个范围,就改用两个像素或者 32 位浮点纹理。
- uHighlightFactor 在这里用来实现“点击后立刻高亮”的预览效果,实际按高亮时混合还是直接替换颜色,取决于你要不要保留原有的选中状态。
绘制循环里对每个模型调用:
glUseProgram(pickProgram); glUniformMatrix4fv(uProj, 1, GL_FALSE, projMatrix.constData()); glUniformMatrix4fv(uView, 1, GL_FALSE, viewMatrix.constData()); glUniformMatrix4fv(uModel, 1, GL_FALSE, modelMatrix.constData()); glUniform1i(uObjectId, model->id()); glBindVertexArray(model->vao()); glDrawElements(GL_TRIANGLES, model->vertexCount(), GL_UNSIGNED_INT, nullptr);这里的绘制流程和主渲染完全一样。为避免重复 C++ 代码,我一般把两种 program 封装成同一个 Renderable 结构,drawCall 接受一个 program 句柄。如果模型用了纹理,拾取 shader 不需要采样纹理,更能保证 ID 颜色不被纹理颜色干扰。
2.4 Qt 里把鼠标坐标映射到 FBO 像素并反算 ID
QOpenGLWidget 的高分屏和 FBO 尺寸之间有一个缩放关系。如果 QOpenGLWidget 的实际像素尺寸是 widgetWidth * devicePixelRatio,而 FBO 是按照物理像素创建的,那么事件里的 pos() 需要先乘上 devicePixelRatioF() 再传给 glReadPixels。
void ViewportWidget::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { QOpenGLWidget::mousePressEvent(event); return; } QPoint widgetPos = event->pos(); qreal dpr = windowHandle() ? windowHandle()->devicePixelRatio() : 1.0; int fbX = int(widgetPos.x() * dpr); int fbY = int(widgetPos.y() * dpr); // FBO 的坐标原点在左下角,而 Qt 窗口坐标原点在左上角 int pickY = m_fboHeight - fbY; unsigned int id = m_pickRenderer->readIdAt(fbX, pickY); // 注意:这里不能立即在这个函数里做 glReadPixels,除非确保上下文当前绑定了 FBO。 // 常见做法是在 paintGL 的最后将鼠标悬停点写入成员变量,下一帧再读取渲染结果, // 或者直接在鼠标事件里 makeCurrent() 绑定 m_fbo 然后读取。 }这条注释背后是一个隐蔽的坑:鼠标事件触发时机处于 Qt 事件循环,而 OpenGL 命令必须发生在对应的 context 绑定期间。如果你在事件回调里直接调用 glReadPixels,需要先 makeCurrent() 并绑定 FBO。更稳妥的思路是记录坐标,在 paintGL 里完成读取。每次鼠标移动触发 update(),拾取开销只有一次 ReadPixels 加一次场景绘制。这种做法称为“延迟拾取”,特别适合渲染本身在 60 FPS 上下、读像素又不频繁的场景。
3. 把选中结果变成 Qt 信号:从点选到交互动效
3.1 鼠标移动连续拾取与悬停反馈
模型悬停高亮需要连续拾取,但连续调用 glReadPixels 会打断渲染流水线。常见的位移思路是把拾取渲染放到独立的 FBO,每帧只进行一次。以下代码实现了悬停 ID 跟踪:
void ViewportWidget::mouseMoveEvent(QMouseEvent* event) { m_hoverPos = event->pos(); update(); // 触发 paintGL } void ViewportWidget::paintGL() { // 正常渲染场景 drawMainScene(); // 拾取阶段 if (m_hoverPos.has_value()) { int pickId = performPick(m_hoverPos.value()); if (pickId != m_hoveredId) { m_hoveredId = pickId; emit hoverIdChanged(pickId); } } }performPick 函数内部是:绑定 FBO,清颜色,绘制所有模型到 FBO,绑定默认缓冲,ReadPixels。注意要关闭深度测试以外的混合,否则 ID 颜色会被叠加。
提示:不要用 QTimer 做高频拾取。鼠标移动事件的触发频率已经足够,渲染管线里的 FBO 往返一次在 1024x768 下大约 0.2 到 0.5 毫秒,比起 CPU 求交要快得多。
3.2 高亮模型的两种实现:替换颜色与描边
获得选中的 model id 之后,最容易满足视觉效果的是切换颜色。在正常渲染的 shader 里加一个 uniform int uSelectedId,片段颜色按是否等于该 id 做插值:
uniform int uSelectedId; uniform vec3 uBaseColor; uniform vec3 uSelectedColor; uniform int uObjectId; ... vec3 finalColor = (uObjectId == uSelectedId) ? uSelectedColor : uBaseColor;这个方案的缺点是轮廓不够醒目,尤其是多个模型颜色相近时。更专业的做法是“轮廓描边”,在拾取到模型后,单独对该模型进行背面放大渲染,表面反转,再绘制一个纯色轮廓。轮廓厚度可以枚举为深度偏移量:
| 方法 | 效果 | 额外开销 | 适用场景 |
|---|---|---|---|
| 顶点外扩法(Backface Stencil) | 轮廓宽度均匀,适合游戏风格 | 两次 draw、一次 stencil 操作 | 单个模型高亮 |
| 屏幕空间边缘检测 | 边缘精细,适合 CAD 风格 | 需要后处理全屏纹理 | 多个模型同时高亮 |
| 纹理颜色替换 | 实现最简单 | 无额外开销 | 原型验证、低面数 |
顶点外扩法在实践里效果最好。绘制流程为:先关闭深度写入,启用模板测试,正常绘制选中模型并写入模板;再开启背面剔除和顶点沿法线偏移,只画模板里没有写入的片段,颜色设置为纯白。这种描边不会和模型自身 Z 值冲突,适合 Qt 窗口里快速加交互反馈。
3.3 让拾取结果通过信号槽进入业务层
把 OpenGL 选中的 ID 直接暴露给 UI 层,模块边界会很糟糕。我通常定义一个纯数据类 PickEvent:
struct PickEvent { uint32_t modelId; QPoint widgetPos; uint32_t triangleIndex; // 精确拾取时使用,模型级默认 0 bool valid; }; Q_DECLARE_METATYPE(PickEvent)然后在 QOpenGLWidget 派生类里声明信号:
signals: void modelPicked(const PickEvent& pick);在捡取完成的流程里发出信号。业务层可以自由决定是否弹出属性面板、是否更新选择集合。信号槽跨线程时,需要保证 PickEvent 是已注册的 metatype,默认 Q_DECLARE_METATYPE 即可。
这类设计的优势是:你的 OpenGL 窗口不依赖具体业务模型,几何仓库、属性面板、属性编辑器全部通过监听信号来响应。模型被选中后,如果需要执行删除、移动或着变色,代码全部集中在业务对象内部。
4. 从窗口坐标到世界坐标:射线拾取的正确交互模型
4.1 用 glm::unProject 构建射线,并解决 y 轴翻转
颜色编码解决了模型级拾取,但如果你要拾取的是“xyz 坐标点”,比如鼠标在模型表面打标记,仅靠 ID 还不够。这时需要把鼠标坐标反投影到世界坐标空间,和网格求交。Qt 的窗口坐标和 OpenGL 的 NDC 坐标最大的差异在 y 轴方向,OpenGL NDC 的 y 轴向上,窗口坐标的 y 轴向下。unProject 的参数里,winZ 从深度缓冲读取,距离相机近处是 0,远处是 1。
#include <QVector3D> #include <QMatrix4x4> #include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> glm::vec3 screenToWorld(const QPointF& pos, const QMatrix4x4& proj, const QMatrix4x4& view, int viewportWidth, int viewportHeight) { glm::vec3 winPos(pos.x() * devicePixelRatioF(), viewportHeight - pos.y() * devicePixelRatioF(), 0.0f); // 进平面 glm::mat4 glmProj = glm::make_mat4(proj.constData()); glm::mat4 glmView = glm::make_mat4(view.constData()); glm::vec3 world = glm::unProject(winPos, glmView * glm::inverse(glmProj)); return world; }这段代码的关键是最后那个乘法的顺序。其实 glm::unProject 内部自己做了逆矩阵计算,你直接传 view 矩阵和 proj 矩阵即可。很多工程上误把 model 矩阵也乘进 view,导致拾取点在模型世界空间和全局世界空间之间偏移。
4.2 与包围盒求交:先用 AABB 粗筛,再按层级细分
射线和模型表面求交不需要一上来就和三角形逐一计算。一般做法是:每个模型上传时计算一个 OBB 或 AABB 包围盒,射线先和每个包围盒做 slab 算法求交,命中后再进入网格检测。这样在几百个模型的场景里,每帧可以保持 1ms 以下。
bool intersectRayAABB(const QVector3D& orig, const QVector3D& invDir, const QVector3D& bmin, const QVector3D& bmax, float& tMin, float& tMax) { float t1 = (bmin.x() - orig.x()) * invDir.x(); float t2 = (bmax.x() - orig.x()) * invDir.x(); tMin = qMin(t1, t2); tMax = qMax(t1, t2); float t3 = (bmin.y() - orig.y()) * invDir.y(); float t4 = (bmax.y() - orig.y()) * invDir.y(); tMin = qMax(tMin, qMin(t3, t4)); tMax = qMin(tMax, qMax(t3, t4)); if (tMax < tMin) return false; float t5 = (bmin.z() - orig.z()) * invDir.z(); float t6 = (bmax.z() - orig.z()) * invDir.z(); tMin = qMax(tMin, qMin(t5, t6)); tMax = qMin(tMax, qMax(t5, t6)); return tMax >= tMin; }invDir 方向分量是 1/dir,用来避免重复除法,这是 GPU 上的常规优化。你还需要把射线变换到模型的局部坐标系,否则模型旋转后包围盒失效。具体的变换方法是对 ray 的 origin 和 direction 做 model 矩阵的逆变换,密集模型中逐模型求逆代价高,建议每个模型缓存其逆矩阵。
4.3 精确到三角形:Möller–Trumbore 算法加入工程
包围盒命中之后,你面对的是几千到几十万个三角形。逐个遍历并求交很慢,但如果你只是交互时取一点,用 Möller–Trumbore 算法整段跑完,时间仍然可以接受。一个测过 200 万三角形的模型,在近似最坏场景下约为 20~40 微秒。而如果你想让拾取更快,可以建立空间网格或者 BVH,但那属于预处理范畴。
bool rayTriangleIntersect(const QVector3D& orig, const QVector3D& dir, const QVector3D& v0, const QVector3D& v1, const QVector3D& v2, float& t, float& u, float& v) { QVector3D edge1 = v1 - v0; QVector3D edge2 = v2 - v0; QVector3D pvec = QVector3D::crossProduct(dir, edge2); float det = QVector3D::dotProduct(edge1, pvec); if (qFabs(det) < 1e-8) return false; float invDet = 1.0f / det; QVector3D tvec = orig - v0; u = QVector3D::dotProduct(tvec, pvec) * invDet; if (u < 0.0f || u > 1.0f) return false; QVector3D qvec = QVector3D::crossProduct(tvec, edge1); v = QVector3D::dotProduct(dir, qvec) * invDet; if (v < 0.0f || u + v > 1.0f) return false; t = QVector3D::dotProduct(edge2, qvec) * invDet; return t > 0.0f; }这个函数返回 u、v 重心坐标和 t 距离。如果要精确求命中点,用 (1-u-v)*v0 + u*v1 + v*v2。放入工程时,可以把三角形的顶点缓存为 float 数组,减少 QVector3D 构造开销。上千个三角形逐次调用时,这种细节会直接影响帧率。
4.4 混合拾取策略:颜色编码做初筛,射线做精定位
两种拾取方式并非对立,实际生产里最佳策略是先做颜色拾取获得 modelId,拿到模型的本地坐标矩阵后,再用射线和该模型的三角形求交。这样避免了全局射线对所有模型做包围盒检测的浪费,也绕开了颜色 FBO 在精确三角形命中上的软肋。
具体的执行顺序是:
- 鼠标点击,先走 2、3 章的 FBO 拾取,拿到 modelId。
- 如果 modelId 合法,将射线变换到该 model 的物体空间。
- 读取该模型的 VBO 顶点数据(CPU 侧备份或必要的 glMapBufferRange),执行 AABB 粗筛加三角形精求交。
这方案在 Qt 和 OpenGL 的组合里最贴合实际工程场景:颜色拾取给硬件加速,射线求交负责精度和坐标,两个手段各干各的强项。
5. 把拾取结果绑定到业务操作:悬停标记、点选列表和动态更新
5.1 合并在拾取阶段更新的选择集合
多个模型同时被选中时,你需要一个集合来管理选中状态。常见做法是用 QSet<uint32_t> 保存在 QOpenGLWidget 的子类里。单击替换选中,Ctrl+单击追加选择,Shift+范围选择。这些状态的维护和 OpenGL 无关,可以放到独立的 SelectionController 中,把 view 层完全隔离。
class SelectionController { public: void click(uint32_t id, bool additive) { if (additive) { if (m_selection.contains(id)) m_selection.remove(id); else m_selection.insert(id); } else { m_selection.clear(); m_selection.insert(id); } emit selectionChanged(m_selection); } private: QSet<uint32_t> m_selection; signals: void selectionChanged(const QSet<uint32_t>& selection); };交点用 QSet 记录模型 ID,UI 面板和属性栏都监听 selectionChanged,这样拾取组件不需要知道模型的名字或类型。最重要的是,这个控制器和 OpenGL 完全隔离,后续做 Qt 命令撤销时可以直接复用它。
5.2 UI 层反馈:自定义边框、属性面板和状态栏显示
选中模型后,属性面板如果实时刷新,体验会好很多。不必重新创建一个复杂的数据管理系统,只需要在 selectionChanged 信号触发后遍历模型仓库,把对应模型的名称、面数、包围盒尺寸发出去。
void ModelInspector::updateSelection(const QSet<uint32_t>& selection) { m_list->clear(); for (uint32_t id : selection) { auto* model = m_repository->find(id); if (model) { new QListWidgetItem(model->name(), m_list); } } }如果模型数量大,频繁发射信号会导致 UI 闪烁。可以在控制器内部加入定时批处理,比如 100ms 窗口内聚合多次变化的 selection 再 emit 一次。这个技巧被常用于鼠标拖动连续拾取时的流畅反馈。
5.3 鼠标离开窗口后的拾取失效处理
鼠标移出 QOpenGLWidget 时,应清空悬停状态,否则最后一次拾取结果会留在画面里。在 QOpenGLWidget 中可以用 enterEvent 和 leaveEvent 重写,或者安装事件过滤器:
bool ViewportWidget::eventFilter(QObject* obj, QEvent* event) { if (event->type() == QEvent::Leave) { m_hoverId = -1; m_hoverPos.reset(); update(); } return QOpenGLWidget::eventFilter(obj, event); }这样能避免交互层残留上一次悬停模型的高亮。尤其在 Qt 窗口被别的窗口遮挡或鼠标移动到其他控件上时,不清理会误导用户。
5.4 动态更新模型时的拾取同步
如果你在运行期动态加载、删除或移动模型,必须同步更新拾取相关缓存。最容易出错的场景是你用颜色拾取拿到了一个 ID,但这个模型已经删除,GPU 里的 VBO 也被释放。这里建议在 FBO 拾取渲染之前查一次 ID 映射表,把不再存在的模型的 uniform 值设成一个保留 ID,比如 0xFFFFFF,读出来后在上层判断是否有效。
bool isModelValid(uint32_t id, const QMap<uint32_t, Model*>& models) { return models.contains(id); }FBO 拾取的 uniform 更新必须发生在 glDrawElements 之前,也就是对每个模型重新绑定 uniform。如果模型的可见性被关闭,不要直接跳过绘制,否则 ID 颜色缓冲区里会残留上一次的颜色,导致误判。正确做法是设置一个 disable 标志,绘制时仍以透明方式写入 ID,再在读取端丢弃。
6. 工程化落地:把拾取做好看也做稳的 6 个细节
6.1 用 Y-flip 正反馈验证坐标映射
坐标变换错误是此类项目最隐蔽的问题。建议在窗口中心绘制一个标准立方体,鼠标点其中心时打印读到的 ID 并输出调试坐标。如果点击位置偏向任意轴,优先检查 devicePixelRatioF 和 FBO 尺寸。很多时候问题不在 shader,而在 Qt 的 high-DPI 模式导致的坐标偏差。
6.2 读像素前必须恢复默认帧缓冲
调用 glReadPixels 是绑定了拾取 FBO 的,读取完成后应立即绑定回默认缓冲,否则下一帧场景绘制会错乱。一种常见的安全写法是:
m_fbo->release(); glBindFramebuffer(GL_FRAMEBUFFER, defaultFramebufferObject());defaultFramebufferObject() 是 QOpenGLWidget 提供的默认 FBO 句柄,在 Qt 5.5 以后的版本里,都建议用它而不是写死 0。
6.3 深度测试的坑:拾取 FBO 必须和主渲染使用相同深度缓冲精度
如果主渲染用 24 位深度,而拾取 FBO 的深度附件也必须是 24 位。QOpenGLFramebufferObject::Depth 选项默认能满足需求。如果拾取 shader 里忘记开启深度测试,远处模型会覆盖近处模型,导致点击结果错误。拾取渲染前必须显式开启:
glEnable(GL_DEPTH_TEST); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);6.4 大量模型选择时启用拾取缓冲裁剪
场景极其庞大时,FBO 里的所有模型每帧都渲染一遍是浪费。在 paintGL 里做拾取之前,先判断鼠标是否在窗口内,再用一个“选中范围内绘制”的裁剪逻辑。最简单的优化是把屏幕外物体的 draw call 跳过,但这对拾取没有帮助,因为物体可以部分可见但仍需拾取。合理的办法是做视锥裁剪,这是 OpenGL 的经典话题,Qt 里用 QFrustum 或者自写平面检测均可。
6.5 把读取结果和 UI 的闪烁问题一起解决
如果不是点击而是悬停高亮,鼠标在每个模型间快速移动会产生大量 Qt 信号。配合 5.2 里的批量发射策略,同时用 QTimer::singleShot(0, this, [this]{ refreshUI(); }) 合并同一帧内的多次更新,可以显著降低 CPU 峰值。到这里,整个拾取功能才算“完工”:它不仅有正确的命中结果,还能保证在快速交互时 UI 稳定。
OpenGL 和 Qt 的拾取组合,核心技巧只有两条:一是让 GPU 替你回答“点在哪个模型上”,二是把模型 ID 从颜色里解出来。做到这两点后,射线求交只是补充精确度的配件,而不是第一件要做的事。如果你的项目恰好用 QOpenGLWidget 渲染医学体素或者连续点云,把本文的颜色编码拾取和射线求交按模型结构拆开,就能看到点击选中的实体名称和坐标都出现在状态栏里,整个交互链路在这个点真正闭环了。
本文还有配套的精品资源,点击获取