至今为止,我们所有的工作都发生在一个 (x, y)
坐标的平面上。现在,我们要引入一个全新的维度:深度 ,也就是 Z 轴。
X
轴通常代表左右,Y
轴代表上下,而 Z
轴则代表前后 。在 WebGL 的标准坐标系(右手坐标系)中,Z 轴的正方向通常指向屏幕外 ,负方向指向屏幕内。
有了 (x, y, z)
三个坐标,我们就可以在虚拟空间中定义任何一个点的位置了。但是,一个严峻的问题摆在我们面前:我们的显示器终究是一块 2D 的平面,它要如何展示一个 3D 的世界呢?
答案来自于生活:透视 (Perspective)。我们的大脑之所以能感知到深度,一个关键因素就是"近大远小"。WebGL 要做的,就是用数学来模拟这个过程。
终极武器:MVP 矩阵
在第 3 篇中,我们用一个变换矩阵来控制图形。在 3D 世界中,这个过程被扩展为一条更精密的流水线,由三个核心矩阵相乘,得到一个最终的超级矩阵。这就是传说中的 MVP 矩阵,理解了它,就理解了现代 3D 渲染的核心。
-
M - Model (模型) 矩阵:
- 作用: 把模型"摆好姿势"。
- 职责: 这个矩阵和我们之前用的变换矩阵几乎一样。它负责对一个模型进行平移、旋转和缩放,定义它在整个世界空间 (World Space) 中的位置、朝向和大小。
- 回答的问题: "这个物体在世界的哪个角落?它朝向哪?有多大?"
-
V - View (视图) 矩阵:
- 作用: 架设"相机"。
- 职责: 这个矩阵非常巧妙。它不是真的去移动一个虚拟相机,而是反过来移动整个世界,使得我们想观察的场景正好落在相机的视野里。想象一下,为了拍清楚一个杯子,你是后退一步(移动相机),还是把整个桌子连同杯子一起推远一点(移动世界)?在 WebGL 里,我们选择后者。这个矩阵定义了相机的位置和它所看向的目标点。
- 回答的问题: "我(相机)正站在哪里,朝哪个方向看?"
-
P - Projection (投影) 矩阵:
- 作用: 定义"镜头"并施展"近大远小"的魔法。
- 职责: 这是实现 3D 观感的最后一步。它会创建一个被称为视锥体 (Frustum) 的虚拟观察空间(一个被切掉顶部的金字塔)。所有在这个"金字塔"内的物体都会被保留,并在最终被"压扁"到 2D 屏幕上。这个"压扁"的过程,就会自动产生透视效果------离"金字塔"小头(相机)近的物体,压扁后会更大;远的物体,压扁后会更小。
- 回答的问题: "我的镜头有多广角(FOV)?物体在多近或多远时会被我忽略(近/远裁剪面)?"
最终流程: 最终变换 = 投影矩阵 * 视图矩阵 * 模型矩阵
这三个矩阵在 JavaScript 中计算好,相乘得到一个最终的 mat4
(4x4 矩阵),然后作为一个 uniform
变量,一次性发送给顶点着色器。顶点着色器的工作反而变得异常简单,它只需要用这个最终的 MVP 矩阵去乘以顶点的原始坐标就行了。
新的挑战:深度遮挡
在 3D 空间里,物体会互相遮挡。如果我们不告诉 WebGL 如何处理,它可能会把后面的三角形画到前面三角形的上面,造成"穿模"的混乱效果。
解决方法很简单:开启深度测试 (Depth Test)。
你可以想象屏幕的每个像素点除了有颜色值,还有一个深度值(Z 值)。当 WebGL 准备绘制一个像素时,它会检查这个新像素的深度值,和已经画在那里的像素的深度值。如果新像素更"靠前"(深度值更小),就覆盖掉旧的;否则,就直接丢弃。
我们只需要在初始化时告诉 WebGL 启用这个功能即可。
构建我们的第一个 3D 场景:旋转的立方体
是时候把理论付诸实践了!我们将创建一个由 6 个不同颜色的面组成的立方体,并让它在 3D 空间中自由旋转。
1. 升级顶点着色器
glsl
attribute vec4 a_position; // 从 vec2/vec3 升级到 vec4
attribute vec4 a_color;
uniform mat4 u_mvpMatrix; // 接收最终的 MVP 矩阵
varying vec4 v_color;
void main() {
// 工作变得无比简单!
gl_Position = u_mvpMatrix * a_position;
v_color = a_color;
}
2. JavaScript:矩阵的交响乐
JavaScript 的部分是这次的重头戏。我们将:
- 定义一个立方体的所有顶点和颜色。
- 引入一套 4x4 矩阵的数学函数(在实际项目中请务必使用
gl-matrix
这样的库)。 - 在初始化时开启深度测试。
- 在动画循环中,分别计算 M、V、P 三个矩阵,将它们相乘,然后上传给着色器。
这部分代码会显得很长,但逻辑非常清晰。仔细阅读注释,你会发现它正是我们上面所讨论的 MVP 流程的完美再现。
xml
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebGL 教程 5:3D 立方体</title>
<style>
body { background-color: #333; color: #eee; text-align: center; }
canvas { background-color: #000; border: 1px solid #555; }
</style>
</head>
<body onload="main()">
<h1>从 2D 到 3D - 坐标系、透视与相机</h1>
<canvas id="webgl-canvas" width="600" height="600"></canvas>
<!-- 顶点着色器 (已更新) -->
<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position; // 顶点位置 (x, y, z, 1.0)
attribute vec4 a_color; // 顶点颜色
uniform mat4 u_mvpMatrix; // 接收合并后的 MVP 矩阵
varying vec4 v_color;
void main() {
// 将顶点位置与 MVP 矩阵相乘,得到最终裁剪空间中的坐标
gl_Position = u_mvpMatrix * a_position;
v_color = a_color;
}
</script>
<!-- 片元着色器 (无变化) -->
<script id="fragment-shader" type="x-shader/x-fragment">
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
</script>
<!-- 引入 gl-matrix 库来简化矩阵运算 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
<script>
function main() {
const canvas = document.getElementById('webgl-canvas');
const gl = canvas.getContext('webgl');
if (!gl) { alert('WebGL not supported!'); return; }
const vsSource = document.getElementById('vertex-shader').text;
const fsSource = document.getElementById('fragment-shader').text;
const program = createProgram(gl, vsSource, fsSource);
const locations = {
position: gl.getAttribLocation(program, "a_position"),
color: gl.getAttribLocation(program, "a_color"),
mvpMatrix: gl.getUniformLocation(program, "u_mvpMatrix"),
};
const buffer = initBuffers(gl);
gl.useProgram(program);
// 关键:开启深度测试
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL); // 近处的物体遮挡远处的
let cubeRotation = 0.0;
let lastTime = 0;
function animate(now) {
now *= 0.001; // convert to seconds
const deltaTime = now - lastTime;
lastTime = now;
drawScene(gl, locations, buffer, cubeRotation);
cubeRotation += deltaTime;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
// 绘制场景的函数
function drawScene(gl, locations, buffer, cubeRotation) {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
// 关键:清除颜色和深度缓冲区
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// --- MVP 矩阵计算 ---
// P - Projection (投影) 矩阵
const fieldOfView = 45 * Math.PI / 180; // 45度视角
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const zNear = 0.1;
const zFar = 100.0;
const projectionMatrix = mat4.create();
mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
// V - View (视图) 矩阵 - "相机"
const viewMatrix = mat4.create();
// 相机位置在(0, 0, 6),看向原点(0,0,0),头部朝上(0,1,0)
mat4.lookAt(viewMatrix,,,);
// M - Model (模型) 矩阵
const modelMatrix = mat4.create();
mat4.translate(modelMatrix, modelMatrix, [0.0, 0.0, 0.0]); // 平移
mat4.rotate(modelMatrix, modelMatrix, cubeRotation * .7,); // 绕 Y 轴旋转
mat4.rotate(modelMatrix, modelMatrix, cubeRotation,); // 绕 (1,0,1) 轴旋转
// 合并 MVP
const mvpMatrix = mat4.create();
mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
// --- 数据绑定与绘制 ---
const FSIZE = Float32Array.BYTES_PER_ELEMENT;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(locations.position, 3, gl.FLOAT, false, 6 * FSIZE, 0);
gl.enableVertexAttribArray(locations.position);
gl.vertexAttribPointer(locations.color, 3, gl.FLOAT, false, 6 * FSIZE, 3 * FSIZE);
gl.enableVertexAttribArray(locations.color);
gl.uniformMatrix4fv(locations.mvpMatrix, false, mvpMatrix);
// 绘制 36 个顶点
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
// 初始化立方体顶点数据的函数
function initBuffers(gl) {
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const verticesColors = new Float32Array([
// Front face
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0,
1.0, -1.0, 1.0, 1.0, 0.0, 0.0,
1.0, 1.0, 1.0, 1.0, 0.0, 0.0,
-1.0, 1.0, 1.0, 1.0, 0.0, 0.0,
// Back face
-1.0, -1.0, -1.0, 0.0, 1.0, 0.0,
-1.0, 1.0, -1.0, 0.0, 1.0, 0.0,
1.0, 1.0, -1.0, 0.0, 1.0, 0.0,
1.0, -1.0, -1.0, 0.0, 1.0, 0.0,
// Top face
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0,
-1.0, 1.0, 1.0, 0.0, 0.0, 1.0,
1.0, 1.0, 1.0, 0.0, 0.0, 1.0,
1.0, 1.0, -1.0, 0.0, 0.0, 1.0,
// Bottom face
-1.0, -1.0, -1.0, 1.0, 1.0, 0.0,
1.0, -1.0, -1.0, 1.0, 1.0, 0.0,
1.0, -1.0, 1.0, 1.0, 1.0, 0.0,
-1.0, -1.0, 1.0, 1.0, 1.0, 0.0,
// Right face
1.0, -1.0, -1.0, 1.0, 0.0, 1.0,
1.0, 1.0, -1.0, 1.0, 0.0, 1.0,
1.0, 1.0, 1.0, 1.0, 0.0, 1.0,
1.0, -1.0, 1.0, 1.0, 0.0, 1.0,
// Left face
-1.0, -1.0, -1.0, 0.0, 1.0, 1.0,
-1.0, -1.0, 1.0, 0.0, 1.0, 1.0,
-1.0, 1.0, 1.0, 0.0, 1.0, 1.0,
-1.0, 1.0, -1.0, 0.0, 1.0, 1.0,
]);
// 为了让立方体看起来是一个整体,我们需要明确指定每个面的顶点索引
const indices = new Uint16Array([
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // back
8, 9, 10, 8, 10, 11, // top
12, 13, 14, 12, 14, 15, // bottom
16, 17, 18, 16, 18, 19, // right
20, 21, 22, 20, 22, 23, // left
]);
const positionColorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
return {
position: positionColorBuffer,
indices: indexBuffer
};
}
// 辅助函数
function createProgram(gl, vsSource, fsSource) { /* ... same as before ... */
function createShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error("Shader error:", gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } const vs = createShader(gl.VERTEX_SHADER, vsSource); const fs = createShader(gl.FRAGMENT_SHADER, fsSource); const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error("Program link error:", gl.getProgramInfoLog(prog)); gl.deleteProgram(prog); return null; } return prog;
}
</script>
</body>
</html>
总结与展望
我们成功了! 浏览器中现在应该有一个真正在 3D 空间中旋转的彩色立方体。你可以清晰地看到它的不同面,以及它们是如何根据透视正确地显示和遮挡的。
今天,我们跨越了从 2D 到 3D 最大的鸿沟:
- 掌握了 MVP 矩阵:这是现代实时 3D 渲染的基石,分离了模型、相机和投影的控制。
- 学会了设置"相机"和"镜头":通过视图矩阵和投影矩阵来定义我们的观察视角。
- 启用了深度测试:解决了 3D 场景中物体间的遮挡问题。
然而,你可能会注意到,我们的立方体看起来有点"平",缺乏立体感。它有形状,但没有光影带来的体积感。
在下一篇文章中,我们将解决这个问题。我们将引入 3D 世界的灵魂------光照 。我们将学习最基础的光照模型,计算每个面的法向量,并模拟光线与物体表面的交互,让我们的立方体第一次拥有明暗变化和真实的体积感。