Skip to main content

第 2 周:矩阵和坐标变换

📅 学习计划

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

时间安排: 每天 1-2 小时,共 5 天


第 1 天:理解矩阵

📖 理论:矩阵是什么?

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

4x4 矩阵结构:

[ m00 m01 m02 m03 ]
[ m10 m11 m12 m13 ]
[ m20 m21 m22 m23 ]
[ m30 m31 m32 m33 ]

矩阵的神奇之处:

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

💻 实践: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)

💻 实践:项目中的矩阵

查看代码: line_shader/index.ts

// 着色器代码
// 这里可以看到矩阵乘法的实际应用
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. 着色器变换

    // line_shader/index.ts
    // 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) 在底层真正做的事情。