虽然是WebGPU,但是速度很慢!?
我们将解释如何充分利用 WebGPU 性能。这次我们以绘制大量物体为例,根据"使用纹理"中的代码进行一些更改并绘制 900 个立方体。
要均匀分布立方体,可以按如下方式更新 worldMatrix:
javascript
for (let i=0; i<30*30; i++) {
draw({context, pipeline, verticesBuffer, indicesBuffer, uniformBindGroup, uniformBuffer, depthTexture, i});
}
javascript
const worldMatrix = glMatrix.mat4.create();
const now = Date.now() / 1000;
glMatrix.mat4.translate(
worldMatrix,
worldMatrix,
glMatrix.vec3.fromValues((i % 30) * 5 - 100, Math.floor(i / 30) * 5 + -50, 0)
);
glMatrix.mat4.rotate(
worldMatrix,
worldMatrix,
1,
glMatrix.vec3.fromValues(Math.sin(now), Math.cos(now), 0)
);
g_device.queue.writeBuffer(
uniformBuffer,
4 * 16 * 2,
worldMatrix.buffer,
worldMatrix.byteOffset,
worldMatrix.byteLength
);
可以看一个不考虑性能调整的多路立方体绘制示例。我们发现绘制非常断断续续且缓慢。
缓慢的原因
无法重用CommandEncoder
基本上,g_device.queue.submit([commandEncoder.finish()])
速度非常慢。在此代码中,它被调用了 900 次。但是理想情况下,最好只在绘制结束时执行一次。
无法重用RenderPassEncoder
在当前代码中,RenderPassEncoder也无法重用。我们要尽可能的去重复使用RenderPassEncoder,可以从 CommandEncoder 多次生成 RenderPassEncoder。
其他问题
下面的示例将 passEncoder.end();
和 g_device.queue.submit([commandEncoder.finish()]);
放在draw函数之外,以便仅在绘图帧的开头生成 commandEncoder 和 renderPassEncoder。这是示例。
但是我们发现,除了一个立方体之外,所有立方体都消失了。这是因为 GPU 仅在执行 g_device.queue.submit([commandEncoder.finish()]);
时执行绘图命令。
即使每次在绘制函数中更新WorldMatrix,Uniform区域也只是针对一个立方体。当draw函数处理完成并且Uniform区域中的WorldMatrix更新为最后的位置信息后,在绘制帧结束时,所有的立方体最终都通过g_device.queue.submit([commandEncoder.finish()]);
来绘制。因此,所有立方体都引用表示最后位置的WorldMatrix,并且所有立方体都绘制在最后位置。
因此,为了将所有立方体绘制在正确的位置,我们需要重写Uniform区域缓冲区,然后每次执行g_device.queue.submit([commandEncoder.finish()]);
。然而,这并不能加快 WebGPU 处理速度。
这就是WebGPU编程的难点。我们应该怎么办?
解决方法
一种解决方案是将所有立方体的所有 WorldMatrix 解压到缓冲区中。 然后,仅在绘图帧结束时执行一次 g_device.queue.submit([commandEncoder.finish()]);
。
这是一个改进版本的示例代码。
javascript
const cubeNumber = 30*30;
const vertWGSL = `
struct Uniforms {
projectionMatrix : mat4x4<f32>,
viewMatrix : mat4x4<f32>,
}
@binding(0) @group(0) var<uniform> uniforms : Uniforms;
struct WorldStorage {
worldMatrices : array<mat4x4<f32>>,
}
@binding(3) @group(0) var<storage> worldStorage : WorldStorage;
...
worldMatrix 定义已移至单独的新Storage Buffer。在处理大量数据时,Storage Buffer比Uniform Buffer更好。
900 个 WorldMatrix 以数组格式定义。
javascript
@vertex
fn main(
@builtin(instance_index) instance_index: u32,
@location(0) position: vec4<f32>,
@location(1) color: vec4<f32>,
@location(2) uv: vec2<f32>
) -> VertexOutput {
var output : VertexOutput;
output.Position = uniforms.projectionMatrix * uniforms.viewMatrix * worldUniforms.worldMatrices[instance_index] * position;
output.fragUV = uv;
return output;
}
WorldMatrix是使用内置变量实例号instance_index
从数组中提取的。
javascript
const storageBufferSize = 4 * 16 * cubeNumber; // 4x4 matrix * 3
const storageBufferCubes = g_device.createBuffer({
size: storageBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
这次,我们为多维数据集的数量创建一个新的存储缓冲区"storageBufferCubes"。
javascript
const uniformBindGroup = g_device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: {
buffer: uniformBuffer,
},
},
{
binding: 1,
resource: texture.createView(),
},
{
binding: 2,
resource: sampler,
},
{
binding: 3,
resource: {
buffer: storageBufferCubes, // <--- 追加
}
},
],
});
BindGroup 还将 storageBufferCubes 指定为binding:3
。
javascript
for (let i=0; i<cubeNumber; i++) {
const worldMatrix = glMatrix.mat4.create();
const now = Date.now() / 1000;
glMatrix.mat4.translate(
worldMatrix,
worldMatrix,
glMatrix.vec3.fromValues((i % 30) * 5 - 100, Math.floor(i / 30) * 5 + -50, 0)
);
glMatrix.mat4.rotate(
worldMatrix,
worldMatrix,
1,
glMatrix.vec3.fromValues(Math.sin(now), Math.cos(now), 0)
);
g_device.queue.writeBuffer(
storageBufferCubes,
4 * 16 * i,
worldMatrix.buffer,
worldMatrix.byteOffset,
worldMatrix.byteLength
);
}
在getTransformationMatrix中,900个WorldMatrix被写入storageBufferCubes。
javascript
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, uniformBindGroup);
passEncoder.setVertexBuffer(0, verticesBuffer);
passEncoder.draw(cubeVertexCount, cubeNumber); // <---绘制900个实例
另外,绘制时,在draw函数的第二个参数中指定要绘制实例的立方体数量。 现在,将一次绘制900个立方体,着色器将根据每个实例编号引用WorldMatrix并在适当的位置绘制。
javascript
function frame(
{context, pipeline, verticesBuffer, indicesBuffer, uniformBindGroup, uniformBuffer, depthTexture}:
{context: GPUCanvasContext, pipeline: GPURenderPipeline, verticesBuffer: GPUBuffer, uniformBindGroup: GPUBindGroup, uniformBuffer: GPUBuffer, depthTexture: GPUTexture, texture: GPUTexture}
): void {
for (let i=0; i<30*30; i++) {
draw({context, pipeline, verticesBuffer, indicesBuffer, uniformBindGroup, uniformBuffer, depthTexture, i});
}
passEncoder.end();
passEncoder = undefined;
g_device.queue.submit([commandEncoder.finish()]);
commandEncoder = undefined;
requestAnimationFrame(frame.bind(frame, {context, pipeline, verticesBuffer, uniformBindGroup, uniformBuffer, depthTexture, texture}));
}
请注意, g_device.queue.submit([commandEncoder.finish()]);
仅在绘制帧结束时执行一次。
其他调整
重用RenderPipeline和BindGroup
在这种情况下,我们只需要一个RenderPipeline,但在复杂的场景中,根据对象的不同,使用的着色器和顶点信息会有所不同,因此我们需要相应地使用多个RenderPipeline。 为了加快速度,不要在每次执行绘制过程时生成 RenderPipeline,而是多次重复使用创建的 RenderPipeline。 BindGroup 也是如此。
使用RenderBundle
如果我们是多次绘制常规内容,请考虑将它们转换为 RenderBundle 以重用绘图。 RenderBundle 在"使用 RenderBundle"部分中进行了解释。
总结
使用WebGPU,我们需要自己优化绘图命令,类似于驱动层对WebGL所做的事情。因此,如果编码没有适当优化,结果可能会比WebGL慢。
确定每个 WebGPU 函数调用的性能特征并优化渲染代码非常重要。为了做到这一点,在某些情况下可能需要检查我们正在创建的库或应用程序的设计。在许多情况下,需要将着色器可以访问的大部分数据预先部署到 GPU 上的缓冲区中。