WebGPU 高效使用视频
英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-textures-external-video.html

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

零拷贝视频纹理:importExternalTexture
在上一篇文章中,我们通过 copyExternalImageToTexture 将视频的当前帧拷贝到一个预先创建的纹理中。WebGPU 提供了另一种更直接的方法:importExternalTexture。它返回一个 GPUExternalTexture,该外部纹理直接表示视频中的数据,无需进行拷贝 [1]。
你只需要将视频传入 importExternalTexture,它就会返回一个可直接使用的纹理。
使用外部纹理的三大限制
-
纹理仅在当前 JavaScript 任务内有效
对于大多数 WebGPU 应用来说,这意味着纹理只存在于你的requestAnimationFrame回调函数(或者requestVideoFrameCallback、setTimeout、mouseMove等)执行期间。一旦函数退出,纹理就会过期。要再次使用视频,你必须重新调用importExternalTexture。
推论:每次调用importExternalTexture后,你都需要创建一个新的绑定组(bind group),以便将新的纹理传递给着色器 [2]。 -
着色器中必须使用
texture_external类型
之前我们一直使用texture_2d<f32>,但importExternalTexture返回的纹理只能绑定到texture_external类型的绑定点上。 -
着色器中必须使用
textureSampleBaseClampToEdge函数
之前我们使用textureSample,但外部纹理只能使用textureSampleBaseClampToEdge[3]。顾名思义,该函数只能采样基础 mip 级别(level 0),也就是说外部纹理不能拥有 mipmap。此外,该函数会强制将采样钳制到纹理边缘,因此即使将采样器的addressModeU设置为'repeat'也会被忽略。
如果你需要重复效果,可以在着色器内通过fract(texcoord)手动实现:let color = textureSampleBaseClampToEdge( someExternalTexture, someSampler, fract(texcoord) );
如果这些限制不能满足你的需求,那么你仍然需要使用我们在上一篇文章中介绍过的 copyExternalImageToTexture。

代码修改示例
下面我们基于上一篇文章的示例进行修改。
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);
无需其他更改。
三个核心优化要点
-
零拷贝视频纹理
使用importExternalTexture直接引用视频帧数据,避免copyExternalImageToTexture带来的每帧 CPU→GPU 纹理拷贝,大幅降低内存带宽和延迟。 -
底层原生视频格式处理
外部纹理允许 WebGPU 直接在 YUV 多平面格式下采样视频,并通过注入代码自动转换为 RGBA,无需预先将 YUV 数据转换为 RGBA 纹理,进一步减少 GPU 内部复制和转换开销。 -
生命周期感知的绑定组管理
由于外部纹理仅在当前 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 中高效视频纹理的使用方式及其背后的优化原理。
