【译】 Web 端视频帧处理——WebAssembly、WebGPU、WebGL、WebCodecs、WebNN 和 WebTransport

【译】 Web 端视频帧处理——WebAssembly、WebGPU、WebGL、WebCodecs、WebNN 和 WebTransport

Tags
WebRTC
WebMedia
WebAssembly
Published
Mar 31, 2023

前言

去年二月份的时候,我写了一篇文章对比了当时在 Chrome 94+ 中如何在本地简单进行视频混流的一些方法和性能。一年过去了,浏览器新增了比较多能力:
  • Chrome 97+ 正式支持了WebTransport
  • Safari 16.4 正式支持了WebCodecs
  • Chrome 113 正式支持 WebGPU
本篇文章讲述了当前在 2023 这个节点,浏览器的一些处理视频的相关技术实现与方案对比,值得阅读。

正文

在本月初,W3C 网络标准专家 François Daoust 和 Dominique Hazaël-Massieux(Dom)加入我们来讨论使用 WebCodecs 和 Streams 进行实时视频处理。他们专注于如何建立一个用来处理从摄像机、WebRTC 流或其他来源传入的视频帧的低延迟处理流程。他们的 demo 展示了处理的一些示例应用:更改颜色、叠加图像,甚至更改视频编解码器。其他参考包括:机器学习处理,例如添加虚拟背景。
今天,他们将重点关注实际视频处理部分的许多技术选项。读取和更改视频帧有许多方案。他们回顾了对现有基于 Web 可用的方案的实验 - JavaScript,WebAssembly(wasm),WebGPU,WebGL,WebCodecs,Web Neural Networks(WebNN)和 WebTransport。其中一些技术已经存在一段时间,但其中许多是新近推出的。
任何人在做任何类型的视频分析或处理时,这里都有一些资源可以查阅。感谢 François 和 Dominique 分享他们的研究——测试浏览器上可用的视频处理的各种方案!
notion image

视频帧处理选项

下表总结了可以用来处理 VideoFrame 视频帧的技术。它包含了一些深层次的考虑来帮助选择最合适的技术。更多的内容请看关于他们更详细的章节。
正如在第一部分中所看到的,处理工作流程的性能在很大程度上取决于是否需要进行内存拷贝,这取决于视频帧首先存在于何处,而这又取决于浏览器是如何创建它的。在第一部分所设想的工作流程类型中,视频帧似乎有可能在某些时候存在于 GPU 内存中。这就是这里与性能有关的利弊假设。
notion image

使用 JavaScript 处理

处理像素的明显是使用常规的 JavaScript 开始。JavaScript 数据存储在 CPU 内存中,而视频帧像素通常存储在GPU内存中。首先从 JavaScript 代码访问帧像素意味着将它们复制到 ArrayBuffer 中,然后以某种方式对其进行处理:
// 将帧复制到一个足够大的(full HD frame)缓冲器中
const buffer = new Uint8Array(1920*1080*4);
await frame.copyTo(buffer);
// 保存帧的预设(尺寸、格式、色彩空间)并关闭它
const frameSettings = getFrameSettings(frame);
frame.close();
// 处理缓冲区并创建更新的视频帧
process(buffer, frameSettings);
const processedFrame = new VideoFrame(buffer, frameSettings);

像素格式

ArrayBuffer中的字节代表什么?当然是颜色,但是帧的像素格式(在frame.format中)可能会有所不同。简单的说,应用程序将获取一些红色、绿色、蓝色和 alpha 成分(RGBABGRA),或者一些亮度分量(Y)和两个色度分量(UV)的组合。带有 alpha 组件的格式具有等效的不透明格式(例如,RGBA 的等效格式为RGBX,其中 alpha 被忽略)。
没有一种方法可以请求特定的格式,提供的格式通常取决于上下文。例如,从相机或编码流生成的VideoFrame 可能会使用称为 NV12YUV 格式,而从画布生成的VideoFrame可能会使用 RGBABGRA。有公式可以将帧在格式之间进行转换,例如:维基百科上的 YUV文章
应该如何解释颜色?这取决于帧的颜色空间。在那里,我们必须承认缺乏专业知识。根据您对像素应用的转换,您可能需要考虑这一点。至少,在转换结束后创建结果帧时,您需要向 VideoFrame 构造函数传递该信息。还要注意,高动态范围(HDR)和广色域(WCG)的支持正在进行中,但尚未包含在 WebCodecs 中或得到浏览器的支持。这部分是因为在实现这一点之前,还需要扩展其他领域,例如为 HDR 内容添加画布支持。

性能

性能方面,调用copyTo函数是昂贵的。Mozilla 的 Paul Adenot 在2021年底的一次 W3C 专业媒体生产工作坊上详细介绍了WebCodecs中的内存访问模式。在高端系统上,对于标准动态范围(SDR)下的全高清帧(1920*1080),C++复制需要 ~5ms 的时间,除非该帧已经在CPU缓存中。
来源:Paul Adenot的 WebCodecs中的内存访问模式 演讲
在没有实际进程的典型桌面计算机上,上述逻辑在 Chrome 中需要 15 到 22ms 的时间。这很重要,特别是如果转换后的帧还需要被渲染并写回到 GPU 内存(尽管复制速度似乎很快)。考虑每帧的时间预算为 40ms,以 25 帧/秒(FPS)计算,则需要在 20ms 内准备好。在 50 fps 下,需要在 20 ms内准备好。对于 WebCodecs 的支持仍处于初级阶段,copyTo函数的性能可能会继续改善。正如 Paul 所示,无论如何,复制都不能是瞬间完成的。
一旦复制完成,在 JavaScript 中循环遍历全高清帧的所有像素通常需要 10-20ms 的时间,在智能手机上需要 40-60ms 的时间。现在,SharedArrayBuffer在 Web 浏览器中可用,我们可以使用不同的工作线程进行并行处理以提高性能。
实际上,代码示例实际上进行了两个副本,一个是调用copyTo时,另一个是创建新的VideoFrame对象时。这些副本无法避免,因为 WebCodecs 没有(还没有?)将传入的ArrayBuffer的所有权转移给VideoFrame的机制。这正在考虑中,例如参见分离编解码器输入问题。实际上,在某些情况下,即使没有这样的机制,浏览器也可以减少副本的数量。例如,在移动设备上,GPU 和 CPU通常是集成 的,并共享相同的物理 RAM。在这种设备上,理论上可以避免复制!

使用 WebAssembly

WebAssembly(WASM)可以为 CPU 处理提供接近 native 的性能。这使得它很适合处理帧。如果您不熟悉WebAssembly,我可以用四个要点来概括它:
  1. 顾名思义,WebAssembly 是一种低级汇编语言,编译成二进制格式在浏览器(和其他运行时)中运行。除了提供接近 native 性能外,还有许多编译器可用于从常见源语言(C / C ++,Rust,C#,AssemblyScript 等)生成 WebAssembly 代码,使 WebAssembly 适用于将现有代码库移植到 Web 上。
  1. WebAssembly 仅具有数字类型(好吧,引用类型也存在,但它们与手头的问题无关,让我们忽略它们)。从字符串到更复杂的对象,任何其他内容都是应用程序(或编译器)需要在数字类型之上创建的抽象。
  1. WebAssembly 可以将函数导出 / 导入 JavaScript,因此与 JavaScript 的集成很简单。
  1. WebAssembly 代码在线性内存上操作,即 ArrayBuffer,可以被 JavaScript 代码和WebAssembly 代码访问。
要使用 WebAssembly 处理 VideoFrame,起点与使用 JavaScript 相同:需要使用copyTo将像素复制到共享的 JavaScript / WebAssembly 内存缓冲区中。然后,您需要在WebAssembly 中处理像素,并从结果创建一个新的VideoFrame(这会触发另一个内存复制)。

演示代码

我们使用 WebAssembly 文本格式编写了转换函数,一个简单的绿色背景转换器,它是二进制代码的直接文本表示。请参见 GreenBackgroundReplacer.wat 文件中的结果代码。可以使用 WebAssembly 二进制工具包中的 wat2wasm 将其编译为二进制 WebAssembly。在实践中,这些转换通常会使用C++,Rust,C#等编写,然后编译为 WebAssembly 字节码。
notion image

速度

WebAssembly 是否更快?内存拷贝的成本与纯 JavaScript 处理相同。在 WebAssembly 中循环处理一个全高清帧的像素,在桌面浏览器上只需要几毫秒,比纯 JavaScript 代码略少。总的来说,每一帧的处理在我们的台式电脑上平均需要~25ms,在我们的智能手机上需要~50ms。
不过代码还可以进一步优化:
  1. 像素可以使用 WebAssembly线程 进行并行处理(类似 JavaScript Worker)。WebAssembly 线程仍处于 proposal 阶段,尚未成为 WebAssembly 核心标准的一部分,但它们已经被各浏览器支持。
  1. 单指令多数据(SIMD)指令可用于一次处理多达四个像素,详情见 WebAssembly 规范中的矢量指令

其他考虑

利用 WebAssembly 的专业视频编辑应用程序倾向于用 WebAssembly 做一切事情,多路复用/解复用、编码/解码和处理以节省时间。这有助于这些应用程序与已经这样做的本地应用程序共享相同的C/C++代码。除了性能之外,这种方法给了他们更多的灵活性来支持浏览器可能不支持的编解码。本博客上的旧文章 Zoom的网络客户端如何避免使用WebRTC 显示Zoom 是一个例子。
其他说明:
  • WebAssembly 读/写数字时使用 little-endian 的字节排序。演示中的 WebAssembly 代码一次性读取了四种颜色的组件,有效地颠倒了所得数字中的字节顺序(RGBA 变成ABGR)。
  • 与 JavaScript 一样,WebAssembly 代码也需要处理所有不同的像素格式。我们的代码只处理类似 RGBA 的格式,因为我们使用WebGPU 将传入的帧转换为RGBA(见下文),然后再到达 WebAssembly 转换。

使用 WebGPU

使用 JavaScript 和 WebAssembly 处理帧的主要缺点是从 GPU 内存回读拷贝到 CPU 内存的成本,以及使用 Worker 和 CPU 线程实现的并行性有限。这些限制在 GPU 世界中并不存在,因此 WebGPU 非常适合处理视频帧。

Demo 代码

WebGPU 公开了 importExternalTexture 方法,用于直接从 <video> 元素导入视频帧作为纹理。虽然这还没有写入规范(请参阅 扩展提案中的定义 WebCodecs 交互),但同样的方法也可用于导入 VideoFrame。这就是我们需要的 hook,其余的代码......如果您不熟悉GPU的概念,就会觉得晦涩难懂,但大部分都是模板。
Demo 中的 WebGPU 变换位于 VideoFrameTimestampDecorator.js 中。它在视频帧的右下角使用颜色代码覆盖帧的时间戳。
我们的想法是创建一个渲染流水线,将 VideoFrame 作为纹理导入,然后逐个处理像素。渲染流水线大致由顶点着色器阶段组成,该阶段生成裁剪空间坐标并将其解释为三角形。然后,片段着色器阶段获取这些三角形,并计算三角形中每个像素的颜色。着色器使用 WebGPU 着色语言(WGSL)编写。
除非转换打算改变视频帧的形状和尺寸,否则最简单的做法是调用顶点着色器六次,以产生覆盖整个帧的两个三角形。坐标也是以所谓的 uv 坐标产生的,它与常规的帧坐标相匹配。在WGSL中,顶点着色器的核心是:
@vertex
fn vert_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
  var pos = array<vec2<f32>, 6>(
    vec2<f32>(1.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(-1.0, -1.0),
    vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, -1.0), vec2<f32>(-1.0, 1.0));
  var uv = array<vec2<f32>, 6>(
    vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
    vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0), vec2<f32>(0.0, 0.0));
  var output : VertexOutput;
  output.Position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
  output.uv = uv[VertexIndex];
  return output;
}
然后,片段着色器查看需要着色的像素的坐标。如果像素位于右下角,着色器会计算叠加颜色。否则,着色器将使用 WebGPU 提供的纹理采样器和 textureSampleBaseClampToEdge 函数输出原始视频帧中的像素。在WGSL中,片段着色器的核心是:
@fragment
fn frag_main(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> {
  if (uv.x > 0.75 && uv.y > 0.75) {
    let xcomp: f32 = (1 + sign(uv.x - 0.875)) / 2;
    let ycomp: f32 = (1 + sign(uv.y - 0.875)) / 2;
    let idx: u32 = u32(sign(xcomp) + 2 * sign(ycomp));
    return timestampToColor(params.timestamp, idx);
  } else {
    return textureSampleBaseClampToEdge(myTexture, mySampler, uv);
  }
}
从 JavaScript 的角度来看,GPU 被初始化以将结果写入 Canvas,然后可用于创建最终的 VideoFrame。一旦参数和 GPU命令准备就绪,转换逻辑将如下所示:
gpuDevice.queue.submit([commandEncoder.finish()]);
const processedFrame = new VideoFrame(canvas,);
controller.enqueue(processedFrame);
关于演示代码的一些补充说明:
  • submit 的调用在GPU上运行命令,这显然是一个异步过程。但无需等待,浏览器会自动等待与Canvas 链接的GPU命令完成,然后再尝试读取画布(此处是对VideoFrame构造函数的调用)。
  • 从 WebGPU 的角度来看,Canvas 并非严格需要创建的,因为我们的目标不是将结果显示在屏幕上,而是生成一个新的 VideoFrame 对象。相反,我们可以使用 gpuDevice.createTexture 渲染普通纹理。问题是没有直接的方法从 GPUBuffer 中创建 VideoFrame,因此从 WebCodecs 的角度来看需要 Canvas 来避免将结果复制到 CPU内存中。

GPU 编程是完全不同的

除非您已经熟悉 GPU 编程,否则学习曲线非常陡峭。一些例子包括
  1. WebGPU 着色语言(WGSL)中的内存布局在对齐和大小方面对值施加了限制。当您想将参数与纹理一起传递给GPU时,很容易出错。
  1. 内存位置被划分为地址空间。我不清楚 uniformstorage 之间的区别。什么情况下应该使用这两种方式?什么是合理的内存容量?
  1. 实现相同结果的方法有很多。有正确的方法吗?例如,按照目前的写法,片段着色器的效率很低,因为它要检查收到的每个像素的像素坐标。如果扩展顶点着色器,将整个帧划分为一组三角形,并为每个坐标返回一个附加参数,说明该点是取自原始帧,还是取自彩色编码点,则效率会更高。这样做值得吗?

WebGPU 速度很快!

虽然学习曲线很陡峭,但结果是值得的!首先,WebGPU 提供的采样器具有神奇的内部功能:无论帧的原始像素格式如何,采样器都会返回RGBA格式的颜色。这意味着应用程序无需担心转换问题,它们将始终处理RGBA颜色。它还可以轻松地将任何传入的帧转换为RGBA格式,将片段着色器简化为对采样器的简单调用,如 ToRGBXVideoFrameConverter.js 中的做法:
@fragment
fn frag_main(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> {
  return textureSampleBaseClampToEdge(myTexture, mySampler, uv);
}
注: 技术上讲,虽然着色器将看到 RGBA 颜色,但输出格式由 canvas 的格式决定,canvas 格式也可以是 BGRA,但这由应用程序控制。
更重要的是,由于在导入纹理时无需复制,在生成的帧上也无需复制,并且单个像素是并行处理的,因此 WebGPU 的处理速度非常快,在我们的简单场景中,台式机的平均处理时间约为 1ms(变化较小),智能手机的平均处理时间约为 3ms。尽管如此,我们并不完全清楚在使用 WebGPU 时我们所测量的时间是否正确:很有可能 Chrome 仅在绝对需要时才会阻止 JavaScript 以等待 GPU 工作的完成,而不是在创建 VideoFrame 时。尽管如此,使用 WebGPU 的处理仍能在各种设备上流畅运行。

WebGPU samples 可能已经损坏

WebGPU API 和 WGSL 语言在继续发展。例如,在传递给 createComputePipeline 的对象中现在需要layout 参数,请查阅:https://github.com/gpuweb/gpuweb/issues/2636.
这一变化和其他变化可能会影响网络上的 WebGPU 示例,包括官方WebRTC repo 示例中的 WebGPU示例。我在以下网站报告了该问题: https://github.com/webrtc/samples/issues/1602.

使用 WebGL

我们在演示中使用了 WebGPU ,因为我们想更熟悉该 API。WebGL 同样可以用于处理帧。更重要的是,WebGL 是跨浏览器实现的,在 WebGPU 可用之前,WebGL 将是一个更明智的选择!(请参见第1部分中的链接,以帮助跟踪WebGPU的可用性)。
虽然 WebGL 不同于 WebGPU,但总体方法是相同的。Media Working Group 维护了一个 WebCodecs 示例,其中包括 VideoFrame 对象的 WebGL 渲染器。您将在这段代码中看到顶点着色器和片段着色器对 VideoFrame 像素进行相同的采样。这一次,它们是作为二维纹理导入的:
gl.texImage2D(
	gl.TEXTURE_2D, 0,
	gl.RGBA, gl.RGBA,
	gl.UNSIGNED_BYTE,
	frame);
我们的演示没有与 Streams 集成,但我们在 WebGPU 上采用的方法同样有效。
除非您想使用只有 WebGPU 才提供的高级 GPU 功能,否则 WebGL VideoFrame 的处理性能应该与WebGPU相同。

使用 WebCodecs

当然,WebCodecs 可以通过 VideoEncoderVideoDecoder 接口用于视频帧的编码和解码。虽然WebCodecs 不允许修改单个像素或检查图像本身,但它确实包含了许多可调整的编码/解码参数,这些参数可以调整流。关于参数更详细的信息请参考 spec
worker-transform.js 文件中可以找到对H.264视频流进行编码和对H.264视频流进行解码的示例逻辑。

TransformStream

VideoEncoderVideoDecoder 都使用内部队列,但很容易将其连接到 TransformStream 并产生 backpressure signals (下游处理不过来上游数据):只需将 transform 函数返回的承诺分辨率与编码或解码函数的输出绑定即可:
const EncodeVideoStream = new TransformStream({
  start(controller) {
    // Skipped: a few per-frame parameters
    this.encodedCallback = null;
    this.encoder = encoder = new VideoEncoder({
      output: (chunk, cfg) => {
        if (cfg.decoderConfig) {
          // Serialize decoder config as chunk
          const decoderConfig = JSON.stringify(cfg.decoderConfig);
          const configChunk = {};
          controller.enqueue(configChunk);
        }
        // Skipped: increment per-frame parameters
        if (this.encodedCallback) {
          this.encodedCallback();
          this.encodedCallback = null;
        }
        controller.enqueue(chunk);
      },
      error: e => { console.error(e); }
    });
    VideoEncoder.isConfigSupported(encodeConfig)
      .then(encoderSupport => {
        // Skipped: check that config is really supported
        this.encoder.configure(encoderSupport.config);
      })
  },
  transform(frame, controller) {
    // Skip: check encoder state
    // encode() runs async, resolve transform() promise once done
    return new Promise(resolve => {
      this.encodedCallback = resolve;
      // Skipped: check need to encode frame as key frame
      this.encoder.encode(frame, {});
      frame.close();
    });
  }
});
请注意,编码配置项适用于后续编码帧。您需要将编码配置发送给之后解码流的逻辑。我发现最简单的方法是将配置作为流中的一个特定块发送,就像代码中做的那样。这样做的好处是允许在流中更改配置。

WebCodecs 性能

编码性能在很大程度上取决于编解码器、分辨率和底层设备。在典型的台式设备上,H.264 全高清视频帧编码可能需要 8 - 20 ms。解码速度通常更快,平均为 1 ms。在智能手机上,相同帧的编码可能需要70 ms,而解码通常需要 16 ms。在所有情况下,最初几帧的编码和解码时间通常较长,可达数百毫秒。这可能是由于初始化编码器/解码器所需的时间。

CPU vs. GPU

在编码方面,我们的时间测量是有区别的,是根据帧是在 GPU 内存还是 CPU 内存。当帧首先进入WebAssembly(WebAssembly 将帧移动到 CPU 内存)时,编码耗时约8ms;当帧停留在GPU上时,编码耗时约20ms。这可能意味着在我们的机器上,编码是在 CPU 内存中完成的,需要从 GPU 内存读回到 CPU 内存。
题外话:将 GPU 内存和 CPU 内存分开进行可视化是非常有用的。在某些架构中,GPU 内存和 CPU 内存可能是相同的,例如在智能手机上,但在桌面架构中,它们通常是分离的。无论如何,出于安全考虑,浏览器可能会将它们隔离在不同的进程中。

使用 WebTransport

我们已经提到WebTransport是一种 向/从 云发送和接收编码帧的机制。我们在演示中没有时间深入研究。不过,WebCodecs 和 WebTransport 规范的联合编辑Bernard Aboba写了一个WebCodecs/WebTransport示例,其中混合了 WebCodecs 和 WebTransport。
该示例代码没有将 VideoEncoderVideoDecoder 队列链接到 WHATWG Streams。相反,它监控VideoEncoder的队列,丢弃不能及时编码的传入帧。根据应用程序的需要,backpressure signals 可以传播到编码器或源。
通常,应用程序可能只希望在出现 backpressure signals 时,在绝对需要时丢弃传入帧。相反,它们可能希望改变编码设置,例如降低编码视频的质量和减少带宽
 
管理 backpressure 的困难正是编码和发送步骤在核心 WebRTC API 中纠缠在一起的原因(参见第一部分开头)。编码需要对网络的实时波动做出反应。
此外,正如 Real-Time Video Processing with WebCodecs and Streams: 处理流水线(第一部分)中指出,现实生活中的应用将更加复杂,以避免队头阻塞问题,并且并行使用多个传输流,最多每帧一个。在接收端,需要对单个流上接收到的帧重新排序和合并,以重新创建唯一的编码帧流。
注: 使用 RTCDataChannel 发送和接收编码帧的工作原理类似。Backpressure signals 也需要由应用程序处理。

使用 Web Neural Network (WebNN)

实时处理视频帧的一个关键用例是模糊或去除用户背景。如今,这通常是通过机器学习模型完成的。WebGPU 具有 GPU 计算能力,可用于运行机器学习算法。也就是说,现在的设备通常都嵌入了专用的神经网络推理硬件和特殊指令,WebGPU 无法针对这些硬件和指令。新生的 Web Neural Network API 为运行机器学习模型提供了一个与硬件无关的抽象层。
WebNN API的共同编辑 Ningxin Hu 开发了示例代码,以表现 WebNN 如何用于模糊背景。该示例代码还在 GitHub issue 中进行了描述和讨论。
SOURCE: https://www.w3.org/TR/webnn/#example-99852ef0

const context = await navigator.ml.createContext({powerPreference: 'low-power'});
/*
The following code builds a graph as:
constant1 ---+
             +--- Add ---> intermediateOutput1 ---+
input1    ---+                                    |
                                                  +--- Mul---> output
constant2 ---+                                    |
             +--- Add ---> intermediateOutput2 ---+
input2    ---+
*/

// Use tensors in 4 dimensions.
const TENSOR_DIMS = [1, 2, 2, 2];
const TENSOR_SIZE = 8;

const builder = new MLGraphBuilder(context);

// Create MLOperandDescriptor object.
const desc = {type: 'float32', dimensions: TENSOR_DIMS};

// constant1 is a constant MLOperand with the value 0.5.
const constantBuffer1 = new Float32Array(TENSOR_SIZE).fill(0.5);
const constant1 = builder.constant(desc, constantBuffer1);

// input1 is one of the input MLOperands. Its value will be set before execution.
const input1 = builder.input('input1', desc);

// constant2 is another constant MLOperand with the value 0.5.
const constantBuffer2 = new Float32Array(TENSOR_SIZE).fill(0.5);
const constant2 = builder.constant(desc, constantBuffer2);

// input2 is another input MLOperand. Its value will be set before execution.
const input2 = builder.input('input2', desc);

// intermediateOutput1 is the output of the first Add operation.
const intermediateOutput1 = builder.add(constant1, input1);

// intermediateOutput2 is the output of the second Add operation.
const intermediateOutput2 = builder.add(constant2, input2);

// output is the output MLOperand of the Mul operation.
const output = builder.mul(intermediateOutput1, intermediateOutput2);
请注意,在写这篇文章的时候,WebGPU 示例 可能已损坏部分,这些 API 正在开发中。WebNN 尚未在任何地方实现,因此 WebNN 示例代码暂时构建在 WebGPU之上。

总结

用 stream 还是不用 stream

WHATWG Streams 中的 backpressure 机制需要一些时间来适应,但一段时间后就会显得简单而强大。在这两篇文章中描述的视频处理流水线中,仍然很难对backpressure 进行推理,因为WHATWG Streams 并没有在整个流水线中使用。例如,WebRTC中 MediaStreamTrack 的使用将管道链划分为具有不同 backpressure 机制的不同部分。从开发人员的角度来看,这增加了混合技术的难度。它还创造了不止一种构建处理管道的方法,但是 queuing 和 backpressure 却没有明显的正确方式。
当我们编写代码时,我们发现 WHATWG Streams 的使用很自然,但演示仍然是基础简单的。当需要处理大量极端情况情况时,使用WHATWG Streams可能效果不佳。负责 WebCodecs 标准化的媒体工作组决定不将 WebCodecs 和 Streams 耦合在一起。他们在 Decoupling WebCodecs from Streams 中记录了这样做的理由。原因包括需要沿着处理链发送控制信号。例如,我们需要将解码配置作为一个大块发送。其他例如 flush reset 的信号不能很容易地被映射成 chunks。我们所采用的方法是否适用于实际应用场景?探索更真实的视频会议场景将是非常有趣的。

像素格式和色彩空间

WebCodecs 暴露的是内存中的原始视频帧。这就要求应用程序处理不同的视频像素格式并正确解释颜色。像素格式之间的转换既不容易也不难,但却很繁琐且容易出错。我们发现 WebGPU 提供的将所有内容转换为 RGBA 的功能非常有用。How to handle varying pixel formatsAPI for conversion between pixel formats中提出的转换 API 可能有很大价值。

技术和复杂度

这种探索为我们提供了一个很好的借口,使我们能够更加熟悉 WebGPUWebAssemblyStreamsWebCodecs。每种方法都有自己的概念和机制。浏览API、编写代码和学习如何调试,都需要花费大量精力。例如,WebGPU 和 WGSL 中的流水线布局、内存对齐概念、着色器阶段和参数都需要费一番脑筋——除非您习惯于GPU 编程。WebAssembly 的内存布局和指令、视频编解码参数、流和背压信号等也是如此。
换句话说,将各种技术结合在一起会产生认知负荷,由于这些技术生活在各自的生态系统中,其概念和社区相互脱节,因此会产生更大的认知负荷。在文档编制工作之外,能做的事情很少。标准规范在解释上往往有些枯燥,而且,鉴于这些技术是最近才出现的,因此很少有文章谈到使用 WebGPU、WebTransport 和 WebCodecs 进行媒体处理,这也不足为奇。毫无疑问,随着时间的推移,这种情况将会得到改善。

拷贝和隐藏拷贝

该演示的数据为每种技术提供了一些视频帧处理性能数据。主要的启示是,从 GPU 内存到 CPU 内存的原始帧拷贝是成本最高的操作。因此,应用程序需要确保其处理流水线最多只需要一次 GPU 到 CPU 的拷贝。
说起来容易做起来难。从纯粹的帧变换角度来看,在 GPU 上保持性能在可控范围内,WebGPU 被证明是非常强大的。不过不幸的是,至少现在使用 JavaScript 或WebAssembly 处理一个帧需要两个副本。
也就是说,与直觉相反的是,当初始帧在 GPU 内存中时,即使使用了硬件加速,帧编码也可能会产生一个副本(取决于用户的设备,硬件加速可能受 CPU 限制)。使用 WebGPU 处理帧所获得的性能提升可能会在后续编码阶段被抵消。相反,如果在 WebAssembly 处理之后进入编码阶段,与 WebAssembly 相关的拷贝成本可能不是问题。
当您比较 WebAssembly 中变换操作后的编码时间和仅应用编码/解码变换时的编码时间时,演示中就说明了这一点:
encoding takes ~17ms on a GPU-backed frame
notion image
encoding only takes ~8ms on a CPU-backed frame
notion image
 
我们的测量结果与我们认为可能的结果不符......一些拷贝可能是在看不见的地方发生的,而记录时间时可能也不是那么精准,尤其是在使用 WebGPU 时。
无论如何,我们仍然很难推理出拷贝是在什么时候进行的,也很难推理出可以改善处理时间的设置。当调用 copyTo 方法时,复制是显式的,但浏览器也可能在其他时间进行内部复制。在每帧间隔时间预算较少的实时流场景中,这些复制可能会带来明显的延迟。前段时间,WICG/reducing-memory-copies 仓库开始联合讨论内存拷贝问题,负责相关技术的工作组也在持续进行讨论。无论该领域的解决方案是否会实现,我们都注意到,尽管延迟很明显,但我们测得的延迟并不妨碍跨设备实时处理合理的视频帧(例如 25fps 的高清帧)。