第 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 世界的灵魂------光照 。我们将学习最基础的光照模型,计算每个面的法向量,并模拟光线与物体表面的交互,让我们的立方体第一次拥有明暗变化和真实的体积感。

相关推荐
折七2 小时前
expo sdk53+ 集成极光推送消息推送 ios swift
前端·javascript·ios
猪哥帅过吴彦祖2 小时前
Flutter 系列教程:布局基础 (上) - `Container`, `Row`, `Column`, `Flex`
前端·flutter·ios
lifejump2 小时前
DVWA | XSS 跨站脚本注入
前端·xss
gplitems1232 小时前
Tripfery - Travel & Tour Booking WordPress Theme Tested
前端
流星稍逝2 小时前
前端&后端解决跨域的方法
前端·后端
白水清风2 小时前
【基础】关于函数式编程的知识
前端·javascript·面试
蓝莓味的口香糖2 小时前
【JS】JS基础-对象处理方法整合
开发语言·前端·javascript
sanx183 小时前
一站式电竞平台解决方案:数据、直播、源码,助力业务飞速启航
前端·数据库·apache·数据库开发·时序数据库
余防3 小时前
存储型XSS,反射型xss
前端·安全·web安全·网络安全·xss