欢迎回来!我们已经指挥一个彩色的三角形跳起了旋转舞。但坦白说,无论是单色还是渐变色,它看起来仍然像一个抽象的几何图形。在真实的世界里,物体表面充满了细节------木头的纹理、金属的光泽、墙壁的砖缝。
想在 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 中采样纹理 :通过
sampler2D
和texture2D()
函数。 - 掌握了在 JavaScript 中加载和配置纹理:这是一套虽固定但必须掌握的流程。
- 从三角形走向了更复杂的组合图形。
我们的 2D 探索之旅基本告一段落。我们拥有了形状、颜色、动画和表面材质------构建一个 2D 游戏或应用所需的核心要素几乎都齐备了。
但是,WebGL 的全名是 Web Graphics Library,G 代表的图形可不仅仅是 2D。从下一篇开始,我们将冲破平面的束缚,正式进入激动人心的 3D 世界!我们将引入 Z 坐标、透视相机和 MVP 矩阵,真正开始构建有深度、有空间感的场景。
准备好迎接维度的飞跃了吗?敬请期待 《第 5 篇:从 2D 到 3D - 坐标系、透视与相机》!