MSAA 是 “多重采样抗锯齿”(Multi-Sampling Anti-aliasing)的缩写。抗锯齿(Anti-aliasing)指的是试图解决锯齿问题 —— 当我们把矢量图形绘制为离散像素时,出现的那种边缘块状化现象就是锯齿。
在关于基础知识的文章中,我们已经介绍过 WebGPU 的绘制原理:它会获取顶点着色器中通过 @builtin(position)
返回的裁剪空间顶点,每 3 个顶点计算出一个三角形,然后对三角形内部每个像素的中心调用片段着色器,以确定该像素应呈现的颜色。
上面这个三角形的边缘非常粗糙。我们可以尝试提高分辨率,但受限于显示器自身的分辨率,最高也只能达到屏幕的物理分辨率,这可能仍不足以消除边缘的块状感。
一种解决方案是使用更高的分辨率进行渲染。例如,我们可以将分辨率提高 4 倍(宽和高各增加 1 倍),然后通过 “双线性滤波” 将渲染结果缩放至画布大小。关于 “双线性滤波” 的具体内容,我们在纹理相关的文章中已经介绍过。
这种解决方案虽然有效,但存在浪费。左侧图像中每 2x2 的像素块会被转换为右侧图像中的 1 个像素,然而很多时候,这 4 个像素可能都位于三角形内部 —— 此时根本不需要抗锯齿,因为这 4 个像素都是红色的。
(也就是说,在图形内部的区域,高分辨率渲染的额外像素并不会提升画质,却消耗了更多的计算资源,这就是这种方法的低效之处。)
绘制 4 个红色像素而非 1 个像素,完全是种浪费。GPU 会调用 4 次片段着色器,而片段着色器往往规模较大且运算量繁重,因此我们希望尽可能减少其调用次数。即便三角形只覆盖了 3 个像素,也会出现这种不必要的开销。
(这种高分辨率渲染方案的问题在于,无论像素是否处于图形边缘 —— 也就是是否真正需要抗锯齿处理 —— 都会消耗同等的计算资源,导致性能浪费。而 MSAA 等多重采样技术的优势就在于,它只针对边缘区域的像素进行额外采样,从而在保证抗锯齿效果的同时减少计算量。)
上面这种情况中,在4倍分辨率渲染模式下,当三角形覆盖3个像素中心时,片段着色器会被调用3次,之后我们再对结果进行双线性滤波。 而这正是多重采样MSAA更高效的地方:我们创建一种特殊的“多重采样纹理”。当我们向多重采样纹理绘制三角形时,只要4个采样点中有任何一个位于三角形内部,GPU就会调用一次片段着色器,随后仅向那些位于三角形内部的采样点写入结果。 (简单来说,多重采样不会像高分辨率渲染那样对每个像素重复调用片段着色器,而是通过一次调用就能确定多个采样点的颜色,既保证了边缘抗锯齿效果,又大幅减少了片段着色器的调用次数,从而提升效率。)
在上面的多重采样渲染场景中,当三角形覆盖 3 个采样点时,片段着色器仅会被调用 1 次,之后我们对结果进行 “解析”(resolve)。即便三角形覆盖了全部 4 个采样点,流程也类似:片段着色器同样只调用 1 次,但结果会写入所有 4 个采样点。
需要注意的是,4 倍分辨率渲染中,CPU 会检查 4 个像素的中心是否位于三角形内部;而多重采样渲染则不同,GPU 检查的是 “采样点位置”,这些位置并非呈网格分布。同样,采样值本身也不代表网格结构,因此 “解析” 过程并非双线性滤波,具体方式由 GPU 决定。显然,这种非中心分布的采样点位置,在大多数情况下能带来更好的抗锯齿效果。
如何使用多重采样
那么我们该如何使用多重采样呢?主要通过以下三个基本步骤:
- 将渲染管道设置为渲染到多重采样纹理
- 创建一个与最终纹理尺寸相同的多重采样纹理
- 将渲染通道设置为渲染到多重采样纹理,并解析到最终纹理(即我们的画布)
为简单起见,让我们以三角形示例为例,添加多重采样功能。
第一步:将渲染管道设置为渲染到多重采样纹理
要实现这一点,需要在创建渲染管道时配置 multisample
属性,指定采样数量(如 4x MSAA):
const pipeline = device.createRenderPipeline({
label: 'our hardcoded red triangle pipeline',
layout: 'auto',
vertex: {
module,
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
multisample: {
count: 4,
},
});
添加上述多重采样设置后,该渲染管道就能渲染到多重采样纹理了。
第二步:创建一个与最终纹理尺寸相同的多重采样纹理
我们的最终纹理是画布的纹理。由于画布可能会改变尺寸(比如用户调整窗口大小时),我们会在渲染时创建这个多重采样纹理。
可以这样实现:
let multisampleTexture;
function render () {
// 从画布上下文上下文中 (context) 中获取当前纹理
const canvasTexture = context.getCurrentTexture ();
// 如果多重采样纹理不存在,或者尺寸不匹配,则创建一个新的
if (!multisampleTexture ||
multisampleTexture.width !== canvasTexture.width ||
multisampleTexture.height !== canvasTexture.height) {
// 如果已有多重采样纹理,先销毁它
if (multisampleTexture) {
multisampleTexture.destroy ();
}
// 创建一个与画布尺寸匹配的新多重采样纹理
multisampleTexture = device.createTexture ({
format: canvasTexture.format, // 与画布纹理格式一致
usage: GPUTextureUsage.RENDER_ATTACHMENT, // 用途为渲染附件
size: [canvasTexture.width, canvasTexture.height], // 尺寸与画布相同
sampleCount: 4, // 4 倍多重采样
});
}
...
上面的代码会在两种情况下创建多重采样纹理:(a)当我们还没有多重采样纹理时;(b)当已有的多重采样纹理与画布尺寸不匹配时。我们创建的纹理与画布尺寸相同,并且通过添加 sampleCount: 4
将其设置为多重采样纹理。
第三:将渲染通道设置为渲染到多重采样纹理并解析到最终纹理(即我们的画布)
要实现这一点,需要在渲染通道描述符中指定多重采样纹理作为渲染目标,并将画布纹理设置为解析目标。示例如下:
// 将多重采样纹理设置为渲染目标纹理
renderPassDescriptor.colorAttachments [0].view =
multisampleTexture.createView ();
// 将画布纹理设置为多重采样纹理的 "解析" 目标
renderPassDescriptor.colorAttachments [0].resolveTarget =
canvasTexture.createView ();
解析(Resolving)是将多重采样纹理转换为我们实际需要尺寸的纹理的过程。在本例中,就是转换为画布的尺寸。前面我们在 4 倍分辨率渲染的方案中,是通过手动对 4 倍尺寸的纹理进行双线性滤波来得到 1 倍尺寸纹理的。这两种过程有些类似,但对于多重采样纹理来说,解析实际上并不是双线性滤波。具体可参考下述代码:
async function main() {
// 请求WebGPU适配器和设备
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('需要支持WebGPU的浏览器');
return;
}
// 从canvas获取WebGPU上下文并配置
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
// 创建着色器模块 - 包含顶点着色器和片段着色器
const module = device.createShaderModule({
label: '我们的硬编码红色三角形着色器',
code: `
// 顶点着色器:定义三角形的三个顶点位置
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // 顶部中心
vec2f(-0.5, -0.5), // 底部左侧
vec2f( 0.5, -0.5) // 底部右侧
);
return vec4f(pos[vertexIndex], 0.0, 1.0);
}
// 片段着色器:返回红色
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // 红色 (RGBA)
}
`,
});
// 创建渲染管道
const pipeline = device.createRenderPipeline({
label: '我们的硬编码红色三角形管道',
layout: 'auto',
vertex: {
module, // 使用上面定义的着色器模块
},
fragment: {
module, // 使用上面定义的着色器模块
targets: [{ format: presentationFormat }], // 输出格式与画布匹配
},
multisample: {
count: 4, // 启用4x多重采样抗锯齿
},
});
// 渲染通道描述符
const renderPassDescriptor = {
label: '我们的基础画布渲染通道',
colorAttachments: [
{
// view: 渲染时会填充这个值
clearValue: [0.3, 0.3, 0.3, 1], // 灰色背景
loadOp: 'clear', // 加载操作:清除
storeOp: 'store', // 存储操作:保存
},
],
};
// 多重采样纹理变量
let multisampleTexture;
// 渲染函数
function render() {
// 从画布上下文获取当前纹理
const canvasTexture = context.getCurrentTexture();
// 如果多重采样纹理不存在,或者尺寸不匹配,则创建新的
if (!multisampleTexture ||
multisampleTexture.width !== canvasTexture.width ||
multisampleTexture.height !== canvasTexture.height) {
// 如果已有多重采样纹理,先销毁它
if (multisampleTexture) {
multisampleTexture.destroy();
}
// 创建与画布尺寸匹配的新多重采样纹理
multisampleTexture = device.createTexture({
format: canvasTexture.format, // 与画布纹理格式一致
usage: GPUTextureUsage.RENDER_ATTACHMENT, // 用途为渲染附件
size: [canvasTexture.width, canvasTexture.height], // 尺寸与画布相同
sampleCount: 4, // 4x多重采样
});
}
// 将多重采样纹理设置为渲染目标
renderPassDescriptor.colorAttachments[0].view =
multisampleTexture.createView();
// 将画布纹理设置为多重采样纹理的解析目标
renderPassDescriptor.colorAttachments[0].resolveTarget =
canvasTexture.createView();
// 创建命令编码器
const encoder = device.createCommandEncoder({ label: '我们的编码器' });
// 开始渲染通道
const pass = encoder.beginRenderPass(renderPassDescriptor);
// 设置渲染管道
pass.setPipeline(pipeline);
// 绘制三角形(调用顶点着色器3次)
pass.draw(3);
// 结束渲染通道
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);
}
// 错误提示函数
function fail(msg) {
// eslint-disable-next-line no-alert
alert(msg);
}
// 启动程序
main();
虽然视觉上差异可能不明显,但如果我们在低分辨率下将两者并排比较 —— 左侧是未使用多重采样的原始图像,右侧是启用了多重采样的图像 —— 就能清晰看到右侧的图像已经进行过抗锯齿处理。
具体来说,未启用多重采样的三角形边缘会呈现明显的 “锯齿状”(像素块离散分布),而启用后边缘会更平滑:这是因为多重采样通过对边缘区域的多个采样点计算,最终融合出更自然的过渡色,减少了块状感。这种差异在低分辨率下尤为突出,因为像素密度较低时,锯齿问题会更明显。
需要注意的几点:
在 WebGPU 1.0 版本中,count
的值只能设为 4:
在渲染管道上设置 multisample: { count }
时,只能使用 4 或 1 这两个值。同样,纹理的 sampleCount
属性也只能设为 4 或 1。其中 1 是默认值,表示该纹理不是多重采样纹理。
这一限制意味着 WebGPU 当前版本仅支持 4 倍多重采样(4x MSAA)或完全不使用多重采样(1x,即默认状态),暂时不支持其他倍数的多重采样配置。
多重采样并非基于网格分布
正如前文所指出的,多重采样的采样点并非呈规则网格排列。当 sampleCount = 4
(即 4 倍多重采样)时,每个像素内的采样点位置大致如下(非精确坐标,仅示意分布逻辑):
WebGPU 目前仅支持采样数(count)为 4 的多重采样配置。
关于 WebGPU 多重采样的补充说明
1. 并非每个渲染通道都必须设置解析目标
向 WebGPU 设置 colorAttachment[0].resolveTarget
,本质是在告诉它:“当这个渲染通道中的所有绘制操作完成后,将多重采样纹理缩放到 resolveTarget
所指定的纹理中”。
如果你的渲染流程包含多个渲染通道,通常不需要在每个通道都执行解析—— 更合理的做法是在最后一个渲染通道中进行解析。这样做的好处是避免中间步骤的性能损耗,因为过早解析会失去后续通道利用多重采样纹理进行处理的机会。
- 最优选择:在最后一个渲染通道中完成解析,效率最高;
- 备选方案:如果需要,也可以创建一个 “空的最后渲染通道”,不执行任何绘制,仅用于解析。此时需注意:除了第一个渲染通道外,其他所有通道的
loadOp
都应设为'load'
而非'clear'
,否则之前渲染通道的结果会被清空,导致解析出错误的内容。
2. 可选择让片段着色器在每个采样点上执行(非默认行为)
前文提到,在多重采样纹理中,片段着色器通常每 4 个采样点仅执行一次:执行一次后,将结果存储到那些确实位于三角形内部的采样点中。这也是多重采样比 “4 倍分辨率渲染” 更快的核心原因 —— 减少了片段着色器的调用次数。
但 WebGPU 也提供了另一种可选模式:让片段着色器在每个采样点上单独执行。要实现这一点,需要结合 “阶段间变量(inter-stage variables)” 的相关配置:
- 在顶点着色器与片段着色器之间传递变量时,可以用
@interpolate(...)
属性标记变量的插值方式。其中sample
选项就会触发 “片段着色器按采样点执行”—— 此时片段着色器会为每个采样点单独运行一次。 - 同时,WebGPU 还提供了相关内置变量(builtin)辅助这种模式:
@builtin(sample_index)
:作为输入,告知当前处理的是第几个采样点(可用于区分 4 个采样点);@builtin(sample_mask)
:作为输入时,会告诉你哪些采样点位于图形内部;作为输出时,可手动控制哪些采样点不更新颜色(例如屏蔽某些采样点的结果)。
这种 “按采样点执行片段着色器” 的模式会牺牲部分性能(调用次数增加),但能实现更精细的效果(例如基于每个采样点的位置计算不同颜色),适合对画质有极高要求的场景(如复杂光照、体积雾等)。
中心采样(center)与质心采样(centroid)
WebGPU 中有三种采样插值模式。前面我们提到了 'sample'
模式(片段着色器为每个采样点单独调用一次),另外两种是默认的 'center'
(中心采样)和 'centroid'
(质心采样)。
'center'
模式:基于像素中心插值
'center'
是默认模式,它会基于像素中心对阶段间变量(inter-stage variables)进行插值计算。
假设一个像素中有两个采样点(s1 和 s3)位于三角形内部(如你所见),此时片段着色器只会调用一次,并且传入的阶段间变量值是基于像素中心(c)插值得到的。但问题在于:这个像素中心(c)可能在三角形外部(即使部分采样点在内部)。
这种情况未必会产生明显问题,但如果你的计算逻辑假设 “插值位置一定在三角形内部”,就可能出错。举个例子:假设我们在顶点着色器中定义了重心坐标(barycentric coordinates)—— 这是三个范围在 0 到 1 之间的坐标,每个值代表某点到三角形一个顶点的距离。我们可能会在顶点着色器中这样定义:
struct VOut {
@builtin(position) position: vec4f,
@location(0) baryCoord: vec3f,
};
@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
);
let bary = array(
vec3f(1, 0, 0),
vec3f(0, 1, 0),
vec3f(0, 0, 1),
);
var vout: VOut;
vout.position = vec4f(pos[vertexIndex], 0.0, 1.0);
vout.baryCoord = bary[vertexIndex];
return vout;
}
@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 / 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')模式,可尝试解决这一问题。在 “质心” 模式下,GPU 会使用三角形位于该像素内部区域的质心(作为插值基准)。
如果我们以这个示例为例,并将插值模式改为 “质心”('centroid')模式:
struct VOut {
@builtin(position) position: vec4f,
@location(0) @interpolate(perspective, centroid) baryCoord: vec3f,
};
现在,GPU 会传递基于质心插值得到的阶段间变量值,之前出现的黄色像素问题也随之消失了。
注意:GPU 未必会实际计算三角形位于像素内部区域的质心。(该模式)仅能保证一点:阶段间变量的插值计算,会基于三角形与像素相交部分内部的某个位置来进行。
(说明:“centroid” 此处为技术语境下的 “质心”,即几何图形的中心平衡点;“intersects” 指图形间的 “相交”,具体为三角形边缘与像素边界交叉重叠的区域,是判断插值位置范围的关键前提。)
三角形内部的抗锯齿该如何处理?
多重采样通常仅对三角形的边缘有帮助。由于它只调用一次片段着色器,当所有采样点都位于三角形内部时,片段着色器的同一结果会被写入所有采样点 —— 这意味着最终效果与不使用多重采样相比没有任何区别。
在上面的示例中,我们绘制的是纯红色三角形,因此显然不存在问题。但如果我们是从纹理中采样(获取颜色),而三角形内部存在相邻的高对比度颜色,那该怎么办呢?我们难道不希望每个采样点的颜色都来自纹理中的不同位置吗?
在三角形内部,我们会使用 MIP 贴图(纹理多级渐远贴图)和滤波技术来选择合适的颜色,因此三角形内部的抗锯齿可能不那么重要。另一方面,某些渲染技术下这仍可能成为问题 —— 这也正是为什么除了多重采样之外,还存在其他抗锯齿解决方案;同时,这或许也是如果你想进行逐采样点处理,就可以使用 @interpolate(..., sample)
(采样模式插值)的原因。
多重采样并非唯一的抗锯齿解决方案
本文中我们提到了两种解决方案:(1)先渲染到更高分辨率的纹理,再将该纹理以较低分辨率绘制(即超采样抗锯齿的核心思路);(2)使用多重采样。不过,除此之外还有许多其他方案。这篇文章介绍了其中的几种,可供参考。