WebGPU 基础 (WebGPU Fundamentals)
WebGPU 基础 (WebGPU Fundamentals)
本文将尝试向你教授 WebGPU 的最基本基础知识。
在阅读本文之前,默认你已经了解 JavaScript。本文将广泛使用数组映射(mapping arrays)、解构赋值(destructuring assignment)、展开运算符(spreading values)、async/await、es6 模块等概念。如果你还不了解 JavaScript 并想学习它,请参阅 JavaScript.info、Eloquent JavaScript 和/或 CodeCademy。
如果你已经了解 WebGL,请阅读这篇文章。
WebGPU 是一个允许你执行 2 项基本操作的 API:
- 在纹理(textures)上绘制三角形/点/线
- 在 GPU 上运行计算(computations)
仅此而已!
除此之外,关于 WebGPU 的一切都取决于你。这就像学习 JavaScript、Rust 或 C++ 等计算机语言一样。首先你学习基础知识,然后由你创造性地利用这些基础知识来解决你的问题。
WebGPU 是一个极低级别的 API。虽然你可以制作一些简单的示例,但对于许多应用来说,它可能需要大量的代码和严的数据组织。例如,支持 WebGPU 的 three.js 包含约 550k 字节的压缩 JavaScript,而这仅仅是其基础库。这还不包括加载器(loaders)、控制器(controls)、后处理(post-processing)和许多其他功能。同样,TensorFlow 的核心加上 WebGPU 后端约为 600k 字节的压缩 JavaScript,且不包括对各种可选功能的持。
重点是,如果你只是想在屏幕上显示某些东西,最好选择一个能提供大量代码的库,因为如果你自己动手,就必须编写这些代码。
另一方面,也许你有自定义的使用场景,或者你想修改现有库,或者你只是好奇它是如何工作的。在这些情况下,请继续阅读!
入门 (Getting Started)
很难决定从哪里开始。在某种程度上,WebGPU 是一个非常简单的系统。它所做的只是在 GPU 上运行 3 种类型的函数:顶点着色器(Vertex Shaders)、片元着色器(Fragment Shaders)和计算着色器(Compute Shaders)。
顶点着色器计算顶点。着色器返回顶点位置。对于顶点着色器函数返回的每组 3 个顶点,都会在这 3 个位置之间绘制一个三角形。[1]
片元着色器计算颜色。[2] 当绘制三角形时,对于要绘制的每个像素,GPU 都会调用你的片元着色器。片元着色器随后返回一种颜色。
计算着色器更通用。它实际上只是一个你调用的函数,并说“执行这个函数 N 次”。GPU 在每次调用函数时都会传递迭代次数,因此你可以使用该数字在每次迭代中执行独特的操作。
如果你仔细观察,可以认为这些函数类似于传递给 array.forEach 或 array.map 的函数。你在 GPU 上运行的函数就是函数,就像 JavaScript 函数一样。不同之处在于它们运行在 GPU 上,因此为了运行它们,你需要将希望它们访问的所有数据以缓冲区(buffers)和纹理(textures)的形式复制到 GPU,并且它们只能输出到这些缓冲区和纹理。你需要在函数中指定函数将查找数据的绑定(bindings)或位置(locations)。而且,在 JavaScript 中,你需要将持有数据的缓冲区和纹理绑定到这些绑定或位置。完成这些操作后,你告诉 GPU 执行该函数。
关于这张图需要注意的地方:
- 有一个 管线(Pipeline) 。它包含了 GPU 将运行的顶点着色器和片元着色器。你也可以拥有包含计算着色器的管线。
- 着色器通过 绑定组(Bind Groups) 间接引用资源(缓冲区、纹理、采样器)。
- 管线定义了通过内部状态间接引用缓冲区的属性(Attributes)。
- 属性从缓冲区中提取数据并将其输入到顶点着色器中。
- 顶点着色器可能会将数据输入到片元着色器中。
- 片元着色器通过渲染通道描述(render pass description)间接写入纹理。
要在 GPU 上执行着色器,你需要创建所有这些资源并设置这些状态。资源的创建相对直接。有趣的一点是,大多数 WebGPU 资源在创建后不能更改。你可以更改它们的内容,但不能更改它们的大小、用法、格式等。如果你想更改这些内容,你需要创建一个新资源并销毁旧资源。
某些状态是通过创建并执行 命令缓冲区(command buffers) 来设置的。命令缓冲区正如其名,它们是命令的缓冲区。你创建 命令编码器(command encoders) 。编码器将命令编码到命令缓冲区中。然后你完成编码器,它会返回创建的命令缓冲区。接着你可以提交该命令缓冲区,让 WebGPU 执行这些命令。
以下是编码命令缓冲区的一些伪代码,以及生成的命令缓冲区的表示:
JavaScript
encoder = device.createCommandEncoder()
// 绘制某些东西
{
pass = encoder.beginRenderPass(...)
pass.setPipeline(...)
pass.setVertexBuffer(0, …)
pass.setVertexBuffer(1, …)
pass.setIndexBuffer(...)
pass.setBindGroup(0, …)
pass.setBindGroup(1, …)
pass.draw(...)
pass.end()
}
// 绘制其他东西
{
pass = encoder.beginRenderPass(...)
pass.setPipeline(...)
pass.setVertexBuffer(0, …)
pass.setBindGroup(0, …)
pass.draw(...)
pass.end()
}
// 计算某些东西
{
pass = encoder.beginComputePass(...)
pass.setBindGroup(0, …)
pass.setPipeline(...)
pass.dispatchWorkgroups(...)
pass.end();
}
commandBuffer = encoder.finish();
一旦创建了命令缓冲区,你就可以提交它来执行:
JavaScript
device.queue.submit([commandBuffer]);
前面显示的“WebGPU 设置简化图”表示命令缓冲区中单个绘制命令的状态。执行命令将设置内部状态,然后绘制命令将告诉 GPU 执行顶点着色器(并间接执行片元着色器)。dispatchWorkgroup 命令将告诉 GPU 执行计算着色器。
我希望这能让你对需要设置的状态有一些心理映射。如上所述,WebGPU 可以做 2 件基本事情:
- 在纹理上绘制三角形/点/线
- 在 GPU 上运行计算
我们将详细讲解执行这两件事的小示例。其他文章将展示向这些事物提供数据的各种方法。请注意,这将非常基础。我们需要建立这些基础。稍后我们将展示如何使用它们来执行人们通常使用 GPU 执行的操作,如 2D 图形、3D 图形等。
在纹理上绘制三角形 (Drawing triangles to textures)
WebGPU 可以将三角形绘制到纹理上。就本文而言,纹理是像素的 2D 矩形。[3] <canvas> 元素代表网页上的一个纹理。在 WebGPU 中,我们可以向画布请求一个纹理,然后渲染到该纹理。
为了使用 WebGPU 绘制三角形,我们必须提供 2 个“着色器”。再次强调,着色器是运行在 GPU 上的函数。这两个着色器是:
- 顶点着色器 (Vertex Shaders) :计算用于绘制三角形/线/点的顶点位置的函数。
- 片元着色器 (Fragment Shaders) :计算在绘制三角形/线/点时,要绘制/光栅化的每个像素的颜色(或其他数据)的函数。
让我们从一个非常小的 WebGPU 程序开始画一个三角形。
我们需要一个画布来显示我们的三角形:
<canvas></canvas>
然后我们需要一个 <script> 标签来存放我们的 JavaScript:
<canvas></canvas>
<script type="module">
... javascript goes here ...
</script>
下面的所有 JavaScript 都将放在这个 script 标签内。
WebGPU 是一个异步 API,因此在异步函数中使用它是最简单的。我们首先请求一个适配器(adapter),然后从适配器请求一个设备(device)。
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 不存在,适配器将是 undefined。如果它存在,我们将调用 requestAdapter。它异步返回结果,所以我们需要 await。适配器代表特定的 GPU。某些设备有多个 GPU。
从适配器中,我们请求设备,同样使用 ?.,这样如果适配器恰好是 undefined,设备也将是 undefined。如果设备未设置,可能是用户使用了旧浏览器。
接下来,我们查找画布并为其创建 webgpu 上下文。这将让我们获得一个要渲染到的纹理。该纹理将用于在网页中显示画布。
// 从画布获取 WebGPU 上下文并进行配置
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
同样,上面的代码非常直白。我们从画布获取 "webgpu" 上下文。我们询问系统首选的画布格式是什么。这将是 "rgba8unorm" 或 "bgra8unorm"。它是什么并不重要,但查询它会使用户的系统运行得更快。
我们将该格式作为 format 通过调用 configure 传递到 webgpu 画布上下文中。我们还传入了 device,这将此画布与我们刚刚创建的设备关联起来。
接下来,我们创建一个着色器模块。着色器模块包含一个或多个着色器函数。在我们的例子中,我们将创建一个顶点着色器函数和一个片元着色器函数。
const module = device.createShaderModule({
label: 'our hardcoded red triangle shaders',
code: /* wgsl */ `
@vertex
fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // 顶部中心
vec2f(-0.5, -0.5), // 左下角
vec2f( 0.5, -0.5) // 右下角
);
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 是一种强类型语言,我们将在另一篇文章中尝试更详细地介绍。目前,我希望通过一些解释,你可以推断出一些基础知识。
注意:在本网站中,存储 WGSL 的字符串前面都有 /* wgsl */ 注释。这是一种约定,旨在帮助文本编辑器尝试对 WGSL 进行语法高亮和/或提供智能提示。
上面我们看到一个名为 vs 的函数使用了 @vertex 属性声明。这指定它为一个顶点着色器函数。
@vertex
fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
...
它接受一个我们命名为 vertexIndex 的参数。vertexIndex 是一个 u32,意思是 32 位无符号整数。它从名为 vertex_index 的内置变量(builtin)中获取值。vertex_index 就像一个迭代次数,类似于 JavaScript 的 Array.map(function(value, index) { ... }) 中的 index。如果我们通过调用 draw 告诉 GPU 执行此函数 10 次,第一次 vertex_index 将为 0,第二次为 1,第三次为 2,依此类推。[4]
我们的 vs 函数声明返回一个 vec4f,它是四个 32 位浮点值的向量。可以把它看作一个包含 4 个值的数组或一个具有 4 个属性的对象,如 {x: 0, y: 0, z: 0, w: 0}。此返回值将被分配给 position 内置变量。在“三角形列表”(triangle-list)模式下,顶点着色器每执行 3 次,就会连接我们返回的 3 个位置值绘制一个三角形。
WebGPU 中的位置需要返回到 裁剪空间(clip space) 中,其中 X 从左侧的 -1.0 到右侧的 +1.0,Y 从底部的 -1.0 到顶部的 +1.0。无论我们要绘制的纹理大小如何,这都是正确的。
vs 函数声明了一个由 3 个 vec2f 组成的数组。每个 vec2f 由两个 32 位浮点值组成。
let pos = array(
vec2f( 0.0, 0.5), // 顶部中心
vec2f(-0.5, -0.5), // 左下角
vec2f( 0.5, -0.5) // 右下角
);
最后,它使用 vertexIndex 从数组中返回 3 个值之一。由于该函数要求返回类型为 4 个浮点值,且由于 pos 是 vec2f 数组,因此代码为剩余的 2 个值提供了 0.0 和 1.0。
return vec4f(pos[vertexIndex], 0.0, 1.0);
请注意,对于在 2D 中绘制内容,我们通常只需要位置的 x 和 y 值。z 值用于深度测试(depth testing),将在正交投影文章中提到。w 值用于透视除法(perspective divide),将在透视投影文章中提到。目前,将 z 设置为 0.0,将 w 设置为 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 对象都可以带有一个标签。标签完全是可选的,但最好给它们贴上标签。当发生错误时,大多数 WebGPU 错误会显示引发错误的对象的标签。在包含 100 个着色器模块、100 个管线、100 个缓冲区的程序中,如果没有标签,你可能会收到类似“着色器模块发生错误”的错误,这将需要大量工作才能找出具体是哪一个。如果给它们贴上标签,你会得到类似“着色器模块 '我们的硬编码红色三角形着色器' 发生错误”的错误,这更具描述性。
现在我们有了着色器模块,接下来需要创建一个渲染管线。
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 对应于我们在片元着色器返回值中指定的 location 0。稍后,我们将把该目标设置为画布的纹理。
一个快捷方式是,对于每个着色阶段 vertex 和 fragment,如果对应类型只有一个函数,则无需指定 entryPoint。WebGPU 将使用与着色阶段匹配的唯一函数。因此我们可以简化上面的代码。
接下来,我们准备一个 GPURenderPassDescriptor,它描述了我们要绘制到哪些纹理以及如何使用它们。
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- 渲染时填充
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() {
// 从画布上下文获取当前纹理
// 并将其设置为我们要渲染到的纹理。
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
// 创建一个命令编码器来开始编码命令
const encoder = device.createCommandEncoder({ label: 'our encoder' });
// 开启渲染通道来运行着色器
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.draw(3); // 调用顶点着色器 3 次
pass.end();
// 完成编码并提交命令
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
render();
首先,我们通过调用 context.getCurrentTexture().createView() 获取画布的当前纹理视图。通过调用 context.getCurrentTexture(),我们正在获取一个将显示在网页画布中的纹理。我们还调用 createView。你可以从纹理的一部分创建视图,但在没有任何参数的情况下,它将返回最常见的默认视图。我们需要设置 colorAttachments[0].view。
接下来,我们创建一个命令编码器。然后通过调用 encoder.beginRenderPass(renderPassDescriptor) 创建一个渲染通道(render pass)。渲染通道会执行我们的渲染命令。我们在 renderPassDescriptor 中传入了颜色附件,因此它将开始通过清除纹理进行渲染。
我们设置管线,然后调用 draw。由于我们将 3 传递给 draw,我们的顶点着色器将被调用 3 次,vertex_index 将分别为 0、1 和 2。由于我们的顶点着色器在每次执行时返回不同的位置,因此每组 3 个位置将产生一个三角形。
最后,我们结束通道,完成编码器以获得命令缓冲区,并提交命令缓冲区。
运行该程序,我们得到一个三角形。
[Triangle Demo Placeholder]
GPU 计算 (Running computations on the GPU)
接下来让我们看看如何利用 GPU 进行计算。
我们将使用一个简单的例子:取一些数字并将它们翻倍。
首先,我们需要一个计算着色器。
const module = device.createShaderModule({
label: 'doubling compute shader',
code: /* wgsl */ `
@group(0) @binding(0) var<storage, read_write> data: array<f32>;
@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) id: vec3u) {
data[id.x] = data[id.x] * 2.0;
}
`,
});
在这个着色器中,我们声明了一个名为 data 的变量。
@group(0) @binding(0) var<storage, read_write> data: array<f32>;
它被赋予了 @group(0) 和 @binding(0)。它被声明为 var<storage, read_write>,这意味着它将被存储在缓冲区中,并且它是可读写的。它被定义为 array<f32>,即 32 位浮点数的数组。
然后我们定义了函数 main。
@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) id: vec3u) {
我们赋予它 @compute 属性,使其成为计算着色器。我们还赋予它 @workgroup_size(1) 属性,我们将在另一篇文章中讨论它的含义。
它接受一个参数 id。id 是一个 vec3u 类型,由三个 32 位无符号整数组成。它通过内置变量 global_invocation_id 获取它的值。如果你仔细观察,你可以认为这就像我们在上面谈到的 vertex_index。如果我们告诉 GPU 运行此函数 10 次,那么在第一次运行中 id.x 将为 0,第二次为 1,第三次为 2,依此类推。
代码本身非常简单:
data[id.x] = data[id.x] * 2.0;
它使用 id.x 索引到我们的数组中,并将值乘以 2。
现在我们有了着色器,我们需要创建一个计算管线。
const pipeline = device.createComputePipeline({
label: 'doubling compute pipeline',
layout: 'auto',
compute: {
module,
entryPoint: 'main',
},
});
正如我们之前所做的,我们将 layout 设置为 'auto'。
接下来,我们需要一些数据。
const input = new Float32Array([1, 3, 5]);
由于数据是在 JavaScript 端(CPU 端),我们需要在 GPU 端创建一个缓冲区,并将数据从 JavaScript 复制到 GPU 缓冲区。
// 在 GPU 上创建一个缓冲区来保存数据
const workBuffer = device.createBuffer({
label: 'work buffer',
size: input.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
// 将数据复制到缓冲区
device.queue.writeBuffer(workBuffer, 0, input);
通过传递 GPUBufferUsage.STORAGE,我们说希望该缓冲区可被用作存储。这使其与着色器中的 var<storage,...> 兼容。此外,我们希望能够将数据复制到此缓冲区,因此我们包含了 GPUBufferUsage.COPY_DST 标志。最后,我们希望能够从缓冲区复制数据,因此我们包含了 GPUBufferUsage.COPY_SRC。
请注意,你无法直接从 JavaScript 读取 WebGPU 缓冲区的内容。相反,你必须对其进行“映射”(map),这是另一种向 WebGPU 请求访问缓冲区的方式,因为缓冲区可能正在使用中,并且它可能仅存在于 GPU 上。
可以映射到 JavaScript 的 WebGPU 缓冲区不能用于太多其他用途。换句话说,我们不能直接映射上面创建的缓冲区,如果我们尝试添加标志使其可映射,我们将收到一个错误,因为它与用法 STORAGE 不兼容。
因此,为了看到计算结果,我们需要另一个缓冲区。运行计算后,我们将把上面的缓冲区复制到这个结果缓冲区中,并设置其标志以便我们可以对其进行映射。
// 在 GPU 上创建一个缓冲区来获取结果的副本
const resultBuffer = device.createBuffer({
label: 'result buffer',
size: input.byteLength,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});
MAP_READ 意味着我们希望能够映射此缓冲区以读取数据。
为了告诉我们的着色器我们要处理的缓冲区,我们需要创建一个绑定组(bindGroup)。
// 设置一个绑定组来告诉着色器使用哪个
// 缓冲区进行计算
const bindGroup = device.createBindGroup({
label: 'bindGroup for work buffer',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: workBuffer } },
],
});
我们从管线获取绑定组的布局。然后设置绑定组条目。pipeline.getBindGroupLayout(0) 中的 0 对应着色器中的 @group(0)。条目中的 {binding: 0 ... 对应着色器中的 @group(0) @binding(0)。
现在我们可以开始编码命令。
// 编码执行计算的命令
const encoder = device.createCommandEncoder({
label: 'doubling encoder',
});
const pass = encoder.beginComputePass({
label: 'doubling compute pass',
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(input.length);
pass.end();
我们创建一个命令编码器。开始一个计算通道。设置管线,然后设置绑定组。这里,pass.setBindGroup(0, bindGroup) 中的 0 对应着色器中的 @group(0)。然后我们调用 dispatchWorkgroups,在这种情况下,我们传入 input.length(即 3),告诉 WebGPU 运行计算着色器 3 次。然后结束通道。
这是执行 dispatchWorkgroups 时的情况。
计算完成后,我们要求 WebGPU 从 workBuffer 复制到 resultBuffer。
// 编码将结果复制到可映射缓冲区的命令。
encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);
现在我们可以完成编码器以获得命令缓冲区,然后提交该命令缓冲区。
// 完成编码并提交命令
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
然后我们映射结果缓冲区并获取数据的副本。
// 读取结果
await resultBuffer.mapAsync(GPUMapMode.READ);
const result = new Float32Array(resultBuffer.getMappedRange());
console.log('input', input);
console.log('result', result);
resultBuffer.unmap();
要映射结果缓冲区,我们调用 mapAsync 并必须等待它完成。映射后,我们可以调用 resultBuffer.getMappedRange(),在不带参数的情况下它将返回整个缓冲区的 ArrayBuffer。我们将其放入 Float32Array 类型数组视图中,然后查看这些值。一个重要的细节是,getMappedRange 返回的 ArrayBuffer 仅在调用 unmap 之前有效。在 unmap 之后,它的长度将被设置为 0,其数据将不再可访问。
运行该程序,我们可以看到我们得到了结果,所有的数字都翻倍了。
[Compute Demo Placeholder]
我们将在其他文章中介绍如何真正使用计算着色器。目前,希望你已经对 WebGPU 的作用有了初步的了解。除此之外的一切都取决于你!将 WebGPU 视为类似于其他编程语言。它提供了一些基本功能,其余的留给你的创造力。
使 WebGPU 编程特别的是,这些函数(顶点着色器、片元着色器和计算着色器)运行在你的 GPU 上。GPU 可能拥有超过 10,000 个处理器,这意味着它们可以并行进行 10,000 次以上的计算,这可能比你的 CPU 并行计算能力高出 3 个或更多数量级。
(后略:关于画布调整大小等细节)