Skip to content

第6章:矩阵变换与相机

6.1 章节概述

要在 WebGL 中显示 3D 场景,我们需要将 3D 物体的坐标变换到 2D 屏幕上。这个过程涉及三个核心矩阵:模型矩阵视图矩阵投影矩阵

本章将深入讲解:

  • 坐标空间:局部空间、世界空间、观察空间、裁剪空间
  • 变换矩阵:平移、旋转、缩放的矩阵表示
  • MVP 矩阵:模型-视图-投影变换管线
  • 相机实现:自由相机、轨道相机
  • 投影方式:透视投影、正交投影

6.2 坐标空间与变换管线

6.2.1 坐标空间概览

3D 渲染的坐标变换管线

┌─────────────┐     模型矩阵      ┌─────────────┐
│  局部空间    │  ─────────────→  │  世界空间    │
│ Local Space │     Model        │ World Space │
│             │                  │             │
│ 物体自身    │                  │ 场景中的    │
│ 坐标系      │                  │ 绝对位置    │
└─────────────┘                  └─────────────┘

                                       │ 视图矩阵
                                       │ View

┌─────────────┐     投影矩阵      ┌─────────────┐
│  裁剪空间    │  ←─────────────  │  观察空间    │
│ Clip Space  │    Projection    │ View Space  │
│             │                  │             │
│ 齐次坐标    │                  │ 相机为原点  │
│ -w ≤ xyz ≤ w│                  │ 的坐标系    │
└─────────────┘                  └─────────────┘

       │ 透视除法 + 视口变换

┌─────────────┐
│  屏幕空间    │
│Screen Space │
│             │
│ 像素坐标    │
└─────────────┘


完整变换公式:
gl_Position = Projection × View × Model × vec4(position, 1.0)
         =    P      ×    V  ×   M   ×     v

6.2.2 各空间详解

1. 局部空间(Local/Object Space)
──────────────────────────────────
物体自身的坐标系,建模时使用

例:一个立方体的顶点(中心在原点)
      ↑ Y

   ┌──┼──┐
   │  │  │
───┼──●──┼───→ X
   │  │  │
   └──┼──┘

      
顶点:(-1,-1,-1), (1,-1,-1), (1,1,-1), ...


2. 世界空间(World Space)
──────────────────────────────────
场景的全局坐标系

多个物体放置在世界中:
      ↑ Y

      │     立方体A
      │    ┌───┐
      │    │   │ (位置: 3, 2, 0)
      │    └───┘

      │  球体B        立方体C
      │    ○         ┌───┐
      │ (1,0,0)      └───┘ (5,0,0)
──────●────────────────────→ X




3. 观察空间(View/Camera Space)
──────────────────────────────────
以相机为原点的坐标系

相机位置 → 原点
相机看向 → -Z 方向
相机上方 → +Y 方向

          ↑ Y (相机上方)


          │   物体们相对于
          │   相机的位置
      📷──●──────→ X (相机右方)
          │╲
          │ ╲
          ↓  ╲ Z (相机后方)
              看向 -Z


4. 裁剪空间(Clip Space)
──────────────────────────────────
投影后的齐次坐标

所有可见内容满足:
-w ≤ x ≤ w
-w ≤ y ≤ w
-w ≤ z ≤ w

         w
    ┌────┼────┐
    │    │    │
 -w ├────●────┤ w
    │    │    │
    └────┼────┘
        -w


5. NDC(标准化设备坐标)
──────────────────────────────────
透视除法后:xyz / w

范围:[-1, 1] 在所有轴

      1
    ┌──┼──┐
    │  │  │
 -1 ├──●──┤ 1
    │  │  │
    └──┼──┘
      -1


6. 屏幕空间(Screen Space)
──────────────────────────────────
最终的像素坐标

(0,0)────────────────→ X

  │     ┌───────┐
  │     │ 渲染  │
  │     │ 结果  │
  │     └───────┘

  ↓ Y
        (width, height)

6.3 变换矩阵基础

6.3.1 为什么使用矩阵?

矩阵的优势

1. 统一表示
──────────────────────────────────
平移、旋转、缩放都可以用 4×4 矩阵表示

2. 组合变换
──────────────────────────────────
多个变换可以预乘成一个矩阵

先缩放、再旋转、再平移:
Final = Translation × Rotation × Scale

3. 高效计算
──────────────────────────────────
一次矩阵乘法完成所有变换
GPU 高度优化了矩阵运算

4. 可逆性
──────────────────────────────────
通过逆矩阵可以反向变换

6.3.2 齐次坐标

为什么需要 4×4 矩阵?(齐次坐标)

3×3 矩阵可以表示旋转和缩放,但无法表示平移!

平移需要加法:P' = P + T
但矩阵乘法是乘法运算

解决方案:齐次坐标
──────────────────────────────────
将 3D 点扩展为 4D:(x, y, z) → (x, y, z, 1)

现在平移可以用 4×4 矩阵表示:

┌ 1  0  0  tx ┐   ┌ x ┐   ┌ x + tx ┐
│ 0  1  0  ty │ × │ y │ = │ y + ty │
│ 0  0  1  tz │   │ z │   │ z + tz │
└ 0  0  0  1  ┘   └ 1 ┘   └   1    ┘


点 vs 向量
──────────────────────────────────
- 点(位置):w = 1,会被平移影响
- 向量(方向):w = 0,不会被平移影响

向量:(dx, dy, dz, 0)

┌ 1  0  0  tx ┐   ┌ dx ┐   ┌ dx ┐
│ 0  1  0  ty │ × │ dy │ = │ dy │  平移不影响向量!
│ 0  0  1  tz │   │ dz │   │ dz │
└ 0  0  0  1  ┘   └ 0  ┘   └ 0  ┘

6.3.3 基本变换矩阵

javascript
/**
 * 4×4 矩阵库
 * WebGL 使用列优先存储
 */
const Mat4 = {
    /**
     * 单位矩阵
     * 
     *     ┌ 1  0  0  0 ┐
     * I = │ 0  1  0  0 │
     *     │ 0  0  1  0 │
     *     └ 0  0  0  1 ┘
     */
    identity() {
        return new Float32Array([
            1, 0, 0, 0,
            0, 1, 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ]);
    },

    /**
     * 平移矩阵
     * 
     *     ┌ 1  0  0  tx ┐
     * T = │ 0  1  0  ty │
     *     │ 0  0  1  tz │
     *     └ 0  0  0  1  ┘
     */
    translation(tx, ty, tz) {
        return new Float32Array([
            1, 0, 0, 0,
            0, 1, 0, 0,
            0, 0, 1, 0,
            tx, ty, tz, 1  // 列优先,所以平移在最后一列
        ]);
    },

    /**
     * 缩放矩阵
     * 
     *     ┌ sx  0  0  0 ┐
     * S = │ 0  sy  0  0 │
     *     │ 0  0  sz  0 │
     *     └ 0  0  0   1 ┘
     */
    scaling(sx, sy, sz) {
        return new Float32Array([
            sx, 0, 0, 0,
            0, sy, 0, 0,
            0, 0, sz, 0,
            0, 0, 0, 1
        ]);
    },

    /**
     * 绕 X 轴旋转
     * 
     *      ┌ 1   0      0     0 ┐
     * Rx = │ 0  cos θ -sin θ  0 │
     *      │ 0  sin θ  cos θ  0 │
     *      └ 0   0      0     1 ┘
     */
    rotationX(angleRadians) {
        const c = Math.cos(angleRadians);
        const s = Math.sin(angleRadians);
        return new Float32Array([
            1, 0, 0, 0,
            0, c, s, 0,
            0, -s, c, 0,
            0, 0, 0, 1
        ]);
    },

    /**
     * 绕 Y 轴旋转
     * 
     *      ┌  cos θ  0  sin θ  0 ┐
     * Ry = │   0     1   0     0 │
     *      │ -sin θ  0  cos θ  0 │
     *      └   0     0   0     1 ┘
     */
    rotationY(angleRadians) {
        const c = Math.cos(angleRadians);
        const s = Math.sin(angleRadians);
        return new Float32Array([
            c, 0, -s, 0,
            0, 1, 0, 0,
            s, 0, c, 0,
            0, 0, 0, 1
        ]);
    },

    /**
     * 绕 Z 轴旋转
     * 
     *      ┌ cos θ -sin θ  0  0 ┐
     * Rz = │ sin θ  cos θ  0  0 │
     *      │  0      0     1  0 │
     *      └  0      0     0  1 ┘
     */
    rotationZ(angleRadians) {
        const c = Math.cos(angleRadians);
        const s = Math.sin(angleRadians);
        return new Float32Array([
            c, s, 0, 0,
            -s, c, 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ]);
    },

    /**
     * 矩阵乘法 A × B
     */
    multiply(a, b) {
        const result = new Float32Array(16);
        
        for (let row = 0; row < 4; row++) {
            for (let col = 0; col < 4; col++) {
                result[col * 4 + row] = 
                    a[0 * 4 + row] * b[col * 4 + 0] +
                    a[1 * 4 + row] * b[col * 4 + 1] +
                    a[2 * 4 + row] * b[col * 4 + 2] +
                    a[3 * 4 + row] * b[col * 4 + 3];
            }
        }
        
        return result;
    }
};

6.3.4 变换顺序的重要性

变换顺序示例

原始立方体(中心在原点):
      ┌───┐
      │ ● │
      └───┘
      原点


顺序1: 先平移(3,0,0),再旋转45°
──────────────────────────────────

Step 1 - 平移:
              ┌───┐
              │ ● │
              └───┘
●─────────────────→
原点          (3,0,0)

Step 2 - 绕原点旋转45°:
         ╱╲
        ╱  ╲ ← 立方体
       ╱ ●  ╲
●───────────────
原点

立方体围绕世界原点旋转,离原点更远!


顺序2: 先旋转45°,再平移(3,0,0)
──────────────────────────────────

Step 1 - 旋转:
      ╱╲
     ╱  ╲
     ╲● ╱
      ╲╱
      原点(立方体还在原点)

Step 2 - 平移:
              ╱╲
             ╱  ╲
             ╲● ╱
              ╲╱
●─────────────────→
原点          (3,0,0)

立方体先自转,再移动


代码对应:
──────────────────────────────────
// 顺序1: 先平移再旋转
// 变换从右到左读!
// Final = Rotation × Translation × vertex
let m1 = Mat4.multiply(rotateY, translateX);  // 先 T 再 R

// 顺序2: 先旋转再平移  
// Final = Translation × Rotation × vertex
let m2 = Mat4.multiply(translateX, rotateY);  // 先 R 再 T


记住:矩阵乘法顺序是 **从右到左** 的!
gl_Position = P × V × M × vertex

               最先应用的变换

6.4 模型矩阵(Model Matrix)

6.4.1 模型矩阵的作用

模型矩阵 = 将物体从局部空间变换到世界空间

包含物体的:
- 位置(Translation)
- 旋转(Rotation)
- 缩放(Scale)

Model = T × R × S

(先缩放、再旋转、再平移 —— 通常最合理的顺序)

6.4.2 实现变换组件

javascript
/**
 * Transform 组件
 * 管理物体的位置、旋转、缩放
 */
class Transform {
    constructor() {
        this.position = { x: 0, y: 0, z: 0 };
        this.rotation = { x: 0, y: 0, z: 0 };  // 欧拉角(弧度)
        this.scale = { x: 1, y: 1, z: 1 };
        
        this._matrix = Mat4.identity();
        this._dirty = true;
    }
    
    setPosition(x, y, z) {
        this.position.x = x;
        this.position.y = y;
        this.position.z = z;
        this._dirty = true;
        return this;
    }
    
    setRotation(x, y, z) {
        this.rotation.x = x;
        this.rotation.y = y;
        this.rotation.z = z;
        this._dirty = true;
        return this;
    }
    
    setScale(x, y, z) {
        this.scale.x = x;
        this.scale.y = y;
        this.scale.z = z;
        this._dirty = true;
        return this;
    }
    
    /**
     * 获取模型矩阵
     * Model = T × Rz × Ry × Rx × S
     */
    getMatrix() {
        if (this._dirty) {
            const { position: p, rotation: r, scale: s } = this;
            
            // 构建各个变换矩阵
            const T = Mat4.translation(p.x, p.y, p.z);
            const Rx = Mat4.rotationX(r.x);
            const Ry = Mat4.rotationY(r.y);
            const Rz = Mat4.rotationZ(r.z);
            const S = Mat4.scaling(s.x, s.y, s.z);
            
            // 组合: T × Rz × Ry × Rx × S
            let result = S;
            result = Mat4.multiply(Rx, result);
            result = Mat4.multiply(Ry, result);
            result = Mat4.multiply(Rz, result);
            result = Mat4.multiply(T, result);
            
            this._matrix = result;
            this._dirty = false;
        }
        
        return this._matrix;
    }
}

// 使用示例
const cubeTransform = new Transform();
cubeTransform
    .setPosition(3, 0, 0)
    .setRotation(0, Math.PI / 4, 0)
    .setScale(2, 2, 2);

const modelMatrix = cubeTransform.getMatrix();

6.5 视图矩阵(View Matrix)

6.5.1 视图矩阵的作用

视图矩阵 = 将世界空间变换到相机空间

等价于:将整个世界移动,使相机位于原点

相机看向 -Z 方向的约定(OpenGL/WebGL 惯例):
- 相机位置 → 原点 (0, 0, 0)
- 相机看向 → -Z 方向
- 相机上方 → +Y 方向
- 相机右方 → +X 方向


视图矩阵 = 相机变换的逆矩阵

如果相机在位置 P,旋转 R:
相机矩阵 C = T(P) × R
视图矩阵 V = C⁻¹ = R⁻¹ × T(-P)

6.5.2 LookAt 矩阵

LookAt 是构建视图矩阵最常用的方法:

LookAt 矩阵构建

输入:
- eye: 相机位置
- target: 相机看向的点
- up: 世界空间的"上"方向(通常是 (0, 1, 0))

构建过程:

Step 1: 计算相机的三个轴向量

    forward (f) = normalize(eye - target)  // 相机看向的反方向(因为看向 -Z)
    right (r) = normalize(cross(up, f))    // 相机右方
    up (u) = cross(f, r)                    // 相机真正的上方

              u (相机上)



      ────────●────→ r (相机右)
             ╱  eye

           ↓ f (相机后 / 看向的反方向)
          
          target 在 -f 方向


Step 2: 构建视图矩阵

视图矩阵 = 旋转部分 × 平移部分

     ┌ rx  ry  rz  0 ┐   ┌ 1  0  0 -eye.x ┐
V =  │ ux  uy  uz  0 │ × │ 0  1  0 -eye.y │
     │ fx  fy  fz  0 │   │ 0  0  1 -eye.z │
     └ 0   0   0   1 ┘   └ 0  0  0    1   ┘

简化后:
     ┌ rx   ry   rz  -dot(r, eye) ┐
V =  │ ux   uy   uz  -dot(u, eye) │
     │ fx   fy   fz  -dot(f, eye) │
     └ 0    0    0        1       ┘
javascript
/**
 * LookAt 矩阵实现
 */
function lookAt(eye, target, up) {
    // 计算 forward(相机看向的反方向)
    const f = normalize({
        x: eye.x - target.x,
        y: eye.y - target.y,
        z: eye.z - target.z
    });
    
    // 计算 right
    const r = normalize(cross(up, f));
    
    // 计算真正的 up
    const u = cross(f, r);
    
    // 构建视图矩阵(列优先)
    return new Float32Array([
        r.x, u.x, f.x, 0,
        r.y, u.y, f.y, 0,
        r.z, u.z, f.z, 0,
        -dot(r, eye), -dot(u, eye), -dot(f, eye), 1
    ]);
}

// 向量工具函数
function normalize(v) {
    const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
    return { x: v.x / len, y: v.y / len, z: v.z / len };
}

function cross(a, b) {
    return {
        x: a.y * b.z - a.z * b.y,
        y: a.z * b.x - a.x * b.z,
        z: a.x * b.y - a.y * b.x
    };
}

function dot(a, b) {
    return a.x * b.x + a.y * b.y + a.z * b.z;
}


// 使用示例
const viewMatrix = lookAt(
    { x: 0, y: 5, z: 10 },   // 相机位置
    { x: 0, y: 0, z: 0 },    // 看向原点
    { x: 0, y: 1, z: 0 }     // 上方向
);

6.6 投影矩阵(Projection Matrix)

6.6.1 透视投影 vs 正交投影

两种投影方式对比

透视投影(Perspective):
──────────────────────────────────
模拟人眼视觉,近大远小

              近平面    远平面
        eye →  │╲       ╱│
               │ ╲     ╱ │
               │  ╲   ╱  │
               │   ╲ ╱   │
               │    ╳    │
               │   ╱ ╲   │
               │  ╱   ╲  │
               │ ╱     ╲ │
               │╱       ╲│

- 有"消失点"
- 3D 游戏、模拟真实场景


正交投影(Orthographic):
──────────────────────────────────
平行投影,没有透视效果

               │        │
        eye →  │ ────── │
               │ │    │ │
               │ │    │ │
               │ │    │ │
               │ │    │ │
               │ ────── │
               │        │
              近平面   远平面

- 平行线保持平行
- 2D 游戏、CAD、技术绘图

6.6.2 透视投影矩阵

透视投影的参数

                    top
           ╱────────────────╲
          ╱        ↑         ╲
         ╱         │ fov/2    ╲
        ╱          │           ╲
       ╱    ●──────┼────────────╲
      ╱   near     │              ╲
     ╱             │               ╲
    ╱              │                ╲
   ╱ left          │           right ╲
  ╱                │                  ╲
 ╱─────────────────●───────────────────╲
                  eye                   far

参数:
- fov (Field of View): 视野角度(垂直方向)
- aspect: 宽高比 = width / height
- near: 近平面距离
- far: 远平面距离


透视投影矩阵公式:

f = 1 / tan(fov / 2)

     ┌ f/aspect   0        0                   0      ┐
P =  │    0       f        0                   0      │
     │    0       0   (far+near)/(near-far)   -1      │
     └    0       0   (2*far*near)/(near-far)  0      ┘

注意:第4列是 (0, 0, -1, 0),这导致 w = -z
透视除法后:x/w = x/-z,y/w = y/-z(近大远小)
javascript
/**
 * 透视投影矩阵
 * @param fovRadians 垂直视野角度(弧度)
 * @param aspect 宽高比
 * @param near 近平面
 * @param far 远平面
 */
function perspective(fovRadians, aspect, near, far) {
    const f = 1 / Math.tan(fovRadians / 2);
    const rangeInv = 1 / (near - far);
    
    return new Float32Array([
        f / aspect, 0, 0, 0,
        0, f, 0, 0,
        0, 0, (far + near) * rangeInv, -1,
        0, 0, 2 * far * near * rangeInv, 0
    ]);
}

// 使用示例
const projectionMatrix = perspective(
    Math.PI / 4,     // 45° 视野
    canvas.width / canvas.height,  // 宽高比
    0.1,             // 近平面
    100              // 远平面
);

6.6.3 正交投影矩阵

正交投影的参数

    top ───────────────────────
       │                      │
       │                      │
  left │         ●            │ right
       │        eye           │
       │                      │
       │                      │
 bottom ───────────────────────
        near                 far


正交投影矩阵公式:

     ┌ 2/(r-l)     0          0        -(r+l)/(r-l) ┐
P =  │    0     2/(t-b)       0        -(t+b)/(t-b) │
     │    0        0      -2/(f-n)     -(f+n)/(f-n) │
     └    0        0          0              1      ┘

r = right, l = left, t = top, b = bottom, f = far, n = near
javascript
/**
 * 正交投影矩阵
 */
function orthographic(left, right, bottom, top, near, far) {
    return new Float32Array([
        2 / (right - left), 0, 0, 0,
        0, 2 / (top - bottom), 0, 0,
        0, 0, -2 / (far - near), 0,
        -(right + left) / (right - left),
        -(top + bottom) / (top - bottom),
        -(far + near) / (far - near),
        1
    ]);
}

// 使用示例(2D 场景)
const orthoMatrix = orthographic(
    0, canvas.width,   // left, right
    canvas.height, 0,  // bottom, top(注意 Y 轴翻转)
    -1, 1              // near, far
);

6.7 完整的相机实现

6.7.1 基础相机类

javascript
/**
 * 基础相机类
 */
class Camera {
    constructor() {
        this.position = { x: 0, y: 0, z: 5 };
        this.target = { x: 0, y: 0, z: 0 };
        this.up = { x: 0, y: 1, z: 0 };
        
        // 投影参数
        this.fov = Math.PI / 4;  // 45°
        this.aspect = 1;
        this.near = 0.1;
        this.far = 100;
        
        this._viewMatrix = null;
        this._projectionMatrix = null;
        this._viewProjectionMatrix = null;
        this._dirty = true;
    }
    
    setPosition(x, y, z) {
        this.position = { x, y, z };
        this._dirty = true;
        return this;
    }
    
    setTarget(x, y, z) {
        this.target = { x, y, z };
        this._dirty = true;
        return this;
    }
    
    setAspect(aspect) {
        this.aspect = aspect;
        this._projectionMatrix = null;
        return this;
    }
    
    getViewMatrix() {
        if (this._dirty || !this._viewMatrix) {
            this._viewMatrix = lookAt(this.position, this.target, this.up);
            this._viewProjectionMatrix = null;
        }
        return this._viewMatrix;
    }
    
    getProjectionMatrix() {
        if (!this._projectionMatrix) {
            this._projectionMatrix = perspective(
                this.fov,
                this.aspect,
                this.near,
                this.far
            );
            this._viewProjectionMatrix = null;
        }
        return this._projectionMatrix;
    }
    
    getViewProjectionMatrix() {
        if (!this._viewProjectionMatrix) {
            this._viewProjectionMatrix = Mat4.multiply(
                this.getProjectionMatrix(),
                this.getViewMatrix()
            );
            this._dirty = false;
        }
        return this._viewProjectionMatrix;
    }
}

6.7.2 轨道相机(Orbit Camera)

javascript
/**
 * 轨道相机
 * 围绕目标点旋转
 */
class OrbitCamera extends Camera {
    constructor() {
        super();
        
        // 球面坐标
        this.theta = 0;          // 水平角度(绕 Y 轴)
        this.phi = Math.PI / 4;  // 垂直角度(从 Y 轴)
        this.distance = 5;       // 到目标的距离
        
        this.minDistance = 1;
        this.maxDistance = 50;
        this.minPhi = 0.1;
        this.maxPhi = Math.PI - 0.1;
        
        this.updatePosition();
    }
    
    /**
     * 从球面坐标计算相机位置
     * 
     *         Y
     *         ↑
     *         │  ╱ 相机
     *         │ ╱
     *         │╱ phi(从Y轴的角度)
     * ────────●───────→ X
     *        ╱│
     *       ╱ │
     *      ╱  │
     *     Z
     *    theta(绕Y轴的角度)
     */
    updatePosition() {
        const x = this.distance * Math.sin(this.phi) * Math.sin(this.theta);
        const y = this.distance * Math.cos(this.phi);
        const z = this.distance * Math.sin(this.phi) * Math.cos(this.theta);
        
        this.position.x = this.target.x + x;
        this.position.y = this.target.y + y;
        this.position.z = this.target.z + z;
        
        this._dirty = true;
    }
    
    /**
     * 旋转(鼠标拖动)
     */
    rotate(deltaTheta, deltaPhi) {
        this.theta += deltaTheta;
        this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi + deltaPhi));
        this.updatePosition();
    }
    
    /**
     * 缩放(鼠标滚轮)
     */
    zoom(delta) {
        this.distance = Math.max(
            this.minDistance,
            Math.min(this.maxDistance, this.distance * (1 + delta))
        );
        this.updatePosition();
    }
    
    /**
     * 平移(中键拖动)
     */
    pan(deltaX, deltaY) {
        // 计算相机的右向量和上向量
        const forward = normalize({
            x: this.target.x - this.position.x,
            y: this.target.y - this.position.y,
            z: this.target.z - this.position.z
        });
        const right = normalize(cross(this.up, forward));
        const up = cross(forward, right);
        
        // 移动目标点
        this.target.x += right.x * deltaX + up.x * deltaY;
        this.target.y += right.y * deltaX + up.y * deltaY;
        this.target.z += right.z * deltaX + up.z * deltaY;
        
        this.updatePosition();
    }
}

6.7.3 添加鼠标控制

javascript
/**
 * 相机控制器
 */
class CameraController {
    constructor(camera, canvas) {
        this.camera = camera;
        this.canvas = canvas;
        
        this.isRotating = false;
        this.isPanning = false;
        this.lastX = 0;
        this.lastY = 0;
        
        this.rotateSpeed = 0.005;
        this.panSpeed = 0.01;
        this.zoomSpeed = 0.001;
        
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        const canvas = this.canvas;
        
        // 鼠标按下
        canvas.addEventListener('mousedown', (e) => {
            if (e.button === 0) {  // 左键
                this.isRotating = true;
            } else if (e.button === 1 || e.button === 2) {  // 中键或右键
                this.isPanning = true;
            }
            this.lastX = e.clientX;
            this.lastY = e.clientY;
            e.preventDefault();
        });
        
        // 鼠标移动
        canvas.addEventListener('mousemove', (e) => {
            const deltaX = e.clientX - this.lastX;
            const deltaY = e.clientY - this.lastY;
            
            if (this.isRotating && this.camera.rotate) {
                this.camera.rotate(
                    -deltaX * this.rotateSpeed,
                    deltaY * this.rotateSpeed
                );
            }
            
            if (this.isPanning && this.camera.pan) {
                this.camera.pan(
                    -deltaX * this.panSpeed * this.camera.distance,
                    deltaY * this.panSpeed * this.camera.distance
                );
            }
            
            this.lastX = e.clientX;
            this.lastY = e.clientY;
        });
        
        // 鼠标释放
        window.addEventListener('mouseup', () => {
            this.isRotating = false;
            this.isPanning = false;
        });
        
        // 滚轮缩放
        canvas.addEventListener('wheel', (e) => {
            if (this.camera.zoom) {
                this.camera.zoom(e.deltaY * this.zoomSpeed);
            }
            e.preventDefault();
        });
        
        // 禁用右键菜单
        canvas.addEventListener('contextmenu', (e) => e.preventDefault());
    }
}

// 使用
const camera = new OrbitCamera();
const controller = new CameraController(camera, canvas);

6.8 在着色器中使用 MVP 矩阵

6.8.1 完整的顶点着色器

glsl
// 顶点着色器
attribute vec3 a_position;
attribute vec3 a_normal;
attribute vec2 a_texCoord;

uniform mat4 u_modelMatrix;
uniform mat4 u_viewMatrix;
uniform mat4 u_projectionMatrix;

// 或者直接传入组合后的矩阵
// uniform mat4 u_modelViewMatrix;     // View × Model
// uniform mat4 u_mvpMatrix;           // Projection × View × Model

varying vec3 v_worldPosition;
varying vec3 v_normal;
varying vec2 v_texCoord;

void main() {
    // 计算世界空间位置
    vec4 worldPos = u_modelMatrix * vec4(a_position, 1.0);
    v_worldPosition = worldPos.xyz;
    
    // 计算世界空间法线(需要使用法线矩阵)
    // 简化版本(假设没有非均匀缩放)
    v_normal = (u_modelMatrix * vec4(a_normal, 0.0)).xyz;
    
    // 传递纹理坐标
    v_texCoord = a_texCoord;
    
    // 计算最终位置
    gl_Position = u_projectionMatrix * u_viewMatrix * worldPos;
}

6.8.2 JavaScript 端传递矩阵

javascript
function render() {
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    
    // 更新相机
    camera.setAspect(canvas.width / canvas.height);
    const viewMatrix = camera.getViewMatrix();
    const projectionMatrix = camera.getProjectionMatrix();
    
    // 传递矩阵
    gl.uniformMatrix4fv(
        gl.getUniformLocation(program, 'u_viewMatrix'),
        false,
        viewMatrix
    );
    
    gl.uniformMatrix4fv(
        gl.getUniformLocation(program, 'u_projectionMatrix'),
        false,
        projectionMatrix
    );
    
    // 渲染每个物体
    objects.forEach(obj => {
        const modelMatrix = obj.transform.getMatrix();
        
        gl.uniformMatrix4fv(
            gl.getUniformLocation(program, 'u_modelMatrix'),
            false,
            modelMatrix
        );
        
        // 绑定 VAO、绘制...
        obj.draw(gl);
    });
    
    requestAnimationFrame(render);
}

6.9 法线矩阵

6.9.1 为什么需要法线矩阵?

法线变换的问题

当模型有非均匀缩放时,直接用模型矩阵变换法线会出错!

原始情况(正方形):
      N

  ┌───┼───┐
  │   │   │  法线 N 垂直于表面 ✓
  │   ●───┼→
  │       │
  └───────┘


非均匀缩放后(X 方向缩放 2 倍):
          N'

  ┌───────────────┐
  │       │       │  如果直接变换法线
  │       ●───────┼→ N' 仍然指向上方
  │               │  但实际应该倾斜!
  └───────────────┘

正确的法线应该考虑表面的倾斜!


解决方案:法线矩阵 = (M⁻¹)ᵀ

法线矩阵 = 模型矩阵的逆矩阵的转置
NormalMatrix = transpose(inverse(ModelMatrix))

对于只有旋转的变换:法线矩阵 = 模型矩阵(旋转矩阵的逆等于转置)
对于均匀缩放:法线矩阵 = 模型矩阵(缩放被抵消)
javascript
/**
 * 计算法线矩阵
 */
function getNormalMatrix(modelMatrix) {
    // 计算 4×4 矩阵的逆
    const inverse = Mat4.inverse(modelMatrix);
    
    // 计算转置
    const normalMatrix = Mat4.transpose(inverse);
    
    return normalMatrix;
}

// 或者提取 3×3 部分
function getNormalMatrix3x3(modelMatrix) {
    const m = modelMatrix;
    
    // 提取左上 3×3
    const m3 = new Float32Array([
        m[0], m[1], m[2],
        m[4], m[5], m[6],
        m[8], m[9], m[10]
    ]);
    
    // 计算 3×3 逆转置...
    return Mat3.transpose(Mat3.inverse(m3));
}
glsl
// 在着色器中使用法线矩阵
uniform mat3 u_normalMatrix;  // 3×3 版本

void main() {
    // 正确变换法线
    v_normal = normalize(u_normalMatrix * a_normal);
    // ...
}

6.10 使用矩阵库(glMatrix)

手动实现矩阵运算容易出错,推荐使用成熟的库:

javascript
// 使用 gl-matrix 库
import { mat4, vec3 } from 'gl-matrix';

// 创建矩阵
const modelMatrix = mat4.create();
const viewMatrix = mat4.create();
const projectionMatrix = mat4.create();

// 设置模型变换
mat4.identity(modelMatrix);
mat4.translate(modelMatrix, modelMatrix, [3, 0, 0]);
mat4.rotateY(modelMatrix, modelMatrix, Math.PI / 4);
mat4.scale(modelMatrix, modelMatrix, [2, 2, 2]);

// 设置视图矩阵
mat4.lookAt(
    viewMatrix,
    [0, 5, 10],    // eye
    [0, 0, 0],     // target
    [0, 1, 0]      // up
);

// 设置投影矩阵
mat4.perspective(
    projectionMatrix,
    Math.PI / 4,   // fov
    canvas.width / canvas.height,  // aspect
    0.1,           // near
    100            // far
);

// 计算 MVP
const mvpMatrix = mat4.create();
mat4.multiply(mvpMatrix, viewMatrix, modelMatrix);
mat4.multiply(mvpMatrix, projectionMatrix, mvpMatrix);

// 传递给着色器
gl.uniformMatrix4fv(mvpLocation, false, mvpMatrix);

6.11 本章小结

核心概念

概念说明
局部空间物体自身坐标系
世界空间场景全局坐标系
观察空间以相机为原点的坐标系
裁剪空间投影后的齐次坐标空间
模型矩阵局部 → 世界变换
视图矩阵世界 → 相机变换
投影矩阵相机 → 裁剪变换
MVPP × V × M 组合矩阵

关键公式

gl_Position = Projection × View × Model × vec4(position, 1.0)

透视投影: 近大远小,有消失点
正交投影: 平行投影,无透视

法线矩阵 = transpose(inverse(ModelMatrix))

6.12 练习题

基础练习

  1. 实现一个旋转的立方体(只使用模型矩阵)

  2. 实现基于 lookAt 的视图矩阵,相机围绕原点旋转

  3. 对比透视投影和正交投影的效果

进阶练习

  1. 实现完整的轨道相机控制(旋转、缩放、平移)

  2. 实现第一人称相机控制(WASD + 鼠标)

挑战练习

  1. 实现多视口渲染(同一场景从不同角度观察)

下一章预告:在第7章中,我们将学习光照和材质,让 3D 物体拥有真实的光影效果。


文档版本:v1.0
字数统计:约 13,000 字
代码示例:40+ 个

如有转载或 CV 的请标注本站原文地址