第 6 节:纹理映射
🎯 学习目标
- 理解纹理的作用和使用
- 掌握纹理的加载和绑定
- 理解 UV 坐标系统
- 对比原生 WebGL 和 Three.js 的纹理系统
📖 理论:纹理基础
纹理是将 2D 图像映射到 3D 表面的技术。
纹理坐标(UV)
- U 轴:水平方向,范围 [0, 1]
- V 轴:垂直方向,范围 [0, 1]
- 原点:左下角为 (0, 0),右上角为 (1, 1)
纹理过滤与 Mipmap
当我们把纹理贴到一个离相机很近(放大)或者很远(缩小)的物体上时,如何决定像素颜色的过程叫做纹理过滤(Texture Filtering)。
放大过滤 (MAG_FILTER)
当一个纹理像素(Texel)映射到多个屏幕像素时:
gl.NEAREST:最近邻采样,产生马赛克/像素化效果。gl.LINEAR:双线性插值,对周围四个纹理像素求加权平均,产生平滑模糊的效果。
缩小过滤 (MIN_FILTER) 与 Mipmap
当多个纹理像素映射到一个屏幕像素时,如果仅仅使用 NEAREST 或 LINEAR,会产生严重的锯齿或闪烁(摩尔纹),因为采样点跳跃太大。
为了解决这个问题,WebGL 使用了 Mipmap(多级渐远纹理)。Mipmap 是一系列预先计算好的、分辨率逐级减半的纹理图像集合。
缩小过滤除了可以设置为 NEAREST 和 LINEAR,还可以利用 Mipmap 进行三线性插值:
gl.LINEAR_MIPMAP_LINEAR(推荐/默认):在两个最合适的 Mipmap 层级之间进行插值,并在每个层级内进行双线性插值(即三线性插值),提供最平滑的远距离效果。
注意:要使用 Mipmap,纹理的宽高通常必须是 2 的幂次方(如 512x512)。可以通过 gl.generateMipmap(gl.TEXTURE_2D) 自动生成。
纹理包装
- REPEAT:重复纹理
- CLAMP_TO_EDGE:边缘拉伸
💻 原生 WebGL 实现
加载并应用纹理:
<!DOCTYPE html>
<html>
<head>
<title>纹理映射 - 原生 WebGL</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script>
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}
// Shader 源码
const vertexShaderSource = `
attribute vec4 aVertexPosition;
attribute vec2 aTextureCoord;
uniform mat4 uModelViewProjection;
varying vec2 vTextureCoord;
void main() {
gl_Position = uModelViewProjection * aVertexPosition;
vTextureCoord = aTextureCoord;
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture, vTextureCoord);
}
`;
// 编译函数(复用之前的)
function createShader(gl, type, source) {
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;
}
function createProgram(gl, vertexShader, fragmentShader) {
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));
gl.deleteProgram(program);
return null;
}
return program;
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);
// 平面顶点数据(位置 + UV)
const vertices = new Float32Array([
// 位置(x,y,z) + UV(u,v)
-1.0, -1.0, 0.0, 0.0, 0.0,
1.0, -1.0, 0.0, 1.0, 0.0,
1.0, 1.0, 0.0, 1.0, 1.0,
-1.0, 1.0, 0.0, 0.0, 1.0,
]);
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
// 创建缓冲区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 配置属性
const positionLoc = gl.getAttribLocation(program, "aVertexPosition");
const texCoordLoc = gl.getAttribLocation(program, "aTextureCoord");
gl.enableVertexAttribArray(positionLoc);
gl.enableVertexAttribArray(texCoordLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 5 * 4, 0);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 5 * 4, 3 * 4);
// 加载纹理
function loadTexture(gl, url) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 临时使用 1x1 像素的占位纹理
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([255, 0, 255, 255]) // 紫色
);
const image = new Image();
image.onload = function () {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// 如果图片是 2 的幂次方,生成 mipmap
if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
gl.generateMipmap(gl.TEXTURE_2D);
} else {
// 否则设置包装和过滤模式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
};
image.src = url;
return texture;
}
function isPowerOf2(value) {
return (value & (value - 1)) === 0;
}
// 创建纹理(使用程序生成的纹理作为示例)
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 创建一个 2x2 的棋盘纹理
const pixels = new Uint8Array([
255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
]);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
2,
2,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
// 设置 Uniform
const mvpLoc = gl.getUniformLocation(program, "uModelViewProjection");
const textureLoc = gl.getUniformLocation(program, "uTexture");
// 设置投影矩阵(简化版,使用正交投影)
const projectionMatrix = new Float32Array([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1,
]);
gl.uniformMatrix4fv(mvpLoc, false, projectionMatrix);
// 激活纹理单元 0
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(textureLoc, 0);
gl.viewport(0, 0, canvas.width, canvas.height);
// 绘制
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
</script>
</body>
</html>
关键点
- 创建纹理:
gl.createTexture() - 绑定纹理:
gl.bindTexture(gl.TEXTURE_2D, texture) - 上传图像:
gl.texImage2D() - 设置参数:过滤和包装模式
- 激活纹理单元:
gl.activeTexture(gl.TEXTURE0) - 采样纹理:
texture2D(sampler, uv)
🎨 Three.js 对比实现
Three.js 使用 TextureLoader 简化纹理加载:
<!DOCTYPE html>
<html>
<head>
<title>纹理映射 - Three.js</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);
// 创建纹理(程序生成)
const texture = new THREE.DataTexture(
new Uint8Array([255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255]),
2,
2,
THREE.RGBAFormat
);
texture.needsUpdate = true;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
// 或者使用 TextureLoader 加载图片
// const loader = new THREE.TextureLoader();
// const texture = loader.load('texture.jpg');
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
renderer.render(scene, camera);
</script>
</body>
</html>
Three.js 的优势
- 自动加载:
TextureLoader自动处理异步加载 - 自动配置:自动设置过滤和包装模式
- 便捷 API:直接设置
map属性 - 多种格式:支持多种纹理格式和加载器
🔍 原理对比分析
1. 纹理加载
原生 WebGL:
// 需要手动处理异步加载
const image = new Image();
image.onload = function() {
gl.texImage2D(...);
};
image.src = url;
Three.js:
// 自动处理异步加载
const loader = new THREE.TextureLoader();
const texture = loader.load('texture.jpg');
2. 纹理参数
原生 WebGL:
// 需要手动设置每个参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
Three.js:
// 直接设置属性
texture.minFilter = THREE.LinearFilter;
texture.wrapS = THREE.RepeatWrapping;
3. 纹理单元
原生 WebGL:
// 需要手动激活纹理单元
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(textureLoc, 0);
Three.js:
// 自动管理纹理单元
material.map = texture;
📝 编程作业
基础作业(⭐)
任务:给立方体贴图
要求:
- 创建一个立方体
- 给每个面贴上不同的纹理
- 使用原生 WebGL 和 Three.js 两种方式实现
检查清单:
- 立方体的每个面都有纹理
- 理解 UV 坐标的作用
- 理解纹理过滤模式
进阶作业(⭐⭐)
任务:实现纹理动画
要求:
- 使用 uniform 传递时间
- 在 Shader 中修改 UV 坐标
- 实现纹理滚动、旋转等效果
提示:
// 滚动
vec2 uv = vTextureCoord + vec2(uTime * 0.1, 0.0);
// 旋转
float angle = uTime;
mat2 rotation = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
vec2 uv = rotation * (vTextureCoord - 0.5) + 0.5;
检查清单:
- 纹理可以滚动
- 纹理可以旋转
- 理解 UV 坐标的变换
挑战作业(⭐⭐⭐)
任务:实现多纹理混合
要求:
- 使用多个纹理单元
- 在 Shader 中混合多个纹理
- 实现纹理遮罩效果
扩展功能:
- 支持纹理切换动画
- 支持纹理参数实时调整
- 支持法线贴图和高光贴图
🎓 本节总结
关键概念
- 纹理坐标(UV):将 2D 图像映射到 3D 表面
- 纹理过滤与 Mipmap:控制放大(马赛克或模糊)和缩小(通过 Mipmap 消除摩尔纹和闪烁)时的采样表现。
- 纹理包装:REPEAT(重复)和 CLAMP_TO_EDGE(拉伸)
- 纹理单元:WebGL 支持多个纹理单元
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 纹理加载 | 手动处理异步 | 自动处理 |
| 参数设置 | 手动设置 | 属性设置 |
| 纹理单元 | 手动管理 | 自动管理 |
| 代码量 | 多 | 少 |
最佳实践
- 使用 2 的幂次方:纹理尺寸最好是 2 的幂次方
- 生成 Mipmap:提高远距离纹理质量
- 合理使用过滤:根据需求选择 NEAREST 或 LINEAR
- 压缩纹理:使用压缩格式减少内存
下一步
完成作业后,进入下一节:第 7 节:图像处理基础与卷积