解释部分WebGPU设计的核心内容,方便理解下一代Web图形技术

  • 与WebGL的差异
  • GPU Buffer的可见性、映射与所有权
  • 多线程

WebGL与WebGPU

  • 它们的共同目标是让web页面使用GPU执行运算与绘制复杂图像,但通过WebGPU可以使用GPU上更高级的特性
  • WebGL主要用来绘图,需要引入大量额外的处理才能使其进行其他类型的运算,而WebGPU优先支持的就是GPU上的通用计算(general computations)

WebGPU不同于WebGL的用例

  • 绘制包含大量不同对象(如CAD模型)的高精细度场景
  • 执行渲染真实场景所需的高级算法
  • 在GPU上更高效的执行机器学习模型

目标

  • 同时支持屏上与离屏的现代图形渲染
  • 在GPU上更高效的执行通用计算任务
  • 支持针对多种底层GPU API的实现: D3D12, Metal, Vulkan
  • 提供一种人类可写的语言来配置在GPU上执行的运算
  • 尽可能地使应用能在不同的用户系统与浏览器间移植
  • 提供一个在web上暴露现代GPU能力的框架,WebGPU的结构与现在所有的底层GPU API都很相似,但没有提供他们的所有功能,在之后的计划会扩展它使其具有更现代的功能

为何不是WebGL3.0

  • WebGL历史悠久,其设计可追溯到1992的OpenGL1.0,更远可以到1980年代的IRIS GL。这一族规范具有庞大可用的知识体系与可移植性强(OpenGL ES => WebGL)等优点。
  • 同时也意味着WebGL与现代GPU的设计相性不合,会导致一些CPU与GPU的性能问题,也使在现代的底层GPU API的上层实现WebGL变得更加困难。WebGL 2.0 Compute曾尝试给2.0上添加通用计算能力,但与底层图形API的相性不合使其变得很难实现

GPU Buffer

GPU与CPU的内存可见性

  • 对于独显,多数GPU分配的内存空间对于CPU是不可见的,存在于GPU的RAM(或VRAM中)
  • 对于集显,多数内存分配都使用(与CPU)相同的物理设备,但由于各种原因不会被GPU可见。为了让CPU看到GPU Buffer中的内容,它必须能被映射,使其在应用的虚拟内存空间中可用。GPUBuffer为了可被映射需要被进行特殊的分配,这会降低从GPU访问的效率

在浏览器中,GPU驱动器会在GPU进程中被加载,因此底层的GPU Buffer仅能被映射到GPU进程的虚拟内存中,通常不会被直接映射到内容进程中,在这种结构中实现需要在GPU与内容进程间的共享内存中提供一个额外的暂存空间

普通ArrayBuffer Shared Memory 可映射的GPU buffer 不可映射的GPU buffer (或纹理)
CPU, 内容进程中 可见 可见 不可见 不可见
CPU, GPU进程中 不可见 可见 可见 不可见
GPU 不可见 不可见 可见 可见

Buffer映射与所有权

GPUBuffer对象表示了一块可由其他GPU操作使用的内存空间。该块内存可被线性的访问,与GPUTexture不同的是,GPUTexture纹素序列的内存布局是未知的。

CPU与GPU间的所有权传递

为了可移植性与一致性,WebGPU避免了几乎所有的数据竞争。由于某些驱动器会需要额外的共享内存功能,因此映射buffer上的数据竞争会提高不可移植的风险。这就是为何GPUBuffer的映射通过CPU与GPU间的所有权转移来完成,同一时刻仅有一方可以访问,因此不会存在竞态

当应用请求去映射一个buffer时,会初始化将buffer所有权到CPU的转移。此时可能GPU在该buffer上还需要执行一些操作,则此次转移需要等待所有之前入队的GPU操作全部完成。这就是为何映射buffer是异步操作

当CPU中的应用使用完buffer后,可以通过取消映射来将所有权还给GPU,该操作时立即执行,使应用失去在CPU中对该buffer的所有访问权限。

const myMapReadBuffer = ...;
await myMapReadBuffer.mapAsync(GPUMapMode.READ, 0, 4);
// Do something with the mapped buffer.
buffer.unmap();

将所有权转移给CPU时,可能需要从底层映射的Buffer(GPU进程)复制到内容进程可见的共享内存。为了避免不必要的复制,应用程序可以在调用GPUBuffer.mapAsync时指定它感兴趣的范围。

当GPUBuffer被CPU所拥有时,不能使用它提交任何设备上的操作,否则会出现校验错误,但可以用它来记录GPUCommandBuffer指令。

可映射buffer的创建

GPUBuffer底层buffer的物理内存位置取决于是否可映射与是否可映射用于读取或写入(比如底层API对于CPU缓存的行为进行了部分控制)。

目前可映射buffer只能用于传输数据(因此除了MAP_*外只能包含COPY_SRC或COPY_DST的用法)。这就是为何应用必须在buffer创建时通过互斥的GPUBufferUsage.MAP_READ与GPUBufferUsage.MAP_WRITE标记指明其是可映射的:

const myMapReadBuffer = device.createBuffer({
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
    size: 1000,
});
const myMapWriteBuffer = device.createBuffer({
    usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
    size: 1000,
});

访问映射buffer

一旦一个GPUBuffer被映射了,则可以通过JS调用GPUBuffer.getMappedRange来访问其内存,返回一个ArrayBuffer对象。在调用GPUBuffer.unmap或GPUBuffer.destroy分离方法前均为可用的状态。

这些ArrayBuffer并不是新分配的内存,而是指向内容进程中可用的共享内存指针。

当所有权转移给GPU时,需要一份从共享内存到底层映射buffer的拷贝(PS:用来更新底层buffer),GPUBuffer.getMappedRange可以得到用于映射的一个自定义范围的buffer(PS:共享内存中的buffer),这样浏览器就知道底层buffer中哪部分已经无效,哪部分需要通过内存映射进行更新。

const myMapReadBuffer = ...;
await myMapReadBuffer.mapAsync(GPUMapMode.READ);
const data = myMapReadBuffer.getMappedRange();
// Do something with the data
myMapReadBuffer.unmap();

创建时映射buffer

一个常见需求是在创建GPUBuffer的同时就填充一些数据,可以通过四步实现: 创建底层buffer=>创建映射buffer=>填充映射buffer=>复制映射buffer到底层buffer,这样做效率较低下。相反,可以在创建时就将buffer设为CPU所有的来实现。所有buffer可以在创建时就被映射,即是它们没有显式设置MAP_WRITE用法,即创建时映射

当一个buffer在创建时被映射时,它的行为就如映射buffer一般:使用GPUBUffer.getMappedRange()检索arraybuffer,使用GPUBuffer.unmap()将所有权转移给GPU。

const buffer = device.createBuffer({
    usage: GPUBufferUsage.UNIFORM,
    size: 256,
    mappedAtCreation: true,
});
const data = buffer.getMappedRange();
// write to data
buffer.unmap();

多线程

  • 多线程是现代图形API的一个关键部分,允许在多个线程进行录制指令、提交工作、给GPU传递数据等任务,缓解CPU侧瓶颈
  • WebGPU的对象本质是与浏览器GPU进程中对象关联的句柄,因此在多线程中共享他们是相对简单的。如通过postMessage将GPUTexture对象传递给其他线程,创建一个新的GPUTexture JS对象,包含引用同一GPU进程中对象的句柄。
  • 一些如GPUBuffer的对象存在客户端状态(client-side state),应用程序需要在多个线程中使用这些状态,而不必使用[Transferable]语义来回postMessage这些对象(也会创建新的包装对象并破坏旧引用)。因此这些对象也可以是[Serializable]的,但拥有少量的共享状态(内容侧),像SharedArrayBuffer。

以GPUBuffer对象的buffer state为例:

  • 主线程: const B1 = device.createBuffer(...);.
  • 主线程: 使用postMessage将B1传递给Worker
  • Worker线程: 收到message -> B2,虽然B2是内部序列化后得到的新对象,但共享了buffer state状态
  • Worker线程: const mapPromise = B2.mapAsync() -> 成功,将buffer设为map pending状态
  • 主线程:B1.mapAsync() -> 失败,会抛出一个异常且无法改变buffer状态
  • 主线程: 编码一些使用B1的指令:
    encoder.copyBufferToTexture(B1, T);
    const commandBuffer = encoder.finish();
    

    -> 成功,不依赖buffer的客户端状态

  • 主线程: queue.submit(commandBuffer) -> 失败,产生异步的WebGPU错误,由于CPU(Worker线程)拥有当前buffer的所有权
  • Worker线程: await mapPromise, 写入映射内存, B2.unmap()(状态变为unmapped,可用于GPU操作)
  • 主线程: queue.submit(commandBuffer) -> 成功
  • 主线程: -> 成功,将buffer设为map pending状态

指令编码与提交

WebGPU中的许多操作都是纯GPU端操作,不使用来自CPU的数据。这些操作不是直接发出的,而是通过类似构建器的GPUCommandEncoder接口编码进GPUCommandBuffers中,接着通过gpuQueue.submit()发送给GPU。

底层图形API也使用了这种设计,可以提供如下便利:

  • command buffer的编码独立于其他状态,允许编码(与验证)工作利用多个CPU线程执行
  • 一次能提供更多的工作量,允许GPU驱动器执行更多的全局优化,尤其是在跨GPU硬件的任务调度协调方面。

Canvas输出

将渲染完成的内容输出到窗口中。

交换链

  • 以如下的结构提供展示层相关的交换链与纹理资源:
    • canvas.getContext('gpupresent')提供一个GPUCanvasContext对象
    • GPUCanvasContext.configureSwapChain({ device, format, usage })提供一个GPUSwapChain对象
      • 将之前所有的交换链变为不可用
      • 将canvas关联到所提供的逻辑设备上
      • 为提供的纹理设置GPUTextureFormatGPUTextureUsage
    • GPUSwapChain.getCurrentTexture()提供一个GPUTexture对象
  • 上面这个结构与底层图形API的相关内容具有很高的兼容性
    • 如vulkan中在这里的处理: 设置surface展示模式与性能参数=>创建交换链=>获取交换链图像=>获取图像视图
  • canvas接近底层图形API中surface的概念,这样的一个平台相关的surface对象会产生一个叫做交换链的对象,来提供用于绘制的1-3个纹理中最前端(up-front)或固定列表(fixed list)的纹理

当前纹理

  • GPUSwapChain通过getCurrentTexture()提供一个当前纹理
  • 对于canvas元素,会返回一个用于当前帧的纹理,[[currentTexture]]
  • 在事件循环处理的更新渲染步骤中,浏览器合成器会获取[[currentTexture]]的所有权来用于显示,该内部插槽中的内容会在下一帧被清空

getSwapChainPreferredFormat()

  • 由于不同硬件帧缓存的差别,不同设备在展示层表面的内存布局上有不同的偏好。虽然所有系统上都可以使用任一被允许的格式,但应用可能为了节省资源会使用偏好的格式。
  • 桌面系统硬件通常偏向bgra8unorm(4 bytes in BGRA order),而移动端硬件则偏向rgba8unorm (4 bytes in RGBA order)
  • 对于高比特位深(high-bit-depth),不同系统也有不同的偏好格式,如rgba16float或rgb10a2unorm

绑定模型与描述符集

Todo

参考