第 5 节:矩阵变换(MVP)
🎯 学习目标
- 理解 MVP 矩阵的作用
- 掌握矩阵乘法的顺序
- 实现物体的旋转、缩放、平移
- 对比原生 WebGL 和 Three.js 的矩阵系统
📖 理论:MVP 矩阵
MVP 矩阵将 3D 坐标从模型空间变换到屏幕空间:
模型空间 → Model 矩阵 → 世界空间 → View 矩阵 → 视图空间 → Projection 矩阵 → 裁剪空间
Model 矩阵(模型变换)
将物体从模型空间变换到世界空间:
// 平移
translate(x, y, z);
// 旋转(绕 Z 轴)
rotate(angle, 0, 0, 1);
// 缩放
scale(sx, sy, sz);
View 矩阵(视图变换)
将世界空间变换到视图空间(相机空间):
// 相机位置和朝向
lookAt(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ);
Projection 矩阵(投影变换)
将视图空间变换到裁剪空间:
// 透视投影
perspective(fov, aspect, near, far);
// 正交投影
ortho(left, right, bottom, top, near, far);
矩阵乘法顺序
重要:矩阵乘法从右到左执行!
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
// 执行顺序:先 model,再 view,最后 projection
💻 原生 WebGL 实现
实现一个旋转的立方体(需要手动计算矩阵):
<!DOCTYPE html>
<html>
<head>
<title>矩阵变换 - 原生 WebGL</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.3/gl-matrix-min.js"></script>
<script>
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}
// 使用 gl-matrix 库进行矩阵计算
const mat4 = glMatrix.mat4;
// Shader 源码
const vertexShaderSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewProjection;
varying vec4 vColor;
void main() {
gl_Position = uModelViewProjection * aVertexPosition;
vColor = aVertexColor;
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec4 vColor;
void main() {
gl_FragColor = vColor;
}
`;
// 编译函数(复用之前的)
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("编译错误:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("链接错误:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
const vertexShader = createShader(
gl,
gl.VERTEX_SHADER,
vertexShaderSource,
);
const fragmentShader = createShader(
gl,
gl.FRAGMENT_SHADER,
fragmentShaderSource,
);
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);
// 立方体顶点数据(8 个顶点)
const vertices = new Float32Array([
// 前面
-0.5, -0.5, 0.5, 1.0, 0.0, 0.0, 0.5, -0.5, 0.5, 0.0, 1.0, 0.0, 0.5, 0.5,
0.5, 0.0, 0.0, 1.0, -0.5, 0.5, 0.5, 1.0, 1.0, 0.0,
// 后面
-0.5, -0.5, -0.5, 1.0, 0.0, 1.0, 0.5, -0.5, -0.5, 0.0, 1.0, 1.0, 0.5, 0.5,
-0.5, 1.0, 1.0, 1.0, -0.5, 0.5, -0.5, 0.5, 0.5, 0.5,
]);
// 立方体索引(12 个三角形,36 个顶点)
const indices = new Uint16Array([
0,
1,
2,
0,
2,
3, // 前面
4,
7,
6,
4,
6,
5, // 后面
0,
4,
5,
0,
5,
1, // 下面
2,
6,
7,
2,
7,
3, // 上面
0,
3,
7,
0,
7,
4, // 左面
1,
5,
6,
1,
6,
2, // 右面
]);
// 创建缓冲区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 配置属性
const positionLoc = gl.getAttribLocation(program, "aVertexPosition");
const colorLoc = gl.getAttribLocation(program, "aVertexColor");
gl.enableVertexAttribArray(positionLoc);
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 6 * 4, 0);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 6 * 4, 3 * 4);
// 获取 Uniform 位置
const mvpLoc = gl.getUniformLocation(program, "uModelViewProjection");
// 启用深度测试
gl.enable(gl.DEPTH_TEST);
gl.viewport(0, 0, canvas.width, canvas.height);
// 创建矩阵
const modelMatrix = mat4.create();
const viewMatrix = mat4.create();
const projectionMatrix = mat4.create();
const mvpMatrix = mat4.create();
// 设置投影矩阵
mat4.perspective(
projectionMatrix,
Math.PI / 4, // 45 度
canvas.width / canvas.height,
0.1,
100.0,
);
// 设置视图矩阵(相机位置)
mat4.lookAt(viewMatrix, [0, 0, 5], [0, 0, 0], [0, 1, 0]);
// 渲染循环
let angle = 0;
function animate() {
requestAnimationFrame(animate);
// 更新旋转角度
angle += 0.02;
// 重置模型矩阵
mat4.identity(modelMatrix);
// 旋转
mat4.rotateY(modelMatrix, modelMatrix, angle);
mat4.rotateX(modelMatrix, modelMatrix, angle * 0.5);
// 计算 MVP 矩阵:P * V * M
mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
// 设置 Uniform
gl.uniformMatrix4fv(mvpLoc, false, mvpMatrix);
// 清空并绘制
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
}
animate();
</script>
</body>
</html>
关键点
- 矩阵库:使用
gl-matrix进行矩阵计算 - 矩阵顺序:P V M(从右到左执行)
- 深度测试:需要启用
gl.DEPTH_TEST才能正确显示 3D - 索引绘制:使用
drawElements绘制立方体- 立方体有 8 个角点,但需要绘制 12 个三角形(36 个顶点索引)
- 使用
drawElements可以复用顶点数据,避免重复存储 - 相比
drawArrays,内存占用从 36 个顶点减少到 8 个顶点(节省 78%) - 📖 详细对比:参考 第 2 节:深入理解 drawArrays vs drawElements
🎨 Three.js 对比实现
Three.js 自动管理所有矩阵:
<!DOCTYPE html>
<html>
<head>
<title>矩阵变换 - Three.js</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);
// 创建几何体和材质
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
vertexColors: true,
});
// 设置顶点颜色
const colors = [];
const color = new THREE.Color();
for (let i = 0; i < geometry.attributes.position.count; i++) {
color.setHSL((i / geometry.attributes.position.count) * 0.5, 1.0, 0.5);
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
material.vertexColors = true;
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 渲染循环
function animate() {
requestAnimationFrame(animate);
// Three.js 自动更新矩阵
// 只需要修改 mesh 的 rotation
mesh.rotation.y += 0.02;
mesh.rotation.x += 0.01;
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
Three.js 的优势
- 自动矩阵更新:修改
position、rotation、scale自动更新矩阵 - 自动传递矩阵:Material 自动接收
modelViewMatrix和projectionMatrix - 便捷 API:直接修改
rotation而不需要计算矩阵 - 自动深度测试:Renderer 自动启用深度测试
🔍 原理对比分析
1. 矩阵管理
原生 WebGL:
// 需要手动计算所有矩阵
mat4.perspective(projectionMatrix, ...);
mat4.lookAt(viewMatrix, ...);
mat4.rotateY(modelMatrix, modelMatrix, angle);
mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
gl.uniformMatrix4fv(mvpLoc, false, mvpMatrix);
Three.js:
// 自动管理所有矩阵
mesh.rotation.y += 0.02;
// Three.js 内部自动:
// 1. 更新 modelMatrix
// 2. 计算 modelViewMatrix
// 3. 传递给 Shader
2. 矩阵传递
原生 WebGL:
- 需要手动计算 MVP 矩阵
- 需要手动传递给 Shader
Three.js:
- 自动计算和传递
- Shader 中可以直接使用
modelViewMatrix和projectionMatrix
3. 变换操作
原生 WebGL:
// 需要矩阵运算
mat4.rotateY(modelMatrix, modelMatrix, angle);
Three.js:
// 直接修改属性
mesh.rotation.y += 0.02;
📝 编程作业
基础作业(⭐)
任务:实现一个旋转的立方体
要求:
- 使用 MVP 矩阵变换
- 实现绕 Y 轴的旋转
- 使用原生 WebGL 和 Three.js 两种方式实现
检查清单:
- 立方体可以旋转
- 理解矩阵乘法的顺序
- 理解 MVP 矩阵的作用
进阶作业(⭐⭐)
任务:实现相机的旋转和缩放
要求:
- 实现相机绕物体旋转(Orbit 控制)
- 实现鼠标滚轮缩放
- 理解 View 矩阵的计算
提示:
// 计算相机位置(球坐标)
const radius = 5;
const theta = mouseX;
const phi = mouseY;
camera.position.x = radius * Math.sin(phi) * Math.cos(theta);
camera.position.y = radius * Math.cos(phi);
camera.position.z = radius * Math.sin(phi) * Math.sin(theta);
检查清单:
- 相机可以绕物体旋转
- 可以缩放
- 理解 View 矩阵的作用
挑战作业(⭐⭐⭐)
任务:实现完整的相机控制系统
要求:
- 支持 Orbit、First Person、Third Person 三种模式
- 支持平滑插值切换
- 支持保存和恢复相机状态
扩展功能:
- 实现相机动画路径
- 实现多相机切换
- 实现相机碰撞检测
🎓 本节总结
关键概念
- MVP 矩阵:Model、View、Projection 三个矩阵
- 矩阵顺序:P V M(从右到左执行)
- 矩阵计算:使用
gl-matrix库或手动计算 - 深度测试:需要启用才能正确显示 3D
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 矩阵计算 | 手动计算 | 自动计算 |
| 矩阵传递 | 手动传递 | 自动传递 |
| 变换操作 | 矩阵运算 | 属性修改 |
| 代码量 | 多 | 少 |
最佳实践
- 使用矩阵库:不要手动计算矩阵
- 理解顺序:矩阵乘法从右到左
- 启用深度测试:3D 场景必须启用
- 优化矩阵更新:只在需要时更新矩阵
下一步
完成作业后,进入下一节:第 6 节:纹理映射