WebGPU 纹理

原文作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-textures.html


WebGPU Textures

前言

本文是 WebGPU 系列教程中关于如何向着色器提供数据的第五篇,聚焦于**纹理(Texture)这一核心数据通道。纹理在 WebGPU 中不仅仅用于 2D 图像,更是通过硬件采样器(Sampler)**实现高效过滤与插值的关键机制。本文将深入解读纹理的基础概念、采样机制、过滤模式、Mipmap 生成以及纹理类型与格式,并保留核心代码片段以帮助读者掌握实践要点。


WebGPU Textures

一、纹理基础:为什么需要纹理?

纹理通常代表一张 2D 图像,本质上是一个 2D 颜色数组。我们完全可以用存储缓冲区(Storage Buffer)来模拟,但纹理的特殊之处在于它可以被一种专用硬件——**采样器(Sampler)**访问。采样器能够一次性读取纹理中最多 16 个值,并根据过滤设置进行混合,这对于图像缩放、平滑显示等常见需求极为高效。

纹理坐标

纹理坐标(UV 坐标)在采样时以归一化形式表示,范围为 0.0 到 1.0,与纹理的实际尺寸无关。坐标 (0,0) 对应纹理数据的第一个纹素(texel),通常位于纹理的左下角(取决于数据排列方式)。

简单示例:绘制带纹理的四边形

将之前的三角形示例改为四边形(两个三角形),并在着色器中使用纹理采样:

struct OurVertexShaderOutput {
  @builtin(position) position: vec4f,
  @location(0) texcoord: vec2f,
};

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32
) -> OurVertexShaderOutput {
  let pos = array(
    // 第一个三角形
    vec2f( 0.0,  0.0),  // 中心
    vec2f( 1.0,  0.0),  // 右中
    vec2f( 0.0,  1.0),  // 中上

    // 第二个三角形
    vec2f( 0.0,  1.0),  // 中上
    vec2f( 1.0,  0.0),  // 右中
    vec2f( 1.0,  1.0),  // 右上
  );

  var vsOutput: OurVertexShaderOutput;
  let xy = pos[vertexIndex];
  vsOutput.position = vec4f(xy, 0.0, 1.0);
  vsOutput.texcoord = xy;
  return vsOutput;
}

@group(0) @binding(0) var ourSampler: sampler;
@group(0) @binding(1) var ourTexture: `texture_2d<f32>`;

@fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
  return textureSample(ourTexture, ourSampler, fsInput.texcoord);
}

⚠️ 注意:这里为了简化直接使用了裁剪空间位置作为纹理坐标。实际项目中纹理坐标通常通过顶点缓冲区提供。


二、纹理数据与创建

创建一个 5x7 纹素的纹理

const kTextureWidth = 5;
const kTextureHeight = 7;
const _ = [255,   0,   0, 255];  // 红
const y = [255, 255,   0, 255];  // 黄
const b = [  0,   0, 255, 255];  // 蓝
const textureData = new Uint8Array([
  b, _, _, _, _,
  _, y, y, y, _,
  _, y, _, _, _,
  _, y, y, _, _,
  _, y, _, _, _,
  _, y, _, _, _,
  _, _, _, _, _,
].flat());

创建 GPUTexture 对象

使用 rgba8unorm 格式,表示每个通道 8 位无符号归一化整数,在着色器中会被自动转换为 [0.0, 1.0] 的浮点值。

const texture = device.createTexture({
  size: [kTextureWidth, kTextureHeight],
  format: 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

device.queue.writeTexture(
  { texture },
  textureData,
  { bytesPerRow: kTextureWidth * 4 },
  { width: kTextureWidth, height: kTextureHeight },
);

创建采样器并绑定

const sampler = device.createSampler();

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: sampler },
    { binding: 1, resource: texture },
  ],
});

三、过滤模式:magFilter 与 minFilter

采样器可以配置不同的过滤模式,影响纹理被放大或缩小时的颜色计算方式。

magFilter(放大过滤)

当纹理被放大显示时,采样器默认使用 nearest(最近邻),也可以设置为 linear(线性插值)。线性插值会取周围 4 个纹素,根据纹理坐标的小数部分进行混合,产生更平滑的结果。

const sampler = device.createSampler({
  magFilter: 'linear',
  addressModeU: 'repeat',
  addressModeV: 'repeat',
});

minFilter(缩小过滤)

当纹理被缩小显示时,如果仍使用 linear,由于只取 4 个纹素混合,会导致严重的闪烁和颜色跳动。解决方案是使用 Mipmap。


四、Mipmap:解决缩小闪烁的关键

Mipmap(多级渐远纹理)是一系列逐级缩小的纹理,每一级都是上一级尺寸的一半(向下取整),并预先混合颜色。当纹理被缩小时,GPU 可以根据实际绘制尺寸自动选择合适的 Mip 级别,避免单次采样带来的闪烁。

生成 Mipmap 的示例代码

const lerp = (a, b, t) => a + (b - a) * t;
const mix = (a, b, t) => a.map((v, i) => lerp(v, b[i], t));
const bilinearFilter = (tl, tr, bl, br, t1, t2) => {
  const t = mix(tl, tr, t1);
  const b = mix(bl, br, t1);
  return mix(t, b, t2);
};

const createNextMipLevelRgba8Unorm = ({data: src, width: srcWidth, height: srcHeight}) => {
  const dstWidth = Math.max(1, srcWidth / 2 | 0);
  const dstHeight = Math.max(1, srcHeight / 2 | 0);
  const dst = new Uint8Array(dstWidth * dstHeight * 4);

  const getSrcPixel = (x, y) => {
    const offset = (y * srcWidth + x) * 4;
    return src.subarray(offset, offset + 4);
  };

  for (let y = 0; y < dstHeight; ++y) {
    for (let x = 0; x < dstWidth; ++x) {
      const u = (x + 0.5) / dstWidth;
      const v = (y + 0.5) / dstHeight;
      const au = (u * srcWidth - 0.5);
      const av = (v * srcHeight - 0.5);
      const tx = au | 0;
      const ty = av | 0;
      const t1 = au % 1;
      const t2 = av % 1;

      const tl = getSrcPixel(tx, ty);
      const tr = getSrcPixel(tx + 1, ty);
      const bl = getSrcPixel(tx, ty + 1);
      const br = getSrcPixel(tx + 1, ty + 1);

      const dstOffset = (y * dstWidth + x) * 4;
      dst.set(bilinearFilter(tl, tr, bl, br, t1, t2), dstOffset);
    }
  }
  return { data: dst, width: dstWidth, height: dstHeight };
};

const generateMips = (src, srcWidth) => {
  const srcHeight = src.length / 4 / srcWidth;
  let mip = { data: src, width: srcWidth, height: srcHeight };
  const mips = [mip];
  while (mip.width > 1 || mip.height > 1) {
    mip = createNextMipLevelRgba8Unorm(mip);
    mips.push(mip);
  }
  return mips;
};

创建带 Mipmap 的纹理

const mips = generateMips(textureData, kTextureWidth);

const texture = device.createTexture({
  label: 'yellow F on red',
  size: [mips[0].width, mips[0].height],
  mipLevelCount: mips.length,
  format: 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

mips.forEach(({data, width, height}, mipLevel) => {
  device.queue.writeTexture(
    { texture, mipLevel },
    data,
    { bytesPerRow: width * 4 },
    { width, height },
  );
});

五、mipmapFilter:Mip 级别之间的过渡

除了 magFilter 和 minFilter,还有 mipmapFilter,用于控制 Mip 级别切换时的过渡方式。设置为 linear 时,GPU 会从两个相邻 Mip 级别中采样并混合,产生更平滑的过渡,尤其适用于 3D 场景中的远景平面。

示例:绘制 8 个不同过滤组合的平面,可以直观对比。

const sampler = device.createSampler({
  addressModeU: 'repeat',
  addressModeV: 'repeat',
  magFilter: (i & 1) ? 'linear' : 'nearest',
  minFilter: (i & 2) ? 'linear' : 'nearest',
  mipmapFilter: (i & 4) ? 'linear' : 'nearest',
});

六、纹理类型与视图

WebGPU 支持三种基础纹理类型:

类型说明
"1d"一维纹理,高度和深度固定为 1
"2d"二维纹理,最常用
"3d"三维纹理,可视为多个 2D 纹理的堆叠

纹理视图(Texture View) 则提供了更丰富的解释方式:

  • "2d" 纹理可以有 "2d-array"、"cube"、"cube-array" 视图
  • "1d" 和 "3d" 纹理只能有同维度视图
  • 立方体纹理(Cube Map)常用于天空盒、环境映射等

在 WGSL 中,纹理类型与视图维度对应:

视图维度WGSL 类型
"1d"texture_1d 或 texture_storage_1d
"2d"texture_2d、texture_storage_2d、texture_multisampled_2d 等
"2d-array"texture_2d_array、texture_storage_2d_array
"3d"texture_3d、texture_storage_3d
"cube"texture_cube、texture_depth_cube
"cube-array"texture_cube_array、texture_depth_cube_array

七、纹理格式概览

WebGPU 支持丰富的纹理格式,命名规则如 rgba8unorm、rg16float、rg11b10ufloat 等,其中字母表示通道(r/g/b/a),数字表示每通道位数,后缀表示数据类型:

  • unorm:无符号归一化(0 → 1)
  • snorm:有符号归一化(-1 → +1)
  • uint:无符号整数
  • sint:有符号整数
  • float:浮点数

某些格式还需要特殊特性支持(如 float32-filterable),并且采样器类型在 WGSL 中需匹配(texture_2d<f32>、texture_2d<i32>、texture_2d<u32>)。


3 个核心优化要点

  1. 使用 Mipmap 解决纹理缩小闪烁
    当纹理被缩小显示时,必须生成 Mipmap 并设置 minFilter: 'linear'(或配合 mipmapFilter: 'linear'),否则会出现严重的颜色跳动和视觉噪声。Mipmap 预先混合了各级颜色,使 GPU 能选择合适级别或进行级别间过渡。

  2. 合理选择过滤模式以平衡性能与画质
    所有过滤均设为 linear 可获得最佳平滑效果,但采样时需要读取 8 个纹素(2D 纹理),而 nearest 只需 1 个纹素。对于像素风格游戏或性能敏感场景,应优先使用 nearest;对于需要平滑缩放的场景,使用 linear + Mipmap。

  3. 根据硬件能力选择纹理格式与采样器
    某些纹理格式(如 rgba32float)默认不可过滤,除非启用 float32-filterable 特性。在移动设备上,32 位浮点纹理过滤通常不可用,因此应尽量使用 rgba16float 或 rgba8unorm 等更通用的格式。同时,注意 WGSL 中纹理泛型参数(<f32>、<i32>、<u32>)必须与纹理格式匹配。


GLSL/WebGL 专业术语检查

本文涉及的部分术语在 GLSL/WebGL 中有对应概念,为确保读者不混淆,特此对照说明:

WebGPU / WGSL 术语GLSL / WebGL 对应术语说明
texture_2d<f32>sampler2D二维纹理对象
samplersampler(在 GLSL 中通常与纹理绑定,但概念相同)采样器,定义过滤和寻址模式
textureSample()texture() 或 texture2D()纹理采样函数
magFilter / minFilterGL_TEXTURE_MAG_FILTER / GL_TEXTURE_MIN_FILTER放大/缩小过滤模式
nearest / linearGL_NEAREST / GL_LINEAR最近邻 / 线性插值
mipmapFilterGL_TEXTURE_MIN_FILTER 中的 GL_LINEAR_MIPMAP_LINEAR 等Mipmap 过滤
addressModeU/V (repeat, clamp-to-edge)GL_REPEAT, GL_CLAMP_TO_EDGE纹理坐标包裹模式
rgba8unormGL_RGBA8纹理内部格式
mipLevelCountglGenerateMipmap() 后自动生成Mipmap 级别数量
GPUTextureUsage.RENDER_ATTACHMENTgl.framebufferTexture2D()渲染到纹理

在 GLSL 中,采样器通常直接与纹理绑定,而在 WebGPU/WGSL 中,纹理和采样器是独立的资源,通过绑定组分别绑定。这一设计使得采样器可以复用,也便于精确控制过滤行为。


总结

本文深入解读了 WebGPU 纹理的基础概念与采样机制,涵盖了纹理数据创建、过滤模式、Mipmap 生成、纹理类型与视图以及纹理格式等核心内容。通过合理配置采样器和纹理格式,开发者能够在 WebGPU 中高效地实现高质量图像渲染。后续文章将继续探讨图像导入、视频纹理、立方体贴图等进阶主题。