WebGPU 作为一项全球性技术,有望将尖端的 GPU 计算能力带入 Web 领域,惠及所有使用共享代码库的消费平台。
其前身 WebGL 虽然功能强大,但却严重缺乏计算着色器功能,限制了应用范围。
WGSL(WebGPU 着色器/计算语言)汲取了 Rust 和 GLSL 等领域的最佳实践。
在我学习使用 WebGPU 的过程中,发现文档中存在一些空白:我希望找到一个简单的起点,使用计算着色器来计算顶点和片段着色器的相关数据。
本教程中所有代码的单文件 HTML 可在 https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb 找到——请继续阅读以了解详细分解。
以下是该 HTML 在我的域名上运行的单次点击演示:https://www.php.cn/link/bed827b4857bf056d05980661990ccdc WebGPU 的浏览器,例如 Chrome 或 Edge https://www.php.cn/link/bae00fb8b4115786ba5dbbb67b9b177a)。
这是一个粒子模拟——它会随着时间推移,按时间步进行。
时间在 JS/CPU 上跟踪,作为 (float) uniform 传递给 GPU。
粒子数据完全在 GPU 上管理——尽管仍然与 CPU 交互,允许分配内存并设置初始值。也可以将数据读回 CPU,但这在本教程中省略了。
此设置的神奇之处在于,每个粒子都与所有其他粒子并行更新,从而在浏览器中实现了令人难以置信的计算和渲染速度(并行化最大限度地达到 GPU 的核心数量;我们可以将粒子数量除以核心数量以获得每个核心每次更新步骤的真实循环次数)。
WebGPU 用于 CPU 与 GPU 之间数据交换的机制是绑定——JS 数组(如 Float32Array)可以使用 WebGPU 缓冲区“绑定”到 WGSL 中的内存位置。WGSL 内存位置由两个整数标识:组号和绑定号。
在我们的例子中,计算着色器和顶点着色器都依赖于两个数据绑定:时间和粒子位置。
uniform 定义存在于计算着色器 (https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb#L43) 和顶点着色器 (https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb#L69) 中——计算着色器更新位置,顶点着色器根据时间更新颜色。
让我们看一下 JS 和 WGSL 中的绑定设置,从计算着色器开始。
<code>const computeBindGroup = device.createBindGroup({ /* 参见 computePipeline 定义,网址为 https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb#L102 它允许将 JS 字符串与 WGSL 代码链接到 WebGPU */ layout: computePipeline.getBindGroupLayout(0), // 组号 0 entries: [{ // 时间绑定在绑定号 0 binding: 0, resource: { /* 作为参考,缓冲区声明为: const timeBuffer = device.createBuffer({ size: Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST}) }) https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb#L129 */ buffer: timeBuffer } }, { // 粒子位置数据在绑定号 1(仍在组 0) binding: 1, resource: { buffer: particleBuffer } }] });</code>
以及计算着色器中相应的声明
<code>// 来自计算着色器 - 顶点着色器中也有类似的声明 @group(0) @binding(0) var<uniform> t: f32; @group(0) @binding(1) var<storage read_write=""> particles : array<particle>; </particle></storage></uniform></code>
重要的是,我们通过匹配 JS 和 WGSL 中的组号和绑定号,将 JS 端的 timeBuffer 绑定到 WGSL。
这使我们能够从 JS 控制变量的值:
<code>/* 数组中只需要 1 个元素,因为时间是单个浮点值 */ const timeJs = new Float32Array(1) let t = 5.3 /* 纯 JS,只需设置值 */ timeJs.set([t], 0) /* 将数据从 CPU/JS 传递到 GPU/WGSL */ device.queue.writeBuffer(timeBuffer, 0, timeJs);</code>
我们将粒子位置直接存储和更新在 GPU 可访问的内存中——允许我们利用 GPU 的大规模多核架构并行更新它们。
并行化是在工作组大小的帮助下协调的,在计算着色器中声明:
<code>@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) global_id : vec3<u32>) { // ... } </u32></code>
@builtin(global_invocation_id) global_id : vec3
根据定义,global_invocation_id = workgroup_id * workgroup_size local_invocation_id——这意味着它可以用作粒子索引。
例如,如果我们有 10k 个粒子,并且 workgroup_size 为 64,我们需要调度 Math.ceil(10000/64) 个工作组。每次从 JS 触发计算传递时,我们将明确地告诉 GPU 执行该数量的工作:
<code>computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / WORKGROUP_SIZE));</code>
如果 PARTICLE_COUNT == 10000 且 WORKGROUP_SIZE == 64,我们将启动 157 个工作组 (10000/64 = 156.25),每个工作组的计算范围 local_invocation_id 为 0 到 63(而 workgroup_id 的范围为 0 到 157)。由于 157 * 64 = 1048,我们将最终在一个工作组中进行稍微更多的计算。我们通过丢弃多余的调用来处理溢出。
以下是考虑这些因素后计算着色器的最终结果:
<code>@compute @workgroup_size(${WORKGROUP_SIZE}) fn main(@builtin(global_invocation_id) global_id : vec3<u32>) { let index = global_id.x; // 由于工作组网格未对齐,因此丢弃额外的计算 if (index >= arrayLength(&particles)) { return; } /* 将整数索引转换为浮点数,以便我们可以根据索引(和时间)计算位置更新 */ let fi = f32(index); particles[index].position = vec2<f32>( /* 公式背后没有宏伟的意图 - 只不过是用时间+索引的例子 */ cos(fi * 0.11) * 0.8 + sin((t + fi)/100)/10, sin(fi * 0.11) * 0.8 + cos((t + fi)/100)/10 ); } </f32></u32></code>
这些值将在计算传递中持续存在,因为粒子被定义为存储变量。
为了从计算着色器中在顶点着色器中读取粒子位置,我们需要一个只读视图,因为只有计算着色器才能写入存储。
以下是 WGSL 的声明:
<code>@group(0) @binding(0) var<uniform> t: f32; @group(0) @binding(1) var<storage> particles : array<vec2>>; /* 或等效: @group(0) @binding(1) var<storage read=""> particles : array<vec2>>; */ </vec2></storage></vec2></storage></uniform></code>
尝试重新使用计算着色器中相同的 read_write 样式只会出错:
<code>var with 'storage' address space and 'read_write' access mode cannot be used by vertex pipeline stage</code>
请注意,顶点着色器中的绑定号不必与计算着色器绑定号匹配——它们只需要与顶点着色器的绑定组声明匹配:
<code>const renderBindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: timeBuffer } }, { binding: 1, resource: { buffer: particleBuffer } }] });</code>
我在 GitHub 示例代码 https://www.php.cn/link/2e5281ee978b78d6f5728aad8f28fedb#L70 中选择绑定:2——只是为了探索 WebGPU 强加的约束的边界
所有设置就位后,更新和渲染循环在 JS 中协调:
<code>/* 从 t = 0 开始模拟 */ let t = 0 function frame() { /* 为简单起见,使用恒定整数时间步 - 无论帧速率如何,都会一致渲染。 */ t += 1 timeJs.set([t], 0) device.queue.writeBuffer(timeBuffer, 0, timeJs); // 计算传递以更新粒子位置 const computePassEncoder = device.createCommandEncoder(); const computePass = computePassEncoder.beginComputePass(); computePass.setPipeline(computePipeline); computePass.setBindGroup(0, computeBindGroup); // 重要的是要调度正确数量的工作组以处理所有粒子 computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / WORKGROUP_SIZE)); computePass.end(); device.queue.submit([computePassEncoder.finish()]); // 渲染传递 const commandEncoder = device.createCommandEncoder(); const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, loadOp: 'clear', storeOp: 'store', }] }); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, renderBindGroup); passEncoder.draw(PARTICLE_COUNT); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); requestAnimationFrame(frame); } frame();</code>
WebGPU 在浏览器中释放了大规模并行 GPU 计算的强大功能。
它以传递方式运行——每个传递都有局部变量,通过具有内存绑定的管道启用(桥接 CPU 内存和 GPU 内存)。
计算传递允许通过工作组协调并行工作负载。
虽然它确实需要一些繁重的设置,但我认为局部绑定/状态样式比 WebGL 的全局状态模型有了巨大的改进——使其更容易使用,同时也最终将 GPU 计算的强大功能带入了 Web。
以上是WebGPU 教程:网络上的计算、顶点和片段着色器的详细内容。更多信息请关注PHP中文网其他相关文章!