WebGPU 的原理
原作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-how-it-works.html
我们尝试用 JavaScript 实现类似 GPU 在顶点着色器和片元着色器中所做的事,以此解释 WebGPU。希望这能给你一种直观感受,了解背后发生了什么。
如果你熟悉 Array.map,仔细想想就能对这两种不同的着色器函数如何工作有所体会。使用 Array.map 时,你提供一个函数来转换一个值。
示例:
const shader = (v) => v * 2; // double the input
const input = [1, 2, 3, 4];
const output = input.map(shader); // result [2, 4, 6, 8]
上面我们为 array.map 写的“着色器”只是一个函数,输入一个数字,返回它的两倍。这可能是 JavaScript 中与“着色器”含义最接近的类比。着色器是一个返回或生成值的函数。你不会直接调用它,而是指定它,然后由系统为你调用。
对于 GPU 顶点着色器,你不是对一个输入数组进行映射。相反,你只需指定希望该函数被调用的次数。
function draw(count, vertexShaderFn) {
const internalBuffer = [];
for (let i = 0; i < count; ++i) {
internalBuffer[i] = vertexShaderFn(i);
}
console.log(JSON.stringify(internalBuffer));
}
一个结果是,与 Array.map 不同,我们不再需要源数组来做点什么。
const shader = (v) => v * 2;
const count = 4;
draw(count, shader);
// outputs [0, 2, 4, 6]
GPU 工作之所以复杂,是因为这些函数运行在计算机中一个独立的系统——GPU 上。这意味着你创建和引用的所有数据都必须以某种方式发送到 GPU,然后你需要告诉着色器数据放在哪里以及如何访问。
顶点着色器和片元着色器可以通过 6 种方式接收数据:Uniforms、Attributes、Buffers、Textures、Inter-Stage Variables、Constants。
1. Uniforms
Uniforms 是着色器每次迭代都相同的值。可以把它们看作常量全局变量。你可以在着色器运行之前设置它们,但在着色器使用期间它们保持不变,或者换个说法,它们保持“uniform”(一致)。
让我们修改 draw 以将 uniforms 传递给着色器。为此,我们创建一个名为 bindings 的数组,并用它来传递 uniforms。
function draw(count, vertexShaderFn, bindings) {
const internalBuffer = [];
for (let i = 0; i < count; ++i) {
internalBuffer[i] = vertexShaderFn(i, bindings);
}
console.log(JSON.stringify(internalBuffer));
}
然后修改着色器来使用 uniforms:
const vertexShader = (v, bindings) => {
const uniforms = bindings[0];
return v * uniforms.multiplier;
};
const count = 4;
const uniforms1 = { multiplier: 3 };
const uniforms2 = { multiplier: 5 };
const bindings1 = [uniforms1];
const bindings2 = [uniforms2];
draw(count, vertexShader, bindings1);
// outputs [0, 3, 6, 9]
draw(count, vertexShader, bindings2);
// outputs [0, 5, 10, 15]
所以,uniforms 的概念应该相当直观。通过 bindings 进行间接访问是因为这与 WebGPU 中的实际做法“相似”。如前所述,我们通过位置/索引来访问这些事物,这里就是 bindings[0]。
2. Attributes(仅顶点着色器)
Attributes 提供每次着色器迭代的数据。在上面的 Array.map 中,值 v 从 input 中取出并自动提供给函数。这与着色器中的 attribute 非常相似。
区别在于,我们没有对输入进行映射,而是因为我们只是在计数,所以需要告诉 WebGPU 这些输入是什么,以及如何从中获取数据。
想象我们这样更新 draw:
function draw(count, vertexShaderFn, bindings, attribsSpec) {
const internalBuffer = [];
for (let i = 0; i < count; ++i) {
const attribs = getAttribs(attribsSpec, i);
internalBuffer[i] = vertexShaderFn(i, bindings, attribs);
}
console.log(JSON.stringify(internalBuffer));
}
function getAttribs(attribs, ndx) {
return attribs.map(
({ source, offset, stride }) => source[ndx * stride + offset],
);
}
然后可以这样调用:
const buffer1 = [0, 1, 2, 3, 4, 5, 6, 7];
const buffer2 = [11, 22, 33, 44];
const attribsSpec = [
{ source: buffer1, offset: 0, stride: 2 },
{ source: buffer1, offset: 1, stride: 2 },
{ source: buffer2, offset: 0, stride: 1 },
];
const vertexShader = (v, bindings, attribs) =>
(attribs[0] + attribs[1]) * attribs[2];
const bindings = [];
const count = 4;
draw(count, vertexShader, bindings, attribsSpec);
// outputs [11, 110, 297, 572]
如上所示,getAttribs 使用 offset 和 stride 计算对应 source 缓冲区中的索引并取出值。取出的值随后被发送到着色器。每次迭代中 attribs 都不同。
iteration | attribs
----------+-------------
0 | [0, 1, 11]
1 | [2, 3, 22]
2 | [4, 5, 33]
3 | [6, 7, 44]
3. Raw Buffers
Buffers 本质上就是数组。再次类比,我们创建一个使用 buffers 的 draw 版本。我们将通过 bindings 传递这些 buffers,就像传递 uniforms 一样。
const buffer1 = [0, 1, 2, 3, 4, 5, 6, 7];
const buffer2 = [11, 22, 33, 44];
const attribsSpec = [];
const bindings = [buffer1, buffer2];
const vertexShader = (ndx, bindings, attribs) =>
(bindings[0][ndx * 2] + bindings[0][ndx * 2 + 1]) * bindings[1][ndx];
const count = 4;
draw(count, vertexShader, bindings, attribsSpec);
// outputs [11, 110, 297, 572]
这里我们得到了与 attributes 相同的结果,但这次我们不依赖系统从 buffers 中提取值,而是自己计算了绑定缓冲区的索引。这比 attributes 更灵活,因为我们基本上可以随机访问数组。但出于同样的原因,它可能更慢。根据 attributes 的工作方式,GPU 知道值将按顺序访问,并可以利用这一点进行优化。例如,顺序访问通常对缓存友好。而当我们自己计算索引时,GPU 直到我们实际尝试访问时才知道我们要访问缓冲区的哪一部分。
4. Textures
Textures 是 1 维、2 维或 3 维数据数组。当然,我们可以使用 buffers 实现自己的 2 维或 3 维数组。Textures 的特殊之处在于它们可以被采样(sampled)。采样意味着我们可以让 GPU 在提供的值之间计算一个值。这一点我们将在纹理文章中详细讨论。现在,我们再做一个 JavaScript 类比。
首先创建一个 textureSample 函数,它在数组的值之间进行采样:
function textureSample(texture, ndx) {
const startNdx = ndx | 0; // round down to an int
const fraction = ndx % 1; // get the fractional part between indices
const start = texture[startNdx];
const end = texture[startNdx + 1];
return start + (end - start) * fraction; // compute value between start and end
}
类似这样的函数在 GPU 上已经存在。
现在在着色器中使用它:
const texture = [10, 20, 30, 40, 50, 60, 70, 80];
const attribsSpec = [];
const bindings = [texture];
const vertexShader = (ndx, bindings, attribs) =>
textureSample(bindings[0], ndx * 1.75);
const count = 4;
draw(count, vertexShader, bindings, attribsSpec);
// outputs [10, 27.5, 45, 62.5]
当 ndx 为 3 时,我们会将 3 * 1.75 即 5.25 传入 textureSample。它会计算出 startNdx 为 5。于是我们取出索引 5 和 6 的值,分别是 60 和 70。fraction 变为 0.25,因此我们得到 60 + (70 - 60) * 0.25,即 62.5。
看上面的代码,我们本可以在着色器函数中自己编写 textureSample,手动取出两个值并在它们之间插值。GPU 之所以有这个特殊功能,是因为它可以更快地完成,而且根据设置,它可能读取多达十六个 4-float 值来为我们生成一个 4-float 值。手动完成这些工作量会非常大。
5. Inter-Stage Variables(仅片元着色器)
Inter-Stage Variables 是从顶点着色器输出并传递到片元着色器的变量。如前所述,顶点着色器输出位置,用于绘制/光栅化点、线和三角形。
假设我们正在画一条线。假设顶点着色器运行了两次,第一次输出相当于 5,0,第二次输出相当于 25,4。给定这两个点,GPU 将从 5,0 到 25,4 画一条线(不包括终点)。为此,它会调用片元着色器 20 次,即该线上的每个像素调用一次。每次调用片元着色器时,由我们决定返回什么颜色。
假设我们有一对函数帮助我们绘制两个点之间的线。第一个函数计算我们需要绘制多少个像素以及一些辅助值。第二个函数接收这些信息加上像素编号,然后给出像素位置。示例:
const line = calcLine([10, 10], [13, 13]);
for (let i = 0; i < line.numPixels; ++i) {
const p = calcLinePoint(line, i);
console.log(p);
}
// prints
// 10,10
// 11,11
// 12,12
注意:calcLine 和 calcLinePoint 的具体实现并不重要,重要的是它们能正常工作,并让上面的循环提供一条线的像素位置。如果你好奇,可以查看文章底部附近的实时代码示例。
现在修改顶点着色器,使其每次迭代输出两个值。我们可以用多种方式实现。这里有一种:
const buffer1 = [5, 0, 25, 4];
const attribsSpec = [
{ source: buffer1, offset: 0, stride: 2 },
{ source: buffer1, offset: 1, stride: 2 },
];
const bindings = [];
const dest = new Array(2);
const vertexShader = (ndx, bindings, attribs) => [attribs[0], attribs[1]];
const count = 2;
draw(count, vertexShader, bindings, attribsSpec);
// outputs [[5, 0], [25, 4]]
现在编写一些代码,每次循环两个点并调用 rasterizeLines 来光栅化一条线:
function rasterizeLines(dest, destWidth, inputs, fragShaderFn, bindings) {
for (let ndx = 0; ndx < inputs.length - 1; ndx += 2) {
const p0 = inputs[ndx];
const p1 = inputs[ndx + 1];
const line = calcLine(p0, p1);
for (let i = 0; i < line.numPixels; ++i) {
const p = calcLinePoint(line, i);
const offset = p[1] * destWidth + p[0]; // y * width + x
dest[offset] = fragShaderFn(bindings);
}
}
}
我们可以这样更新 draw 来使用那段代码:
function draw(
dest,
destWidth,
count,
vertexShaderFn,
fragmentShaderFn,
bindings,
attribsSpec,
) {
const internalBuffer = [];
for (let i = 0; i < count; ++i) {
const attribs = getAttribs(attribsSpec, i);
internalBuffer[i] = vertexShaderFn(i, bindings, attribs);
}
rasterizeLines(dest, destWidth, internalBuffer, fragmentShaderFn, bindings);
}
现在我们真正用上了 internalBuffer 😃!
更新调用 draw 的代码:
const buffer1 = [5, 0, 25, 4];
const attribsSpec = [
{ source: buffer1, offset: 0, stride: 2 },
{ source: buffer1, offset: 1, stride: 2 },
];
const bindings = [];
const vertexShader = (ndx, bindings, attribs) => [attribs[0], attribs[1]];
const count = 2;
const width = 30;
const height = 5;
const pixels = new Array(width * height).fill(0);
const fragShader = (bindings) => 6;
draw(pixels, width, count, vertexShader, fragShader, bindings, attribsSpec);
如果我们将 pixels 按矩形打印,其中 0 变成 .,我们会得到这样的结果:
.....666......................
........66666.................
.............66666............
..................66666.......
.......................66.....
不幸的是,我们的片元着色器没有得到每次迭代都变化的输入,因此无法为每个像素输出不同的值。这就是 inter-stage variables 的用武之地。让我们修改第一个着色器,使其输出一个额外的值:
const buffer1 = [5, 0, 25, 4];
const buffer2 = [9, 3];
const attribsSpec = [
{ source: buffer1, offset: 0, stride: 2 },
{ source: buffer1, offset: 1, stride: 2 },
{ source: buffer2, offset: 0, stride: 1 },
];
const bindings = [];
const dest = new Array(2);
const vertexShader = (ndx, bindings, attribs) => [
[attribs[0], attribs[1]],
[attribs[2]],
];
如果我们不做其他改变,在 draw 内部的循环之后,internalBuffer 会包含这些值:
[
[[5, 0], [9]],
[[25, 4], [3]],
];
我们可以轻松计算一个从 0.0 到 1.0 的值,表示我们沿线的进度。我们可以用它来插值刚添加的额外值:
function rasterizeLines(dest, destWidth, inputs, fragShaderFn, bindings) {
for (let ndx = 0; ndx < inputs.length - 1; ndx += 2) {
const p0 = inputs[ndx][0];
const p1 = inputs[ndx + 1][0];
const v0 = inputs[ndx].slice(1); // everything but the first value
const v1 = inputs[ndx + 1].slice(1);
const line = calcLine(p0, p1);
for (let i = 0; i < line.numPixels; ++i) {
const p = calcLinePoint(line, i);
const t = i / line.numPixels;
const interStageVariables = interpolateArrays(v0, v1, t);
const offset = p[1] * destWidth + p[0]; // y * width + x
dest[offset] = fragShaderFn(bindings, interStageVariables);
}
}
}
// interpolateArrays([[1,2]], [[3,4]], 0.25) => [[1.5, 2.5]]
function interpolateArrays(v0, v1, t) {
return v0.map((array0, ndx) => {
const array1 = v1[ndx];
return interpolateValues(array0, array1, t);
});
}
// interpolateValues([1,2], [3,4], 0.25) => [1.5, 2.5]
function interpolateValues(array0, array1, t) {
return array0.map((a, ndx) => {
const b = array1[ndx];
return a + (b - a) * t;
});
}
现在我们可以在片元着色器中使用这些 inter-stage variables:
const fragShader = (bindings, interStageVariables) =>
interStageVariables[0] | 0; // convert to int
如果现在运行,我们会看到类似这样的结果:
.....988......................
........87776.................
.............66655............
..................54443.......
.......................33.....
顶点着色器的第一次迭代输出 [[5,0], [9]],第二次迭代输出 [[25,4], [3]],你可以看到,随着片元着色器被调用,这些数组的第二个值在两个值之间进行了插值。
我们还可以再写一个 mapTriangle 函数,给定 3 个点,光栅化一个三角形,并针对三角形内的每个点调用片元着色器函数。它会从 3 个点而不是 2 个点插值 inter-stage variables。
下面是上述所有示例的实时运行,你可以随意试验以帮助理解:
上面 JavaScript 中发生的一切只是一个类比。在 WebGPU 中,inter-stage variables 如何实际插值、线条如何绘制、缓冲区如何访问、纹理如何采样、uniforms 和 attributes 如何指定等细节都有所不同,但概念非常相似。因此,我希望这个 JavaScript 类比能帮助你在脑海中建立发生过程的模型。
为什么会这样设计?如果你查看 draw 和 rasterizeLines,可能会注意到每次迭代完全独立于其他迭代。换句话说,你可以按任意顺序处理每次迭代。你可以不按 0、1、2、3、4 的顺序,而是按 3、1、4、0、2 的顺序处理,结果完全一样。迭代之间相互独立意味着每次迭代可以由不同的处理器并行运行。2021 年顶级 GPU 拥有 10000 个或更多处理器。这意味着最多可以同时并行运行 10000 个任务。这正是使用 GPU 的强大之处。遵循这些模式,系统就能大规模并行化工作。
最大的限制是:
- 着色器函数只能引用其输入(attributes、buffers、textures、uniforms、inter-stage variables)。
- 着色器不能分配内存。
- 着色器在引用其写入的对象(即它正在为其生成值的对象)时必须小心。
仔细想想,这是有道理的。假设上面的 fragShader 试图直接引用 dest。这意味着在尝试并行化时,将无法协调。哪个迭代先执行?如果第 3 次迭代引用了 dest[0],那么第 0 次迭代必须先运行;但如果第 0 次迭代引用了 dest[3],那么第 3 次迭代必须先运行。
在 CPU 和多线程/多进程中也需要围绕这个限制进行设计,但在 GPU 领域,多达 10000 个处理器同时运行,需要特殊的协调。我们会在其他文章中介绍一些技巧。
核心优化要点提炼
-
充分利用迭代独立性以最大化并行度
着色器的每次迭代相互独立,可被 GPU 上数千至上万个处理器同时执行。设计着色器与渲染流程时应避免跨迭代数据依赖,让系统能够安全地大规模并行化工作,这是发挥 GPU 算力的基础。 -
优先使用顺序访问的数据传递方式(Attributes 优于 Buffer 随机索引)
顶点属性(Attributes)让 GPU 提前知晓数据将按顺序被访问,从而启用缓存友好优化;而在着色器中自行计算 Buffer 索引则属于随机访问,GPU 无法预测访问模式,可能导致性能下降。当访问模式可预测时,应尽量使用顶点属性。 -
利用纹理采样硬件加速插值与过滤
GPU 内置的纹理采样器能够以硬件速度完成插值,一次采样操作甚至可能读取多达 16 个 4-float 值并生成一个 4-float 值,远超手动在着色器中用 Buffer 实现的效率。凡涉及插值、过滤或需要多维数据访问的场景,应优先使用纹理采样而非自行计算。
GLSL / WebGL 专业术语检查
本文实际讨论的是 WebGPU,而非 WebGL/GLSL,但二者同属 GPU 渲染管线,术语大多对应或相通。现列出文中关键术语及其中文规范译法,并注明与 WebGL/GLSL 的对应关系:
| 英文术语 | 中文规范译法 | 对应 WebGL/GLSL 术语 | 备注 |
|---|---|---|---|
| Uniforms | 统一变量(或 Uniform 变量) | Uniform | 所有顶点/片元同值的全局变量 |
| Attributes | 顶点属性 | Attribute | WebGL/GLSL 中的 attribute,仅在顶点着色器中使用 |
| Buffers | 缓冲区 | Buffer Object / VBO | WebGPU 中更细分为 Vertex Buffer / Storage Buffer |
| Textures | 纹理 | Texture | 具有硬件采样能力的 1D/2D/3D 数据数组 |
| Inter-Stage Variables | 阶段间变量 | Varying | WebGL/GLSL 中用 varying 实现顶点到片元的数据传递 |
| Constants | 常量 | const / #define | 编译期确定的固定值 |
| Bindings | 绑定(资源绑定) | Bindings / Texture Units | WebGPU 通过 bind group 组织资源绑定 |
| Sampling | 采样 | Sampling | GPU 硬件在纹理值之间插值 |
| Rasterize | 光栅化 | Rasterization | 将图元转化为片元/像素 |
| Interpolation | 插值 | Interpolation | 阶段间变量在光栅化过程中自动插值 |
| Stride | 步长(步幅) | Stride | 顶点属性从一个元素到下一个元素的字节间距 |
| Offset | 偏移量 | Offset | 属性在缓冲区中的起始偏移 |
| Cache friendly | 缓存友好 | Cache-friendly | 顺序访问有助于缓存命中 |
| Compute Shader | 计算着色器 | Compute Shader | 侧边栏提及,通用 GPU 并行计算 |
| Storage Buffer | 存储缓冲区 | SSBO | 可读写的大容量缓冲区 |
| Multisampling | 多重采样 | MSAA | 抗锯齿技术 |
| Bind Group Layout | 绑定组布局 | - | WebGPU 专有名词,描述资源绑定结构 |
重点提示:WebGPU 中不存在 GLSL 的 varying 关键字,顶点到片元的传递变量在 WGSL 中通过函数的返回值和入口参数实现,官方术语为 “inter-stage variables”。此外,WebGPU 用 Bind Group 取代了 WebGL 中零散的纹理单元和 uniform 绑定接口,设计上更贴近现代底层 API(如 Vulkan、Metal)。
