第 18 章:相机与投影变换

欢迎来到 3D 渲染中最核心的章节之一!

想象一下:你已经学会了如何在 3D 世界中创建物体、给它们上色、让它们旋转。但如果没有"相机",你就像一位画家闭着眼睛作画——你知道作品在那里,却永远无法欣赏它。

这一章,我们要回答一个根本问题:3D 世界是如何被"看到"并变成屏幕上的 2D 图像的?

让我们像摄影师一样,一步步搭建起从 3D 到 2D 的完整桥梁。


本章路线图

3D 世界中的物体
    ↓
[模型变换 Model]  —— 把物体放到世界中的正确位置
    ↓
[视图变换 View]   —— 从相机的角度看世界(本章重点)
    ↓
[投影变换 Projection] —— 把 3D 场景压扁到 2D(本章重点)
    ↓
[视口变换 Viewport] —— 映射到屏幕像素
    ↓
屏幕上的 2D 图像

1. 什么是 3D 图形中的"相机"?

1.1 现实生活中的类比

想象你是一位摄影师,站在山顶拍摄日出。

你手里拿着一台真实的相机,它有几个关键属性:

  • 位置:你站在山顶的哪个坐标?
  • 朝向:你面向东方(日出方向)还是西方?
  • 上方向:你的头顶指向天空的哪个方向?(这决定了照片不会拍歪)

在 3D 图形中,"相机"并不是一台真实的物理设备,而是一个数学抽象。它本质上是一个坐标系——一个定义了"观察者站在哪里、看向哪里"的参考框架。

1.2 本质:相机就是一个坐标系

世界坐标系(World Space)
    Y
    │
    │    🏠 房子在世界中的某个位置
    │
    └──────────→ X
   /
  Z

相机坐标系(Camera Space / View Space)
    注意:这里 Y 朝上,Z 指向屏幕外(或内,取决于约定)

    相机的"上"方向
         ↑ Y
         │
    ←────┼────→ X  (相机的"右"方向)
         │
         ↓ Z  (相机的"前"方向,指向被拍摄物体)

核心洞察:3D 图形中的相机,本质上是一组定义了观察方向的三个正交单位向量(右、上、前)加上一个位置点。它没有镜头、没有快门、没有感光元件——只有数学。

1.3 代码示例:最简单的"相机"

让我们看看 cylinder.html 中最基础的相机设置:

// 文件: akira-graphics/3d-camera/cylinder.html

// 这个投影矩阵实际上是一个"什么都不做"的默认投影
// 它把 Z 轴翻转(因为 WebGL 的裁剪空间 Z 是朝内的)
renderer.uniforms.projectionMatrix = [
  1, 0, 0, 0,   // 第 1 列: X 不变
  0, 1, 0, 0,   // 第 2 列: Y 不变
  0, 0, -1, 0,  // 第 3 列: Z 取反(WebGL 约定)
  0, 0, 0, 1,   // 第 4 列: 齐次坐标
];

// 设置相机位置
function updateCamera(eye, target = [0, 0, 0]) {
  const [x, y, z] = eye;
  const m = new Mat4(
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    x, y, z, 1,  // 最后一行是平移
  );
  const up = [0, 1, 0];  // 世界的"上"是 Y 轴正方向
  m.lookAt(eye, target, up).inverse();  // 关键!取逆矩阵
  renderer.uniforms.viewMatrix = m;
}

updateCamera([0.5, 0, 0.5]); // 相机站在 (0.5, 0, 0.5),看向原点

1.4 常见误区

误区 1:"相机在 3D 中是一个物体模型"

不对。虽然在某些 3D 编辑器中你会看到相机的小图标,但在渲染管线中,相机不是一个被渲染的物体。它只是一组数学参数(位置、朝向),用来计算变换矩阵。

误区 2:"移动相机和移动世界是一样的"

这是一个深刻且正确的直觉!在数学上,把相机向右移动 1 单位,等价于把整个世界向左移动 1 单位。这就是为什么 View 矩阵是相机变换的逆矩阵——我们不是移动相机,而是移动整个世界来"迎合"相机。

误区 3:"相机有焦距、光圈等物理属性"

在基础 3D 渲染中,这些概念被大大简化了。"焦距"对应的是 FOV(视野角度),"光圈"对应的是 near/far 裁剪面。没有景深、没有散景——除非你专门实现这些高级效果。

1.5 动手试一试

打开 cylinder.html,尝试修改相机位置:

// 从不同的角度观察圆柱体
updateCamera([2, 2, 2]);    // 从斜上方看
updateCamera([0, 0, 2]);    // 从正前方看
updateCamera([0, 2, 0]);    // 从正上方看(俯视图)
updateCamera([-2, 1, 1]);   // 从另一侧看

观察圆柱体的蓝色顶盖和灰色侧面在不同角度下的可见性变化。


2. 视图矩阵(View Matrix):从世界空间到相机空间

2.1 现实生活中的类比

想象你坐在一列行驶的火车上,看着窗外的风景。

从你的视角:

  • 树木在向后飞退
  • 远处的山移动得很慢
  • 近处的电线杆一闪而过

但从地面观察者的视角:

  • 火车在向前行驶
  • 树木是静止的
  • 山也是静止的

关键洞察:运动是相对的。在 3D 渲染中,我们不移动相机——我们移动整个世界,让它相对于相机静止下来。

2.2 本质:视图矩阵是世界变换的逆

如果相机的变换矩阵是 $M_{camera}$(把相机从原点移动到世界中的某个位置并旋转到某个朝向),那么视图矩阵就是:

$$V = M_{camera}^{-1}$$

为什么?因为:

世界坐标 × V = 相机坐标

也就是说:
世界坐标 × M_camera^{-1} = 相机坐标

验证:
相机原点(0,0,0) × M_camera = 相机在世界中的位置
相机在世界中的位置 × M_camera^{-1} = 相机原点(0,0,0) ✓

2.3 LookAt 矩阵的推导

LookAt 是构建视图矩阵最常用的方法。它需要三个向量:

参数 含义 类比
eye 相机在世界中的位置 你站在哪里
center / target 相机看向哪里 你的眼睛盯着哪个点
up 哪个方向是"上" 你的头顶指向哪里

步骤 1:计算相机的三个轴

前向轴(Forward / Z轴):  f = normalize(eye - target)
   // 注意:这里用 eye - target,因为相机看向的是 -Z 方向

右向轴(Right / X轴):    r = normalize(up × f)
   // 叉积:up 叉乘 forward,得到垂直于两者的右方向

上向轴(Up / Y轴):       u = f × r
   // 再次叉积,确保三个轴严格正交

叉积的几何意义:两个向量的叉积产生一个垂直于它们所在平面的新向量。右手定则:食指指向第一个向量,中指指向第二个向量,拇指就是叉积方向。

步骤 2:构建旋转矩阵

这三个正交单位向量 $r, u, f$ 构成了相机坐标系的基向量。旋转矩阵就是把世界坐标投影到相机坐标系:

$$R = \begin{bmatrix} r_x & r_y & r_z & 0 \ u_x & u_y & u_z & 0 \ f_x & f_y & f_z & 0 \ 0 & 0 & 0 & 1 \end{bmatrix}$$

步骤 3:加入平移

相机不在原点,所以需要先平移,让 eye 位置移动到原点:

$$T = \begin{bmatrix} 1 & 0 & 0 & 0 \ 0 & 1 & 0 & 0 \ 0 & 0 & 1 & 0 \ -eye_x & -eye_y & -eye_z & 1 \end{bmatrix}$$

步骤 4:完整的 LookAt 矩阵

$$View = T \times R = \begin{bmatrix} r_x & r_y & r_z & 0 \ u_x & u_y & u_z & 0 \ f_x & f_y & f_z & 0 \ -(r \cdot eye) & -(u \cdot eye) & -(f \cdot eye) & 1 \end{bmatrix}$$

2.4 代码实现

// 来自 Mat4Func.js 的 targetTo 函数
export function targetTo(out, eye, target, up) {
  // Step 1: 计算前向轴 Z
  let z0 = eye[0] - target[0];
  let z1 = eye[1] - target[1];
  let z2 = eye[2] - target[2];

  // 归一化
  let len = Math.sqrt(z0 * z0 + z1 * z1 + z2 * z2);
  z0 *= 1 / len;
  z1 *= 1 / len;
  z2 *= 1 / len;

  // Step 2: 计算右向轴 X = up × Z
  let x0 = up[1] * z2 - up[2] * z1;
  let x1 = up[2] * z0 - up[0] * z2;
  let x2 = up[0] * z1 - up[1] * z0;

  // 归一化
  len = Math.sqrt(x0 * x0 + x1 * x1 + x2 * x2);
  x0 *= 1 / len;
  x1 *= 1 / len;
  x2 *= 1 / len;

  // Step 3: 计算上向轴 Y = Z × X(重新正交化)
  let y0 = z1 * x2 - z2 * x1;
  let y1 = z2 * x0 - z0 * x2;
  let y2 = z0 * x1 - z1 * x0;

  // Step 4: 填充矩阵(注意:这是相机变换矩阵,不是视图矩阵)
  out[0] = x0;   out[1] = y0;   out[2] = z0;   out[3] = 0;
  out[4] = x1;   out[5] = y1;   out[6] = z1;   out[7] = 0;
  out[8] = x2;   out[9] = y2;   out[10] = z2;  out[11] = 0;
  out[12] = eye[0];  out[13] = eye[1];  out[14] = eye[2];  out[15] = 1;

  return out;
}

注意:这个函数生成的是相机在世界中的变换矩阵。要得到视图矩阵,需要取逆:

// 在 cylinder.html 中
const m = new Mat4(...);
m.lookAt(eye, target, up).inverse();  // ← 关键:取逆!
renderer.uniforms.viewMatrix = m;

2.5 在 Vertex Shader 中的应用

// 顶点着色器中
uniform mat4 viewMatrix;      // 把世界坐标转到相机坐标
uniform mat4 modelMatrix;     // 把模型坐标转到世界坐标

void main() {
  // 先进行模型变换(物体放到世界中),再进行视图变换(世界转到相机视角)
  vec4 worldPos = modelMatrix * vec4(a_vertexPosition, 1.0);
  vec4 cameraPos = viewMatrix * worldPos;
  // ...
}

注意矩阵乘法的顺序!从右往左读:viewMatrix * modelMatrix * position 意味着先 model,再 view。

2.6 常见误区

误区:"LookAt 矩阵就是视图矩阵"

不完全是。lookAt(eye, target, up) 生成的是把相机从原点变换到 eye 位置的矩阵。视图矩阵是这个矩阵的。因为我们要做的是"把世界变换到相机的坐标系中",而不是"把相机放到世界中"。

误区:"up 向量必须是 [0, 1, 0]"

不一定。[0, 1, 0] 只是最常见的选择(Y 轴朝上)。但如果相机本身是朝上的(比如从正下方往上看),你需要提供一个不同的 up 向量,否则叉积会产生零向量导致错误。在 targetTo 函数中,代码已经处理了这种退化情况:

if (len === 0) {
  // up 和 z 平行,稍微扰动一下 up
  upx += 1e-6;
}

2.7 动手试一试

// 尝试不同的 up 向量,观察效果
updateCamera([2, 2, 3], [0, 0, 0]);  // 默认 up = [0, 1, 0]

// 如果相机在正上方往下看呢?
// 此时 forward = [0, -1, 0],和 up 平行,需要特殊处理
updateCamera([0, 5, 0], [0, 0, 0]);  // 从正上方俯视

3. 正交投影(Orthographic Projection):平行线永不相交

3.1 现实生活中的类比

想象你是一位建筑工程师,需要绘制一栋大楼的工程图纸。

在工程制图中,你使用正交投影

  • 所有平行线保持平行
  • 不管物体多远,大小都不变
  • 没有"近大远小"的透视效果
  • 就像从无限远处用望远镜观察

这非常适合 CAD 软件、2D 游戏、地图渲染、以及任何需要精确测量的场景。

3.2 本质:把视景体压缩成一个立方体

正交投影把一个长方体视景体(由 left, right, bottom, top, near, far 定义)映射到标准化的裁剪立方体 $[-1, 1]^3$:

世界空间中的正交视景体          裁剪空间中的立方体

      top                          1
       │                           │
  left ├──── right      →    -1 ──┼─── 1
       │ bottom                     │
      near/far                    -1

(Z 轴类似:near 映射到 -1,far 映射到 1)

3.3 公式推导

我们需要把 $[left, right]$ 映射到 $[-1, 1]$,把 $[bottom, top]$ 映射到 $[-1, 1]$,把 $[near, far]$ 映射到 $[-1, 1]$。

X 轴映射:

先把 $[left, right]$ 缩放到长度 2: $$scale_x = \frac{2}{right - left}$$

再平移使中点对齐到 0: $$translate_x = -\frac{right + left}{right - left}$$

所以: $$x_{clip} = \frac{2x}{right - left} - \frac{right + left}{right - left} = \frac{2x - (right + left)}{right - left}$$

Y 轴和 Z 轴同理:

$$y_{clip} = \frac{2y - (top + bottom)}{top - bottom}$$

$$z_{clip} = \frac{2z - (far + near)}{far - near}$$

写成矩阵形式:

$$P_{ortho} = \begin{bmatrix} \frac{2}{right-left} & 0 & 0 & 0 \ 0 & \frac{2}{top-bottom} & 0 & 0 \ 0 & 0 & \frac{-2}{far-near} & 0 \ -\frac{right+left}{right-left} & -\frac{top+bottom}{top-bottom} & -\frac{far+near}{far-near} & 1 \end{bmatrix}$$

注意 Z 轴的缩放因子是负数!这是因为在 WebGL 中,裁剪空间的 Z 轴朝内(指向屏幕内部),而通常我们认为 near 到 far 是朝外的。这个负号完成了坐标系的翻转。

3.4 代码实现

// 来自 Mat4Func.js
export function ortho(out, left, right, bottom, top, near, far) {
  let lr = 1 / (left - right);    // = -1 / (right - left)
  let bt = 1 / (bottom - top);    // = -1 / (top - bottom)
  let nf = 1 / (near - far);      // = -1 / (far - near)

  out[0]  = -2 * lr;              // 2 / (right - left)
  out[1]  = 0;
  out[2]  = 0;
  out[3]  = 0;

  out[4]  = 0;
  out[5]  = -2 * bt;              // 2 / (top - bottom)
  out[6]  = 0;
  out[7]  = 0;

  out[8]  = 0;
  out[9]  = 0;
  out[10] = 2 * nf;               // -2 / (far - near)
  out[11] = 0;

  out[12] = (left + right) * lr;  // -(right+left)/(right-left)
  out[13] = (top + bottom) * bt;  // -(top+bottom)/(top-bottom)
  out[14] = (far + near) * nf;    // -(far+near)/(far-near)
  out[15] = 1;

  return out;
}

3.5 在 Demo 中使用

// 文件: cylinder-ortho.html
function projection(left, right, bottom, top, near, far) {
  return ortho([], left, right, bottom, top, near, far);
}

// 创建一个对称的正交投影
// 可见范围:X∈[-2,2], Y∈[-2,2], Z∈[-2,2]
const projectionMatrix = projection(-2, 2, -2, 2, -2, 2);
renderer.uniforms.projectionMatrix = projectionMatrix;

3.6 常见误区

误区:"正交投影是 2D 的"

不是。正交投影仍然是 3D 的——它有 depth buffer(深度缓冲),可以正确处理遮挡关系。物体仍然有前后之分,只是没有透视缩短。你可以把它理解为"3D 场景的一种特殊观察方式"。

误区:"正交投影中远近物体一样大"

对,但这不是因为"没有 Z 轴"。而是因为所有光线被假设为平行于视线方向。在数学上,Z 坐标仍然被保留并用于深度测试,只是 X 和 Y 的缩放不随 Z 变化。

3.7 动手试一试

// 尝试不同的正交投影参数
projection(-1, 1, -1, 1, -1, 1);   // 窄视野 = 放大效果
projection(-5, 5, -5, 5, -5, 5);   // 宽视野 = 缩小效果
projection(-2, 2, -1, 1, -2, 2);   // 非对称,Y 方向被压缩

// 把 near/far 调得很近,观察裁剪效果
projection(-2, 2, -2, 2, -0.5, 0.5);  // 大部分物体被裁掉了!

4. 透视投影(Perspective Projection):近大远小

4.1 现实生活中的类比

站在铁轨上,看向远方。你会发现:

  • 近处的铁轨枕木看起来很宽
  • 远处的枕木越来越窄
  • 最终两条平行的铁轨似乎在"无穷远处"相交于一点

这就是透视投影——我们日常生活中眼睛看到的真实效果。

4.2 本质:模拟针孔相机

透视投影模拟了一个针孔相机模型:

        物体
          │
          │ 光线
          ▼
    ═════════════  近裁剪面 (near plane)
          │\    /
          │ \  /   视锥体 (Frustum)
          │  \/
          │  /\
          │ /  \
         针孔 (eye/camera)

关键参数:

参数 含义 类比
fov 垂直视野角度 你眼睛的"广角"程度
aspect 宽高比 屏幕是宽屏还是方屏
near 近裁剪面距离 相机多近的东西能被看到
far 远裁剪面距离 相机多远的东西能被看到

4.3 公式推导

步骤 1:从 FOV 推导缩放因子

想象一个截面图:

         │
         │  fov/2
         │ /
    ─────┼/───── near 平面
         │\
         │ \
        相机

在 near 平面上,从中心到顶边的距离是 $near \times \tan(fov/2)$。

为了让这个距离映射到裁剪空间的 $y = 1$,我们需要缩放因子:

$$f = \frac{1}{\tan(fov/2)}$$

这就是 Y 方向的缩放。

步骤 2:考虑宽高比

X 方向的视野由宽高比决定。如果屏幕很宽(aspect > 1),X 方向的缩放要更大:

$$scale_x = \frac{f}{aspect}$$

$$scale_y = f$$

步骤 3:Z 轴的映射(非线性!)

这是透视投影最精妙的部分。我们需要把 $z \in [near, far]$ 映射到裁剪空间的 $[-1, 1]$。

但这里有个问题:透视投影后,Z 值会被 $w = -z$ 除(齐次除法)。所以我们需要在除法之前,把 Z 编码成一种特殊形式。

设映射函数为:

$$z_{clip} = \frac{A \cdot z + B}{-z}$$

我们希望:

  • 当 $z = near$ 时,$z_{clip} = -1$
  • 当 $z = far$ 时,$z_{clip} = 1$

解方程组:

$$\frac{A \cdot near + B}{-near} = -1 \Rightarrow A \cdot near + B = near$$

$$\frac{A \cdot far + B}{-far} = 1 \Rightarrow A \cdot far + B = -far$$

相减: $$A(near - far) = near + far$$

$$A = \frac{far + near}{near - far}$$

代回第一个方程: $$B = near - A \cdot near = near - \frac{(far + near) \cdot near}{near - far} = \frac{near(near - far) - near(far + near)}{near - far} = \frac{-2 \cdot far \cdot near}{near - far}$$

步骤 4:完整的透视投影矩阵

$$P_{persp} = \begin{bmatrix} \frac{f}{aspect} & 0 & 0 & 0 \ 0 & f & 0 & 0 \ 0 & 0 & \frac{far+near}{near-far} & -1 \ 0 & 0 & \frac{2 \cdot far \cdot near}{near-far} & 0 \end{bmatrix}$$

注意第 4 列的 $-1$!这意味着变换后 $w = -z$,在齐次除法后会产生透视缩短效果。

4.4 代码实现

// 来自 Mat4Func.js
export function perspective(out, fovy, aspect, near, far) {
  // fovy 是垂直视野角度(弧度)
  let f = 1.0 / Math.tan(fovy / 2);  // 缩放因子
  let nf = 1 / (near - far);

  out[0]  = f / aspect;   // X 缩放(考虑宽高比)
  out[1]  = 0;
  out[2]  = 0;
  out[3]  = 0;

  out[4]  = 0;
  out[5]  = f;            // Y 缩放
  out[6]  = 0;
  out[7]  = 0;

  out[8]  = 0;
  out[9]  = 0;
  out[10] = (far + near) * nf;   // Z 缩放和平移的组合
  out[11] = -1;           // ← 关键!把 Z 复制到 W,产生透视效果

  out[12] = 0;
  out[13] = 0;
  out[14] = (2 * far * near) * nf;  // Z 平移
  out[15] = 0;

  return out;
}

4.5 在 Demo 中使用

// 文件: cylinder-perspective.html
function projection(near = 0.1, far = 100, fov = 45, aspect = 1) {
  // fov 从角度转弧度
  return perspective([], fov * Math.PI / 180, aspect, near, far);
}

const projectionMatrix = projection();
renderer.uniforms.projectionMatrix = projectionMatrix;

// 相机放在斜上方
updateCamera([2, 2, 3]);

4.6 齐次除法:透视的秘密

为什么透视投影矩阵能工作?关键在于齐次除法

在顶点着色器输出 gl_Position 后,GPU 会自动执行:

x_ndc = x_clip / w_clip
y_ndc = y_clip / w_clip
z_ndc = z_clip / w_clip

对于透视投影矩阵:

[x_clip]   [f/aspect  0        0              0    ]   [x]
[y_clip] = [0         f        0              0    ] × [y]
[z_clip]   [0         0   (far+near)/(near-far)  -1  ]   [z]
[w_clip]   [0         0   2·far·near/(near-far)  0  ]   [1]

计算结果:

  • $x_{clip} = (f/aspect) \cdot x$
  • $y_{clip} = f \cdot y$
  • $z_{clip} = \frac{far+near}{near-far} \cdot z - \frac{2 \cdot far \cdot near}{near-far}$ (注意符号,代码里是 +2·far·near·nf)
  • $w_{clip} = -z$

经过齐次除法:

$$x_{ndc} = \frac{x_{clip}}{w_{clip}} = \frac{(f/aspect) \cdot x}{-z} = -\frac{f \cdot x}{aspect \cdot z}$$

看到了吗?$x_{ndc}$ 与 $z$ 成反比!这就是"近大远小"的数学根源。

4.7 常见误区

误区:"透视投影让远处的物体变小"

不完全准确。透视投影本身只是一个矩阵乘法,它不会"让"物体变小。真正产生效果的是随后的齐次除法——除以 $w = -z$。如果 GPU 不做这步除法,就不会有透视效果。

误区:"near 和 far 只是裁剪距离"

它们确实定义了裁剪范围,但同时它们也深度参与了 Z 缓冲的精度分布。near 设得太小会导致 Z-fighting(深度冲突),因为 Z 缓冲的精度在 near 处高、在 far 处低。一个经验法则:near 不要小于 0.1,且尽量让 near:far 的比例不要太悬殊。

误区:"FOV 越大看到越多"

对,但代价是物体会变小。FOV 就像相机的"广角程度"。90° FOV 比 45° 看到更宽的视野,但同样的物体在画面中只占更小的区域。极端情况下,180° FOV 会产生鱼眼效果。

4.8 动手试一试

// 尝试不同的 FOV
projection(0.1, 100, 30, 1);   // 窄视野 = 望远镜效果
projection(0.1, 100, 90, 1);   // 宽视野 = 广角效果
projection(0.1, 100, 120, 1);  // 超广角,边缘会扭曲

// 尝试不同的 near/far
projection(0.01, 1000, 45, 1);  // near 太小 → Z-fighting!
projection(1, 10, 45, 1);       // 裁剪范围很窄

// 尝试不同的宽高比
projection(0.1, 100, 45, 16/9);  // 宽屏
projection(0.1, 100, 45, 9/16);  // 竖屏(手机)

5. 视景体与裁剪(View Frustum & Clipping)

5.1 现实生活中的类比

想象你站在一个金字塔形的房间里,透过金字塔顶端的窗户看世界。

  • 窗户太小?你看不到窗外的全貌(裁剪)。
  • 物体在窗户后面?它被墙挡住了(近裁剪)。
  • 物体太远?它小到看不见(远裁剪)。
  • 物体在窗户边缘外?你看不到(左右上下裁剪)。

这个金字塔形的观察空间就是视景体(View Frustum)

5.2 本质:只渲染可见的部分

透视视景体(一个四棱锥截去顶端)

         ╱╲
        ╱  ╲
    ───╱────╲───  far 平面
      │      │     ↑
      │      │     │
      │      │   可见区域
      │      │     │
    ──╲────╱───  near 平面
        ╲  ╱
         ╲╱
        相机

正交视景体(一个长方体)

    ┌────────┐
    │        │  far 平面
    │        │
    │        │
    │        │
    └────────┘
    │        │  near 平面
    └────────┘

5.3 裁剪的过程

  1. 顶点变换:每个顶点经过 Model → View → Projection 变换,进入裁剪空间
  2. 裁剪测试:GPU 检查顶点是否在裁剪立方体 $[-1, 1]^3$ 内
  3. 图元裁剪:如果三角形部分在内部、部分在外部,GPU 会切割它
  4. 齐次除法:$x/w, y/w, z/w$,进入 NDC(标准化设备坐标)
  5. 视口映射:NDC 映射到屏幕像素坐标

5.4 在 Demo 中观察裁剪

// 文件: cylinder-clip.html
// 这个 demo 没有使用 viewMatrix,直接对模型做变换

// 投影矩阵只是一个简单的 Z 翻转
renderer.uniforms.projectionMatrix = [
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, -1, 0,
  0, 0, 0, 1,
];

// 模型矩阵把圆柱体平移到 (0.5, 0.5, 0.5)
modelMatrix[12] = 0.5;  // X 平移
modelMatrix[13] = 0.5;  // Y 平移
modelMatrix[14] = 0.5;  // Z 平移

注意:这个 demo 的 canvas 有 border: solid,可以看到边界。如果物体移出 $[-1, 1]$ 的 NDC 范围,就会被裁剪掉。

5.5 常见误区

误区:"裁剪只是不画超出屏幕的部分"

裁剪发生在齐次除法之前!在裁剪空间中,GPU 使用 $w$ 分量来做裁剪测试($ -w \le x \le w$ 等),而不是等映射到屏幕后才判断。这是因为除法后的透视校正属性插值需要正确的裁剪。

误区:"near 平面可以设为 0"

绝对不行!如果 $near = 0$,透视投影矩阵中的 $nf = 1/(0 - far) = -1/far$,而 $w = -z$。当物体恰好在 $z = 0$(相机平面上)时,$w = 0$,齐次除法会产生除零错误。而且 $near = 0$ 会让 Z 缓冲的精度分布完全崩溃。

5.6 动手试一试

cylinder-clip.html 中:

// 把物体移出裁剪范围
modelMatrix[12] = 2.0;  // 移出右边界
modelMatrix[13] = 0;
modelMatrix[14] = 0;

// 观察:圆柱体部分或全部消失
// 尝试不同的值:0.9, 1.0, 1.1, 2.0

6. 完整的变换管线:Model → View → Projection

6.1 现实生活中的类比

想象你要给朋友寄一张你拍摄的照片:

  1. 模型变换(Model):你把一个花瓶放在桌子上(花瓶在房间中的位置)
  2. 视图变换(View):你举起相机,对准花瓶(从相机角度重新描述世界)
  3. 投影变换(Projection):相机镜头把 3D 场景聚焦到底片上(3D → 2D)
  4. 视口变换(Viewport):照片被扫描并显示在手机屏幕上(2D → 像素)

6.2 本质:四个坐标系的接力

模型空间(Model/Local Space)
    │  物体的本地坐标,以物体中心为原点
    │  例如:圆柱体的顶点在 [-0.5, 0.5] 范围内定义
    ▼
  Model Matrix(模型矩阵)
    │  缩放、旋转、平移,把物体放到世界中
    ▼
世界空间(World Space)
    │  所有物体共享的坐标系
    │  房子在 (10, 0, 5),树在 (-3, 0, 8)
    ▼
  View Matrix(视图矩阵)
    │  以相机为原点重新描述世界
    ▼
相机空间(View/Camera Space)
    │  相机在原点,看向 -Z 方向
    ▼
  Projection Matrix(投影矩阵)
    │  把视景体映射到裁剪立方体
    ▼
裁剪空间(Clip Space)
    │  齐次坐标 (x, y, z, w)
    ▼
  齐次除法(Perspective Division)
    │  x/w, y/w, z/w
    ▼
NDC 空间(Normalized Device Coordinate)
    │  范围 [-1, 1] 的立方体
    ▼
  Viewport 变换
    │  映射到屏幕像素坐标
    ▼
屏幕空间(Screen Space)
    │  像素坐标 (0,0) 到 (width, height)

6.3 在 Vertex Shader 中的完整实现

// 来自 cylinder-perspective.html
uniform mat4 projectionMatrix;  // 投影变换
uniform mat4 modelMatrix;       // 模型变换
uniform mat4 viewMatrix;        // 视图变换

void main() {
  // 完整的变换管线,从右往左读:
  // 1. 先 model:顶点从模型空间到世界空间
  // 2. 再 view:从世界空间到相机空间
  // 3. 最后 projection:从相机空间到裁剪空间

  vec4 pos = viewMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);

  // 光照计算在相机空间进行(这样光源位置也一致)
  vec4 lp = viewMatrix * vec4(lightPosition, 1.0);
  vec3 invLight = lp.xyz - pos.xyz;

  // 最终输出到裁剪空间
  gl_Position = projectionMatrix * pos;
}

注意:这里 viewMatrix * modelMatrix 先算,结果再乘 projectionMatrix。在数学上,矩阵乘法满足结合律,所以也可以写成:

// 预计算 Model-View 矩阵(CPU 端)
mat4 modelViewMatrix = viewMatrix * modelMatrix;

// 在 Shader 中
vec4 pos = modelViewMatrix * vec4(a_vertexPosition, 1.0);
gl_Position = projectionMatrix * pos;

这更高效,因为 Shader 中少了一次矩阵乘法。

6.4 公式总结

$$\vec{v}{clip} = P \cdot V \cdot M \cdot \vec{v}{model}$$

其中:

  • $M$ = Model Matrix(模型变换)
  • $V$ = View Matrix(视图变换,即相机变换的逆)
  • $P$ = Projection Matrix(投影变换)
  • $\vec{v}_{model}$ = 模型空间中的顶点坐标(齐次坐标,$w=1$)

6.5 常见误区

误区:"矩阵乘法顺序不重要"

非常重要!矩阵乘法不满足交换律:$A \times B \neq B \times A$。在 Shader 中,projection * view * model * position 的顺序不能乱。从右往左读:先 model,再 view,最后 projection。

误区:"可以在任意坐标系做光照计算"

理论上可以,但要保持一致。如果顶点在相机空间,光源也必须变换到相机空间。cylinder-perspective.html 中的做法:

vec4 lp = viewMatrix * vec4(lightPosition, 1.0);  // 光源也转到相机空间

这样光照方向和法线都在同一坐标系中,点积才有意义。

6.6 动手试一试

// 在 cylinder-perspective.html 中尝试不同的变换组合

// 1. 只改变模型位置
renderer.uniforms.modelMatrix = new Mat4(
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, 1, 0,
  1, 0, 0, 1,  // 平移到 (1, 0, 0)
);

// 2. 旋转模型
const modelMatrix = fromRotation(0.5, 0.5, 0);  // 绕 X、Y 轴旋转
renderer.uniforms.modelMatrix = modelMatrix;

// 3. 缩放模型
const scaleMatrix = new Mat4(
  2, 0, 0, 0,   // X 方向放大 2 倍
  0, 1, 0, 0,
  0, 0, 1, 0,
  0, 0, 0, 1,
);
renderer.uniforms.modelMatrix = scaleMatrix;

7. OGL 库:简化 3D 的利器

7.1 现实生活中的类比

学做菜时,你可以从种小麦开始,也可以直接买面粉。

前面的章节我们"从种小麦开始"——手动写矩阵运算、手动创建几何体、手动管理 Shader。而 ogl-basic.html 展示了如何使用 OGL 这个轻量级 3D 库,它帮你做好了大部分基础工作,你只需要关注"做什么菜"。

7.2 本质:封装了重复的 3D 工作

OGL 是一个极简的 WebGL 封装库,它提供了:

作用 对应我们手动做的事
Renderer 管理 WebGL 上下文 new GlRenderer(canvas)
Camera 内置相机,自动处理矩阵 手动 lookAt + inverse + perspective
Transform 场景图节点,管理父子变换 手动乘矩阵
Program 编译和链接着色器 renderer.compileSync
Sphere/Box/Cylinder/Torus 内置几何体 手动写 cylinder() 函数
Mesh 把几何体 + 材质组合成可渲染对象 手动 setMeshData

7.3 代码对比

手动方式(cylinder-perspective.html):

import {Mat4} from '../common/lib/math/Mat4.js';
import {multiply, ortho, perspective} from '../common/lib/math/functions/Mat4Func.js';
import {cross, subtract, normalize} from '../common/lib/math/functions/Vec3Func.js';
import {normalFromMat4} from '../common/lib/math/functions/Mat3Func.js';

// 手动创建几何体
function cylinder(radius, height, segments) { /* 100+ 行代码 */ }

// 手动设置投影
function projection(near, far, fov, aspect) {
  return perspective([], fov * Math.PI / 180, aspect, near, far);
}
renderer.uniforms.projectionMatrix = projection();

// 手动设置相机
function updateCamera(eye, target) {
  const m = new Mat4(...);
  m.lookAt(eye, target, [0, 1, 0]).inverse();
  renderer.uniforms.viewMatrix = m;
}
updateCamera([2, 2, 3]);

// 手动管理光照和法线矩阵
const modelViewMatrix = multiply([], viewMatrix, modelMatrix);
renderer.uniforms.normalMatrix = normalFromMat4([], modelViewMatrix);

OGL 方式(ogl-basic.html):

import {Renderer, Camera, Transform, Program, Sphere, Box, Cylinder, Torus, Mesh}
  from '../common/lib/ogl/index.mjs';

// 一行创建渲染器
const renderer = new Renderer({canvas, width: 512, height: 512});

// 一行创建相机(自动处理 perspective + lookAt)
const camera = new Camera(gl, {fov: 35});
camera.position.set(0, 1, 7);
camera.lookAt([0, 0, 0]);

// 一行创建场景图
const scene = new Transform();

// 一行创建几何体
const sphereGeometry = new Sphere(gl);
const cubeGeometry = new Box(gl);
const cylinderGeometry = new Cylinder(gl);
const torusGeometry = new Torus(gl);

// 一行创建材质
const program = new Program(gl, {vertex, fragment});

// 一行创建网格并加入场景
const sphere = new Mesh(gl, {geometry: sphereGeometry, program});
sphere.position.set(1.3, 0, 0);
sphere.setParent(scene);

// 渲染时自动处理所有矩阵
renderer.render({scene, camera});

7.4 OGL 的 Camera 内部做了什么

// OGL 的 Camera 类帮你封装了这些:
class Camera extends Transform {
  constructor(gl, {fov = 45, near = 0.1, far = 100, aspect = 1} = {}) {
    // 1. 自动创建透视投影矩阵
    this.projectionMatrix = new Mat4();
    this.projectionMatrix.fromPerspective({fov, aspect, near, far});

    // 2. 自动计算视图矩阵(世界变换的逆)
    this.viewMatrix = new Mat4();
  }

  lookAt(target) {
    // 自动计算 lookAt 并取逆
    this.viewMatrix.lookAt(this.position, target, this.up).inverse();
  }

  update() {
    // 自动组合 modelViewMatrix
    this.modelViewMatrix.multiply(this.viewMatrix, this.worldMatrix);
  }
}

7.5 常见误区

误区:"用库就是偷懒,应该全部手写"

学习阶段手写一遍非常有价值,因为你会理解底层原理。但生产环境中,使用成熟的库可以大幅减少 bug 和提高效率。关键是知道库在做什么,而不是盲目使用。

误区:"OGL 和 Three.js 一样重"

OGL 是一个非常轻量的库(~10KB),它只提供最基本的封装。Three.js 则是一个功能完整的庞大框架。对于学习和小项目,OGL 的简洁性反而是优势。

7.6 动手试一试

// 在 ogl-basic.html 的基础上修改

// 1. 改变相机位置
camera.position.set(5, 5, 5);  // 从更高的位置看
camera.lookAt([0, 0, 0]);

// 2. 改变 FOV
const camera = new Camera(gl, {fov: 60});  // 更宽的视野

// 3. 添加更多物体
const anotherSphere = new Mesh(gl, {geometry: sphereGeometry, program});
anotherSphere.position.set(-2, 1, 1);
anotherSphere.setParent(scene);

// 4. 改变旋转速度
torus.rotation.y -= 0.05;   // 转得更快
sphere.rotation.y -= 0.01;  // 转得更慢

8. 本章总结

核心概念回顾

┌─────────────────────────────────────────────────────────────┐
│                    3D 渲染的相机系统                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   1. 相机 = 坐标系(位置 + 朝向)                              │
│      └── LookAt(eye, center, up) → 相机变换矩阵 → 取逆得 View   │
│                                                             │
│   2. 正交投影 = 平行投影,无透视缩短                           │
│      └── ortho(left, right, bottom, top, near, far)          │
│                                                             │
│   3. 透视投影 = 针孔相机,近大远小                             │
│      └── perspective(fov, aspect, near, far)                 │
│      └── 秘密:w = -z,齐次除法产生透视效果                     │
│                                                             │
│   4. 完整管线:Model → View → Projection → 齐次除法 → Viewport │
│                                                             │
│   5. OGL 库 = 封装了重复工作,让代码更简洁                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

关键公式速查

变换 核心操作
View Matrix $V = LookAt(eye, target, up)^{-1}$
Orthographic $x_{ndc} = \frac{2x - (r+l)}{r-l}$,线性映射
Perspective $x_{clip} = \frac{f}{aspect} \cdot x$,$w = -z$,然后 $x_{ndc} = x_{clip}/w$
完整变换 $\vec{v}{clip} = P \cdot V \cdot M \cdot \vec{v}{model}$

文件对应关系

概念 Demo 文件
基础相机 + 默认投影 cylinder.html
透视投影 cylinder-perspective.html
正交投影 cylinder-ortho.html
裁剪观察 cylinder-clip.html
OGL 库使用 ogl-basic.html

Q&A 常见问题解答

Q1:为什么 View 矩阵要取逆?

A:因为 LookAt 生成的是"把相机从原点放到世界中"的变换。但我们要做的是"把世界变换到相机的坐标系中"。这两个变换互为逆。想象:你向前走 1 步 = 世界向你后退 1 步。

Q2:正交投影和透视投影什么时候用哪个?

A:

  • 正交:CAD、建筑图纸、2.5D 游戏(如《饥荒》)、UI 渲染、需要精确测量的场景
  • 透视:追求真实感的 3D 游戏、虚拟现实、建筑漫游、大多数"现实世界"模拟

Q3:为什么我的物体会消失/变形?

A:检查以下几点:

  1. 相机位置是否太远或太近?
  2. near/far 平面设置是否合理?(near 太小会导致精度问题)
  3. 矩阵乘法顺序是否正确?(应该是 $P \times V \times M$)
  4. 宽高比是否正确?(错误的 aspect 会导致画面被拉伸)

Q4:什么是"右手坐标系"和"左手坐标系"?

A:这是 Z 轴方向的约定:

  • 右手系:Z 轴朝向你(OpenGL 传统)
  • 左手系:Z 轴远离你(DirectX、Unity) WebGL 的裁剪空间是左手系(Z 朝内),但我们的代码通常使用右手系的世界坐标。这就是投影矩阵中 Z 分量有负号的原因——它在坐标系之间做转换。

Q5:OGL 的 Camera 和 Three.js 的 Camera 有什么区别?

A:概念上几乎一样,都封装了投影矩阵和视图矩阵的计算。OGL 更轻量、更底层,Three.js 功能更完整。学习 OGL 有助于理解底层原理,而 Three.js 更适合快速开发复杂项目。

Q6:为什么透视投影的 Z 缓冲是非线性的?

A:因为透视投影矩阵对 Z 做了非线性映射。$z_{ndc}$ 与 $1/z$ 成比例,这意味着:

  • near 平面附近:Z 精度很高(微小的 Z 差异也能区分)
  • far 平面附近:Z 精度很低(大的 Z 差异可能映射到同一个值) 这就是为什么 near 不能太小的原因——它会把大量精度浪费在看不见的近距离空间。

Q7:我可以把相机放在物体内吗?

A:可以!如果 near 平面设置得当,你会看到物体的内部。这在某些效果(如 X 光、剖面图)中很有用。但要注意背面剔除(back-face culling)可能会让内表面不可见。


进阶阅读建议

  1. 矩阵的深入理解:学习线性代数中的基变换、正交矩阵的性质(逆 = 转置)
  2. 四元数旋转:比欧拉角更稳定的旋转表示方法
  3. 投影矩阵的变体:无限远投影、反转 Z(reverse Z)技术
  4. 多相机渲染:分屏游戏、后视镜、小地图的实现原理
  5. 相机控制模式:轨道相机(Orbit)、第一人称(FPS)、跟随相机(Follow)

给初学者的一句话

相机和投影是 3D 渲染的"眼睛"。理解它们,你就理解了"3D 世界如何被看见"。不要被矩阵吓到——它们只是记录"位置、方向、缩放"的紧凑方式。多写代码、多调参数、多观察效果,你很快就能凭直觉感知这些变换的作用。

下一章,我们将探索光照与着色——让你的 3D 世界不再平淡,而是充满明暗、质感和氛围。