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 向 | 2257 | 1067 | 2264 |
| 旋转 4 向 | 1848 | 2599 | 1661 |
| 旋转 4 向 + 3×3 模糊 | 1126 | 1068 | 929 |
读法很清楚:旋转用"更吵"换"更准"(粗糙度翻倍,MAE 降 18%),模糊再把吵的部分收回来(粗糙度回到 1068,MAE 再降 39%)。三步缺一不可——只旋转不模糊,画面上是均匀的沙噪;只模糊不旋转,低频带纹会被模糊糊开但依然在,而且边缘会发糊。
常见伪影与对策
- 自遮挡:曲面把采样点误判成被自己挡住。对策是深度比较加 bias,bias 取深度量化步长的量级。
- 带纹(banding):固定方向集每像素同向,误差聚成低频弧线。对策是逐像素旋转方向集,把低频打散成高频。
- 光晕(halo):采样半径跨过了前景与背景的深度断层,背景被前景"遮蔽"。对策是限制半径、或按法线夹角给权重。
- 整体过暗:所有采样都被算成遮挡,AO 系统性偏低。对策是给半径加距离衰减,越远的采样贡献越小。
与 5.1 的对照
| 5.1 解析 AO | 5.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 环境贴图与球谐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。