WebGPU 基础知识-绘制三角形到纹理
WebGPU是WebGPU标准的一部分,由Khronos Group领导的WebGPU工作组制定,是一种为Web公开GPU硬件功能的API。它提供对网络上硬件的图形和计算能力的访问,旨在提供基于现代GPU的高性能、低延迟的图形渲染服务。
推荐使用NSDT 3DConvert进行3D模型格式转换,支持glb、obj、stp、fbx、ifc等多种3D模型格式之间进行互相转换,在转换过程中,能够很好的保留模型原有的颜色、材质等信息。
绘制三角形到纹理 Drawing triangles to textures
WebGPU 可以绘制三角形到纹理。就本文而言,纹理是像素的二维矩形[见注释3] 。 元素表示网页上的纹理。在 WebGPU 中,我们可以向画布请求纹理,然后渲染到该纹理。
要使用 WebGPU 绘制三角形,我们必须提供 2 个“着色器”。同样,着色器是在 GPU 上运行的函数。这2个着色器是
Vertex Shaders 顶点着色器是计算绘制三角形/直线/点的顶点位置的函数
Fragment Shaders 片段着色器是在绘制三角形/线/点时计算要绘制/栅格化的每个像素的颜色(或其他数据)的函数
让我们从一个非常小的 WebGPU 程序开始画一个三角形。
我们需要一个画布来显示我们的三角形:
<canvas></canvas>
然后我们需要一个 <script>
标签来保存我们的 JavaScript。
<canvas></canvas>
<script type="module">
... javascript goes here ...
</script>
下面的所有 JavaScript 都将放在这个脚本标签中。
WebGPU 是一种异步 API,因此容易在异步函数中使用。我们首先请求适配器,然后从适配器请求设备。
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('need a browser that supports WebGPU');
return;
}
}
main();
上面的代码是相当清楚的。首先,我们使用 ?. 可选链式运算符请求适配器。因此,如果 navigator.gpu 不存在,则 adapter 将是未定义的。如果它确实存在,那么我们将调用 requestAdapter 。它异步地转换结果,所以需要 await 。适配器代表一个特定的 GPU。有些设备有多个 GPU。
我们从适配器请求设备,但再次使用 ?. ,这样如果适配器碰巧未定义,那么设备也将是未定义的。
如果未设置 device ,则可能是用户使用的是旧版本浏览器(chrome>=113 才支持WebGPU)。
接下来我们查找画布并为其创建一个 webgpu 上下文。这将使我们获得一个纹理来渲染,该纹理将用于渲染网页中的画布。
// Get a WebGPU context from the canvas and configure it
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
同样,上面的代码也很清晰。我们从画布中获得了一个 "webgpu" 上下文。我们询问系统首选的画布格式是什么。这将是 "rgba8unorm" 或 "bgra8unorm" 。它是什么并不重要,但通过查询 它将使用户系统的速度最快的格式。
我们通过调用 configure 将其作为 format 传递到 webgpu 画布上下文中。我们还传入了 device ,它将此画布与我们刚刚创建的设备相关联。
接下来我们创建一个着色器模块。着色器模块包含一个或多个着色器函数。在我们的例子中,我们将编写 1 个顶点着色器函数和 1 个片段着色器函数。
const module = device.createShaderModule({
label: 'our hardcoded red triangle shaders',
code: `
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
return vec4f(pos[vertexIndex], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1.0, 0.0, 0.0, 1.0);
}
`,
});
着色器是用一种称为 WebGPU 着色语言 (WGSL) 的语言编写的,通常发音为 wig-sil。 WGSL 是一种强类型语言,我们将在另一篇文章中尝试详细介绍。现在,我希望通过一些说明,您可以推断出一些基础知识。
上面我们看到一个名为 vs 的函数是使用 @vertex 属性声明的。这将其指定为顶点着色器函数。
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
...
它接受一个命名为 vertexIndex 的参数。 vertexIndex 是一个 u32 ,表示一个 32 位无符号整数。它从名为 vertex_index 的内置函数中获取其值(用@builtin(vertex_index)表示 )。 vertex_index 就像一个迭代数(iteration number),类似于 JavaScript 的 Array.map(function(value, index) { ... }) 中的 index 。如果我们通过调用 draw 告诉 GPU 执行此函数 10 次,第一次 vertex_index 将是 0 ,第二次将是 1 ,第三次将是 2 ,等等…
我们的 vs 函数被声明为返回一个 vec4f ,它是四个 32 位浮点值的向量。将其视为一个包含 4 个值的数组或一个具有 4 个属性的对象,如 {x: 0, y: 0, z: 0, w: 0} 。此返回值将分配给 position 内置函数(即@builtin(position))。在“三角形列表 triangle-list”模式下,每执行 3 次顶点着色器,就会绘制一个三角形,连接我们返回的 3 个 position 值。
WebGPU 中的位置需要在裁剪空间(clip space)中返回,其中 X 从左侧的 -1.0 变为右侧的 +1.0,Y 从底部的 -1.0 变为顶部的 +1.0。无论我们绘制的纹理大小如何,都是如此。
vs 函数声明了一个包含 3 个 vec2f 的数组。每个 vec2f 由两个 32 位浮点值组成。然后代码用 3 个 vec2f 填充该数组。
var pos = array<vec2f, 3>(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
最后,它使用 vertexIndex 从数组中返回 3 个值之一。由于该函数需要 4 个浮点值作为其返回类型,并且由于 pos 是 vec2f 的数组,因此代码为其余的 2 个值提供了 0.0 和 1.0 。
return vec4f(pos[vertexIndex], 0.0, 1.0);
着色器模块还声明了一个名为 fs 的函数,该函数使用 @fragment 属性声明,使其成为片段着色器函数。
@fragment fn fs() -> @location(0) vec4f {
此函数不接受任何参数,并在 location(0) 处返回 vec4f 。这意味着它将写入第一个渲染目标。稍后我们会将第一个渲染目标作为我们的画布。
return vec4f(1, 0, 0, 1);
代码返回红色的 1, 0, 0, 1 。 WebGPU 中的颜色通常指定为从 0.0 到 1.0 的浮点值,其中上述 4 个值分别对应红色、绿色、蓝色和 alpha。
当 GPU 光栅化三角形(用像素绘制它)时,它会调用片段着色器来找出每个像素的颜色。在我们的例子中,我们只是返回红色。
需要注意的另一件事是 label 。您可以使用 WebGPU 创建的几乎每个对象都可以使用 label 。标签完全是可选的,但最好为您制作的所有东西贴上标签。原因是,当您遇到错误时,大多数 WebGPU 实现都会打印一条错误消息,其中包含与错误相关的事物的标签。
在一个普通的应用程序中,你会有 100 或 1000 的缓冲区、纹理、着色器模块、管道等......如果你得到一个像 "WGSL syntax error in shaderModule at line 10" 这样的错误,如果你有 100 个着色器模块,哪个有错误?如果你给模块贴上标签,那么你会得到一个更像 "WGSL syntax error in shaderModule('our hardcoded red triangle shaders') at line 10 的错误,这是一种更有用的错误信息,可以为你节省大量时间来跟踪问题。
现在我们已经创建了一个着色器模块,接下来我们需要制作一个渲染管线。
const pipeline = device.createRenderPipeline({
label: 'our hardcoded red triangle pipeline',
layout: 'auto',
vertex: {
module,
entryPoint: 'vs',
},
fragment: {
module,
entryPoint: 'fs',
targets: [{ format: presentationFormat }],
},
});
在这个例子里,没什么可看的。我们将 layout 设置为 'auto' ,这意味着要求 WebGPU 从着色器中获取数据布局。不过我们没有使用任何数据。
然后,我们告诉渲染管线使用着色器模块中的 vs 函数作为顶点着色器,使用 fs 函数作为片段着色器。另外我们告诉它第一个渲染目标的格式。 “渲染目标”是指我们将渲染到的纹理。我们创建了一个管道,我们必须指定我们将使用该管道最终渲染到的纹理的格式。
targets 数组的元素 0 对应于我们为片段着色器的返回值指定的位置 0。稍后,我们将该目标设置为画布的纹理。
接下来我们准备一个 GPURenderPassDescriptor ,它描述了我们想要绘制哪些纹理以及如何使用它们。
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- to be filled out when we render
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
};
GPURenderPassDescriptor 有一个 colorAttachments 数组,其中列出了我们将渲染到的纹理以及如何处理这些纹理。我们将等待填充我们实际想要渲染的纹理。现在,我们设置了一个简单的半深灰色值,以及一个 loadOp 和 storeOp 。 loadOp: 'clear' 指定在绘制之前将纹理清除为清除值。另一个选项是 'load' ,这意味着将纹理的现有内容加载到 GPU 中,这样我们就可以绘制已经存在的内容。 storeOp: 'store' 表示存储我们绘制的结果。我们也可以传递 'discard' ,这会丢弃我们绘制的内容。我们将在另一篇文章中介绍为什么我们可能想要这样做。
现在是渲染的时候了。
function render() {
// Get the current texture from the canvas context and
// set it as the texture to render to.
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
// make a command encoder to start encoding commands
const encoder = device.createCommandEncoder({ label: 'our encoder' });
// make a render pass encoder to encode render specific commands
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.draw(3); // call our vertex shader 3 times
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
render();
首先我们调用 context.getCurrentTexture() 来获取将出现在画布中的纹理。调用 createView 可以查看纹理的特定部分,但如果没有参数,它将返回默认部分,这正是我们在这种情况下想要的。在这种情况下,我们唯一的 colorAttachment 是来自画布的纹理视图,我们通过在开始时创建的上下文获得它。同样, colorAttachments 数组的元素 0 对应于我们为片段着色器的返回值指定的 location(0) 。
接下来我们创建一个命令编码器。命令编码器用于创建命令缓冲区。我们用它来编码命令,然后“提交”它创建的命令缓冲区来执行命令。
然后,我们使用命令编码器通过调用 beginRenderPass 创建渲染通道编码器。渲染通道编码器是用于创建与渲染相关的命令的特定编码器。我们将 renderPassDescriptor 传递给它以告诉它我们要渲染到哪个纹理。
我们对命令 setPipeline 进行编码,以设置我们的管道,然后通过使用 3 调用 draw 来告诉它执行我们的顶点着色器 3 次。默认情况下,每执行 3 次我们的顶点着色器,将通过连接绘制一个三角形刚刚从顶点着色器返回的 3 个值。
最后我们结束渲染通道,然后结束编码器。这为我们提供了一个命令缓冲区,表示我们刚刚指定的步骤。最后我们提交要执行的命令缓冲区。
当执行 draw 命令时,这将是我们的状态。
我们没有纹理、没有缓冲区、没有绑定组,但我们有一个管道、一个顶点和片段着色器,以及一个渲染过程描述符,它告诉我们的着色器渲染到画布纹理。
下边是全部代码和运行结果截图:
@import url(https://webgpufundamentals.org/webgpu/resources/webgpu-lesson.css);
<canvas></canvas>
<script type="module">
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('need a browser that supports WebGPU');
return;
}
// Get a WebGPU context from the canvas and configure it
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
const module = device.createShaderModule({
label: 'our hardcoded red triangle shaders',
code: `
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
return vec4f(pos[vertexIndex], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`,
});
const pipeline = device.createRenderPipeline({
label: 'our hardcoded red triangle pipeline',
layout: 'auto',
vertex: {
module,
entryPoint: 'vs',
},
fragment: {
module,
entryPoint: 'fs',
targets: [{ format: presentationFormat }],
},
});
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- to be filled out when we render
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
};
function render() {
// Get the current texture from the canvas context and
// set it as the texture to render to.
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
// make a command encoder to start encoding commands
const encoder = device.createCommandEncoder({ label: 'our encoder' });
// make a render pass encoder to encode render specific commands
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.draw(3); // call our vertex shader 3 times.
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
render();
}
function fail(msg) {
// eslint-disable-next-line no-alert
alert(msg);
}
main();
</script>
重要的是要强调我们调用的所有这些函数,如 setPipeline 和 draw 仅将命令添加到命令缓冲区。他们实际上并不执行命令。当我们将命令缓冲区提交到设备队列时执行这些命令。
So, now we’ve seen a very small working WebGPU example. It should be pretty obvious that hard coding a triangle inside a shader is not very flexible. We need ways to provide data and we’ll cover those in the following articles. The points to take away from the code above, 所以,现在我们已经看到了一个非常小的 WebGPU 工作示例。很明显,在着色器中硬编码三角形不是很灵活。我们需要提供数据的方法,我们将在以下文章中介绍这些方法。从上面的代码中获得的要点如下:
- WebGPU 只是运行着色器。由你来给它们填充代码来做有用的事情
- 着色器在着色器模块中指定,然后装配到渲染管道
- WebGPU 可以绘制三角形
- WebGPU 绘制到纹理(我们碰巧从画布上获取纹理)
- WebGPU 通过编码命令然后提交它们来工作。