WebGL基础教程(十四):网络图片纹理映射渲染完整实战(新手也能轻松上手)

还在为WebGL物体只有单调的颜色而烦恼?想让3D模型拥有真实的砖墙、木质纹理或者照片级的外观,却不知道如何下手?

今天这篇教程,我们将彻底搞懂WebGL的纹理映射技术。从最基础的纹理坐标原理、纹理过滤、环绕方式,到完整的纹理加载流程和着色器采样,最终实现一个带有五张动物图片的旋转立方体。全文将围绕标准的WebGL API展开,手把手带你写出第一个纹理程序,新手也能跟着敲出效果,彻底掌握WebGL贴图的核心。

效果如下图:

1. 先搞懂:纹理映射的本质

1.1 什么是纹理映射?

纹理映射(Texture Mapping) 是将一张2D图片"贴"到3D物体表面的技术。这张图片可以是照片、手绘图、程序生成的图案,甚至是视频。

为什么需要纹理映射?

  • 没有纹理:物体只有单调的颜色,像塑料模型

  • 有纹理:物体拥有丰富的表面细节,像真实世界

1.2 纹理映射的核心原理

纹理映射的本质是从纹理空间到屏幕空间的坐标变换

  1. 纹理空间 :UV坐标系,范围 [0, 0][1, 1]

  2. 模型空间 :3D顶点坐标 (x, y, z)

  3. 映射过程:每个顶点被赋予一个UV坐标,GPU在光栅化阶段对三角形内部的每个像素进行UV插值,然后用插值后的UV去纹理图片上采样颜色

数学表达

text

复制代码
对于三角形内的任意点P,其纹理坐标 = α·UV₁ + β·UV₂ + γ·UV₃
其中α+β+γ=1,是重心坐标
1.3 纹理采样原理

当GPU确定一个片元(像素)的颜色时,它会:

  1. 根据插值得到该片元的UV坐标 (u, v)

  2. 将UV坐标映射到纹理图像的具体位置:

    text

    复制代码
    x_tex = u × 纹理宽度
    y_tex = v × 纹理高度
  3. 根据纹理过滤模式,采样一个或多个纹素计算最终颜色

最近邻采样公式

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?

  1. 提升渲染质量:远处的物体自动使用低分辨率纹理,避免摩尔纹和闪烁

  2. 提升性能:减少纹理采样时的内存带宽消耗

  3. 提升缓存效率:小纹理更容易命中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]范围时,可以通过环绕方式定义如何获取颜色。

四种环绕方式

  1. gl.REPEAT(重复):

    text

    复制代码
    效果:纹理在表面上无限重复平铺
    公式:u' = u - floor(u)
    适用:砖墙、地板、布料等重复性纹理
  2. gl.MIRRORED_REPEAT(镜像重复):

    text

    复制代码
    效果:纹理重复,但每次重复时进行镜像翻转
    公式:u' = 1 - |(u mod 2) - 1|
    适用:需要对称效果的纹理
  3. gl.CLAMP_TO_EDGE(钳位到边缘):

    text

    复制代码
    效果:超出范围的坐标取纹理边缘的颜色
    公式:u' = clamp(u, 0, 1)
    适用:照片、UI元素、非重复纹理
  4. 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函数工作原理

  1. 接收UV坐标 (u, v)

  2. 计算纹素位置:x = u * width, y = v * height

  3. 根据纹理过滤模式,采样一个或多个纹素

  4. 返回颜色值 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.activeTexturegl.bindTexture 顺序
7.2 纹理显示为紫色
  • 原因:创建纹理后未上传数据,使用默认颜色

    • 解决:确保 texImage2D 成功执行
7.3 纹理模糊
  • 原因 :过滤方式设置为 LINEAR,这是正常的平滑效果

    • 解决:如需像素风格,改为 NEAREST
7.4 纹理边缘有奇怪颜色
  • 原因 :非2的幂纹理使用了 REPEAT

    • 解决:使用 CLAMP_TO_EDGE 或确保图片是2的幂
7.5 远处纹理闪烁
  • 原因:未使用Mipmap

    • 解决:生成Mipmap并设置合适的 MIN_FILTER

8. 纹理映射核心知识点总结

必须掌握的概念

  • UV坐标:每个顶点对应纹理上的位置,范围0-1

  • 纹理采样texture2D(sampler, uv) 获取颜色

  • 过滤方式NEAREST/LINEAR 控制采样质量

  • Mipmap:多级纹理提升性能和质量

  • 环绕方式:控制UV超出边界的行为

必须遵循的流程

  1. 创建纹理对象

  2. 绑定纹理

  3. 加载图片

  4. 上传到GPU

  5. 设置参数

  6. 生成Mipmap

  7. 激活单元并绑定

  8. 着色器采样

最佳实践

  • 总是为2的幂图片生成Mipmap

  • 非2的幂图片使用 CLAMP_TO_EDGE

  • 处理好异步加载状态

  • 使用纹理单元管理多纹理

掌握了纹理映射的原理和完整开发流程,你就可以在WebGL项目中给任何3D物体穿上真实的"皮肤"了!下一节我们将学习多纹理混合纹理动画等高级技巧。🎨

相关推荐
河码匠2 小时前
Linux sar 命令
linux·运维·网络
Xzq2105092 小时前
以太网协议 —— 数据链路层
服务器·网络·网络协议
xUxIAOrUIII2 小时前
【WebSocket】原理介绍
网络·websocket·网络协议
Predestination王瀞潞3 小时前
计科-计网8-计算题「整理」
网络·计算机网络·架构·智能路由器·计网
aq55356003 小时前
SQL 注入漏洞原理以及修复方法
网络·数据库·sql
nanaki502133 小时前
Lwip协议简述
网络·lwip
德迅云安全杨德俊3 小时前
筑牢企业服务器防线:安全体系构建实操手册
网络·安全·web安全
liulilittle3 小时前
OPENPPP2静态隧道UDP中断问题排查与解决
网络·网络协议·ubuntu·udp·debian·信息与通信·通信
清水白石0083 小时前
Python 方法绑定机制深度解析:bound method、三种方法类型与代码评审实战
开发语言·网络·python