Skip to main content

第 2 周:矩阵和坐标变换

📅 学习计划

理解矩阵的作用、矩阵乘法顺序以及在 Three.js 中的应用。


第 1 天:理解矩阵

📖 理论:矩阵是什么?

在 3D 图形学中,4x4 矩阵是描述变换(平移、旋转、缩放)的神器。

4x4 矩阵结构:

[ m00 m01 m02 m03 ]  <- 第一行
[ m10 m11 m12 m13 ] <- 第二行
[ m20 m21 m22 m23 ] <- 第三行
[ m30 m31 m32 m33 ] <- 第四行

🧩 拆解 4x4 矩阵的几何含义

把这个 4x4 矩阵拆成四个“功能区”,你就秒懂了:

1. 左上角 3x3 区域:线性变换(旋转、缩放、切变)

这是矩阵中最难理解但也最核心的部分。最直观的理解方式是:这三列分别代表了变换后,“新的 X、Y、Z 轴”指向了哪里。

  新X轴   新Y轴   新Z轴
[ m00 m01 m02 ]
[ m10 m11 m12 ]
[ m20 m21 m22 ]
  • 第一列 (m00, m10, m20):原本指向 (1, 0, 0)X 轴,变换后指到了哪里。
  • 第二列 (m01, m11, m21):原本指向 (0, 1, 0)Y 轴,变换后指到了哪里。
  • 第三列 (m02, m12, m22):原本指向 (0, 0, 1)Z 轴,变换后指到了哪里。

通过这个视角看变换:

  • 缩放 (Scaling): 如果第一列是 (2, 0, 0),意味着 X 轴被拉长到了原来的 2 倍。物体在 X 方向就会变胖。
  • 旋转 (Rotation): 如果第一列变成了 (0, 1, 0),第二列变成了 (-1, 0, 0),意味着 X 轴指向了原来的 Y,Y 轴指向了原来的 -X。这其实就是绕 Z 轴旋转了 90 度
  • 切变 (Shearing): 如果轴不再互相垂直(比如 X 轴斜着长),物体就会发生“歪斜”。

💡 核心直觉:只要看矩阵的前三列,你就能立刻知道现在的局部坐标系是怎么摆放的。每一列就是一个基向量 (Basis Vector)

🎨 具象化案例:从 API 到像素的完整调用链

光看公式抽象?我们走一遍完整链路——从 makeXxx() 到最终顶点坐标,每一步都摆出来。

🧱 准备:跟踪一个具体顶点

为避免抽象,全程跟踪单位正方体的右上角顶点 v5 = (+0.5, +0.5, +0.5),看它经过矩阵后变成什么。

单位正方体(边长 1,以原点为中心):

Y

│ v5 •(+0.5,+0.5,+0.5)
│ ╱
│ ╱
─────┼────────→ X
╱│
╱ │
↙ │
Z │

完整链路 5 步走(每个案例都按此结构展开):

步骤含义关键产物
1. API 调用你写的代码Matrix4 对象
2. 源码实现Three.js 内部 set() 怎么写16 个数按行传入
3. elements 数组列主序存储的 16 个数内存里的实际数据
4. 矩阵乘法M × v 手算全过程新坐标 v'
5. 几何结果立方体长什么样ASCII 形变图

🔍 案例 1:makeScale(2, 1, 1) —— X 方向拉长 2 倍

Step 1: API 调用

const m = new THREE.Matrix4().makeScale(2, 1, 1);

Step 2: Three.js 内部源码(简化自 src/math/Matrix4.js

makeScale(x, y, z) {
this.set(
x, 0, 0, 0, // 第 0 行
0, y, 0, 0, // 第 1 行
0, 0, z, 0, // 第 2 行
0, 0, 0, 1 // 第 3 行(齐次行固定 [0,0,0,1])
);
return this;
}

Step 3: elements 数组(列主序存储)

索引对应
[0, 1, 2, 3]2, 0, 0, 0列 0 = X 轴基向量 = (2,0,0)
[4, 5, 6, 7]0, 1, 0, 0列 1 = Y 轴基向量 = (0,1,0)
[8, 9, 10, 11]0, 0, 1, 0列 2 = Z 轴基向量 = (0,0,1)
[12, 13, 14, 15]0, 0, 0, 1列 3 = 平移 = (0,0,0)

Step 4: 矩阵 × 向量(手算全过程)

设 v5 = (0.5, 0.5, 0.5, 1),把它"竖着写"放在矩阵右边:

| 2  0  0  0 |     | 0.5 |     | 2·0.5 + 0·0.5 + 0·0.5 + 0·1 |     | 1.0 |
| 0 1 0 0 | | 0.5 | | 0·0.5 + 1·0.5 + 0·0.5 + 0·1 | | 0.5 |
| 0 0 1 0 | × | 0.5 | = | 0·0.5 + 0·0.5 + 1·0.5 + 0·1 | = | 0.5 |
| 0 0 0 1 | | 1 | | 0·0.5 + 0·0.5 + 0·0.5 + 1·1 | | 1 |
↑ ↑
第 0 行的 2 X 分量翻倍

关键路径

  • 只有第 0 行的 2 参与了运算 → 把 v5 的 X 分量 0.5 翻倍成 1.0
  • Y、Z 分量乘以 1,原样保留

Step 5: 几何结果

v5 变换前:                          v5 变换后:
位置 (+0.5, +0.5, +0.5) 位置 (+1.0, +0.5, +0.5)

┌────┐ ┌──────┐
│ │ │ │
│ v5 • │ v5'•
│ │ │ │
└────┘ └──────┘
立方体(1×1×1) 长方体(2×1×1)

结论:X 轴上的所有点都被拉远到 2 倍距离,Y/Z 轴上的点完全没动


🔍 案例 2:makeRotationZ(Math.PI / 2) —— 绕 Z 轴逆时针 90°

Step 1: API 调用

const m = new THREE.Matrix4().makeRotationZ(Math.PI / 2);

Step 2: Three.js 内部源码

makeRotationZ(theta) {
const c = Math.cos(theta), s = Math.sin(theta); // c: Cosine, s: Sine
this.set(
c, -s, 0, 0, // 第 0 行
s, c, 0, 0, // 第 1 行
0, 0, 1, 0, // 第 2 行(Z 轴本身不变)
0, 0, 0, 1
);
return this;
}

怎么更好地理解这里的c和s?

理解旋转矩阵最简单的方法是看基向量(Basis Vectors)的变化。在 2D/绕 Z 轴旋转中,我们观察 X 轴 $(1,0)$ 和 Y 轴 $(0,1)$ 旋转 $\theta$ 后的位置:

  1. 新 X 轴:原向量 $(1,0)$ 旋转 $\theta$ 后,坐标变为 $(\cos\theta, \sin\theta)$。这构成了矩阵的第一列
  2. 新 Y 轴:原向量 $(0,1)$ 旋转 $\theta$ 后,坐标变为 $(-\sin\theta, \cos\theta)$。这构成了矩阵的第二列
       Y 轴
^ 新 X 轴 (c, s)
| /
| / θ
|/-----> X 轴

注意: 虽然 Three.js 内部是列主序存储,但 matrix.set() 方法接收的是行主序参数。所以你在源码中看到的是:

  • 第一行:c, -s, ... (对应新 X 轴的 x 和新 Y 轴的 x)
  • 第二行:s, c, ... (对应新 X 轴的 y 和新 Y 轴的 y)

θ = π/2 时,cos(π/2) = 0, sin(π/2) = 1,代入得:

|  0  -1   0  0 |
| 1 0 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |

Step 3: elements 数组

索引对应
[0, 1, 2, 3]0, 1, 0, 0列 0(新 X 轴)= (0,1,0),指 +Y
[4, 5, 6, 7]-1, 0, 0, 0列 1(新 Y 轴)= (-1,0,0),指 -X
[8, 9, 10, 11]0, 0, 1, 0列 2 = Z 轴:不变
[12, 13, 14, 15]0, 0, 0, 1列 3:恒等

关键观察:列 0 和列 1 互换了角色(X 跑到 Y,Y 跑到 -X),列 2 完全不变。

Step 4: 矩阵 × 向量(手算全过程)

|  0  -1   0  0 |     | 0.5 |     | 0·0.5 + (-1)·0.5 + 0·0.5 + 0·1 |     | -0.5 |
| 1 0 0 0 | | 0.5 | | 1·0.5 + 0·0.5 + 0·0.5 + 0·1 | | 0.5 |
| 0 0 1 0 | × | 0.5 | = | 0·0.5 + 0·0.5 + 1·0.5 + 0·1 | = | 0.5 |
| 0 0 0 1 | | 1 | | 0·0.5 + 0·0.5 + 0·0.5 + 1·1 | | 1 |
↑ ↑ ↑ ↑
X Y Y 翻转到 -X

关键路径

  • 第 1 列的 -1:把 v5 的 Y 分量 0.5 翻转到 -0.5
  • 第 0 列的 1:把 v5 的 X 分量 0.5 加到 Y 输出
  • Z 行完全没参与(因为绕 Z 转)

Step 5: 几何结果(俯视 Z 轴方向)

v5 变换前:                              v5 变换后:
(+0.5, +0.5, +0.5) (-0.5, +0.5, +0.5)

Y X
↑ ↑
│ v5 • │
│ │ • v5'
─────┼─────→ X Y ─────┼────→
│ │

Z(指向你)

结论:整个立方体绕 Z 轴(指向你)逆时针转了 90°,v5 从第一象限跳到第二象限。


🔍 案例 3:makeTranslation(2, 3, 0) —— 整体平移

Step 1: API 调用

const m = new THREE.Matrix4().makeTranslation(2, 3, 0);

Step 2: Three.js 内部源码

makeTranslation(x, y, z) {
this.set(
1, 0, 0, x, // 第 0 行:注意平移值塞在第 4 列!
0, 1, 0, y, // 第 1 行
0, 0, 1, z, // 第 2 行
0, 0, 0, 1 // 第 3 行
);
return this;
}

Step 3: elements 数组

索引对应
[0, 1, 2, 3]1, 0, 0, 0列 0 = X 轴基向量:不变
[4, 5, 6, 7]0, 1, 0, 0列 1 = Y 轴基向量:不变
[8, 9, 10, 11]0, 0, 1, 0列 2 = Z 轴基向量:不变
[12, 13, 14, 15]2, 3, 0, 1列 3:平移向量 (2, 3, 0)

关键观察前三列一字未动! 平移值被塞到了最后一列(索引 [12, 13, 14, 15])。

Step 4: 矩阵 × 向量(手算全过程)

| 1  0  0  2 |     | 0.5 |     | 1·0.5 + 0·0.5 + 0·0.5 + 2·1 |     | 2.5 |
| 0 1 0 3 | | 0.5 | | 0·0.5 + 1·0.5 + 0·0.5 + 3·1 | | 3.5 |
| 0 0 1 0 | × | 0.5 | = | 0·0.5 + 0·0.5 + 1·0.5 + 0·1 | = | 0.5 |
| 0 0 0 1 | | 1 | | 0·0.5 + 0·0.5 + 0·0.5 + 1·1 | | 1 |
↑ ↑ ↑ ↑
w=1 才 2·1=2 X/Y 各加 2/3
激活平移 3·1=3

关键路径

  • 只有 w=1 这一项激活了第 4 列的平移值
  • 2·1 = 2 加到 X 输出,3·1 = 3 加到 Y 输出
  • 这就是齐次坐标 w=1 的真正意义——告诉矩阵"我是一个点,请把我平移"

Step 5: 几何结果

v5 变换前:                            v5 变换后:
位置 (+0.5, +0.5, +0.5) 位置 (+2.5, +3.5, +0.5)

Y Y
↑ ↑
v5 • v5' •
│ │
─────┼────→ X ─────┼────→ X
原点附近 (2, 3) 附近

结论:立方体整体平移了 (+2, +3, 0),但形状、姿态完全没变


🎯 把 3 个案例拼起来:T * R * S

实际项目中,物体的最终变换 = 平移 × 旋转 × 缩放(从右往左读):

// 第 1 步:分别构造
const S = new THREE.Matrix4().makeScale(2, 1, 1);
const R = new THREE.Matrix4().makeRotationZ(Math.PI / 2);
const T = new THREE.Matrix4().makeTranslation(2, 3, 0);

// 第 2 步:按 T·R·S 顺序相乘
const M = new THREE.Matrix4()
.multiply(T) // T 左乘
.multiply(R) // R 左乘
.multiply(S); // S 左乘

// 第 3 步:把 v5 喂给 M
const v5 = new THREE.Vector3(0.5, 0.5, 0.5);
v5.applyMatrix4(M);
console.log(v5); // Vector3 {x: 1.5, y: 4.0, z: 0.5}

手算验证 v5 = (0.5, 0.5, 0.5) 经过 T·R·S

  • S 阶段(0.5, 0.5, 0.5)(1.0, 0.5, 0.5)(X 翻倍)
  • R 阶段(1.0, 0.5, 0.5)(-0.5, 1.0, 0.5)(绕 Z 转 90°,X 变 -Y、Y 变 X)
  • T 阶段(-0.5, 1.0, 0.5)(1.5, 4.0, 0.5)(加平移 2/3)

这就是为什么 Three.js 输出 (1.5, 4.0, 0.5)——手算 = 代码 = 真实结果,链路是闭合的。


🔬 链路终点:applyMatrix4 内部到底做了什么?

最后揭密 Three.js 怎么用 elements 数组算新坐标(Vector3.applyMatrix4 简化版):

applyMatrix4(m) {
const x = this.x, y = this.y, z = this.z;
const e = m.elements; // ← 列主序的 16 个数

this.x = e[0] * x + e[4] * y + e[8] * z + e[12]; // 第 0 列贡献
this.y = e[1] * x + e[5] * y + e[9] * z + e[13]; // 第 1 列贡献
this.z = e[2] * x + e[6] * y + e[10] * z + e[14]; // 第 2 列贡献
// e[12,13,14] = 平移
return this;
}

注意索引的"跳跃":e[0], e[4], e[8], e[12] 正好是列 0、列 1、列 2、列 3 的第 0 行——这就是列主序存储的物理意义:每 4 个元素就是一列

💡 链路全貌makeXxx() API → Three.js set() 源码 → 列主序 elements[16]applyMatrix4 内部按列乘加 → 新坐标 v'。其中前 3 列是基向量(决定姿态)、第 4 列是平移(决定位置)——记住这两条铁律,整个 4×4 矩阵就再无秘密。

2. 右侧第一列:平移(Translation)

[ m03 ]  -> X 轴平移
[ m13 ] -> Y 轴平移
[ m23 ] -> Z 轴平移
  • 这三个数字直接对应物体在空间中移动的距离。
  • 为什么在 3D 变换中需要 4x4 而不是 3x3? 就是为了能把“平移”也塞进矩阵乘法里,实现一次乘法搞定所有变换(齐次坐标)。

3. 最下面一行:投影与齐次控制

[ m30 m31 m32 m33 ]
  • 在基础的仿射变换中,这一行通常固定为 [ 0, 0, 0, 1 ]
  • m30, m31, m32:在投影矩阵中用于产生透视效果(近大远小)。
  • m33:齐次坐标的缩放因子,通常保持为 1。

矩阵的神奇之处:

  • 一个矩阵可以同时包含平移、旋转和缩放信息。
  • 矩阵相乘可以叠加变换(例如:先旋转再平移)。

🤔 矩阵 vs 向量:有什么区别?

向量描述"东西在哪/朝哪",矩阵描述"东西怎么变"。

维度向量矩阵
数学本质有方向和大小的量一组按行列排布的数
几何意义一个点 / 一条箭头一个变换(移动/旋转/缩放)
维度通常 2D / 3D / 4D2D 用 3x3,3D 用 4x4
运算加法、点积、叉积、标量乘矩阵乘法、求逆、转置
在 Three.js 中Vector3(x, y, z) 表示一个点Matrix4 表示一个变换
举个例子"相机的位置是 (0, 5, 10)""把相机先绕 Y 轴转 30°,再平移到 (0, 5, 10)"

关键直觉:矩阵作用于向量

// 向量:描述"我在哪"
const position = new THREE.Vector3(1, 2, 3);

// 矩阵:描述"我要怎么变"
const translation = new THREE.Matrix4().makeTranslation(10, 0, 0);

// 矩阵作用于向量:得到新位置 (11, 2, 3)
position.applyMatrix4(translation);

可以这样想:向量是"名词",矩阵是"动词"。向量是静态的"东西",矩阵是动态的"动作",而动作作用在东西上,就产生了新的位置/方向。

💡 记忆技巧:第 1 周学的"点积"和"叉积"都是两个向量之间的运算;这周学的"矩阵乘法"是矩阵与向量矩阵与矩阵之间的运算。矩阵是向量的"升级版工具"——当你想表达"一个动作"时,向量不够用,就需要矩阵上场了。

💻 实践:Three.js 中的矩阵

Three.js 使用 Matrix4 类。通常你不需要手动填数字,而是使用 helper 方法。

import * as THREE from "three";

const matrix = new THREE.Matrix4();

// 创建平移矩阵
matrix.makeTranslation(10, 0, 0);

// 📊 平移矩阵的列主序存储图示:
//
// 1️⃣ 数学上的矩阵表示(行主序写法,便于理解):
// ┌─────────────────────────────┐
// │ 1 0 0 10 │ ← 第0行 │
// │ 0 1 0 0 │ ← 第1行 │
// │ 0 0 1 0 │ ← 第2行 │
// │ 0 0 0 1 │ ← 第3行 │
// └─────────────────────────────┘
// ↑ ↑ ↑ ↑
// 列0 列1 列2 列3
//
// 2️⃣ 在 elements 数组中按列存储(列主序,实际存储):
// matrix.elements = [
// 1, 0, 0, 0, ← 列0 (X轴方向,单位向量) 索引 [0, 1, 2, 3]
// 0, 1, 0, 0, ← 列1 (Y轴方向,单位向量) 索引 [4, 5, 6, 7]
// 0, 0, 1, 0, ← 列2 (Z轴方向,单位向量) 索引 [8, 9, 10, 11]
// 10, 0, 0, 1 ← 列3 (平移分量) 索引 [12, 13, 14, 15]
// ]
//
// 3️⃣ 几何意义(沿 X 轴平移 10 单位):
// - 列0 [1, 0, 0, 0]:X 轴方向不变(单位向量)
// - 列1 [0, 1, 0, 0]:Y 轴方向不变(单位向量)
// - 列2 [0, 0, 1, 0]:Z 轴方向不变(单位向量)
// - 列3 [10, 0, 0, 1]:平移分量,X 方向移动 10,Y 和 Z 方向移动 0
//
// 💡 关键理解:平移矩阵只改变列3(平移分量),前三列保持单位矩阵形式
//
// ❓ 为什么平移分量要单独放在一列(列3)?
//
// 1️⃣ 数学限制(更直觉的说法):3x3 只能表示「线性变换」,线性变换不会移动原点
// 3x3 变换写成 v' = A v(A 是 3x3),那么一定有:A * 0 = 0
// 也就是说:原点 (0,0,0) 变换后仍然是 (0,0,0)
// 但「平移」的要求恰恰是:把原点移动到 (tx, ty, tz)
// (0,0,0) → (tx, ty, tz)
// 这和 “A * 0 = 0” 冲突,所以纯 3x3 不可能表示平移。
//
// 2️⃣ 解决方案:使用 4x4 矩阵 + 齐次坐标
// 通过增加第 4 行和第 4 列,利用矩阵乘法的最后一行来实现加法:
// ┌─────────────┐ ┌───┐ ┌──────┐
// │ 1 0 0 10 │ │ x │ │ x+10 │
// │ 0 1 0 0 │ × │ y │ = │ y │ ✅ 正确!
// │ 0 0 1 0 │ │ z │ │ z │
// │ 0 0 0 1 │ │ 1 │ │ 1 │
// └─────────────┘ └───┘ └──────┘
// 计算过程:x*1 + y*0 + z*0 + 1*10 = x + 10 ✓
//
// 🔎 手算一遍你就能看到“10 是怎么被加进去的”:
// 令点 p = (1, 2, 3),齐次坐标写成 (1, 2, 3, 1)
// T(10,0,0) * (1,2,3,1) =
// x' = 1*1 + 0*2 + 0*3 + 10*1 = 11
// y' = 0*1 + 1*2 + 0*3 + 0*1 = 2
// z' = 0*1 + 0*2 + 1*3 + 0*1 = 3
// w' = 1
// 所以结果是 (11, 2, 3) —— 这就是“平移 = 加一个常量”被塞进矩阵乘法的原因。
//
// 3️⃣ 列3的特殊性
// - 列3的前三个元素(elements[12, 13, 14])存储平移值
// - 列3的第四个元素(elements[15])固定为 1,用于齐次坐标
// - 当点的 w=1 时,列3的平移值会被加到坐标上
// - 当向量的 w=0 时,列3的平移值会被抵消(向量不受平移影响)
//
// 4️⃣ 统一表示所有变换
// 通过 4x4 矩阵,我们可以用统一的矩阵乘法表示:
// - 旋转(改变列0、列1、列2)
// - 缩放(改变列0、列1、列2的对角线)
// - 平移(改变列3)
// 这样所有变换都可以用矩阵乘法组合!

// 创建旋转矩阵(绕 Y 轴 90 度)
const rotation = new THREE.Matrix4();
rotation.makeRotationY(Math.PI / 2);

// 📊 旋转矩阵的列主序存储图示:
//
// 1️⃣ 数学上的矩阵表示(行主序写法,便于理解):
// ┌─────────────────────────────┐
// │ 0 0 1 0 │ ← 第0行 │
// │ 0 1 0 0 │ ← 第1行 │
// │ -1 0 0 0 │ ← 第2行 │
// │ 0 0 0 1 │ ← 第3行 │
// └─────────────────────────────┘
// ↑ ↑ ↑ ↑
// 列0 列1 列2 列3
//
// 2️⃣ 在 elements 数组中按列存储(列主序,实际存储):
// rotation.elements = [
// 0, 0, -1, 0, ← 列0 (新X轴方向) 索引 [0, 1, 2, 3]
// 0, 1, 0, 0, ← 列1 (新Y轴方向) 索引 [4, 5, 6, 7]
// 1, 0, 0, 0, ← 列2 (新Z轴方向) 索引 [8, 9, 10, 11]
// 0, 0, 0, 1 ← 列3 (平移分量) 索引 [12, 13, 14, 15]
// ]
//
// 3️⃣ 几何意义(绕 Y 轴旋转 90° 后):
// - 列0 [0, 0, -1, 0]:原来的 Z 轴负方向 → 新的 X 轴正方向
// - 列1 [0, 1, 0, 0]:Y 轴保持不变(因为是绕 Y 轴旋转)
// - 列2 [1, 0, 0, 0]:原来的 X 轴正方向 → 新的 Z 轴正方向
// - 列3 [0, 0, 0, 1]:没有平移(纯旋转矩阵)
//
// 4️⃣ 矩阵推导过程(绕 Y 轴旋转的一般公式):
// 绕 Y 轴旋转角度 θ 的旋转矩阵为:
// ┌─────────────────────────────┐
// │ cos(θ) 0 sin(θ) 0 │
// │ 0 1 0 0 │
// │ -sin(θ) 0 cos(θ) 0 │
// │ 0 0 0 1 │
// └─────────────────────────────┘
//
// 当 θ = 90° = π/2 时:
// - cos(π/2) = 0
// - sin(π/2) = 1
//
// 代入公式得到:
// ┌─────────────────────────────┐
// │ 0 0 1 0 │ ← cos(π/2)=0, sin(π/2)=1
// │ 0 1 0 0 │ ← Y轴不变
// │ -1 0 0 0 │ ← -sin(π/2)=-1, cos(π/2)=0
// │ 0 0 0 1 │
// └─────────────────────────────┘
//
// 🔎 手算验证:令点 p = (1, 0, 0),绕 Y 轴旋转 90°
// 齐次坐标写成 (1, 0, 0, 1)
// R_y(90°) * (1, 0, 0, 1) =
// x' = 0*1 + 0*0 + 1*0 + 0*1 = 0
// y' = 0*1 + 1*0 + 0*0 + 0*1 = 0
// z' = -1*1 + 0*0 + 0*0 + 0*1 = -1
// w' = 1
// 所以结果是 (0, 0, -1) ✓
// 几何验证:点 (1, 0, 0) 在 X 轴正方向,绕 Y 轴旋转 90° 后应该在 Z 轴负方向,正确!

// 创建缩放矩阵
const scale = new THREE.Matrix4();
scale.makeScale(2, 2, 2);

// 查看矩阵的存储方式(列主序)
const translation = new THREE.Matrix4();
translation.makeTranslation(5, 10, 15);
console.log(translation.elements);
// 输出: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 10, 15, 1]
// ↑列0↑ ↑列1↑ ↑列2↑ ↑列3(平移)↑
// 注意:平移值 (5, 10, 15) 在索引 [12, 13, 14] 位置

Three.js Matrix4 存储方式:

  • 列主序(Column-Major)存储:矩阵按列存储在数组中。
  • 数组索引映射
    列0 (X轴方向): 索引 [0, 1, 2, 3]
    列1 (Y轴方向): 索引 [4, 5, 6, 7]
    列2 (Z轴方向): 索引 [8, 9, 10, 11]
    列3 (平移): 索引 [12, 13, 14, 15]
  • 为什么是列主序? WebGL/OpenGL 标准使用列主序,Three.js 遵循这个标准以便直接传给 GPU。
  • 实际存储matrix.elements 是一个长度为 16 的数组,按列顺序存储。

🎯 练习 2.1:应用矩阵

import * as THREE from "three";

const p = new THREE.Vector3(1, 0, 0);
const m = new THREE.Matrix4();

// 1. 创建一个向 X 轴平移 2 单位的矩阵
m.makeTranslation(2, 0, 0);

// 2. 应用矩阵到向量
p.applyMatrix4(m);

console.log(p); // (3, 0, 0) -> 原来的 1 + 平移 2

📝 今日任务

  • 理解矩阵是用来存储变换信息的容器
  • 尝试 Three.js 的 makeTranslation, makeRotation, makeScale

第 2 天:矩阵乘法与顺序

📖 理论:矩阵乘法

核心规则:

  1. 不满足交换律A × B ≠ B × A。顺序非常重要!
  2. 从右向左阅读:变换通常是从右向左应用的。
    • Matrix = Translation * Rotation * Scale
    • 对向量 v 应用:v' = Matrix * v
    • 实际顺序:先缩放(Scale),再旋转(Rotation),最后平移(Translation)。

为什么要先缩放再旋转再平移?

  • 核心原因:旋转和缩放都是绕原点 (0,0,0) 进行的。如果先平移,物体已经离开原点,再旋转时就会绕原点“公转”,而不是绕自身中心“自转”。
  • 直观例子:物体在 (5, 0),先平移再旋转 90°。旋转时绕的是原点,所以 (5,0) 会跑到 (-0,5) 附近,整条轨迹是绕原点的圆弧,看起来像被“甩”出去。正确做法是先旋转(在原点处摆好姿态),再平移到目标位置,物体才会乖乖待在你想放的地方。
  • 补充(为什么缩放通常放在旋转之前):缩放和旋转也不满足交换律(尤其是非等比缩放 x/y/z 不一样时)。直觉是:缩放是沿着“当前坐标轴方向”拉伸/压扁,旋转会改变坐标轴方向,所以 先缩放再旋转先旋转再缩放 会得到不同形状/方向。只有等比缩放(x=y=z)时,两者才近似可交换。
    • 最小例子(2D):点 (1,0),先旋转 90° 得 (0,1),再做 x 方向缩放 2 倍仍是 (0,1);但先 x 缩放 2 倍得 (2,0),再旋转 90° 变 (0,2)。结果不同,所以顺序重要。
    • 图示(同一个点,两条路径)

💻 实践:理解顺序

import * as THREE from "three";

const p = new THREE.Vector3(1, 0, 0);

// 变换 1:先旋转再平移(通常是我们想要的)
const m1 = new THREE.Matrix4();
const r = new THREE.Matrix4().makeRotationZ(Math.PI / 2); // 旋 90 度
const t = new THREE.Matrix4().makeTranslation(0, 1, 0); // 移 1 单位

m1.multiplyMatrices(t, r); // T * R
const p1 = p.clone().applyMatrix4(m1);
// (1,0,0) -> 旋90度 -> (0,1,0) -> 移(0,1,0) -> (0,2,0)

// 变换 2:先平移再旋转
const m2 = new THREE.Matrix4();
m2.multiplyMatrices(r, t); // R * T
const p2 = p.clone().applyMatrix4(m2);
// (1,0,0) -> 移(0,1,0) -> (1,1,0) -> 旋90度 -> (-1,1,0)

💻 实践:项目中的矩阵

// 着色器代码
// 这里可以看到矩阵乘法的实际应用
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

解析:

  • position:顶点的原始坐标。
  • modelViewMatrix:模型矩阵 * 视图矩阵。先把物体放到世界位置,再转换到相机视角。
  • projectionMatrix:投影矩阵。把 3D 坐标压扁到 2D 屏幕空间。
  • 顺序:点 -> 视图变换 -> 投影变换。

📝 今日任务

  • 记住矩阵乘法不交换
  • 理解 T R S 的标准顺序
  • 理解着色器中的矩阵乘法顺序

第 3 天:齐次坐标

📖 理论:齐次坐标

你可能注意到着色器里写的是 vec4(position, 1.0),多出来的 1.0 是什么?

这就是齐次坐标(Homogeneous Coordinates)

  • 3D 坐标是 (x, y, z)
  • 齐次坐标是 (x, y, z, w)

为什么需要它?

  • 为了让平移也能用矩阵乘法表示。3x3 矩阵无法表示平移。
  • w 分量通常是 1.0(表示点)或 0.0(表示向量/方向)。
    • 点 + 向量 = 点
    • 向量 + 向量 = 向量
    • 平移对方向(w=0)无效,只对点(w=1)有效。这也是为什么法线变换要特殊处理。

💻 实践:Three.js 中的齐次坐标

Three.js 内部自动处理,但在着色器中需要显式写出。

// 将 vec3 转换为 vec4
vec4 pos = vec4(position, 1.0);

// 如果是一个方向(比如法线),w 应该是 0
vec4 normal = vec4(normal, 0.0);

🎯 练习 2.3:理解 w 分量

思考:为什么光照计算中,法线(Normal)变换时 w=0?

  • 因为法线只代表方向。如果你移动物体,法线的方向不会变,所以平移矩阵不应该影响法线。w=0 使得平移分量在矩阵乘法中被抵消。

📝 今日任务

  • 理解为什么是 4x4 矩阵而不是 3x3
  • 知道 w=1 代表点,w=0 代表向量

💡 核心知识点总结:

  1. 为什么需要 4x4 矩阵?

    • 原因:3x3 矩阵只能表示线性变换(旋转、缩放),无法表示平移
    • 解决:引入齐次坐标(4x4),利用最后一行/列将加法(平移)转换为乘法。
    • 结果:所有变换(缩放 S、旋转 R、平移 T)统一为矩阵相乘,大大简化了计算管线。
  2. w 分量的作用 (w=0 vs w=1)

    • w=1 (点 Point)需要平移
      • 矩阵乘法中:平移分量 * 1 被加到坐标上。
    • w=0 (向量 Vector)不需要平移(向量只有方向和长度)。
      • 矩阵乘法中:平移分量 * 0 被抵消,向量仅受旋转/缩放影响。

第 4-5 天:综合练习

🎯 项目实战:分析渲染管线中的矩阵

任务: 追踪一个顶点是如何从模型变成屏幕上的像素的。

  1. 相机设置

    // renderer/map/index.ts
    this.camera = new THREE.PerspectiveCamera();

    相机定义了 projectionMatrix(透视投影)和 viewMatrix(相机位置逆变换)。

  2. 物体变换 Three.js 的 Mesh 都有 position, rotation, scale。 Three.js 会自动计算 modelMatrix

  3. 着色器变换

    // Three.js 自动传入 modelViewMatrix 和 projectionMatrix
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);

    行业里常说的 MVP 矩阵,其实就是这三个矩阵的组合:

    • M(Model)模型矩阵 = modelMatrix

      • 作用:把点从 模型空间(Model Space) 变换到 世界空间(World Space)
      • 也就是把“以物体自己为原点”的坐标,搬到“世界坐标系”里摆好位置、姿态、大小。
    • V(View)观察矩阵 = viewMatrix

      • 作用:把点从 世界空间 变换到 相机空间 / 视图空间(View / Camera Space)
      • 可以理解为:把“世界”反向移动/旋转到以“相机”为原点的坐标系中。
    • P(Projection)投影矩阵 = projectionMatrix

      • 作用:把点从 相机空间 变换到 裁剪空间 / 齐次裁剪空间(Clip Space),再经过齐次除法(xyz /= w)得到 NDC(规范设备坐标),范围落在 ([-1,1]) 立方体中,方便 GPU 做裁剪和视口映射。

    在 Three.js 的顶点着色器里,通常会先把 MV 乘起来得到:

    • modelViewMatrix = V * M

    然后再左乘 P,总体公式就是:

    • gl_Position = P * V * M * vec4(position, 1.0);

    也就是:

    • 模型空间 →(M)→ 世界空间 →(V)→ 相机空间 →(P)→ 裁剪空间 →(齐次除法)→ NDC → 屏幕像素

    你看到的这行:

    gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);

    就是在做同样的事情,只是把 MV 合成了一个 modelViewMatrix,从右到左依次完成:
    模型空间坐标 pointPos → 相机视角下的坐标 → 投影到裁剪空间 → 最终屏幕上的位置

  4. LineRender 特殊处理 LineRender 中有一个特殊的步骤:线宽扩展

    // 顶点位置偏移
    vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);

    它先在模型空间计算出线的宽度(向两侧偏移),然后再进行矩阵变换。这是一种常用的技法,用于在 GPU 端生成有宽度的线。

📝 本周总结

  • 我理解矩阵可以组合平移、旋转、缩放。
  • 我知道矩阵乘法是从右向左生效的。
  • 我明白 vec4(pos, 1.0) 的含义。
  • 我理解着色器中 gl_Position 的计算公式

下一步: 第 3 周:图形学基础概念

🔍 makeTranslation 底层原理拆解

makeTranslation(tx, ty, tz) 本质上就是构造了一个平移矩阵

在齐次坐标下,一个 3D 平移矩阵长这样:

| 1  0  0  0 |
| 0 1 0 0 |
| 0 0 1 0 |
| tx ty tz 1 |

当我们用它去乘以一个点 ((x, y, z, 1)) 时:

| 1  0  0  0 |   | x |   | x + tx |
| 0 1 0 0 | * | y | = | y + ty |
| 0 0 1 0 | | z | | z + tz |
| tx ty tz 1 | | 1 | | 1 |

也就是说:x、y、z 分别被加上了平移量 tx、ty、tz

结合前面讲的 Three.js 的列主序存储方式,makeTranslation(tx, ty, tz) 的“底层版”可以理解为:

function makeTranslationMatrix(tx, ty, tz) {
const m = new THREE.Matrix4();

// 先设为单位矩阵
m.identity();

// 再把平移部分写到最后一列
const e = m.elements;
e[12] = tx; // 第 4 列第 1 行
e[13] = ty; // 第 4 列第 2 行
e[14] = tz; // 第 4 列第 3 行

return m;
}

如果完全不用 Three.js,只用一个数组来模拟 4×4 矩阵,也可以这样写一个“数组层面”的 makeTranslation

// 返回一个长度为 16 的数组,列主序 4x4 矩阵
function makeTranslationArray(tx, ty, tz) {
// 单位矩阵
const e = [
1,
0,
0,
0, // 列 0
0,
1,
0,
0, // 列 1
0,
0,
1,
0, // 列 2
0,
0,
0,
1, // 列 3
];

// 写入平移量(最后一列)
e[12] = tx;
e[13] = ty;
e[14] = tz;

return e;
}

这样你就可以从“数组层面”直观看到:平移信息就是塞进最后一列的 e[12], e[13], e[14],这就是 m.makeTranslation(2, 0, 0) 在底层真正做的事情。