第 4 篇:赋予表面生命 - WebGL 纹理贴图

欢迎回来!我们已经指挥一个彩色的三角形跳起了旋转舞。但坦白说,无论是单色还是渐变色,它看起来仍然像一个抽象的几何图形。在真实的世界里,物体表面充满了细节------木头的纹理、金属的光泽、墙壁的砖缝。

想在 WebGL 中模拟这一切,最直接的方法就是纹理贴图 (Texture Mapping)

简单来说,纹理贴图就像是给 3D 模型"包上一层礼品包装纸"。这张"包装纸"(一张普通的 2D 图片),就是纹理 (Texture)

核心概念:UV 坐标

现在,我们面临一个核心问题:如何告诉 WebGL,这张 2D 图片的哪个部分应该贴到我们模型的哪个角上?

答案,是一组新的顶点数据,叫做纹理坐标 (Texture Coordinates) ,通常简称为 UV 坐标

想象一下,你有一个矩形的弹性布料(你的纹理图片),上面印着图案。你还有一个铁丝框(你的 3D 模型)。UV 坐标就像是告诉你,应该把布料上的哪个点,精确地钉在铁丝框的哪个顶点上。

  • UV 坐标是一个二维向量 (u, v)
  • u 代表图片水平方向的位置,v 代表垂直方向的位置。
  • 它的坐标系非常特别:无论你的图片是 100x100 像素还是 1920x1080 像素,UV 坐标的范围永远是从左下角的 (0, 0) 到右上角的 (1, 1)。它使用的是百分比,而不是像素。

只要我们为模型的每个顶点都指定一个 UV 坐标,GPU 就能像拉伸那块弹性布料一样,自动计算出模型表面上所有像素点应该从纹理图片的哪个位置取色。这又是通过我们熟悉的 varying 变量进行插值来完成的。

着色器的新任务

我们的着色器需要再次升级,来处理这些新信息。

  • 顶点着色器 :它的任务很简单。接收 UV 坐标作为新的 attribute,然后不加修改地把它通过 varying 变量传递给片元着色器。
  • 片元着色器 :这是变化的核心。它不再使用之前的颜色 varying。而是接收插值后的 UV 坐标,并利用这个坐标,从纹理中"采样"出颜色,作为最终的像素颜色。

为了在 GLSL 中访问纹理,我们需要一种新的 uniform 类型,叫做 sampler2D 。你可以把它理解为一个"纹理取样器",它代表了我们在 JavaScript 中上传的整张图片。然后,我们使用一个内置函数 texture2D(sampler, uv) 来完成取色操作。

动手实践:给一个正方形贴图

为了更好地展示贴图效果,我们这次不再用三角形,而是画一个由两个三角形拼成的正方形

1. 准备一张图片

你可以使用任何你喜欢的图片。为了方便,你可以先右键保存下面这张图片,和你的 HTML 文件放在同一个目录下,并命名为 texture.png

2. 升级顶点着色器

ini 复制代码
glsl
attribute vec2 a_position;
// 新增: 接收纹理坐标
attribute vec2 a_texCoord;

uniform mat3 u_transformMatrix;

// 新增: 传递纹理坐标给片元着色器
varying vec2 v_texCoord;

void main() {
  vec2 transformedPosition = (u_transformMatrix * vec3(a_position, 1.0)).xy;
  gl_Position = vec4(transformedPosition, 0.0, 1.0);
  
  // 将纹理坐标传递下去
  v_texCoord = a_texCoord;
}

3. 升级片元着色器

glsl 复制代码
precision mediump float;

// 新增: 接收纹理本身 (取样器)
uniform sampler2D u_sampler;

// 新增: 接收插值后的纹理坐标
varying vec2 v_texCoord;

void main() {
  // 从纹理中,根据 v_texCoord 提供的坐标进行采样取色
  gl_FragColor = texture2D(u_sampler, v_texCoord);
}

4. JavaScript:加载与配置纹理

JavaScript 的部分改动较大,因为它需要负责加载图片、创建 WebGL 纹理对象、上传图片数据,并进行相关配置。

  • 图片加载 :我们必须确保图片完全加载后,才能开始 WebGL 的渲染。所以,我们会把 WebGL 的初始化代码全部放进 image.onload 回调函数中。
  • 顶点数据 :我们的交错数据现在是 [X, Y, U, V, ...]
  • 创建纹理 :会有一套标准的流程来创建和配置纹理,包括设置**滤波 (Filtering)方式(图片放大缩小时如何取色)和环绕 (Wrapping)**方式(UV 坐标超出 0-1 范围时如何处理)。

下面是最终的代码。请确保你已经保存了 texture.png 在同级目录下。

xml 复制代码
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebGL 教程 4:纹理贴图</title>
    <style>
        body { background-color: #333; color: #eee; text-align: center; }
        canvas { background-color: #000; border: 1px solid #555; }
        /* 隐藏图片,我们只需要它被加载 */
        #texture-image { display: none; }
    </style>
</head>
<body onload="main()">
    <h1>赋予表面生命 - WebGL 纹理贴图</h1>
    <canvas id="webgl-canvas" width="500" height="500"></canvas>
    <!-- 图片必须存在于 DOM 中才能被加载 -->
    <img id="texture-image" src="texture.png" alt="texture">

    <!-- 顶点着色器 -->
    <script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 a_position;
        attribute vec2 a_texCoord; // 新增: 纹理坐标
        uniform mat3 u_transformMatrix;
        varying vec2 v_texCoord; // 新增: 传递给片元
        void main() {
            vec2 pos = (u_transformMatrix * vec3(a_position, 1.0)).xy;
            gl_Position = vec4(pos, 0.0, 1.0);
            v_texCoord = a_texCoord;
        }
    </script>

    <!-- 片元着色器 -->
    <script id="fragment-shader" type="x-shader/x-fragment">
        precision mediump float;
        uniform sampler2D u_sampler; // 新增: 纹理取样器
        varying vec2 v_texCoord; // 新增: 接收插值后的 UV
        void main() {
            // 根据 UV 坐标从纹理中采样颜色
            gl_FragColor = texture2D(u_sampler, v_texCoord);
        }
    </script>

    <script>
        function main() {
            const image = document.getElementById('texture-image');
            // 关键:必须等图片加载完成后再执行 WebGL 初始化
            image.onload = () => {
                runWebGL(image);
            };
            // 如果图片已经加载完成 (例如从缓存中读取),手动触发 onload
            if (image.complete) {
                image.onload();
            }
        }

        function runWebGL(textureImage) {
            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"),
                texCoord: gl.getAttribLocation(program, "a_texCoord"),
                matrix: gl.getUniformLocation(program, "u_transformMatrix"),
                sampler: gl.getUniformLocation(program, "u_sampler"),
            };

            const buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
            // 用两个三角形组成一个正方形
            // 数据交错: [X, Y, U, V]
            const vertexData = [
                // 三角形 1
                -0.5,  0.5,   0, 1, // 左上
                -0.5, -0.5,   0, 0, // 左下
                 0.5,  0.5,   1, 1, // 右上
                // 三角形 2
                -0.5, -0.5,   0, 0, // 左下
                 0.5, -0.5,   1, 0, // 右下
                 0.5,  0.5,   1, 1, // 右上
            ];
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData), gl.STATIC_DRAW);
            
            // --- 创建和配置纹理 ---
            const texture = gl.createTexture();
            gl.bindTexture(gl.TEXTURE_2D, texture);
            // 配置纹理参数
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); // S = U = 水平环绕
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // T = V = 垂直环绕
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // 缩小滤波器
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // 放大滤波器
            // 上传图片数据到纹理
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureImage);

            gl.useProgram(program);
            
            gl.enableVertexAttribArray(locations.position);
            gl.enableVertexAttribArray(locations.texCoord);
            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

            const FSIZE = Float32Array.BYTES_PER_ELEMENT;
            const STRIDE = 4 * FSIZE;
            gl.vertexAttribPointer(locations.position, 2, gl.FLOAT, false, STRIDE, 0);
            gl.vertexAttribPointer(locations.texCoord, 2, gl.FLOAT, false, STRIDE, 2 * FSIZE);
            
            let currentAngle = 0;
            function animate() {
                currentAngle += 0.01;
                const matrix = [ /* ... building matrix like before ... */ ];
                const c = Math.cos(currentAngle), s = Math.sin(currentAngle);
                const rotationMatrix = [ c, -s, 0, s, c, 0, 0, 0, 1 ];

                gl.clearColor(0.1, 0.1, 0.1, 1.0);
                gl.clear(gl.COLOR_BUFFER_BIT);

                // 告诉着色器使用哪个纹理单元 (我们用 0 号)
                gl.activeTexture(gl.TEXTURE0);
                gl.bindTexture(gl.TEXTURE_2D, texture);
                gl.uniform1i(locations.sampler, 0); // 将 0 号纹理单元分配给 sampler
                
                gl.uniformMatrix3fv(locations.matrix, false, rotationMatrix);
                // 我们现在有 6 个顶点要画
                gl.drawArrays(gl.TRIANGLES, 0, 6);
                
                requestAnimationFrame(animate);
            }
            animate();
        }

        // --- 辅助函数 (与之前相同, 简化版) ---
        function createProgram(gl, vsSource, fsSource) {
            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>

总结与展望

现在,你的画布上应该是一个贴着 F 图案并且在旋转的正方形了!

今天我们完成了 WebGL 学习中一个里程碑式的成就:

  • 理解了 UV 坐标:它是连接模型顶点和 2D 纹理的桥梁。
  • 学会了在 GLSL 中采样纹理 :通过 sampler2Dtexture2D() 函数。
  • 掌握了在 JavaScript 中加载和配置纹理:这是一套虽固定但必须掌握的流程。
  • 从三角形走向了更复杂的组合图形

我们的 2D 探索之旅基本告一段落。我们拥有了形状、颜色、动画和表面材质------构建一个 2D 游戏或应用所需的核心要素几乎都齐备了。

但是,WebGL 的全名是 Web Graphics Library,G 代表的图形可不仅仅是 2D。从下一篇开始,我们将冲破平面的束缚,正式进入激动人心的 3D 世界!我们将引入 Z 坐标、透视相机和 MVP 矩阵,真正开始构建有深度、有空间感的场景。

准备好迎接维度的飞跃了吗?敬请期待 《第 5 篇:从 2D 到 3D - 坐标系、透视与相机》

相关推荐
猪哥帅过吴彦祖3 小时前
Flutter 系列教程:核心概念 - StatelessWidget vs. StatefulWidget
前端·javascript·flutter
郝学胜-神的一滴3 小时前
解析前端框架 Axios 的设计理念与源码
开发语言·前端·javascript·设计模式·前端框架·软件工程
aixfe3 小时前
Ant Design V5 Token 体系颜色处理最佳实践
前端
yanessa_yu3 小时前
前端请求竞态问题
前端
web打印社区3 小时前
如何在 Vue 中打印页面:直接用 web-print-pdf(npm 包)
前端·vue.js·pdf
web打印社区3 小时前
最简单的 Web 打印方案:用 5 分钟上手 web-print-pdf(npm 包)
前端·pdf·npm
转转技术团队3 小时前
AI在前后端联调提效的实践
前端·后端
UrbanJazzerati3 小时前
使用Mockoon快速搭建Mock API:从入门到实战
前端·面试