第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 × v6.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 = nearjavascript
/**
* 正交投影矩阵
*/
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 本章小结
核心概念
| 概念 | 说明 |
|---|---|
| 局部空间 | 物体自身坐标系 |
| 世界空间 | 场景全局坐标系 |
| 观察空间 | 以相机为原点的坐标系 |
| 裁剪空间 | 投影后的齐次坐标空间 |
| 模型矩阵 | 局部 → 世界变换 |
| 视图矩阵 | 世界 → 相机变换 |
| 投影矩阵 | 相机 → 裁剪变换 |
| MVP | P × V × M 组合矩阵 |
关键公式
gl_Position = Projection × View × Model × vec4(position, 1.0)
透视投影: 近大远小,有消失点
正交投影: 平行投影,无透视
法线矩阵 = transpose(inverse(ModelMatrix))6.12 练习题
基础练习
实现一个旋转的立方体(只使用模型矩阵)
实现基于
lookAt的视图矩阵,相机围绕原点旋转对比透视投影和正交投影的效果
进阶练习
实现完整的轨道相机控制(旋转、缩放、平移)
实现第一人称相机控制(WASD + 鼠标)
挑战练习
- 实现多视口渲染(同一场景从不同角度观察)
下一章预告:在第7章中,我们将学习光照和材质,让 3D 物体拥有真实的光影效果。
文档版本:v1.0
字数统计:约 13,000 字
代码示例:40+ 个
