Hello WebGPU —— 绘制多个立方体

1,088 阅读3分钟

「这是我参与2022首次更文挑战的第7天,活动详情查看:2022首次更文挑战」。

前言

Hello WebGPU —— 旋转立方体 - 掘金 (juejin.cn)Hello WebGPU —— 贴图操作 - 掘金 (juejin.cn)的话题,这次我们继续操作这个立方体,这次我们开始着重于增加立方体的个数。首先,我们从绘制2个立方体开始。

绘制2个立方体

我们现在希望绘制两个形状外形完全相同,只是处于不同位置的立方体应该怎样做呢?

首先,我们肯定是希望复用立方体的顶点数据的,我们仅仅只是希望改变其变换矩阵修改其位置就好了。那么,我们很容易就想到创建两个 Buffer ,其中一个保存第一个立方体的变换矩阵,另一个保存第二个立方体的变换矩阵。


  const matrixSize = 4 * 16; // 4x4 matrix
  const uniformBuffer = device.createBuffer({
    size: matrixSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  const uniformBuffer2 = device.createBuffer({
    size: matrixSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

现在有了两个Buffer,相应的,我们还需要两套 BindGroup


  const uniformBindGroup1 = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
        binding: 0,
        resource: {
          buffer: uniformBuffer,
          offset: 0,
          size: matrixSize,
        },
      },
    ],
  });

  const uniformBindGroup2 = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
        binding: 0,
        resource: {
          buffer: uniformBuffer2,
          offset: 0,
          size: matrixSize,
        },
      },
    ],
  });

OK,现在我们之前只需要在进行渲染的时候更新这两个Buffer中的数据,然后进行渲染即可。

    device.queue.writeBuffer(
      uniformBuffer,
      0,
      modelViewProjectionMatrix1.buffer,
      modelViewProjectionMatrix1.byteOffset,
      modelViewProjectionMatrix1.byteLength
    );
    device.queue.writeBuffer(
      uniformBuffer2,
      0,
      modelViewProjectionMatrix2.buffer,
      modelViewProjectionMatrix2.byteOffset,
      modelViewProjectionMatrix2.byteLength
    );
    
    // ........此处部分省略代码
    
    passEncoder.setVertexBuffer(0, verticesBuffer);

    // Bind the bind group (with the transformation matrix) for
    // each cube, and draw.
    passEncoder.setBindGroup(0, uniformBindGroup1);
    passEncoder.draw(cubeVertexCount, 1, 0, 0);

    passEncoder.setBindGroup(0, uniformBindGroup2);
    passEncoder.draw(cubeVertexCount, 1, 0, 0);

这样,我们就渲染出了两个一样的立方体了。 image.png

小优化

这里还有一个小小的优化技巧,我们可以将两个变换矩阵的信息都放在一份Buffer中,通过设置不同的偏移量来区分。比如这样:


  const matrixSize = 4 * 16; // 4x4 matrix
  const offset = 256; // uniformBindGroup offset must be 256-byte aligned
  const uniformBufferSize = offset + matrixSize;

  const uniformBuffer = device.createBuffer({
    size: uniformBufferSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

这里,我们的 Buffer 大小变为了 offset + matrixSize,这里 offset 必须是4的倍数且最小必须是256,这是由于WebGPU其中的一些关于内存对齐的限制导致的。具体的信息可以参考WebGPU-minUniformBufferOffsetAlignment

但是我们还是需要两个 BindGroup 来告诉GPU应该从内存的那个起始位置开始读取内存

image.png


  const uniformBindGroup2 = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
        binding: 0,
        resource: {
          buffer: uniformBuffer,
          offset: offset,
          size: matrixSize,
        },
      },
    ],
  });

后续的渲染流程还是一样,只不过都是往同一块 Buffer 中的不同起始位置写入数据了。

绘制N个立方体

现在,我们完成了两个相同立方体的绘制,那么现在需要变为了我们需要绘制N个立方体,这里的N可能是3、4、5、6……200 甚至更多。那么我们是否要采取类似于上面绘制2个立方体这样的方式呢?答案是肯定的,比如我们可以用一个数组来保存我们的 BindGroup


  const bindGroups: GPUBindGroup[] = [];

  for (let i = 0; i < cubeNums; i++) {
    const group = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [
        {
          binding: 0,
          resource: {
            buffer: uniformBuffer,
            offset: offset * i,
            size: matrixSize,
          },
        },
      ],
    });

    bindGroups.push(group);
  }

绘制的时候对再利用一个循环来进行绘制


    for (let i = 0; i < cubeNums; i++) {
      const mat = mvpMatricesData.subarray(i * 16, (i + 1) * 16);

      device.queue.writeBuffer(
        uniformBuffer,
        i * offset,
        mat.buffer,
        mat.byteOffset,
        mat.byteLength
      );
      passEncoder.setBindGroup(0, bindGroups[i]);
      passEncoder.draw(cubeVertexCount, 1, 0, 0);
    }

通过这样的方式,肯定也是能够绘制出多个立方体的。不过WebGPU为我们提供了更加方便友好且效率更高的绘制方法——就是 instaced drawing(多实例绘制)

一个几何图形一般需要一次渲染,如果我们要绘制多个图形的话,因为每个图形的顶点、颜色、位置等属性都不一样,所以我们只能一一渲染,不能一起渲染。但是,如果几何图形的顶点数据都相同,颜色、位置等属性就都可以在着色器计算,那么我们就可以使用 WebGPU 支持的多实例绘制方式,一次性地把所有的图形都渲染出来。

Instanced Drawing

现在让我们继续修改我们的代码,多实例绘制不需要多个Buffer,我们只需要一个Buffer,但是往这个Buffer中写入的数据,是需要将所有立方体用的变换矩阵全部写入其中。


  const uniformBindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
        binding: 0,
        resource: {
          buffer: uniformBuffer,
        },
      },
    ],
  });

其余的渲染步骤与绘制一个立方体的步骤相同,只是在最后调用draw命令的时候提供需要绘制的实例个数即可:

passEncoder.draw(cubeVertexCount, numInstances, 0, 0);

修改Shader

除了删除的操作外,我们还需要修改一下我们的顶点着色器程序


fn main(
[[builtin(instance_index)]] instanceIdx: u32, 
[[location(0)]] position: vec4<f32>, 
[[location(1)]] uv: vec2<f32>) -> VertexOutput {
  var output : VertexOutput;
  output.Position = uniforms.modelViewProjectionMatrix[instanceIdx] * position;
  output.fragUV = uv;
  output.fragPosition = 0.5 * (position + vec4<f32>(1.0, 1.0, 1.0, 1.0));
  return output;
}

我们可以看到,我们的顶点着色器接受的第一个参数是一个内置的变量,与我们Hello,WebGPU —— 绘制第一个三角形 - 掘金 (juejin.cn)中讲解的类似,这里我们可以通过 [[builtin(instance_index)]] 来获取到多实例的索引,然后根据索引值获取uniform对象中的矩阵。

结果如下:

image.png

总结

今天我们学习了如何绘制多个立方体,从绘制2个立方体开始,我们可以利用多个BindGroup 然后调用多次drawCall来绘制多个立方体,也可以采用WebGPU提供的多实例绘制的方法来进行,多实例绘制提供了一种非常简单的方法使我们能够很方便的绘制顶点数据一样的图形,并且这种方法拥有很不错的性能,希望大家能仔细体会这种绘制方式。

OK,今天的内容相对来说还是比较简单的,如果你觉得本文不错的话,别忘了点个赞~