俯拍书桌:暖光下的彩色文具、手写便笺与手绘画册

这是我「图形学基础」系列的第二篇。上一篇聊 3D 向量时留了个悬念:点和向量到底怎么从数学上彻底分开?本篇的主角 4×4 矩阵就来兑现这个承诺——顺便把图形学里所有的空间变换,统统收编成「一次矩阵乘法」。

4×4 矩阵是图形学里表达空间变换的通用语言:物体的平移、旋转、缩放,相机的投影,最终全部归结为若干个 4×4 矩阵的乘法。 本篇聚焦:为什么偏偏是 4×4、矩阵在内存里怎么存怎么读、变换如何组合,以及转置 / 行列式 / 逆 / 正交这些概念为什么值得在面试前多看一眼。


目录


一、为什么偏偏是 4×4:齐次坐标

1.1 平移:3×3 表达不了的加法

无论你多喜欢 3×3 矩阵,它都干不了一件事:平移。

3×3 矩阵乘向量只能表达线性变换——旋转、缩放、剪切、反射。线性变换有条铁律:原点必须待在原地不动。旋转像拧魔方,缩放像拉面团,剪切像推倒一摞扑克牌,原点始终是钉死的钉子。而平移是纯粹的加法:

p′=p+t\mathbf{p'} = \mathbf{p} + \mathbf{t}

它要把整个空间连根拔起搬走,连原点都不放过。加法写不成「矩阵乘向量」的形式——于是我们引入第 4 个分量 ww,把三维点扩展成四维齐次坐标 (Homogeneous Coordinates):

p=(x,y,z)  ⟶  p~=(xyz1)\mathbf{p} = (x, y, z) \;\longrightarrow\; \tilde{\mathbf{p}} = \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix}

1.2 引入 w 分量:把加法塞回乘法

解决方案简单又优雅:多一个分量,就多一列可以藏私货。平移量塞进矩阵的第 4 列:

(x′y′z′w′)=(m00m01m02txm10m11m12tym20m21m22tzm30m31m32m33)(xyz1)\begin{pmatrix} x' \\ y' \\ z' \\ w' \end{pmatrix} = \begin{pmatrix} m_{00} & m_{01} & m_{02} & t_x \\ m_{10} & m_{11} & m_{12} & t_y \\ m_{20} & m_{21} & m_{22} & t_z \\ m_{30} & m_{31} & m_{32} & m_{33} \end{pmatrix} \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix}

当左上角 3×33\times3 是单位阵时,结果就是 (x+tx,  y+ty,  z+tz,  1)(x+t_x,\; y+t_y,\; z+t_z,\; 1)——纯平移。加法就这样被「伪装」成了乘法。

1.3 w 的两种语义:点与方向

ww 不只是个补丁,它正好兑现了上一篇的承诺——从数学上区分点和方向:

ww 值含义例子
w=1w=1点 (Point):受平移影响顶点坐标
w=0w=0方向 (Direction):不受平移影响法向量、方向向量
w≠0,1w\neq 0,1透视投影后的点,需透视除法 ÷w÷w 还原投影矩阵输出

想想也合理:把整座城市向东平移一公里,「北」这个方向并不会因此变成「东北」——方向天生对平移免疫,所以它的 w=0w=0;而房子的位置确实挪了,所以点是 w=1w=1。

面试关键点:用 w=0/1w=0/1 区分「方向」和「点」,是齐次坐标的核心技巧。对应到代码就是两个接口:transformDirection() 按 w=0w=0(忽略平移列),transformPoint() 按 w=1w=1(含平移),见 3.4 节。

4×4 的真正好处在于:平移、旋转、缩放、投影全部统一为一种运算——矩阵乘法,而且可以任意串联合并成一个矩阵。一百万个顶点各自做十步变换,不如先把十步合成一步,再让每个顶点只乘一次。


二、矩阵怎么读:行、列与内存布局

本节是面试最高频的混淆点,没有之一。先记住总纲:「列向量 / 行向量」是数学约定,「列主序 / 行主序」是内存布局——两组概念互相独立,却总被搅在一起讨论。

2.1 每一列就是一个基向量

在列向量约定下,矩阵 MM 的第 jj 列就是「原始基向量 ej\mathbf{e}_j 变换后的落点」:

M=(∣∣∣∣c0c1c2c3∣∣∣∣)M = \begin{pmatrix} \big| & \big| & \big| & \big| \\ \mathbf{c}_0 & \mathbf{c}_1 & \mathbf{c}_2 & \mathbf{c}_3 \\ \big| & \big| & \big| & \big| \end{pmatrix}

可以把它想象成给坐标系拍「变身后的合影」:x、y、z 三根轴原本分别朝向 (1,0,0)(1,0,0)、(0,1,0)(0,1,0)、(0,0,1)(0,0,1),变换后各自落到哪里,就把落点按列写进矩阵。

例如纯旋转矩阵的左上 3×33\times3,三列就是变换后的 X/Y/Z 三个坐标轴方向。所以想读「物体朝向」,取矩阵前三列即可——一个运算都不用做。

2.2 列向量 vs 行向量:一对数学约定

这是约定 (Convention),不是存储格式。它决定的是「矩阵乘向量」怎么写:

列向量约定 (本文 / OpenGL / GLM):     行向量约定 (DirectX 默认 / 部分教材):
   [ m00 m01 m02 m03 ]   [x]                 [ x y z 1 ] [ m00 m10 m20 m30 ]
   [ m10 m11 m12 m13 ] × [y]                            [ m01 m11 m21 m31 ]
   [ m20 m21 m22 m23 ]   [z]                            [ m02 m12 m22 m32 ]
   [ m30 m31 m32 m33 ]   [1]                            [ m03 m13 m23 m33 ]
        M  ×   v                                            vᵀ ×  Mᵀ
   写法:M * v                                        写法:v * M
   组合:从右到左执行                                  组合:从左到右执行
对比项列向量约定(本文)行向量约定
变换写法MvM\mathbf{v}vTM\mathbf{v}^T M
组合顺序TRSvT R S \mathbf{v},先 S 后 R 后 TvTSRT\mathbf{v}^T S R T,先 S 后 R 后 T
平移位置矩阵第 4 列(上三行)矩阵第 4 行(前三列)
互转关系列约定的 MM = 行约定的 MTM^T—

⚠️ 同一个数学变换,两种约定下的矩阵互为转置。 跨引擎搬代码(如 GLM → DXMath)时这是第一大坑:公式抄对了,矩阵却是「镜像」的。读任何资料前,先看它用哪种约定。

本文与 OpenGL / GLM / PBRT 一致,统一采用列向量约定,所有公式按 MvM\mathbf{v} 推导。

2.3 列主序 vs 行主序:一对内存布局

矩阵在内存里终究是 16 个连续的数。同一列的元素挨在一起存,就叫列主序 (Column-Major);同一行挨在一起,就叫行主序 (Row-Major)。这是纯工程概念。

列主序下,数组 m[16] 的布局长这样:

       col0   col1   col2   col3      ← 数学列
      ┌─────┬─────┬─────┬─────┐
row0  │ m[0]│ m[4]│ m[8]│m[12]│      ← 数学第 0 行
row1  │ m[1]│ m[5]│ m[9]│m[13]│      ← 数学第 1 行
row2  │ m[2]│ m[6]│m[10]│m[14]│      ← 数学第 2 行
row3  │ m[3]│ m[7]│m[11]│m[15]│      ← 数学第 3 行
      └─────┴─────┴─────┴─────┘
       连续   连续   连续   连续

即:

  • m[0..3] = 第 0 列 (m00, m10, m20, m30)
  • m[4..7] = 第 1 列
  • m[8..11] = 第 2 列
  • m[12..15]= 第 3 列

下标公式:

m[ col×4+row ]=M[row][col]m[\,\text{col}\times 4 + \text{row}\,] = M[\text{row}][\text{col}]

行主序(C/C++ 原生二维数组、DirectX Math)则反过来:同一行连续,下标是 m[row*4+col]。

2.4 两组概念,互相独立(重要澄清)

列主序行主序
是什么内存存储布局内存存储布局
决定数组下标怎么算数组下标怎么算
列向量约定行向量约定
是什么数学约定数学约定
决定矩阵×向量的写法、平移放第几列/行同左

这两组是正交的! 理论上可以任意组合(列主序+列向量、行主序+行向量、甚至行主序+列向量)。OpenGL/GLM 用「列主序 + 列向量」,DirectX Math 默认「行主序 + 行向量」。巧合的是,当存储约定与向量约定「配套」时(列主序配列向量、行主序配行向量),同一份 16 个浮点数的二进制内容表示的数学矩阵恰好相同——所以 GLM 和 DXMath 的常量数组常常长得一模一样,只是解释方式不同。这正是新手最容易绕晕的地方。

本文统一采用 「列主序 + 列向量」,与 OpenGL / GLM / PBRT 完全一致。

2.5 代码表示:Matrix4

一个典型的矩阵类型用模板结构体实现,和上一篇的 Vec3 是一个套路:

template <typename T>
struct Matrix4 {
    // 列主序存储:m[0..3] 是数学第 0 列,m[4..7] 第 1 列,m[8..11] 第 2 列,m[12..15] 第 3 列
    T m[16];

    // 数学记法 M[row][col] 的读写入口;注意参数顺序是 (col, row)!
    T&       operator()(int col, int row)       { return m[col * 4 + row]; }
    const T& operator()(int col, int row) const { return m[col * 4 + row]; }

    static Matrix4 identity();            // 单位矩阵

    Matrix4 transposed() const;           // 转置
    T       determinant() const;          // 行列式
    Matrix4 inverted() const;             // 逆矩阵(奇异时返回单位矩阵兜底)
    bool    isOrthogonal(T eps) const;    // 是否正交矩阵

    Vec3<T> transformPoint(const Vec3<T>& p) const;      // 按 w=1 变换点(含平移)
    Vec3<T> transformDirection(const Vec3<T>& d) const;  // 按 w=0 变换方向(不含平移)
};

using Matrix4f = Matrix4<float>;
using Matrix4d = Matrix4<double>;

要点:

  • 16 个数存成一维数组 m[16],列主序;
  • operator()(col, row) 负责把「数学下标」翻译成「内存下标」——读代码时切勿按惯性当成 (row, col)!
  • 工厂函数 translation() / scaling() / rotationX/Y/Z() 用来造基本变换(第三节);
  • transformPoint / transformDirection 对应 w=1w=1 / w=0w=0 两种语义(见 3.4)。
using namespace gm;

Matrix4f I = Matrix4f::identity();               // 单位矩阵:乘谁都等于没乘
Matrix4f T = translation(Vec3f(1.0f, 2.0f, 3.0f));
Vec3f p = T.transformPoint(Vec3f(1, 1, 1));      // (2, 3, 4)

三、三大基本变换:平移、缩放、旋转

3.1 平移矩阵 translation()

T(t)=(100tx010ty001tz0001)T(\mathbf{t}) = \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix}

平移量住在第 4 列的上三行(列向量约定),列主序下恰好是 m[12]、m[13]、m[14]。所以调试时想知道「这个矩阵把物体挪到哪了」,直接看这三个数就行。典型实现:

template <typename T>
Matrix4<T> translation(const Vec3<T>& t) {
    Matrix4<T> r = Matrix4<T>::identity();
    r(3, 0) = t.x;   // M[0][3] = tx
    r(3, 1) = t.y;   // M[1][3] = ty
    r(3, 2) = t.z;   // M[2][3] = tz
    return r;
}

注意这里 r(3, 0) 的含义:第 3 列第 0 行——参数是 (col, row),别看反了。

3.2 缩放矩阵 scaling()

S(s)=(sx0000sy0000sz00001)S(\mathbf{s}) = \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

一个对角矩阵,缩放因子躺在对角线上,没什么花头:

template <typename T>
Matrix4<T> scaling(const Vec3<T>& s) {
    Matrix4<T> r = Matrix4<T>::identity();
    r(0, 0) = s.x;   // M[0][0]
    r(1, 1) = s.y;   // M[1][1]
    r(2, 2) = s.z;   // M[2][2]
    return r;
}
using namespace gm;
Matrix4f S = scaling(Vec3f(2.0f, 3.0f, 4.0f));
Vec3f ps  = S.transformPoint(Vec3f(1, 1, 1));   // (2, 3, 4)
// 缩放矩阵的行列式 det = sx*sy*sz = 24(第五节会用到)

3.3 旋转矩阵 rotationX / rotationY / rotationZ

右手坐标系,角度为弧度,逆时针为正(从轴的正方向看向原点)。

绕 X 轴(Y→Z 平面内旋转):

Rx(θ)=(10000cos⁡θ−sin⁡θ00sin⁡θcos⁡θ00001)R_x(\theta) = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta & 0 \\ 0 & \sin\theta & \cos\theta & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

绕 Y 轴(Z→X 平面内旋转):

Ry(θ)=(cos⁡θ0sin⁡θ00100−sin⁡θ0cos⁡θ00001)R_y(\theta) = \begin{pmatrix} \cos\theta & 0 & \sin\theta & 0 \\ 0 & 1 & 0 & 0 \\ -\sin\theta & 0 & \cos\theta & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

绕 Z 轴(X→Y 平面内旋转):

Rz(θ)=(cos⁡θ−sin⁡θ00sin⁡θcos⁡θ0000100001)R_z(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

记忆窍门:绕哪个轴旋转,该轴对角元素就是 1;剩下两轴构成 2×22\times2 的 (cos⁡−sin⁡sin⁡cos⁡)\begin{pmatrix}\cos&-\sin\\\sin&\cos\end{pmatrix}。注意 RyR_y 中 sin⁡\sin 的符号位置与 Rx/RzR_x/R_z 相反(因为叉积循环 X×Y=Z、Y×Z=X、Z×X=Y 的方向)。

以 rotationZ 为例的典型实现:

template <typename T>
Matrix4<T> rotationZ(T radians) {
    Matrix4<T> r = Matrix4<T>::identity();
    const T c = std::cos(radians);
    const T s = std::sin(radians);
    r(0, 0) =  c;  r(1, 0) = -s;   // M[0][0]=c,  M[0][1]=-s
    r(0, 1) =  s;  r(1, 1) =  c;   // M[1][0]=s,  M[1][1]=c
    return r;
}
using namespace gm;
Matrix4f Rz = rotationZ(static_cast<float>(M_PI) / 2.0f);  // 绕 Z 转 90°
Vec3f rx   = Rz.transformDirection(Vec3f(1, 0, 0));        // (0, 1, 0)  X 轴→Y 轴

3.4 点与方向的分野:transformPoint / transformDirection

第一节说 w=1w=1 是点、w=0w=0 是方向,落到代码里就是下面这对函数——它们的差别只有一行:

// w = 1:第 4 列(平移)参与运算 —— 变换「点」
template <typename T>
Vec3<T> Matrix4<T>::transformPoint(const Vec3<T>& p) const {
    return Vec3<T>(
        m[0] * p.x + m[4] * p.y + m[8]  * p.z + m[12],
        m[1] * p.x + m[5] * p.y + m[9]  * p.z + m[13],
        m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14]);
}

// w = 0:无视第 4 列,只留左上 3×3 的线性部分 —— 变换「方向」
template <typename T>
Vec3<T> Matrix4<T>::transformDirection(const Vec3<T>& d) const {
    return Vec3<T>(
        m[0] * d.x + m[4] * d.y + m[8]  * d.z,
        m[1] * d.x + m[5] * d.y + m[9]  * d.z,
        m[2] * d.x + m[6] * d.y + m[10] * d.z);
}

尾部那三个 + m[12/13/14],就是 w=1w=1 与 w=0w=0 在代码里的全部区别。

using namespace gm;
Matrix4f T = translation(Vec3f(1, 2, 3));
Vec3f p(1, 1, 1);
Vec3f pt = T.transformPoint(p);      // (2, 3, 4)  点:跟着走
Vec3f pd = T.transformDirection(p);  // (1, 1, 1)  方向:平移免疫

易错点:把顶点位置错喂给 transformDirection(或反过来),模型会「散架」或「飘走」。变换法线、光线方向用 direction;变换顶点、位置用 point。


四、矩阵乘法:一条从右往左的流水线

4.1 乘法规则:行点列

两个 4×4 矩阵相乘 C=ABC = AB,每个元素都是「A 的行」点「B 的列」:

Cij=∑k=03Aik BkjC_{ij} = \sum_{k=0}^{3} A_{ik}\, B_{kj}

列主序下的典型实现就是教科书式的三重循环:

Matrix4 operator*(const Matrix4& o) const {
    Matrix4 r;
    for (int col = 0; col < 4; ++col)
        for (int row = 0; row < 4; ++row) {
            T sum = 0;
            for (int k = 0; k < 4; ++k)
                sum += (*this)(k, row) * o(col, k);   // C[row][col] = Σ A[row][k]·B[k][col]
            r(col, row) = sum;
        }
    return r;
}

一个矩阵乘法 = 64 次乘加。CPU 看着嫌多,GPU 每帧做几百万次,眼都不眨。

4.2 不可交换:先穿袜子还是先穿鞋

性质公式说明
结合律(AB)C=A(BC)(AB)C = A(BC)✅ 可任意加括号
不满足交换律AB≠BAAB \neq BA❌ 顺序至关重要
单位矩阵AI=IA=AAI = IA = A—

顺序为什么重要?生活里也一样:先穿袜子再穿鞋没问题,先穿鞋再穿袜子就很离谱——每一步都「正确」,但顺序一换,结果全变。数值例子(绕 Z 转 90° vs 平移 (1,0,0)):

  • Rz(90°) T(1,0,0) exR_{z}(90°)\,T(1,0,0)\,\mathbf{e}_x:先把 ex\mathbf{e}_x 平移到 (1,0,0)(1,0,0),再绕 Z 转 90° → (0,1,0)(0,1,0)
  • T(1,0,0) Rz(90°) exT(1,0,0)\,R_{z}(90°)\,\mathbf{e}_x:先把 ex\mathbf{e}_x 转到 (0,1,0)(0,1,0),再平移 → (1,1,0)(1,1,0)

同样的两个矩阵,乘的顺序一换,结果完全不同。

4.3 TRS 合成:从右往左读

列向量约定下写成 MvM\mathbf{v},最右边的矩阵先作用于向量,从右到左依次执行:

T⏟第3步:平移  R⏟第2步:旋转  S⏟第1步:缩放  v\underbrace{T}_{\text{第3步:平移}}\;\underbrace{R}_{\text{第2步:旋转}}\;\underbrace{S}_{\text{第1步:缩放}}\;\mathbf{v}
   v ──S──> 缩放后的 v ──R──> 旋转后 ──T──> 平移后 = 最终位置
                                                         ▲
                                            写成代码:TRS * v
                                       (写在最左的 T 反而最后执行)

像函数嵌套 f(g(h(x)))f(g(h(x))):最贴近 xx 的 hh 先执行。典型用法:先缩放物体到目标尺寸,再旋转朝向,最后平移到世界坐标——三步合成一个矩阵:

using namespace gm;
// 列向量约定:T*R*S*v 表示先缩放、再旋转、最后平移
Matrix4f TRS = translation(Vec3f(10, 0, 0))
             * rotationZ(static_cast<float>(M_PI) / 2.0f)
             * scaling(Vec3f(1, 1, 1));
// 取点 (1,0,0):绕Z转90° → (0,1,0),再平移(10,0,0) → (10,1,0)
Vec3f combo = TRS.transformPoint(Vec3f(1, 0, 0));   // (10, 1, 0)

⚠️ 行向量约定正好相反:vTSRT\mathbf{v}^T S R T,代码写成 v * S * R * T,从左到右执行。两边「执行顺序」一致,但矩阵书写顺序相反。面试被问「矩阵相乘顺序」,先声明你用哪种约定,再答执行顺序,稳。

口诀:「列向量约定,从右往左读——离向量最近的先动手。」


五、转置、行列式、逆矩阵

5.1 转置 transposed():行列互换

转置就是把矩阵沿对角线「翻面」:MTM^T 的第 ii 行第 jj 列 = MM 的第 jj 行第 ii 列。

(MT)ij=Mji\big(M^T\big)_{ij} = M_{ji}
Matrix4 transposed() const {
    Matrix4 r;
    for (int col = 0; col < 4; ++col)
        for (int row = 0; row < 4; ++row)
            r(col, row) = (*this)(row, col);   // 行列对调
    return r;
}

两条重要性质:

(AB)T=BTAT(顺序反转!)(AB)^T = B^T A^T \qquad \text{(顺序反转!)} (MT)T=M(对合律:翻两次回到原样)(M^T)^T = M \qquad \text{(对合律:翻两次回到原样)}

用途集中在三处:

  • 正交矩阵求逆:若 QQ 正交,则 Q−1=QTQ^{-1} = Q^T(第六节的主角);
  • 列/行向量约定互转:两种约定下的同一变换互为转置;
  • 法线变换:变换法线要用模型矩阵左上 3×33\times3 的逆转置(法线变换专题再展开)。

5.2 行列式 determinant():体积的缩放因子

计算上,4×4 行列式按第 0 行拉普拉斯展开,降成四个 3×3 子行列式(3×3 再用萨鲁斯法则):

det⁡(M)=∑j=03(−1)j M0j det⁡(M(0,j))\det(M) = \sum_{j=0}^{3} (-1)^{j}\, M_{0j}\, \det(M^{(0,j)})
T determinant() const {
    // 按第 0 行展开:det = Σ (-1)^j · M[0][j] · 去掉第 0 行第 j 列的 3×3 余子式
    T det = 0;
    for (int j = 0; j < 4; ++j)
        det += ((j % 2 == 0) ? T(1) : T(-1)) * (*this)(j, 0) * minor3(j, 0);
    return det;
}

几何意义比公式重要得多:想象一块单位体积的橡皮泥塞进变换机器,∣det⁡(M)∣|\det(M)| 就是出来时的体积。

  • det⁡>0\det > 0:保持手性(纯旋转/缩放,没翻面);
  • det⁡<0\det < 0:含镜像反射——像把一张印章盖反过来,「翻面了」;
  • det⁡=0\det = 0:空间被压扁(降维成一张纸甚至一条线),信息丢了,不可逆——一张纸压不出原来的立体橡皮泥。
矩阵det⁡\det含义
单位阵 II11体积不变
缩放 S(sx,sy,sz)S(s_x,s_y,s_z)sxsyszs_x s_y s_z各轴缩放因子之积
旋转 RR11保体积、保手性
反射−1-1翻面,体积不变
using namespace gm;
Matrix4f::identity().determinant();          // 1
scaling(Vec3f(2, 3, 4)).determinant();       // 24 = 2*3*4
rotationZ(0.5f).determinant();               // 1(旋转保体积)

5.3 逆矩阵 inverted():撤销一个变换

如果矩阵 MM 是「做一件事」,逆矩阵 M−1M^{-1} 就是「撤销这件事」:MM−1=M−1M=IMM^{-1} = M^{-1}M = I。可逆的充要条件是 det⁡(M)≠0\det(M) \neq 0——被压扁的空间回不去。

通用求法是伴随矩阵法 (Adjugate / Cofactor):

M−1=1det⁡(M) adj(M),(M−1)ij=(−1)i+j Cjidet⁡(M)M^{-1} = \frac{1}{\det(M)}\,\text{adj}(M), \qquad \big(M^{-1}\big)_{ij} = \frac{(-1)^{i+j}\, C_{ji}}{\det(M)}
Matrix4 inverted() const {
    const T det = determinant();
    if (std::fabs(det) <= std::numeric_limits<T>::epsilon())
        return identity();          // 奇异矩阵兜底:压扁的橡皮泥回不去,退回单位矩阵

    Matrix4 r;
    const T invDet = T(1) / det;
    for (int col = 0; col < 4; ++col)
        for (int row = 0; row < 4; ++row)
            r(col, row) = cofactorSign(row, col) * minor3(row, col) * invDet;
    // 注意余子式按下标「转置」放回 —— 这正是伴随矩阵的定义
    return r;
}

实话实说:通用 4×4 伴随矩阵法要算 16 个 3×3 行列式,计算量不小。图形学里更常用高斯消元,以及对仿射变换、正交矩阵的特化求逆——后者是第六节的内容,也是面试最爱。

验证:逆矩阵乘回原矩阵应得到单位阵。

using namespace gm;
Matrix4f A    = translation(Vec3f(1, 2, 3));
Matrix4f Ainv = A.inverted();
Matrix4f prod = A * Ainv;           // == I(单位矩阵)

六、正交矩阵:旋转矩阵的免费午餐

正交矩阵大概是图形学里「性价比」最高的概念:旋转矩阵都是正交矩阵,而正交矩阵的逆就是转置——上百次乘法的求逆,瞬间变成 16 个元素挪个位置。

6.1 定义:Q 的转置就是它的逆

若方阵 QQ 满足

QTQ=QQT=IQ^T Q = Q Q^T = I

则称 QQ 为正交矩阵,且

Q−1=QTQ^{-1} = Q^T
bool isOrthogonal(T eps = T(1e-5)) const {
    Matrix4 prod = (*this) * transposed();   // Q · Qᵀ
    return prod == identity();               // 实际比较按 eps 容差
}

6.2 等价刻画:四种说法一回事

以下四条任选其一即可判定正交:

  1. QTQ=IQ^T Q = I(列向量组两两正交且长度为 1)
  2. QQT=IQ Q^T = I(行向量组两两正交且长度为 1)
  3. Q−1=QTQ^{-1} = Q^T
  4. 行(列)向量组构成标准正交基 (Orthonormal Basis)

第 4 条最有画面感:矩阵的每一列都是单位向量、且任意两列互相垂直——这个矩阵里住着一组标准正交基。

6.3 几何意义:保长、保角、保体积

正交变换不改变向量的长度、两向量的夹角、空间的体积:

∥Qu∥=∥u∥,(Qu)⋅(Qv)=u⋅v\|Q\mathbf{u}\| = \|\mathbf{u}\|, \qquad (Q\mathbf{u})\cdot(Q\mathbf{v}) = \mathbf{u}\cdot\mathbf{v}

几何上,正交变换只可能是刚体旋转或反射(含镜像)——没有缩放、没有剪切,像真正的刚体,只转不改大小。

6.4 从行列式看:旋转还是反射

由 det⁡(QTQ)=det⁡(I)\det(Q^T Q) = \det(I) 得 det⁡(Q)2=1\det(Q)^2 = 1,故:

det⁡(Q)=±1\det(Q) = \pm 1
det⁡\det含义
+1+1纯旋转 (Proper Rotation),保持手性
−1-1含反射 (Reflection),翻转手性

6.5 旋转矩阵求逆 = 转置

Rx,Ry,RzR_x, R_y, R_z 及其任意组合都是正交矩阵(det⁡=1\det=1),因此:

R−1=RT(转置即可,无需通用求逆)R^{-1} = R^T \quad \text{(转置即可,无需通用求逆)}

转置只是 O(16)O(16) 个元素换位置,而通用 4×4 求逆要上百次乘法。动画、骨骼蒙皮、相机视图矩阵这些每帧大量求逆的场景,这个优化是硬通货。

using namespace gm;
Matrix4f Rz = rotationZ(static_cast<float>(M_PI) / 2.0f);
Rz.isOrthogonal();                          // true  —— 旋转矩阵是正交矩阵
translation(Vec3f(1, 2, 3)).isOrthogonal(); // false —— 平移矩阵不是正交矩阵

注意:纯平移矩阵不是正交矩阵(它的第 4 列 (tx,ty,tz,1)T(t_x,t_y,t_z,1)^T 不是单位向量)。但「旋转 + 平移」组成的刚体变换仍有廉价求逆:旋转部分转置、平移部分取反,再重新组装——完整 4×4 仿射求逆可分解为「左上 3×33\times3 求逆 + 平移修正」。

6.6 速查:常见变换谁是正交的

变换正交?det⁡\det求逆方式
旋转 RR✅+1+1RTR^T
反射✅−1-1MTM^T
纯平移 TT❌11平移量取反
非均匀缩放 SS❌sxsyszs_xs_ys_z对角元素取倒数
旋转 + 平移(刚体)❌(4×4)11旋转转置 + 平移修正
投影矩阵❌—通常不求逆,或用专门公式

七、特征值与特征向量

7.1 定义:方向不变的方向

对方阵 AA,若存在标量 λ\lambda 和非零向量 v\mathbf{v} 满足

Av=λvA\mathbf{v} = \lambda \mathbf{v}

则称 λ\lambda 为 AA 的特征值 (Eigenvalue),v\mathbf{v} 为对应的特征向量 (Eigenvector)。

7.2 几何意义:倔强的方向与拉伸倍数

大多数向量经过变换后会「拐弯」,但总有几个方向特别倔强——变换前后方向不变(或恰好反向),只是被拉长或压短。这些倔强的方向就是特征向量,拉长压短的倍数就是特征值:

  • λ>1\lambda > 1:沿该方向拉伸;
  • 0<λ<10 < \lambda < 1:沿该方向压缩;
  • λ<0\lambda < 0:反向缩放(含反射成分);
  • λ=0\lambda = 0:该方向被压扁到零(矩阵奇异)。

7.3 旋转矩阵的特征值:旋转轴的秘密

3D 旋转矩阵的特征值很有特点:必有一个 λ=1\lambda = 1——对应的特征向量正是旋转轴(旋转中岿然不动的方向);另两个是一对共轭复数 e±iθe^{\pm i\theta}(模为 1,对应旋转平面)。这也给出了「从旋转矩阵反提取旋转轴 / 旋转角」的思路。

7.4 数值解法:幂迭代

精确求一般 4×4 矩阵的特征值,需求解 4 次特征多项式 det⁡(A−λI)=0\det(A - \lambda I) = 0,公式繁琐到没人手写。实用做法是数值迭代——幂迭代法 (Power Iteration) 求主特征值(绝对值最大的那个):

原理:对任意非零向量 b\mathbf{b} 反复乘 AA,b\mathbf{b} 会逐渐「对齐」到主特征向量方向——就像反复对折一张纸,不管初始多歪,最终都会对齐折痕。对齐后的缩放比即为主特征值。

b₀ (任意非零)
 ↓  归一化
v₀ = normalize(b₀)
 ↓  反复迭代
v_{k+1} = normalize(A v_k)
λ_{k+1} ≈ v_k^T A v_k   (Rayleigh 商近似)
 ↓  收敛
λ → 主特征值
Matrix4f M = /* 某矩阵 */;
float lambda  = M.maxEigenvalue();             // 默认迭代 50 次
float lambda2 = M.maxEigenvalue(200, 1e-8f);   // 自定义迭代次数与收敛阈值

实现要点:

  • 只取左上 3×33\times3 线性部分(忽略平移列),聚焦线性变换本身;
  • 用 Rayleigh 商 λ≈vTAv\lambda \approx \mathbf{v}^T A \mathbf{v} 估计特征值;
  • 收敛条件:相邻两次估计差值 <ε< \varepsilon;
  • 限制:要求矩阵有占优主特征值(∣λ1∣>∣λ2∣|\lambda_1| > |\lambda_2|),否则可能不收敛或收敛很慢。

补充:对称矩阵的特征值全是实数、特征向量相互正交,可用雅可比 (Jacobi) 对角化精确求解。图形学里的惯性张量、协方差矩阵(PCA)常用此法;幂迭代则是不要求对称性的快速估计。


八、坐标空间变换链:MVP

一个顶点从「出生」(建模软件里定下的局部坐标)到「上屏」(变成屏幕上的像素),要闯过一道道关卡,而每一道关卡都是一次矩阵乘法。

8.1 一个顶点的旅程

  局部坐标          世界坐标            相机/观察坐标           裁剪坐标
 (Local/Object) ──M──> (World) ──V──> (View/Camera) ──P──> (Clip)
                                                                        │
                                                                  透视除法 ÷w
                                                                        ▼
                                                                   NDC 坐标
                                                                        │
                                                                  视口变换
                                                                        ▼
                                                                   屏幕坐标

合成矩阵(列向量约定,从右到左):

pclip=P⏟投影  V⏟视图  M⏟模型  plocal=MVP  plocal\mathbf{p}_{\text{clip}} = \underbrace{P}_{\text{投影}}\;\underbrace{V}_{\text{视图}}\;\underbrace{M}_{\text{模型}}\;\mathbf{p}_{\text{local}} = \text{MVP}\;\mathbf{p}_{\text{local}}
矩阵作用典型构造
Model (M)局部 → 世界:物体的平移/旋转/缩放translation()*rotationY()*scaling()
View (V)世界 → 相机:把相机移到原点、看向 -Z相机逆变换(V=(相机世界矩阵)−1V = (\text{相机世界矩阵})^{-1})
Projection (P)相机 → 裁剪:定义视锥、透视/正交透视:fov/aspect/near/far;正交:左右上下前后

8.2 透视投影 vs 正交投影

  • 透视投影 (Perspective):近大远小,ww 分量不再恒为 1(含 zz),需透视除法。平行线会汇聚到灭点——像站在铁路上望远处,两条铁轨在天边交汇。模拟人眼,主流 3D 游戏都用它。
  • 正交投影 (Orthographic):无近大远小,ww 恒为 1,平行线仍平行——像工程制图,尺寸就是一切。常用于 CAD、2D 游戏、阴影贴图。

8.3 View 矩阵:lookAt 的思路

视图矩阵的本质是「把相机请回原点、朝向 −Z-Z」的刚体变换,等于相机自身世界变换矩阵的逆。与其让相机走向世界,不如让整个世界反向搬运过来——两种说法数学等价:

V=(Tcam Rcam)−1=RcamT  T(−tcam)V = \big(T_{\text{cam}}\, R_{\text{cam}}\big)^{-1} = R_{\text{cam}}^T\; T(-\mathbf{t}_{\text{cam}})

由于相机的旋转部分是正交的,求逆直接用转置(第 6 节的免费午餐在这里兑现)。

8.4 拼装一个完整的 MVP

using namespace gm;

// Model:把物体搬到世界 (1,2,3),绕 Y 转 45°,整体放大 2 倍
Matrix4f M = translation(Vec3f(1, 2, 3))
           * rotationY(static_cast<float>(M_PI) / 4.0f)
           * scaling(Vec3f(2, 2, 2));

// View:相机世界变换的逆(旋转部分正交,可用转置加速求逆)
Matrix4f V = cameraWorld.inverted();

// Projection:透视投影(fov / aspect / near / far),推导留给后续相机篇
Matrix4f P = perspective(fovY, aspect, near, far);

// 合成 MVP:列向量约定下 P·V·M,M 离向量最近、最先执行
Matrix4f MVP = P * V * M;
Vec3f worldP = M.transformPoint(localP);   // 也可以分步:局部 → 世界

记忆:MVP 写法 P V M vP\,V\,M\,\mathbf{v} 中,最靠近向量的 MM 先执行——与第四节「从右到左」完全一致。三个矩阵预先合体成一个,顶点着色器里每个顶点只乘一次,这正是「合成」的意义。


九、核心概念速查

齐次坐标 / 4×4

  • 平移是加法,3×3 表达不了;引入 ww(点 w=1w=1、方向 w=0w=0)统一为矩阵乘法。
  • transformPoint() 含平移(w=1w=1),transformDirection() 不含平移(w=0w=0)。

存储 vs 约定(最易混淆)

  • 列主序/行主序是内存布局;列向量/行向量是数学约定,两者正交。
  • 本文:列主序 + 列向量(同 OpenGL/GLM)。下标 m[col*4+row],operator()(col,row) 返回 M[row][col]M[\text{row}][\text{col}]。
  • 平移在第 4 列(列向量约定);若在第 4 行则是行向量约定。

变换顺序

  • 列向量 MvM\mathbf{v}:TRSvT R S \mathbf{v} 从右到左,先 S 后 R 后 T。
  • 行向量 vTM\mathbf{v}^T M:vTSRT\mathbf{v}^T S R T 从左到右,执行顺序相同,矩阵书写顺序相反。
  • 矩阵乘法不可交换 AB≠BAAB\neq BA,但满足结合律。

行列式

  • ∣det⁡∣|\det| = 体积缩放因子;det⁡=0\det=0 奇异不可逆(降维);det⁡<0\det<0 含反射。
  • 旋转 det⁡=1\det=1,缩放 det⁡=sxsysz\det=s_xs_ys_z。

逆矩阵

  • 通用:伴随矩阵法 M−1=adj(M)/det⁡(M)M^{-1}=\text{adj}(M)/\det(M);实用中更多用高斯消元或特化求逆。
  • 正交矩阵:Q−1=QTQ^{-1}=Q^T(转置即可,旋转矩阵高频优化)。
  • det⁡=0\det=0 不可逆。

正交矩阵(重点)

  • QTQ=I⇔Q−1=QTQ^TQ=I \Leftrightarrow Q^{-1}=Q^T,行/列构成标准正交基。
  • 保长、保角、保体积;det⁡=±1\det=\pm1(+1 旋转,−1 含反射)。
  • 旋转矩阵是正交矩阵 → 求逆只需转置。平移/缩放/投影不是正交矩阵。

特征值

  • Av=λvA\mathbf{v}=\lambda\mathbf{v}:特征向量方向不变,λ\lambda 是缩放因子。
  • 旋转矩阵 λ=1\lambda=1 对应旋转轴。
  • maxEigenvalue() 用幂迭代法求主特征值(数值近似);精确解需解 4 次多项式,不实用。
  • 对称矩阵特征值全实数、特征向量正交,可用雅可比对角化。

MVP

  • pclip=P V M plocal\mathbf{p}_{\text{clip}} = P\,V\,M\,\mathbf{p}_{\text{local}},MM 先执行(从右到左)。
  • Model:局部→世界;View:世界→相机(=相机世界矩阵的逆);Projection:相机→裁剪(透视/正交)。

必记口诀

  • 点 w=1w=1,方向 w=0w=0:点会跟着平移走,方向对平移免疫。
  • 每一列是变身后的基向量:读朝向,取前三列。
  • 列向量约定,从右往左读:TRS vTRS\,\mathbf{v},先缩放、再旋转、最后平移。
  • det⁡\det 是体积:正 = 保手性,负 = 翻面,零 = 压扁不可逆。
  • 旋转的逆 = 转置:正交矩阵的免费午餐。
  • MVP 离向量最近的先执行:P V M vP\,V\,M\,\mathbf{v}。

评论

由 Giscus 驱动,需使用 GitHub 账号登录后评论。