剑英陪你玩转图形学 (三)归去来
图形学里有一类特别有意思的问题,就是“去而复返”。第三篇我起名叫《归去来》,一开始是借了陶渊明那篇赋的名字,后来发现用在坐标变换上意外贴切——模型从本地空间一路走到屏幕,是“去”;鼠标在窗口里点一下,要反查出点中的是哪个物体、哪条边、哪个三角形,是“归”。这俩动作看起来是一正一反,但图形学里的“归”从来不是简单地把矩阵求个逆就完事,中间藏着透视除法、深度约定、视口映射这些细节,任何一个环节出错,你点到的位置就会偏到十万八千里外。
这一篇我打算把“去”与“归”这条链完整拆开来讲,顺便带大家做一个屏幕点拾取的小实验:一个旋转的彩色立方体,鼠标点击哪个三角形,哪个三角形就被高亮。这个场景听起来简单,但把渲染管线、矩阵逆变换、射线与三角形求交全串起来了,非常适合修完图形学实验一、刚把OpenGL环境跑通的同学继续往前推一步。当然,如果你已经在CSDN或GitHub上被各种新旧代码折磨过,这篇文章也能帮你把这些碎片拼回一张完整的地图。
1. 图形学里的“去”与“回”到底指什么
1.1 渲染管线本质上是一条单行道
我读大学那会儿,图形学实验一基本都是一个模板:搭好OpenGL窗口,创建一个三角形或立方体,然后看着坐标写在代码里,模型转一转,完事。CSDN上这类代码一抓一大把,但你有没有想过,屏幕上那个三角形,它的顶点坐标到底经历了什么?
一个简单的三角网格模型,建模时顶点存储的通常是以物体自身中心为原点的局部坐标。渲染时要把它摆进一个更大的世界场景里,就得乘上模型矩阵;为了让相机决定“从哪个角度去看”,又要乘上视图矩阵;为了让近大远小、把视锥压缩成一个立方体,还得乘上投影矩阵。乘完之后得到的是裁剪空间齐次坐标,继续做透视除法,也就是把 x、y、z 都除以 w,得到范围在 [-1,1] 的 NDC 坐标。最后通过视口变换,映射到窗口的像素坐标。
我把这个过程叫作“去”。值得强调的是,这里每一步矩阵乘法都更像是在“加工坐标”,而不是简单的移动位置。模型矩阵可能包含平移、旋转、缩放;视图矩阵把世界坐标换成以相机为原点的观察坐标;投影矩阵则决定了哪些物体在视野内、哪些被裁剪掉。四个空间一环扣一环,最后你才能在屏幕上看到一个正常不拉伸的三角形。
这条链路的最大特点,是它设计成了“正向流水线”。不是每个顶点都能一路走到屏幕上的——如果在裁剪空间超出了 w 的范围,顶点会被丢弃或裁剪掉。这正是渲染管线的基本逻辑:把数不尽的三角形丢进去,最后只保留看得见的部分。可一旦你想做鼠标拾取、编辑器里的拖拽手柄、或者把像素坐标换算成世界坐标去做后期特效,你就要逆着这条单向的流水线往回走,这就是“归”。
1.2 为什么要逆着渲染管线走
“归”并不是为了好玩,它直接对应一大类交互功能。最常见的场景就是鼠标拾取:游戏里你点了屏幕上的一个敌人,程序怎么知道点中了谁?一个简单的做法是把每条三角形的世界坐标重新投影一遍,比较哪个三角形离鼠标点最近,但这个做法在复杂场景里效率很低,工程上通常用射线拾取:从相机出发,穿过鼠标点击的那个屏幕位置,生成一条射线,然后去和场景中的几何体求交。生成这条射线的过程,就要把鼠标的屏幕坐标逆变换回世界坐标。
此外,很多编辑器功能也依赖“回”。比如你想在三维场景里用鼠标拖动一个物体沿着地面移动,就需要把鼠标所在屏幕坐标投射到地面上的一条射线,计算射线与地面的交点。再比如阴影贴图、UV 反投影、把深度缓冲还原成三维坐标做屏幕空间效果,全都建立在逆变换的基础之上。
所以你在图形学实验里忽略“归”,短期内似乎也能把画面画得挺好看,但一旦涉及交互、涉及用户输入,你就不得不把这条链彻底弄明白。这也是为什么我把第三篇的名字起成“归去来”:真正想玩转图形学,不能只会在正向管线里推着顶点往前走,还得学会在需要的时候优雅地走回来。
2. 核心矩阵拆解:从本地坐标到像素坐标
2.1 四个坐标空间之间的换算关系
在动手写代码之前,我建议先把四个空间和它们之间的对应关系刻在脑子里。图形学里天天念叨的模型空间、世界空间、观察空间、裁剪空间,再往后接 NDC 和屏幕空间,它们的核心作用可以浓缩成下面这张表:
| 空间 | 代表坐标系 | 说明 | 关键矩阵/操作 |
|---|---|---|---|
| 模型空间 | 物体自身坐标 | 顶点在建模软件里的原始位置 | 模型矩阵 M |
| 世界空间 | 场景统一坐标 | 所有物体摆在一起的位置 | M 的作用结果 |
| 观察空间 | 以相机为原点 | 相机看向 -Z 方向 | 视图矩阵 V |
| 裁剪空间 | 齐次坐标 | 将视锥压成 [-w, w] | 投影矩阵 P |
| NDC | 归一化设备坐标 | 坐标系范围 [-1,1] | 透视除法 x/w, y/w, z/w |
| 屏幕空间 | 窗口像素坐标 | 从窗口左上角或左下角开始计数 | 视口变换 |
正变换的完整公式是clip = P * V * M * localPos,然后透视除法得到 NDC,再做视口变换得到窗口坐标。每一次变换都是左乘一个矩阵,这也就意味着,如果你想从屏幕坐标回到世界坐标,理论上只需要把每一步都反过来:先做视口逆变换,再做透视除法的逆操作,然后左乘(P*V)的逆矩阵,就能得到一个世界坐标点。
但这里有个非常容易踩坑的地方:透视除法本身不是线性变换,它把坐标除以了 w。你从屏幕坐标反推 NDC 时只知道 x、y,并不知道原来的 w 是多少。这也是很多刚入门的人做逆变换时得到一堆 NaN 的原因。后面我会说清楚怎么绕开这个坑。
2.2 为什么矩阵可以“去”也可以“归”
线性代数里,一个方阵如果行列式不为零,就存在逆矩阵。从几何直觉上讲,矩阵表示的是对一个向量做缩放、旋转、错切这类线性变换,而逆矩阵就是把这个变换“撤销”掉。旋转矩阵的逆就是反着转同样的角度;缩放矩阵的逆就是把缩放系数取倒数;平移矩阵的逆就是减去平移量。
模型矩阵、视图矩阵、投影矩阵通常都是可逆的,尤其是投影矩阵,虽然它把视锥压成了长方体,但它并没有丢掉原始的深度信息,所以逆投影矩阵可以把一个 NDC 坐标还原回观察空间坐标。这也是为什么把(P*V)求逆是一个可行的做法。
你可以把矩阵逆变换想象成一个“存档与读档”的过程:正向渲染时,每个顶点都从本地一路变换到屏幕,相当于走了一条路;逆变换就是为了拿到一个世界坐标或观察坐标,你要从这个路的尽头一步步倒着走回去,而逆矩阵就是每一步的“回程票”。只要中途没有发生不可逆的退化,例如矩阵行列式为零、或者你把深度信息完全丢弃了,回程就一定能走通。
真正工程上需要留心的,是各种坐标约定的差异。OpenGL 的 NDC 深度范围是 [-1,1],DirectX 的是 [0,1];窗口原点一个在左下角一个在左上角;鼠标 API 返回的 y 坐标通常是向下增长的。这些差异叠加起来,“归”的就不仅仅是数学,还有一堆坐标系习惯。我见过无数代码,数学算得挺对,结果忘记把鼠标 y 轴翻一下,拾取就偏到了地底下。
2.3 一个经典的“回不去”例子:法线变换
“归去来”这个概念,还能延伸到法线变换上。很多人不知道,如果模型矩阵里包含了非均匀缩放,那么顶点坐标可以乘模型矩阵来变换,但法线不能直接乘模型矩阵。
道理很简单:法线本质上不是一个普通的位置向量,它代表的是物体表面的方向。当一个表面沿 x 轴拉伸两倍时,它的法线并不会简单地跟着拉伸,否则法线就不再垂直于表面了。正确的做法是用模型矩阵的逆转置矩阵(M^-1)^T来变换法线。也就是说,你要先求模型矩阵的逆,再转置,才能让法线在变换后依然垂直于表面。
这算是“归去来”的一个隐藏关卡:位置向量在“去”的时候乘 M,法线在“去”的时候却要乘一个由 M 的逆派生的矩阵。我在帮学生调实验一作业时,经常看到有人给物体加了缩放之后光照颜色变得很奇怪,查来查去才发现是法线没走“逆转置”这条路。这也提醒我们:图形学里很多看似对称的数学关系,实际用起来都有一层容易忽略的条件。
3. 实操:做一个鼠标点选三角形的小实验
3.1 实验目标与前置准备
这个实验的目标是:在之前画好的旋转立方体场景里,用鼠标点击屏幕,点击到的三角形会被高亮成白色,其余三角形正常显示颜色。为了聚焦于“归去来”这个主题,我把场景保持在最简单的层次:一个立方体,一个透视相机,鼠标点击拾取。
前置条件不复杂:一个能跑的 OpenGL 环境,加上 GLM 数学库。GLM 提供了glm::unProject,可以直接完成部分逆变换,但为了把原理讲透,我会先展示手动实现的方式,这样你也能在 shader 里写出类似逻辑。
我假设你已经能画出一个带 MVPP 矩阵的立方体了。如果你还在实验一阶段,建一个顶点缓冲对象,用 Element Buffer 画 12 个三角形(立方体 6 个面,每个面两个三角形),再挂一个简单的颜色 shader,就可以了。下面我从正向链路的代码开始,把整个拾取流程串起来。
3.2 正向链路:把立方体的每个顶点送到屏幕上
虽然你已经会画立方体了,但为了后面对照,我还是要先明确一下正向变换里每个矩阵的作用。透视相机一般这么设置:
glm::mat4 view = glm::lookAt(cameraPos, cameraTarget, cameraUp); glm::mat4 proj = glm::perspective( glm::radians(45.0f), // 纵向视角 (float)width / height, // 宽高比 0.1f, // 近裁剪面 100.0f // 远裁剪面 ); glm::mat4 model = glm::mat4(1.0f);顶点着色器里的核心代码就是:
#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 model; uniform mat4 view; uniform mat4 proj; void main() { gl_Position = proj * view * model * vec4(aPos, 1.0); }这段代码太常见了,以至于很多人忽略了它背后做的一连串坐标搬家:aPos 是模型空间坐标,乘 model 变成世界坐标,乘 view 变成观察空间坐标,乘 proj 变成裁剪空间齐次坐标,然后固定管线自动做透视除法,得到 NDC 坐标。最后窗口系统根据视口设置,把它映射到屏幕上的像素位置。
我在这里想提醒一个细节:裁剪空间坐标要求-w <= x,y,z <= w,而w在透视投影下等于-viewZ(观察空间下的深度值)。也就是说,离相机越远的点,w 越大,裁剪空间的坐标范围也越“宽容”,这正是透视投影压缩视锥的方式。理解这一点,后面做屏幕拾取时你才能明白为什么不能简单地在 NDC 里直接线性映射。
3.3 反向链路:从鼠标位置还原出世界空间射线
现在进入重头戏。当鼠标点击窗口的某个像素位置(mouseX, mouseY)时,我们需要构造一条从相机出发、穿过该像素的射线。标准的做法分两步:第一步把屏幕坐标还原成 NDC 坐标,第二步用逆 VP 矩阵把 NDC 坐标还原成世界坐标。
先把鼠标坐标转换成 NDC。大多数鼠标 API 返回的屏幕坐标原点在窗口左上角,y 轴向下,而 OpenGL 的 NDC 原点在窗口中心,y 轴向上。所以:
float ndcX = (2.0f * mouseX) / width - 1.0f; float ndcY = 1.0f - (2.0f * mouseY) / height;这里width和height是窗口的像素尺寸,注意要用实际的 framebuffer size,很多人在高DPI 屏幕上因为这个踩过坑,后面我还会提。现在我们得到了鼠标在近裁剪面上的 NDC 点。要生成射线,我们还需要远裁剪面上的另一个点,或者说,我们需要两个点来定义射线方向。常见的做法是取 NDC 的 z 等于 -1(近面)和 1(远面)。
glm::vec4 ndcNear(ndcX, ndcY, -1.0f, 1.0f); glm::vec4 ndcFar(ndcX, ndcY, 1.0f, 1.0f);然后把这两个点从 NDC 逆变换回世界坐标。这里要特别注意:向量要回到裁剪空间,再除以 w,才能得到正确的三维坐标。因为 NDC 坐标实际上就是裁剪空间坐标除以 w 之后的结果,所以我们把ndcNear乘上逆 VP 矩阵得到的是一个裁剪空间的齐次点,还要再除以它自己的 w 分量,才真正回到观察空间或世界空间的坐标。
如果你用glm::unProject,这个库已经帮你处理好了这些细节:
glm::vec3 worldNear = glm::unProject( glm::vec3(mouseX, height - mouseY, 0.0f), view, proj, glm::vec4(0.0f, 0.0f, width, height) ); glm::vec3 worldFar = glm::unProject( glm::vec3(mouseX, height - mouseY, 1.0f), view, proj, glm::vec4(0.0f, 0.0f, width, height) ); glm::vec3 rayDir = glm::normalize(worldFar - worldNear); glm::vec3 rayOrigin = worldNear;注意glm::unProject里我传入的鼠标 y 坐标是height - mouseY,因为 GLM 默认视口原点在左下角,而我们的鼠标坐标原点在左上角。如果你搞反了,射线会在垂直方向上完全反向。这也是使用第三方数学库时最容易忽略的约定问题。
如果你想不依赖 GLM 的 unProject,自己写也很简单,核心逻辑就是一个手动 unproject:
glm::vec3 manualUnProject(glm::vec3 winPos, const glm::mat4& view, const glm::mat4& proj, const glm::vec4& viewport) { glm::mat4 invVP = glm::inverse(proj * view); glm::vec4 ndc; ndc.x = (2.0f * (winPos.x - viewport.x)) / viewport.z - 1.0f; ndc.y = (2.0f * (winPos.y - viewport.y)) / viewport.w - 1.0f; ndc.z = 2.0f * winPos.z - 1.0f; ndc.w = 1.0f; glm::vec4 clip = invVP * ndc; return glm::vec3(clip) / clip.w; }这个手动版本对理解原理非常友好:先做视口逆变换得到 NDC,再做逆 VP 矩阵变换得到裁剪空间齐次坐标,最后除以 w。代码里的clip.w就是那个关键步骤,去掉它你就等着接 NaN 吧。
3.4 射线与三角形求交:Möller–Trumbore 算法
有了射线,接下来就是几何求交。场景里的立方体本质上是一堆三角形,所以我们要遍历所有 36 个顶点组成的 12 个三角形,判断射线是否穿过某个三角形。判断方法最常用的是 Möller–Trumbore 算法,它基于重心坐标,一次就能判出交点,效率很高。
bool rayTriangleIntersect(const glm::vec3& ro, const glm::vec3& rd, const glm::vec3& v0, const glm::vec3& v1, const glm::vec3& v2, float& t, float& u, float& v) { const float eps = 1e-8f; glm::vec3 e1 = v1 - v0; glm::vec3 e2 = v2 - v0; glm::vec3 p = glm::cross(rd, e2); float det = glm::dot(e1, p); if (fabs(det) < eps) return false; // 射线与三角形平行 float invDet = 1.0f / det; glm::vec3 s = ro - v0; u = glm::dot(s, p) * invDet; if (u < 0.0f || u > 1.0f) return false; glm::vec3 q = glm::cross(s, e1); v = glm::dot(rd, q) * invDet; if (v < 0.0f || u + v > 1.0f) return false; t = glm::dot(e2, q) * invDet; return t > eps; }算法的内部逻辑就是解一个线性方程组ro + t * rd = v0 + u * e1 + v * e2。t表示射线从起点出发走了多远,u和v是三角形上的重心坐标,只要u >= 0、v >= 0、u+v <= 1,交点就在三角形内部。我第一次看这个算法时,觉得它像黑魔法,后来发现它不过是在用混合积重写克莱姆法则,数学底子够的话很容易推导出来。
需要注意一点,做拾取时要记得把射线的远近范围限制在t > 0和t < 远裁剪面距离之间,否则你可能会捡到相机背后的三角形,或者捡到远到根本看不见的三角形。在实际项目中,这里还会配合各种加速结构,比如 BVH、八叉树,但对一个立方体来说,暴力遍历完全够用。
3.5 组装:让鼠标点击变成高亮反馈
拾取流程的最后一步,是把整条链组装起来。当鼠标点击时,执行以下步骤:
- 根据鼠标像素坐标,用逆 VP 矩阵生成近点和远点的世界坐标,得到射线原点和方向。
- 遍历所有三角形,用 Möller–Trumbore 算法求交。
- 在所有相交结果中,选取
t值最小的那个三角形(也就是离相机最近的),它就是用户点中的目标。 - 把该三角形的索引记录到 uniforms 里,渲染时判断当前三角形是否是要高亮的目标,如果是就用白色,否则用原色。
渲染高亮也很简单,把三角形索引数组上传为一个 uniform int 数组,或者直接在 CPU 侧修改要绘制的颜色,再更新顶点缓冲。对立方体这种小规模模型,CPU 修改是完全没有压力的。
我在自己的实验环境里跑了一遍完整流程,整体效果就是:旋转立方体时鼠标点哪个面,哪个面就闪白。看起来简简单单,但它背后同时用到了正向渲染的全部矩阵知识和逆向推理能力。我觉得这是对“图形学实验一:画个立方体”最有价值的延伸之一。
4. 常见问题与排查技巧实录
4.1 逆矩阵得到的结果全是 NaN 或无穷大
这是我见过最多的问题,几乎每个第一次写逆变换的同学都会撞上。原因通常有几种:一是投影矩阵或视图矩阵不可逆,比如你把近裁剪面设成了 0,或者某个缩放分量设成了 0;二是你用了glm::inverse的结果但忘了除以 w 分量;三是你把 NDC 坐标的 z 直接设成了深度缓冲里的值,但这个值在某些平台上是 [0,1],在另一些格式下是 [-1,1],算出来自然不对。
排查方法我建议先写一个最小测试:把一个已知的世界坐标点,用 MVP 矩阵正变换到屏幕坐标,再立刻用逆 VP 矩阵变换回来,看能不能还原。如果还原不了,把中间每一步的数值打印出来,通常一眼就能看出是哪一步出了问题。这个测试的做法本身也很有价值,相当于给你的坐标变换代码做了一次单元测试。
4.2 射线方向反了,鼠标点上面拾取到下面
这类问题九成是坐标系的 y 轴没处理好。窗口坐标系的 y 轴是向下的,而 OpenGL 的 NDC y 轴是向上的。如果你直接把鼠标的mouseY当成 NDC y,射线就会上下颠倒。用glm::unProject时,记得传入viewportHeight - mouseY;用手动 unproject 时,ndc.y = 1.0f - 2.0f * mouseY / height。
我以前在一篇文章里看到把鼠标坐标直接喂给glm::unProject,代码跑起来也没报错,但结果永远差半截,这就是典型的“看文档不仔细”。GLM 的视口参数用的是左下角原点,和常见的窗口事件坐标不一样,一定要翻一下。
4.3 拾取精度差,点小物体非常吃力
立方体足够大,所以这个实验里不会遇到明显的精度问题。但如果你把同样的思路挪到实际项目里,点一个小按钮或者细长的电线杆,射线拾取就会暴露出一个核心问题:像素误差被放大了。屏幕上一个像素可能对应世界空间里很细的一条锥,远距离下误差非常显著。
对策有几个方向:用更高分辨率的深度缓冲区做 GPU 拾取;用面积更大的包围盒做预判;或者使用“射线与包围盒求交优先,命中了再和你关心的三角形求交”的层级策略。工程上还会额外给三角形一个很小的厚度容差,避免因为浮点计算误差导致点在三角形边缘时捡不到。我在做编辑器工具时,经常给拾取射线加一个很小的锥角半径,让范围内的物体都可以被选中,这种“宽容拾取”能极大改善操作手感。
4.4 视口大小和实际帧缓冲大小不一致
高 DPI 屏、窗口缩放、以及显卡驱动对缩放的处理,都会导致逻辑窗口大小和实际渲染的像素数量不一致。如果你用窗口大小做逆变换,但渲染目标实际有 2 倍或 3 倍的像素,那么你的 NDC 坐标就会整体偏移。这个问题在 Windows 上特别常见,因为系统级缩放默认是 125% 或 150%。
解决办法是每次渲染时向 OpenGL 查询实际的帧缓冲大小,也就是glGetRenderbufferParameteriv或glfwGetFramebufferSize,而不是用glfwGetWindowSize。如果你用了 Qt、或者 OF 框架,也要特别注意区分“逻辑尺寸”和“物理像素”。我在这个坑上浪费过一下午,排查到最后发现不是算法错了,是尺寸来源错了。
4.5 OpenGL 和 DirectX 的 NDC 差异
如果你是两边都写的选手,这里有一个高频事故点:OpenGL 的 NDC 深度范围是 [-1,1],DirectX 是 [0,1],所以同一套逆变换代码,从 OpenGL 搬到 DirectX 时,z 的映射必须改。很多跨平台渲染代码库都会为此做一层包装,把深度范围统一成 [0,1] 或者记录一个 convention 标记。
此外,OpenGL 的 NDC y 轴向上,DirectX 的 y 轴也向上(只是深度方向不同),但很多 UI 框架传入屏幕坐标时都是 y 向下,所以无论在哪个 API 里,你都要在接入底层渲染 API 之前把 y 轴翻转一次。把这些约定搞清楚,才能保证你的“归”在任何一个渲染后端上都能走通。
5. 从实验一到实际项目:几点个人体会
5.1 图形学实验不是“调参游戏”
我知道很多人在做实验一时,都是照着教程把代码敲一遍,看到立方体动了就觉得自己会了。其实“画面能跑”和“理解原理”之间隔着一条巨大的鸿沟。我的建议是,每做完一个实验,就给自己出一个逆向题目:比如这个立方体旋转时,你能不能通过鼠标点击让某个角变红?这种“逆着渲染管线想一次”的训练,比单纯调形状、调颜色要有价值得多。
我在线下带实验课时,判断学生是否真的懂矩阵,从来不问他矩阵怎么乘,而是给他一个屏幕坐标,让他手工算出去射线方向。能算出来的,说明他脑子里真有一张坐标空间地图;算不出来的,往往正变换背得再熟,只需要换个方向问就露馅了。图形学这门课,说白了就是训练你在不同坐标系之间自由切换的能力。
5.2 一个逆向思考的练习
如果你想把“归去来”玩得更深,我建议你试试这几个变体练习:
- 不用
glm::unProject,自己写逆变换函数,并用精确到小数的坐标验证结果。 - 把透视投影改成正交投影,看看射线拾取哪里会有不同。
- 做一个“地面网格拾取”:射线和 y=0 的平面求交,让鼠标点哪,一个小球就移动到哪。这个练习能帮你熟悉“射线与平面求交”的套路,编辑器里拖拽物体的常用方案。
- 试着把 NDC 坐标还原成观察空间坐标,而不是世界空间坐标,看看有什么区别。
这些都是同一套数学思想的延伸。我自己当年练完这些之后,再看图形学论文里的各种反投影技巧,感觉明显顺畅多了。
5.3 “归去来”不止是拾取
最后再聊一个更大的视角:图像渲染本身就是一个“去”的过程,而很多高级效果都在“归”上。比如延迟渲染里的 G-Buffer,就是把各种几何信息写入纹理,后续光照计算时再从纹理里读取还原出世界坐标;屏幕空间反射也是从深度缓冲中重建像素位置,再做射线步进。你可以把整个渲染管线想象成一列单向行驶的火车,但聪明的工程师会时不时下车,从铁轨的另一端走回去,拾起有用的信息再回来。
所以这篇文章里讲的屏幕拾取,虽然看起来只是一个小交互功能,但它其实给你打开了一扇门:理解逆变换,就是理解很多现代渲染技术的起点。往后的阴影贴图、SSAO、体积光、GI 里,到处都有“去而复归”的影子。
我把这套东西彻底想明白,其实已经很晚了,是在工作后被一个实时拾取 bug 逼着重新翻开线性代数教材才补上的课。如果你现在还在做图形学实验一、二,那正好,趁课程压力不大,把这个“归去来”的小实验写一遍,后面省下的时间绝对值得。