还在为WebGL物体只有单调的颜色而烦恼?想让3D模型拥有真实的砖墙、木质纹理或者照片级的外观,却不知道如何下手?
今天这篇教程,我们将彻底搞懂WebGL的纹理映射技术。从最基础的纹理坐标原理、纹理过滤、环绕方式,到完整的纹理加载流程和着色器采样,最终实现一个带有五张动物图片的旋转立方体。全文将围绕标准的WebGL API展开,手把手带你写出第一个纹理程序,新手也能跟着敲出效果,彻底掌握WebGL贴图的核心。
效果如下图:

1. 先搞懂:纹理映射的本质
1.1 什么是纹理映射?
纹理映射(Texture Mapping) 是将一张2D图片"贴"到3D物体表面的技术。这张图片可以是照片、手绘图、程序生成的图案,甚至是视频。
为什么需要纹理映射?
-
没有纹理:物体只有单调的颜色,像塑料模型
-
有纹理:物体拥有丰富的表面细节,像真实世界
1.2 纹理映射的核心原理
纹理映射的本质是从纹理空间到屏幕空间的坐标变换:
-
纹理空间 :UV坐标系,范围
[0, 0]到[1, 1] -
模型空间 :3D顶点坐标
(x, y, z) -
映射过程:每个顶点被赋予一个UV坐标,GPU在光栅化阶段对三角形内部的每个像素进行UV插值,然后用插值后的UV去纹理图片上采样颜色
数学表达:
text
对于三角形内的任意点P,其纹理坐标 = α·UV₁ + β·UV₂ + γ·UV₃
其中α+β+γ=1,是重心坐标
1.3 纹理采样原理
当GPU确定一个片元(像素)的颜色时,它会:
-
根据插值得到该片元的UV坐标
(u, v) -
将UV坐标映射到纹理图像的具体位置:
text
x_tex = u × 纹理宽度 y_tex = v × 纹理高度 -
根据纹理过滤模式,采样一个或多个纹素计算最终颜色
最近邻采样公式:
text
color = texel[floor(u × width), floor(v × height)]
双线性插值公式:
text
color = (1-u_offset)(1-v_offset)·texel[i][j] +
u_offset(1-v_offset)·texel[i+1][j] +
(1-u_offset)v_offset·texel[i][j+1] +
u_offset·v_offset·texel[i+1][j+1]
2. 纹理映射的核心概念
2.1 纹理坐标 (UV坐标)
UV坐标是连接3D顶点和2D纹理的桥梁,这是纹理映射中最基础也最重要的概念。
UV坐标的定义:
-
U轴:水平方向,从左到右,范围0.0 → 1.0
-
V轴:垂直方向,从下到上,范围0.0 → 1.0
-
原点(0,0):纹理图片的左下角
-
终点(1,1):纹理图片的右上角
纹理坐标原理图解:
text
纹理图片 3D物体的面
(0,1) ┌─────┐ (1,1) (x1,y1)─────(x2,y2)
│ │ │ │
│ │ │ │
│ │ │ │
(0,0) └─────┘ (1,0) (x3,y3)─────(x4,y4)
每个顶点对应一个UV坐标:
左下顶点 (x3,y3) → UV(0,0)
右下顶点 (x4,y4) → UV(1,0)
右上顶点 (x2,y2) → UV(1,1)
左上顶点 (x1,y1) → UV(0,1)
关键理解:想象一根橡皮筋,上面均匀地标记了0到1的刻度。当你把这根橡皮筋拉伸并固定到一条线段(由两个顶点构成)的两端时,线段中间的任何一个点,都可以根据它在橡皮筋上的位置找到对应的刻度。UV坐标的工作原理与此完全相同,只不过是在二维平面上操作。
2.2 纹理过滤 (Texture Filtering)
当纹理被放大或缩小时,一个屏幕像素可能对应多个纹素,或者多个屏幕像素对应一个纹素。这时就需要纹理过滤来决定如何采样。
放大过滤 (Magnification Filter):当纹理被放大时,一个屏幕像素对应不到一个纹素
-
gl.NEAREST:最近邻过滤,直接选择距离最近的纹素,效果呈块状像素风格
-
gl.LINEAR:线性过滤,取周围2x2区域的纹素进行加权平均,效果更平滑
缩小过滤 (Minification Filter):当纹理被缩小时,一个屏幕像素对应多个纹素
-
gl.NEAREST:最近邻过滤,效果粗糙,容易产生锯齿
-
gl.LINEAR:线性过滤,效果比NEAREST好,但仍可能产生摩尔纹
-
gl.NEAREST_MIPMAP_NEAREST:选择最近的Mipmap层,使用最近邻采样
-
gl.LINEAR_MIPMAP_NEAREST:选择最近的Mipmap层,使用线性采样
-
gl.NEAREST_MIPMAP_LINEAR:在两层Mipmap之间线性插值,使用最近邻采样
-
gl.LINEAR_MIPMAP_LINEAR:在两层Mipmap之间线性插值,使用线性采样(效果最好,开销也最大)
过滤方式选择原则:
javascript
// 推荐设置
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2.3 Mipmap原理
什么是Mipmap? Mipmap是预先创建的一系列逐渐缩小的纹理图片链,每一级的大小是上一级的1/2。
Mipmap层级示例:
text
级别0: 256x256 (原始纹理)
级别1: 128x128 (1/4大小)
级别2: 64x64 (1/16大小)
级别3: 32x32 (1/64大小)
级别4: 16x16 (1/256大小)
为什么需要Mipmap?
-
提升渲染质量:远处的物体自动使用低分辨率纹理,避免摩尔纹和闪烁
-
提升性能:减少纹理采样时的内存带宽消耗
-
提升缓存效率:小纹理更容易命中GPU缓存
Mipmap选择公式:
text
λ = log₂(max(√((∂u/∂x)² + (∂v/∂x)², √((∂u/∂y)² + (∂v/∂y)²)))
选择的Mipmap级别 = clamp(floor(λ + 0.5), 0, maxLevel)
2.4 纹理环绕方式 (Texture Wrapping)
当纹理坐标超出[0.0, 1.0]范围时,可以通过环绕方式定义如何获取颜色。
四种环绕方式:
-
gl.REPEAT(重复):
text
效果:纹理在表面上无限重复平铺 公式:u' = u - floor(u) 适用:砖墙、地板、布料等重复性纹理 -
gl.MIRRORED_REPEAT(镜像重复):
text
效果:纹理重复,但每次重复时进行镜像翻转 公式:u' = 1 - |(u mod 2) - 1| 适用:需要对称效果的纹理 -
gl.CLAMP_TO_EDGE(钳位到边缘):
text
效果:超出范围的坐标取纹理边缘的颜色 公式:u' = clamp(u, 0, 1) 适用:照片、UI元素、非重复纹理 -
gl.CLAMP_TO_BORDER(钳位到边框):
text
效果:超出范围显示指定的边框颜色 需要额外设置边框颜色 适用:需要特定边框效果的情况
设置环绕方式:
javascript
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); // U方向
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); // V方向
3. 纹理映射完整开发流程
3.1 纹理开发全流程图解
text
┌─────────────────────────────────────────────────────────────────┐
│ 纹理开发完整流程 │
└─────────────────────────────────────────────────────────────────┘
【阶段1: 准备阶段】 【阶段2: 数据上传阶段】
┌─────────────────┐ ┌─────────────────┐
│ 1. 创建纹理对象 │ │ 5. 上传图片数据 │
│ gl.createTexture│ │ gl.texImage2D │
└────────┬────────┘ └────────┬────────┘
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ 2. 绑定纹理对象 │ │ 6. 设置纹理参数 │
│ gl.bindTexture │ │ gl.texParameteri│
└────────┬────────┘ └────────┬────────┘
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ 3. 准备图片数据 │ │ 7. 生成Mipmap │
│ Image / Canvas │ │ gl.generateMip │
└────────┬────────┘ └────────┬────────┘
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ 4. 等待加载完成 │ │ 8. 解除绑定 │
│ onload回调 │ │ gl.bindTexture │
└─────────────────┘ └─────────────────┘
【阶段3: 渲染阶段】 【阶段4: 绘制阶段】
┌─────────────────┐ ┌─────────────────┐
│ 9. 激活纹理单元 │ │ 12. 顶点着色器 │
│ gl.activeTexture│ │ 接收UV坐标 │
└────────┬────────┘ └────────┬────────┘
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│10. 绑定到单元 │ │13. 片元着色器 │
│ gl.bindTexture │ │ texture2D采样 │
└────────┬────────┘ └────────┬────────┘
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│11. 设置采样器 │ │14. gl.drawArrays│
│ gl.uniform1i │ │ 执行渲染 │
└─────────────────┘ └─────────────────┘
3.2 阶段一:纹理准备阶段
步骤1:创建纹理对象
javascript
const texture = gl.createTexture();
-
在GPU端分配一个纹理ID
-
返回一个纹理对象句柄
步骤2:绑定纹理对象
javascript
gl.bindTexture(gl.TEXTURE_2D, texture);
-
将纹理对象绑定到当前纹理目标
-
后续所有纹理操作都作用于当前绑定的纹理
步骤3:准备图片数据
javascript
const image = new Image();
image.src = 'image.jpg';
-
创建Image对象
-
设置图片URL,开始异步加载
步骤4:等待加载完成
javascript
image.onload = () => {
// 图片加载完成,进入下一阶段
};
-
必须等待图片完全加载后才能上传到GPU
-
期间可以显示占位纹理或加载动画
3.3 阶段二:数据上传阶段
步骤5:上传图片数据到GPU
javascript
gl.texImage2D(
gl.TEXTURE_2D, // 目标:2D纹理
0, // mipmap级别:0为基础级别
gl.RGBA, // 内部格式:GPU存储格式
gl.RGBA, // 源格式:图片数据格式
gl.UNSIGNED_BYTE, // 数据类型:每个通道8位
image // 图片数据
);
参数详解:
-
target :纹理目标,2D纹理使用
gl.TEXTURE_2D -
level:mipmap级别,0表示基础级别
-
internalFormat:GPU内部存储格式,决定每个纹素占用多少内存
-
format:源数据的格式,必须与internalFormat兼容
-
type :数据类型,通常是
gl.UNSIGNED_BYTE -
pixels:图片数据源
步骤6:设置纹理参数
javascript
// 设置缩小过滤方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
// 设置放大过滤方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// 设置U方向环绕方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
// 设置V方向环绕方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
步骤7:生成Mipmap
javascript
gl.generateMipmap(gl.TEXTURE_2D);
-
自动生成从原始大小到1x1的所有mipmap层级
-
必须图片尺寸是2的幂才能生成完整的mipmap链
步骤8:解除绑定
javascript
gl.bindTexture(gl.TEXTURE_2D, null);
- 防止后续操作意外修改这个纹理
3.4 阶段三:渲染准备阶段
步骤9:激活纹理单元
javascript
gl.activeTexture(gl.TEXTURE0); // 激活纹理单元0
-
WebGL支持多个纹理单元(通常至少8个)
-
每个纹理单元可以绑定一个纹理
步骤10:绑定纹理到单元
javascript
gl.bindTexture(gl.TEXTURE_2D, texture);
-
将纹理绑定到当前激活的纹理单元
-
一个纹理可以绑定到多个单元,但通常一对一的
步骤11:设置采样器uniform
javascript
const u_Sampler = gl.getUniformLocation(program, 'u_Sampler');
gl.uniform1i(u_Sampler, 0); // 告诉着色器使用纹理单元0
-
将纹理单元索引传递给着色器
-
着色器中的
sampler2D变量会从指定单元采样
3.5 阶段四:着色器采样阶段
顶点着色器 - 传递UV坐标
glsl
attribute vec4 a_Position;
attribute vec2 a_TexCoord; // 接收顶点UV坐标
varying vec2 v_TexCoord; // 传递给片元着色器
void main() {
v_TexCoord = a_TexCoord; // 直接传递,GPU会自动插值
gl_Position = ...;
}
片元着色器 - 纹理采样
glsl
precision mediump float;
varying vec2 v_TexCoord; // 接收插值后的UV坐标
uniform sampler2D u_Sampler; // 纹理采样器
void main() {
// 核心采样函数:texture2D
gl_FragColor = texture2D(u_Sampler, v_TexCoord);
}
texture2D函数工作原理:
-
接收UV坐标
(u, v) -
计算纹素位置:
x = u * width, y = v * height -
根据纹理过滤模式,采样一个或多个纹素
-
返回颜色值
vec4(r, g, b, a)
4. 核心API速查表
| 阶段 | API | 参数 | 说明 |
|---|---|---|---|
| 创建 | gl.createTexture() |
无 | 创建纹理对象 |
| 绑定 | gl.bindTexture(target, texture) |
target: gl.TEXTURE_2D texture: 纹理对象 |
绑定纹理到目标 |
| 上传 | gl.texImage2D(target, level, internalFormat, format, type, pixels) |
internalFormat: gl.RGBA format: gl.RGBA type: gl.UNSIGNED_BYTE |
上传图片数据 |
| 过滤 | gl.texParameteri(target, pname, param) |
pname: TEXTURE_MIN_FILTER param: gl.LINEAR |
设置缩小过滤 |
| 过滤 | gl.texParameteri(target, pname, param) |
pname: TEXTURE_MAG_FILTER param: gl.LINEAR |
设置放大过滤 |
| 环绕 | gl.texParameteri(target, pname, param) |
pname: TEXTURE_WRAP_S param: gl.REPEAT |
U方向环绕 |
| 环绕 | gl.texParameteri(target, pname, param) |
pname: TEXTURE_WRAP_T param: gl.REPEAT |
V方向环绕 |
| Mipmap | gl.generateMipmap(target) |
target: gl.TEXTURE_2D |
生成mipmap链 |
| 激活 | gl.activeTexture(textureUnit) |
textureUnit: gl.TEXTURE0 |
激活纹理单元 |
| 采样器 | gl.uniform1i(location, value) |
value: 0-7 |
设置采样器单元 |
5. 实战:动物图片纹理立方体
下面我们创建一个完整的示例,使用猫、狗、熊猫、马、猪五张动物图片作为纹理,让立方体的每个面都变成可爱的动物照片。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>🐱 WebGL纹理实战:动物图片立方体(原理+完整流程)</title>
<style>
body {
margin: 0;
overflow: hidden;
font-family: 'Microsoft YaHei', sans-serif;
}
#info {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 20px 30px;
border-radius: 16px;
z-index: 100;
border-left: 6px solid #00aaff;
box-shadow: 0 8px 25px rgba(0,0,0,0.5);
backdrop-filter: blur(5px);
pointer-events: none;
max-width: 380px;
}
#info h2 {
margin: 0 0 15px 0;
color: #00aaff;
}
#info .principle-item {
margin: 8px 0;
font-size: 14px;
color: #ddd;
border-left: 2px solid #00aaff;
padding-left: 10px;
}
#status-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 20px;
border-radius: 16px;
z-index: 100;
border-left: 6px solid #ffaa00;
backdrop-filter: blur(5px);
min-width: 280px;
}
#status-panel h3 {
margin: 0 0 15px 0;
color: #ffaa00;
}
.texture-item {
margin: 12px 0;
padding: 8px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
display: flex;
align-items: center;
}
.texture-emoji {
font-size: 24px;
margin-right: 12px;
width: 40px;
text-align: center;
}
.texture-name {
flex: 1;
font-size: 16px;
}
.state-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.badge-loading { background: #ffaa00; color: #000; }
.badge-success { background: #00cc66; color: #fff; }
.badge-error { background: #ff4444; color: #fff; }
#process-flow {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
color: #aaa;
padding: 12px 20px;
border-radius: 40px;
z-index: 100;
font-size: 13px;
border-left: 4px solid #ff55aa;
backdrop-filter: blur(5px);
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.formula {
font-family: monospace;
background: rgba(255,255,255,0.1);
padding: 2px 6px;
border-radius: 4px;
color: #ffaa00;
}
</style>
<!-- 引入gl-matrix库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
</head>
<body>
<div id="info">
<h2>📚 纹理映射原理与开发流程</h2>
<div class="principle-item">
<span class="formula">UV坐标</span> 每个顶点(0-1) → 三角形内插值
</div>
<div class="principle-item">
<span class="formula">纹理采样</span> texture2D(sampler, uv) → 颜色
</div>
<div class="principle-item">
<span class="formula">过滤方式</span> NEAREST(像素风) / LINEAR(平滑)
</div>
<div class="principle-item">
<span class="formula">Mipmap</span> 预生成多级纹理,提升性能和质量
</div>
<div class="principle-item">
<span class="formula">环绕方式</span> REPEAT / CLAMP_TO_EDGE / MIRROR
</div>
</div>
<div id="status-panel">
<h3>🖼️ 纹理加载状态</h3>
<div id="texture-list">
<!-- 动态生成 -->
</div>
<div style="margin-top: 15px; padding: 10px; background: rgba(0,0,0,0.3); border-radius: 8px; text-align: center;" id="mipmap-status">
🔍 Mipmap状态: 等待加载...
</div>
</div>
<div id="process-flow">
⚙️ 纹理开发流程: 创建纹理 → 绑定 → 加载图片 → 上传GPU → 设置参数 → 生成Mipmap → 激活单元 → 着色器采样
</div>
<canvas id="canvas"></canvas>
<script>
(function() {
// ==================== 1. 初始化WebGL上下文 ====================
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl');
if (!gl) {
alert('您的浏览器不支持WebGL!');
return;
}
// 设置视口
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
// 开启深度测试
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
// ==================== 2. 定义着色器(含UV传递和纹理采样)====================
const vertexShaderSource = `
attribute vec4 a_Position; // 顶点位置
attribute vec2 a_TexCoord; // 纹理坐标 (UV)
attribute float a_TexIndex; // 纹理索引 (0-4)
varying vec2 v_TexCoord; // 传递给片元着色器的UV
varying float v_TexIndex; // 传递给片元着色器的索引
uniform mat4 u_ModelMatrix;
uniform mat4 u_ViewMatrix;
uniform mat4 u_ProjMatrix;
void main() {
// 步骤1: 传递UV坐标(GPU会在三角形内自动插值)
v_TexCoord = a_TexCoord;
v_TexIndex = a_TexIndex;
// 计算裁剪空间坐标
gl_Position = u_ProjMatrix * u_ViewMatrix * u_ModelMatrix * a_Position;
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec2 v_TexCoord; // 插值后的UV坐标 (每个像素不同)
varying float v_TexIndex; // 插值后的纹理索引
uniform sampler2D u_Sampler0; // 纹理单元0 - 猫
uniform sampler2D u_Sampler1; // 纹理单元1 - 狗
uniform sampler2D u_Sampler2; // 纹理单元2 - 熊猫
uniform sampler2D u_Sampler3; // 纹理单元3 - 马
uniform sampler2D u_Sampler4; // 纹理单元4 - 猪
void main() {
// 步骤2: 根据纹理索引选择对应的采样器
vec4 color;
if (v_TexIndex < 0.5) {
color = texture2D(u_Sampler0, v_TexCoord);
} else if (v_TexIndex < 1.5) {
color = texture2D(u_Sampler1, v_TexCoord);
} else if (v_TexIndex < 2.5) {
color = texture2D(u_Sampler2, v_TexCoord);
} else if (v_TexIndex < 3.5) {
color = texture2D(u_Sampler3, v_TexCoord);
} else {
color = texture2D(u_Sampler4, v_TexCoord);
}
gl_FragColor = color;
}
`;
// ==================== 3. 编译着色器 ====================
function createShader(gl, 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 vertexShader = createShader(gl, vertexShaderSource, gl.VERTEX_SHADER);
const fragmentShader = createShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// ==================== 4. 创建带纹理索引的立方体数据 ====================
function createCubeWithTextureIndices() {
// 顶点格式: [x, y, z, u, v, texIndex]
// 6个面 × 6个顶点 × 6个值 = 216个数值
const vertices = [];
// 辅助函数:添加一个面的顶点(两个三角形)
function addFace(x1, y1, z1, x2, y2, z2, x3, y3, z3, x4, y4, z4, texIndex) {
// 三角形1: 左下 → 右下 → 右上
vertices.push(x1, y1, z1, 0, 0, texIndex);
vertices.push(x2, y2, z2, 1, 0, texIndex);
vertices.push(x3, y3, z3, 1, 1, texIndex);
// 三角形2: 左下 → 右上 → 左上
vertices.push(x1, y1, z1, 0, 0, texIndex);
vertices.push(x3, y3, z3, 1, 1, texIndex);
vertices.push(x4, y4, z4, 0, 1, texIndex);
}
// 六个面分别使用不同的纹理索引
addFace(-1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, 0); // 前: 猫
addFace(-1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, -1, 1); // 后: 狗
addFace(-1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 2); // 左: 熊猫
addFace( 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 3); // 右: 马
addFace(-1, 1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, 4); // 上: 猪
addFace(-1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1, 0); // 下: 猫 (重复)
return new Float32Array(vertices);
}
const vertices = createCubeWithTextureIndices();
// 创建顶点缓冲区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 设置顶点属性指针
const FSIZE = vertices.BYTES_PER_ELEMENT;
const a_Position = gl.getAttribLocation(program, 'a_Position');
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0);
gl.enableVertexAttribArray(a_Position);
const a_TexCoord = gl.getAttribLocation(program, 'a_TexCoord');
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 6, FSIZE * 3);
gl.enableVertexAttribArray(a_TexCoord);
const a_TexIndex = gl.getAttribLocation(program, 'a_TexIndex');
gl.vertexAttribPointer(a_TexIndex, 1, gl.FLOAT, false, FSIZE * 6, FSIZE * 5);
gl.enableVertexAttribArray(a_TexIndex);
// ==================== 5. 纹理开发核心流程 ====================
// 动物图片配置
const textureItems = [
{ name: '猫', emoji: '🐱', url: 'https://images.pexels.com/photos/45201/kitty-cat-kitten-pet-45201.jpeg?w=512&h=512&fit=crop' },
{ name: '狗', emoji: '🐶', url: 'https://images.pexels.com/photos/1805164/pexels-photo-1805164.jpeg?w=512&h=512&fit=crop' },
{ name: '熊猫', emoji: '🐼', url: 'https://images.pexels.com/photos/158109/kodiak-brown-bear-adult-portrait-wildlife-158109.jpeg?w=512&h=512&fit=crop' },
{ name: '马', emoji: '🐴', url: 'https://images.pexels.com/photos/198709/pexels-photo-198709.jpeg?w=512&h=512&fit=crop' },
{ name: '猪', emoji: '🐷', url: 'https://images.pexels.com/photos/247968/pexels-photo-247968.jpeg?w=512&h=512&fit=crop' }
];
// 创建UI状态显示
function createStatusUI() {
const container = document.getElementById('texture-list');
container.innerHTML = '';
textureItems.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'texture-item';
div.id = `texture-${index}`;
div.innerHTML = `
<span class="texture-emoji">${item.emoji}</span>
<span class="texture-name">${item.name}</span>
<span class="state-badge badge-loading" id="badge-${index}">加载中</span>
`;
container.appendChild(div);
});
}
createStatusUI();
function updateTextureStatus(index, state, message) {
const badge = document.getElementById(`badge-${index}`);
if (badge) {
badge.className = `state-badge badge-${state}`;
badge.textContent = message;
}
}
// 检查是否为2的幂
function isPowerOf2(value) {
return (value & (value - 1)) === 0;
}
// 纹理状态管理
const textures = new Array(textureItems.length).fill(null);
let texturesLoaded = 0;
const totalTextures = textureItems.length;
// 核心函数:加载单个纹理(完整开发流程)
function loadTexture(gl, url, index) {
return new Promise((resolve) => {
console.log(`【步骤1】开始加载纹理 ${index+1}: ${url}`);
// ===== 步骤1: 创建纹理对象 =====
const texture = gl.createTexture();
// ===== 步骤2: 绑定纹理对象 =====
gl.bindTexture(gl.TEXTURE_2D, texture);
// ===== 步骤3: 设置默认数据(防止采样错误)=====
const defaultColor = new Uint8Array([200, 200, 200, 255]); // 灰色
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, defaultColor);
// ===== 步骤4: 创建Image对象并开始加载 =====
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
console.log(`【步骤4】图片 ${index+1} 加载完成: ${image.width}x${image.height}`);
// ===== 步骤5: 上传图片数据到GPU =====
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// ===== 步骤6: 设置纹理参数(过滤和环绕)=====
if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
// 2的幂纹理:可以使用Mipmap和REPEAT
console.log(` ✅ 图片是2的幂,生成Mipmap,支持REPEAT`);
// 设置缩小过滤(使用三线性过滤)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
// 设置放大过滤(使用线性过滤)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// 设置U方向环绕方式(重复)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
// 设置V方向环绕方式(重复)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
// ===== 步骤7: 生成Mipmap =====
gl.generateMipmap(gl.TEXTURE_2D);
console.log(` ✅ Mipmap生成完成`);
} else {
// 非2的幂纹理:不能使用Mipmap和REPEAT
console.log(` ℹ️ 图片不是2的幂,使用线性过滤和CLAMP_TO_EDGE`);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
}
// ===== 步骤8: 解除绑定 =====
gl.bindTexture(gl.TEXTURE_2D, null);
// 更新状态
textures[index] = texture;
texturesLoaded++;
updateTextureStatus(index, 'success', '成功');
// 更新Mipmap状态显示
if (texturesLoaded === totalTextures) {
document.getElementById('mipmap-status').innerHTML =
'✅ 所有纹理加载完成,Mipmap已生成(2的幂图片)';
}
console.log(`✅ 纹理 ${index+1} 完整加载流程完成`);
resolve(texture);
};
image.onerror = (error) => {
console.error(`❌ 纹理 ${index+1} 加载失败:`, error);
updateTextureStatus(index, 'error', '失败');
// 创建默认纹理
const defaultTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, defaultTex);
const colors = [
[255, 200, 200, 255], // 浅红
[200, 255, 200, 255], // 浅绿
[200, 200, 255, 255], // 浅蓝
[255, 255, 200, 255], // 浅黄
[255, 200, 255, 255] // 浅紫
];
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array(colors[index]));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
textures[index] = defaultTex;
texturesLoaded++;
resolve(defaultTex);
};
// 开始加载图片
image.src = url;
});
}
// 并行加载所有纹理
async function loadAllTextures() {
console.log('🚀 开始纹理加载完整流程...');
const promises = textureItems.map((item, index) => loadTexture(gl, item.url, index));
await Promise.all(promises);
console.log('✅ 所有纹理加载流程完成');
}
// ==================== 6. 设置Uniform变量 ====================
const u_ModelMatrix = gl.getUniformLocation(program, 'u_ModelMatrix');
const u_ViewMatrix = gl.getUniformLocation(program, 'u_ViewMatrix');
const u_ProjMatrix = gl.getUniformLocation(program, 'u_ProjMatrix');
// 获取5个采样器的位置
const u_Samplers = [
gl.getUniformLocation(program, 'u_Sampler0'),
gl.getUniformLocation(program, 'u_Sampler1'),
gl.getUniformLocation(program, 'u_Sampler2'),
gl.getUniformLocation(program, 'u_Sampler3'),
gl.getUniformLocation(program, 'u_Sampler4')
];
// 设置采样器单元
u_Samplers.forEach((loc, index) => {
gl.uniform1i(loc, index);
});
// 设置视图和投影矩阵
const viewMatrix = mat4.create();
mat4.lookAt(viewMatrix, [4, 3, 5], [0, 0, 0], [0, 1, 0]);
gl.uniformMatrix4fv(u_ViewMatrix, false, viewMatrix);
const projMatrix = mat4.create();
mat4.perspective(projMatrix, Math.PI / 3, canvas.width / canvas.height, 0.1, 100.0);
gl.uniformMatrix4fv(u_ProjMatrix, false, projMatrix);
// ==================== 7. 相机控制 ====================
let cameraRadius = 7.0;
let cameraTheta = 0.5;
let cameraPhi = 0.8;
let mouseX = 0, mouseY = 0, isMouseDown = false;
canvas.addEventListener('mousedown', (e) => {
isMouseDown = true;
mouseX = e.clientX;
mouseY = e.clientY;
});
canvas.addEventListener('mouseup', () => {
isMouseDown = false;
});
canvas.addEventListener('mousemove', (e) => {
if (isMouseDown) {
const deltaX = e.clientX - mouseX;
const deltaY = e.clientY - mouseY;
cameraTheta += deltaX * 0.005;
cameraPhi += deltaY * 0.005;
cameraPhi = Math.max(0.1, Math.min(Math.PI - 0.1, cameraPhi));
mouseX = e.clientX;
mouseY = e.clientY;
}
});
canvas.addEventListener('wheel', (e) => {
cameraRadius += e.deltaY * 0.005;
cameraRadius = Math.max(3.0, Math.min(15.0, cameraRadius));
e.preventDefault();
});
// ==================== 8. 开始加载纹理 ====================
loadAllTextures();
// ==================== 9. 渲染循环 ====================
const modelMatrix = mat4.create();
function render() {
// 更新相机
const cameraX = cameraRadius * Math.sin(cameraPhi) * Math.sin(cameraTheta);
const cameraY = cameraRadius * Math.cos(cameraPhi);
const cameraZ = cameraRadius * Math.sin(cameraPhi) * Math.cos(cameraTheta);
mat4.lookAt(viewMatrix, [cameraX, cameraY, cameraZ], [0, 0, 0], [0, 1, 0]);
gl.uniformMatrix4fv(u_ViewMatrix, false, viewMatrix);
// 旋转立方体
const time = performance.now() * 0.001;
mat4.identity(modelMatrix);
mat4.rotateY(modelMatrix, modelMatrix, time * 0.2);
mat4.rotateX(modelMatrix, modelMatrix, Math.sin(time * 0.1) * 0.1);
gl.uniformMatrix4fv(u_ModelMatrix, false, modelMatrix);
// ===== 步骤9: 激活纹理单元并绑定纹理 =====
for (let i = 0; i < 5; i++) {
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, textures[i] || null);
}
// 清除并绘制
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 36); // 36个顶点(6个面 × 6个顶点)
requestAnimationFrame(render);
}
render();
// ==================== 10. 窗口大小调整 ====================
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
mat4.perspective(projMatrix, Math.PI / 3, canvas.width / canvas.height, 0.1, 100.0);
gl.uniformMatrix4fv(u_ProjMatrix, false, projMatrix);
});
console.log('🚀 纹理映射完整示例已启动');
})();
</script>
</body>
</html>
6. 纹理开发流程总结
6.1 完整开发流程速查
| 步骤 | 操作 | 关键代码 | 说明 |
|---|---|---|---|
| 1 | 创建纹理对象 | gl.createTexture() |
GPU分配纹理ID |
| 2 | 绑定纹理 | gl.bindTexture(gl.TEXTURE_2D, texture) |
设置当前操作的纹理 |
| 3 | 准备图片 | new Image() |
创建Image对象 |
| 4 | 设置CORS | image.crossOrigin = 'anonymous' |
跨域图片必须 |
| 5 | 加载图片 | image.src = url |
开始异步加载 |
| 6 | 等待onload | image.onload = () => {} |
加载完成后处理 |
| 7 | 上传数据 | gl.texImage2D(...) |
CPU → GPU数据传输 |
| 8 | 设置过滤 | gl.texParameteri(...) |
控制采样质量 |
| 9 | 设置环绕 | gl.texParameteri(...) |
控制UV边界行为 |
| 10 | 生成Mipmap | gl.generateMipmap() |
创建LOD链 |
| 11 | 激活单元 | gl.activeTexture(gl.TEXTURE0) |
选择硬件单元 |
| 12 | 绑定到单元 | gl.bindTexture(...) |
关联纹理到单元 |
| 13 | 设置采样器 | gl.uniform1i(location, 0) |
告诉着色器用哪个单元 |
| 14 | 顶点着色器 | varying vec2 v_TexCoord |
传递UV坐标 |
| 15 | 片元着色器 | texture2D(sampler, uv) |
采样纹素颜色 |
| 16 | 绘制 | gl.drawArrays() |
GPU执行采样渲染 |
6.2 纹理参数选择指南
| 场景 | MIN_FILTER | MAG_FILTER | WRAP_S/T | Mipmap |
|---|---|---|---|---|
| 照片级真实感 | LINEAR_MIPMAP_LINEAR |
LINEAR |
CLAMP_TO_EDGE |
需要 |
| 像素风格游戏 | NEAREST_MIPMAP_NEAREST |
NEAREST |
REPEAT |
可选 |
| 重复纹理(砖墙) | LINEAR_MIPMAP_LINEAR |
LINEAR |
REPEAT |
需要 |
| UI元素 | LINEAR |
LINEAR |
CLAMP_TO_EDGE |
不需要 |
| 非2的幂图片 | LINEAR |
LINEAR |
CLAMP_TO_EDGE |
不能 |
7. 纹理开发常见问题排查
7.1 纹理显示为黑色
-
原因1:图片未加载完成就开始渲染
- 解决:使用加载状态管理,加载完成前显示占位纹理
-
原因2:CORS跨域问题
- 解决:设置
image.crossOrigin = 'anonymous'且服务器有CORS头
- 解决:设置
-
原因3:纹理单元未正确绑定
- 解决:检查
gl.activeTexture和gl.bindTexture顺序
- 解决:检查
7.2 纹理显示为紫色
-
原因:创建纹理后未上传数据,使用默认颜色
- 解决:确保
texImage2D成功执行
- 解决:确保
7.3 纹理模糊
-
原因 :过滤方式设置为
LINEAR,这是正常的平滑效果- 解决:如需像素风格,改为
NEAREST
- 解决:如需像素风格,改为
7.4 纹理边缘有奇怪颜色
-
原因 :非2的幂纹理使用了
REPEAT- 解决:使用
CLAMP_TO_EDGE或确保图片是2的幂
- 解决:使用
7.5 远处纹理闪烁
-
原因:未使用Mipmap
- 解决:生成Mipmap并设置合适的
MIN_FILTER
- 解决:生成Mipmap并设置合适的
8. 纹理映射核心知识点总结
✅ 必须掌握的概念:
-
UV坐标:每个顶点对应纹理上的位置,范围0-1
-
纹理采样 :
texture2D(sampler, uv)获取颜色 -
过滤方式 :
NEAREST/LINEAR控制采样质量 -
Mipmap:多级纹理提升性能和质量
-
环绕方式:控制UV超出边界的行为
✅ 必须遵循的流程:
-
创建纹理对象
-
绑定纹理
-
加载图片
-
上传到GPU
-
设置参数
-
生成Mipmap
-
激活单元并绑定
-
着色器采样
✅ 最佳实践:
-
总是为2的幂图片生成Mipmap
-
非2的幂图片使用
CLAMP_TO_EDGE -
处理好异步加载状态
-
使用纹理单元管理多纹理
掌握了纹理映射的原理和完整开发流程,你就可以在WebGL项目中给任何3D物体穿上真实的"皮肤"了!下一节我们将学习多纹理混合 和纹理动画等高级技巧。🎨