「这是我参与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);
大致分为以下几步:
- 创建Buffer
- 往Buffer中填充数据
- 获取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
的解释为:
A
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 theGPUBuffer
, subject to alignment restrictions depending on the operation. SomeGPUBuffers
can be mapped which makes the block of memory accessible via anArrayBuffer
called its mapping.
GPUBuffer
表示的是能够在GPU中进行操作的一块内存,其数据以线性存储的方式存在其中,这意味着每一位的数据都可以通过offset进行直接寻址。某些GPUBuffer
能够将其映射为ArrayBuffer
从而对其进行数据的读写。
这里对 GPUBuffer
对象多说两句,它有几个重要的概念:
[[state]]
: 它属于GPUBuffer
中的内部属性(这里可以理解为是私有变量) 一共有以下几个状态- "mapped": 当
GPUBuffer
处于此状态时,表示它对于CPU是可操作的,对于GPU是不可操作的。 - "mapped at creation": 表示
GPUBuffer
在创建时就会被设置为 "mapped" 状态,换句话说,也就是GPUBuffer
创建好时,对CPU就是可用的了。 - "mapping pending": 表示其还处于pending状态,对CPU和GPU都不可用
- “unmapped”: 表示对GPU可用。
- ”destroyed“: 表示该buffer 已经被销毁,不可再用。
- "mapped": 当
所以当一个 GPUBuffer
处于 mapped的状态时,才能够被CPU所读写。
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',
},
});
我们着重看一下与第一章绘制三角形中不同的部分:
- 为顶点着色器配置了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
,其表示剔除模式,这里是表示剔除模型的背面。那么何为正面,何为背面?
熟悉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时,指定的 usage
和 mappedAtCreation
不一样。如果我们指定 mappedAtCreation
为 true
,那么我们则需要在创建后就为其设置值。而这里的为什么我们没有为Uniform Buffer 设置 mappedAtCreation
为 true
呢?因为我们可能在后面的渲染帧中修改它。所以我们需要以另外的方式去写入数据。
接下来,我们为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写入值,必须满足以下条件:
buffer
必须有效合法- buffer 对象必须合法
- buffer.device 对象必须合法
- buffer.device 与 创建它的device 必须是同一个对象
buffer
的[[state]]
必须是unmapped
状态,也就是对于GPU可用的状态。buffer
的usage
中必须包含COPY_DST
的标志位。bufferOffset
转换为bytes, 必须是4 byte的倍数。bufferOffset
+contentSize
转换为bytes, 必须小于buffer
的size
大小。
最后,我们还需要在 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
需要一一对应。注意这里 fragUV
与 fragPosition
都是插值后的结果。
最终的渲染结果如下:
总结
最后让我们来总结一下今天学习到的内容
- 顶点数据传递:我们通过
createBuffer
创建出一个 GPUBuffer,然后往其中塞入数据。 - 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中传递数据
- uniform类型的Buffer的 usage 除了设置为
- 学习了在shader程序中使用结构体来使我们的程序更加清晰,如何在顶点着色器和片元着色器之间传递变量的知识。
- 我们还涉及到了一些深度/模板 测试/写入的相关内容,不过这不是我们这节的重点,我们可以直接忽略这部分的内容。相关的配置项也可以进行删除。
今天的主要内容就是这么多啦,如果你觉得本文对你有用的话,请点个赞哦,你的支持就是我更新的动力~