1. 场景与需求:为什么每个三维交互都离不开坐标转换
1.1 一个典型的鼠标拾取需求
在 QOpenGLWidget 里做三维交互,最常见的第一道坎就是鼠标拾取:我想点击屏幕上的某个立方体,然后让这个立方体高亮、被选中、或者被拖走。鼠标给你的只是两个坐标,x 和 y,单位是像素;可立方体活在三维世界里,位置是 (x, y, z),单位是空间长度。这两个东西怎么关联起来?答案就是标题里的那句话:窗口坐标与世界坐标系的转换。
如果你只用 Qt 做过普通二维界面,可能觉得这件事很绕。鼠标点一下,不就是在屏幕对应位置拿到控件吗?二维界面里控件本身就在屏幕上有位置,框架帮你算好了。但三维渲染不一样:屏幕上那个像素点是摄像机把三维场景“拍扁”之后的结果,同一个屏幕位置,在不同深度上可能压着几十个物体。鼠标给的信息天然少了一维,缺的就是深度 z。gluProject / gluUnProject 这对函数,就是用来在“三维空间坐标”和“二维窗口坐标”之间做正反变换的标准工具。
下面我会把这些坐标到底怎么转、Qt 里有哪些坑、矩阵从哪里拿,一步步拆开讲。适合刚开始接触 QOpenGLWidget 交互的开发者,也适合用过 gluUnProject 但总出诡异 bug 的人——大部分诡异 bug,最后都集中在坐标系约定和矩阵一致性问题上面。
1.2 四套坐标系,一条完整的链路
要做转换,先得把这条链路上涉及的四套坐标说清楚。它们分别是:
- 世界坐标:物体在三维场景中的位置,也是你建模、摆放物体时用的坐标。
- 眼坐标(观察坐标):以摄像机为原点的坐标,摄像机朝 -Z 方向看。
- 规范化设备坐标(NDC):所有变换做完并除以 w 之后的坐标,x、y、z 范围都在 [-1, 1]。
- 窗口坐标:最终绘制在屏幕上的像素坐标,内容包含 x、y 像素位置和 z 深度值。
世界坐标到窗口坐标这条链路,基本可以写成:
世界坐标 → 模型视图矩阵 → 眼坐标 → 投影矩阵 → 裁剪坐标 → 透视除法 → NDC → 视口变换 → 窗口坐标gluProject 就是顺着这条链路把三维坐标变成屏幕坐标,gluUnProject 则反向执行。说穿了不复杂,但工程实现里,矩阵来源、y 轴方向、高分屏缩放这三个问题不处理好,转换结果立刻面目全非。
2. 数学原理:gluProject / gluUnProject 的每一步
2.1 正向链路:世界坐标到窗口坐标
在固定管线的时代,GLU 库提供了现成的 gluProject,它接收四个参数:世界坐标 (wx, wy, wz)、模型视图矩阵 modelview、投影矩阵 projection、视口参数 viewport[4]。其中 viewport 就是 glViewport 设置的四个整数:x,y,width,height。
它对输入的世界坐标做的事情,等价于下面这串计算:
- 用模型视图矩阵乘世界坐标,得到眼坐标 (ex, ey, ez, ew)。如果模型视图矩阵还包含位移,ew 通常是 1,但这个分量一定要保留,后续会用到。
- 用投影矩阵乘眼坐标,得到裁剪坐标 (cx, cy, cz, cw)。
- 做透视除法,也就是把裁剪坐标的 x、y、z 分别除以 cw,得到 NDC 坐标。这一步把透视投影产生的“远小近大”效果固化下来。在正交投影下 cw 恒为 1,透视除法不改变值,原理上仍然要走这一步。
- 做视口变换:NDC 的 x、y 范围是 [-1, 1],视口变换把它映射到像素坐标。公式是:
winX = viewport[0] + (ndcX + 1) / 2 * viewport[2] winY = viewport[1] + (ndcY + 1) / 2 * viewport[3]注意这里是 OpenGL 的窗口坐标约定,原点在左下角,y 轴向上。如果你直接用 Qt 的鼠标事件坐标,y 轴是向下的,必须翻转。
窗口深度值的公式也一并给出:
winZ = (ndcZ + 1) / 2OpenGL 的深度缓冲默认范围是 [0, 1],而 NDC 的 z 范围是 [-1, 1],中间就是一次线性映射。很多人在这一步纠结深度值,其实不用想太多,记住 (ndcZ + 1) / 2 就够了。
2.2 逆向链路:窗口坐标回到世界坐标
gluUnProject 做的事情就是倒着走刚才那条链路,输入窗口坐标 (winX, winY, winZ),配合同样的模型视图矩阵、投影矩阵、视口参数,输出世界坐标 (wx, wy, wz)。
关键一步是求“模型视图矩阵 × 投影矩阵”的逆矩阵。如果这个矩阵不可逆,函数会返回 GL_FALSE,说明矩阵退化。实际项目中,投影矩阵的 near、far 相同,或者模型视图矩阵里的缩放变成 0,都会导致不可逆。正常的透视投影矩阵基本不会出问题,但写代码时仍然要检查返回值。
逆变换的计算步骤:
- 视口逆变换:把窗口坐标映射回 NDC。
- 乘以组合逆矩阵:把 NDC 坐标变成世界坐标。
- 除以 w 分量:因为此时世界坐标可能带着 w,要规范化成 w=1 的四维点坐标。
这里隐藏着一个非常容易被忽略的细节:gluUnProject 的 y 参数,同样必须遵守 OpenGL 窗口坐标系,也就是 y 轴向上。你把 Qt 里拿到的鼠标 y 原封不动传进去,得到的结果一定会上下颠倒。
2.3 z 值的两条路线:深度缓冲与射线构造
窗口坐标有 x、y、z 三个分量,鼠标只给了 x 和 y,那么 z 从哪来?这是整个转换里最需要理解的设计决策。
第一条路线是读取深度缓冲。OpenGL 在绘制物体时,会把每一个片元的深度写进深度缓冲。你可以在鼠标位置调用 glReadPixels 读取 GL_DEPTH_COMPONENT,得到一个 [0, 1] 的深度值,然后把这个值作为 winZ 传给 gluUnProject。这样得到的世界坐标,恰好是鼠标位置“最前面的那个片元”对应的三维位置。适合做点选、测距、吸附等需要“点到物体表面”的操作。
第二条路线是用射线构造。你并不关心具体是哪个深度,而是想构建一条从摄像机出发、穿过鼠标位置的三维射线,让这条射线去和场景里的物体做相交检测。做法很简单:分别对 winZ=0 和 winZ=1 做两次 gluUnProject,得到两个世界坐标,连成一条线。前者对应近裁剪面上的点,后者对应远裁剪面上的点。
射线法比深度法更通用:物体周围有透明遮挡、物体是线框模式、深度缓冲被多次复用导致值不可靠、或者你想在没有任何可见物体的地方也能做交互,射线法都不会受影响。我在实际项目里,除非确实需要“取到物体表面的精确点”,否则优先走射线。
提示:gluUnProject 返回的坐标是“世界坐标”,但如果你在做物体拾取时传入了单独的模型矩阵,那得到的是经过模型变换后的世界坐标。要不要再逆变换到模型局部空间做检测,取决于你场景的组织方式,后面实战部分会给出一种做法。
3. Qt 环境下的工程实现
3.1 QOpenGLWidget 的坐标系约定和高 DPI 陷阱
现在正式进入 Qt。QOpenGLWidget 里有两套坐标系并存。一套是 Qt 的事件坐标:鼠标事件给出来的 position() 以控件左上角为原点,x 向右,y 向下。另一套是 OpenGL 的窗口坐标:以视口左下角为原点,x 向右,y 向上。
所以第一件事永远是翻转 y:
int glY = widgetHeight - eventY;这个 widgetHeight 要小心。如果你在 Qt 平台开启高 DPI 缩放,逻辑坐标和物理像素是不一致的。QOpenGLWidget 内部渲染使用的是物理像素,鼠标事件的逻辑坐标必须先乘以 devicePixelRatio,才能得到对应的物理像素坐标。我见过不少次“拾取偏 1 到 2 像素”的问题,最后都查到这里。
完整转换通常这样写:
qreal dpr = devicePixelRatioF(); double mouseX = event->position().x() * dpr; double mouseY = event->position().y() * dpr; double glY = height() * dpr - mouseY;在 Qt6 中,鼠标事件建议用 e->position(),而不是老的 e->x(),后者已经标记为废弃。另外,如果你在 paintGL 里设置 glViewport 时已经乘过 dpr,那后续所有换算都基于物理像素,保持一致就不会乱。
3.2 矩阵从哪来:现代着色器下的矩阵管理
固定管线时代,矩阵由 OpenGL 内部维护,你可以直接通过 glGetDoublev 把 GL_MODELVIEW_MATRIX 和 GL_PROJECTION_MATRIX 取出来传给 gluProject。但到了现代 OpenGL,也就是 QOpenGLWidget 搭配着色器的主流写法,矩阵栈已经被彻底移除。你自己管理矩阵,自然也就知道矩阵在哪儿——就是你设给 uniform 的那两个 QMatrix4x4。
因此,最稳健的做法是在 QOpenGLWidget 的子类里维护两个成员变量:
QMatrix4x4 m_projection; QMatrix4x4 m_view;绘制时:
m_projection.setToIdentity(); m_projection.perspective(60.0f, aspectRatio, 0.1f, 100.0f); m_view.setToIdentity(); m_view.lookAt(cameraPos, cameraTarget, QVector3D(0, 1, 0));然后把 m_projection * m_view 作为 uniform 传给顶点着色器。坐标转换时,同样只用这两个矩阵,就能保证“渲染用的矩阵”和“计算拾取用的矩阵”完全一致。这是一个极其重要的一致性约定:转换矩阵和渲染矩阵必须是同一份。很多人把 view 矩阵临时改了角度去测试拾取,渲染没变,转换却用了新矩阵,结果自然对不上。
如果你真想保留旧式 GLU 调用,也不是不行。在 .pro 里加上LIBS += -lGLU,包含<GL/glu.h>,然后在画完一帧后取矩阵。但 GLU 是固定管线的“遗物”,现代核心模式下很多环境根本链接不上,即使能链接,也用不到它的核心价值。我更推荐用 Qt 自己的矩阵类实现同样功能,十行代码,可控性高得多。
3.3 手写 project / unproject 函数
用 QMatrix4x4 实现 gluProject / gluUnProject 的逻辑并不复杂。下面这两个函数可以直接拷进项目里用。
#include <QMatrix4x4> #include <QRect> #include <QVector3D> #include <QVector4D> QVector3D projectPoint( const QVector3D& worldPos, const QMatrix4x4& modelView, const QMatrix4x4& projection, const QRect& viewport) { QVector4D clip = projection * (modelView * QVector4D(worldPos, 1.0f)); if (qFuzzyIsNull(clip.w())) return QVector3D(0.0f, 0.0f, 0.0f); QVector3D ndc = clip.toVector3D() / clip.w(); float winX = viewport.x() + (ndc.x() + 1.0f) * 0.5f * viewport.width(); float winY = viewport.y() + (ndc.y() + 1.0f) * 0.5f * viewport.height(); float winZ = (ndc.z() + 1.0f) * 0.5f; return QVector3D(winX, winY, winZ); } bool unprojectPoint( const QVector3D& winPos, const QMatrix4x4& modelView, const QMatrix4x4& projection, const QRect& viewport, QVector3D& worldPos) { float ndcX = (winPos.x() - viewport.x()) / viewport.width() * 2.0f - 1.0f; float ndcY = (winPos.y() - viewport.y()) / viewport.height() * 2.0f - 1.0f; float ndcZ = winPos.z() * 2.0f - 1.0f; bool invertible = false; QMatrix4x4 combined = projection * modelView; QMatrix4x4 inv = combined.inverted(&invertible); if (!invertible) return false; QVector4D world = inv * QVector4D(ndcX, ndcY, ndcZ, 1.0f); if (qFuzzyIsNull(world.w())) return false; worldPos = world.toVector3D() / world.w(); return true; }这段代码里有一个容易写错的点:clip.toVector3D() 只是丢弃 w 分量,并不会除以 w。所以必须手动 clip.toVector3D() / clip.w()。QVector3D 除以 float 返回的还是 QVector3D,这个写法没问题。
另一个细节:QMatrix4x4 默认是列主序,和 OpenGL 的矩阵惯用法一致。projection * (modelView * point)表示先把点变换到眼坐标,再变换到裁剪坐标,数学上完全对应前面推导的链路。不要让乘法的顺序混乱,这是很多初学者对不上结果的原因。
有了这两个函数,gluProject 和 gluUnProject 的工作就被完整替代了。接下来看看它们怎么支撑一个真正的交互功能。
4. 完整案例:鼠标拾取与拖拽三维物体
4.1 场景搭建与渲染矩阵准备
为了演示,我建一个最简单的场景:一个立方体和一个球体。立方体有自己的模型矩阵,可以平移和旋转;球体放在另一个位置。渲染时,每个物体分别计算 MVP:
void MyGLWidget::paintGL() { glClearColor(0.9f, 0.9f, 0.9f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); m_projection.setToIdentity(); m_projection.perspective(60.0f, float(width()) / height(), 0.1f, 100.0f); m_view.setToIdentity(); m_view.lookAt(QVector3D(0, 2, 6), QVector3D(0, 0, 0), QVector3D(0, 1, 0)); // 立方体 m_cubeModel.setToIdentity(); m_cubeModel.translate(-1.5f, 0.0f, 0.0f); QMatrix4x4 mvpCube = m_projection * m_view * m_cubeModel; // 把这个 mvpCube 送到着色器 drawCube(mvpCube); // 球体 m_sphereModel.setToIdentity(); m_sphereModel.translate(1.5f, 0.0f, 0.0f); QMatrix4x4 mvpSphere = m_projection * m_view * m_sphereModel; drawSphere(mvpSphere); }这个场景里的 m_view 和 m_projection 就是坐标转换要用的矩阵。从这一刻开始,请牢记一个原则:拾取代码里出现的矩阵,必须就是这里渲染用的那两个矩阵。
4.2 射线拾取:点击判断物体
鼠标按下,第一步构建射线:
void MyGLWidget::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) return; qreal dpr = devicePixelRatioF(); float mouseX = event->position().x() * dpr; float mouseY = event->position().y() * dpr; float glY = height() * dpr - mouseY; QRect viewport(0, 0, int(width() * dpr), int(height() * dpr)); QVector3D nearPos, farPos; if (!unprojectPoint(QVector3D(mouseX, glY, 0.0f), m_view, m_projection, viewport, nearPos)) return; if (!unprojectPoint(QVector3D(mouseX, glY, 1.0f), m_view, m_projection, viewport, farPos)) return; m_rayOrigin = nearPos; m_rayDir = (farPos - nearPos).normalized(); m_dragging = pickObject(m_rayOrigin, m_rayDir, &m_pickedId); }pickObject 的做法是把射线变换到每个物体的局部坐标系,再用局部空间里的 AABB 做碰撞。为什么要变换到局部空间?因为 AABB 在模型空间里计算最简单,立方体的 min 和 max 是 (-0.5, -0.5, -0.5) 和 (0.5, 0.5, 0.5),不需要根据位移旋转去重新计算八个顶点的世界坐标。
bool MyGLWidget::rayIntersectsAABB( const QVector3D& origin, const QVector3D& dir, const QVector3D& boxMin, const QVector3D& boxMax, float& t) { float tmin = 0.0f, tmax = std::numeric_limits<float>::max(); for (int i = 0; i < 3; ++i) { float o = i == 0 ? origin.x() : (i == 1 ? origin.y() : origin.z()); float d = i == 0 ? dir.x() : (i == 1 ? dir.y() : dir.z()); float bmin = i == 0 ? boxMin.x() : (i == 1 ? boxMin.y() : boxMin.z()); float bmax = i == 0 ? boxMax.x() : (i == 1 ? boxMax.y() : boxMax.z()); if (qAbs(d) < 1e-8) { if (o < bmin || o > bmax) return false; } else { float t1 = (bmin - o) / d; float t2 = (bmax - o) / d; if (t1 > t2) std::swap(t1, t2); tmin = std::max(tmin, t1); tmax = std::min(tmax, t2); if (tmin > tmax) return false; } } t = tmin; return true; }射线变换到局部空间:
bool MyGLWidget::pickObject( const QVector3D& rayOrigin, const QVector3D& rayDir, int* pickedId) { // 依次检测立方体 QMatrix4x4 invCube = m_cubeModel.inverted(); QVector3D localOrigin = invCube.map(rayOrigin); QVector3D localDir = invCube.mapVector(rayDir).normalized(); float tCube; bool hitCube = rayIntersectsAABB( localOrigin, localDir, QVector3D(-0.5f, -0.5f, -0.5f), QVector3D(0.5f, 0.5f, 0.5f), tCube); // 依次检测球体,这里用半径做简化球体相交测试 QMatrix4x4 invSphere = m_sphereModel.inverted(); QVector3D sphereOrigin = invSphere.map(rayOrigin); QVector3D sphereDir = invSphere.mapVector(rayDir).normalized(); QVector3D sphereCenter(0, 0, 0); float radius = 0.6f; QVector3D oc = sphereOrigin - sphereCenter; float a = QVector3D::dotProduct(sphereDir, sphereDir); float b = 2.0f * QVector3D::dotProduct(oc, sphereDir); float c = QVector3D::dotProduct(oc, oc) - radius * radius; float discriminant = b * b - 4.0f * a * c; float tSphere = -1.0f; if (discriminant >= 0.0f) tSphere = (-b - std::sqrt(discriminant)) / (2.0f * a); // 取最近的命中物体 if (hitCube && tSphere >= 0.0f) *pickedId = tCube < tSphere ? 0 : 1; else if (hitCube) *pickedId = 0; else if (tSphere >= 0.0f) *pickedId = 1; else return false; return true; }这里要注意 QMatrix4x4::map 和 mapVector 的区别。map 把 QVector3D 当成点来处理,变换时包含平移;mapVector 把 QVector3D 当成向量来处理,只应用旋转和缩放。射线方向是向量,不能吃平移量,用 mapVector 才对。如果混用,射线方向会莫名其妙发生偏移,拾取命中位置就会很奇怪。
4.3 平面拖拽:让物体跟随鼠标
拾取到物体之后,下一个常规需求是拖拽。拖拽的思路不能直接“把鼠标的屏幕位移当成世界位移”,因为透视投影下,屏幕上一个像素对应的世界距离随深度变化。中央和屏幕边缘也不一样。
我推荐的做法是构造一个经过物体中心的平面,法线是相机的前方向,然后让射线与这个平面求交。这样鼠标每移动到一个新位置,都能得到一个“贴着这个平面”的世界坐标,把这个世界坐标作为物体的目标位置,拖拽手感会很自然。
相机的前方向,就是从观察矩阵里提取出来的。观察矩阵的第三行其实就是相机的 Z 轴方向(在视图空间里,物体离相机越近,z 越小)。用 QMatrix4x4 的话,可以用 column(2) 取第三列:
QVector3D cameraForward = m_view.column(2).toVector3D();严格来说这里拿到的是相机的局部 Z 轴在世界坐标下的方向,因为 m_view 把世界点变换到视图空间,它的第三列反推回来就是相机局部 Z 轴在世界空间的方向。
平面求交代码:
bool rayPlaneIntersection( const QVector3D& origin, const QVector3D& dir, const QVector3D& planeNormal, const QVector3D& planePoint, float& t) { float denom = QVector3D::dotProduct(planeNormal, dir); if (qFuzzyIsNull(denom)) return false; t = QVector3D::dotProduct(planeNormal, planePoint - origin) / denom; return t >= 0.0f; }鼠标移动时,用当前射线和这个平面求交,得到世界位置 offset,然后更新物体的模型矩阵:
void MyGLWidget::mouseMoveEvent(QMouseEvent* event) { if (!m_dragging) return; qreal dpr = devicePixelRatioF(); float mouseX = event->position().x() * dpr; float mouseY = event->position().y() * dpr; float glY = height() * dpr - mouseY; QRect viewport(0, 0, int(width() * dpr), int(height() * dpr)); QVector3D nearPos, farPos; if (!unprojectPoint(QVector3D(mouseX, glY, 0.0f), m_view, m_projection, viewport, nearPos)) return; if (!unprojectPoint(QVector3D(mouseX, glY, 1.0f), m_view, m_projection, viewport, farPos)) return; QVector3D origin = nearPos; QVector3D dir = (farPos - nearPos).normalized(); QVector3D camForward = m_view.column(2).toVector3D(); QVector3D planePoint; // 物体中心,记得提前存下来 if (m_pickedId == 0) planePoint = m_cubeModel * QVector3D(0, 0, 0); else planePoint = m_sphereModel * QVector3D(0, 0, 0); float t; if (!rayPlaneIntersection(origin, dir, camForward, planePoint, t)) return; QVector3D hitPos = origin + dir * t; // 把物体中心放到 hitPos,注意保持原来的旋转 if (m_pickedId == 0) { QMatrix4x4 newModel = m_cubeModel; newModel.setColumn(3, QVector4D(hitPos, 1.0f)); m_cubeModel = newModel; } else { QMatrix4x4 newModel = m_sphereModel; newModel.setColumn(3, QVector4D(hitPos, 1.0f)); m_sphereModel = newModel; } update(); }拖拽时只改平移列,不动旋转和缩放。如果物体本身有旋转,修改 column(3) 可以保留之前的旋转信息。这个方式比直接重建矩阵要安全。
5. 常见问题排查与避坑记录
5.1 症状与对策速查表
坐标转换和拾取相关的 bug,特征通常非常明显。我把这些年见过的典型问题整理成一张表。
| 症状 | 原因 | 对策 |
|---|---|---|
| 拾取物体时,命中的位置上下颠倒 | Qt 的 y 向下与 OpenGL 窗口坐标 y 向上冲突 | 统一在传给 unproject 前做 height - y 翻转 |
| 鼠标点得很准,但拾取结果总是偏几个像素 | 高 DPI 下没有乘 devicePixelRatio | 所有事件坐标都要乘 dpr,glViewport 也要用物理像素 |
| 射线方向忽左忽右,完全不对 | map 和 mapVector 混用 | 方向向量用 mapVector,位置点用 map |
| unproject 返回 false 或位置在异常远处 | 矩阵退化或 near/far 设置错误 | 检查投影矩阵 near/far 是否有效,检查 inverted 返回值 |
| 深度读取全是 1.0 | 该像素没有写入深度,可能是背景 | 先判断深度小于 1.0 再做 unproject,或者改用射线法 |
| 物体能拾取,但拖拽时飘得厉害 | 直接把屏幕位移当世界位移 | 构造相机平面,用射线求交得到目标位置 |
| 明明点中了 A 物体,返回的却是 B | 拾取时没取最近交点 | 先保存所有击中物体的 t,再取最小值 |
这张表基本覆盖了从入门到进阶的大部分问题。可以说,凡是“位置不对”的问题,九成出在坐标系约定上;凡是“命令无效或位置异常”的问题,九成出在矩阵状态上。排查顺序就是先确认 y 轴,再确认矩阵,最后看深度。
5.2 几个最容易被忽略的细节
第一个细节是页码式的矩阵同步问题。你在 paintGL 里用的 m_view,和你在鼠标事件里用的 m_view,如果有一方做了临时修改而忘了还原,就会出现“渲染看起来正常,但是拾取位置完全错位”的现象。解决方案是只在 paintGL 里修改矩阵,所有交互代码都通过函数从渲染状态里读取矩阵,不要允许鼠标事件直接改写渲染矩阵。
第二个细节是视口位置的偏移。如果你在窗口里嵌入了一个工具栏,真正的 OpenGL 裁剪区域可能只占窗口的一部分,那么 glViewport 的起点不是 (0, 0)。坐标转换时,你传入的 viewport 参数也必须对应真实的 glViewport。很多带工具栏的编辑器出现“点哪都不准”的问题,根源就在这里。把 glViewport 的四个值和转换用的 QRect 对不上,结果就一定偏。
第三个细节是关于 z 值的选择。深度缓冲对透视投影是“近处精度高、远处精度低”的,这是透视除法导致的非线性映射。用深度值反算世界坐标时,如果在远处有物体,你会发现鼠标稍微动一点,返回的世界 z 就会跳动得很明显。这不是代码写错了,是深度精度本身的特性。如果只需要做拾取,建议尽量用射线法,绕开深度精度问题。
第四个细节是矩阵求逆前的组合顺序。很多人会习惯性把 glGetDoublev 拿出来的两个矩阵直接相乘,但固定管线里矩阵乘法的顺序和现代着色器里 uniform 的 MVP 顺序必须保持一致。在 QMatrix4x4 里,正确的组合是 projection * modelView,而不是反过来。一旦反了,逆矩阵也会跟着错,unproject 得到的世界坐标就完全失真。这个错误比较隐蔽,因为它的表现不是“完全无效”,而是“偶尔有效”,遇到一次就会让人排查很久。
第五个细节是 double 精度。QMatrix4x4 默认用 float,在超大场景坐标(比如坐标值超过十万单位)下,float 精度会导致换算出来的人物位置偏差越来越大。如果你处理的是城市级场景或者天体级场景,建议自己写一套基于 double 的矩阵计算,或者在 unproject 里用高精度临时变量存储中间结果。普通室内或物体级场景,float 完全够用,没必要提前优化。
注意:如果你确实要读取深度缓冲,请尽量在 paintGL 内部、绘制命令结束后调用 glReadPixels。在 QOpenGLWidget 外部直接读取默认帧缓冲,可能会因为平台差异或 FBO 未绑定而读到脏数据。可以在 paintGL 的最后把鼠标悬停位置的深度保存到一个成员变量,供后续逻辑使用。
6. 从 glu 到现代管线的迁移心得
标题里的 gluProject / gluUnProject 是一个时代的标志。固定管线还流行的年代,几乎所有三维拾取教程都是“glReadPixels 读深度 + gluUnProject 反算坐标”。但现在回头再看,这套经验并没有过时,真正过时的只是实现载体。核心的数学链路一点没变:世界坐标、模型视图矩阵、投影矩阵、视口参数、透视除法,这五个要素在现代 OpenGL 和 Qt 里依然齐备,只是矩阵从 OpenGL 内部的矩阵栈,变成了你自己维护的 QMatrix4x4。
我在实际项目里的体会是,坐标转换最有价值的不是某个函数,而是建立一套统一的矩阵管理习惯。只要保证渲染矩阵与计算矩阵一致,y 轴翻转正确,视口参数与 glViewport 保持一致,那么无论你用 GLU、GLM 还是 Qt 自带数学库,结果都是一样的。反而是在这些约定上省事,才会让一个看似简单的 gluUnProject 调用变得神秘莫测。
如果你还在用老的 QGLWidget 或者固定管线,我建议尽早把手写 project / unproject 换上去。Qt6 对固定管线的支持越来越弱,GLU 库常年没人维护,第三方数学库里的 project / unProject 函数可能在版本升级后改变参数名字。自己维护这十几行代码,相当于给坐标转换逻辑加了一道保险,不管渲染管线怎么迁移,这套转换都跑得通。这个投入极其划算。