WebGPU MSAA 抗锯齿:多重采样配置与性能取舍
英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-multisampling.html
核心优化要点
-
避免 4x 超采样分辨率渲染的浪费
使用多重采样纹理,仅对覆盖样本点的三角形内部调用一次片段着色器,将结果只写入被覆盖的样本,而不是对每个像素中心都执行着色,显著减少片段着色器调用次数。 -
合理设置
resolveTarget以减少解析开销
只应在最后一个渲染通道进行解析,或额外创建一个空的渲染通道专门解析;多通道渲染时,除第一个通道外必须将loadOp设为'load',防止清除已有内容,避免不必要的解析操作。 -
对边界敏感或重心坐标计算使用
centroid插值模式
将阶段间变量的插值参考点从像素中心改为三角形覆盖区域的质心,避免因中心点落在三角形外而导致的插值错误(如重心坐标越界),提升边缘像素的计算准确性。
正文
本文是关于向着色器提供数据系列文章之一。每篇文章都构建于前一篇之上,按顺序阅读最容易理解。
MSAA 代表 Multi-Sampling Anti-aliasing(多重采样抗锯齿)。抗锯齿意味着尝试防止锯齿问题——锯齿是我们尝试将矢量形状绘制为离散像素时出现的块状问题。
我们在 基础篇 中展示了 WebGPU 如何绘制图形。它采用顶点着色器中为 @builtin(position) 返回的裁剪空间顶点,对于每 3 个顶点计算一个三角形,然后对三角形内每个像素的中心调用片段着色器,询问要设置为什么颜色。
(此处省略可拖拽交互图)
这个三角形非常块状。我们可以提高分辨率,但我们能显示的最高分辨率是显示器的分辨率,这可能不足以避免块状。
一种解决方案是以更高分辨率渲染。例如,假设我们将分辨率提高 4 倍(宽高各 2 倍),然后将结果双线性过滤到画布中。我们在 纹理篇 中介绍过双线性过滤。
这个解决方案有效,但很浪费。左侧图像中每 2x2 像素被转换为右侧图像的 1 个像素,但通常这 4 个像素都在三角形内部,因此无需抗锯齿。所有 4 个像素都是红色。
绘制 4 个红色像素而不是 1 个像素是浪费时间。GPU 调用了片段着色器 4 次。片段着色器可能相当大并执行大量工作,所以我们希望尽可能少地调用它们。即使三角形覆盖 3 个像素的中心,情况也是如此。
(此处省略示意图)
在 4 倍渲染且三角形覆盖 3 个像素中心的情况下,片段着色器被调用 3 次。之后我们再对结果进行双线性过滤。
这就是多重采样更高效的地方。我们创建一个特殊的多重采样纹理。当我们绘制三角形到多重采样纹理时,如果 4 个样本中有任意一个在三角形内部,GPU 只调用一次我们的片段着色器,然后将结果只写入三角形内部的那些样本中。
(此处省略示意图)
上面,使用多重采样渲染且三角形覆盖 3 个样本时,片段着色器仅被调用 1 次。然后我们解析结果。如果三角形覆盖所有 4 个样本点,过程类似:片段着色器只会被调用一次,但其结果会写入所有 4 个样本。
注意,与 4 倍渲染时 CPU 检查 4 个像素中心是否在三角形内部不同,在多重采样渲染中,GPU 检查的是采样位置,这些位置不在网格上。同样,采样值本身并不代表网格,因此解析它们的过程不是双线性过滤,而是由 GPU 决定。这些非中心采样位置显然在大多数情况下能提供更好的抗锯齿效果。
如何使用多重采样
我们通过 3 个基本步骤使用它:
- 将管线设置为渲染到多重采样纹理
- 创建与最终纹理相同大小的多重采样纹理
- 将渲染通道设置为渲染到多重采样纹理并解析到最终纹理(我们的画布)
为了简单起见,让我们以 基础篇末尾 的响应式三角形示例为基础,添加多重采样。
设置管线渲染到多重采样纹理
const pipeline = device.createRenderPipeline({
label: 'our hardcoded red triangle pipeline',
layout: 'auto',
vertex: {
module,
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
+ multisample: {
+ count: 4,
+ },
});
添加 multisample 设置使该管线能够渲染到多重采样纹理。
创建与最终纹理相同大小的多重采样纹理
我们的最终纹理是画布的纹理。由于画布可能改变大小(例如用户调整窗口大小),我们将在渲染时创建此纹理。
+ let multisampleTexture;
function render() {
+ // Get the current texture from the canvas context
+ const canvasTexture = context.getCurrentTexture();
+
+ // If the multisample texture doesn't exist or
+ // is the wrong size then make a new one.
+ if (!multisampleTexture ||
+ multisampleTexture.width !== canvasTexture.width ||
+ multisampleTexture.height !== canvasTexture.height) {
+
+ // If we have an existing multisample texture destroy it.
+ if (multisampleTexture) {
+ multisampleTexture.destroy();
+ }
+
+ // Create a new multisample texture that matches our
+ // canvas's size
+ multisampleTexture = device.createTexture({
+ format: canvasTexture.format,
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ size: [canvasTexture.width, canvasTexture.height],
* sampleCount: 4,
+ });
+ }
...
上述代码在(a)我们没有多重采样纹理或(b)现有纹理大小与画布不匹配时创建一个。我们创建一个与画布大小相同的纹理,但添加 sampleCount: 4 使其成为多重采样纹理。
将渲染通道设置为渲染到多重采样纹理并解析到最终纹理(我们的画布)
- // Get the current texture from the canvas context and
- // set it as the texture to render to.
- renderPassDescriptor.colorAttachments[0].view =
- context.getCurrentTexture().createView();
+ // Set the multisample texture as the texture to render to
+ renderPassDescriptor.colorAttachments[0].view =
+ multisampleTexture.createView();
+ // Set the canvas texture as the texture to "resolve"
+ // the multisample texture to.
+ renderPassDescriptor.colorAttachments[0].resolveTarget =
+ canvasTexture.createView();
解析是将多重采样纹理转换为所需尺寸纹理的过程。在这种情况下,是我们的画布。上面,在 4 倍版本中,我们通过将 4 倍纹理双线性过滤到 1 倍纹理来手动完成此步骤。这是类似的过程,但实际上对于多重采样纹理并非双线性过滤(见下文)。
(此处省略在线示例链接)
没有太多可看的,但如果我们在低分辨率下并排比较,左边是原始未使用多重采样的,右边是使用多重采样的,我们可以看到右边的已被抗锯齿。
(此处省略对比图)
需要注意的几点:
count 必须为 4
在 WebGPU 版本 1 中,渲染管线上的 multisample: { count } 只能设置为 4 或 1。同样,纹理上的 sampleCount 也只能设置为 4 或 1。1 是默认值,表示纹理不是多重采样的。
多重采样不使用网格
如上所述,多重采样不在网格上进行。对于 sampleCount = 4,采样位置如下:
(此处省略采样位置示意图,显示 count = 4、2、8、16 的采样点布局)
WebGPU 目前仅支持 count 为 4
无需在每个渲染通道都设置解析目标
设置 colorAttachment[0].resolveTarget 告诉 WebGPU:“当此渲染通道中的所有绘制完成后,将多重采样纹理降采样到 resolveTarget 设置的纹理中。”如果你有多个渲染通道,你可能不想直到最后一个通道才解析。虽然在最后一个通道解析最快,但创建一个空的最后渲染通道仅执行解析也是完全可以的。只需确保在所有通道(除第一个通道外)将 loadOp 设置为 'load' 而不是 'clear',否则它会被清除。
可以选择在每个采样点上运行片段着色器
上面我们说过,多重采样纹理中每 4 个样本片段着色器只运行一次。它运行一次,然后将结果存储在实际位于三角形内部的样本中。这就是它比以 4 倍分辨率渲染更快的原因。
在 阶段间变量篇 中,我们提到可以使用 @interpolate(...) 属性标记如何插值阶段间变量。一个选项是 sample,在这种情况下片段着色器会针对每个样本运行一次。还有内置变量如 @builtin(sample_index)(告诉你当前正在处理哪个样本)和 @builtin(sample_mask)(作为输入告诉你哪些样本在三角形内部,作为输出可以让你阻止样本点被更新)。
center vs centroid
有 3 种采样插值模式。上面我们提到了 'sample' 模式,其中片段着色器会对每个样本调用一次。另外两种模式是 'center'(默认)和 'centroid'。
'center'相对于像素中心插值。
(此处省略示意图,显示像素中心可能落在三角形外)
上面可以看到一个像素/纹素,其中样本点 s1 和 s3 在三角形内部。我们的片段着色器将被调用一次,并传入相对于像素中心 c 插值的阶段间变量值。问题是,c 在三角形外部。
这可能无关紧要,但有可能你有一些数学假设值在三角形内部。我想不出好的例子,但想象一下我们添加重心坐标,每个顶点一个。重心坐标基本上是 3 个从零到一的坐标,每个值表示特定位置距三角形某个顶点多远。为此,我们只需这样添加重心点。
+struct VOut {
+ @builtin(position) position: vec4f,
+ @location(0) baryCoord: vec3f,
+};
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
-) -> @builtin(position) vec4f {
+) -> 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
);
+ let bary = array(
+ vec3f(1, 0, 0),
+ vec3f(0, 1, 0),
+ vec3f(0, 0, 1),
+ );
- return vec4f(pos[vertexIndex], 0.0, 1.0);
+ var vout: VOut;
+ vout.position = vec4f(pos[vertexIndex], 0.0, 1.0);
+ vout.baryCoord = bary[vertexIndex];
+ return vout;
}
-@fragment fn fs() -> @location(0) vec4f {
- return vec4f(1, 0, 0, 1);
+@fragment fn fs(vin: VOut) -> @location(0) vec4f {
+ let allAbove0 = all(vin.baryCoord >= vec3f(0));
+ let allBelow1 = all(vin.baryCoord <= vec3f(1));
+ let inside = allAbove0 && allBelow1;
+ let red = vec4f(1, 0, 0, 1);
+ let yellow = vec4f(1, 1, 0, 1);
+ return select(yellow, red, inside);
}
上面我们将 1, 0, 0 关联到第一个点,0, 1, 0 关联到第二个点,0, 0, 1 关联到第三个点。在它们之间插值,任何值都不应低于 0 或高于 1。
在片段着色器中,我们使用 all(vin.baryCoord >= vec3f(0)) 测试这三个插值值(x、y、z)是否都 >= 0。我们使用 all(vin.baryCoord <= vec3f(1)) 测试它们是否都 <= 1。最后我们将两者 & 在一起。这告诉我们是在三角形内部还是外部。最后如果是内部则选择红色,否则选择黄色。由于我们是在顶点之间插值,你会期望它们始终在内部。
为了尝试一下,让我们把示例设为较低分辨率以便更容易看到结果。
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;
+ const width = entry.contentBoxSize[0].inlineSize / 16 | 0;
+ const height = entry.contentBoxSize[0].blockSize / 16 | 0;
canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
// re-render
render();
}
});
observer.observe(canvas);
添加 CSS:
canvas {
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
display: block; /* make the canvas act like a block */
width: 100%; /* make the canvas fill its container */
height: 100%;
}
运行后我们看到边缘像素中有黄色。这是因为如上所述,传递给片段着色器的插值阶段间变量值是相对于像素中心的。在那些中心在三角形外部的情况下,我们会看到黄色。
将插值采样模式切换为 'centroid' 尝试解决这个问题。在 'centroid' 模式下,GPU 使用三角形在像素内部区域的质心。
(此处省略示意图)
如果我们修改示例并将插值模式改为 'centroid':
struct VOut {
@builtin(position) position: vec4f,
- @location(0) baryCoord: vec3f,
+ @location(0) @interpolate(perspective, centroid) baryCoord: vec3f,
};
现在 GPU 会相对于质心传递插值后的阶段间变量值,黄色像素的问题消失了。
注意:GPU 可能会也可能不会实际计算三角形在像素内部区域的质心。唯一保证的是阶段间变量将相对于三角形与像素相交部分的某个内部区域进行插值。
三角形内部抗锯齿怎么办?
多重采样通常只对三角形边缘有帮助。由于它只调用片段着色器一次,当所有样本位置都在三角形内部时,我们只是将片段着色器的结果写入所有样本,这意味着结果与不使用多重采样时没有区别。
在上面的示例中,由于我们绘制的是纯红色,显然没有问题。但当我们从纹理中采样时呢?三角形内部可能有对比明显的颜色相邻。我们是否希望每个样本的颜色来自纹理中的不同位置?
在三角形内部,我们使用 mipmap 和过滤 来选择合适的颜色,因此在三角形内部抗锯齿可能不太重要。另一方面,这可能会成为某些渲染技术的问题,这就是为什么还有其他抗锯齿解决方案,也可能是为什么如果你想进行逐样本处理,可以使用 @interpolate(..., sample)。
多重采样不是唯一的抗锯齿解决方案
我们在本页提到了两种解决方案:
- 绘制到更高分辨率纹理,然后以更低分辨率绘制该纹理。
- 使用多重采样。
不过还有许多其他方法。这里有一篇文章介绍了几种:
Anti-aliasing techniques overview
其他资源:
术语检查:本文内容基于 WebGPU / WGSL,未涉及 GLSL 或 WebGL。涉及的专业术语(如 @builtin(position)、@interpolate、sampleCount、resolveTarget、loadOp、重心坐标、质心插值等)均符合 WebGPU / WGSL 规范,无混淆 GLSL/WebGL 之处。
