第 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 裁剪的过程
- 顶点变换:每个顶点经过 Model → View → Projection 变换,进入裁剪空间
- 裁剪测试:GPU 检查顶点是否在裁剪立方体 $[-1, 1]^3$ 内
- 图元裁剪:如果三角形部分在内部、部分在外部,GPU 会切割它
- 齐次除法:$x/w, y/w, z/w$,进入 NDC(标准化设备坐标)
- 视口映射: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 现实生活中的类比
想象你要给朋友寄一张你拍摄的照片:
- 模型变换(Model):你把一个花瓶放在桌子上(花瓶在房间中的位置)
- 视图变换(View):你举起相机,对准花瓶(从相机角度重新描述世界)
- 投影变换(Projection):相机镜头把 3D 场景聚焦到底片上(3D → 2D)
- 视口变换(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:检查以下几点:
- 相机位置是否太远或太近?
- near/far 平面设置是否合理?(near 太小会导致精度问题)
- 矩阵乘法顺序是否正确?(应该是 $P \times V \times M$)
- 宽高比是否正确?(错误的 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)可能会让内表面不可见。
进阶阅读建议
- 矩阵的深入理解:学习线性代数中的基变换、正交矩阵的性质(逆 = 转置)
- 四元数旋转:比欧拉角更稳定的旋转表示方法
- 投影矩阵的变体:无限远投影、反转 Z(reverse Z)技术
- 多相机渲染:分屏游戏、后视镜、小地图的实现原理
- 相机控制模式:轨道相机(Orbit)、第一人称(FPS)、跟随相机(Follow)
给初学者的一句话:
相机和投影是 3D 渲染的"眼睛"。理解它们,你就理解了"3D 世界如何被看见"。不要被矩阵吓到——它们只是记录"位置、方向、缩放"的紧凑方式。多写代码、多调参数、多观察效果,你很快就能凭直觉感知这些变换的作用。
下一章,我们将探索光照与着色——让你的 3D 世界不再平淡,而是充满明暗、质感和氛围。