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 的强大之处。遵循这些模式,系统就能大规模并行化工作。

最大的限制是:

  1. 着色器函数只能引用其输入(attributes、buffers、textures、uniforms、inter-stage variables)。
  2. 着色器不能分配内存。
  3. 着色器在引用其写入的对象(即它正在为其生成值的对象)时必须小心。

仔细想想,这是有道理的。假设上面的 fragShader 试图直接引用 dest。这意味着在尝试并行化时,将无法协调。哪个迭代先执行?如果第 3 次迭代引用了 dest[0],那么第 0 次迭代必须先运行;但如果第 0 次迭代引用了 dest[3],那么第 3 次迭代必须先运行。

在 CPU 和多线程/多进程中也需要围绕这个限制进行设计,但在 GPU 领域,多达 10000 个处理器同时运行,需要特殊的协调。我们会在其他文章中介绍一些技巧。


核心优化要点提炼

  1. 充分利用迭代独立性以最大化并行度
    着色器的每次迭代相互独立,可被 GPU 上数千至上万个处理器同时执行。设计着色器与渲染流程时应避免跨迭代数据依赖,让系统能够安全地大规模并行化工作,这是发挥 GPU 算力的基础。

  2. 优先使用顺序访问的数据传递方式(Attributes 优于 Buffer 随机索引)
    顶点属性(Attributes)让 GPU 提前知晓数据将按顺序被访问,从而启用缓存友好优化;而在着色器中自行计算 Buffer 索引则属于随机访问,GPU 无法预测访问模式,可能导致性能下降。当访问模式可预测时,应尽量使用顶点属性。

  3. 利用纹理采样硬件加速插值与过滤
    GPU 内置的纹理采样器能够以硬件速度完成插值,一次采样操作甚至可能读取多达 16 个 4-float 值并生成一个 4-float 值,远超手动在着色器中用 Buffer 实现的效率。凡涉及插值、过滤或需要多维数据访问的场景,应优先使用纹理采样而非自行计算。


GLSL / WebGL 专业术语检查

本文实际讨论的是 WebGPU,而非 WebGL/GLSL,但二者同属 GPU 渲染管线,术语大多对应或相通。现列出文中关键术语及其中文规范译法,并注明与 WebGL/GLSL 的对应关系:

英文术语中文规范译法对应 WebGL/GLSL 术语备注
Uniforms统一变量(或 Uniform 变量)Uniform所有顶点/片元同值的全局变量
Attributes顶点属性AttributeWebGL/GLSL 中的 attribute,仅在顶点着色器中使用
Buffers缓冲区Buffer Object / VBOWebGPU 中更细分为 Vertex Buffer / Storage Buffer
Textures纹理Texture具有硬件采样能力的 1D/2D/3D 数据数组
Inter-Stage Variables阶段间变量VaryingWebGL/GLSL 中用 varying 实现顶点到片元的数据传递
Constants常量const / #define编译期确定的固定值
Bindings绑定(资源绑定)Bindings / Texture UnitsWebGPU 通过 bind group 组织资源绑定
Sampling采样SamplingGPU 硬件在纹理值之间插值
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)。