WebGPU 加载图像到纹理

原文标题:WebGPU Loading Images into Textures
原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html


本文将介绍如何把图像加载到纹理中,以及在 GPU 上生成 mipmap。

WebGPU Loading Images into Textures

使用 copyExternalImageToTexture 加载图像

上一篇文章中,我们通过 device.createTexture 创建纹理,再用 device.queue.writeTexture 写入数据。其实 device.queue 上还有一个函数 device.queue.copyExternalImageToTexture,可以直接将图像复制到纹理中。它接受一个 ImageBitmap 作为源,所以我们可以从上一篇文章的 magFilter 示例修改,改为加载几张图片。

首先编写从图片 URL 获取 ImageBitmap 的代码:

async function loadImageBitmap(url) {
  const res = await fetch(url);
  const blob = await res.blob();
  return await createImageBitmap(blob, { colorSpaceConversion: "none" });
}

这段代码调用 fetch 获取图片 URL 的响应,再从中取得 Blob(图片文件的原始数据),然后调用浏览器标准 API createImageBitmap 生成 ImageBitmap。我们传入 { colorSpaceConversion: 'none' } 告诉浏览器不要应用任何色彩空间转换。是否让浏览器做色彩空间转换取决于你的需求——在 WebGPU 中,我们常加载法线贴图、高度图等非颜色数据,此时绝对不希望浏览器修改图像数据。

有了 ImageBitmap 后,加载一张图片并创建相同尺寸的纹理。这里使用下面这张 F 纹理(带 F 的纹理便于观察方向):

-  const texture = device.createTexture({
-    label: 'yellow F on red',
-    size: [kTextureWidth, kTextureHeight],
-    format: 'rgba8unorm',
-    usage:
-      GPUTextureUsage.TEXTURE_BINDING |
-      GPUTextureUsage.COPY_DST,
-  });
+  const url = 'resources/images/f-texture.png';
+  const source = await loadImageBitmap(url);
+  const texture = device.createTexture({
+    label: url,
+    format: 'rgba8unorm',
+    size: [source.width, source.height],
+    usage: GPUTextureUsage.TEXTURE_BINDING |
+           GPUTextureUsage.COPY_DST |
+           GPUTextureUsage.RENDER_ATTACHMENT,
+  });

注意 copyExternalImageToTexture 要求纹理的 usage 中包含 GPUTextureUsage.COPY_DST 和 GPUTextureUsage.RENDER_ATTACHMENT 标志。

然后将 ImageBitmap 复制到纹理:

-  device.queue.writeTexture(
-      { texture },
-      textureData,
-      { bytesPerRow: kTextureWidth * 4 },
-      { width: kTextureWidth, height: kTextureHeight },
-  );
+  device.queue.copyExternalImageToTexture(
+    { source, flipY: true },
+    { texture },
+    { width: source.width, height: source.height },
+  );

copyExternalImageToTexture 的参数依次是:源、目标、尺寸。源对象中可以设置 flipY: true 在加载时翻转 Y 轴。

这样就可以工作了。

Simple Image Loading Functions

在 GPU 上生成 mipmap

在上一篇文章中我们生成了 mipmap,但当时是直接操作图像数据。加载图片时,你可以先把图片绘制到 2D canvas,再调用 getImageData 获取像素数据,然后生成 mip 并上传。但这样做很慢,而且 canvas 2D 渲染本身是有损的(实现相关)。

之前生成 mip 时我们用的是双线性插值,而这正是 GPU 使用 minFilter: linear 时所做的。所以我们可以利用这个特性在 GPU 上生成 mip。

计算所需 mip 层数:

const numMipLevels = (...sizes) => {
  const maxSize = Math.max(...sizes);
  return (1 + Math.log2(maxSize)) | 0;
};

Math.log2 告诉我们把数字除以 2 多少次后得到 1。例如 numMipLevels(123, 456) 返回 9,因为需要 9 层(0 到 8)。

创建纹理时指定正确的 mip 层数:

const texture = device.createTexture({
  label: url,
  format: "rgba8unorm",
  mipLevelCount: numMipLevels(source.width, source.height),
  size: [source.width, source.height],
  usage:
    GPUTextureUsage.TEXTURE_BINDING |
    GPUTextureUsage.COPY_DST |
    GPUTextureUsage.RENDER_ATTACHMENT,
});
device.queue.copyExternalImageToTexture(
  { source, flipY: true },
  { texture },
  { width: source.width, height: source.height },
);

生成 mip 的完整函数如下(代码较长,但与之前纹理示例几乎相同):

const generateMips = (() => {
  let sampler;
  let module;
  const pipelineByFormat = {};

  return function generateMips(device, texture) {
    if (!module) {
      module = device.createShaderModule({
        label: 'textured quad shaders for mip level generation',
        code: /* wgsl */ `
          struct VSOutput {
            @builtin(position) position: vec4f,
            @location(0) texcoord: vec2f,
          };

          @vertex fn vs(
            @builtin(vertex_index) vertexIndex : u32
          ) -> VSOutput {
            let pos = array(
              // 1st triangle
              vec2f( 0.0,  0.0),  // center
              vec2f( 1.0,  0.0),  // right, center
              vec2f( 0.0,  1.0),  // center, top

              // 2nd triangle
              vec2f( 0.0,  1.0),  // center, top
              vec2f( 1.0,  0.0),  // right, center
              vec2f( 1.0,  1.0),  // right, top
            );

            var vsOutput: VSOutput;
            let xy = pos[vertexIndex];
            vsOutput.position = vec4f(xy * 2.0 - 1.0, 0.0, 1.0);
            vsOutput.texcoord = vec2f(xy.x, 1.0 - xy.y);
            return vsOutput;
          }

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

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

      sampler = device.createSampler({
        minFilter: 'linear',
      });
    }

    if (!pipelineByFormat[texture.format]) {
      pipelineByFormat[texture.format] = device.createRenderPipeline({
        label: 'mip level generator pipeline',
        layout: 'auto',
        vertex: {
          module,
        },
        fragment: {
          module,
          targets: [{ format: texture.format }],
        },
      });
    }
    const pipeline = pipelineByFormat[texture.format];

    const encoder = device.createCommandEncoder({
      label: 'mip gen encoder',
    });

    for (let baseMipLevel = 1; baseMipLevel < texture.mipLevelCount; ++baseMipLevel) {
      const bindGroup = device.createBindGroup({
        layout: pipeline.getBindGroupLayout(0),
        entries: [
          { binding: 0, resource: sampler },
          {
            binding: 1,
            resource: texture.createView({
              baseMipLevel: baseMipLevel - 1,
              mipLevelCount: 1,
            }),
          },
        ],
      });

      const renderPassDescriptor = {
        label: 'our basic canvas renderPass',
        colorAttachments: [
          {
            view: texture.createView({
              baseMipLevel,
              mipLevelCount: 1,
            }),
            loadOp: 'clear',
            storeOp: 'store',
          },
        ],
      };

      const pass = encoder.beginRenderPass(renderPassDescriptor);
      pass.setPipeline(pipeline);
      pass.setBindGroup(0, bindGroup);
      pass.draw(6);  // call our vertex shader 6 times
      pass.end();
    }
    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  };
})();

关键点:

  • 使用闭包保存 module、sampler、pipelineByFormat 以便复用。
  • 着色器与前例几乎相同,只是将顶点位置从 uni.matrix 改为直接映射到裁剪空间,并翻转 Y 纹理坐标,以保证绘制到整个目标 mip 层。
  • pipelineByFormat 按纹理格式缓存渲染管线,因为管线需要知道目标格式。
  • 使用 texture.createView 选择特定的 mip 层:绑定组中读取上一级 mip,渲染目标设置为当前 mip。
  • 遍历每个 mip 层,逐级生成。

注意:这个函数只处理 2D 纹理。立方体贴图文章将扩展它以处理 2D 数组纹理和立方体贴图。

Simple Image Loading Functions

简单图像加载辅助函数

为了方便加载图像并生成 mip,我们封装两个函数:

function copySourceToTexture(device, texture, source, { flipY } = {}) {
  device.queue.copyExternalImageToTexture(
    { source, flipY },
    { texture },
    { width: source.width, height: source.height },
  );

  if (texture.mipLevelCount > 1) {
    generateMips(device, texture);
  }
}
function createTextureFromSource(device, source, options = {}) {
  const texture = device.createTexture({
    format: "rgba8unorm",
    mipLevelCount: options.mips ? numMipLevels(source.width, source.height) : 1,
    size: [source.width, source.height],
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });
  copySourceToTexture(device, texture, source, options);
  return texture;
}
async function createTextureFromImage(device, url, options) {
  const imgBitmap = await loadImageBitmap(url);
  return createTextureFromSource(device, imgBitmap, options);
}

替换原有的 createTextureWithMips 调用:

-  const textures = [
-    createTextureWithMips(createBlendedMipmap(), 'blended'),
-    createTextureWithMips(createCheckedMipmap(), 'checker'),
-  ];
+  const textures = await Promise.all([
+    await createTextureFromImage(device,
+        'resources/images/f-texture.png', {mips: true, flipY: false}),
+    await createTextureFromImage(device,
+        'resources/images/coins.jpg', {mips: true}),
+    await createTextureFromImage(device,
+        'resources/images/Granite_paving_tileable_512x512.jpeg', {mips: true}),
+  ]);

加载 Canvas

copyExternalImageToTexture 还接受 HTMLCanvasElement 作为源。我们可以先在 2D canvas 上绘制,再将结果加载到 WebGPU 纹理。

先创建 canvas 动画:

const size = 256;
const half = size / 2;

const ctx = document.createElement("canvas").getContext("2d");
ctx.canvas.width = size;
ctx.canvas.height = size;

const hsl = (h, s, l) =>
  `hsl(${(h * 360) | 0}, ${s * 100}%, ${(l * 100) | 0}%)`;

function update2DCanvas(time) {
  time *= 0.0001;
  ctx.clearRect(0, 0, size, size);
  ctx.save();
  ctx.translate(half, half);
  const num = 20;
  for (let i = 0; i < num; ++i) {
    ctx.fillStyle = hsl((i / num) * 0.2 + time * 0.1, 1, (i % 2) * 0.5);
    ctx.fillRect(-half, -half, size, size);
    ctx.rotate(time * 0.5);
    ctx.scale(0.85, 0.85);
    ctx.translate(size / 16, 0);
  }
  ctx.restore();
}

function render(time) {
  update2DCanvas(time);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

加载 canvas 到 WebGPU 只需少量修改:

+  const texture = createTextureFromSource(device, ctx.canvas, {mips: true});

  const textures = await Promise.all([
-    await createTextureFromImage(device,
-        'resources/images/f-texture.png', {mips: true, flipY: false}),
-    await createTextureFromImage(device,
-        'resources/images/coins.jpg', {mips: true}),
-    await createTextureFromImage(device,
-        'resources/images/Granite_paving_tileable_512x512.jpeg', {mips: true}),
+    texture,
  ]);

然后改用 requestAnimationFrame 循环,更新 2D canvas 并上传到 WebGPU:

-  function render() {
+  function render(time) {
+    update2DCanvas(time);
+    copySourceToTexture(device, texture, ctx.canvas);

     ...

    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);

加载 Video

加载视频的方法类似。ImageBitmap 和 HTMLCanvasElement 的尺寸属性是 width / height,而 HTMLVideoElement 的尺寸是 videoWidth / videoHeight。需要更新辅助函数以兼容:

+  function getSourceSize(source) {
+    return [
+      source.videoWidth || source.width,
+      source.videoHeight || source.height,
+    ];
+  }

  function copySourceToTexture(device, texture, source, {flipY} = {}) {
    device.queue.copyExternalImageToTexture(
      { source, flipY, },
      { texture },
-      { width: source.width, height: source.height },
+      getSourceSize(source),
    );

    if (texture.mipLevelCount > 1) {
      generateMips(device, texture);
    }
  }

  function createTextureFromSource(device, source, options = {}) {
+    const size = getSourceSize(source);
    const texture = device.createTexture({
      format: 'rgba8unorm',
-      mipLevelCount: options.mips ? numMipLevels(source.width, source.height) : 1,
-      size: [source.width, source.height],
+      mipLevelCount: options.mips ? numMipLevels(...size) : 1,
+      size,
      usage: GPUTextureUsage.TEXTURE_BINDING |
             GPUTextureUsage.COPY_DST |
             GPUTextureUsage.RENDER_ATTACHMENT,
    });
    copySourceToTexture(device, texture, source, options);
    return texture;
  }

视频元素需要等待播放开始才能使用。使用 video.requestVideoFrameCallback 获取新帧回调,或回退到轮询 currentTime:

+  function startPlayingAndWaitForVideo(video) {
+    return new Promise((resolve, reject) => {
+      video.addEventListener('error', reject);
+      if ('requestVideoFrameCallback' in video) {
+        video.requestVideoFrameCallback(resolve);
+      } else {
+        const timeWatcher = () => {
+          if (video.currentTime > 0) {
+            resolve();
+          } else {
+            requestAnimationFrame(timeWatcher);
+          }
+        };
+        timeWatcher();
+      }
+      video.play().catch(reject);
+    });
+  }

此外,浏览器通常要求用户交互后才能播放视频,因此需要添加点击播放按钮:

<body>
  <canvas></canvas>
  +
  <div id="start">
    +
    <div>▶️</div>
    +
  </div>
</body>
#start {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
#start > div {
  font-size: 200px;
  cursor: pointer;
}
+  function waitForClick() {
+    return new Promise(resolve => {
+      window.addEventListener(
+        'click',
+        () => {
+          document.querySelector('#start').style.display = 'none';
+          resolve();
+        },
+        { once: true });
+    });
+  }

优化:仅在视频有新帧时更新纹理,避免每渲染帧都复制视频数据:

  const video = document.createElement('video');
  video.muted = true;
  video.loop = true;
  video.preload = 'auto';
  video.src = 'resources/videos/Golden_retriever_swimming_the_doggy_paddle-360-no-audio.webm';
  await waitForClick();
  await startPlayingAndWaitForVideo(video);

+  let alwaysUpdateVideo = !('requestVideoFrameCallback' in video);
+  let haveNewVideoFrame = false;
+  if (!alwaysUpdateVideo) {
+    function recordHaveNewFrame() {
+      haveNewVideoFrame = true;
+      video.requestVideoFrameCallback(recordHaveNewFrame);
+    }
+    video.requestVideoFrameCallback(recordHaveNewFrame);
+  }

  ...

  function render() {
+    if (alwaysUpdateVideo || haveNewVideoFrame) {
+      haveNewVideoFrame = false;
      copySourceToTexture(device, texture, video);
+    }

    ...

但注意:WebGPU 对高效使用视频有特殊支持。本文使用 copyExternalImageToTexture 实际上产生了一次拷贝。例如 4K 视频分辨率 3840×2160,rgba8unorm 下每帧约 31MB 数据需要复制。下一篇文章将介绍外部纹理,它允许直接使用视频数据(零拷贝),但需要不同的方法和有一些限制。

纹理图集(Texture Atlases)

如果同一个物体需要多个不同纹理(如椅子腿是木头、坐垫是布料),可以为每个部分单独绘制,但会产生多次 Draw Call。更常见的做法是使用纹理图集:把多个图像合并到一张大纹理中,然后用纹理坐标选择不同区域。

例如,把 6 张图合并为一张 4×2 的图集:

然后为立方体的每个面设置对应图集区域的纹理坐标。顶点数据如下(包含位置和纹理坐标):

function createCubeVertices() {
  const vertexData = new Float32Array([
    //  position   |  texture coordinate
    //-------------+----------------------
    // front face     select the top left image
    -1, 1, 1, 0, 0, -1, -1, 1, 0, 0.5, 1, 1, 1, 0.25, 0, 1, -1, 1, 0.25, 0.5,
    // right face     select the top middle image
    1, 1, -1, 0.25, 0, 1, 1, 1, 0.5, 0, 1, -1, -1, 0.25, 0.5, 1, -1, 1, 0.5,
    0.5,
    // back face      select to top right image
    1, 1, -1, 0.5, 0, 1, -1, -1, 0.5, 0.5, -1, 1, -1, 0.75, 0, -1, -1, -1, 0.75,
    0.5,
    // left face       select the bottom left image
    -1, 1, 1, 0, 0.5, -1, 1, -1, 0.25, 0.5, -1, -1, 1, 0, 1, -1, -1, -1, 0.25,
    1,
    // bottom face     select the bottom middle image
    1, -1, 1, 0.25, 0.5, -1, -1, 1, 0.5, 0.5, 1, -1, -1, 0.25, 1, -1, -1, -1,
    0.5, 1,
    // top face        select the bottom right image
    -1, 1, 1, 0.5, 0.5, 1, 1, 1, 0.75, 0.5, -1, 1, -1, 0.5, 1, 1, 1, -1, 0.75,
    1,
  ]);

  const indexData = new Uint16Array([
    0,
    1,
    2,
    2,
    1,
    3, // front
    4,
    5,
    6,
    6,
    5,
    7, // right
    8,
    9,
    10,
    10,
    9,
    11, // back
    12,
    13,
    14,
    14,
    13,
    15, // left
    16,
    17,
    18,
    18,
    17,
    19, // bottom
    20,
    21,
    22,
    22,
    21,
    23, // top
  ]);

  return {
    vertexData,
    indexData,
    numVertices: indexData.length,
  };
}

着色器与之前纹理示例几乎相同,只需将颜色属性改为纹理坐标:

struct Uniforms {
  matrix: mat4x4f,
};

struct Vertex {
  @location(0) position: vec4f,
-  @location(1) color: vec4f,
+  @location(1) texcoord: vec2f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
-  @location(0) color: vec4f,
+  @location(0) texcoord: vec2f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;
+@group(0) @binding(1) var ourSampler: sampler;
+@group(0) @binding(2) var ourTexture: `texture_2d<f32>`;

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = uni.matrix * vert.position;
-  vsOut.color = vert.color;
+  vsOut.texcoord = vert.texcoord;
  return vsOut;
}

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
-  return vsOut.color;
+  return textureSample(ourTexture, ourSampler, vsOut.texcoord);
}

管线配置修改顶点布局:

  const pipeline = device.createRenderPipeline({
    label: '2 attributes',
    layout: 'auto',
    vertex: {
      module,
      buffers: [
        {
-          arrayStride: (4) * 4, // (3) floats 4 bytes each + one 4 byte color
+          arrayStride: (3 + 2) * 4, // (3+2) floats 4 bytes each
          attributes: [
            {shaderLocation: 0, offset: 0, format: 'float32x3'},  // position
-            {shaderLocation: 1, offset: 12, format: 'unorm8x4'},  // color
+            {shaderLocation: 1, offset: 12, format: 'float32x2'},  // texcoord
          ],
        },
      ],
    },
    fragment: {
      module,
      targets: [{ format: presentationFormat }],
    },
    primitive: {
      cullMode: 'back',
    },
    depthStencil: {
      depthWriteEnabled: true,
      depthCompare: 'less',
      format: 'depth24plus',
    },
  });

创建索引缓冲区并加载纹理图集:

-  const { vertexData, numVertices } = createFVertices();
+  const { vertexData, indexData, numVertices } = createCubeVertices();
  const vertexBuffer = device.createBuffer({
    label: 'vertex buffer vertices',
    size: vertexData.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(vertexBuffer, 0, vertexData);

+  const indexBuffer = device.createBuffer({
+    label: 'index buffer',
+    size: indexData.byteLength,
+    usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
+  });
+  device.queue.writeBuffer(indexBuffer, 0, indexData);

加载纹理并创建采样器:

+  const texture = await createTextureFromImage(device,
+      'resources/images/noodles.jpg', {mips: true, flipY: false});
+
+  const sampler = device.createSampler({
+    magFilter: 'linear',
+    minFilter: 'linear',
+    mipmapFilter: 'linear',
+  });

  const bindGroup = device.createBindGroup({
    label: 'bind group for object',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: uniformBuffer },
+      { binding: 1, resource: sampler },
+      { binding: 2, resource: texture },
    ],
  });

渲染时使用索引绘制:

    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.setPipeline(pipeline);
    pass.setVertexBuffer(0, vertexBuffer);
+    pass.setIndexBuffer(indexBuffer, 'uint16');

    ...

    pass.setBindGroup(0, bindGroup);
-    pass.draw(numVertices);
+    pass.drawIndexed(numVertices);

    pass.end();

这样,一个立方体的每个面显示图集中的不同图像,只使用了一个纹理、一次绘制调用。

使用纹理图集的好处是:只需加载一个纹理,着色器保持简单(只引用一个纹理),只需一次 Draw Call 即可绘制整个形状,而不是每个纹理一次 Draw Call。


3 个核心优化要点

  1. 在 GPU 上生成 mipmap
    利用 GPU 的双线性插值(minFilter: linear)逐级生成 mip,而不是通过 canvas 2D getImageData 在 CPU 端生成。这样不仅快,而且避免了 canvas 2D 渲染的精度损失和额外内存拷贝。

  2. 使用纹理图集减少 Draw Call 和纹理切换
    将多个纹理合并到一张图集(Texture Atlas)中,通过纹理坐标选择不同区域。这样多个材质可以共享一次绘制调用,显著降低 CPU 提交开销和纹理绑定切换成本,尤其适用于包含多种材质的模型(如椅子、汽车等)。

  3. 视频纹理按帧驱动更新(并推荐外部纹理)
    通过 requestVideoFrameCallback 仅在视频有新帧时才调用 copyExternalImageToTexture,避免每渲染帧都复制整个视频数据。但更优的方案是使用 WebGPU 的外部纹理(importExternalTexture),它可以直接引用视频帧,实现零拷贝,适合 4K 等高分辨率视频。


GLSL/WebGL 专业术语检查说明

本文内容涉及 WebGPU / WGSL,而非 GLSL / WebGL。相关专业术语处理如下(附中英对照):

  • mipmap / mip level:mipmap / mip 层级,保留英文常见用法。
  • sampler:采样器。
  • texture:纹理。
  • texture_2d:2D 纹理(WGSL 类型)。
  • bind group:绑定组。
  • render pass:渲染通道。
  • texture atlas:纹理图集。
  • copyExternalImageToTexture:WebGPU API 方法,保留原文。
  • GPUTextureUsage.COPY_DST / RENDER_ATTACHMENT:WebGPU 纹理用途标志,保留原文。
  • ImageBitmap / HTMLCanvasElement / HTMLVideoElement:浏览器 API,保留原文。

文中出现的 WGSL 着色器代码均为 WebGPU 标准,涉及的关键词如 @builtin(position)、@location、textureSample 等均未翻译,以保证技术准确性。若读者有 GLSL 背景,需要注意 WGSL 语法与之不同,但概念(如 mipmap、采样器、纹理坐标)是一致的。