第 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$ 后的位置:
- 新 X 轴:原向量 $(1,0)$ 旋转 $\theta$ 后,坐标变为 $(\cos\theta, \sin\theta)$。这构成了矩阵的第一列。
- 新 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.jsset()源码 → 列主序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 / 4D | 2D 用 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 天:矩阵乘法与顺序
📖 理论:矩阵乘法
核心规则:
- 不满足交换律:
A × B ≠ B × A。顺序非常重要! - 从右向左阅读:变换通常是从右向左应用的。
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)。结果不同,所以顺序重要。 - 图示(同一个点,两条路径):
- 最小例子(2D):点
💻 实践:理解顺序
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 代表向量
💡 核心知识点总结:
为什么需要 4x4 矩阵?
- 原因:3x3 矩阵只能表示线性变换(旋转、缩放),无法表示平移。
- 解决:引入齐次坐标(4x4),利用最后一行/列将加法(平移)转换为乘法。
- 结果:所有变换(缩放 S、旋转 R、平移 T)统一为矩阵相乘,大大简化了计算管线。
w 分量的作用 (w=0 vs w=1)
- w=1 (点 Point):需要平移。
- 矩阵乘法中:
平移分量 * 1被加到坐标上。
- 矩阵乘法中:
- w=0 (向量 Vector):不需要平移(向量只有方向和长度)。
- 矩阵乘法中:
平移分量 * 0被抵消,向量仅受旋转/缩放影响。
- 矩阵乘法中:
- w=1 (点 Point):需要平移。
第 4-5 天:综合练习
🎯 项目实战:分析渲染管线中的矩阵
任务: 追踪一个顶点是如何从模型变成屏幕上的像素的。
相机设置
// renderer/map/index.ts
this.camera = new THREE.PerspectiveCamera();相机定义了
projectionMatrix(透视投影)和viewMatrix(相机位置逆变换)。物体变换 Three.js 的
Mesh都有position,rotation,scale。 Three.js 会自动计算modelMatrix。着色器变换
// 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 做裁剪和视口映射。
- 作用:把点从 相机空间 变换到 裁剪空间 / 齐次裁剪空间(Clip Space),再经过齐次除法(
在 Three.js 的顶点着色器里,通常会先把
M和V乘起来得到: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);就是在做同样的事情,只是把
M和V合成了一个modelViewMatrix,从右到左依次完成:
模型空间坐标pointPos→ 相机视角下的坐标 → 投影到裁剪空间 → 最终屏幕上的位置。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) 在底层真正做的事情。