3D 变换与 MVP 链
3D 变换与 MVP 链
坐标约定换了。 Part 1(第 1~3 篇)用 Android 屏幕坐标:原点左上、 向下。从本篇起改用图形学惯例:右手系、 向上。
这不是我在制造混乱——这两套约定在真实世界里就是并存的,合成器一套、渲染管线一套。与其假装它们一样,不如在切换点说清楚。本篇第 2 节专门讲这个差异会造成什么后果。
1. 从 3×3 到 4×4:规则一模一样
第 3 篇讲过升维的套路:三维点 记成 ,用 4×4 矩阵,第四列管平移,第四行管透视。
平移:
缩放:
旋转在三维里变复杂了:二维只能绕一个点转,三维要指定绕哪根轴。三根轴各有一个矩阵(这里只写左上角 3×3,第四行第四列照旧补 ):
别背,看规律就行:
- 绕哪根轴转,那根轴对应的行列就是"什么都不做"(对角线上是 1,其余是 0)——因为绕 Z 转,点的 坐标当然不变。
- 剩下的 2×2 就是第 2 篇那个二维旋转矩阵,原样搬进来。
- 的负号位置和另外两个不一样,这是右手系下轴序循环()的结果,不是笔误。
图:同一个小房子,绕三根不同的轴各转 55°。绕 Z 转(最右)就是第 2 篇讲的那个二维旋转——整件事发生在同一个平面里。
绕 Z 转, 坐标全程不变,整件事发生在同一个平面上,一个 3×3 矩阵就装得下。而绕 X 或 Y 转会把点推离原来的平面——有的地方离屏幕近了,有的远了。要把这个"远近"变成画面上的大小差异,就必须有一台相机来定义"从哪儿看"——这是第 5 节 View 矩阵的事。
2. 右手系与左手系
二维平面没有手性问题。三维有:给定 X 和 Y 轴,Z 轴可以指向两个相反的方向。
- 右手系:伸出右手,食指指 ,中指指 ,大拇指指的方向就是 。
- 左手系:换左手做同样的动作。
一样的判据是叉积:右手系里 ,左手系里 。
这个约定一变,跟着反过来的东西有一串:
| 会变的东西 | 影响 |
|---|---|
| 叉积的方向 | 法线朝里还是朝外 |
| 旋转的正方向 | 同一个角度,转向相反 |
| 三角形正面的绕序 | 背面剔除剔掉哪一半 |
顺带把一件容易混淆的事说清:"近处映射成 0 还是 1"跟手性无关——深度怎么映射另有一套约定(第 5 篇展开),右手系和左手系都可以任选一种。
常见图形教程通常采用右手的世界/相机空间;但 API 一族并不强制世界空间的手性,不同引擎会自行选择。这也是第 1 篇那道自测题的完整版:任何一句"按逆时针绕序算正面",不说清坐标系就是无效信息。
本篇开头的坐标约定切换就是一处现成的符号折算:同一个旋转方向,在第 1~3 篇的屏幕坐标( 向下)和本篇的图形学约定( 向上)下,正负号正好相反。两套约定的代码相遇时,符号折算写在哪一层、由谁负责,是跨坐标系代码里最容易出错也最难查的一类问题。
3. 三维叉积:法线是怎么来的
第 1 篇讲的二维叉积结果是一个数。三维叉积的结果是一个向量,它同时垂直于原来两个向量:
(这个式子不用背。它的第三个分量 就是第 1 篇那个二维叉积,另外两个分量按 轮换即可。)
它的长度仍然是平行四边形的面积,方向由右手定则决定:右手四指从 弯向 ,大拇指指的就是 。
用它求三角形的法线
法线(normal)是垂直于表面的单位向量。它决定了光照怎么反射——第 1 篇提过的 dot(N, L) 里的 就是它。
给定三角形三个顶点 ,取两条边做叉积:
再归一化成单位长度就能用了。
图:同样三个顶点,只是遍历顺序换了一下,法线就掉了个头。
顶点绕序决定法线朝哪面。 这一条串起来好几件事:
- 模型被镜像之后光照全错——因为绕序反了,所有法线朝里了;
- 背面剔除剔错了一半——判据就是绕序;
- 第 2 篇讲的行列式符号为负表示"翻面",说的是同一件事。
4. MVP:三次换坐标系
现在拼装完整的变换链。要把一个三维模型画到屏幕上,需要连续换四次坐标系:
| 步骤 | 矩阵 | 从哪里到哪里 | 在做什么 |
|---|---|---|---|
| ① | — | 模型空间 | 建模时的坐标,原点在物体自己身上 |
| ② | Model | 模型 → 世界空间 | 把物体摆到场景里该在的位置和朝向 |
| ③ | View | 世界 → 相机空间 | 把相机挪到原点,世界跟着反向动 |
| ④ | Projection | 相机 → 裁剪空间 | 决定看得见多大范围,为透视除法做准备 |
三个矩阵预先乘成一个:
在顶点着色器里,这条链通常就是一行:把顶点乘上预先合并好的 MVP。
注意乘法顺序:按第 2 篇的规矩,靠近向量的先生效,所以实际顺序是 先、 次、 最后——和名字的读法正好相反。这是初学者最容易写反的一处。
这和第 1 篇是同一个思路。 第 1 篇说过:代码里常给每个坐标系起名字,强迫每个坐标值带上"我属于哪个空间",两套数字之间隔着一条确定的换算规则。MVP 把这句话扩展到了三维:一串具名的坐标空间,加上空间之间的换算矩阵。你已经掌握的是这套思维方式本身,新增的只是每一步具体在算什么。
5. View 矩阵的本质:相机摆放的逆
初学 MVP 最费解的一步是 View。
我们想做的是"把相机搬到某个位置、朝某个方向看"。但图形管线里根本没有"相机"这种东西——投影公式写死了观察者在原点。
所以做法是反过来的:与其把相机搬过去,不如把整个世界反向搬过来。
图:相机往右上方挪 ,等价于整个世界往左下方挪 。画面完全一样,但相机永远待在原点。
所以:
相机朝向和坐标轴到底是什么关系
这里最容易混淆的是:相机的观察方向,不一定是坐标轴的 方向。
世界有一套固定的坐标轴,相机自己还带着一套局部坐标系,可以用右方向 、上方向 和观察方向 描述它的姿态。相机移动或旋转,改变的是这个局部坐标系相对世界坐标系的位置和朝向;View 矩阵再把世界中的点,重新表达成相对于相机的坐标。
在本篇采用的右手系里,如果规定相机局部的 向右、 向上,那么根据 ,相机局部的 会从画面里指向观察者。相机要看向画面深处,观察方向就只能是 :
图:世界坐标轴保持固定,相机的局部坐标轴随相机一起移动和旋转。在这里,相机局部的 指向后方,因此观察方向 指向 ;相机前方距离为 的点进入相机空间后, 坐标为 。
设相机在世界中的位置为 ,看向目标点 ,那么用世界坐标表示的观察方向就是 。对于世界中的一点 ,先求它相对相机的向量 ,再分别投影到相机的三根轴上:
因此,相机正前方距离为 的点 ,进入相机空间后是 。也就是说,在这套约定里:
- 相机位于相机空间的原点;
- 相机朝 看;
- 相机前面的点,其相机空间 坐标为负数。
例如相机位于世界坐标 、朝原点看,它在世界中的观察方向是 ;世界原点经过 View 矩阵后变成 。这正好体现了“相机往 退 5”等价于“世界往 移 5”。
只是约定,不是自然规律。 有些左手系管线会让相机朝 看;API 也不一定强制世界空间采用哪种手性。看到“相机朝前”时,要先查清楚当前坐标系怎样定义 ,不能直接把“前方”和“”画等号。
这个例子还附带一条直观结论:相机离内容越近,透视越强烈——同样大小的东西,离得近时在视野里占的角度更大,近处和远处的尺寸差异也被放大(想想广角镜头的畸变)。第 5 篇会用投影公式把这件事算清楚。
6. 法线的陷阱:不能用同一个矩阵
有一个坑必须在这里说清楚,因为它极其常见而且症状很隐蔽。
顶点位置用 变换,法线不能也用 。
图:一个 45° 的斜面,法线本来垂直于它(左,90°)。把整个东西沿 压扁到 0.35 倍之后,如果法线也乘同一个缩放矩阵,它和表面的夹角变成了 38.6°(中)——根本不垂直了,拿它去算光照必然是错的。改用逆转置矩阵变换法线,才重新垂直(右)。
正确的做法是用 的逆转置(inverse transpose):
为什么等比缩放和纯旋转不会出问题? 因为第 2 篇证过:纯旋转矩阵的转置就是它的逆,所以 ——逆转置和原矩阵一模一样,用哪个都对。等比缩放同理(只差一个整体倍数,归一化之后就没了)。
只有非等比缩放(和切变)会露馅。 这就是为什么这个 bug 特别难查:模型只做旋转和平移时一切正常,某天美术给某个物体加了个"压扁"的缩放,光照就悄悄错了,而且错得不明显——不是全黑,只是有点不对劲。
第 2 篇末尾预告过这个陷阱:"旋转矩阵求逆的捷径只对纯旋转成立"。这里就是它的反面。
7. 四元数是干什么用的
这一节只回答"它解决什么问题、什么时候需要它",不展开数学——在你真正要做旋转插值之前,不需要懂它的内部构造。
欧拉角的问题
最直观的旋转表示是三个角度:绕 X 转多少、绕 Y 转多少、绕 Z 转多少,叫欧拉角(Euler angles)。
它有两个毛病:
- 顺序有歧义。 先绕 X 再绕 Y,和先绕 Y 再绕 X,结果不同(第 2 篇讲过矩阵乘法不交换)。所以"绕 X 转 30°、绕 Y 转 45°"这句话,不说清顺序就是不完整的。
- 万向锁(gimbal lock)。 当中间那个角转到 ±90° 时,第一根轴和第三根轴会重合,三个自由度塌成两个——你会发现无论怎么调参数,就是转不到某些姿态。
还有第三个更实际的问题:两个欧拉角之间没法平滑插值。 想让相机从姿态 A 平滑转到姿态 B,逐个角度做线性插值会得到一条扭来扭去的怪路径。
四元数做了什么
四元数(quaternion)用四个数表示一个三维旋转。它绕开了上面全部三个问题:没有顺序歧义、没有万向锁、而且两个四元数之间可以平滑插值(那个插值算法叫 slerp,球面线性插值)。
代价是它不直观——你没法看着四个数想象出它代表哪个姿态。
实践中的分工基本是固定的:
| 场景 | 用什么 |
|---|---|
| 让人手动填参数、看数值调试 | 欧拉角 |
| 存储、传递、组合旋转 | 四元数 |
| 在两个姿态之间做平滑过渡 | 四元数(必须) |
| 最终送进着色器 | 转成矩阵 |
什么时候你会真的需要它? 做相机的平滑转向、骨骼动画的姿态过渡,或者任何"在两个朝向之间平滑过渡"的场合。做二维合成、做后处理、做图像算法,一辈子碰不到。
自测
问题 1:MVP 三次换坐标系,每一次各解决什么问题?
每一次换坐标系,都是为了让下一步有法算(第 4 节)。Model 把点从"原点还在物体自己身上"的模型空间搬进世界空间——模型造出来时并不知道自己在场景里的位置,这一步把它摆进去。View 把世界搬进相机空间,让相机回到原点——投影公式是按"观察者在原点"写死的,相机不挪过来,公式没法用(第 5 节)。Projection 定下可见范围、为透视除法做准备——除以 之后,三维的点才落得回二维画面(实验图 m1-04-mvp-stages.svg 的第 ④ 格)。
被压进画面的同时丢掉的是深度信息本身:屏幕上这张图不再知道自己哪部分近、哪部分远。多个物体互相遮挡时到底谁挡谁,要靠另外的机制把远近找回来——那是第 5 篇的主题。
问题 2:为什么 View 矩阵是"相机摆放的逆"?
因为管线里根本没有"相机"这个物件:投影公式假定观察者待在原点、朝向固定(第 5 节)。想把相机摆到某处,唯一的办法是把整个世界反着搬同样的量。实验图 m1-04-view-is-inverse.svg 的两格就是这个等价:相机挪 ,世界就挪 ,画面一模一样,而相机永远待在原点。写成矩阵就是 。
问题 3:非均匀缩放时,法线为什么不能直接乘同一个矩阵?
因为法线必须始终垂直于表面,而"乘同一个矩阵"保不住这份垂直(第 6 节)。非均匀缩放把不同方向按不同比例拉伸:实验图里,45° 的斜面沿 压扁到 0.35 倍之后,直接乘同一个矩阵的法线和表面的夹角从 90° 变成了 38.6°——不再垂直,拿它算光照必然是错的。改用逆转置 才重新垂直。纯旋转和等比缩放是仅有的两种可以偷懒的情况,因为那时逆转置和原矩阵给出同一个结果(第 6 节证过)。
1. 绕 Y 轴转 90° 的矩阵是什么?把点 (1, 0, 0) 代进去得到什么?
、,代入 :
作用在 上:。
也就是 轴被转到了 方向。用右手系检验:绕 按右手定则转, 确实朝 去。
2. MVP = P·V·M,三个矩阵实际生效的顺序是什么?
→ → ,也就是从右往左。
因为它作用在顶点上是 ,靠近向量的先算。名字读起来是 MVP,写成乘法是 PVM,生效顺序又是 MVP——这三者的错位是新手最容易写反的地方。
3. 相机要摆到 (0, 0, 5) 并且不旋转,View 矩阵是什么?
相机的摆放是"平移 ",View 是它的逆,也就是平移 。
直觉检验:相机往 退了 5,等价于整个世界往 推了 5。
4. 一个模型只做了旋转和平移,法线还需要用逆转置矩阵吗?
不需要,用原矩阵就是对的。
纯旋转矩阵满足"转置 = 逆",所以 ,逆转置和原矩阵完全相同。平移对法线本来就不该起作用,而第 3 篇讲过,法线的 ,平移列乘 0 自动失效。
只有非等比缩放和切变才必须用逆转置。
5. 同事说"我们的相机插值用欧拉角逐个分量 lerp 就行,简单"。这样做会出什么问题?
两个问题:
- 路径不对。 欧拉角逐分量线性插值得到的中间姿态,不是两个姿态之间的"最短转法",相机会走一条扭来扭去的路径,看起来像在甩头。
- 可能卡死。 如果插值路径经过万向锁的位置(中间那个角接近 ±90°),会出现明显的抽搐或者转不过去。
正确做法是转成四元数做 slerp(球面线性插值),它保证走的是两个姿态之间的最短弧。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 3D 变换矩阵 | 4×4,第四列平移、第四行透视 | 和二维完全同构 |
| 三根轴的旋转 | 绕哪根轴,哪根轴的行列就是单位 | 剩下的 2×2 就是二维旋转矩阵 |
| 手性 | 右手系 ,左手系相反 | 叉积、旋转正方向、正面绕序等约定会受影响;深度映射另行决定 |
| 三维叉积 | 结果是垂直于两者的向量 | 长度 = 平行四边形面积 |
| 法线 | 绕序决定法线朝哪面 | |
| MVP | 模型 → 世界 → 相机 → 裁剪,三次换坐标系 | 一串具名的坐标空间加换算矩阵——和第 1 篇同一个思路 |
| 乘法顺序 | ,但生效顺序是 M→V→P | 最容易写反的地方 |
| View 矩阵 | = 相机摆放的逆 | 管线里没有相机,只能反向搬世界 |
| 法线变换 | 必须用逆转置 | 纯旋转/等比缩放时才可以偷懒 |
| 四元数 | 4 个数表示旋转,能平滑插值、无万向锁 | 只在做姿态过渡时才必须用 |
延伸阅读
- 3Blue1Brown《线性代数的本质》第 5、11 集(行列式、抽象向量空间):https://www.bilibili.com/video/BV1ys411472E
- GAMES101 Lecture 4:Transformation Cont.(3D 变换与 MVP):https://games-cn.org/intro-graphics/
- LearnOpenGL 中文版《坐标系统》:https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/
- 3Blue1Brown《四元数的可视化》:https://www.bilibili.com/video/BV1SW411y7YP

觉得有用?关注公众号「阿豪讲Framework」
Android 系统开发,新文章第一时间推送。