在 Qt 的 QOpenGLWidget 里做 3D 模型工具时,窗口坐标与世界坐标系的转换是一个绕不开的坎。最初我以为它不过是调一下 gluProject、gluUnProject 两个函数,实际动手才发现,坐标变换管线、深度缓冲读取、矩阵同步、坐标原点朝向,每一个环节都可能让你辛辛苦苦算出来的点偏到天边去。这篇文章就是一次完整记录:从屏幕上的像素位置反推出三维世界坐标,以及把一个三维点准确投影回屏幕,分别要怎么做、为什么这样做、有哪些坑我已经替你先踩平了。内容同样适用于模型拾取、鼠标选点、3D 标注、测距这类常见交互功能。
1. 窗口坐标与世界坐标系:先拆开坐标变换的完整链路
很多人一上来就查 gluUnProject 的用法,忽略了一个前提:窗口坐标和世界坐标之间并不是一个函数就能解决的,中间隔着一整套变换链路。理解了这条链路,你才能真正看懂函数参数为什么要那么传,也才知道出错时该去哪一步排查。
OpenGL 的常规变换顺序可以这样概括:模型矩阵先把物体从局部坐标系搬到世界坐标系,视图矩阵把整个场景从世界坐标系变换到相机坐标系,投影矩阵再把可视范围压成一个裁剪体,经过透视除法得到 NDC 坐标,最后经过视口变换铺到屏幕上成为窗口坐标。你屏幕上看到的每一个像素,背后都经过了这五道工序。
如果记模型矩阵为 (M),视图矩阵为 (V),投影矩阵为 (P),模型上的点为 (p),那么完整公式是:
[ p_{view} = V \cdot M \cdot p ]
[ p_{clip} = P \cdot p_{view} ]
[ p_{ndc} = p_{clip.xyz} / p_{clip.w} ]
[ win.x = viewport[0] + (p_{ndc.x} + 1) \cdot viewport[2] / 2 ]
[ win.y = viewport[1] + (p_{ndc.y} + 1) \cdot viewport[3] / 2 ]
[ win.z = (p_{ndc.z} + 1) / 2 ]
这里 viewport 是四个整数,一般用 glGetIntegerv(GL_VIEWPORT, viewport) 拿到,内容依次是视口左下角的 x、y 和视口的宽、高。
我建议你把这个公式抄在纸上,竖着写下来,再用箭头标出每一步的逆运算。因为 gluUnProject 做的事情,就是从最下面一行往上反推回去,直到还原出顶点经过模型矩阵之前的坐标。后面所有代码,本质上都在实现这张纸上的逆运算。
1.1 从一个三维点到屏幕像素,经历的五段变换
这里有个概念容易一开始没意识到:现代 OpenGL 渲染里,代码中维护的“模型矩阵”和“视图矩阵”通常是分开的,但在传统固定管线里,它们会合并成一个 GL_MODELVIEW 矩阵。所以当你调用 glGetDoublev(GL_MODELVIEW_MATRIX) 时,拿到的往往包含了相机变换和模型变换的乘积。
各个坐标空间的实际意义区别很大。模型坐标系是建模软件里那个局部原点;世界坐标系是把所有物体、相机、灯光统一摆放的大场景;相机坐标系则是以摄像机为原点,摄像机朝前看;裁剪坐标系是投影后用于裁剪的齐次空间;NDC 坐标系是裁剪后经过透视除法的规范化设备坐标,x、y、z 都落在 [-1, 1] 范围;窗口坐标系才是最终像素坐标。
拿相机拍照来类比:世界坐标是场景里物体真实的摆放位置,视图矩阵是选择机位和朝向,投影矩阵是决定用广角还是长焦,视口变换是把照片洗到你指定的画框尺寸。你在屏幕上点击的像素,对应的是照片纸上的一个点;你要还原的是这个点在世界场景里到底在哪里。
1.2 从窗口坐标反推世界坐标的逆运算骨架
既然正向链路是五段变换,反向链路就必须逐级求逆。窗口坐标到世界坐标的完整反向过程如下:
第一步,把窗口坐标映射回 NDC 坐标:
[ nx = 2 \cdot (winX - viewport[0]) / viewport[2] - 1 ]
[ ny = 2 \cdot (winY - viewport[1]) / viewport[3] - 1 ]
[ nz = 2 \cdot winZ - 1 ]
第二步,把 (nx, ny, nz, 1) 作为一个齐次坐标向量,乘上投影矩阵和模型视图矩阵乘积的逆矩阵,得到一个齐次坐标结果。
第三步,把结果的 x、y、z 分别除以 w 分量,得到最终三维坐标。
这就是 gluUnProject 内部全过程的数学骨架。它不访问 GPU,不关心渲染状态,只看你传给它的四个参数:模型视图矩阵、投影矩阵、视口参数、屏幕坐标和深度值。矩阵给错了,结果就是错的,这种错往往还不会给你报错,只会表现为“算出来的点飘在奇怪的位置”。
1.3 原点在哪直接决定结果的上下颠倒
这是最容易让新手当场崩溃的一点。Qt 的鼠标坐标、控件坐标体系,以左上角为原点,x 向右,y 向下;而 OpenGL 的窗口坐标体系,以视口左下角为原点,x 向右,y 向上。如果你把 event->pos() 直接喂给 gluUnProject,算出来的点上下一定是反的,而且你还会觉得“函数是不是坏了”。
正确的翻转代码非常简单:
GLint glY = viewport[3] - static_cast<GLint>(nativePos.y());如果视口的 y 起点不是 0,那需要额外加上 viewport[1],不过绝大多数 QOpenGLWidget 场景下就是 0。记住一件事:凡是来自 Qt 的 y 坐标,在进入 GL 相关函数之前,先做一次以视口高度为基准的翻转。这个动作做完,坐标上下颠倒的毛病基本就没了。
2. gluProject 和 gluUnProject:一个正向输出,一个逆向还原
GLU 是 OpenGL 的实用库,里面这两个函数可以说是坐标变换的“打包器”。它们不做渲染,不碰任何 GPU 状态,纯粹就是拿着矩阵做乘法、做逆运算。只要把当前的模型视图矩阵、投影矩阵、视口参数交给它们,就能在窗口坐标和三维坐标之间来回换算。
这里要特别强调一个容易混淆的点:gluUnProject 返回的并不是严格意义上的“世界坐标”,而是“经过 ModelView 矩阵之前的坐标”。传统固定管线把相机矩阵和模型矩阵合并存在 GL_MODELVIEW 里,所以返回的是模型变换之前局部坐标;如果你的 model 矩阵刚好是单位矩阵,那返回的就是世界坐标。这一点后面还会再遇到,先记住结论。
2.1 函数原型与参数全解
两个函数的原型如下:
GLint gluProject( GLdouble objX, GLdouble objY, GLdouble objZ, const GLdouble *model, const GLdouble *proj, const GLint *view, GLdouble *winX, GLdouble *winY, GLdouble *winZ); GLint gluUnProject( GLdouble winX, GLdouble winY, GLdouble winZ, const GLdouble *model, const GLdouble *proj, const GLint *view, GLdouble *objX, GLdouble *objY, GLdouble *objZ);参数含义如下表:
| 参数 | 含义 |
|---|---|
| model | 模型视图矩阵,长度为 16 的 GLdouble 数组,列主序 |
| proj | 投影矩阵,长度为 16 的 GLdouble 数组,列主序 |
| view | 视口参数,长度为 4 的 GLint 数组,依次是 x、y、width、height |
| winX / winY | 窗口坐标,单位是物理像素,注意 GL 约定以左下角为原点 |
| winZ | 深度值,范围 0 到 1,一般用 glReadPixels 从深度缓冲读取 |
| objX / objY / objZ | 输出参数,接收换算得到的三维坐标 |
返回值只有两种结果:GL_TRUE 表示成功,GL_FALSE 表示失败。失败往往意味着投影矩阵和模型视图矩阵的乘积不可逆,或者传入的齐次坐标 w 分量为 0。这个返回值千万要检查,否则一旦失败,输出参数可能是一些未定义的垃圾值,再传给后续逻辑会让你排查半天。
2.2 gluUnProject 的内部逻辑:逆变换的完整过程
把前面那条反向链路展开就是 gluUnProject 干的事。把窗口坐标转换为 NDC,然后把逆矩阵乘上去,最后做齐次除法。伪代码如下:
ndc.x = 2 * (winX - view[0]) / view[2] - 1 ndc.y = 2 * (winY - view[1]) / view[3] - 1 ndc.z = 2 * winZ - 1 clip = inverse(proj * model) * vec4(ndc.x, ndc.y, ndc.z, 1) if clip.w == 0: return GL_FALSE obj = clip.xyz / clip.w很多人问为什么传入的 winZ 必须是深度值,而不能随便填 0 或者 1。因为这个 z 值决定了你要反投影到三维空间里的哪一层。屏幕上同一个 (x, y) 位置,从近处到远处的一条射线上有无数个三维点,只有附加一个深度值,才能确定你关心的到底是谁。如果填 0,得到的是近裁剪面上的点;填 1,得到的是远裁剪面上的点;只有用真实深度值,得到的才是鼠标所点击物体表面的那个点。
还有一个常见的误解:认为 gluUnProject 能把“屏幕坐标”唯一确定地还原成“世界坐标”。它不能。没有深度信息的二维坐标反投影,得到的是整条射线,不是唯一解。想拾取物体,本质上是从这条射线上找第一个交点,或者用深度值来锁定一个具体的深度。理解这一点,你以后再看各种拾取方案时都会通透很多。
2.3 gluProject 的内部逻辑:正变换的完整过程
gluProject 是 gluUnProject 的逆方向,把三维点正向投影回屏幕。内部流程如下:
clip = proj * model * vec4(obj.x, obj.y, obj.z, 1) if clip.w == 0: return GL_FALSE ndc = clip.xyz / clip.w winX = view[0] + (ndc.x + 1) * view[2] / 2 winY = view[1] + (ndc.y + 1) * view[3] / 2 winZ = (ndc.z + 1) / 2这个函数在三维标注、测距、以及把 3D 点投影到 2D 覆盖层时非常有用。它同样不访问 GPU,只做数学运算。后面我会专门讲一个常见场景:视角旋转时,3D 标注点如何实时跟随物体在屏幕上移动。
3. 在 QOpenGLWidget 里跑通 gluUnProject:从矩阵到深度一次到位
原理清楚了,就该面对 Qt 环境里的真实问题了。QOpenGLWidget 本身不提供任何坐标转换封装,但它和 OpenGL 上下文、视口管理配合得很好,基本没有额外门槛。难点集中在三件事:GLU 库怎么链、矩阵怎么取、深度值怎么读。这三点只要有一个做错,结果都不对。
3.1 环境准备:链接 GLU 库
gluProject 和 gluUnProject 位于 GLU 库中,不是 OpenGL 核心函数。构建时需要显式链接。CMake 写法如下:
find_package(OpenGL REQUIRED COMPONENTS OpenGL GLU) target_link_libraries(myapp PRIVATE OpenGL::OpenGL OpenGL::GLU)qmake 写法:
LIBS += -lGLUWindows 上通常需要链接 glu32.lib,Linux 上链接 -lGL,macOS 的系统框架中一般也能找到对应符号,但如果使用新版本 SDK 遇到找不到符号的情况,并不罕见。Qt 6 环境的 OpenGL 支持依然是独立的,GLU 不会由 Qt 自动带入,需要你自行确认。
如果编译阶段直接报“gluUnProject was not declared in this scope”,十有八九就是 GLU 库没有链进来。这是最常见的第一步问题,但也是最容易解决的一步。
3.2 矩阵来源:shader 项目里 glGetDoublev 可能拿不到你要的东西
传统 OpenGL 教程会教你这样获取矩阵:
GLdouble modelview[16], projection[16]; glGetDoublev(GL_MODELVIEW_MATRIX, modelview); glGetDoublev(GL_PROJECTION_MATRIX, projection);但在现代 QOpenGLWidget 项目里,如果你用 QOpenGLShaderProgram 自己上传 MVP 矩阵,从来不调用 glMatrixMode、glLoadMatrix,那么固定管线里的矩阵状态可能一直是默认的单位矩阵。此时 glGetDoublev 拿到的不是 shader 里真正用的矩阵,gluUnProject 的结果自然错得离谱。
解决思路有两种。第一种,把固定管线矩阵和 shader 矩阵保持同步。在绘制前把同一个矩阵推入固定管线:
glMatrixMode(GL_PROJECTION); glLoadMatrixd(projection.constData()); glMatrixMode(GL_MODELVIEW); glLoadMatrixd((view * model).constData());这样 glGetDoublev 就能读到与 shader 一致的矩阵。第二种更推荐:别从 GL 状态里读,直接从代码中维护的 QMatrix4x4 导出数组。QMatrix4x4 的 constData() 返回的是列主序浮点数组,和 OpenGL 的矩阵内存布局一致,直接拷贝给 GLdouble 数组即可。
QMatrix4x4 mv = viewMatrix * modelMatrix; QMatrix4x4 proj = projectionMatrix; GLdouble modelview[16], projection[16]; for (int i = 0; i < 16; ++i) { modelview[i] = static_cast<GLdouble>(mv.constData()[i]); projection[i] = static_cast<GLdouble>(proj.constData()[i]); }第二种做法好处很明显:你永远知道传入的是哪份矩阵,不管渲染管线怎么改,shader 和拾取用的是同一个数据源,排查问题维度直接少一半。
3.3 读取深度值:glReadPixels 的正确打开方式
深度值是窗口坐标 (x, y) 处物体的 z 信息。用 glReadPixels 读取,需要注意以下几点。
第一,必须开启深度测试,并在渲染时正确清空深度缓冲。如果根本没有深度缓冲,或者深度测试关闭,读出来的值通常会是 1.0,对应远裁剪面。
第二,读取时指定的格式要用 GL_FLOAT,类型的 GL_DEPTH_COMPONENT,这样深度值直接以浮点形式返回:
GLfloat depth = 0.0f; glReadPixels(x, y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth);第三,读取时机非常关键。如果你在鼠标事件里直接调用 glReadPixels,那很可能读到的是上一帧或尚未完成的深度数据。稳妥的做法是:鼠标事件里只记录屏幕坐标,然后调用 update() 触发重绘;在 paintGL 里,先把整个场景绘制完成,再读取深度并完成坐标转换。这样读到的深度一定和当前帧画面一致。
有一个细节:深度值等于 1.0 通常意味着鼠标点击的位置没有任何物体。此时再往下调用 gluUnProject 结果没有意义,应直接返回失败。
3.4 完整拾取流程的骨架代码
把以上几点综合起来,给出一个可参考的成员函数。这个函数在 paintGL 绘制完场景之后调用,输入是 Qt 鼠标事件里的逻辑像素坐标,输出是鼠标点击位置对应的模型局部坐标。
bool MyGLWidget::pickWorldPosition(const QPoint &pos, QVector3D &worldPos) { QPoint native = pos * devicePixelRatioF(); GLint viewport[4]; glGetIntegerv(GL_VIEWPORT, viewport); GLint glY = viewport[3] - native.y(); GLfloat depth = 0.0f; glReadPixels(native.x(), glY, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth); if (depth >= 1.0f) return false; GLdouble modelview[16], projection[16]; QMatrix4x4 mv = m_viewMatrix * m_modelMatrix; for (int i = 0; i < 16; ++i) { modelview[i] = static_cast<GLdouble>(mv.constData()[i]); projection[i] = static_cast<GLdouble>(m_projectionMatrix.constData()[i]); } GLdouble wx, wy, wz; if (gluUnProject(native.x(), glY, depth, modelview, projection, viewport, &wx, &wy, &wz) != GL_TRUE) return false; worldPos = QVector3D(wx, wy, wz); return true; }注意这里返回的 worldPos,其实是经过 m_modelMatrix 之前的坐标,即模型的局部坐标。如果你的模型矩阵不是单位矩阵,而你又确实需要世界坐标,正确的做法是把局部坐标再用 m_modelMatrix 变换一次;如果是拾取,局部坐标通常才是你需要的,因为它能直接对应到模型的原始顶点数据。QMatrix4x4 的 constData() 返回 float,我在这里显式转成 GLdouble,避免 double 和 float 之间隐式转换带来的精度问题;高精度的拾取对数学误差比较敏感,这个习惯值得保留。
使用流程上,我通常在 mousePressEvent 里只保存坐标:
void MyGLWidget::mousePressEvent(QMouseEvent *event) { m_pendingPickPos = event->pos(); update(); }然后在 paintGL 的末尾,等所有几何体绘制结束,再执行转换并把结果存到成员变量,或者触发一个信号交给业务层。这个方法表面看起来绕了一圈,实际比在鼠标事件里直接读深度可靠得多,因为你不可能在绘制完成前拿到这一帧画面的深度数据。
4. 反向应用:用 gluProject 把一个 3D 点钉到屏幕
坐标转换从来不只有鼠标点选这一个方向。很多时候你需要把一个三维点显示在屏幕上:给模型顶点贴编号标签、在三维测量场景里绘制两点之间的距离文字、鼠标悬停时高亮某个部件并显示提示框。这些功能都依赖正向投影,也就是 gluProject。
4.1 场景:为什么 3D 标注点不能直接固定屏幕位置
如果你只是想在屏幕固定位置显示一个按钮或一行文字,那直接用 Qt 的 QPainter 画就行,不需要 gluProject。但三维标注不同:标注要跟随物体移动。模型旋转、摄像机平移、视角切换后,同一个三维点对应的屏幕位置会不断变化,你不能手动维护这个变化,必须每帧从三维坐标重新计算。
这类需求我曾经不以为然,后来做一个测量工具时才深有体会。测量线段的两个端点在世界坐标里是固定的,但用户只要拖动一下视角,标注文字还停在原地就会显得非常出戏。用 gluProject 每帧把端点投影到屏幕,再更新文字位置,问题就解决了。
4.2 调用方式与 Qt 坐标回换算
gluProject 的输出 winX、winY 是 OpenGL 窗口坐标,以左下角为原点。要把这个坐标用于 Qt 的 QPainter 绘制,需要再做一次 Y 轴翻转,并除以 devicePixelRatioF() 回到逻辑像素:
QPointF projectToScreen(const QVector3D &obj) { GLdouble modelview[16], projection[16]; GLint viewport[4]; QMatrix4x4 mv = m_viewMatrix * m_modelMatrix; for (int i = 0; i < 16; ++i) { modelview[i] = static_cast<GLdouble>(mv.constData()[i]); projection[i] = static_cast<GLdouble>(m_projectionMatrix.constData()[i]); } glGetIntegerv(GL_VIEWPORT, viewport); GLdouble winX, winY, winZ; if (gluProject(obj.x(), obj.y(), obj.z(), modelview, projection, viewport, &winX, &winY, &winZ) != GL_TRUE) { return {}; } double dpr = devicePixelRatioF(); qreal screenX = winX / dpr; qreal screenY = (viewport[3] - winY) / dpr; return QPointF(screenX, screenY); }这一段代码里有一个容易被忽略的小技巧:winZ 也是有用的。你可以用 winZ 做简单的遮挡判断,比如两个标注点重叠时,只绘制 z 值更小的那个。虽然这种基于单个像素点的遮挡判断比较粗糙,但处理简单标注已经足够。要想精确遮挡还是得用深度缓冲或射线检测,不过在标注场景里这个技巧能省不少事。
4.3 正反变换的闭环验证法
调试坐标转换,最容易出现的错觉是“单次调用结果看着没问题”。一个非常有效的验证手段是正反变换闭环:取一个已知的三维点 p0,先用 gluProject 得到屏幕坐标 s;再从 s 处读取深度,调用 gluUnProject 得到一个三维点 p1;比较 p0 和 p1,理论上它们应该几乎完全一致,误差一般在 1e-6 级别。
反过来也一样:先用鼠标在屏幕上取一个点 s0,做 gluUnProject 到 p,再把 p 用 gluProject 转回屏幕,应该回到 s0 附近。如果闭环不成立,说明矩阵、Y 轴翻转或深度读取必然有一处出了问题。这个验证不做,后面写再多业务功能都可能在一个看不见的坐标误差上慢慢累积。
我之前调试时,就是先用一个简单的立方体跑闭环,把每个中间变量打到日志里,确认投影和反投影一致后,才去接真实的模型。这样做的好处是,出问题时可以明确区分“坐标换算 bug”还是“模型数据 bug”,而不是混在一起瞎猜。
5. 没有 GLU 环境时的替代方案与最容易踩的坑
GLU 库在很多新环境里越来越不受待见:Qt 6 里不默认带 GLU,Vulkan、OpenGL ES 等场景里可能根本没有 GLU。与其到处找库,不如自己实现一个精简版,还能顺带把坐标变换的底裤看个干净。
5.1 手写 myProject 和 myUnProject
先准备一个 4x4 矩阵求逆和一个矩阵乘向量函数。最常用的 4x4 矩阵求逆可以借助伴随矩阵法实现。我这里给一个比较标准的手写版本,直接按列主序存储:
static bool invertMatrix(const double m[16], double invOut[16]) { double inv[16]; inv[0] = m[5]*m[10]*m[15] - m[5]*m[11]*m[14] - m[9]*m[6]*m[15] + m[9]*m[7]*m[14] + m[13]*m[6]*m[11] - m[13]*m[7]*m[10]; inv[4] = -m[4]*m[10]*m[15] + m[4]*m[11]*m[14] + m[8]*m[6]*m[15] - m[8]*m[7]*m[14] - m[12]*m[6]*m[11] + m[12]*m[7]*m[10]; inv[8] = m[4]*m[9]*m[15] - m[4]*m[11]*m[13] - m[8]*m[5]*m[15] + m[8]*m[7]*m[13] + m[12]*m[5]*m[11] - m[12]*m[7]*m[9]; inv[12] = -m[4]*m[9]*m[14] + m[4]*m[10]*m[13] + m[8]*m[5]*m[14] - m[8]*m[6]*m[13] - m[12]*m[5]*m[10] + m[12]*m[6]*m[9]; inv[1] = -m[1]*m[10]*m[15] + m[1]*m[11]*m[14] + m[9]*m[2]*m[15] - m[9]*m[3]*m[14] - m[13]*m[2]*m[11] + m[13]*m[3]*m[10]; inv[5] = m[0]*m[10]*m[15] - m[0]*m[11]*m[14] - m[8]*m[2]*m[15] + m[8]*m[3]*m[14] + m[12]*m[2]*m[11] - m[12]*m[3]*m[10]; inv[9] = -m[0]*m[9]*m[15] + m[0]*m[11]*m[13] + m[8]*m[1]*m[15] - m[8]*m[3]*m[13] - m[12]*m[1]*m[11] + m[12]*m[3]*m[9]; inv[13] = m[0]*m[9]*m[14] - m[0]*m[10]*m[13] - m[8]*m[1]*m[14] + m[8]*m[2]*m[13] + m[12]*m[1]*m[10] - m[12]*m[2]*m[9]; inv[2] = m[1]*m[6]*m[15] - m[1]*m[7]*m[14] - m[5]*m[2]*m[15] + m[5]*m[3]*m[14] + m[13]*m[2]*m[7] - m[13]*m[3]*m[6]; inv[6] = -m[0]*m[6]*m[15] + m[0]*m[7]*m[14] + m[4]*m[2]*m[15] - m[4]*m[3]*m[14] - m[12]*m[2]*m[7] + m[12]*m[3]*m[6]; inv[10] = m[0]*m[5]*m[15] - m[0]*m[7]*m[13] - m[4]*m[1]*m[15] + m[4]*m[3]*m[13] + m[12]*m[1]*m[7] - m[12]*m[3]*m[5]; inv[14] = -m[0]*m[5]*m[14] + m[0]*m[6]*m[13] + m[4]*m[1]*m[14] - m[4]*m[2]*m[13] - m[12]*m[1]*m[6] + m[12]*m[2]*m[5]; inv[3] = -m[1]*m[6]*m[11] + m[1]*m[7]*m[10] + m[5]*m[2]*m[11] - m[5]*m[3]*m[10] - m[9]*m[2]*m[7] + m[9]*m[3]*m[6]; inv[7] = m[0]*m[6]*m[11] - m[0]*m[7]*m[10] - m[4]*m[2]*m[11] + m[4]*m[3]*m[10] + m[8]*m[2]*m[7] - m[8]*m[3]*m[6]; inv[11] = -m[0]*m[5]*m[11] + m[0]*m[7]*m[9] + m[4]*m[1]*m[11] - m[4]*m[3]*m[9] - m[8]*m[1]*m[7] + m[8]*m[3]*m[5]; inv[15] = m[0]*m[5]*m[10] - m[0]*m[6]*m[9] - m[4]*m[1]*m[10] + m[4]*m[2]*m[9] + m[8]*m[1]*m[6] - m[8]*m[2]*m[5]; double det = m[0]*inv[0] + m[1]*inv[4] + m[2]*inv[8] + m[3]*inv[12]; if (det == 0.0) return false; double invDet = 1.0 / det; for (int i = 0; i < 16; ++i) invOut[i] = inv[i] * invDet; return true; }然后封装一个与我们需求吻合的 myUnProject:
static bool myUnProject(double winX, double winY, double winZ, const double model[16], const double proj[16], const int view[4], double *objX, double *objY, double *objZ) { double mvp[16]; multiplyMatrices(proj, model, mvp); double inv[16]; if (!invertMatrix(mvp, inv)) return false; double in[4]; in[0] = 2.0 * (winX - view[0]) / view[2] - 1.0; in[1] = 2.0 * (winY - view[1]) / view[3] - 1.0; in[2] = 2.0 * winZ - 1.0; in[3] = 1.0; double out[4]; multiplyMatrixVec(inv, in, out); if (out[3] == 0.0) return false; *objX = out[0] / out[3]; *objY = out[1] / out[3]; *objZ = out[2] / out[3]; return true; }这里的 multiplyMatrices 要小心顺序。OpenGL 经典变换是 projection * modelview,两个 4x4 列主序矩阵相乘,结果等价于先应用 modelview 再应用 projection。我在前面的骨架里写的是 inverse(proj * model),所以这里的 mvp 也必须等于 proj * model。如果你写成 model * proj,整个结果会完全错误。
如果你项目里已经用了 GLM 数学库,事情更简单。glm::unProject 一行就能替代:
glm::vec3 world = glm::unProject( glm::vec3(winX, winY, winZ), view * model, projection, viewport);glm::project 也一样。GLM 的底层逻辑和我上面的手写版本完全一致,只是帮你省去了求逆函数。要不要引入这个依赖,看项目现状就好。
5.2 高频问题排查表
把这些年遇到过的问题按症状整理成一张表,排查时可以按图索骥:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 转换出的点总是 NaN | 矩阵不可逆,或返回值没被检查 | 先打印模型视图矩阵、投影矩阵,检查行列式;调用前判断返回值 |
| 点击屏幕上的点,结果点在另一边或上下颠倒 | Qt 与 OpenGL 坐标原点不一致 | 检查 glY 是否做了视口高度翻转 |
| 鼠标移动和拾取点偏移明显 | 高 DPI 下逻辑像素与物理像素未换算 | 进入 GL 前乘以 devicePixelRatioF() |
| 点总能算出来,但不在模型表面 | shader 用的矩阵和传入 gluUnProject 的矩阵不一致 | 从同一份 QMatrix4x4 导出矩阵,不要用 glGetDoublev 盲读 |
| 所有深度值都是 1.0 | 深度测试没开、深度缓冲未清、或读取时机不对 | 开启 GL_DEPTH_TEST,在 paintGL 绘制完成后读取 |
| 离屏 FBO 渲染时深度值异常 | FBO 没有附加深度缓冲 | 创建 QOpenGLFramebufferObject 时指定深度附件 |
这张表几乎覆盖了我遇到过的所有坐标转换诡异问题。你如果发现自己卡在某个症状里,先别怀疑公式,按表里最匹配的一项去查。绝大部分情况都是这些基础环节出了偏差。
5.3 调试顺序建议:先单位矩阵,再画点验证
最后一个建议,也是我吃过亏之后总结出来的:不要一上来就接真实模型做测试。先用最简单的场景验证链路。
具体操作是:把模型矩阵设为单位矩阵,视图矩阵设为单位矩阵,投影矩阵用一个简单透视或正交矩阵。然后拿一个已知坐标,比如 (0, 0, -5),手动用公式算它应该投影到屏幕哪个位置,再调用 gluProject 验证。如果一致,再用 gluUnProject 从屏幕坐标反推回三维坐标,看是否能回到 (0, 0, -5)。这个过程跑通了,说明数学部分没有问题。
第二步,在场景里用一个明显可见的小球或十字标记,把它放在 unProject 返回的坐标上,然后渲染出来。移动鼠标,观察标记点是否准确地跟随鼠标点击的位置。这一步可以非常直观地暴露 Y 轴翻转、矩阵不同步这类问题,比盯着数字日志效率高得多。
这两步验证做完,再接入真实模型矩阵和复杂场景。你会发现后续的调试顺畅很多,因为变换链路本身已经是被验证过的,剩下的问题只会来自模型数据或业务逻辑。
我在实际项目中反复体会到一个结论:坐标转换本身并不复杂,真正复杂的永远是“传进去的矩阵到底是不是渲染时真正用的那一套”。现代渲染把矩阵分散在代码的各个角落,稍不注意就会把某一套旧矩阵传给 gluUnProject。所以第一次做这个功能时,别急着套函数,先把坐标变换公式写在纸上,再遵守两条铁律:所有矩阵从代码里统一导出,深度值一定在绘制完成后读取。这两条做到了,这个功能基本就稳了。后面再想扩展拾取、标注、测距,都是在这条链路上自然生长的事情。