11.3 MSAA 的 CPU 实现
超采样按覆盖率混合颜色,代价是每个子样本都要着色一次。MSAA(多重采样抗锯齿)把这件事拆开:只在覆盖上多采样,着色一次。站内的 抗锯齿方法 讲的是 MSAA / TAA 在图形 API 里怎么配、怎么取样本、工程上怎么取舍;本卷从整数边函数推覆盖率,把采样与抗锯齿做成可断言的整数。本节用 8 片带深度的叶片,把 MSAA 的 CPU 实现写出来,再让浏览器 WebGL2 做同一件事、按声明容差对上。
每像素 4 个子样本,各自覆盖与深度测试
MSAA 的结构是:每个像素挂 $N$ 个子样本,每个子样本各有一份覆盖位与一份深度。光栅化时,三角形的每个子样本单独做覆盖判定与深度测试;解析(resolve)时把 $N$ 个样本的颜色平均。
实现上最省事的写法是复用已经有的单样本光栅化器:把三角形整体平移,让它按「像素中心」求值的位置正好落在某个子样本上。平移量是 $(32-o_x, 32-o_y)$——因为底座按 $p_x\cdot 64+32$ 求值,平移后等价于在原坐标系里按 $p_x\cdot 64+o_x$ 求值。跑 4 遍,再把 4 张缓冲按 4 平均,就是 MSAA 解析。
MSAA 与 SSAA 的区别
如果每个三角形是单色的(本节场景就是),MSAA 与超采样的数值完全一样——着色只在覆盖处加权。区别要等颜色在三角形内部变化时才显出来:MSAA 在像素中心着色一次,再按覆盖数加权;SSAA 在每个子样本处各着色一次再平均。
用一个逐顶点色的三角形把两者分开:边缘像素 20 个,MSAA 与 SSAA 在全部 20 个上给出不同的值。样例像素 $(3,2)$ 覆盖率 $3/4$,MSAA 给 [159,51,39],SSAA 给 [159,50,41]——差在插值的取样位置。这就是「MSAA 着色一次」的确切含义。
可运行产物:4 个子样本,各自深度
场景是 8 片从中心放射、彼此重叠的细叶片,各带不同深度;每片单色。下面把 4 个子样本各自光栅化(含深度测试)再按 4 平均,并复用底座 cglib 的 rasterize。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_11_3_msaa.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_11_3_msaa.py
"""11.3 MSAA 的 CPU 实现:每像素 4 个子样本各自做覆盖与深度测试,再按样本平均。
与超采样的区别:MSAA 只在**覆盖**上多采样,着色一次。场景是 8 片带深度的叶片
(config/cg_raster_11_msaa.json),每片单色,故 MSAA 与 SSAA 数值相同;另用一个
逐顶点色的微用例把两者分开:MSAA 在像素中心着色一次再按覆盖加权,SSAA 在每个子样本
处各着色一次再平均,边缘像素会给出不同的值。
复用底座 cglib.rasterize:把三角形整体平移 (32-ox, 32-oy),它按像素中心 px*64+32
求值,等价于在原坐标系里在 px*64+ox 处求值。
产物:out/cg_raster_11_3_msaa.png / _nomsaa.png / _msaa.rgb / _nomsaa.rgb / _msaa.json。
"""
import hashlib
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SUB1, HALF = cglib.SUB1, cglib.SUB1 // 2
OFFS = [(16, 16), (48, 16), (16, 48), (48, 48)] # 2x2 规则 4 样本
def load_tris(spec):
return [tuple(v for k in range(3) for v in (t["xy"][k][0], t["xy"][k][1], t["d"][k], *t["c"][k]))
for t in spec["triangles"]]
def shift(tris, dx, dy):
out = []
for t in tris:
o = list(t)
for k in (0, 6, 12):
o[k] += dx
o[k + 1] += dy
out.append(tuple(o))
return out
def resolve_msaa(tris, w, h, bg):
"""4 个子样本各自光栅化(含深度测试),再按 4 个样本平均(背景计入平均)。"""
acc = np.zeros((h, w, 3), dtype=np.int64)
cov_any = np.zeros((h, w), dtype=bool)
for ox, oy in OFFS:
color, _d, cov, _c = cglib.rasterize(shift(tris, HALF - ox, HALF - oy), w, h, bg)
acc += color
cov_any |= cov
return acc // len(OFFS), cov_any
def bary(tri, X, Y):
"""三角形 (x,y,d,r,g,b) x3 在 (X,Y) 处的重心插值色;同时返回是否在三角形内。"""
(x0, y0), (x1, y1), (x2, y2) = (tri[0], tri[1]), (tri[6], tri[7]), (tri[12], tri[13])
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
w0 = (x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1)
w1 = (x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2)
w2 = (x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0)
c = [(w0 * tri[3 + ch] + w1 * tri[9 + ch] + w2 * tri[15 + ch]) // area for ch in range(3)]
return c, (w0 >= 0 and w1 >= 0 and w2 >= 0)
def micro():
"""逐顶点色的单三角形微用例:把 MSAA(着色一次)与 SSAA(逐样本着色)分开。"""
w, h, bg = 16, 12, (12, 14, 20)
tri = (2 * SUB1, 2 * SUB1, 100, 240, 40, 40, 14 * SUB1, 4 * SUB1, 100, 40, 240, 40,
3 * SUB1, 10 * SUB1, 100, 40, 40, 240)
msaa = np.zeros((h, w, 3), dtype=np.int64)
ssaa = np.zeros((h, w, 3), dtype=np.int64)
edge = diff = 0
example = None
for py in range(h):
for px in range(w):
hits = [bary(tri, px * SUB1 + ox, py * SUB1 + oy) for ox, oy in OFFS]
cov = sum(1 for _c, ok in hits if ok)
center = bary(tri, px * SUB1 + HALF, py * SUB1 + HALF)[0]
msaa[py, px] = [(center[ch] * cov + bg[ch] * (4 - cov)) // 4 for ch in range(3)]
ssaa[py, px] = [sum(c[ch] if ok else bg[ch] for c, ok in hits) // 4 for ch in range(3)]
if 0 < cov < 4:
edge += 1
if not np.array_equal(msaa[py, px], ssaa[py, px]):
diff += 1
if example is None:
example = [px, py, cov, [int(v) for v in msaa[py, px]], [int(v) for v in ssaa[py, px]]]
return {"edge_pixels": edge, "differ_pixels": diff, "example": example,
"msaa_sha256": sha256(msaa.astype(np.uint8)), "ssaa_sha256": sha256(ssaa.astype(np.uint8))}
def sha256(a):
return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()
def main():
with open(os.path.join(ROOT, "config", "cg_raster_11_msaa.json"), encoding="utf-8") as fh:
spec = json.load(fh)
W, H, bg = spec["width"], spec["height"], tuple(spec["backgroundQ8"])
tris = load_tris(spec)
msaa, cov_any = resolve_msaa(tris, W, H, bg)
nomsaa, _nd, ncov, ncull = cglib.rasterize(tris, W, H, bg)
mae = int(np.abs(msaa - nomsaa).mean())
edge = int((msaa != nomsaa).any(axis=2).sum())
mi = micro()
out = os.path.join(ROOT, "out")
png_m = cglib.save_png(os.path.join(out, "cg_raster_11_3_msaa.png"), msaa)
png_n = cglib.save_png(os.path.join(out, "cg_raster_11_3_nomsaa.png"), nomsaa)
for name, buf in (("cg_raster_11_3_msaa.rgb", msaa), ("cg_raster_11_3_nomsaa.rgb", nomsaa)):
with open(os.path.join(out, name), "wb") as fh:
fh.write(np.clip(buf, 0, 255).astype(np.uint8).tobytes())
meta = {"canvas": [W, H], "triangles": len(tris), "samples": len(OFFS), "offsets": OFFS,
"covered_pixels_msaa": int(cov_any.sum()), "covered_pixels_nomsaa": int(ncov.sum()),
"culled_nomsaa": int(ncull), "msaa_vs_nomsaa_mae": mae, "msaa_vs_nomsaa_edge_pixels": edge,
"msaa_sha256": sha256(np.clip(msaa, 0, 255).astype(np.uint8)),
"nomsaa_sha256": sha256(np.clip(nomsaa, 0, 255).astype(np.uint8)),
"micro": mi, "png_msaa_bytes": png_m, "png_nomsaa_bytes": png_n}
with open(os.path.join(out, "cg_raster_11_3_msaa.json"), "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
print(f"场景 {W}x{H} / {len(tris)} 三角形 / {len(OFFS)} 子样本 覆盖像素 {int(cov_any.sum())}(1x 为 {int(ncov.sum())})")
print(f"MSAA vs 1x : MAE={mae}/255,{edge} 个像素不同 <- 只在边缘")
print(f"微用例(逐顶点色,MSAA 着色一次 vs SSAA 逐样本着色):")
print(f" 边缘像素 {mi['edge_pixels']} 个,其中 MSAA != SSAA 的 {mi['differ_pixels']} 个")
print(f" 样例像素 {mi['example']}")
print(f"sha256 MSAA : {meta['msaa_sha256'][:32]}...")
if __name__ == "__main__":
main()
实跑输出:
场景 64x48 / 8 三角形 / 4 子样本 覆盖像素 388(1x 为 300)
MSAA vs 1x : MAE=3/255,192 个像素不同 <- 只在边缘
微用例(逐顶点色,MSAA 着色一次 vs SSAA 逐样本着色):
边缘像素 20 个,其中 MSAA != SSAA 的 20 个
样例像素 [3, 2, 3, [159, 51, 39], [159, 50, 41]]
sha256 MSAA : 7d2ab6aca185a9cf2ed7652f5e765531...
W 层:浏览器里做同一件事
S 层的 MSAA 是 CPU 上可断言的整数。第三种宿主是浏览器:用 WebGL2 渲染同一份场景。做法是把 MSAA 拆成 4 遍——顶点着色器接收 S/P 层算好的屏幕 Q6 坐标与深度,只做「屏幕 → NDC」的线性映射,另加一个 uShift 把几何整体平移 $(32-o_x, 32-o_y)$。第 $k$ 遍渲染就等价于在像素内第 $k$ 个子样本处求值;4 遍各自 readPixels 后在 JS 里平均,就是 MSAA 解析。
// snippet: 顶点着色器主体:uShift 把几何平移 (32-ox, 32-oy),等价于在子样本处求值
void main() {
vec2 px = (aPosQ6 + uShift) / 64.0; // 屏幕 Q6 -> 像素
vec2 ndc = vec2(px.x / uW * 2.0 - 1.0, 1.0 - px.y / uH * 2.0);
vColor = aColor / 255.0; // 顶点色须归一化,0-255 会被 clamp 成白
gl_Position = vec4(ndc, aDepthQ16 / 65536.0 * 2.0 - 1.0, 1.0);
}
判据必须是对舍入不敏感的统计量,不能是「逐位相等」:GPU 的光栅化规则与 CPU 不同——top-left rule 决定共享边像素归属,浮点插值与舍入方式也不同。故用 MAE、PSNR 与「覆盖归属差异」三条,并配一个负例(关掉 MSAA,单遍像素中心)。
// file: lantern/web/verify_raster11.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_raster11.mjs
// 卷一 ch11 的 W 层验证:WebGL2 的 4 遍子样本平均 与 CPU 定点 MSAA 在**同一份叶片场景**下对照。
//
// 判据口径(为什么不能用「逐位相等」):GPU 的光栅化规则与 CPU 不同 —— top-left rule
// 决定共享边像素归属、浮点插值与舍入方式不同。故只用对舍入不敏感的统计量,并把
// 「覆盖归属差异」单独设上限;判据必须配负例,否则 PASS 不可信。
// 负例就是「关掉 MSAA」(单遍像素中心)——它必须被同一判据拒绝。
// 本机 headless 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,差异不得归因于真实硬件。
import { createRequire } from "node:module";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const OUT = join(ROOT, "out");
mkdirSync(join(OUT, "shots"), { recursive: true });
const BG = JSON.parse(readFileSync(join(ROOT, "config", "cg_raster_11_msaa.json"), "utf8")).backgroundQ8;
const golden = new Uint8Array(readFileSync(join(OUT, "cg_raster_11_3_msaa.rgb")));
const covered = (rgb) => {
const mask = new Uint8Array(rgb.length / 3);
for (let p = 0; p < mask.length; p += 1) {
const o = p * 3;
if (rgb[o] !== BG[0] || rgb[o + 1] !== BG[1] || rgb[o + 2] !== BG[2]) mask[p] = 1;
}
return mask;
};
function stats(actual, expect, acov, ecov) {
let diffPx = 0;
let coverDiff = 0;
let maxDiff = 0;
let sumAbs = 0;
let sumSq = 0;
const pixels = expect.length / 3;
for (let i = 0; i < expect.length; i += 3) {
const p = i / 3;
let pxDiff = 0;
for (let c = 0; c < 3; c += 1) {
const d = Math.abs(actual[i + c] - expect[i + c]);
if (d > pxDiff) pxDiff = d;
sumAbs += d;
sumSq += d * d;
}
if (pxDiff > 0) diffPx += 1;
if (pxDiff > maxDiff) maxDiff = pxDiff;
if (acov[p] !== ecov[p]) coverDiff += 1;
}
const mse = sumSq / expect.length;
return { pixels, diffPx, coverDiff, maxDiff, mae: sumAbs / expect.length,
psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse) };
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`file://${join(ROOT, "web", "dist", "raster11.html")}`);
await page.waitForFunction(() => globalThis.__lantern && globalThis.__lantern.canvasDrawn, null, { timeout: 20000 });
const data = await page.evaluate(() => globalThis.__lantern);
const shot = join(OUT, "shots", "raster11_gl.png");
await page.screenshot({ path: shot });
await browser.close();
const cpu = Uint8Array.from(data.cpu.msaa);
const gl = Uint8Array.from(data.gl.msaa);
const glNoMsaa = Uint8Array.from(data.gl.noMsaa);
const cpuCov = covered(cpu);
const glCov = covered(gl);
let cpuVsS = 0;
for (let i = 0; i < cpu.length; i += 1) if (cpu[i] !== golden[i]) cpuVsS += 1;
const glStats = stats(gl, cpu, glCov, cpuCov);
const badStats = stats(glNoMsaa, cpu, covered(glNoMsaa), cpuCov);
console.log(`canvasDrawn : ${data.canvasDrawn}`);
console.log(`场景 : ${data.meta.width}x${data.meta.height} / ${data.meta.triangles} 三角形 / ${data.meta.samples} 子样本`);
console.log(`CPU MSAA covered: ${glCov.reduce((a, b) => a + b, 0)}(CPU 解析缓冲 ${cpuCov.reduce((a, b) => a + b, 0)})`);
console.log(`GL 后端 : ${data.gl.unmaskedRenderer}`);
console.log(`GL 版本 : ${data.gl.version} glError : ${data.gl.glError}`);
console.log(`① 页面内 CPU MSAA vs S 层缓冲:不同字节 = ${cpuVsS} ← 必须为 0`);
console.log(`② CPU 定点 MSAA vs WebGL2(4 遍平均):`);
console.log(` 任一通道差>0 : ${glStats.diffPx} / ${glStats.pixels} (${(glStats.diffPx / glStats.pixels * 1000).toFixed(2)}‰) ← 含 ±1 舍入,不作判据`);
console.log(` 覆盖归属不同 : ${glStats.coverDiff} / ${glStats.pixels} (${(glStats.coverDiff / glStats.pixels * 1000).toFixed(2)}‰) ← 主判据`);
console.log(` 最大通道差 : ${glStats.maxDiff}`);
console.log(` MAE : ${glStats.mae.toFixed(4)}`);
console.log(` PSNR : ${glStats.psnr.toFixed(2)} dB`);
console.log(` 截图 : ${shot}`);
console.log(`③ 负例(关掉 MSAA,单遍): MAE=${badStats.mae.toFixed(4)} PSNR=${badStats.psnr.toFixed(2)} dB ← 必须被同一判据拒绝`);
const fail = [];
if (!data.gl.ok) fail.push("② WebGL2 上下文不可用");
if (cpuVsS !== 0) fail.push(`① 页面内 CPU 与 S 层缓冲不同(${cpuVsS} 字节)`);
if (glStats.mae > 1.0) fail.push(`② MAE ${glStats.mae.toFixed(4)} 超出 1.0`);
if (glStats.psnr < 30) fail.push(`② PSNR ${glStats.psnr.toFixed(2)} dB 低于 30 dB`);
if (glStats.coverDiff / glStats.pixels * 1000 > 20) fail.push("② 覆盖归属差异超出 20‰");
if (badStats.mae <= 1.0 && badStats.psnr >= 30) fail.push("③ 负例未被判据拒绝 —— 判据无区分力");
if (fail.length) {
console.log(`\nFAIL:\n ${fail.join("\n ")}`);
process.exit(1);
}
console.log("\nPASS");
本机 headless 的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,所以下面的差异不得归因于真实硬件;需要真实 GPU 性能数据的论断属 X 档,命令行不可复现。实测输出:
canvasDrawn : true
场景 : 64x48 / 8 三角形 / 4 子样本
CPU MSAA covered: 388(CPU 解析缓冲 388)
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本 : WebGL 2.0 (OpenGL ES 3.0 Chromium) glError : 0
① 页面内 CPU MSAA vs S 层缓冲:不同字节 = 0 ← 必须为 0
② CPU 定点 MSAA vs WebGL2(4 遍平均):
任一通道差>0 : 2 / 3072 (0.65‰) ← 含 ±1 舍入,不作判据
覆盖归属不同 : 0 / 3072 (0.00‰) ← 主判据
最大通道差 : 51
MAE : 0.0271
PSNR : 47.40 dB
③ 负例(关掉 MSAA,单遍): MAE=3.1126 PSNR=25.27 dB ← 必须被同一判据拒绝
PASS
三条判据同时满足:MAE $0.0271 \le 1.0$、PSNR $47.40 \ge 30$ dB、覆盖归属差异 $0.00‰ \le 20‰$。负例(关掉 MSAA) 的 MAE $3.1126 > 1.0$、PSNR $25.27 < 30$ dB,被同一判据拒绝——判据有区分力,PASS 才可信。TypeScript 侧的断言:
✔ MSAA 解析缓冲的 sha256 与 Python 侧逐字节相同
✔ MSAA 在细叶片上多认领像素:覆盖数高于单样本,且只在边缘产生差异
✔ 微用例:MSAA(着色一次)与 SSAA(逐样本着色)在边缘像素上不同
✔ 负例:把子样本位置写错(全部落在像素中心)或干脆关掉 MSAA,sha256 必须不同
ℹ tests 4 ℹ pass 4 ℹ fail 0
阅读导航:上一节:11.2 超采样与覆盖率 · 下一节:12.1 UV 与纹理坐标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。