04. 3D 变换与 MVP 链
04. 3D 变换与 MVP 链
本篇位置:模块一 · 第 4 篇 | 前置:齐次坐标 | 预计阅读:45 分钟
你其实只碰过一点点
从这一篇起,你不会有太多既视感。
前三篇讲的东西你天天在用,只是不知道它们的数学名字。而 3D 变换、相机、投影、深度——SurfaceFlinger 里基本没有。它是一个二维合成器:没有深度缓冲,没有真正的相机,asMatrix4() 的注释明说新插进来的第三维是单位变换、什么都不做。
所以本篇节奏会放慢。但你确实碰过一点点,就在 hwui 里。
RenderProperties::updateMatrix()(frameworks/base/libs/hwui/RenderProperties.cpp)有两个分支:
if (MathUtils::isZero(getRotationX()) && MathUtils::isZero(getRotationY())) {
// 纯二维:平移、绕 Z 转、缩放,全在一个 SkMatrix 里搞定
transform->setTranslate(getTranslationX(), getTranslationY());
transform->preRotate(getRotation(), getPivotX(), getPivotY());
transform->preScale(getScaleX(), getScaleY(), getPivotX(), getPivotY());
} else {
// 一旦要绕 X 或 Y 转,就得动用一台"相机"
SkMatrix transform3D;
mComputedFields.mTransformCamera.save();
transform->preScale(getScaleX(), getScaleY(), getPivotX(), getPivotY());
mComputedFields.mTransformCamera.rotateX(mPrimitiveFields.mRotationX);
mComputedFields.mTransformCamera.rotateY(mPrimitiveFields.mRotationY);
mComputedFields.mTransformCamera.rotateZ(-mPrimitiveFields.mRotation);
mComputedFields.mTransformCamera.getMatrix(&transform3D);
transform3D.preTranslate(-getPivotX(), -getPivotY());
transform3D.postTranslate(getPivotX() + getTranslationX(),
getPivotY() + getTranslationY());
transform->postConcat(transform3D);
mComputedFields.mTransformCamera.restore();
}而那台"相机"(Skia 的 Sk3DView)默认摆在这个位置(external/skia/src/utils/SkCamera.cpp):
fLocation = {0, 0, -SkIntToScalar(576)}; // 8 inches backward三个问题:
- 为什么绕 Z 转(普通的
View.rotation)不需要相机,绕 X/Y 转就需要? - 相机凭什么"在屏幕后面 8 英寸"? 这个位置是拿来干什么的?
getMatrix()把三维的结果压回一个 3×3 的SkMatrix——它凭什么能压回去?丢了什么?
顺带说一句:那两行 preTranslate(-pivot) / postTranslate(+pivot),正是第 2 篇讲过的"挪到原点 → 变换 → 挪回来",只不过这次绕的是 View 的中心点。
学完你会什么
- 写出三维的缩放、平移、绕三根轴旋转的 4×4 矩阵,并说清它们和二维版本的关系。
- 说清右手系和左手系的区别,以及为什么这个约定一变、一堆结论跟着反。
- 用三维叉积求三角形法线,并说清顶点绕序怎么决定法线朝哪面。
- 说清 MVP 三个矩阵各自把点从哪个坐标系搬到哪个坐标系。
- 说清 View 矩阵为什么是"相机摆放的逆"。
- 说清法线为什么不能用同一个矩阵变换,什么时候必须用逆转置。
- 知道四元数是干什么用的,以及什么时候才需要它。
坐标约定换了。 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 篇讲的那个二维旋转——所以 View.rotation 那个属性根本不需要三维。
这就是问题 1 的答案。 绕 Z 转, 坐标全程不变,整件事发生在同一个平面上,一个 3×3 的 SkMatrix 装得下。而绕 X 或 Y 转会把点推离原来的平面——有的地方离屏幕近了,有的远了。要把这个"远近"变成画面上的大小差异,就必须有一台相机来定义"从哪儿看"。
2. 右手系与左手系
二维平面没有手性问题。三维有:给定 X 和 Y 轴,Z 轴可以指向两个相反的方向。
- 右手系:伸出右手,食指指 ,中指指 ,大拇指指的方向就是 。
- 左手系:换左手做同样的动作。
一样的判据是叉积:右手系里 ,左手系里 。
这个约定一变,跟着反过来的东西有一串:
| 会变的东西 | 影响 |
|---|---|
| 叉积的方向 | 法线朝里还是朝外 |
| 旋转的正方向 | 同一个角度,转向相反 |
| 深度值增长的方向 | 近处 大还是远处 大 |
| 三角形正面的绕序 | 背面剔除剔掉哪一半 |
OpenGL 的世界空间和相机空间习惯用右手系;DirectX、Unity 用左手系。这也是第 1 篇那道自测题的完整版:任何一句"按逆时针绕序算正面",不说清坐标系就是无效信息。
hwui 那段代码里的
rotateZ(-mPrimitiveFields.mRotation),那个负号就是在做这类符号折算——View.rotation是按 Android 屏幕坐标( 向下)定义的"顺时针为正",而Sk3DView内部是另一套约定。符号折算写在哪一层、由谁负责,是跨坐标系代码里最容易出错也最难查的一类问题。
3. 三维叉积:法线是怎么来的
第 1 篇讲的二维叉积结果是一个数。三维叉积的结果是一个向量,它同时垂直于原来两个向量:
(这个式子不用背。它的第三个分量 就是第 1 篇那个二维叉积,另外两个分量按 轮换即可。)
它的长度仍然是平行四边形的面积,方向由右手定则决定:右手四指从 弯向 ,大拇指指的就是 。
用它求三角形的法线
法线(normal)是垂直于表面的单位向量。它决定了光照怎么反射——第 1 篇提过的 dot(N, L) 里的 就是它。
给定三角形三个顶点 ,取两条边做叉积:
再归一化成单位长度就能用了。
图:同样三个顶点,只是遍历顺序换了一下,法线就掉了个头。
顶点绕序决定法线朝哪面。 这一条串起来好几件事:
- 模型被镜像之后光照全错——因为绕序反了,所有法线朝里了;
- 背面剔除剔错了一半——判据就是绕序;
- 第 2 篇讲的行列式符号为负表示"翻面",说的是同一件事。
4. MVP:三次换坐标系
现在拼装完整的变换链。要把一个三维模型画到屏幕上,需要连续换四次坐标系:
| 步骤 | 矩阵 | 从哪里到哪里 | 在做什么 |
|---|---|---|---|
| ① | — | 模型空间 | 建模时的坐标,原点在物体自己身上 |
| ② | Model | 模型 → 世界空间 | 把物体摆到场景里该在的位置和朝向 |
| ③ | View | 世界 → 相机空间 | 把相机挪到原点,世界跟着反向动 |
| ④ | Projection | 相机 → 裁剪空间 | 决定看得见多大范围,为透视除法做准备 |
三个矩阵预先乘成一个:
顶点着色器里通常就是一行 gl_Position = uMVP * aPosition;。
注意乘法顺序:按第 2 篇的规矩,靠近向量的先生效,所以实际顺序是 先、 次、 最后——和名字的读法正好相反。这是初学者最容易写反的一处。
这一整套你其实早就懂了。 第 1 篇讲过
layerStackSpace、orientedDisplaySpace、framebufferSpace、displaySpace四个坐标空间,以及ui::Transform作为它们之间的换算规则。MVP 是同一件事:一串具名的坐标空间,加上空间之间的换算矩阵。区别只是合成器的空间都是二维的,而这里多了一维和一台相机。你已经掌握的是这套思维方式本身,新增的只是每一步具体在算什么。
5. View 矩阵的本质:相机摆放的逆
初学 MVP 最费解的一步是 View。
我们想做的是"把相机搬到某个位置、朝某个方向看"。但图形管线里根本没有"相机"这种东西——投影公式写死了观察者在原点。
所以做法是反过来的:与其把相机搬过去,不如把整个世界反向搬过来。
图:相机往右上方挪 ,等价于整个世界往左下方挪 。画面完全一样,但相机永远待在原点。
所以:
这也解释了 Sk3DView 那个 fLocation = {0, 0, -576}——这就是问题 2 的答案。相机被放在 (8 英寸,Skia 里 1 英寸 = 72 单位)。当 View 树上的内容绕 X 轴倾斜时,靠近相机的一边会被投影得更大、远离的一边更小,产生"立起来"的透视效果。
这个距离决定了透视有多强烈:相机离得越近,同样的倾斜角看起来越夸张(想想广角镜头的畸变)。576 这个数是个视觉上折中的经验值——够远,不至于让普通的卡片翻转动画看起来像鱼眼镜头。
6. 法线的陷阱:不能用同一个矩阵
有一个坑必须在这里说清楚,因为它极其常见而且症状很隐蔽。
顶点位置用 变换,法线不能也用 。
图:一个 45° 的斜面,法线本来垂直于它(左,90°)。把整个东西沿 压扁到 0.35 倍之后,如果法线也乘同一个缩放矩阵,它和表面的夹角变成了 38.6°(中)——根本不垂直了,拿它去算光照必然是错的。改用逆转置矩阵变换法线,才重新垂直(右)。
正确的做法是用 的逆转置(inverse transpose):
为什么等比缩放和纯旋转不会出问题? 因为第 2 篇证过:纯旋转矩阵的转置就是它的逆,所以 ——逆转置和原矩阵一模一样,用哪个都对。等比缩放同理(只差一个整体倍数,归一化之后就没了)。
只有非等比缩放(和切变)会露馅。 这就是为什么这个 bug 特别难查:模型只做旋转和平移时一切正常,某天美术给某个物体加了个"压扁"的缩放,光照就悄悄错了,而且错得不明显——不是全黑,只是有点不对劲。
第 2 篇末尾预告过这个陷阱:"旋转矩阵求逆的捷径只对纯旋转成立"。这里就是它的反面。
7. 四元数是干什么用的
这一节只回答"它解决什么问题、什么时候需要它",不展开数学——在你真正要做旋转插值之前,不需要懂它的内部构造。
欧拉角的问题
最直观的旋转表示是三个角度:绕 X 转多少、绕 Y 转多少、绕 Z 转多少,叫欧拉角(Euler angles)。hwui 的 rotationX/rotationY/rotation 就是欧拉角。
它有两个毛病:
- 顺序有歧义。 先绕 X 再绕 Y,和先绕 Y 再绕 X,结果不同(第 2 篇讲过矩阵乘法不交换)。所以"绕 X 转 30°、绕 Y 转 45°"这句话,不说清顺序就是不完整的。
- 万向锁(gimbal lock)。 当中间那个角转到 ±90° 时,第一根轴和第三根轴会重合,三个自由度塌成两个——你会发现无论怎么调参数,就是转不到某些姿态。
还有第三个更实际的问题:两个欧拉角之间没法平滑插值。 想让相机从姿态 A 平滑转到姿态 B,逐个角度做线性插值会得到一条扭来扭去的怪路径。
四元数做了什么
四元数(quaternion)用四个数表示一个三维旋转。它绕开了上面全部三个问题:没有顺序歧义、没有万向锁、而且两个四元数之间可以平滑插值(那个插值算法叫 slerp,球面线性插值)。
代价是它不直观——你没法看着四个数想象出它代表哪个姿态。
实践中的分工基本是固定的:
| 场景 | 用什么 |
|---|---|
| 让人手动填参数、看数值调试 | 欧拉角 |
| 存储、传递、组合旋转 | 四元数 |
| 在两个姿态之间做平滑过渡 | 四元数(必须) |
| 最终送进着色器 | 转成矩阵 |
AOSP 里有现成的实现:frameworks/native/libs/math/include/math/quat.h。
什么时候你会真的需要它? 做相机的平滑转向、骨骼动画的姿态混合、或者任何"在两个朝向之间过渡"的场合。做二维合成、做后处理、做图像算法,一辈子碰不到。
回到源码
问题 1:为什么绕 Z 转不需要相机,绕 X/Y 转就需要?
绕 Z 转时每个点的 坐标不变,整件事在同一个平面内完成,一个 3×3 的 SkMatrix 就够。绕 X/Y 转会把点推离原平面,产生真正的远近差异;要把远近变成画面上的大小差异,必须先定义"从哪儿看"——那就是相机。
问题 2:相机为什么在屏幕后面 8 英寸?
那是 View 矩阵里相机的位置。(8 英寸 × 72 单位)决定了透视的强度:离得越近,同样的倾斜看起来越夸张。576 是个视觉折中值,让常见的卡片翻转动画既有立体感又不至于畸变。
问题 3:getMatrix() 凭什么能把三维结果压回一个 3×3?丢了什么?
因为 hwui 的下游(Skia 的二维绘制管线)只认二维。SkCamera3D::patchToMatrix 做的事情是把三维变换 + 透视投影的结果,塞进 SkMatrix 的透视行:
matrix->set(SkMatrix::kMPersp0, SkScalarDotDiv(3, patchPtr, 1, mapPtr+6, 1, dot));
matrix->set(SkMatrix::kMPersp1, SkScalarDotDiv(3, patchPtr, 1, mapPtr+6, 1, dot));
matrix->set(SkMatrix::kMPersp2, SK_Scalar1);写的正是第 3 篇讲过的 kMPersp0/1/2。所以那次"压回"不是丢掉透视,而是把透视效果编码进了二维矩阵的第三行。
丢掉的是深度信息本身。压回二维之后,这个 View 只剩下一张被透视扭曲的平面图像,它不再知道自己哪部分离得近哪部分远——所以两个互相穿插的 3D View 没法正确地遮挡对方,只能按图层顺序前后叠。这正是合成器和渲染器的根本区别:合成器处理的是一堆已经拍扁的二维图像,渲染器处理的是还带着深度的三维场景。
自测
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 | 模型 → 世界 → 相机 → 裁剪,三次换坐标系 | 和四个 ProjectionSpace 是同一个思路 |
| 乘法顺序 | ,但生效顺序是 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
- AOSP / Skia 源码:
frameworks/base/libs/hwui/RenderProperties.cpp、external/skia/src/utils/SkCamera.cpp、frameworks/native/libs/math/include/math/quat.h