11.3 MSAA 的 CPU 实现

MSAA 只在覆盖上多采样、着色一次。本节用 8 片带深度的叶片,把每像素 4 个子样本各自做覆盖与深度测试再按覆盖平均的 CPU 实现写出来,并让浏览器 WebGL2 用 4 遍平移渲染做同一件事,按 MAE/PSNR/覆盖归属三条容差判据对上,且负例必被拒绝。

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 与纹理坐标 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图