11.1 时间抗锯齿

时间抗锯齿的全部机制可以压成三个整数:帧间权重 w = ONE//(k+1)、确定性的 Halton 子像素抖动、历史帧的 3×3 邻域裁剪。本节在解析圆盘上实测——静止相机下 1→16 帧的 MAE 从 1.9163 收敛到 0.3881;每帧微动 0.25 px 时,裁剪把重影误差从 9.2405 压到 4.929。

11.1 时间抗锯齿

站内 /graphics-anti-aliasing-methods/ 那篇讲的是 TAA 的工程落地:运动矢量怎么写、历史缓冲怎么分配、API 怎么接。本节把 TAA 剥到最小——一帧只采一个样本,抗锯齿的效果全部来自「帧与帧之间采的地方不一样」;并且把「收敛到什么程度」从形容词变成可复算的数字。

要度量 TAA,被测对象必须有一个精确的真值。lantern 场景没有——它的像素值本身来自采样。所以本节换成一个解析圆盘:64×64 画布上一个半径 20 px 的圆,真值用每像素 16×16 = 256 个分层样本算出覆盖率(Q16.16,0..ONE)。TAA 要做的,是用 N 帧单样本去逼近这个真值。

三个整数,一台 TAA

整条 TAA 只有三件事,而且三件都可以是整数:

  1. 帧间权重。第 k 帧(从 0 起)的当前帧权重 w = ONE // (k+1),历史帧权重 ONE - w。两者的和恒等于 ONE——这是本节的不变量,代码里直接断言。取 ONE // (k+1) 而不是 1/(k+1) 的好处是:整条累积链上没有一次浮点,帧序定了,结果就定了。
  2. 子像素抖动。Halton(base 2, 3) 的 Q16 整数展开,写在 config 里,Python 与 WebGL2 读同一份。没有随机数——同样的帧序在两侧给出同样的采样位置。
  3. 历史帧裁剪。相机微动时,上一帧的颜色在新位置可能已经不成立,直接混合会留下重影(ghosting)。做法是把历史缓冲夹到当前帧 3×3 邻域的 [min, max](neighborhood clamping)。

为什么抖动序列不用随机数。 随机数听起来更「自然」,但它一次弄坏三件事:结果不可复现(同一份代码两次跑出不同图)、跨宿主对不齐(Python 与 WebGL2 必须读同一份序列,否则 W 层的比对没有意义)、以及低差异序列本身的收敛更快。Halton(base 2, 3) 是确定性的低差异序列——第 k 项只由 k 决定,haltonQ16(k, 2) 与 haltonQ16(k, 3) 给出两个方向上的偏移,两个底数互质使采样点在像素内均匀铺开。这也是本章的红线之一:不许出现无种子的随机调用。

下面是 S 层里最核心的两段(完整脚本见工作区 sim/cg_shade_11_1_taa.py):

# snippet: 11.1 的帧间权重累积(节选):w = ONE//(k+1),历史权重 = ONE - w,和恒为 ONE
def taa(frames, clamp_cur=None, radius=1):
    """帧间整数权重累积。第 k 帧当前帧权重 = ONE // (k+1),历史帧权重 = ONE - w。"""
    out = frames[0].copy()
    clamped_px = 0
    for k in range(1, len(frames)):
        if clamp_cur is not None:
            cur = clamp_cur[k]
            stack = [np.roll(np.roll(cur, dy, 0), dx, 1)
                     for dy in range(-radius, radius + 1)
                     for dx in range(-radius, radius + 1)]
            lo = np.minimum.reduce(stack)
            hi = np.maximum.reduce(stack)
            before = out.copy()
            out = np.clip(out, lo, hi)
            clamped_px += int((out != before).sum())
        w = ONE // (k + 1)
        out = (out * (ONE - w) + frames[k] * w) >> 16
    return out, clamped_px

>> 16 这里是算术右移,等价于除以 ONE 后向下取整。分子 out*(ONE-w) + frames[k]*w 恒非负(覆盖率 Q16 ∈ [0, ONE]),所以「向下取整」没有歧义。这一点在 P 层会变成一个真实的坑,下面再说。

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

画布 / 参考解   : 64x64  参考 256 spp  圆盘覆盖 1324 像素
无抗锯齿 (1 spp): MAE 1.9163  PSNR 42.48 dB
相机静止 · TAA 收敛:
  N= 1  MAE  1.9163  PSNR  42.48 dB  残差峰 41984
  N= 2  MAE  1.2009  PSNR  46.54 dB  残差峰 39936
  N= 4  MAE   1.016  PSNR  47.99 dB  残差峰 26624
  N= 8  MAE  0.5364  PSNR  53.54 dB  残差峰 15362
  N=16  MAE  0.3881  PSNR  56.35 dB  残差峰 11270
相机微动 · 每帧 +0.25 px(16 帧共 3.75 px):
  朴素累积 : MAE 9.2405  PSNR 28.82 dB  重影 245 px
  裁剪累积 : MAE 4.929  PSNR 34.28 dB  重影 124 px  裁剪改动 193 px

两件事值得停下来看。第一,收敛是真的:MAE 从 1.9163 单调降到 0.3881,16 帧把误差压到单帧的五分之一。但收敛不是免费的平均——w = ONE//(k+1) 是滑动平均,不是「攒够 N 帧再除 N」,所以第 8 帧到第 16 帧只多降了 0.15,边际收益递减得很快。

第二,微动时朴素累积会崩。圆盘每帧右移 0.25 px,16 帧共 3.75 px,末帧的真值已经是移过的圆;朴素累积把 16 帧的历史全留着,于是在圆的左侧拖出一条 245 px 的「尾巴」,MAE 冲到 9.2405。邻域裁剪把历史夹到当前帧的 3×3 [min,max],改动 193 个像素,重影缩到 124 px,MAE 降到 4.929。注意裁剪不是免费的午餐:它也会削掉真实的历史累积,所以静止场景下裁剪反而略增误差——它是为运动场景付的保险费。

P 层:帧间累积的定点内核

P 层用 TypeScript 重写同一套东西(core/src/lantern/cg_shade_11_taa.ts),要求逐字节复现 S 层的 Q16 缓冲:

// file: lantern/core/src/lantern/cg_shade_11_taa.ts
// tier: P
// verify: node --test 'core/test/cg_shade_11_1_taa.test.ts'
// 11.1 时间抗锯齿的 P 层:Halton Q16 抖动序列 + 帧间整数权重累积 + 历史帧裁剪。
// 与 Python 侧 sim/cg_shade_11_1_taa.py 做**同一件事**,两侧是两份独立代码。
//
// 逐像素层不需要 BigInt:屏幕坐标 <= 64*ONE = 2^22,dx^2 <= 2^44,
// dx^2+dy^2 < 2^45 < 2^53;权重混合 out*(ONE-w) 的中间量 <= 2^33。全在 Number 精确区。
// 降精度用 `Math.floor(v / ONE)`:分子非负(覆盖率 Q16 ∈ [0, ONE]),floor 与 Python 的
// 算术右移同义;JS 的 `>>` 只有 32 位域,而权重混合的分子可达 2^32,不能用 `>>`。

export const ONE = 65536;

/** Halton 序列的 Q16 展开:haltonQ16(1, 2) = 0.5 * ONE,haltonQ16(2, 2) = 0.25 * ONE。 */
export function haltonQ16(index: number, base: number): number {
  let f = ONE;
  let r = 0;
  let i = index;
  while (i > 0) {
    f = Math.floor(f / base);
    r += f * (i % base);
    i = Math.floor(i / base);
  }
  return r;
}

/** Q16.16 圆内判定:dx^2 + dy^2 <= r^2。 */
export function diskInside(cx: number, cy: number, r: number, ox: number, oy: number): boolean {
  const dx = ox - cx;
  const dy = oy - cy;
  return dx * dx + dy * dy <= r * r;
}

/** 一帧:像素 (px, py) 内偏移 (jx, jy) 处取 1 个样本,命中返回 ONE,否则 0。 */
export function pointFrame(
  width: number, height: number, cx: number, cy: number, r: number,
  jx: number, jy: number,
): Int32Array {
  const out = new Int32Array(width * height);
  for (let y = 0; y < height; y += 1) {
    const oy = (y << 16) + jy;
    for (let x = 0; x < width; x += 1) {
      const ox = (x << 16) + jx;
      out[y * width + x] = diskInside(cx, cy, r, ox, oy) ? ONE : 0;
    }
  }
  return out;
}

/** 第 k 帧(0 起)的当前帧权重 = ONE // (k+1);历史帧权重 = ONE - w,两者和恒为 ONE。 */
export function frameWeight(k: number): number {
  return Math.floor(ONE / (k + 1));
}

/** 3x3 邻域的 [min, max](neighborhood clamping 的上下界)。 */
export function neighborhoodMinMax(cur: Int32Array, width: number, height: number, x: number, y: number): [number, number] {
  let lo = ONE;
  let hi = 0;
  for (let dy = -1; dy <= 1; dy += 1) {
    const yy = y + dy;
    if (yy < 0 || yy >= height) continue;
    for (let dx = -1; dx <= 1; dx += 1) {
      const xx = x + dx;
      if (xx < 0 || xx >= width) continue;
      const v = cur[yy * width + xx];
      if (v < lo) lo = v;
      if (v > hi) hi = v;
    }
  }
  return [lo, hi];
}

/**
 * 帧间整数权重累积。第 k 帧:`out = (out*(ONE-w) + cur*w) >> 16`。
 * `clamp` 为真时先把 out 夹到当前帧 3x3 邻域的 [min, max]。返回 `[末帧结果, 被裁剪改动像素数]`。
 */
export function taaAccumulate(
  frames: Int32Array[], width: number, height: number, clamp: boolean,
): [Int32Array, number] {
  let out = Int32Array.from(frames[0]);
  let changed = 0;
  for (let k = 1; k < frames.length; k += 1) {
    const cur = frames[k];
    if (clamp) {
      for (let y = 0; y < height; y += 1) {
        for (let x = 0; x < width; x += 1) {
          const idx = y * width + x;
          const [lo, hi] = neighborhoodMinMax(cur, width, height, x, y);
          const v = out[idx];
          const c = v < lo ? lo : (v > hi ? hi : v);
          if (c !== v) { out[idx] = c; changed += 1; }
        }
      }
    }
    const w = frameWeight(k);
    // 分子 = out*(ONE-w) + cur*w <= 2^32,超出 JS `>>` 的 32 位域,
    // 故用 Math.floor(v / ONE)。分子非负(覆盖率 Q16 ∈ [0, ONE]),floor 与算术右移同义。
    for (let i = 0; i < out.length; i += 1) {
      out[i] = Math.floor((out[i] * (ONE - w) + cur[i] * w) / ONE);
    }
  }
  return [out, changed];
}

/** 与 256 spp 参考解的 MAE(8 位灰度单位),入参为 Q16 覆盖率缓冲。 */
export function maeQ16(a: Int32Array, b: Int32Array): number {
  let s = 0;
  for (let i = 0; i < a.length; i += 1) s += Math.abs(a[i] - b[i]);
  return (s / a.length) / ONE * 255;
}

/** 16x16 分层超采样参考解(每像素 256 样本,覆盖率 Q16)。 */
export function reference(
  width: number, height: number, cx: number, cy: number, r: number, n: number,
): Int32Array {
  const step = Math.floor(ONE / n);
  const out = new Int32Array(width * height);
  for (let y = 0; y < height; y += 1) {
    for (let x = 0; x < width; x += 1) {
      let count = 0;
      for (let a = 0; a < n; a += 1) {
        for (let b = 0; b < n; b += 1) {
          if (diskInside(cx, cy, r, (x << 16) + a * step + (step >> 1),
                         (y << 16) + b * step + (step >> 1))) count += 1;
        }
      }
      out[y * width + x] = count * Math.floor(ONE / (n * n));
    }
  }
  return out;
}

注意 taaAccumulate 里那两行注释——这是本节最值钱的一处坑。照抄 Python 的 >> 16 写成 JS 的 >> 16 会静默出错:JS 的 >> 只有 32 位域,而分子 out*(ONE-w) + cur*w 可以到 2^32,高位直接被截掉。实测的错误版本 MAE 是 104.87(Python 是 0.3881),Q16 缓冲几乎全被压成 0。改成 Math.floor(v / ONE) 才对——因为分子非负,floor 与算术右移同义。

node --test 的结果:

✔ Halton Q16 序列与 config 逐项相同(base 2 / 3,无随机数)
✔ 帧间权重不变量:当前帧权重 + 历史帧权重 == ONE
✔ 静止 16 帧 TAA:Q16 缓冲与 S 层逐字节相同
✔ 收敛曲线:N=1/2/4/8/16 的 MAE 与 S 层一致且单调下降
✔ 相机微动:裁剪把重影误差从 ~9.24 降到 ~4.93
✔ 负例:关掉子像素抖动(16 帧都采像素中心)→ 不收敛,被同一判据拒绝
✔ 圆盘覆盖与参考解:几何一致性
tests 7 / pass 7 / fail 0

最后一条负例是整个 P 层的守门员:把 16 帧的采样位置全设成像素中心,TAA 就退化成「16 次采样同一个点」,MAE 停在 1.7743,判据必须拒绝它。一条判据若连「关掉抖动」都拦不住,它就没有区分力。

W 层:同一串抖动搬进 WebGL2

W 层把同一份 config、同一串 Halton 抖动、同一个权重公式搬进片元着色器:全屏四边形,片元在 gl_FragCoord ± uJitter 处做圆内判定,输出 0/1 覆盖率,16 遍累积。页面内还并入了同一份 Q16 内核做交叉检查。

判据的口径必须先说清楚:S 层是 Q16.16 定点(逐帧 0/ONE + 权重 floor),W 层是浮点片元着色器,两者的舍入路径不同,加上光栅化规则决定片元归属,所以不能要求逐位相等,只能按声明容差比对:

canvasDrawn     : true   已绘制像素 : 1304 / 4096
GL 后端         : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0)), SwiftShader driver)
GL 版本         : WebGL 2.0 (OpenGL ES 3.0 Chromium)   glError : 0
① 页面内 CPU 定点 vs S 层 Q16 缓冲:不同像素 = 0  ← 必须为 0
② WebGL2 16 帧 TAA vs S 层 Q16 定点:
   不同像素     : 98 / 4096 (23.93‰)
   最大通道差   : 6 / 65536
   MAE          : 0.0003
   PSNR         : 100.52 dB
③ 确定性        : 两遍逐字节相同 = true
④ 负例(关掉抖动): MAE=1.7432 PSNR=26.05 dB  ← 必须被同一判据拒绝
PASS

读法:① 是同一份实现的自比——页面内 CPU 复算的就是同一个 Q16 内核,所以不同像素必须是 0;② 才是跨宿主的,4096 个像素里有 98 个差 1 个通道(最大通道差 6/65536),MAE 0.0003、PSNR 100.52 dB,落在「MAE ≤ 1.0 且 PSNR ≥ 30 dB」的容差里。③ 是确定性——同一页面渲染两遍逐字节相同。④ 是负例,必须被同一条判据拒绝。

必须写明的边界:本机 headless 的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU。②里那 98 个像素的差异来自浮点插值与光栅化规则,不得归因于真实硬件。真实驱动上的浮点行为需要真实 GPU 环境,命令行不可复现——那是 X 档的范畴。

小结

TAA 的机制不复杂,难的是把它写成可度量的东西。本节定下三条:帧间权重是整数且和恒为 ONE;抖动序列是确定性的、无随机数;历史裁剪的代价必须和它换来的收益一起报(静止时略增误差,运动时把重影砍掉一半)。下一节换一个完全不同的抗锯齿手段——抖动,它不增加采样数,而是把量化误差从低频搬到高频。

阅读导航:上一节:10.3 色调与调色 · 下一节:11.2 抖动与蓝噪声 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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