Hello WebGPU —— 旋转立方体

1,143 阅读12分钟

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

前言

接上文,上次我们讨论了如何在WebGPU中开启MSAA,还记得我们的“三步走”大法吗?如果你忘记了,希望你能够及时回过头去复习一下。今天我们将讨论另一个新的话题,我们开始在WebGPU中绘制3D图形,首先我们从绘制一个立方体开始入手吧!

顶点数据传递

还记得我们之前在绘制三角形的时候,是如何指定三角形的顶点的吗? (此处希望你能仔细回忆一番)


一个世纪过去了。。。。。。


没错,之前我们是在顶点着色器中指定的顶点信息,那么对于立方体,我们是否也可以这样做呢?没错,我们也可以这样做!但是如果我们一直这样做的话,我们的Shader程序的通用性就太差!所以这次我们采用从CPU中向GPU中传递数据的方式。

首先,我们还是先来看看在WebGL中是如何做的吧!

WebGL中传递顶点数据

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, bufferData, gl.STATIC_DRAW);

let a_position = gl.getAttribLocation(program, 'a_position');
gl.vertexAttribPointer(a_position, 2, gl.FLOAT, false, fsize * 4, 0);
gl.enableVertexAttribArray(a_position);

大致分为以下几步:

  1. 创建Buffer
  2. 往Buffer中填充数据
  3. 获取GPU中顶点变量的位置,往GPU中传递数据。

如果对这块内容不太熟悉的同学,可以去看看我这一篇文章 WebGL概述——原理篇

WebGPU中传递顶点数据

现在,让我们看看在WebGPU中又是如何往顶点中传递数据的呢.与WebGL中类似,首先我们都需要创建一个Buffer对象。。

const verticesBuffer = device.createBuffer({
    size: cubeVertexArray.byteLength,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true,
  });
  
  
new Float32Array(verticesBuffer.getMappedRange()).set(cubeVertexArray);
verticesBuffer.unmap();

在W3C的标准中对 GPUBuffer的解释为:

GPUBuffer represents a block of memory that can be used in GPU operations. Data is stored in linear layout, meaning that each byte of the allocation can be addressed by its offset from the start of the GPUBuffer, subject to alignment restrictions depending on the operation. Some GPUBuffers can be mapped which makes the block of memory accessible via an ArrayBuffer called its mapping.

GPUBuffer 表示的是能够在GPU中进行操作的一块内存,其数据以线性存储的方式存在其中,这意味着每一位的数据都可以通过offset进行直接寻址。某些 GPUBuffer 能够将其映射为 ArrayBuffer 从而对其进行数据的读写。

这里对 GPUBuffer 对象多说两句,它有几个重要的概念:

  1. [[state]]: 它属于GPUBuffer 中的内部属性(这里可以理解为是私有变量) 一共有以下几个状态
    • "mapped": 当 GPUBuffer 处于此状态时,表示它对于CPU是可操作的,对于GPU是不可操作的。
    • "mapped at creation": 表示 GPUBuffer 在创建时就会被设置为 "mapped" 状态,换句话说,也就是 GPUBuffer 创建好时,对CPU就是可用的了。
    • "mapping pending": 表示其还处于pending状态,对CPU和GPU都不可用
    • “unmapped”: 表示对GPU可用。
    • ”destroyed“: 表示该buffer 已经被销毁,不可再用。

所以当一个 GPUBuffer 处于 mapped的状态时,才能够被CPU所读写。

  1. getMappedRange(offset, size): 其返回一个 ArrayBuffer 对象,包含了 GPUBuffer 中的数据。
    • 如何往 ArrayBuffer中写入数据呢?熟悉 ArrayBuffer的同学应该知道,如果需要往里写入数据的话,我们需要使用 ArrayBuffer的视图来进行操作,换句话说,需要使用类型化数组包装 ArrayBuffer,这样我们才能够把数据正确的写入内存当中。

OK,至此我们完成了顶点数据Buffer的创建和写入数据。接下来,我们需要修改 Pipeline State Object的配置。

修改Pipeline State Object

PSO 对象的具体配置如下:

cnst pipeline = device.createRenderPipeline({
    vertex: {
      module: device.createShaderModule({
        code: basicVertWGSL,
      }),
      entryPoint: 'main',
      buffers: [ // 为顶点着色器配置Buffer
        {
          arrayStride: cubeVertexSize,
          attributes: [
            {
              // position
              shaderLocation: 0,
              offset: cubePositionOffset,
              format: 'float32x4',
            },
            {
              // uv
              shaderLocation: 1,
              offset: cubeUVOffset,
              format: 'float32x2',
            },
          ],
        },
      ],
    },
    fragment: {
      module: device.createShaderModule({
        code: vertexPositionColorWGSL,
      }),
      entryPoint: 'main',
      targets: [
        {
          format: presentationFormat,
        },
      ],
    },
    primitive: {
      topology: 'triangle-list',

      // Backface culling since the cube is solid piece of geometry.
      // Faces pointing away from the camera will be occluded by faces
      // pointing toward the camera.
      cullMode: 'back',
    },
    multisample: {
      count: 4
    },

    // Enable depth testing so that the fragment closest to the camera
    // is rendered in front.
    depthStencil: {
      depthWriteEnabled: true,
      depthCompare: 'less',
      format: 'depth24plus',
    },
  });

我们着重看一下与第一章绘制三角形中不同的部分:

  1. 为顶点着色器配置了buffer,该buffer包含了立方体的顶点位置,uv坐标的信息。
{
      arrayStride: cubeVertexSize,
      attributes: [
        {
          // position
          shaderLocation: 0,
          offset: cubePositionOffset,
          format: 'float32x4',
        },
        {
          // uv
          shaderLocation: 1,
          offset: cubeUVOffset,
          format: 'float32x2',
        },
      ],
}

熟悉WebGL的同学应该很容易看懂上述的信息

  • arrayStride表示的是一个顶点包含的所有信息所占的大小,例如:我们这里的数据是这样组织的:
  // point        color       uv
  1, -1, 1, 1,   1, 0, 1, 1,  1, 1, // point1
  -1, -1, 1, 1,  0, 0, 1, 1,  0, 1, // point2
  -1, -1, -1, 1, 0, 0, 0, 1,  0, 0, // point3

我们可以看到,立方体的一个顶点中包含了位置信息颜色信息纹理坐标 三类数据,这三类数据总共占据4x10 = 40 byte 大小,也就是一个顶点所占的大小,这里为什么是 4x10,因为每一位数据都是 float32 类型的数据, float32类型的数据刚好占据4个byte的大小。

  • attributes: 表示这套数据中有哪些属性
    • shaderLocation: 表示它对应shader中的哪个位置

    • offset: 提供一个偏移量,让GPU方便的进行快速寻址找到对应的数据,比如:这里position的数据就在每一行的开头,所以偏移量是0,uv数据在末尾,在uv数据之前有8个数字,所以它的偏移量是 4x8=32 byte。

    • format: 表示这个属性的数据类型,float32 表示具体的数据类型,后面的x4 表示是由4个 float32 类型的数据组成。

现在,顶点相关的数据我们已经配置完成,接下来我们看看fragment的部分是否有修改,OK,fragment的部分在PSO中是没有任何的修改。

接着,查看 primitive 的部分


primitive: {
    topology: 'triangle-list',
    // Backface culling since the cube is solid piece of geometry.
    // Faces pointing away from the camera will be occluded by faces
    // pointing toward the camera.
    cullMode: 'back',
},    

这里增加了一个属性 cullMode,其表示剔除模式,这里是表示剔除模型的背面。那么何为正面,何为背面?

image.png

熟悉WebGL的同学应该知道,在WebGL中,三角形的顶点顺序为逆时针时,则该三角形为正面三角形,;三角形的顶点顺序为顺时针时,则为背面三角形。所以 cullMode: back 的意思就是:凡是顶点顺序为顺时针的三角形,统统都不进行绘制。

最后,我们还发现新增了一个 depthStencil的配置,这是用于配置深度及模板测试的。这里我们只说明深度测试的配置。

depthStencil: {
      depthWriteEnabled: true,
      depthCompare: 'less',
      format: 'depth24plus',
    },

其含义如下;

  • depthWriteEnabled:表示开启深度写入,表示在这次渲染中,会将图形的深度值写入深度缓冲中。
  • depthCompare: 进行深度测试时使用,对比深度缓冲中的值和即将绘制的像素的深度值,如果不满足条件,则无法通过深度测试,也就无法进行接来下的渲染了。这里的 less 表示新的像素的深度值必须小于深度缓冲中的值才能够进行接下来的渲染。
  • format: 表示深度值的数据格式

到目前为止,PSO的修改也完成了。

设置UniformBuffer

接下来,该设置UniformBuffer了,以方便我们往GPU中传入Uniform类型的值,比如MVP矩阵等相关信息

注意:

本文不详细讨论MVP矩阵的相关内容,文章中也不会出现如何计算MVP矩阵的任何信息,如果读者有兴趣可自行查阅资料。

创建的方法如下:


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

我们看一下在WebGL中传递uniform变量的方法:

const uniformLocation = gl.getUniformLocation('modelViewProjectionMatrix')
gl.uniformMatrix4fv(uniformLocation, false, matrix);

我们可以看到在WebGL中传递uniform变量时并不需要额外的创建一个Buffer,而是通过相关API获取到GPU中uniform变量的位置,然后通过相关API直接赋值。

而在WebGPU中,统一往顶点数据中传递和传递uniform变量的方式,都是需要创建Buffer的。我们对比一下刚刚创建的顶点数据Buffer和我们现在的UniformBuffer的区别:

usage: 
    GPUBufferUsage.VERTEX,    // Vertex Buffer
    GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST // Uniform Buffer
    
mappedAtCreation:
    true // Vertex Buffer
    false // Uniform Buffer

我们可以看到主要就是创建Buffer时,指定的 usagemappedAtCreation 不一样。如果我们指定 mappedAtCreationtrue,那么我们则需要在创建后就为其设置值。而这里的为什么我们没有为Uniform Buffer 设置 mappedAtCreationtrue 呢?因为我们可能在后面的渲染帧中修改它。所以我们需要以另外的方式去写入数据。

接下来,我们为Uniform Buffer 创建绑定组(BindGroup)

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

首先我们还是先来解释一下BindGroup的含义吧。 createBindGroup 返回一个 GPUBindGroup 对象。 它定义了一系列需要被绑定在一起的资源,并指定它们应该在shader中怎样使用。

我们再来看下它们的属性:

  • layout: 表示该bindGroup在Shader中的布局位置。在shader中通过形如 [[binding(0), group(0)]] 这样的方式来指定使用的bindGroup的值。
  • entries: 表示相关资源使用的Buffer及绑定位置。 比如下面的shader代码表示uniforms变量对应的就是我们刚刚创建的bindGroup中的uniformBuffer中的数据。
[[binding(0), group(0)]] var<uniform> uniforms 

OK,到目前位置,我们的相关Buffer已经基本准备完了。我们可以正式进入修改渲染流程的步骤。

修改渲染流程

关于渲染流程中,我们需要修改的部分,主要是需要修改 renderPass 中的配置

const renderPassDescriptor: GPURenderPassDescriptor = {
    colorAttachments: [
      {
        view: context.getCurrentTexture().createView(), // Assigned later

        loadValue: { r: 0.5, g: 0.5, b: 0.5, a: 1.0 },
        storeOp: 'store',
      },
    ],
    depthStencilAttachment: {
      view: depthTexture.createView(),

      depthLoadValue: 1.0,
      depthStoreOp: 'store',
      stencilLoadValue: 0,
      stencilStoreOp: 'store',
    },
  };

我们可以看到主要是新增了 depthStencilAttachment 这一项,这一项的主要用途是为深度测试和模板测试提供初始值的。这里我们可以先行略过。如果我们在PSO对象中没有设置 depthStencil 这一项的话,depthStencialAttachment 也是不必要的。它们总是成对出现的。

另一个重要的步骤是往之前我们创建的uniform Buffer中写入值。

device.queue.writeBuffer(
      uniformBuffer,
      0,
      transformationMatrix.buffer,
      transformationMatrix.byteOffset,
      transformationMatrix.byteLength
    );

注意,如果我们要使用这个API往Buffer写入值,必须满足以下条件:

  1. buffer 必须有效合法
    • buffer 对象必须合法
    • buffer.device 对象必须合法
    • buffer.device 与 创建它的device 必须是同一个对象
  2. buffer[[state]] 必须是 unmapped 状态,也就是对于GPU可用的状态。
  3. bufferusage 中必须包含 COPY_DST 的标志位。
  4. bufferOffset 转换为bytes, 必须是4 byte的倍数。
  5. bufferOffset + contentSize 转换为bytes, 必须小于 buffersize 大小。

最后,我们还需要在 renderPass 中设置 uniformBuffer 的 BindGroup

passEncoder.setBindGroup(0, uniformBindGroup);

最后这一步可以算是特别简单了,其余的步骤与我们之前绘制三角形的步骤基本一致。

修改WGSL

vertex shader

struct Uniforms {
  modelViewProjectionMatrix : mat4x4<f32>;
};
[[binding(0), group(0)]] var<uniform> uniforms : Uniforms;

struct VertexOutput {
  [[builtin(position)]] Position : vec4<f32>;
  [[location(0)]] fragUV : vec2<f32>;
  [[location(1)]] fragPosition: vec4<f32>;
};

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

这里我们采用了结构体来使我们的代码更加的清晰,这里主要解释一下数据绑定的部分:

[[binding(0), group(0)]]: 这个参数与我们创建的BindGroup中的layout参数与binding参数一一对应。这样我们就知道它的值到底是什么了。

VertexOutput 会作为我们的输出进入渲染管线的光栅化阶段,比如 output.Position 指定了最后的顶点位置,output.fragUV, output.fragPosition 在光栅化阶段会被插值。然后我们可以在片元着色器中可以获取到插值后的结果。

fragment shader

[[stage(fragment)]]
fn main(
    [[location(0)]] fragUV: vec2<f32>,
    [[location(1)]] fragPosition: vec4<f32>,
) -> [[location(0)]] vec4<f32> {
  return fragPosition;
}

我们可以看到这里的输入为:[[location(0)]] fragUV [[location(1)]] fragPosition,这里与顶点着色器中的 VertexOutput 中的 location 需要一一对应。注意这里 fragUVfragPosition 都是插值后的结果。

最终的渲染结果如下:

image.png

总结

最后让我们来总结一下今天学习到的内容

  1. 顶点数据传递:我们通过 createBuffer 创建出一个 GPUBuffer,然后往其中塞入数据。
  2. uniform类型的数据传递:同样也是通过 createBuffer 创建出GPUBuffer,然后往其中塞入数据,不过与顶点数据传递略微不同的是:
    • uniform类型的Buffer的 usage 除了设置为 GPUBufferUsage.UNIFORM 外,还需要加上 GPUBufferUsage.COPY_DST 。因为如果没有 GPUBufferUsage.COPY_DST,那么我们无法通过 writeBuffer 这个API往其中写入数据。
    • 顶点数据的buffer 是在创建时通过设置了 mappedAtCreation: true 将 buffer 设置为了 mapped 状态,以便我们可以直接通过类型化数组直接写入数据。
    • uniform类型的Buffer需要通过穿件 bindGroup向GPU中传递数据
  3. 学习了在shader程序中使用结构体来使我们的程序更加清晰,如何在顶点着色器和片元着色器之间传递变量的知识。
  4. 我们还涉及到了一些深度/模板 测试/写入的相关内容,不过这不是我们这节的重点,我们可以直接忽略这部分的内容。相关的配置项也可以进行删除。

今天的主要内容就是这么多啦,如果你觉得本文对你有用的话,请点个赞哦,你的支持就是我更新的动力~

参考资料