WebGPU 透明度与混合

原文标题:WebGPU Transparency and Blending
原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-transparency.html

以下内容基于原文编译,保留核心代码,并对关键术语进行规范化翻译。


很难全面覆盖透明度和混合,因为不同场景下的需求往往不同。因此,本文将主要对 WebGPU 相关特性进行导览,以便后续在涉及具体技术时回溯参考。

Canvas alphaMode

Canvas alphaMode

首先需要明确:WebGPU 内部的透明与混合,与 WebGPU canvas 和 HTML 页面之间的透明与混合,是两回事。

默认情况下,WebGPU canvas 是不透明的,其 alpha 通道被忽略。若要让 alpha 生效,必须在调用 configure 时将其 alphaMode 设为 'premultiplied'。默认值是 'opaque'。

context.configure({
  device,
  format: presentationFormat,
+    alphaMode: 'premultiplied',
});

理解 alphaMode: 'premultiplied' 的含义很重要:它表示放入 canvas 的颜色值必须已经乘以了 alpha 值。

下面做一个最小示例,只创建渲染通道并设置清除颜色。

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }

  // 获取 canvas 的 WebGPU 上下文并配置
  const canvas = document.querySelector('canvas');
  const context = canvas.getContext('webgpu');
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    device,
    format: presentationFormat,
+    alphaMode: 'premultiplied',
  });

  const clearValue = [1, 0, 0, 0.01];
  const renderPassDescriptor = {
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
        // view: <- 渲染时填充
        clearValue,
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  };

  function render() {
    const encoder = device.createCommandEncoder({ label: 'clear encoder' });
    const canvasTexture = context.getCurrentTexture();
    renderPassDescriptor.colorAttachments[0].view =
        canvasTexture.createView();

    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.end();

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  }

  const observer = new ResizeObserver(entries => {
    for (const entry of entries) {
      const canvas = entry.target;
      const width = entry.contentBoxSize[0].inlineSize;
      const height = entry.contentBoxSize[0].blockSize;
      canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
      canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
      render();
    }
  });
  observer.observe(canvas);
}

再给 canvas 添加灰色棋盘格 CSS 背景:

canvas {
  background-color: #404040;
  background-image:
     linear-gradient(45deg, #808080 25%, transparent 25%),
     linear-gradient(-45deg, #808080 25%, transparent 25%),
     linear-gradient(45deg, transparent 75%, #808080 75%),
     linear-gradient(-45deg, transparent 75%, #808080 75%);
  background-size: 32px 32px;
  background-position: 0 0, 0 16px, 16px -16px, -16px 0px;
}

加入 UI 以便设置清除颜色的 alpha、颜色以及是否预乘:

+import GUI from '../3rdparty/muigui-0.x.module.js';

...

+  const color = [1, 0, 0];
+  const settings = {
+    premultiply: false,
+    color,
+    alpha: 0.01,
+  };
+
+  const gui = new GUI().onChange(render);
+  gui.add(settings, 'premultiply');
+  gui.add(settings, 'alpha', 0, 1);
+  gui.addColor(settings, 'color');

  function render() {
    const encoder = device.createCommandEncoder({ label: 'clear encoder' });
    const canvasTexture = context.getCurrentTexture();
    renderPassDescriptor.colorAttachments[0].view =
        canvasTexture.createView();

+    const { alpha } = settings;
+    clearValue[3] = alpha;
+    if (settings.premultiply) {
+      // 将颜色乘以 alpha 进行预乘
+      clearValue[0] = color[0] * alpha;
+      clearValue[1] = color[1] * alpha;
+      clearValue[2] = color[2] * alpha;
+    } else {
+      // 使用未预乘的颜色
+      clearValue[0] = color[0];
+      clearValue[1] = color[1];
+      clearValue[2] = color[2];
+    }

    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.end();

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  }

运行后会发现有问题:实际出现的颜色是未定义的!

在作者机器上出现了两种亮粉色。问题在于:alpha 设为 0.01,背景为中灰/深灰棋盘格,而清除颜色是红色 (1,0,0)。在灰色背景上叠 0.01 的红色几乎不可察觉,为何出现亮粉?

原因是:这是一个非法颜色!canvas 中的颜色为 1, 0, 0, 0.01,但这不是预乘颜色。“预乘”意味着放入 canvas 的颜色必须已经乘以 alpha 值。当 alpha 为 0.01 时,其他任何分量都不应大于 0.01。

勾选“premultiplied”复选框后,代码将对颜色进行预乘,实际写入 canvas 的值为 0.01, 0, 0, 0.01,就正确了,几乎不可见。调整 alpha 可以看到逐渐变红。

注意:由于 1, 0, 0, 0.01 是非法颜色,其显示方式由浏览器决定,可能在不同设备上不同。不要使用非法颜色。

假设我们希望颜色为橙色 (1, 0.5, 0.25) 且透明度为 33%(alpha=0.33),则预乘后的颜色为:

                      premultiplied
   ---------------------------------
   r = 1    * 0.33   = 0.33
   g = 0.5  * 0.33   = 0.165
   b = 0.25 * 0.33   = 0.0825
   a = 0.33          = 0.33

如何获得预乘颜色取决于你。如果只有未预乘颜色,可以在着色器中执行类似:

   return vec4f(color.rgb * color.a, color.a);

函数 copyExternalImageToTexture(在“导入纹理”一文介绍过)接受 premultipliedAlpha: true 选项。这意味着加载图像时可以让 WebGPU 在复制到纹理时自动预乘颜色,这样调用 textureSample 得到的值就已经是预乘的。

本节要点:

  1. 解释 alphaMode: 'premultiplied' canvas 配置选项,让 WebGPU canvas 支持透明。
  2. 引入预乘 Alpha 颜色概念。如何获得预乘颜色由你决定:可以在 JavaScript 中创建预乘 clearValue,也可以在片元着色器返回时预乘,或通过后处理预乘。关键是使用 alphaMode: 'premultiplied' 时,canvas 中的颜色最终必须是预乘的。推荐阅读 GPUs prefer premultiplication。

Discard

discard 是 WGSL 中的语句,用于在片元着色器中丢弃当前片元,即不绘制该像素。

沿用之前基于 @builtin(position) 绘制棋盘格的示例,我们改为其中一种情况丢弃。

@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;

+        if (checker) {
+          discard;
+        }
+
+        return cyan;

-  return select(red, cyan, checker);
}

同时在 JS 中设置 alphaMode: 'premultiplied' 并将 clearValue 设为透明 [0,0,0,0]。

  context.configure({
    device,
    format: presentationFormat,
+    alphaMode: 'premultiplied',
  });

  ...

  const renderPassDescriptor = {
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
        // view: <- to be filled out when we render
-        clearValue: [0.3, 0.3, 0.3, 1],
+        clearValue: [0, 0, 0, 0],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  };
...

效果是隔一个方格透明。

在透明着色器中,通常根据 alpha 值进行丢弃:

@fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
    let color = ... compute a color ....

    if (color.a < threshold) {
      discard;
    }

    return color;
}

threshold 可以来自 uniform 或常量。这种技术常用于精灵和草叶等植被。因为如果启用了深度纹理(见正交投影一文),即使 alpha 为 0,绘制时也会更新深度缓冲,导致后面的精灵/叶片被遮挡。因此直接 discard 不写入深度。后续文章会详述。

Blend Settings(混合设置)

最后来到混合设置。创建渲染管线时,可以针对片元着色器的每个 target 设置混合状态。常规管线如下:

    const pipeline = device.createRenderPipeline({
      label: 'hardcoded textured quad pipeline',
      layout: pipelineLayout,
      vertex: {
        module,
      },
      fragment: {
        module,
        targets: [
          {
            format: presentationFormat,
          },
        ],
      },
    });

为 target[0] 添加混合:

    const pipeline = device.createRenderPipeline({
      label: 'hardcoded textured quad pipeline',
      layout: pipelineLayout,
      vertex: {
        module,
      },
      fragment: {
        module,
        targets: [
          {
            format: presentationFormat,
+            blend: {
+              color: {
+                srcFactor: 'one',
+                dstFactor: 'one-minus-src-alpha'
+              },
+              alpha: {
+                srcFactor: 'one',
+                dstFactor: 'one-minus-src-alpha'
+              },
+            },
          },
        ],
      },
    });

完整默认设置为:

blend: {
  color: {
    operation: 'add',
    srcFactor: 'one',
    dstFactor: 'zero',
  },
  alpha: {
    operation: 'add',
    srcFactor: 'one',
    dstFactor: 'zero',
  },
}

其中 color 控制 RGB 部分,alpha 控制 alpha 部分。

operation 可选值:'add'、'subtract'、'reverse-subtract'、'min'、'max'。

srcFactor 和 dstFactor 可选值: 'zero'、'one'、'src'、'one-minus-src'、'src-alpha'、'one-minus-src-alpha'、'dst'、'one-minus-dst'、'dst-alpha'、'one-minus-dst-alpha'、'src-alpha-saturated'、'constant'、'one-minus-constant'。

理解公式:

result = operation((src * srcFactor), (dst * dstFactor))

src 是片元着色器返回值,dst 是目标纹理中已有的值。默认 'add', 'one', 'zero' 意味着 result = src。

提到 'constant' 和 'one-minus-constant' 时,常量通过渲染通道 setBlendConstant 命令设置,默认 [0,0,0,0],可在绘制间改变。

最常见的混合设置是:

{
  operation: 'add',
  srcFactor: 'one',
  dstFactor: 'one-minus-src-alpha'
}

这种模式通常与“预乘 Alpha”一起使用,即期望 src 的 RGB 已被 alpha 预乘。

接下来构造示例,用两个 Canvas 2D 图像(具有 alpha)加载为 WebGPU 纹理。创建目标图像和源图像的函数略(原代码较长,核心逻辑保留)。关键部分是创建纹理时传递 premultipliedAlpha 选项,以及使用两组纹理(预乘/未预乘)。随后创建 bindGroupLayout、pipeline、uniform buffer,并允许 UI 调整混合因子、操作和清除颜色。完整示例包含大量交互代码,此处仅提取核心混合管线创建部分:

  const srcPipeline = device.createRenderPipeline({
    label: 'hardcoded textured quad pipeline',
    layout: pipelineLayout,
    vertex: { module },
    fragment: {
      module,
      targets: [
        {
          format: presentationFormat,
          blend: {
            color,
            alpha,
          },
        },
      ],
    },
  });

渲染时先绘制目标纹理(无混合),再绘制源纹理(带混合)。还提供了预设列表对应 Canvas 2D 的 globalCompositeOperation 模式,如 source-over、destination-over、additive 等。

可直接运行完整示例:https://webgpufundamentals.org/webgpu/lessons/webgpu-blend.html

从源图像和目标图像出发,一些实用的混合设置:

  • 预乘混合(source-over):color: {operation:'add', srcFactor:'one', dstFactor:'one-minus-src-alpha'}
  • destination-over:color: {operation:'add', srcFactor:'one-minus-dst-alpha', dstFactor:'one'}
  • 加色(lighten):color: {operation:'add', srcFactor:'one', dstFactor:'one'}
  • source-in / destination-in / source-out / destination-out / source-atop / destination-atop 等。

这些名称来自 Canvas 2D 的 globalCompositeOperation,但某些模式需要超出基础混合设置的额外数学,需自定义解决方案。

掌握了这些 WebGPU 混合基础,后续技术文章便可引用。


核心优化要点

  1. 优先采用预乘 Alpha(Premultiplied Alpha)
    GPU 硬件更偏好预乘颜色,使用 alphaMode: 'premultiplied' 并确保所有颜色值分量不超过 alpha 值,可以避免非法颜色未定义行为,同时混合公式可简化为 src * 1 + dst * (1 - src.a),减少着色器中额外的乘法操作,提高混合效率。

  2. 透明片元使用 discard 而非输出 Alpha=0
    对于精灵、草叶等需要透明镂空的对象,在片元着色器中用 if (alpha < threshold) discard; 直接丢弃片元,可避免写入深度缓冲,从而防止后面的透明物体被错误遮挡。但需注意过度 discard 可能影响 Early-Z 优化,应只对真正需要的地方使用。

  3. 合理配置 Blend State 实现高效混合
    根据需求选择合适的 srcFactor、dstFactor 和 operation。常用预乘混合 srcFactor: 'one', dstFactor: 'one-minus-src-alpha' 最简洁高效。对于加法混合(如光晕、粒子)使用 srcFactor: 'one', dstFactor: 'one'。避免在着色器内手动实现混合,利用硬件混合单元减少带宽和计算开销。

专业术语检查(GLSL/WebGL 相关)

  • Premultiplied Alpha → 预乘 Alpha:颜色分量预先乘以 alpha 值。
  • Blending → 混合:片元输出与目标缓冲中已有颜色的合并过程。
  • Discard → 丢弃:WGSL 语句,与 GLSL 中的 discard 语义相同。
  • Fragment Shader → 片元着色器(WebGPU/WGSL 中与 GLSL fragment shader 对应)。
  • Render Pipeline → 渲染管线。
  • Render Pass → 渲染通道。
  • Bind Group → 绑定组(WebGPU 特有,类似 WebGL 中 uniform/texture 绑定集合)。
  • Depth Texture → 深度纹理。
  • Mipmap → 多级渐远纹理。
  • SrcFactor/DstFactor → 源因子/目标因子。
  • Blend Operation → 混合操作(add/subtract/reverse-subtract/min/max)。

以上术语在编译中均已保持统一,确保技术含义准确。