WebGPU Shader Constants(管线可覆盖常量)

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-constants.html

本文是 WebGPU 系列教程之一,讨论向着色器提供数据的各种方式。本课聚焦于 pipeline-overridable constants——一种在着色器中声明,但可在创建渲染管线时覆盖的常量。


什么是管线可覆盖常量?

在 WGSL 中,你可以使用 override 关键字声明一个常量,并在创建管线时指定新值。一个最简单的例子:

override red = 0.0;
override green = 0.0;
override blue = 0.0;

@fragment fn fs() -> @location(0) vec4f {
  return vec4f(red, green, blue, 1.0);
}

配合基础教程中的顶点着色器:

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
  let pos = array(
    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);
}

如果直接使用这个着色器,会得到一个黑色三角形。但我们可以在创建管线时覆盖这些常量:

const pipeline = device.createRenderPipeline({
  label: 'our hardcoded triangle pipeline',
  layout: 'auto',
  vertex: {
    module,
  },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
    constants: {
      red: 1,
      green: 0.5,
      blue: 1,
    },
  },
});

这样就会得到粉紫色。管线可覆盖常量只能是标量:布尔值、整数或浮点数,不能是向量或矩阵。

如果在着色器中没有给常量默认值,则必须在管线中提供。也可以使用 @id 指定数字 ID:

override red: f32;             // 必须在管线中指定
@id(123) override green = 0.0; // 可以通过名称 "green" 或 ID 123 指定
override blue = 0.0;

@fragment fn fs() -> @location(0) vec4f {
  return vec4f(red, green, blue, 1.0);
}

为什么不用字符串替换?

你可能会想:完全可以在创建 WGSL 代码时直接拼接常量:

const red = 0.5;
const blue = 0.7;
const green = 1.0;

const code = `
const red = ${red};
const green = ${green};
const blue = ${blue};

@fragment fn fs() -> @location(0) vec4f {
  return vec4f(red, green, blue, 1.0);
}
`;

甚至更直接:

const red = 0.5;
const blue = 0.7;
const green = 1.0;

const code = `
@fragment fn fs() -> @location(0) vec4f {
  return vec4f(${red}, ${green}, ${blue}, 1.0);
}
`;

区别在于:管线可覆盖常量可以在着色器模块创建之后才应用,因此理论上比重新创建着色器模块更快。虽然创建管线本身也不是一个很快的操作,但 WebGPU 实现可以在首次使用某组常量创建管线后缓存部分信息,后续使用不同常量创建时能减少重复工作。无论如何,这是一种将少量数据传入着色器的便捷方式。


入口点独立求值

一个容易忽略的重点:每个 entry point(入口点)会被独立评估。可以理解成,传给 createShaderModule 的代码会被“剥离”,只保留与当前入口点相关的部分;管线覆盖常量先应用,之后才为该入口点生成着色器。

因此,同一个 WGSL 模块里的顶点阶段和片段阶段可以各自使用不同的常量值。我们扩展示例,让两个阶段都使用 red/green/blue,并让顶点阶段把颜色通过 inter-stage 变量传给片段阶段,每 50 像素切换一种颜色:

struct VOut {
  @builtin(position) pos: vec4f,
  @location(0) color: vec4f,
}

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32
) -> VOut {
  let pos = array(
    vec2f( 0.0,  0.5),  // top center
    vec2f(-0.5, -0.5),  // bottom left
    vec2f( 0.5, -0.5)   // bottom right
  );

  return VOut(
    vec4f(pos[vertexIndex], 0.0, 1.0),
    vec4f(red, green, blue, 1),
  );
}

override red = 0.0;
override green = 0.0;
override blue = 0.0;

@fragment fn fs(v: VOut) -> @location(0) vec4f {
  let colorFromVertexShader = v.color;
  let colorFromFragmentShader = vec4f(red, green, blue, 1.0);
  // 每 50 像素选择一种颜色
  return select(
    colorFromVertexShader,
    colorFromFragmentShader,
    v.pos.x % 100.0 > 50.0);
}

创建管线时,为两个入口点分别指定不同常量:

const pipeline = device.createRenderPipeline({
  label: 'our hardcoded triangle pipeline',
  layout: 'auto',
  vertex: {
    module,
    constants: {
      red: 1,
      green: 1,
      blue: 0,
    },
  },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
    constants: {
      red: 1,
      green: 0.5,
      blue: 1,
    },
  },
});

这样顶点阶段和片段阶段的常量值不同:顶点阶段输出黄色,片段阶段使用粉紫色,中间插值后出现条纹效果。

从功能上讲,这种做法等价于把同一个 WGSL 模块拆分成两个独立的着色器模块:

const vertexModule = device.createShaderModule({
  code: /* wgsl */ `
    struct VOut {
      @builtin(position) pos: vec4f,
      @location(0) color: vec4f,
    }

    @vertex fn vs(
      @builtin(vertex_index) vertexIndex : u32
    ) -> VOut {
      let pos = array(
        vec2f( 0.0,  0.5),  // top center
        vec2f(-0.5, -0.5),  // bottom left
        vec2f( 0.5, -0.5)   // bottom right
      );

      return VOut(
        vec4f(pos[vertexIndex], 0.0, 1.0),
        vec4f(red, green, blue, 1),
      );
    }

    override red = 0.0;
    override green = 0.0;
    override blue = 0.0;
  `,
});

const fragmentModule = device.createShaderModule({
  code: /* wgsl */ `
    struct VOut {
      @builtin(position) pos: vec4f,
      @location(0) color: vec4f,
    }

    override red = 0.0;
    override green = 0.0;
    override blue = 0.0;

    @fragment fn fs(v: VOut) -> @location(0) vec4f {
      let colorFromVertexShader = v.color;
      let colorFromFragmentShader = vec4f(red, green, blue, 1.0);
      // 每 50 像素选择一种颜色
      return select(
        colorFromVertexShader,
        colorFromFragmentShader,
        v.pos.x % 100.0 > 50.0);
    }
  `,
});

const pipeline = device.createRenderPipeline({
  label: 'our hardcoded triangle pipeline',
  layout: 'auto',
  vertex: {
    module: vertexModule,
    constants: {
      red: 1,
      green: 1,
      blue: 0,
    },
  },
  fragment: {
    module: fragmentModule,
    targets: [{ format: presentationFormat }],
    constants: {
      red: 1,
      green: 0.5,
      blue: 1,
    },
  },
});

两者的行为完全一致。共享一个着色器模块只是省去了重复维护结构体声明等代码。

注意:实际开发中很少使用管线可覆盖常量来传入颜色。这里选择颜色作为例子,只是因为结果直观、便于理解。


3 个核心优化要点

  1. 避免重复编译着色器模块
    管线可覆盖常量允许在 createRenderPipeline 阶段修改常量值,而无需重新创建 GPUShaderModule。对于需要频繁调整少量参数的场景,可能减少着色器模块的重新编译开销。虽然创建管线本身仍有成本,但 WebGPU 实现可以复用之前创建管线时的部分缓存信息。

  2. 利用入口点独立求值,共享模块并隔离常量
    同一个 WGSL 模块中的顶点和片段入口点可以分别应用不同的常量覆盖值,效果等同于为每个入口点单独创建着色器模块。这种设计既能共用结构体、函数等共享代码,又能在不同渲染阶段使用不同配置,避免冗余的代码维护。

  3. 使用 @id 提升常量的可编程管理性
    为可覆盖常量指定显式数字 ID(@id(123))后,可以在管线中通过 ID 而非名称来覆盖常量。这便于在程序化生成着色器或动态调整管线参数时,减少因名称变化导致的兼容性问题,也方便集中管理常量映射。


GLSL / WebGL 专业术语检查备注

  • 本文使用的是 WGSL(WebGPU Shading Language),不是 GLSL。WebGPU 使用 WGSL 编写着色器,WebGL 使用 GLSL,两者语法和关键字不同。
  • 常见术语对照:
    • pipeline-overridable constants:管线可覆盖常量
    • render pipeline:渲染管线
    • entry point:入口点
    • shader module:着色器模块
    • stage(vertex / fragment):阶段(顶点 / 片段)
    • inter-stage variables:阶段间变量
    • builtin(position):内置变量(位置)
    • location:位置(用于绑定输入/输出)
    • override:覆盖(WGSL 关键字,对应概念类似 GLSL 中的 const 替换,但语义不同)
  • WGSL 中的 @builtin(position)、@location(0)、@id(123) 是 attribute 语法,不要与 GLSL 的 layout(location=0) 混淆。