03. 齐次坐标:为什么是 3×3 不是 2×2
03. 齐次坐标:为什么是 3×3 不是 2×2
本篇位置:模块一 · 第 3 篇 | 前置:2D 变换与矩阵 | 预计阅读:35 分钟
你已经在用它了
ui::Transform 明明只处理二维,内部却使用 3×3 矩阵;翻转、旋转和平移最终都被合并进同一个矩阵。
代码来自 frameworks/native/libs/ui/include/ui/Transform.h 和 Transform.cpp(本系列源码摘自 Android 17 分支,不同版本可能略有出入):
enum RotationFlags : uint32_t {
ROT_0 = 0,
FLIP_H = 1, // HAL_TRANSFORM_FLIP_H
FLIP_V = 2, // HAL_TRANSFORM_FLIP_V
ROT_90 = 4, // HAL_TRANSFORM_ROT_90
ROT_180 = FLIP_H | FLIP_V,
ROT_270 = ROT_180 | ROT_90,
ROT_INVALID = 0x80
};再看它在 Transform.cpp 里怎么被兑现成真正的变换(删去了错误分支和类型标记的维护):
status_t Transform::set(uint32_t flags, float w, float h) {
Transform H, V, R;
if (flags & ROT_90) {
// w & h are inverted when rotating by 90 degrees
std::swap(w, h);
}
if (flags & FLIP_H) {
mat33& M(H.mMatrix);
M[0][0] = -1; M[2][0] = w;
}
if (flags & FLIP_V) {
mat33& M(V.mMatrix);
M[1][1] = -1; M[2][1] = h;
}
if (flags & ROT_90) {
const float original_w = h;
mat33& M(R.mMatrix);
M[0][0] = 0; M[1][0] = -1; M[2][0] = original_w;
M[0][1] = 1; M[1][1] = 0;
}
*this = (R*(H*V)); // ← 三个变换被"乘"到了一起
return NO_ERROR;
}struct mat33 {
vec3 v[3];
inline const vec3& operator [] (size_t i) const { return v[i]; }
};
mat33 mMatrix;
void Transform::set(float tx, float ty) {
mMatrix[2][0] = tx; // 平移量写在第三列
mMatrix[2][1] = ty;
mMatrix[2][2] = 1.0f;
// ...
}Skia 的 SkMatrix 也是 3×3,而且它把九个槽位一个个起了名字(external/skia/include/core/SkMatrix.h):
static constexpr int kMScaleX = 0; //!< horizontal scale factor
static constexpr int kMSkewX = 1; //!< horizontal skew factor
static constexpr int kMTransX = 2; //!< horizontal translation
static constexpr int kMSkewY = 3;
static constexpr int kMScaleY = 4;
static constexpr int kMTransY = 5;
static constexpr int kMPersp0 = 6; //!< input x perspective factor
static constexpr int kMPersp1 = 7; //!< input y perspective factor
static constexpr int kMPersp2 = 8; //!< perspective bias最后一行——kMPersp0/1/2——叫透视(perspective)。而 Skia 真的为它准备了一条单独的映射函数(external/skia/src/core/SkMatrix.cpp):
void SkMatrix::Persp_pts(const SkMatrix& m, SkPoint dst[],
const SkPoint src[], int count) {
// ...
SkScalar x = sdot(sx, m.fMat[kMScaleX], sy, m.fMat[kMSkewX]) + m.fMat[kMTransX];
SkScalar y = sdot(sx, m.fMat[kMSkewY], sy, m.fMat[kMScaleY]) + m.fMat[kMTransY];
SkScalar z = sdot(sx, m.fMat[kMPersp0], sy, m.fMat[kMPersp1]) + m.fMat[kMPersp2];
if (z) {
z = 1 / z;
}
dst->fY = y * z; // ← 除以第三个分量
dst->fX = x * z;
// ...
}四个问题:
- 二维变换为什么非要用 3×3? 多出来的那一行一列在干什么,
FLIP_H里的M[2][0] = w又表示什么? - 平面上哪来的"透视"? 为什么第三行会叫
Persp? Persp_pts里算出来的那个z是什么? 为什么要拿x、y去除它?平时的 2D 变换又为什么不用除?- 源码为什么写成
R*(H*V)? 如果交换顺序,旋转后的图层为什么会跑出屏幕?
学完你会什么
- 说清平移为什么塞不进 2×2,以及"加一维"是怎么绕开这个限制的。
- 说清点写成 、方向写成 这个约定,怎么让第 1 篇那张"哪些运算合法"的表自动成立。
- 看懂 3×3 矩阵里三块分区各管什么。
- 说清第三行不是 时会发生什么,以及为什么那叫透视。
- 看懂
Transform::asMatrix4()把 3×3 搬成 4×4 那段代码。
1. 先确认那个限制确实存在
第 1 篇算过:任何 2×2 矩阵乘以原点 ,结果都还是 。
而平移的定义就是"把所有点(包括原点)挪到别处"。所以:
2×2 矩阵永远做不了平移。 这不是"还没找到合适的数字",是这类运算本身办不到。
图:缩放、旋转、切变——三种典型的 2×2 变换,形状怎么变都行,但那个黑点始终钉在原地。
这就麻烦了。图形管线希望把一长串变换乘成一个矩阵(第 2 篇第 4 节讲过),可是平移偏偏是最常用的操作之一。要么放弃"全部合并",每次变换都写成"矩阵乘一下、再单独加个偏移";要么想办法把平移也变成矩阵乘法。
图形学选了后者。
2. 加一维:把平面搬到 z = 1 那一层
办法出人意料地简单:把二维平面塞进三维空间里,放在 这个高度上。
也就是说,平面上的点 ,记成三维的 。
在三维空间里,有一种叫切变(shear)的线性变换:把每个点沿 方向推一段。写成矩阵是
代进去算算,对一个 的点:
加上了 , 没变, 还是 1。从这一层平面上看,这就是往右平移了 。
图:整层平面的 都是 1,所以"推的距离正比于 "这条规则对这一层的每个点给出的都是同一个数——推得一样远,也就是整体平移。
关键在于:切变是一个标准的线性变换,它在三维里完全可以用矩阵表达。 我们没有发明新运算,只是换了个维度看问题,平移就自动变成了矩阵乘法的一部分。
这套把 维问题抬到 维来做的坐标记法,叫齐次坐标(homogeneous coordinates)。这就是问题 1 的答案。
多出来的那个分量通常写作 :
3. 点的 w 是 1,方向的 w 是 0
第 1 篇列过一张表:点减点得到向量、点加向量得到点、而点加点没有意义。当时说这个区分只能靠命名和类型来维持。
齐次坐标把它编进了数字本身:
- 点(位置)的 记作 1;
- 向量(方向、位移)的 记作 0。
然后什么都不用管,让 分量自己跟着算术走:
| 运算 | 怎么算 | 结果的 | 结论 |
|---|---|---|---|
| 点 − 点 | 0 | 得到向量 ✅ | |
| 点 + 向量 | 1 | 得到点 ✅ | |
| 向量 + 向量 | 0 | 得到向量 ✅ | |
| 向量 × 3 | 0 | 还是向量 ✅ | |
| 点 + 点 | 2 | 既不是 1 也不是 0 ❌ |
最后一行是这个设计最漂亮的地方:非法运算不需要你去记,算出来的 自己就是个信号——它既不是点也不是向量。
这还有一个立刻能用的推论。看第 2 节那个平移矩阵,把它作用在一个 的向量上:
平移对向量不起作用——完全正确,因为"往右 3 米"这个位移,不管你在哪儿说它,都还是"往右 3 米"。同一个矩阵,作用在点上会平移,作用在方向上不会,全靠 那一位自动区分。
这个性质在第 4 篇讲法线时会变成一件要紧事:法线是方向,绝不能被平移影响。用 表示它,这件事就自动成立了。
4. 3×3 矩阵的三块分区
现在可以给整个矩阵划分区了:
| 分区 | 管什么 | 在源码里 |
|---|---|---|
| 左上 2×2() | 形状:旋转、缩放、翻转、切变 | SkMatrix 的 kMScaleX/kMSkewX/kMSkewY/kMScaleY |
| 第三列() | 平移到哪 | ui::Transform 的 mMatrix[2][0]、[2][1];SkMatrix 的 kMTransX/kMTransY |
| 第三行(,末位通常是 1) | 透视 | SkMatrix 的 kMPersp0/kMPersp1/kMPersp2 |
源码对照:行列式与求逆
ui::Transform 的行列式和求逆实现操作的是 3×3 矩阵左上角的 2×2 线性部分:
float Transform::det() const {
return mMatrix[0][0] * mMatrix[1][1] - mMatrix[0][1] * mMatrix[1][0];
}const float idet = 1.0f / det();
result.mMatrix[0][0] = d*idet;
result.mMatrix[0][1] = -c*idet;
result.mMatrix[1][0] = -b*idet;
result.mMatrix[1][1] = a*idet;Transform::inverse() 的注释还明确了平移与线性变换的组合顺序:
// our 3x3 matrix is always of the form of a 2x2 transformation
// followed by a translation: T*M, therefore:
// (T*M)^-1 = M^-1 * T^-1左上 2×2 的几何含义已经明确;这一篇剩下的篇幅,重点看第三行。
5. 第三行:把它改掉会发生什么
平时第三行是 。算一下它对结果的贡献:
输出的 恒为 1,跟输入无关。所以做完变换,点还老老实实待在 那一层,什么额外的事都不用做。
现在把 改成 0.35 试试:
随着 变了。 点被推离了 那一层, 越大推得越远。
于是问题来了:一个 的三元组,对应二维平面上的哪个点?
齐次的含义:一整条射线代表同一个点
答案是:把它拉回 那一层,看它落在哪。 拉回的办法就是三个分量都除以 。
图:从原点出发穿过某个点的那条射线上,所有点代表的都是同一个二维坐标。、、 各自除以自己的第三个分量,都得到 。
验算一下最后那个:
这个"除以 "的动作叫透视除法(perspective divide)。"齐次"这个名字就是这么来的——同比例放大整个三元组,代表的还是同一个点。
除完之后,画面发生了什么
图:左边第三行是 , 恒为 1,除不除都一样,方格还是方格。右边第三行是 , 越大 越大,除完之后坐标就被压缩得越狠——方格网变成了梯形,原本平行的竖线不再平行。
平行线不再平行、远处的东西变小——这正是"透视"这个词在日常语境下的意思。所以 Skia 把第三行叫 kMPersp,不是打比方,是名副其实。
这就是问题 2 和问题 3 的答案。回头看 Persp_pts:
SkScalar z = sdot(sx, m.fMat[kMPersp0], sy, m.fMat[kMPersp1]) + m.fMat[kMPersp2];
if (z) { z = 1 / z; }
dst->fX = x * z;
dst->fY = y * z;那个 z 就是算出来的 ,x * z、y * z 就是除以 。而 if (z) 那句判断,防的是 ——从几何上说, 的点落在"过原点且平行于 平面"的方向上,那条射线永远碰不到 这一层,对应的二维点在无穷远处。
Skia 为什么要单独给透视写一个
Persp_pts?因为第三行是 时,那次除法是纯浪费——除数恒为 1。Skia 用
hasPerspective()提前分流,没有透视的矩阵走另一条不含除法的快速路径。这个优化说明了一件事:绝大多数 2D 合成场景根本不碰第三行。 你在 SurfaceFlinger 里天天看到的图层变换,第三行始终是 ——这也是"齐次坐标"这个概念可以在合成器代码里隐身多年的原因。它一直在那儿,只是从没被用满。
6. 升到 4×4:同一招再用一次
三维的情况完全对称:三维点 记成四维的 ,用 4×4 矩阵,第四列管平移,第四行管透视。
ui::Transform::asMatrix4() 做的就是把二维的 3×3 塞进 4×4,注释写得很清楚:
mat4 Transform::asMatrix4() const {
// Internally Transform uses a 3x3 matrix since the transform is meant for
// two-dimensional values. An equivalent 4x4 matrix means inserting an extra
// row and column which adds as an identity transform on the third
// dimension.
mat4 m = mat4{mat4::NO_INIT};
m[0][0] = mMatrix[0][0]; // 左上 2x2 原样搬过去
m[0][1] = mMatrix[0][1];
m[0][2] = 0.f;
m[0][3] = mMatrix[0][2]; // 原来的第三行 → 新的第四行
m[1][0] = mMatrix[1][0];
m[1][1] = mMatrix[1][1];
m[1][2] = 0.f;
m[1][3] = mMatrix[1][2];
m[2][0] = 0.f; // 新插进来的第三维:什么都不做
m[2][1] = 0.f;
m[2][2] = 1.f;
m[2][3] = 0.f;
m[3][0] = mMatrix[2][0]; // 原来的第三列(平移)→ 新的第四列
m[3][1] = mMatrix[2][1];
m[3][2] = 0.f;
m[3][3] = mMatrix[2][2];
return m;
}(mat4 和 mat33 一样是列主序,下标念作 m[列][行],所以 m[3] 是第四列,也就是平移列。)
读法:原来的"形状块"照搬,原来的"平移列"挪到第四列,原来的"透视行"挪到第四行,中间新插进来的那一行一列是单位变换——新的第三维完全不参与。这也符合直觉:一个纯二维的变换,不该对 做任何事。
第 4 篇开始,我们要用的就是完整的 4×4 了。
回到源码
问题 1:二维变换为什么要用 3×3?FLIP_H 里的 M[2][0] = w 又表示什么?
因为平移不是线性变换,2×2 矩阵挪不动原点。把平面抬到三维的 层,三维的切变落到这一层上就表现为二维的平移——于是平移也能被写成矩阵乘法,可以和其他变换合并成一个矩阵。代价就是多出的那一行一列。
所以 M[2][0] = w 就是把水平翻转后的图层沿 轴平移回原来的区间 ;它写入的正是 3×3 矩阵的平移列。
源码里的两个翻转也解释了 ROT_180 = FLIP_H | FLIP_V:水平、垂直翻转的线性部分相乘得到
这正是 180° 旋转。作用在宽 、高 的图层上,平移列再把结果移回原矩形,于是 。
问题 2:平面上哪来的透视?
第三行控制输出的第 4 个分量 。它是 时 恒为 1,什么也不会发生;一旦不是, 就随着输入位置变化,除完之后远处被压缩、平行线不再平行——这就是透视。SkMatrix 的 kMPersp0/1/2 是名副其实的命名。
问题 3:Persp_pts 里那个 z 是什么?为什么平时不用除?
z 就是算出来的 。除以它是把点拉回 那一层,即透视除法。平时不用除,是因为合成场景里第三行几乎永远是 , 恒为 1,除法没有意义——所以 Skia 用 hasPerspective() 分流到不含除法的快速路径。
问题 4:源码为什么是 R*(H*V)?
矩阵作用在列向量上时,靠近向量的先生效,所以源码的顺序是先垂直翻、再水平翻、最后旋转。写成 (H*V)*R 会让旋转提前发生,非方形图层在 90°/270° 情况下就会被映射到错误的位置。
源码开头的 std::swap(w, h) 也属于同一件事:旋转 90° 后宽高互换,后续翻转使用的尺寸必须先恢复到旋转前的逻辑尺寸。
最后,源码里的 det() 和 inverse() 片段只是第 2 篇行列式、逆矩阵公式的直接实现:行列式为 0 时不能求逆;纯旋转的逆可以用转置,带缩放时则不能偷这个捷径。
以后再看到一个 3×3(或 4×4)矩阵,先切三刀:左上角那块看形状,最后一列看平移,最后一行看有没有透视。三块各管各的,一眼就能读出这个矩阵大概要干什么。
自测
1. 齐次坐标 (6, 4, 2) 对应二维平面上的哪个点?
。
三个分量都除以 :,前两个分量就是答案。
顺带一提,、、 是同一个点的三种写法——这就是"齐次"。
2. 把平移矩阵作用在 (5, 3, 0) 上,结果是什么?这说明了什么?
结果还是 ,一点没变。
因为 ,平移列里的 、 都要乘以 才能加进来,乘 0 之后就没了。
这说明 的三元组表示方向而不是位置,而平移对方向本来就不该有影响。"往右 3 米"这个位移,在哪儿说都是"往右 3 米"。
3. 一个 SkMatrix 的第三行是 (0, 0, 2),其余部分是单位矩阵。它对图形做了什么?
把图形整体缩小到一半。
,恒为 2。所有点除以 2,于是 。
这也说明第三行的最后一个数(kMPersp2,Skia 叫 perspective bias)不是必须为 1——它是个全局缩放因子。恒为 1 只是最省事的归一化选择。
4. 变换之后某个点的 w' 算出来是 0,几何上意味着什么?代码里会怎样?
几何上:这个点跑到了无穷远处。 的方向平行于 那个平面,那条射线永远碰不到它,没有对应的有限二维坐标。
代码里:如果直接除就是除以零,得到 inf 或 NaN,之后一路污染。所以 Persp_pts 里写的是 if (z) { z = 1 / z; }—— 为 0 时干脆不取倒数,让结果退化成 而不是产生 NaN。
这和第 2 篇讲行列式为 0 不能求逆,是同一类防御。
5. 为什么 asMatrix4() 里 m[2][2] 被设成 1 而不是 0?
因为那是新插进来的第三维()自己对自己的系数,设成 1 表示" 原样保留",也就是对第三维做单位变换。
设成 0 的话,任何点的 都会被压成 0,整个三维空间被拍扁成一个平面——那可不是"把二维变换升维",那是把三维内容毁掉。注释里 "adds as an identity transform on the third dimension" 说的就是这件事。
6. 为什么 ROT_180 可以写成水平翻转和垂直翻转的组合?
两次翻转的线性部分是
这正是 180° 旋转;第三列的平移量负责把结果放回原来的矩形。
7. 为什么源码里的组合顺序是 R*(H*V),而不是 (H*V)*R?
列向量右侧的矩阵先作用,所以 R*(H*V) 的顺序是先垂直翻、再水平翻、最后旋转。交换顺序会让旋转提前发生,非方形图层在 90°/270° 情况下就可能落到错误位置。
小结
| 概念 | 一句话 | 在源码里 |
|---|---|---|
| 为什么加一维 | 2×2 挪不动原点,平移塞不进去 | ui::Transform 和 SkMatrix 都是 3×3 |
| 齐次坐标 | 把平面放到 层,三维切变 = 二维平移 | 第三列就是平移列 |
| 点 vs 方向 | 点的,方向的 | 平移自动对方向无效 |
| 点+点非法 | 算出,既不是点也不是方向 | 数字自己报错 |
| 三块分区 | 左上=形状,末列=平移,末行=透视 | SkMatrix 九个槽位的命名 |
| 透视除法 | 除以,把点拉回 那层 | Persp_pts 的 z = 1/z; x*z; y*z |
| 齐次的含义 | 一条过原点的射线代表同一个点 | 与 等价 |
| 无穷远点,不能除 | if (z) 那句判断 | |
| 合成器很少用第三行 | 2D 合成里它几乎恒为 | hasPerspective() 分流出快速路径 |
| 升到 4×4 | 同一招再用一次,末列平移、末行透视 | asMatrix4() |
延伸阅读
- 3Blue1Brown《线性代数的本质》第 9 集附录(三维中的线性变换):https://www.bilibili.com/video/BV1ys411472E
- GAMES101 Lecture 4:Transformation Cont.(齐次坐标部分):https://games-cn.org/intro-graphics/
- Scratchapixel: Geometry — Row Major vs Column Major Vector:https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/geometry/row-major-vs-column-major-vector.html
- AOSP / Skia 源码:
frameworks/native/libs/ui/Transform.cpp、external/skia/include/core/SkMatrix.h、external/skia/src/core/SkMatrix.cpp