WebGL 基础教程(十):从 0 到 1 吃透 MVP 矩阵,3D 旋转立方体手到擒来

一、前言:90% 的 WebGL 新手,都栽在了 MVP 矩阵上

1.1 这篇文章,专治你的 3D 恐惧症

你是不是也这样?✅ 能画三角形,却搞不定 3D 立方体旋转✅ 复制粘贴矩阵代码,改个参数就崩✅ 对着满屏矩阵公式,越看越懵,直接放弃

如果答案是 YES,恭喜你刷到了救命文!本文不讲晦涩的数学推导(用初中知识讲透),只教你能直接落地的 3D 实战技巧,看完就能做出旋转的 3D 立方体。

1.2 为什么 MVP 矩阵是 WebGL 3D 的 "通关密码"?

新手最容易踩的坑:"能跑就行,不懂也没关系"。但现实是 ------想做摄像机环绕?卡壳!想让物体绕自身轴旋转?卡壳!想加透视效果让 3D 更真实?还是卡壳!

说白了:不懂 MVP 矩阵,你永远只能做 WebGL 的 "抄代码仔",成不了真正的 3D 开发者

1.3 看完这篇,你能直接拿捏这些技能

✅ 秒懂模型 / 视图 / 投影矩阵:用 "拍电影" 比喻,10 秒记住各自作用✅ 告别死记硬背:用初中数学推导出平移 / 旋转 / 缩放矩阵✅ 实战落地:30 分钟做出可旋转的彩色 3D 立方体,代码直接复制能用✅ 避坑指南:解决深度测试、矩阵乘法顺序等新手必踩的 5 个坑

二、核心概念:用 "拍电影" 讲透 MVP 矩阵(看完忘不掉)

新手最怕的就是抽象,咱用 "拍电影" 的逻辑,把 MVP 矩阵刻进脑子里:

表格

阶段 对应矩阵 拍电影比喻 核心任务
模型变换 模型矩阵 演员在片场走位、摆姿势 把物体从 "自己的小空间" 放到 3D 世界里
视图变换 视图矩阵 摄影师移动摄像机、调角度 确定 "观众" 的视角,让画面以摄像机为中心
投影变换 投影矩阵 摄像机镜头把 3D 画面压成 2D 给画面加 "立体感",定义哪些内容能被看到

💡 划重点:顶点最终位置公式(抄在笔记本上!)gl_Position = 投影矩阵 × 视图矩阵 × 模型矩阵 × 原始顶点坐标记住:矩阵乘法是 "从右到左",先动模型,再调视角,最后加透视!

三、保姆级解析:矩阵怎么用?

3.1 先搞懂:为啥非要用 4x4 矩阵?

新手灵魂拷问:3D 坐标只有 x/y/z,为啥矩阵要搞 4x4?答案:为了齐次坐标------ 简单说,3x3 矩阵搞不定 "平移",加个第四维,平移、旋转、缩放就能用一套公式搞定,这是 WebGL 的 "偷懒小技巧"!

3.2 模型矩阵:让物体 "动起来"(代码直接用)
  • 平移矩阵(让物体挪位置):

javascript

运行

复制代码
// 列主序,WebGL专用格式,Tx/Ty/Tz是平移距离
const translateMatrix = [
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    Tx, Ty, Tz, 1
];
  • 旋转矩阵(让物体转起来):绕 Z 轴旋转的核心代码,改角度就能调转速
  • 缩放矩阵(让物体变大变小):改数值就能控制长宽高
3.3 视图矩阵:做 "上帝视角" 的摄影师

不用自己写矩阵!用 gl-matrix 库的lookAt函数,1 行代码搞定摄像机位置:

复制代码
// 摄像机在(3,3,5),盯着原点看,Y轴向上(符合人眼习惯)
mat4.lookAt(viewMatrix, [3.0, 3.0, 5.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);

改第一个数组,就能让摄像机 "挪位置",比如移到 (0,5,8),就是俯视视角!

函数参数详解

你代码中的这行调用,包含了三个最重要的信息:

javascript

复制代码
mat4.lookAt(viewMatrix,            // 参数1:输出的视图矩阵
            [3.0, 3.0, 5.0],       // 参数2:摄像机位置 (eye)
            [0.0, 0.0, 0.0],       // 参数3:观察目标点 (center/target)
            [0.0, 1.0, 0.0]        // 参数4:上方向 (up)
);
  • 参数1 viewMatrix :这是一个已经创建好的矩阵变量(mat4.create())。函数执行后,计算好的视图矩阵就会存入这个变量,供后续渲染使用。

  • 参数2 [3.0, 3.0, 5.0] (摄像机位置) :想象你的眼睛就放在这个坐标点上。x=3, y=3, z=5 意味着摄像机在世界坐标系的右方、上方、前方。这正是我们常说的"斜45度俯瞰视角"的经典位置,能很好地展现立方体的三维形态。

  • 参数3 [0.0, 0.0, 0.0] (观察目标点) :这是你眼睛盯着看的那个点 。这里设为原点 (0,0,0),而我们的立方体顶点范围是 -11,所以立方体的中心正好在原点。这就保证了立方体始终在视野中央。

  • 参数4 [0.0, 1.0, 0.0] (上方向) :这是一个至关重要的概念。它定义了你头顶的朝向 。这里设为 (0,1,0),代表世界坐标系的Y轴正方向是"向上"。这个向量确保了摄像机是"正着"的,不会歪斜。你可以试着把它改成 (1,0,0)(让X轴向上),想象一下整个观察的世界会怎样旋转90度。

3.4 投影矩阵:给画面加 "立体感"

透视投影是 3D 的灵魂!一行代码搞定:

javascript

运行

复制代码
// 45度视野+自适应宽高比+近0.1远100,不会出现"穿模"
mat4.perspective(projMatrix, Math.PI / 4, canvas.width/canvas.height, 0.1, 100.0);

四、实战炸场:30 分钟做出旋转的彩色立方体(代码可直接复制)

光说不练假把式!下面是完整代码,复制保存为.html 文件,浏览器打开就能看到效果 ------彩色立方体自动旋转,立体感拉满

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebGL MVP矩阵实战:旋转的彩色立方体</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; }
        #info {
            position: absolute;
            top: 20px;
            left: 20px;
            color: white;
            background: rgba(0,0,0,0.6);
            padding: 10px 20px;
            border-radius: 8px;
            pointer-events: none;
            z-index: 100;
            font-size: 14px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
        }
        .note {
            position: absolute;
            bottom: 20px;
            left: 20px;
            color: #aaa;
            font-size: 12px;
            background: rgba(0,0,0,0.4);
            padding: 5px 12px;
            border-radius: 20px;
        }
    </style>
</head>
<body>
    <div id="info">
        <h2>✨ MVP矩阵变换实战</h2>
        <p>模型矩阵(旋转) × 视图矩阵(摄像机) × 投影矩阵(透视) = 最终变换</p>
        <p>📐 当前状态:彩色立方体自动旋转 | 视角:斜上方45°</p>
    </div>
    <div class="note">🔥 爆文配套代码 · WebGL 矩阵变换详解 · 即拿即用</div>

    <!-- 引入 gl-matrix 库,用于矩阵计算 (CDN) -->
   <script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>

    <!-- 使用 WebGL 的着色器脚本模板 -->
    <script id="vertex-shader" type="x-shader/x-vertex">
        // 顶点着色器
        attribute vec4 aPosition;   // 顶点位置 (本地坐标)
        attribute vec4 aColor;      // 顶点颜色

        uniform mat4 uMVP;           // 模型-视图-投影 联合矩阵

        varying vec4 vColor;         // 传递给片元着色器的颜色

        void main() {
            // MVP矩阵变换顶点坐标 (关键步骤!)
            gl_Position = uMVP * aPosition;
            
            // 传递颜色 (不变)
            vColor = aColor;
        }
    </script>

    <script id="fragment-shader" type="x-shader/x-fragment">
        // 片元着色器
        precision mediump float;

        varying vec4 vColor;          // 从顶点着色器接收的颜色

        void main() {
            gl_FragColor = vColor;    // 直接输出颜色
        }
    </script>

    <script>
        (function() {
            // --- 初始化 WebGL 上下文 ---
            const canvas = document.createElement('canvas');
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            document.body.appendChild(canvas);
            
            const gl = canvas.getContext('webgl');
            if (!gl) {
                alert('你的浏览器不支持 WebGL!');
                return;
            }

            // --- 启用深度测试 (非常重要! 否则立方体面片顺序会错乱) ---
            gl.enable(gl.DEPTH_TEST);
            gl.clearColor(0.1, 0.1, 0.15, 1.0); // 深灰色背景

            // --- 编译着色器 ---
            function compileShader(source, type) {
                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;
            }

            const vertexShaderSource = document.getElementById('vertex-shader').text;
            const fragmentShaderSource = document.getElementById('fragment-shader').text;
            
            const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
            const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER);

            // --- 创建程序并链接 ---
            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));
                return;
            }
            gl.useProgram(program);

            // --- 获取 attribute 和 uniform 变量位置 ---
            const aPosition = gl.getAttribLocation(program, 'aPosition');
            const aColor = gl.getAttribLocation(program, 'aColor');
            const uMVP = gl.getUniformLocation(program, 'uMVP');

            // ========== 第一步:定义顶点数据 (8个顶点:位置 + 颜色) ==========
            // 立方体的8个顶点坐标 (本地坐标,范围为 -1 到 1)
            const vertexPositions = new Float32Array([
                // 前面四个顶点 (z = +1)
                -1.0, -1.0,  1.0,  // 0: 前下左
                 1.0, -1.0,  1.0,  // 1: 前下右
                 1.0,  1.0,  1.0,  // 2: 前上右
                -1.0,  1.0,  1.0,  // 3: 前上左
                // 后面四个顶点 (z = -1)
                -1.0, -1.0, -1.0,  // 4: 后下左
                 1.0, -1.0, -1.0,  // 5: 后下右
                 1.0,  1.0, -1.0,  // 6: 后上右
                -1.0,  1.0, -1.0   // 7: 后上左
            ]);

            // 每个顶点的颜色 (为了鲜艳,我们让每个角颜色不同)
            const vertexColors = new Float32Array([
                // 前面四个顶点颜色 (红、黄、绿、蓝)
                1.0, 0.2, 0.2,  // 0: 红色调
                1.0, 1.0, 0.2,  // 1: 黄色调
                0.2, 1.0, 0.2,  // 2: 绿色调
                0.2, 0.6, 1.0,  // 3: 蓝色调
                // 后面四个顶点颜色 (品红、青、橙、紫)
                1.0, 0.4, 0.8,  // 4: 粉红色
                0.2, 1.0, 1.0,  // 5: 青色
                1.0, 0.6, 0.2,  // 6: 橙色
                0.8, 0.4, 1.0   // 7: 紫色
            ]);

            // ========== 第二步:定义索引数据 (12个三角形,共36个索引) ==========
            // 每个面由两个三角形组成,这里按逆时针顺序排列(面向外面)
            const indices = new Uint16Array([
                // 前面 (0,1,2, 0,2,3)
                0, 1, 2,  0, 2, 3,
                // 右面 (1,5,6, 1,6,2)
                1, 5, 6,  1, 6, 2,
                // 后面 (5,4,7, 5,7,6)
                5, 4, 7,  5, 7, 6,
                // 左面 (4,0,3, 4,3,7)
                4, 0, 3,  4, 3, 7,
                // 上面 (3,2,6, 3,6,7)
                3, 2, 6,  3, 6, 7,
                // 下面 (4,5,1, 4,1,0)
                4, 5, 1,  4, 1, 0
            ]);

            // ========== 第三步:创建缓冲区并上传数据 ==========
            // 1. 创建位置缓冲区
            const positionBuffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
            gl.bufferData(gl.ARRAY_BUFFER, vertexPositions, gl.STATIC_DRAW);
            gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0);
            gl.enableVertexAttribArray(aPosition);

            // 2. 创建颜色缓冲区
            const colorBuffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
            gl.bufferData(gl.ARRAY_BUFFER, vertexColors, gl.STATIC_DRAW);
            gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
            gl.enableVertexAttribArray(aColor);

            // 3. 创建索引缓冲区 (ELEMENT_ARRAY_BUFFER)
            const indexBuffer = gl.createBuffer();
            gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
            gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

            // 解绑 ARRAY_BUFFER (避免后续误操作)
            gl.bindBuffer(gl.ARRAY_BUFFER, null);

            // ========== 第四步:矩阵变换相关代码 ==========
            // 创建矩阵存储对象
            const modelMatrix = mat4.create();  // 模型矩阵
            const viewMatrix = mat4.create();   // 视图矩阵
            const projMatrix = mat4.create();   // 投影矩阵
            const mvpMatrix = mat4.create();    // 最终的 MVP 矩阵

            // 初始化投影矩阵 (透视投影)
            // 参数:输出矩阵, 视野角度(弧度), 宽高比, 近平面, 远平面
            mat4.perspective(projMatrix, 
                Math.PI / 4,                    // 45度视野
                canvas.width / canvas.height,    // 宽高比
                0.1,                             // 近平面
                100.0                            // 远平面
            );

            // 初始化视图矩阵 (摄像机位置)
            // 参数:输出矩阵, 摄像机位置, 观察目标点, 上方向
            mat4.lookAt(viewMatrix, 
                [3.0, 3.0, 5.0],    // 眼睛(摄像机)在右上方
                [0.0, 0.0, 0.0],    // 观察原点
                [0.0, 1.0, 0.0]     // Y轴向上
            );

            // ========== 动画循环 ==========
            let angle = 0; // 旋转角度

            function animate() {
                // 更新旋转角度 (随时间增加)
                angle += 0.01;
                
                // --- 1. 更新模型矩阵 (绕Y轴旋转) ---
                mat4.identity(modelMatrix);                 // 重置为单位矩阵
                mat4.rotateY(modelMatrix, modelMatrix, angle); // 绕Y轴旋转
                // 可以尝试同时绕X轴旋转: mat4.rotateX(modelMatrix, modelMatrix, angle * 0.5);

                // --- 2. 计算最终的 MVP 矩阵: MVP = 投影 × 视图 × 模型 ---
                // 先计算 视图 × 模型
                mat4.multiply(mvpMatrix, viewMatrix, modelMatrix);
                // 再乘投影矩阵
                mat4.multiply(mvpMatrix, projMatrix, mvpMatrix);

                // --- 3. 将MVP矩阵传递给着色器 ---
                gl.uniformMatrix4fv(uMVP, false, mvpMatrix);

                // --- 4. 清屏并绘制 ---
                gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
                
                // 使用索引缓冲区绘制立方体
                // 参数:绘制模式, 索引数量, 索引数据类型, 偏移量
                gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

                // 请求下一帧动画
                requestAnimationFrame(animate);
            }

            // 启动动画
            animate();

            // ========== 窗口大小自适应 ==========
            window.addEventListener('resize', () => {
                canvas.width = window.innerWidth;
                canvas.height = window.innerHeight;
                gl.viewport(0, 0, canvas.width, canvas.height);
                
                // 更新投影矩阵的宽高比
                mat4.perspective(projMatrix, 
                    Math.PI / 4, 
                    canvas.width / canvas.height, 
                    0.1, 100.0
                );
            });

        })();
    </script>
</body>
</html>

当你运行这段代码,会看到一个色彩鲜艳的立方体在深色背景中平滑地绕Y轴自动旋转。它的每一个面都有渐变的色彩,立体感非常强。

代码中添加了详细的注释,并特别突出了以下几个关键点,你可以直接在文章中引用:

1. MVP矩阵的完整计算流程

代码中清晰地展示了每一步:

复制代码
// 先计算 视图 × 模型

mat4.multiply(mvpMatrix, viewMatrix, modelMatrix);

// 再乘投影矩阵

mat4.multiply(mvpMatrix, projMatrix, mvpMatrix);

重点强调:矩阵乘法顺序是从右到左的,即先应用模型变换,再应用视图变换,最后应用投影变换。

2. 索引缓冲区的使用

代码使用了36个索引来绘制12个三角形(6个面 × 2个三角形),避免了重复定义顶点数据,这正是你最初提供的参考文章中所讲的性能优化技巧。还记得上一篇索引复用缓存的教程吗?这里我们用36个索引复用了8个顶点,内存效率提升了4.5倍!"

3. 深度测试的启用

gl.enable(gl.DEPTH_TEST); 这行代码至关重要。如果不启用深度测试,立方体背面的面可能会绘制在前面,导致视觉错误。这是很多初学者容易忽略的点,提出来可以展现你的专业度。

4. 响应式画布

代码监听了 resize 事件,当窗口大小改变时,自动更新画布尺寸和投影矩阵的宽高比,确保立方体永远不变形。

五、新手必看:5 个踩坑点,避开直接少走 1 个月弯路

  1. **矩阵乘法顺序别搞反!**错:投影 × 模型 × 视图 → 立方体直接 "飞出屏幕"对:投影 × 视图 × 模型 → 先动物体,再调视角,最后加透视
  2. 一定要开深度测试! 漏了gl.enable(gl.DEPTH_TEST),立方体背面会盖在前面,直接变成 "纸片"
  3. **索引缓冲区超省内存!**8 个顶点 + 36 个索引,比直接画 36 个顶点省 4.5 倍内存,大厂都这么用!
  4. **窗口缩放要更更新投影矩阵!**不然窗口变大 / 变小,立方体就会 "拉伸变形",像哈哈镜一样
  5. 角度更新要加时间因子!requestAnimationFrame更新角度,旋转速度不卡帧,丝滑到离谱

六、进阶挑战:从 "会用" 到 "精通",秀翻面试官

  1. 摄像机漫游:加键盘监听,按 WASD 控制摄像机移动,实现 "在 3D 世界走路"
  2. 多物体动画:给 2 个立方体加不同的模型矩阵,一个绕 Y 轴转,一个绕 X 轴转
  3. 加光照效果:基于模型矩阵推导法线矩阵,让立方体有 "光影感",瞬间变高级

七、结语:MVP 矩阵,是你进 WebGL 3D 大门的钥匙

看完这篇,你已经搞定了 WebGL 3D 的核心 ------ 不用再抄别人的代码,不用再怕改参数崩掉,你可以自己控制物体的旋转、视角的切换、画面的透视。

从画三角形到做 3D 立方体,你只缺这一步:吃透 MVP 矩阵。现在代码给你了,思路给你了,剩下的就是动手改一改、玩一玩 ------ 毕竟,WebGL 的乐趣,就在于把脑子里的 3D 世界,亲手画在屏幕上!

感谢阅读! 喜欢本文请不要吝啬你的 一键三连:点赞、收藏、留言,让更多小伙伴看到这份干货!

相关推荐
weixin_649555672 小时前
C语言程序设计第四版(何钦铭、颜晖)第七章之利用数组求矩阵各行元素之和并输出
c语言·算法·矩阵
I_LPL2 小时前
hot 100 普通数组、矩阵专题
java·数据结构·矩阵·动态规划·贪心·数组·求职面试
智者知已应修善业2 小时前
【输入矩阵将其按副对角线交换后输出】2024-11-27
c语言·c++·经验分享·笔记·线性代数·算法·矩阵
大江东去浪淘尽千古风流人物2 小时前
【claw】 OpenClaw 的架构设计探索
深度学习·算法·3d·机器人·slam
feifeigo1232 小时前
四旋翼无人机仿真系统:GUI与Simulink 3D模拟
3d·无人机
杀生丸学AI3 小时前
【世界模型】WorldWarp:异步视频扩散的3D重建
3d·aigc·扩散模型·视觉大模型·世界模型·点云分割·高斯泼溅
AI浩3 小时前
CoSMo3D:通过大语言模型引导的规范空间建模实现开放世界可提示的3D语义部件分割
人工智能·3d·语言模型
进击的小头12 小时前
第3篇:最优控制理论数学基础——矩阵与向量的导数
python·线性代数·机器学习·矩阵
沙振宇12 小时前
【Web】使用Vue3+PlayCanvas开发3D游戏(一)3D 立方体交互式游戏
游戏·3d·vue·vue3·playcanvas