屏幕空间:SSAO 与 SSR
全课骨架:特效 = 一大堆各自独立的小东西;而画完之后的图,本身还可以再当成数据算一遍。 上一课把"画完的那张图"当成数据,算的是颜色。可那张图里还存着两样东西 —— 深度与法线。 这一课就把这两样用起来:在图像平面上,去找"环境光被挡住了多少"(遮蔽)与"镜子里那份光从哪来"(反射)。 它的全部便宜与全部伪影都来自同一句话:图里没有的东西,就等于不存在。 保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 这条链本来就是"逐像素查图",与真机的 compute / 全屏三角是同一件事,只是并行度不同;读数真机可证 —— 三个手算定点、命中率 = AO、 方向表的分布矩、镜像点、还原性都是本页真算出来的数,每条都能拿笔核。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「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 是"够得着多远",不是个美学参数。
| 定点(画面里的位置) | 手算闭式 | 闭式值 | 场景真值(对几何求交) | 屏幕空间(只查深度图) |
|---|
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;所以判据⑥ 特意换到墙脚那个点去量。)
| 档 | 生成式 | E[μ] 闭式 | E[μ] 实测 | E[μ²] 闭式 | E[μ²] 实测 | 盒下 A(几何真值) | 盒下 A(屏幕空间) |
|---|
11.3 眼睛看不到的地方,屏幕里有
本段只增加一样东西:屏幕空间步进的步长 stride(采样半径与方向表沿用上一段)。
上一段找的是邻域里的遮挡(半径 1~3 世界单位)。可镜面反射要的那份光来自远处 ——
它不在邻域里,而在屏幕的另一处像素上。于是问题变成:沿着反射方向,在图像平面上走,什么时候算撞上了东西?
关键的一句来自 McGuire & Mara(JCGT 2014):1/w 与 Q/w 在屏幕空间是线性的,
所以"沿 3D 射线走、采样点落在相邻的 2D 像素上"等价于把这条射线在屏幕上透视校正地光栅化。
本页就是照这一条做的:只走屏幕上那条直线,深度用线性插值还原 —— 不这么做(在屏幕上等距走却在世界里不等距)就会漏检。
而"不命中"时取什么,正好是下一段的问题;本段先把它按住不动。
| 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)。
| 状态 | 走出屏幕 | 走到背面 | 被厚度挡住 | 不命中合计 | 与"不做 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 色调映射(否则看不出差别)。
本页用到的做法,出处在哪
- 为什么需要 AO —— Crytek 官方文档 Light Material Interaction Basics:直射光有方向 ⇒ 用 shadow map 就能解决遮蔽;间接光没有明确方向 ⇒ "the shadow map techniques don't work well here. This is where ambient occlusion provides better results";而纯 ambient 与法线无关、看起来 flat: cryengine.com/docs/static/engines/cryengine-3/categories/1638401/pages/1605644。 经典 SSAO 见 Martin Mittring (Crytek),Finding Next Gen – CryEngine 2,SIGGRAPH 2007 Course Notes: 球内预置样本 + 用一个过原点的随机平面做镜像反射来逐像素抖动, 以及"AO 只乘在 ambient 上效果最好"那句原话: realtimerendering.com/advances/s2007/…CryEngine2(Siggraph07 Course Notes).pdf。
- AO 的定义式、以及"没有解析解" —— Jorge Jimenez 等,Practical Realtime Strategies for Accurate
Indirect Occlusion(ATVI-TR-16-01,2016):
A(x) = (1/π)∫V⟨n,ω⟩⁺dω, 并明确说 "there is no analytic solution to ambient occlusion integral for arbitrary scenes"; 水平角形式(HBAO 的∫∫V|sinθ|、GTAO 把 V 二值化使内层可解析)与"多弹"(由 albedo 与 AO 拟合出的修正因子)也在这篇里: research.activision.com/publications/archives/atvi-tr-16-01…。 - 屏幕空间的边界(本课 11.4 的正题) —— Epic 官方文档:SSAO 一节 "This is 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.": dev.epicgames.com/documentation/en-us/unreal-engine/post-process-effects-in-unreal-engine、 dev.epicgames.com/documentation/en-us/unreal-engine/distance-field-ambient-occlusion-in-unreal-engine。 以及 Unity URP 的那两处"工程细节":半径是屏幕空间语义("takes samples of the normal texture within this radius")、噪声用 Interleaved Gradient Noise / Blue Noise、After Opaque 会 over-darkening: docs.unity3d.com/6000.0/Documentation/Manual/urp/ssao-renderer-feature-reference.html。
- 屏幕空间光线步进(11.3 的正题) —— Morgan McGuire & Michael Mara,
Efficient GPU Screen-Space Ray Tracing,JCGT 3(4),2014:把透视校正的 DDA 直线光栅化搬到深度缓冲上,
依据是
1/w与Q/w在屏幕空间线性;同文写清了三条限制: 深度缓冲表示不了高深度复杂度与视口之外、少采样会漏交、3D 等距步进 ≠ 2D 等距步进: jcgt.org/published/0003/04/04/。 工程形态见 Tomasz Stachowiak(EA/Frostbite),Stochastic Screen-Space Reflections, SIGGRAPH 2015 Advances in Real-Time Rendering:蒙特卡洛 + 多种降方差手段、 "through filtered importance sampling, we achieve physically correct, yet noisy results"、 跨邻域复用光线、时序重投影降方差: advances.realtimerendering.com/s2015/index.html。 厚度两难见 Unity HDRP 文档原文 "the SSR algorithm can not distinguish thin GameObjects from thick ones": docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@13.1/manual/Override-Screen-Space-Reflection.html。 - 反射方向与平面镜 —— 方向用 Khronos GLSL 规范的
reflect: "reflect returns the reflection direction calculated as I - 2.0 * dot(N, I) * N" (docs.gl/sl4/reflect); 平面镜成像d_i = d_o(像在镜后等距处)—— 反射射线与镜面的交点就等于解析镜像点, 本页 11.3 的"命中点误差"就是以它为靶子。