齐次坐标与 MVP 变换链
本篇位置:模块一 · 第 3 篇 | 前置:矩阵与线性变换 | 预计阅读:25 分钟
学习目标
- 说清楚齐次坐标(homogeneous coordinates)里多出来的第 4 个分量 到底解决了什么问题。
- 默写 4×4 平移矩阵,并能解释它为什么长成这个形状,而不是死记硬背。
- 完整走通 MVP(Model-View-Projection)变换链的每一站,说清楚坐标在每一站的空间含义。
- 知道透视除法(perspective divide)具体发生在管线的哪一步,以及它和裁剪、near/far 平面的关系。
为什么需要第 4 个分量
上一篇结尾留了一个悬念:旋转、缩放、剪切都能写成 (或 )矩阵,唯独平移(translation)不行——因为平移不满足线性变换要求原点不动这条硬性规定。图形学的解法很直接:既然三维空间里塞不下平移,那就人为升到四维,把平移也伪装成一次矩阵乘法。这套技巧叫齐次坐标:每个三维点 额外补上一个分量 ,写成 ,约定当 时它代表三维点 。当 时不需要除法, 就直接对应原来的点——这是图形管线里的默认选择。
补上这一维之后,平移终于可以写成矩阵乘法了:
这个矩阵的构造并不神秘:左上角 是恒等矩阵,保证 原样保留;最后一列的 是要加上的偏移量;把它乘在齐次点 上,矩阵乘法算出来的结果恰好是 ——加法被巧妙地编码成了矩阵最后一列乘以 再累加的结果。 这个分量在这里起的作用,就是给平移量提供一个"总能凑到 1"的开关。
这也解释了为什么 不能随便取值:它不是一个多余的占位符,而是区分"点"与"方向"的开关。 表示一个点(position),受平移影响——把相机往右移,场景里所有点的世界坐标都该跟着变。 表示一个方向(direction),不受平移影响——把上面的平移矩阵乘在 上,最后一列的 全部乘以 消失了,结果原样是 。这正是图形代码里法线(normal)、光照方向(light direction)向量的 分量必须填 的原因:法线描述的是"哪个方向朝外",把物体挪个位置不该改变它朝向哪边;如果误把法线的 填成 ,它就会被平移矩阵错误地拖着走,变换结果不再是一个合法的方向向量。这也是第 1 篇里"点减点得向量、点加向量得点"这条代数关系在齐次坐标下的精确表述: 减 得到 ,点减点确实产出了方向。
4×4 变换全家福
有了这一维铺垫,前一篇的旋转、缩放矩阵也可以顺手升到 :把原来的 (或 推广到三维后的 )矩阵放进左上角,右下角补一个 ,其余位置补 。以绕 轴旋转为例:
缩放同理,对角线摆上 :。这种"升维只加一圈单位边框"的构造对旋转、缩放都成立,因为它们本身不动原点,齐次化之后自然不会牵扯到平移那一列;只有平移利用了最后一列,这也是三种变换在 矩阵里"各管一块"的直观体现——平移管最后一列,旋转缩放管左上角 子块。三者组合起来,一个模型经过"先缩放、再旋转、再平移"就是三个 矩阵相乘(顺序规则和上一篇讲的完全一致:离向量最近的矩阵最先生效)。
这套矩阵不用每次现推,仓库里已经给出一份可以直接拿来跑的参考实现:labs/common/transforms.py,里面的 translate、rotate_y 就是上面两个矩阵的逐字代码化,look_at、perspective、apply 会在接下来两节用到。后续几篇(包括讲投影与深度精度的下一篇)都会直接 import 这个文件,而不是重新推一遍公式。
MVP 变换链
一个顶点从建模软件里的局部坐标,走到屏幕上的一个像素,中间要依次经过好几个坐标空间,每一步都对应一次矩阵乘法:
逐站过一遍职责:局部空间(local/object space)是建模时用的坐标,原点通常在模型自身中心;Model 矩阵把它摆进整个场景,得到世界空间(world space)里的统一坐标;View 矩阵把世界空间搬到以相机为原点、看向固定方向的观察空间(view/eye space);Projection 矩阵把观察空间的视锥体(frustum)映射成一个规整的立方体,落进裁剪空间(clip space);裁剪空间的坐标除以自己的 分量(下一节详细展开)得到NDC(normalized device coordinates,归一化设备坐标);最后 viewport 变换把 的 NDC 映射成屏幕上的像素坐标。、、 三个矩阵依次左乘,正是 MVP 这个名字的由来。
下图用一个立方体实际跑了前三站(世界空间、观察空间、NDC,均省略了最后的 viewport 这一步,因为那只是简单的坐标缩放平移):
图:同一个立方体在三个空间下的俯视图(左、中)与正视图(右)。左图里星号标记相机位置;中图相机已被搬到原点、视线沿 方向(虚线);右图是透视除法后的 NDC,虚线框是 的裁剪边界,立方体的近端更大、远端更小,这正是下一节要讲的透视效果。
视图矩阵的本质
View 矩阵常常被讲得很抽象,但它的构造思路其实只有一句话:把"相机怎么摆"这个变换求逆,再作用到全世界身上。如果相机自己经历了一次旋转 和一次平移到 eye 位置的变换,那么"从相机看出去的世界"就应该是这个变换的逆——世界围着相机反着转、反着移,才能让相机在自己的参考系里始终待在原点、看向固定的 方向。
labs/common/transforms.py 里的 look_at(eye, center, up) 就是照这个思路写的:先算出相机的右方向 、上方向 、前方向 (f = normalize(center - eye),s = normalize(cross(f, up)),u = cross(s, f)),这三个向量本该组成相机自身的旋转矩阵 (按 OpenGL 约定相机看向 ,所以第三行用 )。但代码里直接把 填进了矩阵的行而不是列,这一步就是在利用上一篇讲过的性质: 是正交矩阵(各行/列单位长度且两两垂直),求逆等于转置,——把本该按列摆放的基向量改成按行摆放,写出来的矩阵直接就是 的转置,也就是它的逆,零成本地省掉了一次通用求逆。平移部分同理:不是先转后移,而是要"先撤销相机的平移、再撤销相机的旋转",所以代码里 M[:3, 3] = [-s @ eye, -u @ eye, f @ eye] 算的是 ,对应完整的逆变换 。理解了这一点,look_at 就不再是一段需要背下来的代码,而是"旋转求逆用转置、平移求逆取负号"这条上一篇结论的直接应用。
透视除法与 w
裁剪空间的坐标 还不能直接拿来判断"谁近谁远"或者"谁在屏幕内谁在屏幕外"——因为不同顶点的 并不相等。在 perspective 矩阵的约定下,,也就是顶点在观察空间里离相机的距离(沿 方向);离相机越远, 越大。这意味着裁剪空间里的坐标值本身混杂着"位置"和"距离"两种信息,两个 相同的顶点,可能一个离得近、一个离得远,直接比较毫无意义。判断顶点是否落在视锥体内,用的也是 这种带 的裁剪测试,而不是固定阈值。
把这份坐标除以它自己的 (),也就是透视除法,才把裁剪空间压平成了统一量纲的 NDC,这一步在整条 MVP 链路里唯一不是矩阵乘法能表示的操作——它是非线性的除法,发生在顶点着色器(vertex shader)算出裁剪坐标之后、光栅化(rasterization)之前,由图形管线的固定功能(fixed-function)阶段自动执行,不需要在着色器代码里手写。
"近大远小"正是这次除法的直接数学后果:把两个大小相同、但一个近一个远的物体想象成裁剪空间里 振幅相近但 不同的两组点——远处物体的 更大,除完之后 的振幅被压得更小,NDC 里占的宽度更窄,投到屏幕上自然显得更小。这也是为什么第 1 节反复强调 是齐次坐标的核心:它先在裁剪空间里悄悄记下了距离信息,又在除法这一步把距离信息转换成了屏幕上物体大小的变化,一套机制同时完成了"该怎么裁剪"和"该怎么显得近大远小"两件事。
一个数值例子
用 perspective(60°, 1, 0.5, 50) 手算一遍点 的完整旅程,和 lab 脚本的实现对照。先展开 perspective 矩阵的公式: 时 ,aspect、near、far,代入得
其中 ,。把点补成齐次坐标 与 相乘(矩阵每一行分别点乘这个向量),逐行算:
裁剪坐标是 。做透视除法,各分量除以 :
三个分量都落在 之内,说明这个点确实在相机的视锥体范围内,不会被裁剪掉; 的 NDC 值 比较靠近 (远平面),符合它离相机()比 far=50 近得多、但也不算贴着 near=0.5 的直觉。这组数字和 labs/common/transforms.py 里 apply(perspective(60,1,0.5,50), [[1,1,-5]]) 的实际输出完全一致,也是 lab_03_mvp.py 生成插图时立方体各顶点所经历的同一套计算。
🎯 岗位关联
插帧算法里的重投影(reprojection)——用上一帧的 MVP 矩阵和本帧的 MVP 矩阵之差,把上一帧某个像素反推回它在本帧应该出现的位置,从而生成运动矢量(motion vector)或者做时间上的复用——本质上就是把本篇这条变换链在两个不同时刻各走一遍,再比较结果。不理解 Model/View/Projection 各自的空间含义,就看不懂任何一段做 reprojection 或 TAA(temporal anti-aliasing)的代码在算什么。而这套仿射变换、齐次坐标的数学,和你在 Android 图形栈里打交道的 SurfaceFlinger Transform 类是同一件事——图层的位移、旋转、缩放同样可以写成 (或它的二维简化版)矩阵,Transform 类内部维护的矩阵合成逻辑,和这里"先缩放、再旋转、再平移"矩阵右乘的顺序规则完全一致。
小结
| 空间 | 由谁产生(哪个矩阵/操作) | 谁负责执行 |
|---|---|---|
| 局部空间 local space | —(建模阶段的原始坐标) | 应用层 / 建模工具 |
| 世界空间 world space | Model 矩阵 | 应用层(CPU 组织场景) |
| 观察空间 view space | View 矩阵 | 应用层(CPU 设置相机) |
| 裁剪空间 clip space | Projection 矩阵 | 顶点着色器(输出 gl_Position) |
| NDC | 透视除法(÷ ) | 固定功能(光栅化前自动执行) |
| 屏幕空间 screen space | viewport 变换 | 固定功能(按 glViewport 配置执行) |
延伸阅读
- songho 的 Projection Matrix(透视矩阵每一项是怎么推出来的):https://www.songho.ca/opengl/gl_projectionmatrix.html
- LearnOpenGL-CN 坐标系统(MVP 变换链的图文讲解):https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/
