5.2 SSAO 的采样与噪声

SSAO 从深度缓冲重建视空间位置与法线,再在半球上撒一组固定方向做遮挡估计。方向集固定会让误差呈低频带纹,把它按一张确定性的 4×4 旋转表逐像素转开,误差被打散成高频噪声,再用 3×3 盒式模糊压回去。本节量出 4/8/16/32/64 采样的收敛曲线、旋转前后的粗糙度,以及模糊对内部像素误差的削减,并在 TypeScript 侧逐字节复现。

5.2 SSAO 的采样与噪声

5.1 的 AO 有一个奢侈的前提:场景能被解析求解。真实场景里既没有无限平面,也没有算得出来的闭式解,手上只有一帧深度缓冲。屏幕空间环境光遮蔽(SSAO)的全部内容,就是只靠深度缓冲把 AO 估出来。实时 GI 的完整方案见 实时全局光照 ,本节只处理它最基础的一步:遮蔽的采样估计,以及估计过程本身带来的噪声。

场景:一帧深度缓冲

本节的最小场景沿用 5.1 的平面加球,但换到视空间来搭:相机在原点看向 $-z$,地面 $y=-1$ 铺满下半屏,半径 0.6 的球心悬在 $(0,-0.4,-3)$,与地面相切。深度缓冲 64×64,由解析求交逐像素填出——射线与平面求交、射线与球求交,取最近的那个命中。约 2076/4096 个像素被覆盖,其余是背景。

深度存的是线性化的 NDC 深度 $z_{ndc}=(w-z_{near})/(z_{far}-z_{near})$,Q16.16。之所以先线性化再存,是因为 SSAO 后面要反复把深度还原成视空间距离——存线性值,这一步就只是一次乘加。

从深度缓冲重建视空间位置与法线

深度缓冲里每个像素只存一个 NDC 深度 $z_{ndc}\in[0,1]$。要拿它做三维的事,先得把它还原成视空间的一个点。相机在原点看向 $-z$,视锥半角的正切记作 $t=\tan(\text{fov}/2)$,像素 $(p_x,p_y)$ 在图像平面上的 NDC 坐标是

$$ n_x = \frac{2p_x+1}{W}-1,\qquad n_y = 1-\frac{2p_y+1}{H} $$

先由 NDC 深度反解视空间深度 $w = z_{near} + z_{ndc},(z_{far}-z_{near})$,再逆投影:

$$ P = \bigl(n_x,w,t,;; n_y,w,t,;; -w\bigr) $$

这一步必须逐位精确,否则后面所有判定都跟着歪。脚本里的深度缓冲由解析求交(射线-平面 / 射线-球)生成,重建出来的位置与它比对,最大误差 98/65536 ≈ 0.0015——全部来自深度本身的 16 位量化。

法线不必另外存:从重建出来的深度做有限差分就够了。取右邻与下邻两个重建点,做叉积再归一化。注意屏幕 $y$ 轴向下,所以顺序是 normalize(cross(P[y+1]-P[y], P[x+1]-P[x]))——先纵向差分再横向差分,得到的才是朝外的法线。实测重建法线与解析法线的平均夹角 3.34°,深度量化的代价,够用。

# snippet: 取自 lantern/sim/cg_shade_05_2_ssao.py 的三步核心(重建 / 采样 / 去噪)
def reconstruct(depth, covered):
    """逆投影重建视空间位置:P = (nx*w*tanf, ny*w*tanf, -w),w = near+ndc*(far-near)。"""
    pos = np.zeros((H, W, 3), dtype=np.int64)
    for py in range(H):
        ny = ONE - (2 * py + 1) * ONE // H
        for px in range(W):
            if not covered[py, px]:
                continue
            nx = (2 * px + 1) * ONE // W - ONE
            w = NEAR + ((int(depth[py, px]) * (FAR - NEAR)) >> 16)
            pos[py, px] = (cglib.fmul(cglib.fmul(nx, TAN_HALF), w),
                           cglib.fmul(cglib.fmul(ny, TAN_HALF), w), -w)
    return pos

def ssao(pos, nrm, t, b, covered, depth, dirs, cs=None, sn=None):
    """固定方向集 SSAO;给了 (cs, sn) 就把方向绕法线逐像素旋转。"""
    wbuf = NEAR + ((depth * (FAR - NEAR)) >> 16)
    occ = np.zeros((H, W), dtype=np.int64)
    for dx, dy, dz in dirs:
        if cs is not None:
            rx = ((dx * cs) >> 16) - ((dy * sn) >> 16)
            ry = ((dx * sn) >> 16) + ((dy * cs) >> 16)
            dx, dy = rx, ry
        D = np.stack([((t[..., i] * dx) >> 16) + ((b[..., i] * dy) >> 16) + ((nrm[..., i] * dz) >> 16)
                      for i in range(3)], axis=-1)
        S = pos + ((D * RADIUS) >> 16)
        ws = -S[..., 2]
        denom = (ws * TAN_HALF) >> 16
        sx = ((fdiv_arr(S[..., 0], denom) + ONE) * W) >> 17
        sy = ((ONE - fdiv_arr(S[..., 1], denom)) * H) >> 17
        valid = (sx >= 0) & (sx < W) & (sy >= 0) & (sy < H) & (ws > 0)
        occ += (valid & (ws > wbuf[np.clip(sy, 0, H - 1), np.clip(sx, 0, W - 1)] + BIAS)).astype(np.int64)
    ao = ((len(dirs) - occ) << 16) // len(dirs)
    ao[~covered] = ONE
    return ao

def blur8(a8):
    """8 位 AO 的 3x3 盒式模糊(边缘 clamp),无掩码——跨语言可逐字节复现。"""
    out = np.zeros((H, W), dtype=np.int64)
    for dy in (-1, 0, 1):
        for dx in (-1, 0, 1):
            ys = np.clip(np.arange(H) + dy, 0, H - 1)
            xs = np.clip(np.arange(W) + dx, 0, W - 1)
            out += a8[np.ix_(ys, xs)].astype(np.int64)
    return (out // 9).astype(np.uint8)

半球采样与遮挡判定

SSAO 的核心循环只有四步:把方向集从"法线周围的半球"转到该像素的切空间;采样点沿方向走一小段半径;把采样点投影回屏幕;比较它的深度与深度缓冲里存的那个。若采样点比缓冲里的更远,说明它被挡住了。

切空间由法线 $n$ 和两个正交轴 $t,b$ 张成。方向集是固定的整数分层网格($n_u\times n_\phi$ 条余弦半球方向),转到切空间后得到世界方向 $D = d_x t + d_y b + d_z n$,采样点就是 $S = P + r,D$。

投影回屏幕是逆投影的逆运算:先取 $w_S=-S_z$,再用同一组 $n_x,n_y$ 的公式算出像素坐标,四舍五入到整数格点。落在屏幕外的采样点直接丢弃。

遮挡判定是 w_S > w_buf + bias——bias 必需,否则曲面会自己遮蔽自己(采样点因为量化误差落到了深度缓冲表示的曲面"后面")。本节取 bias = 0.125,是深度量化步长的量级。

采样半径 $r$ 是个权衡:太小看不见大尺度遮挡,太大会把远处不相关的几何算进来。本节取 0.5 世界单位,恰好覆盖球与地面相切处的接触阴影——这也正是 SSAO 最有价值的场景。

噪声从哪来,怎么去掉

固定方向集每像素同向,于是误差是低频带纹——画面上一条条弧线,很扎眼。实测:4 方向固定核的 MAE 2257(对 1024 向参考),粗糙度(相邻像素差之和)1067。

解法是旋转。把方向集按一张固定的 4×4 旋转表逐像素绕法线转开——注意这不是随机数,是确定性的角度表,所以仍然完全可复现。旋转之后,误差从低频被打散成高频噪声:粗糙度从 1067 涨到 2599(画面更吵了),但 MAE 反而降到 1848。代价是更难看的画面,收益是更小的偏差。

高频噪声是 3×3 盒式模糊的拿手活。模糊之后粗糙度回落到 1068,内部像素的 MAE 从 1661 降到 929,误差场的高频含量从 2417 降到 1160。这就是 SSAO 的标准两步:旋转打散 + 模糊收拢。注意模糊必须在 8 位缓冲上做——把 AO 量化到 8 位再模糊,跨语言才好逐字节复现。

覆盖像素              : 2076 / 4096
位置重建最大误差      : 98 / 65536 = 0.001495
法线重建平均 n·n      : 67701 / 65536 = 1.033035  (3.34°)
收敛曲线 (vs 1024 方向参考):
   4 方向  MAE  2257 / 65536 = 0.034439
   8 方向  MAE  1106 / 65536 = 0.016876
  16 方向  MAE   882 / 65536 = 0.013458
  32 方向  MAE   506 / 65536 = 0.007721
  64 方向  MAE   263 / 65536 = 0.004013
固定核  MAE  2257  粗糙度  1067
旋转核  MAE  1848  粗糙度  2599
旋转+模糊 MAE  1126  粗糙度  1068
内部像素              : 1942  旋转 MAE 1661 → 模糊 929
误差场高频 旋转  2417 → 模糊  1160
AO 值域               : [0.2500, 1.0000]
AO 旋转核 sha256      : ab0e8305ab43b2e57a8cba027acb2535d1b19ad28886a4a691c761391302f4f8
AO 模糊 sha256        : 7be872e7345c45c8d21a12dd4678b0d5f4a2d7980dede9b1cfc51e43bfb2b530

收敛曲线

采样数从 4 涨到 64,MAE 单调下降:2257 → 1106 → 882 → 506 → 263(Q16.16)。这是一条干净的 $O(N^{-1/2})$ 曲线——因为估计器本质上仍是蒙特卡洛的,只是把随机方向换成了固定方向。8 方向的收益最大(MAE 直接减半),之后边际递减,这也是为什么移动端常常停在 8~16 采样加模糊。

三种核的实测对比

把同一帧深度缓冲喂给三种核,数字如下(MAE 与内部 MAE 都是对 1024 向参考的 Q16.16 误差,粗糙度是误差场相邻像素差之和):

核MAE粗糙度内部 MAE
固定 4 向225710672264
旋转 4 向184825991661
旋转 4 向 + 3×3 模糊11261068929

读法很清楚:旋转用"更吵"换"更准"(粗糙度翻倍,MAE 降 18%),模糊再把吵的部分收回来(粗糙度回到 1068,MAE 再降 39%)。三步缺一不可——只旋转不模糊,画面上是均匀的沙噪;只模糊不旋转,低频带纹会被模糊糊开但依然在,而且边缘会发糊。

常见伪影与对策

  • 自遮挡:曲面把采样点误判成被自己挡住。对策是深度比较加 bias,bias 取深度量化步长的量级。
  • 带纹(banding):固定方向集每像素同向,误差聚成低频弧线。对策是逐像素旋转方向集,把低频打散成高频。
  • 光晕(halo):采样半径跨过了前景与背景的深度断层,背景被前景"遮蔽"。对策是限制半径、或按法线夹角给权重。
  • 整体过暗:所有采样都被算成遮挡,AO 系统性偏低。对策是给半径加距离衰减,越远的采样贡献越小。

与 5.1 的对照

5.1 解析 AO5.2 SSAO
可见性来源解析求交(射线-球)深度缓冲重建
方向集64 条固定余弦半球4 条固定 + 逐像素旋转
真值闭式解 $1-(r/h)^2$1024 向密集参考
主要误差离散量化、分布偏差深度量化、屏幕空间盲区
跨语言判据AO 图 sha256位置缓冲 + 模糊图 sha256

两者共享同一套定点纪律:固定方向集、整数求交、降精度用算术右移。区别只在可见性从哪里来——一个来自几何的闭式解,一个来自一帧深度。这也解释了为什么 SSAO 的误差结构更"脏":它多了一层深度量化,还多了一片看不见的屏幕外区域。

边界:SSAO 看不见什么

SSAO 的全部信息都来自一帧深度缓冲,这决定了它有三处先天盲区:

  • 屏幕外的遮挡物它看不见——物体边缘的遮挡会被系统性低估。
  • 背向相机的几何不产生深度,也不参与遮挡。
  • 深度缓冲只有最近的那一层,多层遮挡被压成一层,厚遮挡体的内部结构丢失。

这三条都指向同一个结论:SSAO 是一个屏幕空间的近似,它便宜是因为它只用了一帧已有数据,它失真也是因为同一个原因。要跳出屏幕空间,就得回到真正的几何可见性——那正是 5.1 的解析 AO 和卷三的离线光追各自用不同代价换来的东西。

P 层:把确定性的流水线钉死

// file: lantern/core/src/lantern/cg_shade_05_ssao.ts
// tier: P
// verify: node --test 'core/test/cg_shade_05_2_ssao.test.ts'
// 5.2 的 P 侧:从深度缓冲逆投影重建视空间位置,以及去噪用的 3x3 盒式模糊,
// 两者都逐字节复现 Python 侧 sim/cg_shade_05_2_ssao.py。
//
// 量级上界(决定这里能用 Number):depth <= 2^16,FAR-NEAR = 7*2^16 <= 2^19.9,
// 乘积 <= 2^35.9;nx*w*tanf 的中间量 <= 2^18 * 2^18 = 2^36 < 2^53 —— 精确。
// 舍入:降精度一律 Math.floor(a*b/65536)(= Python 的 `>>16`,对负数同为向下取整),
// 禁用 `>>`(JS 的 `>>` 先把操作数转 int32,超过 2^31 的乘积会被静默截断)。

const ONE = 65536;

const fmul = (a: number, b: number): number => Math.floor((a * b) / ONE);

/** 从 NDC 深度逆投影重建视空间位置 P = (nx*w*tanf, ny*w*tanf, -w)。 */
export function reconstructPos(
  depth: Int32Array,
  covered: Uint8Array,
  w: number,
  h: number,
  near: number,
  far: number,
  tanHalf: number,
): Int32Array {
  const out = new Int32Array(w * h * 3);
  const span = far - near;
  for (let py = 0; py < h; py += 1) {
    const ny = ONE - Math.floor(((2 * py + 1) * ONE) / h);
    for (let px = 0; px < w; px += 1) {
      const i = py * w + px;
      if (covered[i] === 0) continue;
      const nx = Math.floor(((2 * px + 1) * ONE) / w) - ONE;
      const ww = near + Math.floor((depth[i] * span) / ONE);
      const o = i * 3;
      out[o] = fmul(fmul(nx, tanHalf), ww);
      out[o + 1] = fmul(fmul(ny, tanHalf), ww);
      out[o + 2] = -ww;
    }
  }
  return out;
}

/** 3x3 盒式模糊(边缘 clamp),输入输出均为 8 位单通道缓冲。 */
export function boxBlur3x3(src: Uint8Array, w: number, h: number): Uint8Array {
  const out = new Uint8Array(w * h);
  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      let acc = 0;
      for (let dy = -1; dy <= 1; dy += 1) {
        const yy = Math.min(h - 1, Math.max(0, y + dy));
        for (let dx = -1; dx <= 1; dx += 1) {
          const xx = Math.min(w - 1, Math.max(0, x + dx));
          acc += src[yy * w + xx];
        }
      }
      out[y * w + x] = Math.floor(acc / 9); // 9 邻域和 <= 2295,非负
    }
  }
  return out;
}

TypeScript 侧从同一份深度缓冲逆投影重建位置,并用同一套 3×3 盒式模糊去噪,两条 sha256 断言必须与 Python 侧逐字节相同。负例把逆投影的 $z$ 分量漏掉取负,位置缓冲的哈希立刻改变——证明这条判据确实在盯着实现,而不是在自我印证。

固定方向集 + 确定性旋转 + 盒式模糊,是一条完全确定的流水线:同一份深度缓冲,跑多少次都得到同一张 AO 图。这是它能进 P 层、能跨语言逐字节比对的前提。而 SSAO 终究只看得到屏幕上的东西——被屏幕边缘裁掉的遮挡物它一无所知,背向相机的几何也无从参与。这正是它比真 AO 便宜的地方,也是它失真的地方。

阅读导航:上一节:5.1 环境光遮蔽 · 下一节:5.3 环境贴图与球谐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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