第 5 篇:WebGL 从 2D 到 3D - 坐标系、透视与相机

至今为止,我们所有的工作都发生在一个 (x, y) 坐标的平面上。现在,我们要引入一个全新的维度:深度 ,也就是 Z 轴

X 轴通常代表左右,Y 轴代表上下,而 Z 轴则代表前后 。在 WebGL 的标准坐标系(右手坐标系)中,Z 轴的正方向通常指向屏幕 ,负方向指向屏幕

有了 (x, y, z) 三个坐标,我们就可以在虚拟空间中定义任何一个点的位置了。但是,一个严峻的问题摆在我们面前:我们的显示器终究是一块 2D 的平面,它要如何展示一个 3D 的世界呢?

答案来自于生活:透视 (Perspective)。我们的大脑之所以能感知到深度,一个关键因素就是"近大远小"。WebGL 要做的,就是用数学来模拟这个过程。

终极武器:MVP 矩阵

在第 3 篇中,我们用一个变换矩阵来控制图形。在 3D 世界中,这个过程被扩展为一条更精密的流水线,由三个核心矩阵相乘,得到一个最终的超级矩阵。这就是传说中的 MVP 矩阵,理解了它,就理解了现代 3D 渲染的核心。

  1. M - Model (模型) 矩阵:

    • 作用: 把模型"摆好姿势"。
    • 职责: 这个矩阵和我们之前用的变换矩阵几乎一样。它负责对一个模型进行平移、旋转和缩放,定义它在整个世界空间 (World Space) 中的位置、朝向和大小。
    • 回答的问题: "这个物体在世界的哪个角落?它朝向哪?有多大?"
  2. V - View (视图) 矩阵:

    • 作用: 架设"相机"。
    • 职责: 这个矩阵非常巧妙。它不是真的去移动一个虚拟相机,而是反过来移动整个世界,使得我们想观察的场景正好落在相机的视野里。想象一下,为了拍清楚一个杯子,你是后退一步(移动相机),还是把整个桌子连同杯子一起推远一点(移动世界)?在 WebGL 里,我们选择后者。这个矩阵定义了相机的位置和它所看向的目标点。
    • 回答的问题: "我(相机)正站在哪里,朝哪个方向看?"
  3. 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 世界的灵魂------光照 。我们将学习最基础的光照模型,计算每个面的法向量,并模拟光线与物体表面的交互,让我们的立方体第一次拥有明暗变化和真实的体积感。

相关推荐
恋猫de小郭2 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端