WebGPU 阶段间变量:顶点着色器向片元着色器传值

原文作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-inter-stage-variables.html

本文编译自 WebGPU Fundamentals 系列教程中的《Inter-stage Variables》一文,不是全盘翻译,而是围绕核心概念、代码示例及实战要点进行深度解读。文章重点解释顶点着色器与片段着色器之间如何通过“阶段间变量”传递数据,以及其中涉及的内建变量、插值设置等关键机制。


1. 阶段间变量是什么?

阶段间变量(Inter-stage Variables)是顶点着色器(Vertex Shader)输出、片段着色器(Fragment Shader)输入,并由 GPU 在光栅化阶段自动插值的一类数据。

当顶点着色器输出三角形的三个顶点位置时,GPU 会光栅化该三角形。除了位置,顶点着色器还可以在每个顶点额外输出其他值(如颜色、纹理坐标、法线等)。默认情况下,这些值在三角形的三个顶点之间进行插值,并将插值结果传入片段着色器。

一个典型的最小示例:让一个三角形三个顶点分别输出红、绿、蓝,片段着色器接收插值后的颜色,最终渲染出渐变三角形。


2. 使用 struct 管理阶段间接口

在 WGSL 中,最清晰的方式是定义一个结构体(struct),同时用于顶点着色器的输出和片段着色器的输入。

struct OurVertexShaderOutput {
  @builtin(position) position: vec4f,
  @location(0) color: vec4f,
};

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32
) -> OurVertexShaderOutput {
  let pos = array(
    vec2f( 0.0,  0.5),  // 顶部中心
    vec2f(-0.5, -0.5),  // 左下
    vec2f( 0.5, -0.5)   // 右下
  );
  var color = `array<vec4f, 3>`(
    vec4f(1, 0, 0, 1), // 红
    vec4f(0, 1, 0, 1), // 绿
    vec4f(0, 0, 1, 1), // 蓝
  );

  var vsOutput: OurVertexShaderOutput;
  vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
  vsOutput.color = color[vertexIndex];
  return vsOutput;
}

@fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
  return fsInput.color;
}

这段代码中,结构体 OurVertexShaderOutput 同时包含:

  • @builtin(position) position: vec4f — 顶点着色器输出裁剪坐标,也是光栅化使用的内置变量,不是普通阶段间变量。
  • @location(0) color: vec4f — 真正的阶段间变量,通过 location 索引与片段着色器对应。

片段着色器通过结构体同名参数接收数据。这样接口清晰,不易出错。


3. 阶段间变量通过 location 连接

WebGPU 中几乎所有的着色器接口匹配都基于索引。阶段间变量通过 location 索引连接,而不是通过变量名。因此,片段着色器也可以不接收整个结构体,而是直接接收对应 @location(0) 的参数:

@fragment fn fs(@location(0) color: vec4f) -> @location(0) vec4f {
  return color;
}

运行效果完全相同。这一点非常重要:顶点着色器的输出和片段着色器的输入只需要 location 一致,变量名可以不同。


4. @builtin(position) 的特殊性

在结构体中,position 字段带有 @builtin(position),它不是阶段间变量,而是内置变量。同样的名字在顶点着色器和片段着色器中含义完全不同:

  • 在顶点着色器中,@builtin(position) 是输出,表示顶点在裁剪空间中的坐标,GPU 用它绘制三角形/线/点。
  • 在片段着色器中,@builtin(position) 是输入,表示当前片段所对应像素在帧缓冲中的坐标。坐标值是像素中心位置。

例如,一个 3×2 像素的画布,其像素中心坐标如下:

(0.5,0.5) (1.5,0.5) (2.5,0.5)
(0.5,1.5) (1.5,1.5) (2.5,1.5)

这种坐标不随三角形变化,只与画布像素相关。利用它可以实现屏幕空间效果,例如棋盘格:

@fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
  let red = vec4f(1, 0, 0, 1);
  let cyan = vec4f(0, 1, 1, 1);

  let grid = vec2u(fsInput.position.xy) / 8;
  let checker = (grid.x + grid.y) % 2 == 1;

  return select(red, cyan, checker);
}

这里 fsInput.position.xy 被转换为无符号整数向量,除以 8 后得到每 8 个像素递增的网格坐标,再通过模 2 运算生成棋盘图案。select 函数等价于 JavaScript 中的三元表达式 condition ? b : a。

即使片段着色器不使用 @builtin(position),只要结构体中声明了它,也可以与顶点着色器共享同一个结构体。但要注意:字段名相同并不代表它们是同一个变量。在 WebGPU 解析 WGSL 时,每个入口点(entry point)是独立分析的,顶点着色器和片段着色器的 position 互不相干。

因此,很多开发者为顶点输出和片段输入分别声明不同的结构体,甚至将两个着色器拆分为独立模块:

// vsModule
struct OurVertexShaderOutput {
  @builtin(position) position: vec4f,
};
@vertex fn vs(...) -> OurVertexShaderOutput { ... }

// fsModule
@fragment fn fs(@builtin(position) pixelPosition: vec4f) -> @location(0) vec4f {
  let grid = vec2u(pixelPosition.xy) / 8;
  ...
}

这样做的好处是:

  • 每个模块职责单一,便于维护和复用。
  • 避免在片段着色器中引入不必要的 @location 变量。
  • 两个入口点可以根据需要选择不同的输入输出接口。

5. 插值设置:@interpolate

默认情况下,阶段间变量采用透视校正插值(perspective correct)和像素中心采样(center)。但可以通过 @interpolate 属性修改行为。

插值类型(Interpolation Type):

类型说明
perspective透视校正插值(默认)
linear线性插值,不做透视校正
flat不插值,使用图元中某个顶点的值

插值采样(Interpolation Sampling):

采样说明
center在像素中心插值(默认)
centroid在覆盖图元的所有采样点内的质心位置插值
sample按采样点逐个插值;片段着色器每次采样调用一次
first仅与 flat 配合,取图元第一个顶点值(默认)
either仅与 flat 配合,取第一个或最后一个顶点值,由实现决定

示例:

@location(2) @interpolate(linear, center) myVariableFoo: vec4f;
@location(3) @interpolate(flat) myVariableBar: vec4f;

关键限制:如果阶段间变量是整数类型,则必须使用 flat 插值,否则会编译错误。另外,对于 flat 插值,若需要确定性,建议显式设置 @interpolate(flat, either),避免不同实现选择不同顶点造成的行为差异。


6. 三个核心优化要点

结合本文内容,可以提炼出以下三个与性能和工程实践相关的优化要点:

优化要点 1:用 struct 规范阶段间接口,减少 location 混乱

将所有阶段间变量集中定义在一个结构体中,并通过 @location 精确匹配顶点输出与片段输入。这不仅能减少人为出错,还能提升接口可维护性。同时,充分利用 WebGPU 按 location 索引连接的特性,避免依赖变量名匹配而引入不必要的包装。

优化要点 2:明确区分 @builtin(position) 与阶段间变量,避免多余数据传递

@builtin(position) 不是阶段间变量,不参与 location 分配,不应为它“占位”。在片段着色器中,通常直接使用内建的像素坐标,而不是通过顶点输出额外传递一遍屏幕坐标,这样可以减少阶段间变量的带宽消耗。正确理解这一点还能避免在片段着色器中错误地试图写入该内置变量(它是只读输入)。

优化要点 3:按需设置 @interpolate,避免默认透视校正的不必要开销

并非所有阶段间变量都需要透视校正插值。例如,某些颜色值、标志位或整数数据,使用 flat 插值可以完全跳过插值计算,提高效率。对于整数类型必须使用 flat,对于不需要透视校正的向量可以使用 linear。此外,使用 flat 时建议指定 either 采样以获得确定性行为。正确选择插值类型和采样方式,既有助于性能,也能保证跨实现的一致性。


7. GLSL/WebGL 专业术语检查

本文原文使用 WebGPU/WGSL 术语,与用户可能熟悉的 GLSL/WebGL 术语存在差异。编译时需要注意区分,避免混用:

英文原文建议中文翻译备注
Inter-stage variable阶段间变量不是 GLSL 中的 varying,WebGPU 中不使用该术语
Vertex shader顶点着色器对应 GLSL 的 vertex shader
Fragment shader片段着色器避免使用“片元着色器”(虽然有时也译作片元,但 WebGPU 规范用 fragment)
Rasterization光栅化与 OpenGL/WebGL 一致
Interpolation插值与 GLSL 中 varying 插值类似,但机制在 WGSL 中更明确
@builtin(position)内建 position 变量在顶点阶段输出裁剪坐标,在片段阶段输入像素坐标,与 GLSL 的 gl_Position / gl_FragCoord 类似但语义不同
@location(n)位置索引(或直接保留 location)用于阶段间变量及片段输出绑定
@interpolate插值属性WGSL 特有,控制插值类型与采样
Perspective correct透视校正与 GLSL 的 perspective 限定符对应
Centroid质心与 GLSL centroid 限定符对应
Flat平坦插值与 GLSL flat 限定符对应
Sample采样与 GLSL sample 限定符类似
Entry point入口点WGSL 中用 @vertex、@fragment、@compute 标记
Struct结构体与 GLSL 中 struct 概念一致
WGSLWebGPU 着色语言不是 GLSL,语法和语义有差异

通过以上对照,可以避免因 GLSL/WebGL 习惯而误读 WebGPU 的 WGSL 代码。例如,将 @builtin(position) 直接等同于 gl_Position 或 gl_FragCoord 都是不准确的,它根据着色器阶段扮演不同的角色。


小结:阶段间变量是 WebGPU 渲染管线中连接顶点与片段着色器的核心机制。掌握 struct + @location 接口约定,理解 @builtin(position) 的双重身份,以及善用 @interpolate 控制插值行为,是写出高效、可维护 WGSL 代码的关键。