WebGPU 高效使用视频

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


Why texture_external?

前言

本文是“向着色器提供数据”系列中的一篇。上一篇文章介绍了如何加载图片、画布以及视频到纹理中,本文将介绍一种在 WebGPU 中更高效地使用视频的方式。

Why texture_external?

零拷贝视频纹理:importExternalTexture

在上一篇文章中,我们通过 copyExternalImageToTexture 将视频的当前帧拷贝到一个预先创建的纹理中。WebGPU 提供了另一种更直接的方法:importExternalTexture。它返回一个 GPUExternalTexture,该外部纹理直接表示视频中的数据,无需进行拷贝 [1]。

你只需要将视频传入 importExternalTexture,它就会返回一个可直接使用的纹理。

使用外部纹理的三大限制

  1. 纹理仅在当前 JavaScript 任务内有效
    对于大多数 WebGPU 应用来说,这意味着纹理只存在于你的 requestAnimationFrame 回调函数(或者 requestVideoFrameCallback、setTimeout、mouseMove 等)执行期间。一旦函数退出,纹理就会过期。要再次使用视频,你必须重新调用 importExternalTexture。
    推论:每次调用 importExternalTexture 后,你都需要创建一个新的绑定组(bind group),以便将新的纹理传递给着色器 [2]。

  2. 着色器中必须使用 texture_external 类型
    之前我们一直使用 texture_2d<f32>,但 importExternalTexture 返回的纹理只能绑定到 texture_external 类型的绑定点上。

  3. 着色器中必须使用 textureSampleBaseClampToEdge 函数
    之前我们使用 textureSample,但外部纹理只能使用 textureSampleBaseClampToEdge [3]。顾名思义,该函数只能采样基础 mip 级别(level 0),也就是说外部纹理不能拥有 mipmap。此外,该函数会强制将采样钳制到纹理边缘,因此即使将采样器的 addressModeU 设置为 'repeat' 也会被忽略。
    如果你需要重复效果,可以在着色器内通过 fract(texcoord) 手动实现:

    let color = textureSampleBaseClampToEdge(
        someExternalTexture,
        someSampler,
        fract(texcoord)
    );
    

如果这些限制不能满足你的需求,那么你仍然需要使用我们在上一篇文章中介绍过的 copyExternalImageToTexture。


Why texture_external?

代码修改示例

下面我们基于上一篇文章的示例进行修改。

1. 更新着色器

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

struct Uniforms {
  matrix: mat4x4f,
};

@group(0) @binding(2) `var<uniform>` uni: Uniforms;

@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 = uni.matrix * vec4f(xy, 0.0, 1.0);
-  vsOutput.texcoord = xy * vec2f(1, 50);
+  vsOutput.texcoord = xy;
  return vsOutput;
}

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

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

2. 移除纹理坐标的重复乘数

上面的修改中,我们不再将纹理坐标乘以 50,因为之前只是为了演示重复采样,而外部纹理不支持重复。

3. 更新视频源

-  video.src = 'resources/videos/Golden_retriever_swimming_the_doggy_paddle-360-no-audio.webm';
+  video.src = 'resources/videos/pexels-anna-bondarenko-5534310 (540p).mp4';

4. 调整采样器循环次数

由于不存在 mipmap,无需创建使用 mipmap 的采样器。我们将循环从 8 次改为 4 次,并移除 mipmapFilter 选项:

  const objectInfos = [];
-  for (let i = 0; i < 8; ++i) {
+  for (let i = 0; i < 4; ++i) {
    const sampler = device.createSampler({
      addressModeU: 'repeat',
      addressModeV: 'repeat',
      magFilter: (i & 1) ? 'linear' : 'nearest',
      minFilter: (i & 2) ? 'linear' : 'nearest',
-      mipmapFilter: (i & 4) ? 'linear' : 'nearest',
    });
    // ...
  }

5. 延迟创建绑定组

由于只有调用 importExternalTexture 后才能获得纹理,我们不能提前创建绑定组。因此保存创建绑定组所需的信息,留待渲染时再创建 [4]。

  const objectInfos = [];
  for (let i = 0; i < 4; ++i) {
    // ...
-    const bindGroups = textures.map(texture =>
-      device.createBindGroup({
-        layout: pipeline.getBindGroupLayout(0),
-        entries: [
-          { binding: 0, resource: sampler },
-          { binding: 1, resource: texture },
-          { binding: 2, resource: uniformBuffer },
-        ],
-      }));

    // 保存渲染该对象所需的数据
    objectInfos.push({
-      bindGroups,
+      sampler,
      matrix,
      uniformValues,
      uniformBuffer,
    });
  }

6. 渲染时导入外部纹理并创建绑定组

  function render() {
-    copySourceToTexture(device, texture, video);
    // ...

    const encoder = device.createCommandEncoder({
      label: 'render quad encoder',
    });
    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.setPipeline(pipeline);

+    const texture = device.importExternalTexture({source: video});

    objectInfos.forEach(({sampler, matrix, uniformBuffer, uniformValues}, i) => {
+      const bindGroup = device.createBindGroup({
+        layout: pipeline.getBindGroupLayout(0),
+        entries: [
+          { binding: 0, resource: sampler },
+          { binding: 1, resource: texture },
+          { binding: 2, resource: uniformBuffer },
+        ],
+      });

      // ...
      pass.setBindGroup(0, bindGroup);
      pass.draw(6);  // 调用顶点着色器 6 次
    });
  }

7. 调整矩阵变换以优化显示

由于不能重复纹理,我们调整矩阵参数,让四边形更明显,不再像之前那样拉伸 50 倍。

  function render() {
    // ...
    objectInfos.forEach(({sampler, matrix, uniformBuffer, uniformValues}, i) => {
      const xSpacing = 1.2;
-      const ySpacing = 0.7;
-      const zDepth = 50;
+      const ySpacing = 0.5;
+      const zDepth = 1;

-      const x = i % 4 - 1.5;
-      const y = i < 4 ? 1 : -1;
+      const x = i % 2 - .5;
+      const y = i < 2 ? 1 : -1;

      mat4.translate(viewProjectionMatrix, [x * xSpacing, y * ySpacing, -zDepth * 0.5], matrix);
-      mat4.rotateX(matrix, 0.5 * Math.PI, matrix);
-      mat4.scale(matrix, [1, zDepth * 2, 1], matrix);
+      mat4.rotateX(matrix, 0.25 * Math.PI * Math.sign(y), matrix);
+      mat4.scale(matrix, [1, -1, 1], matrix);
      mat4.translate(matrix, [-0.5, -0.5, 0], matrix);

      // 将值从 JavaScript 复制到 GPU
      device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

      pass.setBindGroup(0, bindGroup);
      pass.draw(6);  // 调用顶点着色器 6 次
    });
  }

完成以上修改后,我们就获得了一个零拷贝的视频纹理。


为什么是 texture_external?

你可能会注意到,这种使用视频的方式要求你在着色器中使用 texture_external 而不是更通用的 texture_2d<f32>,并且必须使用 textureSampleBaseClampToEdge 而不是 textureSample。这意味着如果你想在渲染中混合静态纹理和视频纹理,就需要为它们编写不同的着色器。

理解底层原理很重要。

视频数据通常将亮度(luminance) 部分与色度(chroma) 部分分开存储。色度部分的分辨率往往低于亮度部分。一种常见的分离与编码方式是 YUV,其中数据被分为亮度(Y)和色度(UV)。这种表示方式通常具有更好的压缩率。

WebGPU 外部纹理的目标是直接以视频提供的格式使用视频数据。为了实现这一点,它会在底层“假装”有一个单一的纹理,但实际实现中可能存在多个纹理。例如,一个纹理存放亮度值(Y),另一个纹理存放 UV 值。而且 UV 值可能会被特殊排列,而不是按像素交错排列。比如:

uvuvuvuvuvuvuvuv
uvuvuvuvuvuvuvuv
uvuvuvuvuvuvuvuv
...

它们可能被排列为:

uuuuuuuu
uuuuuuuu
uuuuuuuu
...
vvvvvvvv
vvvvvvvv
vvvvvvvv
...

这样排列的原因依然是:这种数据布局通常能获得更好的压缩率。

当你在着色器中声明 texture_external 并使用 textureSampleBaseClampToEdge 时,WebGPU 会在幕后向你的着色器注入代码,负责从这些视频数据中采样并返回 RGBA 值。它可能会从多个纹理中采样,或者进行纹理坐标计算,以便从 2 个、3 个或更多位置提取正确的数据并转换为 RGB。

下面是视频的 Y、U、V 通道示例:

  • Y 通道(亮度)
  • U 通道(红 ↔ 黄)
  • V 通道(蓝 ↔ 黄)

WebGPU 实际上提供了一个优化:传统的图形库通常需要你自己编写 YUV 转 RGB 的代码,或者请求操作系统完成转换,然后将数据复制到 RGBA 纹理中供 texture_2d<f32> 使用。这种方式更加灵活,但更慢,因为转换过程必须从 YUV 纹理复制到 RGBA 纹理。

这种较慢但更灵活的方法在 WebGPU 中依然可用,我们在上一篇文章中已经介绍过。如果你需要灵活性,希望视频纹理和静态纹理使用同一套着色器,那么请使用那种方式。

WebGPU 为 texture_external 提供这种优化的一个原因是:这是 Web 平台。浏览器支持的视频格式会随时间变化。WebGPU 会在底层处理这些细节,而如果你自己编写 YUV 转 RGB 的着色器,你就必须假设视频格式永远不会改变,但 Web 并不能做出这种保证。

texture_external 最明显的应用场景是视频相关功能,例如视频会议(Meet、Zoom)、人脸识别或背景分离等。未来 WebGPU 支持 WebXR 后,VR 视频也是一个潜在应用。


使用摄像头

实际上,我们可以很容易地将视频源替换为摄像头,只需很小的改动。

首先,我们不指定要播放的视频文件:

  const video = document.createElement('video');
-  video.muted = true;
-  video.loop = true;
-  video.preload = 'auto';
-  video.src = 'resources/videos/pexels-anna-bondarenko-5534310 (540p).mp4'; /* webgpufundamentals: url */
  await waitForClick();
  await startPlayingAndWaitForVideo(video);

然后,当用户点击播放时,我们调用 getUserMedia 请求摄像头,并将得到的媒体流应用到 video 元素上:

  function waitForClick() {
    return new Promise(resolve => {
      window.addEventListener(
        'click',
-        () => {
+        async () => {
          document.querySelector('#start').style.display = 'none';
-          resolve();
+          try {
+            const stream = await navigator.mediaDevices.getUserMedia({
+              video: true,
+            });
+            video.srcObject = stream;
+            resolve();
+          } catch (e) {
+            fail(`could not access camera: ${e.message ?? ''}`);
+          }
        },
        { once: true });
    });
  }

根据你的使用场景,你可能希望镜像画面,使其看起来像照镜子一样:

      mat4.translate(viewProjectionMatrix, [x * xSpacing, y * ySpacing, -zDepth * 0.5], matrix);
      mat4.rotateX(matrix, 0.25 * Math.PI * Math.sign(y), matrix);
-      mat4.scale(matrix, [1, -1, 1], matrix);
+      mat4.scale(matrix, [-1, -1, 1], matrix);
      mat4.translate(matrix, [-0.5, -0.5, 0], matrix);

无需其他更改。


三个核心优化要点

  1. 零拷贝视频纹理
    使用 importExternalTexture 直接引用视频帧数据,避免 copyExternalImageToTexture 带来的每帧 CPU→GPU 纹理拷贝,大幅降低内存带宽和延迟。

  2. 底层原生视频格式处理
    外部纹理允许 WebGPU 直接在 YUV 多平面格式下采样视频,并通过注入代码自动转换为 RGBA,无需预先将 YUV 数据转换为 RGBA 纹理,进一步减少 GPU 内部复制和转换开销。

  3. 生命周期感知的绑定组管理
    由于外部纹理仅在当前 JavaScript 任务内有效,纹理和绑定组必须按需创建。这种模式避免了长期持有大块视频纹理内存,同时促使开发者采用更动态的资源管理策略,提升整体渲染性能。


GLSL / WebGL 专业术语检查

  • texture_2d<f32>(WGSL)对应 GLSL 中的 sampler2D,但 WGSL 是类型更严格的着色语言。
  • texture_external(WGSL)在 WebGL 中通常通过 OES_EGL_image_external 扩展实现,对应 GLSL 的 samplerExternalOES。两者目的相同:零拷贝访问视频/外部纹理。
  • textureSample(WGSL)对应 GLSL 的 texture() 函数。
  • textureSampleBaseClampToEdge 类似于 GLSL 中 textureLod() 并且强制 clamp 到边缘、仅采样基础 mip 的行为。
  • 绑定组(bind group) 是 WebGPU 的资源绑定模型,类似 WebGL 中多个纹理单元 + uniform block 的组合,但更加显式且可预验证。
  • importExternalTexture 类似于 WebGL 中直接通过 texImage2D 上传视频帧,但 WebGPU 的 GPUExternalTexture 避免了显式上传,让 GPU 直接读取视频解码后的原生数据。
  • YUV 多平面纹理 在 GLSL 中通常需要手动绑定多个采样器并编写转换代码,而 WebGPU 通过 texture_external 自动处理,对开发者透明。

注:原文代码中出现 textureSAmpleBaseClampToEdge(A 大写)应为笔误,正确写法是 textureSampleBaseClampToEdge,首字母 S 之后是小写 a。


脚注

[1] 实际行为取决于浏览器实现。WebGPU 规范的设计目标是希望浏览器无需进行拷贝。
[2] 规范允许实现返回相同的纹理,但并非强制。若想检查是否得到相同纹理,可比较新旧纹理对象:const same = oldTexture === newTexture; 如果相同,则可以重用现有绑定组。
[3] 还可以对 texture_external 使用 textureLoad。
[4] 我们可以拆分绑定组,将采样器和 uniformBuffer 提前创建,另一个只引用外部纹理的绑定组在渲染时创建。是否值得取决于具体需求。


希望这篇深度解读能帮助你理解 WebGPU 中高效视频纹理的使用方式及其背后的优化原理。