11.3 抗锯齿的度量

把「哪个抗锯齿方案更好」写成一条可复算的判据:先用参考解的梯度阈值圈出边缘像素,只在边缘上算 MAE,再配上 PSNR。64×64 圆盘的 296 个边缘像素上,无抗锯齿 → 4 spp SSAA → 8 帧 TAA → 16 spp SSAA 三级判据全部成立,而把覆盖率乘 0.5 的错实现被同一条判据拒绝。

11.3 抗锯齿的度量

站内 /graphics-anti-aliasing-methods/ 那篇列了各种抗锯齿方案,但「哪个更好」在那类文章里通常停在形容词。本节要把这句话变成一个可复算的判据:给定两个方案和一张参考解,判据必须机械地吐出「A 优于 B」或「不成立」,而且换一份实现重算,结论不变。

被测对象还是 11.1 那个 64×64 的解析圆盘——只有解析形状才有精确真值。参考解是每像素 16×16 = 256 个分层样本算出的覆盖率。

代号方案每像素样本数样本怎么放
none无抗锯齿1像素中心
ssaa42×2 SSAA42×2 栅格
taa88 帧 TAA8(跨帧)Halton 抖动,整数权重累积
ssaa164×4 SSAA164×4 栅格
ref参考解25616×16 分层

注意 taa8 与 ssaa4 的样本数只差一倍(8 vs 4),但样本的来路完全不同:SSAA 是同一帧内的空间栅格,TAA 是跨 8 帧的时间序列。判据不关心样本怎么来,只关心最终像素离真值多远——这正是把「谁更好」变成数字的好处。

一条判据,三条度量

直接用全图 MAE 排名是错的。64×64 = 4096 个像素里,绝大多数是纯前景或纯背景,四个方案在这些像素上完全一致——差异只发生在圆盘边缘的那几十个像素上,全图平均会把它们稀释到看不出来。所以要先找出边缘。

三条度量,层层收紧:

  1. 边缘像素的识别。对参考解求中心差分的梯度模长 |∇ref|,超过阈值(本节点 0.25,即 Q16 的 16384)的像素判为边缘。实测圈出 296 个像素——圆盘周长约 2πR ≈ 126 px,边缘带宽约 2~3 px,296 这个量级是合理的。
  2. 边缘 MAE。只在边缘像素上算 MAE。阶梯效应、锯齿、TAA 的残留误差都发生在这里。
  3. 阶梯度。逐行找灰度过 128 的亚像素边缘位置 e[y](线性插值),再看「相邻两行边缘完全不动」的行数占比。无抗锯齿的边缘位置被量化到整数,一段段不动,阶梯度接近 1;平滑边缘每行都在动,阶梯度接近 0。

最后是判据本身:A 优于 B 当且仅当 A 的边缘 MAE 更小 且 A 的 PSNR 更高。两条同时成立才算——只满足一条就是「各有胜负」,判据不表态。

S 层的核心四段(完整脚本见工作区 sim/cg_shade_11_3_metric.py):

# snippet: 11.3 的三条度量与判据(节选):梯度圈边缘 / 亚像素边缘位置 / 阶梯度 / better()
def gradient_mag(img):
    """中心差分的梯度模长(Q16 覆盖率的整数近似,乘 4 避免右移丢精度)。"""
    gx = np.zeros_like(img)
    gy = np.zeros_like(img)
    gx[:, 1:-1] = (img[:, 2:] - img[:, :-2]) * 2
    gy[1:-1, :] = (img[2:, :] - img[:-2, :]) * 2
    return np.abs(gx) + np.abs(gy)


def edge_position_profile(gray):
    """逐行找灰度过 128 的亚像素 x 位置(线性插值),返回 (行号, 位置) 列表。"""
    rows, pos = [], []
    h, w = gray.shape
    for y in range(h):
        row = gray[y].astype(np.float64)
        idx = np.nonzero(row >= 128)[0]
        if idx.size == 0 or idx[0] == 0:
            continue
        x1 = int(idx[0])
        a, b = row[x1 - 1], row[x1]
        if b == a:
            continue
        t = (128.0 - a) / (b - a)
        rows.append(y)
        pos.append((x1 - 1) + t)
    return np.array(rows), np.array(pos)


def staircase(gray):
    """阶梯度:相邻两行边缘位置「完全不动」的行数占比(越高越像阶梯)。"""
    rows, pos = edge_position_profile(gray)
    if pos.size < 2:
        return 0.0, 0
    still = np.sum(np.abs(np.diff(pos)) < 0.05)
    return round(float(still) / (pos.size - 1), 4), int(pos.size)


def better(a, b):
    """判据:A 优于 B 当且仅当边缘 MAE 更小 **且** PSNR 更高。"""
    return a["edge_mae"] < b["edge_mae"] and a["psnr_db"] > b["psnr_db"]

staircase 里那句 np.abs(np.diff(pos)) < 0.05 有个必须说清的细节:它测的是「动没动」,不是「动得对不对」。所以噪声大的边缘(每行都在抖)反而能拿到低分。下面会看到 8 帧 TAA 的阶梯度 0.0513 比参考解自己的 0.0769 还低——这不是 TAA 比真值更平滑,而是它把边缘位置抖散了。阶梯度只能当辅助指标,主判据是边缘 MAE + PSNR。

实测(/opt/homebrew/bin/python3 sim/cg_shade_11_3_metric.py):

画布 / 参考解   : 64x64  参考 256 spp
边缘像素        : 296 个(|∇ref| > 16384 = 0.25)
方案           全局MAE   PSNR dB     边缘MAE      边缘峰值       阶梯度
none        1.7743     43.15   24.5524  122.5195    0.4359
ssaa4        0.642     51.98    8.8841   47.8125    0.1795
ssaa16      0.1984     62.18     2.746   14.9414    0.0769
taa8        0.5364     53.54    7.4233   59.7734    0.0513
ref            0.0       inf       0.0       0.0    0.0769
判据链(后一方案必须边缘MAE更小 且 PSNR更高):
  ssaa4 优于 none ? True
  taa8 优于 ssaa4 ? True
  ssaa16 优于 taa8 ? True
负例(覆盖率x0.5): 边缘MAE 64.0192  PSNR 16.28 dB  -> 被拒绝 True

注意边缘 MAE 那一列和全局 MAE 那一列的差别。 全局 MAE 上 ssaa16 是 0.1984、taa8 是 0.5364,差 2.7 倍;到边缘 MAE 上变成 2.746 与 7.4233,差 2.7 倍——量级关系没变,但绝对差距被放大了一个数量级(0.34 变成 4.68)。这就是「只在边缘上算」的意义:判据的分辨率上来了。无抗锯齿的边缘 MAE 是 24.5524,边缘峰值 122.5195——单个像素上它可以把覆盖率判反(0 判成 1),那正是阶梯效应的定义。

判据链三级全部 True:none < ssaa4 < taa8 < ssaa16。注意顺序——8 帧 TAA 挤在 4 spp 与 16 spp SSAA 之间,这是个不显然的结果:TAA 用 8 帧换来的边缘质量,比 4 spp 好、比 16 spp 差。再看「边缘峰值」一列就更有意思了:taa8 的峰值 59.7734 高于 ssaa4 的 47.8125,尽管它的平均边缘误差更低。TAA 的误差是「少数像素偏得多、多数像素很准」,SSAA 是「均匀地差一点」。这正是判据必须同时看 MAE 与 PSNR 的原因:只看峰值会误判 TAA 更差,只看均值会漏掉它的尾部。

负例是判据的守门员:把参考解的覆盖率乘 0.5 再和参考解比,边缘 MAE 冲到 64.0192、PSNR 掉到 16.28 dB,被同一条判据拒绝(better(错实现, ssaa16) 为 False)。一条判据若不拒绝明显的错实现,它的 PASS 就不可信。

P 层:判据必须换一份实现也能算

判据如果只有一份实现能算出来,那它不是判据,是断言。P 层用 TypeScript 重算整张表(core/src/lantern/cg_shade_11_metric.ts):

// file: lantern/core/src/lantern/cg_shade_11_metric.ts
// tier: P
// verify: node --test 'core/test/cg_shade_11_3_metric.test.ts'
// 11.3 抗锯齿度量的 P 层:把 S 层 sim/cg_shade_11_3_metric.py 的那张表**在 TS 里重算一遍**。
// 若判据不能换一份实现复算,它就只是主观判断换了个说法。
//
// 三个量:
//   gradientMag —— 中心差分梯度模长(乘 2 避免右移丢精度),用来圈边缘像素
//   edgeMaeQ16  —— 只在边缘像素上算 MAE(8 位灰度单位)
//   better      —— 判据:A 优于 B 当且仅当边缘 MAE 更小 **且** PSNR 更高
//
// 全程整数(Q16.16 覆盖率),无浮点累加误差;只有最后转 8 位灰度单位时做一次除法。

import { ONE, maeQ16 } from "./cg_shade_11_taa.ts";

/** 中心差分梯度模长:|gx| + |gy|,边界像素缺的一侧按 0 计。 */
export function gradientMag(img: Int32Array, width: number, height: number): Int32Array {
  const out = new Int32Array(img.length);
  for (let y = 0; y < height; y += 1) {
    for (let x = 0; x < width; x += 1) {
      const i = y * width + x;
      const gx = (x >= 1 && x <= width - 2) ? (img[i + 1] - img[i - 1]) * 2 : 0;
      const gy = (y >= 1 && y <= height - 2) ? (img[i + width] - img[i - width]) * 2 : 0;
      out[i] = Math.abs(gx) + Math.abs(gy);
    }
  }
  return out;
}

/** 边缘像素掩码:参考解的梯度模长超过阈值即为边缘。 */
export function edgeMask(ref: Int32Array, width: number, height: number, thresh: number): Uint8Array {
  const g = gradientMag(ref, width, height);
  const m = new Uint8Array(ref.length);
  for (let i = 0; i < m.length; i += 1) m[i] = g[i] > thresh ? 1 : 0;
  return m;
}

/** 边缘像素上的 MAE(8 位灰度单位)。掩码为空时返回 0。 */
export function edgeMaeQ16(a: Int32Array, ref: Int32Array, mask: Uint8Array): number {
  let s = 0;
  let n = 0;
  for (let i = 0; i < mask.length; i += 1) {
    if (mask[i]) { s += Math.abs(a[i] - ref[i]); n += 1; }
  }
  return n === 0 ? 0 : (s / n) / ONE * 255;
}

/** MAE -> PSNR(dB)。MAE 为 0 时返回 Infinity。 */
export function psnrFromMae(mae: number): number {
  return mae === 0 ? Infinity : 20 * Math.log10(255 / mae);
}

export interface Score {
  readonly edgeMae: number;
  readonly psnr: number;
}

/** 判据:A 优于 B 当且仅当 A 的边缘 MAE 更小 **且** A 的 PSNR 更高。两条同时成立。 */
export function better(a: Score, b: Score): boolean {
  return a.edgeMae < b.edgeMae && a.psnr > b.psnr;
}

/** 一次算全:全局 MAE、PSNR、边缘 MAE、边缘像素数。 */
export function score(q: Int32Array, ref: Int32Array, mask: Uint8Array): Score & { globalMae: number; edgePx: number } {
  const globalMae = maeQ16(q, ref);
  let edgePx = 0;
  for (let i = 0; i < mask.length; i += 1) if (mask[i]) edgePx += 1;
  return { globalMae, edgeMae: edgeMaeQ16(q, ref, mask), psnr: psnrFromMae(globalMae), edgePx };
}

它复用 11.1 的 ONE 与 maeQ16——同一份定点口径,不重写。S 层把五个方案的 Q16 缓冲拼成一个 80 KB 的 cg_shade_11_3_q16.bin 当金标准,P 层读它、重算边缘掩码与整张表、再复算判据链。

node --test 的结果:

✔ 梯度阈值圈出的边缘像素数与 S 层一致
✔ 逐方案重算:全局 MAE / PSNR / 边缘 MAE 与 S 层 JSON 一致
✔ 参考解自比:边缘 MAE 为 0,PSNR 为 Infinity
✔ 判据链:none < ssaa4 < taa8 < ssaa16,每一级都真的更优
✔ 负例:覆盖率 x0.5 的错实现必须被同一条判据拒绝
✔ 8 帧 TAA vs 4 spp SSAA:平均误差更小但峰值更大,误差形状不同
tests 6 / pass 6 / fail 0

第四条里除了「三级都成立」,还有一条反向断言:better(x, x) 必须为 False——判据若对自己也成立,它就是恒真的,没有区分力。第五条是负例。第六条把「TAA 峰值更大」也钉成断言,防止有人把 better 简化成只看 MAE 而悄悄改变结论。

阈值取多少:一次敏感性扫描

「边缘像素用梯度阈值圈出来」——阈值是个自由参数。一条判据如果在调参后就翻脸,它就不是判据。所以必须扫一遍:把阈值从 0.0625(Q16 的 4096)一路放到 1.0(65536),看边缘像素数怎么变、判据链还成不成立。

阈值敏感性(判据必须对「边缘怎么圈」不敏感):
        阈值      边缘像素      none     ssaa4      taa8    ssaa16  链成立
      4096       344   21.1265    7.6444    6.3875    2.3628  True
      8192       328    22.157    8.0173     6.699    2.4781  True
     16384       296   24.5524    8.8841    7.4233     2.746  True
     32768       280   25.9554    9.3917    7.8474    2.9029  True
     65536       240   30.2812    10.957    9.1554    3.3867  True

三件事读得出来。第一,链 5/5 全部成立——阈值放小到 1/16 或放大到 1.0,none < ssaa4 < taa8 < ssaa16 都不变。判据稳。第二,边缘像素数从 344 掉到 240:阈值越高,边缘带越窄,被圈进来的越只剩最陡的那一圈。第三,所有方案的边缘 MAE 都单调上升(无抗锯齿从 21.13 涨到 30.28)——这不是误差变大了,而是样本在往「最难的那些像素」集中。所以边缘 MAE 的绝对值只在同一个阈值下可比,跨阈值比数字没有意义。判据内部比的是同一阈值下的两个方案,这个坑天然避开了。

参考解自己要多准

判据的另一半是参考解。如果参考解本身不准,判据量的是「被测方案离一个错的真值有多远」。16×16 = 256 spp 够不够?拿 32×32 = 1024 spp 当「更真」的参考再算一遍:

参考解精度(拿 1024 spp 当更真的参考):
       样本数  vs1024的MAE      边缘像素      none     ssaa4      taa8    ssaa16  链成立
        64      0.0625       296   24.1755    8.6687    7.1881    2.0999  True
       256      0.0212       296   24.5524    8.8841    7.4233     2.746  True
      1024         0.0       296   24.6701    8.9884    7.4603    2.8167  True

第一列是参考解自己的误差:64 spp 离 1024 spp 差 0.0625,256 spp 差 0.0212——每次把每像素样本数翻两番,参考误差降到约 1/3,符合蒙特卡洛式的收敛。第二列是意外之喜:三档参考解圈出的边缘像素都是 296 个,一个不多一个不少。梯度阈值圈边缘这件事对参考解的精度不敏感,因为边缘像素的梯度远超阈值,而纯色区恒为 0。

判据链三档全成立。但绝对数字会动:ssaa16 的边缘 MAE 在 64/256/1024 spp 下分别是 2.0999 / 2.746 / 2.8167——参考越准,误差越大(因为被测方案真的离真值那么远)。所以「边缘 MAE 是多少」这句话必须带参考解口径,就像上一节的边缘 MAE 必须带阈值口径一样。本节所有正文数字统一用 256 spp,判据内部的比较则不受影响。

判据的边界

三条度量里,前两条(边缘 MAE、PSNR)是硬的:它们是纯整数统计量,换实现、换语言、换机器都能复算出同一个数。阶梯度是软的,两个已知的坑必须写明:

  • 它有地板,不为 0。参考解自己的阶梯度是 0.0769(40 行里 3 行没动)。原因很实在:圆盘在赤道附近边缘接近竖直,e[y] 逐行变化本来就小于 0.05 px,被算成「没动」。所以读阶梯度要拿它和地板比——无抗锯齿 0.4359 是地板的 5.7 倍,这才是有意义的对比。
  • 它对噪声反向敏感。8 帧 TAA 的 0.0513 低于地板,因为它把边缘位置抖散了。阶梯度说「它不平滑」,而实际上它是四个方案里边缘 MAE 第二低的。这说明任何单一指标都可能被反向利用——这也是本节坚持用「边缘 MAE 与 PSNR 同时成立」这条合取判据的原因。

还有一条更根本的边界:这套判据只对「有精确参考解」的对象成立。解析圆盘有真值,lantern 场景没有——场景的像素值本身就来自采样,不存在一个「正确的覆盖率」可比。那时只能换用别的判据:与上一帧比(时间一致性)、与更高采样数的自己比(自洽参考)、或者与 CPU 定点实现比(跨宿主容差)。判据的形式可以变,但「必须可复算、必须配负例」这两条不能变。

阅读导航:上一节:11.2 抖动与蓝噪声 · 下一节:12.1 浮点的容差口径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配