← 图形原理 · 交互课 课题七 · 特效 · 第 5 课

屏幕空间:SSAO 与 SSR

全课骨架:特效 = 一大堆各自独立的小东西;而画完之后的图,本身还可以再当成数据算一遍。 上一课把"画完的那张图"当成数据,算的是颜色。可那张图里还存着两样东西 —— 深度与法线。 这一课就把这两样用起来:在图像平面上,去找"环境光被挡住了多少"(遮蔽)与"镜子里那份光从哪来"(反射)。 它的全部便宜与全部伪影都来自同一句话:图里没有的东西,就等于不存在。 保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 这条链本来就是"逐像素查图",与真机的 compute / 全屏三角是同一件事,只是并行度不同;读数真机可证 —— 三个手算定点、命中率 = AO、 方向表的分布矩、镜像点、还原性都是本页真算出来的数,每条都能拿笔核。

分段:
1.00 (世界单位) | 32
采样方式: | 抖动 + 模糊:
4 | 反射不命中时:
显示: | (两条链按需重算)

(脚本启动中…)

定点观察:先点「11.1 环境光没有方向」,再把「采样半径 R」从 0 拉到 3 → 盯住墙脚那条线与两面墙相交的那个内角 → R→0 时哪里都不遮(AO 全 1);R 长大之后墙脚稳定在 0.5、内角稳定在 0.75 —— 这两个数可以拿笔从定义式算出来。

本帧的读数

(脚本启动中…)

11.1 环境光没有方向,所以阴影贴图管不了它

本段只增加一样东西:半球采样半径 R(世界单位;上一段之后的画面与控件一字不改)。
上一课把"画完的图"当成数据算了一遍,可那一次只用了颜色。而那张图里还存着深度与法线 —— "环境光被挡住多少"这件事,恰好能从这两样量出来。
先说要紧的:直射光有方向,所以它的遮蔽用阴影贴图就能解决;而环境光没有方向, 阴影贴图对它无从下手(一个方向一张图?)。另一方面,常数环境光又与法线无关 —— 于是画面里所有角落一样亮,显得平。
这一段的做法就是 AO 的定义式本身:在法线半球里撒 N 个方向,看每个方向上有没有东西挡着, 被挡住的比例就是遮蔽系数。而"有没有东西挡着"这件事,本页只查深度图 —— 这就是"屏幕空间"这四个字的全部含义。
三个定点可以拿笔核(下节表①):贴墙脚 0.5、90° 内直角 0.75、空地 0。 ★ 但要先说清一件事:这三个数是"无限高墙 / 无限大房间"那个理想化的值,而 AO 的定义里有一个 采样半径 R —— 它只数"R 之内"的遮挡物。所以 R 小的时候实测值本来就该小: 实测 R = 1 时墙脚 0.422、内角 0.484;把 R 拉到 8,它们变成 0.492 / 0.723(对闭式差 0.2% / 3.6%)。 R 是"够得着多远",不是个美学参数。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 G-buffer:深度 + 法线 场景(全解析): 地板 y=0(镜面) 墙 x=−6 / z=−6 一个悬空盒子 采样半径 R = 1.0 ★ 本段只加 R // ① 半球方向表(余弦加权,切线空间) const him = diskToHemi(i, N); // z = sqrt(1 - u1) // ② 摆到世界空间:由该像素的法线定 T/B const d = TBN(n) * him; // ③ 只查深度图:这个样本点后面有没有东西? const wS = wOf(P + G.R * d); const wB = wBuf[ scr(wS).px ]; if (wB + BIAS < wS) occ++; // 被挡 ★ 只查图,不问场景 —— 这就是"屏幕空间" 三个手算定点: 贴墙脚 = 0.5 90° 内角 = 0.75 空地 = 0 AO 只乘在环境项上: c = albedo * (直接 + 环境*AO) (Crytek 原话:只乘 ambient)
定点观察:把「采样半径 R」从 0 拉到 3 → 盯墙脚那条线与内角 → R = 0 时什么都不遮(画面与"关掉 AO"逐位相同);R 长大之后暗带从墙脚一圈圈扩出来, 读数里的三个定点会一起长(实测 R = 0.2 → 8:墙脚 0.141 → 0.492、内角 0.000 → 0.723)—— 把 R 拉到 8,它们就贴到闭式 0.5 / 0.75 的 0.2% / 3.6% 以内; R 小的时候本来就该小,因为 AO 只数"够得着"的范围之内。 ★ 表① 把四个定点、以及"半径 R"与"墙高 h"两处扫描一起摊开:A 依赖遮挡物有多大、也依赖你愿意看多远。
表① AO 的手算定点与两个扫描。定义式 A = (1/π)∫ V⟨n,ω⟩⁺ dω, V = 1 表示那个方向被挡住 ⇒ A 越接近 1 越暗。贴墙脚一栏的闭式是 A = (1/2π)∫ h²cos²φ/(h²cos²φ+d²) dφ(h→∞ 或 d→0 时都回到 0.5) ★ 第 4 列是"场景真值"(沿半球逐方向对真几何求交),第 5 列是本页的屏幕空间估计(只查深度图)—— 两者之差就是"只看图"的代价
★ 下面两块扫描说的是同一件事的另一面:A 依赖"够得着多远"(半径 R), 也依赖遮挡物本身多大(墙高 h)—— 两个都不是美学参数
定点(画面里的位置)手算闭式闭式值场景真值(对几何求交)屏幕空间(只查深度图)

11.2 为什么 AO 看起来是"糊"的

本段只增加一样东西:方向表的给法(采样数 N 与抖动)(半径沿用上一段)。
上一段证明了"半球里数一数就能得到 AO"。可数多少个方向、方向怎么摆,决定了这个估计是准还是脏。
这里有一件容易想错的事:在半球上"均匀"撒方向,是会偏的。因为 AO 的定义式里有一个 ⟨n,ω⟩ (余弦项)—— 按立体角均匀撒,等于把这个权重也当成常数,于是忘了除 pdf。两个档的差别可以直接算出来: 余弦加权下 n·d 的均值是 2/3,按立体角均匀撒是 1/2。 实测(板正下方那个点、256 个方向):余弦加权 0.8047、均匀 + 按 pdf 加权 0.8052(两者都与网格积分 0.8096 差 0.6%), 而"均匀 + 直接数命中率" = 0.5586 —— 偏了 0.246。 注意这句话的准确说法:偏的不是"均匀采样",是"忘了除 pdf"(表② 最后一行就是补上那一项之后回到正确值)。
另一件事:每个像素都用同一组方向,就会留下固定的花纹;工业做法是逐像素把方向表转一个角度 (用交错梯度噪声给角),于是误差从"花纹"变成"噪声",再模糊一下就够了 —— 你看到的那层"糊", 是估计噪声被模糊掉了,不是这个物理量本身糊。实测噪声(同一像素 48 个相位):σ 从 N = 16 的 0.0345 降到 64 的 0.0110 再到 256 的 0.0043(×3.15 / ×2.57 —— 比随机撒点的 1/√N 还快, 因为方向表是黄金角低差异序列)。
★ 还有一件反直觉的:遮挡物对称的时候,转方向表根本不产生噪声。(本页的板正下方就是"绕天顶对称", 48 个相位的 σ 恒为 0;所以判据⑥ 特意换到墙脚那个点去量。)

输入 这个阶段在执行的规则(照抄件二真源码) 输出 采样数 N = 32 采样方式: 余弦加权(圆盘投影) 半球均匀(会偏) 抖动:交错梯度噪声 + 5×5 盒式模糊 ★ 本段只加方向表 // 余弦加权:圆盘上按面积取点,再投影上去 const u1 = (i + 0.5) / N, a = i * 2.399963; him = { r: Math.sqrt(u1), a, z: Math.sqrt(1 - u1) }; // 半球均匀(按立体角):z 均匀 ⇒ 直接 1-u1 him = { r: Math.sqrt(1 - z*z), a, z: 1 - u1 }; // 逐像素转一个角(交错梯度噪声,URP 同款) const ang = IGN(px, py) * 6.2831853; rotateAroundN(him, ang); ★ 同一组方向 ⇒ 固定花纹;转一个角 ⇒ 变成噪声 方向表的两个矩: 余弦 E[μ] = 2/3 均匀 E[μ] = 1/2 (E[μ²] = 1/2 与 1/3) 估计的抖动: σ ∝ 1/√N ★ 偏置与噪声是两回事
定点观察:把「采样数 N」从 8 拉到 128 → 盯同一块地板上的颗粒感 → 小 N 时画面是"脏"的(每个像素的估计值在跳),大 N 变干净 —— 但这不是"模糊",是同一个量的估计变准了 (实测 σ:N = 16 是 0.0345、256 是 0.0043,掉了 8 倍)。 再切到「半球均匀」档 → 整幅 AO 明显偏小:那是"忘了除 pdf"的偏置(实测偏 0.246),不是噪声 —— 偏置靠加大 N 是治不好的,只能把那一项补回去(表② 最后一行)。 ★ 表② 把方向表的两个矩、两种用法、以及"糊"的来源一起摊开:两个档的差别是算出来的,不是看出来的。
表② 方向表的分布矩(可直接对 N 个方向逐个求和核对)、同一张表的两种用法、以及"糊"的来源。 ★ 两个档的 E[μ] 是两条不同的算式:余弦加权在圆盘上按面积取点(u1 均匀), 均匀档让 z = 1−u1 均匀(等价于按立体角均匀)
★ 最后一行是这一段的正题:同一张均匀表,只要按 pdf 加权(补上 2μ 那一项),结果就回到正确值 —— 所以"均匀采样会偏"这句话的准确说法是"忘了除 pdf 才会偏"
★ 最后两列取"盒子正下方"那个定点:那里遮挡集中在天顶附近,正好把两种采样的差别放到最大
档生成式E[μ] 闭式E[μ] 实测E[μ²] 闭式E[μ²] 实测 盒下 A(几何真值)盒下 A(屏幕空间)

11.3 眼睛看不到的地方,屏幕里有

本段只增加一样东西:屏幕空间步进的步长 stride(采样半径与方向表沿用上一段)。
上一段找的是邻域里的遮挡(半径 1~3 世界单位)。可镜面反射要的那份光来自远处 —— 它不在邻域里,而在屏幕的另一处像素上。于是问题变成:沿着反射方向,在图像平面上走,什么时候算撞上了东西?
关键的一句来自 McGuire & Mara(JCGT 2014):1/w 与 Q/w 在屏幕空间是线性的, 所以"沿 3D 射线走、采样点落在相邻的 2D 像素上"等价于把这条射线在屏幕上透视校正地光栅化。 本页就是照这一条做的:只走屏幕上那条直线,深度用线性插值还原 —— 不这么做(在屏幕上等距走却在世界里不等距)就会漏检。
而"不命中"时取什么,正好是下一段的问题;本段先把它按住不动。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 地板像素(镜面) 法线 n = +y 步长 stride = 4 px 厚度 thick(命中窗口) 深度图 + 颜色图 ★ 本段只加 stride // ① 反射方向(Khronos GLSL 规范原式) const R = V - 2 * dot(N, V) * N; // ② 只走屏幕上那条线:1/w 在屏幕空间线性 for (let s = 0; s < M; s += stride) { const iw = mix(iw0, iw1, s / M); const w = 1 / iw, wB = wBuf[pix]; if (w > wB && w - wB < thick) { hit; break; } } ★ 命中就取那一像素的颜色 —— 整幅反射没有一个新光源 命中点该在哪: 镜像点(可闭式算) = 场景求交真值 步长越大: 倒影断/条带 命中误差变大 (表③ 逐档可核)
定点观察:把「步长 stride」从 1 拉到 16 → 盯镜面里那块板的倒影 → 步长大时倒影会"断"(射线一步跨过了交点)并出现条带;stride = 1 时倒影完整。 实测这一档到那一档:命中率 91.95% → 80.75%、命中点的镜像离视线 0.0313 → 0.1864 世界单位、 没命中的像素 16250 → 38856 —— "少采样会漏交"这句话是可以逐档量出来的。 ★ 表③ 把每档的命中率、镜像误差、漏检数一起摊开。
表③ 步长扫描。第 3 列是命中点的镜像离"眼 → 镜面点"那条视线的垂距(世界单位,取中位数)—— 平面镜成像 d_i = d_o 保证了正确命中点的镜像应当正好落在那条视线上,所以这个垂距就是"这一步走粗了多少"
第 4 列是"本该命中却没命中"的像素数(步长跨过了交点,或还没走到就出了屏) ★ 第 1 档(stride = 1)是这条链的"细步长参照",不是真机会用的档
stride(屏幕像素)命中率命中点误差(中位数)漏检像素数

11.4 图里没有的东西,就等于不存在

本段只增加一样东西:不命中时的回退(其余控件沿用上一段)。
上一段的步进在"射线一直留在屏内"时又准又便宜。可只要射线走出屏幕、走到背面、或被别的像素挡住 —— 那里根本没有数据。这不是算错,是没东西可算。
Epic 的文档把这句写得很直:SSAO 是"a screen space effect that is limited to information available within the current view";而在讲 Distance Field AO 时它给了正对照 —— "Unlike Screen Space Ambient Occlusion (SSAO), occlusion is computed from world-space occluders, so there are no artifacts from missing data off-screen." 一句话就是这一段的全部内容:屏幕空间的账,只结在屏幕上。
本段能做的就是如实处理"没数据":不命中时给一个回退值(黑,或者环境色),并把不命中的像素数报出来。 实测(正对机位、stride = 4):镜面像素 201893 个里命中 185118(91.7%),没命中 16775 个 —— 其中走出屏幕 13008、太厚 3767、背面 0。把相机横向转开 24°,走出屏幕那一栏就涨到 14161。
回退档的账也是能算的:"黑"档下这 16775 个像素的反射层逐位为 0(等于根本不做), 而"环境色"档等于凭空注入 16775 个像素 × 3 个通道的值 —— 本页把这一笔也算了(判据⑧ 对着手算值比,差 3.4e−8)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 三类"没数据": 走出屏幕 走到背面 被像素挡住(厚度) 回退档:黑 / 环境色 相机横向偏转 ★ 本段只加回退 // 不命中 = 没有数据(不是算错) if (!hit) { refl = G.fbEnv ? AMB_COLOR : BLACK; nMiss[ind].out += 1; // 如实计数 } ★ 屏幕里没有的那一份,回退补不出来 —— 只能"填一个别的东西" ★ 把"不命中"当 0 ⇒ 关掉反射时画面应与它逐位相同 (这条零差判据就是"回退真的没在造光") 本帧不命中: 走出屏幕 N₁ 走到背面 N₂ 被挡住 N₃ 相机转开之后: 三个数一起涨 (表④ 逐档可核)
定点观察:把相机横向转开(点「11.4」段按钮,机位会绕瞄准点转 24°), 或把回退从「黑」切到「环境色」→ 盯画面右侧那一片反射 → 屏幕里没有的那部分反射不是"算错",是"没数据":转开之后"走出屏幕"的像素数上升(实测 13008 → 14161), 画面里出现一整块死黑(或一整块假环境色)—— 这就是"屏幕空间"这条路的尽头。 ★ 表④ 把三类不命中的计数与还原性一起摊开:"黑"档逐位等于不做、"环境色"档的注入量能对上手算。
表④ 三类"没数据"的计数与回退。★ 最后一行的还原性是一条零差判据: 把反射关掉(贡献恒为 0)时,画面必须与"根本不做 SSR"逐位相同 —— 它同时封死"回退其实在偷偷造光"这件事
状态走出屏幕走到背面被厚度挡住不命中合计与"不做 SSR"比

这一课的位置

四段只做了一件事:把已经画好的那张图(颜色 + 深度 + 法线)当成整个场景。 遮蔽(11.1/11.2)问的是"这个像素的半球里有没有东西",反射(11.3)问的是"沿着反射方向走,屏幕上哪一处像素就是它"; 而 11.4 把这条路的边界写清楚:图里没有的东西,就等于不存在。
这也解释了工业上的那几件事:为什么 AO 只乘在环境项上(直射光有阴影贴图管)、 为什么 SSO/SSR 都要逐像素抖动加空间模糊(估计噪声,不是物理量糊涂)、 为什么引擎里有"屏幕空间"和"世界空间"两套(UE 的 Distance Field AO 就是世界空间那一套, 它换来的正是"没有屏外数据缺失的伪影")、以及为什么 Lumen 用屏幕空间追踪打底、再回退到别的办法。
本页那一格"如实说明":这条链是 CPU 逐像素跑的(示意 · 等价重现)—— 真机上它同样是"逐像素查图",只是并行度不同。三处要写清楚: ① 本页的采样半径是世界单位(Unity URP 里那个"跟着物体缩放的屏幕空间半径"是另一套语义); ② AO 一遍同步跑要 约 0.9 s(N = 32、960×600),所以交互走按行分帧(每帧 24 行,读者看到的是逐段补齐), 判据与表仍走"一次算完"的那一版;SSR 一遍约 0.21 s(stride = 4); ③ 本页不做时序积累(真机靠上一帧摊薄方差),只做 5×5 空间模糊。 另外:所有判据都在线性值上算,显示前才过 Reinhard 色调映射(否则看不出差别)。

本页用到的做法,出处在哪

运行时自检 等待脚本…