Skip to main content

第 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>

关键点

  1. 矩阵库:使用 gl-matrix 进行矩阵计算
  2. 矩阵顺序:P V M(从右到左执行)
  3. 深度测试:需要启用 gl.DEPTH_TEST 才能正确显示 3D
  4. 索引绘制:使用 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 的优势

  1. 自动矩阵更新:修改 positionrotationscale 自动更新矩阵
  2. 自动传递矩阵:Material 自动接收 modelViewMatrixprojectionMatrix
  3. 便捷 API:直接修改 rotation 而不需要计算矩阵
  4. 自动深度测试: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 中可以直接使用 modelViewMatrixprojectionMatrix

3. 变换操作

原生 WebGL

// 需要矩阵运算
mat4.rotateY(modelMatrix, modelMatrix, angle);

Three.js

// 直接修改属性
mesh.rotation.y += 0.02;

📝 编程作业

基础作业(⭐)

任务:实现一个旋转的立方体

要求

  1. 使用 MVP 矩阵变换
  2. 实现绕 Y 轴的旋转
  3. 使用原生 WebGL 和 Three.js 两种方式实现

检查清单

  • 立方体可以旋转
  • 理解矩阵乘法的顺序
  • 理解 MVP 矩阵的作用

进阶作业(⭐⭐)

任务:实现相机的旋转和缩放

要求

  1. 实现相机绕物体旋转(Orbit 控制)
  2. 实现鼠标滚轮缩放
  3. 理解 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 矩阵的作用

挑战作业(⭐⭐⭐)

任务:实现完整的相机控制系统

要求

  1. 支持 Orbit、First Person、Third Person 三种模式
  2. 支持平滑插值切换
  3. 支持保存和恢复相机状态

扩展功能

  • 实现相机动画路径
  • 实现多相机切换
  • 实现相机碰撞检测

🎓 本节总结

关键概念

  1. MVP 矩阵:Model、View、Projection 三个矩阵
  2. 矩阵顺序:P V M(从右到左执行)
  3. 矩阵计算:使用 gl-matrix 库或手动计算
  4. 深度测试:需要启用才能正确显示 3D

原生 WebGL vs Three.js

方面原生 WebGLThree.js
矩阵计算手动计算自动计算
矩阵传递手动传递自动传递
变换操作矩阵运算属性修改
代码量

最佳实践

  1. 使用矩阵库:不要手动计算矩阵
  2. 理解顺序:矩阵乘法从右到左
  3. 启用深度测试:3D 场景必须启用
  4. 优化矩阵更新:只在需要时更新矩阵

下一步

完成作业后,进入下一节:第 6 节:纹理映射


📚 参考资源