Skip to main content

第 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

当多个纹理像素映射到一个屏幕像素时,如果仅仅使用 NEARESTLINEAR,会产生严重的锯齿或闪烁(摩尔纹),因为采样点跳跃太大。 为了解决这个问题,WebGL 使用了 Mipmap(多级渐远纹理)。Mipmap 是一系列预先计算好的、分辨率逐级减半的纹理图像集合。

缩小过滤除了可以设置为 NEARESTLINEAR,还可以利用 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>

关键点

  1. 创建纹理gl.createTexture()
  2. 绑定纹理gl.bindTexture(gl.TEXTURE_2D, texture)
  3. 上传图像gl.texImage2D()
  4. 设置参数:过滤和包装模式
  5. 激活纹理单元gl.activeTexture(gl.TEXTURE0)
  6. 采样纹理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 的优势

  1. 自动加载TextureLoader 自动处理异步加载
  2. 自动配置:自动设置过滤和包装模式
  3. 便捷 API:直接设置 map 属性
  4. 多种格式:支持多种纹理格式和加载器

🔍 原理对比分析

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;

📝 编程作业

基础作业(⭐)

任务:给立方体贴图

要求

  1. 创建一个立方体
  2. 给每个面贴上不同的纹理
  3. 使用原生 WebGL 和 Three.js 两种方式实现

检查清单

  • 立方体的每个面都有纹理
  • 理解 UV 坐标的作用
  • 理解纹理过滤模式

进阶作业(⭐⭐)

任务:实现纹理动画

要求

  1. 使用 uniform 传递时间
  2. 在 Shader 中修改 UV 坐标
  3. 实现纹理滚动、旋转等效果

提示

// 滚动
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 坐标的变换

挑战作业(⭐⭐⭐)

任务:实现多纹理混合

要求

  1. 使用多个纹理单元
  2. 在 Shader 中混合多个纹理
  3. 实现纹理遮罩效果

扩展功能

  • 支持纹理切换动画
  • 支持纹理参数实时调整
  • 支持法线贴图和高光贴图

🎓 本节总结

关键概念

  1. 纹理坐标(UV):将 2D 图像映射到 3D 表面
  2. 纹理过滤与 Mipmap:控制放大(马赛克或模糊)和缩小(通过 Mipmap 消除摩尔纹和闪烁)时的采样表现。
  3. 纹理包装:REPEAT(重复)和 CLAMP_TO_EDGE(拉伸)
  4. 纹理单元:WebGL 支持多个纹理单元

原生 WebGL vs Three.js

方面原生 WebGLThree.js
纹理加载手动处理异步自动处理
参数设置手动设置属性设置
纹理单元手动管理自动管理
代码量

最佳实践

  1. 使用 2 的幂次方:纹理尺寸最好是 2 的幂次方
  2. 生成 Mipmap:提高远距离纹理质量
  3. 合理使用过滤:根据需求选择 NEAREST 或 LINEAR
  4. 压缩纹理:使用压缩格式减少内存

下一步

完成作业后,进入下一节:第 7 节:图像处理基础与卷积


📚 参考资源