WebGPU导游手册
解释部分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关联到所提供的逻辑设备上
- 为提供的纹理设置
GPUTextureFormat
与GPUTextureUsage
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
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/computer-graphics/webgpu-explainer/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。