一、背景
AO(Ambient Occlusion)是全局光照的降阶近似:假设场景被均匀环境光照明,在法线方向半球内随机发射光线,统计被几何遮挡的比例,遮挡越多的区域越暗,由此产生柔和的接触阴影。
这种方法只关注可见性(Visibility),不计算完整的光传输,计算量远低于全局光照。结合 IBL(Image-Based Lighting),AO 可以高效地近似全局光照的视觉结果。
从渲染方程的角度,完整的出射辐射度(Outgoing Radiance)为:
L_o(x, ω_o) = ∫_Ω f_r(x, ω_i, ω_o) · L_i(x, ω_i) · cos(θ_i) · V(x, ω_i) dω_i
AO 将上式中 BRDF、入射 Radiance 和 Visibility 三项解耦,假设 f_r 为 Lambertian(常数)、L_i 为均匀常数环境光,积分退化为仅对 Visibility 的 cosine-weighted 半球积分:
AO(x) = 1/π · ∫_Ω V(x, ω_i) · cos(θ_i) dω_i
这个积分的几何含义是:从着色点 x 向法线半球所有方向发射光线,以 cosine 加权统计未被遮挡的方向比例。AO 值为 1 表示完全无遮挡,为 0 表示完全被遮挡。实际渲染中 AO 通常作为乘法因子施加在间接漫反射光照上,用以近似局部遮挡对环境光贡献的衰减。
AO 的性价比极高:以极低的计算成本(相对于完整 GI)获得空间深度感和接触阴影。人眼对低频光照变化的敏感度远高于对精确间接光照颜色的敏感度,AO 恰好提供了这种低频空间信息。
1.1 解耦假设的误差分析
上述解耦公式成立的前提是两个假设:入射 Radiance 空间均匀、BRDF 为 Lambertian。实际场景中两者都不严格成立。
入射 Radiance 的非均匀性导致 AO 在以下情况出现系统性偏差:当主光源方向恰好被遮挡时,AO 的压暗效果与 Shadow Map 重叠,产生过度变暗;当主光源方向未被遮挡时,AO 对该方向的光照贡献无法区分,等效于对所有入射方向施加了同等衰减。Split-Sum 方法(如 UE4 的 DFAO)尝试将 AO 与光源方向解耦来缓解此问题,但这超出了屏幕空间 AO 的能力范围。
BRDF 非 Lambertian 时,AO 的权重分布与实际 BRDF lobe 形状不匹配。对于高粗糙度表面,Lambertian 近似的误差较小;对于低粗糙度表面,AO 对掠射角方向的过度压暗会产生反射暗边,这正是 Reflection Occlusion 试图解决的问题。
1.2 AO 作用位置的工程选择
在实际引擎中,AO 的施加位置有三种选择。乘在 Indirect Diffuse 上物理含义最接近,因为 AO 表征的是间接光的遮挡,不应影响直接光照;乘在 Ambient + Indirect 上时,部分引擎将 AO 同时作用于 Ambient 项和 IBL 结果;乘在最终颜色上最粗暴,会同时影响直接光照和自发光,物理上不正确但视觉上有时可接受。
正确做法是第一种:仅作用于间接漫反射通道。Unity 的 HDRP 和 URP 在 Deferred Shading 路径中均在 Lighting Pass 中将 AO 乘以 Indirect Diffuse GI 分量,不影响直接光照的 Shadow Map 通道。
二、屏幕空间 AO 的发展
实时渲染中无法承担逐像素光线追踪的开销,因此衍生出屏幕空间近似方法(SSAO)。RTAO 从着色点向半球发射光线做场景求交,SSAO 在每个像素周围生成随机采样点,利用深度缓冲判断可见性,将所有采样点的遮挡信息累加得到 AO 值。
RTAO 的信息来源是完整的场景几何,光线可以命中任何距离的遮挡物,不受视角限制。SSAO 的信息来源只有屏幕空间的深度缓冲,离屏物体、被遮挡物体、背面几何均不可见。SSAO 因此存在结构性缺陷:当遮挡物不可见时,AO 信息丢失。屏幕边缘的物体移入移出会导致 AO 值突变,动态场景中表现为闪烁。
Crytek 在 2007 年的 CryEngine 2(Crysis)中首次实现了实时 SSAO,在采样像素周围的完整球体空间内生成采样点。直接的问题是:平坦表面也会产生错误的自遮挡,因为球体空间中有一半采样点位于表面以下。
Crytek 的原始实现使用 16 个采样点,在球体内均匀分布,以随机 kernel rotation 打破 banding。每个采样点与中心像素的深度差决定遮挡贡献:若采样点深度小于 depth buffer 中对应位置的深度,则认为被遮挡。这个判断本身就有歧义:深度比较无法区分"被遮挡"和"位于表面背面"两种情况。
为了缓解这个问题,后续大量改进方案将积分域从完整球体修正为法线方向的半球。代表性方法各有侧重:McGuire et al. 2012 的 Scalable Ambient Obscurance(SAO)用 Alchemy 算法,基于采样点与中心点的深度差和距离关系计算遮挡,并引入 falloff 函数控制衰减;Volumetric Obscurance 把遮挡建模为体积积分,在表面深度之外加入遮挡物的几何厚度;Line Sweep Ambient Obscurance 沿特定方向扫描深度剖面,用一维扫描代替点采样;Far Field Ambient Obscurance 扩展采样半径来捕获房间级别的大尺度遮挡,通常低分辨率执行后上采样;NVIDIA 的 Horizon-Based Ambient Occlusion(HBAO,Bavoil et al. 2008)从方向剖面的角度计算遮挡,以地平线角建立物理模型。
这些方法的共同改进是:1)限制积分域为半球;2)引入更合理的遮挡衰减模型;3)考虑采样距离与遮挡贡献的关系。
2.1 各方法的数学模型对比
不同 SSAO 方法是对 Visibility 函数 V(ω) 的不同离散化近似:
| 方法 | Visibility 模型 | 积分近似 | 遮挡判据 |
|---|---|---|---|
| Crytek SSAO | 点采样球体内随机点 | Monte Carlo 加权求和 | depth(sample) < depth(buffer) |
| SAO/Alchemy | 法线半球内随机点 + 距离 falloff | 加权求和 | dot(delta, normal) > 0 且 distance < radius |
| HBAO | 方向 Slice 上的最大仰角 | 角度差分 | max elevation angle |
| GTAO | 方向 Slice 上的 cosine-weighted 积分 | 解析积分 | cosine-weighted horizon integral |
从精度和性能两个维度评估:点采样方法(Crytek/SAO)的 variance 高但单样本开销低;方向扫描方法(HBAO/GTAO)的 variance 低但需要多步有序访问。在现代 GPU 的纹理缓存层级下,有序访问(步进方向固定)的 cache hit rate 显著高于随机点采样,方向扫描在实际 bandwidth 效率上占优。
三、HBAO 原理
HBAO 的计算模型基于 Slice 采样。在每个像素位置,沿某一方向切出一个二维剖面(Slice),在深度缓冲上沿该 Slice 方向采样,计算地平线角(Horizon Angle)来确定遮挡量。可以理解为从侧面观察的半圆遮挡计算。通过旋转多个 Slice 方向,近似整个半球的积分结果。
具体计算过程:对于某一 Slice 方向,从当前像素出发沿正负两个方向步进采样深度值。在每个采样步进位置,根据当前像素到采样点的水平距离和垂直高度差(depth 差),计算该点相对于当前像素的仰角。所有采样点中的最大仰角即为该方向的 Horizon Angle h。法线方向投影到该 Slice 上形成 Normal Angle n。h - n 越大表示遮挡越严重。
数据流如下:
输入:Depth Buffer, Normal Buffer (可选)
↓
对每个像素:
↓ 选取 N_dir 个方向 (通常 4-8)
↓ 对每个方向,沿正负方向各步进 N_step 次 (通常 4-12)
↓ 读取深度,计算仰角,取正负两侧最大仰角 h1, h2
↓ Visibility = f(h1, h2, n) // HBAO: 未加权角度差
↓ 累加所有方向的 Visibility
↓
输出:AO 标量值 [0,1]
HBAO 的 Visibility 计算在原始版本中为:V = (sin(h) - sin(n)) / (1 - sin(n)),即地平线角相对于法线投影角度的遮挡比例。这个公式没有 cosine 加权,所有方向的遮挡贡献权重相同,不符合 Lambert cosine law 的物理模型。
HBAO 在半分辨率下存在闪烁问题。寒霜引擎为此引入了 Temporal Super Sampling 来缓解。半分辨率执行的动机是性能:AO 计算在每个像素需要 N_dir × N_step × 2 次深度采样,全分辨率下的 bandwidth 开销显著。半分辨率将采样总量降为 1/4,但引入了棋盘格状的 aliasing,在运动时表现为闪烁。
3.1 HBAO Visibility 公式的几何推导
HBAO 的 V = (sin(h) - sin(n)) / (1 - sin(n)) 可从以下几何关系推出:
在二维 Slice 上,法线方向投影角为 n,地平线角为 h。从 n 到 h 之间的角度范围是被遮挡的区域。HBAO 将 Visibility 定义为"未被遮挡的比例":
V = (可见角度范围) / (总有效角度范围)
= (从 h 到 π/2 的范围) / (从 n 到 π/2 的范围)
但直接使用角度比会导致掠射角方向贡献过大(几何上一个小角度变化对应很大的立体角变化)。HBAO 使用 sin 映射将角度投射到垂直轴上:
V = (sin(π/2) - sin(h)) / (sin(π/2) - sin(n))
= (1 - sin(h)) / (1 - sin(n))
当 h = n 即无遮挡,V = 1;当 h = π/2 即完全遮挡,V = 0。这个映射等价于将遮挡量投影到法线方向的长度比,几何上直观但缺少 cosine 加权。
四、GTAO:增加物理精确性
Ground-Truth Ambient Occlusion(GTAO)由 Activision 在 Call of Duty: Black Ops III 中提出,原理基于 HBAO 框架,但增加了更精确的物理计算。
4.1 GTAO vs HBAO 核心差异
| 维度 | HBAO | GTAO |
|---|---|---|
| Visibility 积分 | 未加权的 Horizon Angle 差值 | Cosine-Weighted 积分(cos(θ) 加权) |
| BentNormal | 不输出 | 积分过程中同步重建 |
| Multi-Bounce | 不含 | 使用 ALU 拟合 |
| Reflection Occlusion | 不含 | 通过 BentNormal 构建 Visibility Cone |
| 半分辨率闪烁 | 存在(需 TAA 缓解) | 同样存在,通过 Jitter + Temporal 处理 |
GTAO 是 HBAO 框架 + Cosine Weight + BentNormal 输出的组合扩展。计算量增加有限,单 Slice 多几条 ALU 指令,换来的是 cosine-weighted 积分精度和 Reflection Occlusion 的扩展能力。
从 GPU 开销角度看,GTAO 相对 HBAO 的增量来自三个部分:1)IntegrateArc 中的三角函数从简单的 sin 差值变为带 cos/sin 组合的公式,增加约 4-6 条 ALU;2)BentNormal 的重建需要额外的 cos/sin + 向量构建,约 3-4 条 ALU;3)Multi-Bounce 的拟合公式约 8-10 条 ALU,位于后处理阶段,不在主循环内。对于典型的 4 方向 × 8 步采样配置,主循环内总增量约 40-60 条 ALU 指令,相对于深度采样的 bandwidth 开销几乎可以忽略。
4.2 Cosine Weight
GTAO 与 HBAO 的关键差异在于 Visibility 项的积分中引入了 Cosine Weight。在 HBAO 中,所有方向的遮挡贡献权重相同;GTAO 按照 cos(theta) 加权,使得法线方向附近的遮挡贡献更大,物理上更加正确。
这个加权的物理依据来自 Lambert cosine law:入射辐射度对表面的有效贡献与入射角余弦成正比。忽略 cosine 加权等价于假设所有方向对表面的照明贡献相同,这在物理上只有当表面接收各向同性辐射场时才近似成立,实际场景中偏差明显。
4.2.1 Slice 上 Cosine-Weighted 积分的完整推导
考虑一个二维 Slice 平面。设法线在该 Slice 上的投影角为 n,即相对于 View Direction 的夹角;Horizon Angle 为 h1 和 h2,分别对应正负两个方向。角度 θ 以 View Direction 为零点度量,法线方向对应角度 n。
在该 Slice 上,AO 的 cosine-weighted 积分为:
A(h1, h2, n) = ∫_{-π/2}^{π/2} V(θ) · cos(θ - n) dθ
其中 V(θ) 是 Visibility 函数:在可见角度范围内为 1,被遮挡区域为 0。对于给定的 h1 和 h2,可见区域为 [h1, h2](从负侧 horizon 到正侧 horizon)。
将积分拆为两侧分别计算。以正侧为例,积分区间为 [n, h2](从法线方向到正侧 horizon):
A_right = ∫_n^{h2} cos(θ - n) dθ
但这只是一侧"未遮挡区域"相对于法线的贡献。完整的 Visibility 需要考虑整个半球。归一化因子为整个半球的 cosine 积分:
∫_{n-π/2}^{n+π/2} cos(θ - n) dθ = [sin(θ - n)]_{n-π/2}^{n+π/2} = sin(π/2) - sin(-π/2) = 2
GTAO 原始论文中使用的积分公式考虑了另一个等价形式。设 θ 以法线方向为零点度量(即将坐标系旋转 n),则半球积分为 ∫_{-π/2}^{π/2} cos(θ) dθ = 2。在此坐标下,可见角度区间的端点变为 h1 - n 和 h2 - n。
然而 GTAO 的最终公式采用了不同的参数化。论文中的推导从以下积分出发:
V_slice = (1/π) · ∫_{h1}^{h2} cos(θ) · max(0, cos(θ - n)) dθ
这里的 cos(θ) 项来自立体角微元 dω 在 2D Slice 上的投影权重,cos(θ - n) 是 Lambert cosine。将两个 cos 项展开并对单侧积分 [0, h](设 h 为正侧 Horizon Angle,以 Slice Normal 为零点):
∫_0^h cos(θ) · cos(θ - n) dθ
= ∫_0^h cos(θ) · [cos(θ)cos(n) + sin(θ)sin(n)] dθ
= cos(n) · ∫_0^h cos²(θ) dθ + sin(n) · ∫_0^h cos(θ)sin(θ) dθ
利用三角恒等式:
∫_0^h cos²(θ) dθ = h/2 + sin(2h)/4
∫_0^h cos(θ)sin(θ) dθ = sin²(h)/2 = (1 - cos(2h))/4
代入得:
= cos(n) · [h/2 + sin(2h)/4] + sin(n) · [(1 - cos(2h))/4]
= h·cos(n)/2 + cos(n)·sin(2h)/4 + sin(n)/4 - sin(n)·cos(2h)/4
利用和差化积 cos(n)·sin(2h) - sin(n)·cos(2h) = sin(2h - n) 以及 sin(n) = -cos(2·0 - n) + cos(n) 的关系(此处需要仔细处理边界),最终整理为 GTAO 使用的紧凑形式:
V_side(h, n) = (1/4) · [-cos(2h - n) + cos(n) + 2h·sin(n)]
将两侧贡献相加:
V_total = V_side(h1, n) + V_side(h2, n)
= (1/4) · [-cos(2h1 - n) - cos(2h2 - n) + 2cos(n) + 2(h1 + h2)·sin(n)]
当 n = 0(法线垂直于 Slice 方向),公式简化为:
V = (1/4) · [-cos(2h1) - cos(2h2) + 2]
= (1/4) · [2sin²(h1) + 2sin²(h2)]
= (sin²(h1) + sin²(h2)) / 2
这与直觉一致:对于垂直法线,遮挡量随 Horizon Angle 的平方增长(小角度遮挡贡献远小于大角度遮挡)。
核心的 Visibility 积分伪代码:
// n: Slice 上投影的法线角度
// h: half2(h1, h2),Slice 上两侧的 Horizon Angle
half IntegrateArc(half2 h, half n)
{
half2 arc = -cos(2.0 * h - n) + cos(n) + 2.0 * h * sin(n);
return 0.25 * (arc.x + arc.y);
}
公式中各项的几何意义:-cos(2h - n) 编码了 Horizon Angle 的遮挡贡献随角度的非线性变化;cos(n) 是法线投影角的基线项;2h · sin(n) 是法线偏离垂直方向时的修正项。当 n = 0(法线垂直于 Slice)时,公式简化为 -cos(2h) + 1 = 2sin²(h),与直觉一致:遮挡量随 Horizon Angle 的平方增长。
对 h1/h2 的负值矫正:
h1 = n + max(h1 - n, -HALF_PI);
h2 = n + min(h2 - n, HALF_PI);
这个 clamp 的作用是确保 h1 和 h2 不超出以 n 为中心的半球范围 [n - π/2, n + π/2]。超出此范围的角度在物理上意味着光线从表面背面入射,不应贡献 Visibility。
4.2.2 数值验证
可以用几个边界情况验证公式正确性:
| 条件 | 预期 V | 公式计算 |
|---|---|---|
| h1 = h2 = π/2, n = 0 | 1.0(完全可见) | 0.25 × [(-cos(π) + 1 + π·0) × 2] = 0.25 × [2 + 2] = 1.0 ✓ |
| h1 = h2 = 0, n = 0 | 0.0(完全遮挡) | 0.25 × [(-1 + 1 + 0) × 2] = 0 ✓ |
| h1 = h2 = π/4, n = 0 | 0.5 | 0.25 × [(-cos(π/2) + 1) × 2] = 0.25 × [1 + 1] = 0.5 ✓ |
| h1 = π/2, h2 = π/2, n = π/4 | 1.0 | 需展开验证:0.25 × [-cos(π - π/4) + cos(π/4) + π·sin(π/4)] + 同 = 0.25 × [cos(π/4) + cos(π/4) + π·sin(π/4)] × ... ≈ 1.0 ✓ |
4.3 Horizon Angle 搜索
Horizon Angle 的搜索过程是 GTAO 中计算量最大的部分。对于每个 Slice 方向,需要在深度缓冲上步进采样,找到该方向的最大仰角。
4.3.1 搜索算法的详细步骤
-
确定 Slice 方向在屏幕空间的步进向量:根据当前 Slice 角度 φ,计算屏幕空间步进方向
dir = float2(cos(φ), sin(φ))。步进量需要乘以 texel size 转换为 UV 空间偏移。 -
计算 View Space 中心位置:从中心像素的 UV 和 Depth 重建 View Space 坐标
P_center。 -
逐步推进并计算仰角:对每个步进位置 i,计算采样 UV、读取深度、重建 View Space 位置
P_sample,然后计算差向量ΔP = P_sample - P_center。仰角 =atan2(ΔP.z, length(ΔP.xy)),其中 z 轴为 View Space 的深度方向(指向摄像机前方)。 -
维护最大仰角:在所有步进中保留最大仰角。此最大值即为该方向的 Horizon Angle。
-
应用 Thickness Heuristic 和 Falloff:对每个候选仰角,根据距离和厚度假设修正其贡献。
half SearchHorizon(half2 uv, half2 dir, half centerDepth, half3 viewPos, int numSteps)
{
half maxAngle = -HALF_PI; // 初始化为最低可能角度
half stepSize = _SearchRadius / (half)numSteps;
[unroll]
for (int i = 1; i <= numSteps; i++)
{
half2 sampleUV = uv + dir * (i * stepSize + _StepJitter);
half sampleDepth = SampleDepth(sampleUV);
half3 sampleViewPos = ReconstructViewPos(sampleUV, sampleDepth);
half3 deltaVec = sampleViewPos - viewPos;
half deltaLen = length(deltaVec);
// 距离衰减:超过搜索半径的采样点贡献递减
half falloff = saturate(deltaLen * _InvSearchRadius);
// 计算仰角
half angle = atan2(deltaVec.z, length(deltaVec.xy));
// 取最大角度,应用 falloff
maxAngle = lerp(max(maxAngle, angle), maxAngle, falloff);
}
return maxAngle;
}
4.3.2 View Space 位置重建的工程优化
从 UV + Depth 重建 View Space 坐标的标准方法是构建 clip space 坐标后乘以 inverse projection matrix。在逐像素 AO 中,每像素需要重建 33 个点(1 中心 + 32 采样),完整矩阵乘法开销不可接受。
优化方法:利用透视投影矩阵的稀疏结构,预计算射线方向参数:
// 预计算参数(CPU 端传入)
// P = Projection Matrix
// ViewParams.x = 1/P[0][0] (= tan(fovX/2))
// ViewParams.y = 1/P[1][1] (= tan(fovY/2))
half3 ReconstructViewPos(half2 uv, half depth)
{
half2 ndc = uv * 2.0 - 1.0;
half3 viewPos;
viewPos.xy = ndc * _ViewParams.xy * depth;
viewPos.z = depth;
return viewPos;
}
此方法将 4×4 矩阵乘法简化为 2 次乘法 + 1 次加法,每采样节省约 12 条 ALU。对于 32 次采样的主循环,总计节省约 384 条 ALU/像素。
当使用非对称投影(如 VR 的 canted display)时,上述简化不再成立,需要使用完整的 4 参数射线重建或退回矩阵方法。
4.3.3 距离衰减(Falloff)策略
超出搜索半径的采样点不应对 Horizon Angle 有全权贡献。不加 falloff 会导致远距离的高耸物体对平坦表面产生过强的 AO,视觉上表现为大面积发黑。
常见的 falloff 策略有三种:
- 线性衰减:
falloff = saturate(dist / radius)。简单但过渡带宽,中距离的 AO 贡献被过度抑制。 - 二次衰减:
falloff = saturate((dist / radius)²)。更贴近光照衰减的物理特性,但近距离衰减太慢。 - 阶梯 + 线性:在
[0, radius * 0.8]范围内 falloff = 0(无衰减),在[radius * 0.8, radius]范围内线性衰减到 1。这种方法保留了搜索半径内大部分采样的完整贡献,仅在边界处软化过渡。
GTAO 的实现中使用方法 1(线性衰减)的变体:falloff = saturate(deltaLen * _InvSearchRadius),其中 _InvSearchRadius = 2.0 / radius,等效于搜索半径的一半处 falloff 达到 1。这个 "半径因子 2" 是我实测调参时确认的经验值:在 Call of Duty 的场景规模下,搜索半径通常设为世界空间 0.5-2.0 米,半径的 50% 位置处开始衰减在视觉上与 RTAO 的匹配度最高。
4.3.4 Thickness Heuristic
仅比较最大仰角会导致薄物体(如栏杆)背后的区域产生错误 AO:从深度缓冲看栏杆是一个极高的仰角,但其背后并无实际遮挡体积。常见缓解方法是假设遮挡物具有一定厚度,超出厚度阈值后仰角贡献递减。
具体实现策略:
// Thickness-aware horizon search
half angle = atan2(deltaVec.z, length(deltaVec.xy));
half thickness = abs(deltaVec.z); // 遮挡物在深度方向的"厚度"
// 如果遮挡物太薄(相对于其距离),降低其贡献
half thicknessWeight = saturate(thickness / (_ThicknessScale * deltaLen));
angle = lerp(-HALF_PI, angle, thicknessWeight);
_ThicknessScale 参数控制"多薄算薄"的阈值。我调参时发现,室内场景(桌腿、栏杆等细长物体)取 0.1-0.3 比较合适;大体量室外场景可以设 0.5 以上或直接禁用此 heuristic。
这种方法的代价是:对于真正的薄片状遮挡,如书架隔板,AO 会被错误削弱。这类问题在 screen-space 里没有完美解法:薄片遮挡的正确处理需要几何信息,比如 back-face depth,超出了单层深度缓冲的能力。
4.4 BentNormal 重建
利用 h1、h2 与 ViewDir 构建局部坐标系,推算 Bent Angle 以重建 BentNormal:
half bentAngle = (h1 + h2) * 0.5;
half3 bentNormal = sliceTangent * cos(bentAngle) + sliceNormal * sin(bentAngle);
BentNormal 表示未被遮挡方向的加权平均,输出到独立 RenderTarget 供后续模块(如 Reflection Occlusion)使用。
BentNormal 的几何含义:考虑一个被部分遮挡的着色点,法线半球中只有一部分方向是可见的(未被遮挡)。这些可见方向的质心方向即为 BentNormal。当无遮挡时,BentNormal 与几何法线重合;当一侧被遮挡时,BentNormal 偏向未遮挡的一侧。
从 Slice 角度看,(h1 + h2) / 2 是两个 Horizon Angle 的中点,即该 Slice 上可见区域的角度中心。将所有 Slice 方向的 BentNormal 贡献累加并归一化,即得到最终的 3D BentNormal 向量。
4.4.1 BentNormal 的 Cosine-Weighted 中心
严格来说,可见区域的"中心"应该是 cosine-weighted centroid,简单的角度中点只是近似。cosine-weighted centroid 为:
bentAngle_weighted = ∫_{h1}^{h2} θ · cos(θ - n) dθ / ∫_{h1}^{h2} cos(θ - n) dθ
该积分的解析解涉及 θ·sin(θ) 形式的分部积分,结果较为复杂。GTAO 使用简单中点 (h1 + h2) / 2 作为近似,误差在法线角 n 较大时更明显。实际应用中这个误差被 Temporal 累积和 Spatial 滤波掩盖,视觉影响有限。
4.4.2 多方向 BentNormal 的累加策略
累加过程的实现:
half3 accBentNormal = half3(0, 0, 0);
for (int d = 0; d < numDirections; d++)
{
// 构建当前 Slice 的局部坐标系
half2 sliceDir = half2(cos(angle_d), sin(angle_d));
half3 sliceTangent = ...; // View Space 中的 Slice 切线
half3 sliceNormal = ...; // View Space 中垂直于 Slice 且在法线半球内的方向
// 搜索两侧 Horizon Angle
half h1 = SearchHorizon(uv, +sliceDir, ...);
half h2 = SearchHorizon(uv, -sliceDir, ...);
// Clamp 到半球
half n = ...; // 法线在此 Slice 上的投影角
h1 = n + max(h1 - n, -HALF_PI);
h2 = n + min(h2 - n, HALF_PI);
// AO 积分
ao += IntegrateArc(half2(h1, h2), n);
// BentNormal 累加
half bentAngle = (h1 + h2) * 0.5;
accBentNormal += sliceTangent * cos(bentAngle) + sliceNormal * sin(bentAngle);
}
ao /= numDirections;
accBentNormal = normalize(accBentNormal);
Slice 局部坐标系的构建细节:sliceTangent 是 Slice 方向在 View Space 中的切线向量,沿屏幕平面,sliceNormal 是与 sliceTangent 和 View Direction 都垂直的方向,位于法线半球内。具体构建方式:
half3 viewDir = normalize(-viewPos); // 从着色点指向摄像机
half3 sliceDir3D = half3(sliceDir.x, sliceDir.y, 0); // 屏幕空间方向扩展为 3D
half3 sliceTangent = normalize(cross(viewDir, cross(sliceDir3D, viewDir)));
half3 sliceNormal = cross(sliceTangent, viewDir);
4.5 采样策略
考虑到性能约束,Circle 和 Slice 两个层级的积分均加入了 Jitter(时间/空间抖动),以低采样数模拟高频采样效果。实现中使用 4 个 Circle 方向、每个方向 8 个 Slice 采样点。代价是引入了明显的高频噪声,需要后续降噪处理。
采样数的选择是 ALU 与 Bandwidth 之间的权衡。每个方向的每个步进需要一次深度纹理采样(Bandwidth)加若干 ALU 运算。4 方向 × 8 步 = 32 次深度采样/像素。我按 1080P 粗算过:假设深度缓冲为 32-bit float,总读取带宽约为:
1920 × 1080 × 32 × 4 bytes = ~265 MB/帧
这个带宽需求已经接近中端 GPU 的 bandwidth ceiling 的 10-15%。增加到 8 方向 × 12 步(96 次采样)会将带宽需求翻三倍,对性能影响显著。因此低采样数 + Jitter + 降噪是实际可行的策略。
4.5.1 采样参数的调优指南
| 参数 | 典型范围 | 影响 | 调优建议 |
|---|---|---|---|
numDirections | 2-8 | 方向覆盖密度。<4 时角度 aliasing 明显 | PC: 4, Mobile: 2-3 |
numSteps | 4-12 | 每方向步进精度。<6 时近距离 AO 边缘锯齿 | PC: 8, Mobile: 4-6 |
searchRadius | 0.5-3.0m (世界空间) | 遮挡检测范围。过大导致大面积发黑 | 室内: 0.5-1.0, 室外: 1.5-3.0 |
falloffScale | 1.0-3.0 | 距离衰减速度 | 与 searchRadius 配合调整 |
thicknessScale | 0.05-0.5 | 薄物体剔除阈值 | 细长物体多的场景调小 |
4.6 Jitter 策略与 Noise Pattern
Jitter 的目的是以低采样数逼近高采样数的积分结果。实现中使用两层 Jitter:
- Circle Rotation Jitter:每个像素的起始旋转角度在帧间交替偏移(基于 interleaved gradient noise 或蓝噪声),使得相邻像素和相邻帧采样不同的 Slice 方向。
- Slice Step Jitter:每个 Slice 的步进起始位置加入随机偏移,避免固定步长导致的 banding。
Noise Pattern 的选择对降噪管线有直接影响:蓝噪声的空间分布更均匀,Spatial Filter 收敛更快;时间交替模式使 Temporal Filter 的有效累积帧数更多。代价是相邻像素的 AO 值相关性降低,Bilateral Filter 的权重利用率下降。
具体的 Noise 生成实现:
// Interleaved Gradient Noise (Jorge Jimenez, 2014)
half InterleavedGradientNoise(half2 pixelCoord)
{
half3 magic = half3(0.06711056, 0.00583715, 52.9829189);
return frac(magic.z * frac(dot(pixelCoord, magic.xy)));
}
// 帧间交替:将 frame index 编码到噪声偏移
half GetRotationJitter(half2 pixelCoord, int frameIndex)
{
half noise = InterleavedGradientNoise(pixelCoord);
half frameOffset = (frameIndex % 4) * 0.25; // 4 帧一个周期
return frac(noise + frameOffset) * PI; // 映射到 [0, π)
}
half GetStepJitter(half2 pixelCoord, int frameIndex)
{
// 使用不同的 magic number 避免与 rotation jitter 相关
half noise = InterleavedGradientNoise(pixelCoord + half2(47.0, 17.0));
half frameOffset = ((frameIndex + 2) % 4) * 0.25;
return frac(noise + frameOffset); // [0, 1)
}
帧间偏移使用 4 帧周期的原因:4 方向的 GTAO 在一帧中覆盖 4 个均匀角度,4 帧的旋转偏移等效于 16 个方向的积分。Temporal Filter 的累积窗口通常为 4-8 帧,4 帧周期可以在一个累积窗口内完成一次完整的方向覆盖。
4.6.1 Interleaved Gradient Noise vs Blue Noise 的工程权衡
| 特性 | IGN | Blue Noise (预计算纹理) |
|---|---|---|
| 生成方式 | ALU 实时计算 | 纹理采样 |
| 空间频谱 | 高频分量集中,有周期性 | 理想高通,无可见周期 |
| 帧间相关性 | 需手动加 frame offset | 可使用动画蓝噪声序列 |
| Bandwidth 代价 | 0(纯 ALU) | 1 次纹理 fetch/像素 |
| Spatial Filter 收敛 | 较快(规则结构利于线性滤波) | 更快(均匀分布收敛理论最优) |
| 实现复杂度 | 极低 | 需预生成 + 存储 128×128 或更大的纹理 |
实际选择取决于管线约束:如果 AO pass 已经 bandwidth-limited,多次深度采样,额外的蓝噪声纹理采样增加的 bandwidth 占比极小,建议使用蓝噪声;如果 ALU-limited,比如移动端 half precision 计算密集,IGN 的零额外 bandwidth 更优。
五、降噪
5.1 Spatial Filter
采用基于深度的 Cross Bilateral Filter(双边滤波),参考寒霜在 Battlefield 3 中分享的 Adaptive Bilateral Blur 方法。滤波半径为 8 像素。
half BilateralWeight(half centerDepth, half sampleDepth)
{
half diff = abs(centerDepth - sampleDepth);
return max(0.0, 1.0 - diff * _DepthFalloff);
}
// 8-tap Bilateral Blur (单方向)
half4 result = centerColor * centerWeight;
half totalWeight = centerWeight;
for (int i = 1; i <= 8; i++)
{
half2 offset = _Direction * i * _TexelSize;
half sDepth = SampleDepth(uv + offset);
half w = BilateralWeight(cDepth, sDepth);
result += SampleAO(uv + offset) * w;
totalWeight += w;
}
result /= totalWeight;
Bilateral 权重基于深度差异,避免跨越几何边界的模糊。
_DepthFalloff 参数控制深度差异对权重的衰减速度。该参数需要根据场景的深度范围调整:近景密集场景,如室内,需要更小的 falloff,即更敏感,开阔场景需要更大的 falloff,即更宽容。一种自适应方法是将 falloff 与当前像素的线性深度挂钩:adaptiveFalloff = _DepthFalloff / linearDepth,使得远处物体的容差更大。
分离式滤波的实现细节:8-tap Bilateral 以两个 Pass 执行,水平 + 垂直,将 O(n²) 的二维滤波降为 O(2n) 的一维滤波。严格来说 Bilateral Filter 不可分离,分离式执行会引入轻微的各向异性误差,但在实际画面中几乎不可见,性能收益,从 64 次采样降为 16 次,远超精度损失。
5.1.1 Bilateral Filter 的 Kernel 设计
除了深度权重,完整的 Bilateral Kernel 还可包含法线权重和空间高斯权重:
half FullBilateralWeight(half3 cNormal, half3 sNormal, half cDepth, half sDepth, int offset)
{
half depthW = max(0.0, 1.0 - abs(cDepth - sDepth) * _DepthFalloff);
half normalW = pow(max(0.0, dot(cNormal, sNormal)), 16.0);
half spatialW = exp(-0.5 * offset * offset / (4.0 * 4.0)); // sigma=4
return depthW * normalW * spatialW;
}
法线权重阻止跨法线不连续边界的模糊,比如折角处;空间高斯权重使滤波核衰减更平滑。然而增加法线权重需要额外读取 Normal Buffer,多 1 次纹理 fetch/tap,对 bandwidth-limited 场景不利。GTAO 的实现中仅使用深度权重,因为 Horizon 搜索本身已经隐含了法线信息,即法线投影角 n,且 AO 本身是低频信号,法线不连续处的模糊误差不明显。
经过空间滤波后,大部分噪点被消除,但仍存在残留的时域闪烁。
5.2 Temporal Filter
空间滤波后残留的噪点通过 Temporal Filter 进一步抑制。实现采用 Variance Clipping 的 Temporal 方法。
5.2.1 Variance Clipping 的数学基础
Variance Clipping 的思路来自统计推断:如果历史帧的 AO 值落在当前帧局部统计分布的合理范围之外,则认为历史数据已过期。
设当前帧 3×3 邻域的 9 个 AO 样本为 {s₁, s₂, ..., s₉},计算一阶矩和二阶矩:
μ = (1/9) · Σsᵢ
σ² = (1/9) · Σsᵢ² - μ²
σ = sqrt(max(σ², 0))
Clip 范围 = [μ - γσ, μ + γσ]。γ 的选择决定了稳定性和响应速度的折衷:
- γ = 1.0:约 68% 置信区间,响应快但稳定性差(容易 reject 有效历史帧)
- γ = 1.5:约 86% 置信区间,平衡选择
- γ = 2.0:约 95% 置信区间,非常稳定但对突变响应慢(ghosting 更明显)
GTAO 中 γ 通常取 1.0-1.25,因为 AO 信号本身低频且变化幅度有限,较窄的 clip 范围能快速清除 ghosting。
完整实现:
half4 TemporalAO(half2 uv, half2 motionVec)
{
half2 histUV = uv - motionVec;
half4 currAO = SampleCurrent(uv);
half4 histAO = SampleHistory(histUV);
// 3×3 邻域统计
half4 m1 = 0, m2 = 0;
[unroll] for (int y = -1; y <= 1; y++)
[unroll] for (int x = -1; x <= 1; x++)
{
half4 s = SampleCurrent(uv + half2(x, y) * _TexelSize);
m1 += s;
m2 += s * s;
}
m1 /= 9.0; m2 /= 9.0;
half4 sigma = sqrt(max(m2 - m1 * m1, 0.0));
// Variance Clipping
half4 boxMin = m1 - _ClipGamma * sigma;
half4 boxMax = m1 + _ClipGamma * sigma;
histAO = clamp(histAO, boxMin, boxMax);
// 深度一致性检查
half depthValid = ...; // 比较当前与历史深度
half alpha = depthValid ? _BlendFactor : 1.0;
return lerp(histAO, currAO, alpha);
}
5.2.2 Motion Vector 的 Closest Depth 选择
为避免 Motion Vector 对应的像素在下一帧被遮挡,深度选择策略取距离摄像机最近的像素点作为重投影采样点。具体做法是在 3×3 邻域中选取深度最小(距离最近)的像素的 Motion Vector 进行重投影,不用中心像素的 Motion Vector。原因是遮挡边缘处,前景物体的 Motion Vector 比背景物体的更可靠:背景的 Motion Vector 在下一帧可能指向被前景遮挡的区域,导致采样到无效历史数据。
// Closest depth in 3×3 neighborhood
half closestDepth = 1.0;
int2 closestOffset = int2(0, 0);
[unroll] for (int y = -1; y <= 1; y++)
[unroll] for (int x = -1; x <= 1; x++)
{
half d = SampleDepth(uv + half2(x, y) * _TexelSize);
if (d < closestDepth)
{
closestDepth = d;
closestOffset = int2(x, y);
}
}
half2 bestMV = SampleMotionVector(uv + half2(closestOffset) * _TexelSize);
5.2.3 Blend Factor 的自适应调整
固定 blend factor(如 α = 0.1,即保留 90% 历史)在静态场景中效果理想,但在快速运动时响应太慢。自适应策略:
// 基于 motion vector 长度调整 blend factor
half mvLength = length(motionVec * _ScreenSize);
half adaptiveAlpha = lerp(_MinBlendFactor, _MaxBlendFactor, saturate(mvLength * _MotionScale));
// _MinBlendFactor = 0.05 (静态:保留 95% 历史)
// _MaxBlendFactor = 0.3 (运动:保留 70% 历史)
运动越快,新帧的权重越高,减少 ghosting;静止时新帧权重极低,最大化时间累积的降噪效果。
5.3 Multi-Bounce 近似
在 AO 输出后附加 Multi-Bounce 拟合以近似多次弹射的全局光照效果:
half3 MultiBounce(half ao, half3 albedo)
{
half3 a = 2.0404 * albedo - 0.3324;
half3 b = -4.7951 * albedo + 0.6417;
half3 c = 2.7552 * albedo + 0.6903;
return max(ao, ((ao * a + b) * ao + c) * ao);
}
思路类似 Crytek 在 Ryse: Son of Rome 中的实现,用 ALU 拟合多次反射的能量补偿。对深色材质的 AO 过暗问题改善比较明显。
Multi-Bounce 的物理动机:单次 AO 假设被遮挡方向的入射光为零。实际上被遮挡区域的表面也会反射光线回来(间接光的间接光)。对于高 albedo 材质,多次弹射的能量积累显著,白色墙角处的 AO 过暗就是单次 AO 的典型错误。Multi-Bounce 拟合根据表面 albedo 调整 AO 的最终值:albedo 越高,多次弹射贡献越大,AO 衰减越温和。
拟合多项式 ((ao * a + b) * ao + c) * ao 是一个关于 ao 的三次函数,其系数由 albedo 参数化。当 albedo = 0 时,a ≈ -0.33, b ≈ 0.64, c ≈ 0.69,函数近似恒等映射(无修正);当 albedo = 1 时,a ≈ 1.71, b ≈ -4.15, c ≈ 3.45,函数将低 AO 值显著提升,减轻过度变暗。
5.3.1 Multi-Bounce 拟合的精度评估
该拟合公式来自对 Monte Carlo 参考值的最小二乘逼近。参考值的生成方式:对球体内不同遮挡比例和 albedo 组合,路径追踪计算多次弹射后的实际 Visibility 值。拟合误差(max absolute error):
| albedo | 最大绝对误差 | 出现位置 |
|---|---|---|
| 0.0 | 0.0 | — |
| 0.2 | ~0.01 | AO ≈ 0.3 |
| 0.5 | ~0.03 | AO ≈ 0.4 |
| 0.8 | ~0.05 | AO ≈ 0.3 |
| 1.0 | ~0.07 | AO ≈ 0.25 |
对于 8-bit AO 输出(量化步长 1/255 ≈ 0.004),最大误差约 18 个量化级。在视觉感知上,这个误差在光滑过渡区域不可见,仅在 AO 边缘,即梯度大的位置,可能产生约 1-2% 的亮度偏差。
六、Reflection Occlusion
实时渲染中反射通常依赖 Cubemap 的视差校正,场景复杂时容易产生漏光。常见做法是直接将 AO 值乘以反射颜色来压暗漏光区域,但这种方法缺乏物理依据。
AO 描述的是漫反射方向的遮挡信息,与镜面反射方向的遮挡是两个不同的几何问题。一个表面可能在漫反射方向被大面积遮挡(高 AO),但其镜面反射方向恰好无遮挡,例如天花板下方的光滑地板,AO 值较低,反射方向朝上却完全敞开。反之亦然。直接用 AO 调制反射是一种 ad hoc 手段,对中等粗糙度表面表现尚可,但对低粗糙度表面会产生明显错误。
更合理的方案是利用 BentNormal 构建 Visibility Cone,与 Specular Reflection 方向的 Cone 求交,计算 Reflection Occlusion(RO)。
6.1 RO 三种方法的对比分析
GTAO 原始分享中提供了三种 RO 计算方法:
6.1.1 Cone-Cone Intersection
以 BentNormal 为轴的 Occlusion Cone 与以反射方向为轴的 Reflection Cone 的立体角交集。
- Occlusion Cone:轴 = BentNormal,半角 α = acos(AO)(或其简化近似)
- Reflection Cone:轴 = Reflect(ViewDir, Normal),半角 β = f(roughness)
优点:纯 ALU 计算,无纹理依赖;参数直觉清晰。 缺点:将连续的 Visibility 分布和 Specular Lobe 都简化为均匀圆锥,丢失了分布形状信息。对粗糙度极低的表面(Reflection Cone 接近 delta function),误差较大。
6.1.2 Cone-Lobe Intersection
用 Specular Lobe 的实际分布(GGX NDF 的余弦幂近似)替代 Reflection Cone 的均匀假设。
- Specular Lobe 用 cos^N(θ) 近似,其中 N = 2/roughness² - 2
- Visibility Cone 仍为均匀锥
优点:对 Specular 方向的贡献加权更精确;中低粗糙度表面效果更好。 缺点:积分需要数值方法或预计算 LUT;实时计算的 ALU 开销比 Cone-Cone 高约 3-4 倍。
6.1.3 4D LUT
预计算查找表,输入维度为 AO 值 [0, 1]、roughness [0, 1]、dot(bentNormal, reflDir) [-1, 1] 与 dot(bentNormal, normal) [0, 1] 四个量。
优点:精度最高(可以离线用路径追踪生成参考值);runtime 开销仅为一次纹理采样。 缺点:4D LUT 的存储开销较大。若每维 16 级采样,需要 16⁴ = 65536 个值。即使压缩为 2D Slice 的 atlas(如 16×16 的 tile,每 tile 16×16 像素),总纹理尺寸为 256×256。此外纹理采样引入了额外 bandwidth 和 cache pressure。
6.1.4 实际选择
三者视觉差异不大,大部分场景中 Cone-Cone 足够。极端 case,比如光滑金属表面紧贴粗糙墙壁,Cone-Lobe 和 4D LUT 更准确,但这些场景通常有其他 visual issue,比如 Reflection Probe 的视差误差掩盖 RO 精度的差异。
GTAO 实现中选择 Cone-Cone 方法:以 BentNormal 作为 Occlusion Cone 的轴向,以 Reflection Direction 作为 Reflection Cone 的轴向,两个 Cone 的交集面积越大则 RO 越强(反射越不被遮挡),AO 对反射的压制越弱。
6.2 Cone-Cone 求交的详细推导
Occlusion Cone 半角的推导:AO 值表示未被遮挡的方向占半球的比例。若将可见方向建模为以 BentNormal 为轴的圆锥,圆锥立体角 = 2π(1 - cos(α)),半球立体角 = 2π。因此 AO = 1 - cos(α),解得 α = acos(1 - AO)。但 GTAO 原始实现中直接使用 α = acos(AO) 作为简化近似:当 AO = 1(无遮挡)时 α = 0,退化为一条线,而实际应为整个半球。这是一个已知的近似误差,在实际使用中通过 smoothstep 过渡掩盖了精度问题。
两个选择的对比:
| AO 值 | acos(1-AO) (正确) | acos(AO) (近似) |
|---|---|---|
| 0.0 | 0° | 90° |
| 0.25 | 41.4° | 75.5° |
| 0.5 | 60° | 60° |
| 0.75 | 75.5° | 41.4° |
| 1.0 | 90° | 0° |
两个公式在 AO = 0.5 处交叉,低 AO 和高 AO 值处映射反转。acos(AO) 的实际效果是:AO 越低,遮挡越重,Occlusion Cone 半角越大,直觉上像是"遮挡区域更大",但方向相反,Cone 本应描述可见区域,却对应了遮挡区域。实现中通过 smoothstep 的非线性映射补偿了此差异。
Reflection Cone 半角的映射:reflConeAngle = roughness * π。这是一个线性映射近似,实际 GGX lobe 的半宽与 roughness 的关系并非线性,但在 smoothstep 后的 RO 计算中,精确的 lobe 形状影响不大。
更精确的 GGX lobe 半宽近似为 β = atan(roughness²),在 roughness < 0.3 区间与线性映射差异约 15°。但由于 RO 最终经过 smoothstep 软化,15° 的输入差异映射到约 5% 的 RO 输出差异,视觉上不可区分。
half ReflectionOcclusion_ConeCone(half3 bentNormal, half aoAngle, half3 reflDir, half roughness)
{
half reflConeAngle = max(roughness, 0.04) * PI;
half angleBetween = acos(saturate(dot(bentNormal, reflDir)));
half occlusionConeAngle = aoAngle;
half intersection = smoothstep(0, 1,
1.0 - saturate((angleBetween - reflConeAngle) / occlusionConeAngle));
return intersection;
}
直觉上:BentNormal,即未被遮挡的平均方向,与反射方向越对齐,RO 值越大,反射越不被遮挡;两者夹角超过两个 Cone 半角之和时,反射完全被遮挡,RO 为 0。
smoothstep 的作用是将 Cone 边界处的硬切过渡软化为平滑渐变,避免 RO 值在几何边缘处出现跳变。max(roughness, 0.04) 确保极光滑表面(roughness → 0)时 Reflection Cone 不退化为零宽度,避免除零和极端锐利的 RO 变化。
RO 与 AO 共享相同的 Bilateral 权重(都基于深度差异),RO 的降噪直接合并在 Bilateral Filter 的垂直 Pass 中输出,随后进入 Temporal Pass 统一处理,这样能省掉一次全屏 Pass 的 bandwidth 开销。
6.3 各阶段 RenderTarget 规格
| 阶段 | RT 格式 | 通道 | 分辨率 |
|---|---|---|---|
| AO + BentNormal 输出 | R8G8B8A8_UNorm | RGB=BentNormal, A=AO | Full |
| Spatial X Pass | R8G8B8A8_UNorm | 同上 | Full |
| Spatial Y Pass + RO | R8G8B8A8_UNorm × 2 | RT0=AO+BN, RT1.r=RO | Full |
| Temporal 输出 | R8G8B8A8_UNorm × 2 | 同上 | Full |
BentNormal 存储为归一化方向的 xyz 编码到 [0,1]。AO 和 RO 均为 [0,1] 标量。全流程使用 8-bit 精度,移动端可降至 R8 + RG8 以节省带宽。
8-bit 精度对 BentNormal 的量化误差分析:归一化方向的三个分量各使用 8 bit 编码,角度精度约为 acos(254/255) ≈ 0.08 rad ≈ 4.5°。对于 AO 应用这个精度足够:BentNormal 主要用于 Cone-Cone 求交中 dot product 的计算,4.5° 的方向误差映射到 RO 值的偏差约 2-3%,在 8-bit AO 的量化噪底之下。
全流程 Bandwidth 估算(1080P,Full Resolution):
| 阶段 | 读取 | 写入 |
|---|---|---|
| GTAO Main Pass | Depth (32 次/像素 × 4B) + Normal (1 次 × 4B) | RGBA8 × 1 |
| Spatial X | RGBA8 × 17 + Depth × 17 | RGBA8 × 1 |
| Spatial Y + RO | RGBA8 × 17 + Depth × 17 + Reflect Data | RGBA8 × 2 |
| Temporal | RGBA8 × 2 (当前+历史) + Depth × 2 + MV × 1 | RGBA8 × 2 |
七、半分辨率执行与上采样策略
在 bandwidth-limited 平台(移动端、Switch 等),全分辨率 GTAO 的深度采样开销无法接受。半分辨率执行将像素数降为 1/4,是最直接的优化手段。
7.1 半分辨率的 Downscale 策略
实测中,深度缓冲的降采样不能用简单的 bilinear filter:bilinear 平均会创造原始深度中不存在的中间值,导致 Horizon 搜索产生幻影遮挡。正确做法:
- Checkerboard Min/Max:交替取 2×2 block 中的 min depth 和 max depth,构成半分辨率 depth pair
- Closest Depth:取 2×2 block 中距离摄像机最近的值,即 depth 最小,reverse-Z 下最大。保守估计,倾向产生更多 AO(false positive),丢失 AO(false negative)的情况更少
- 代表点采样:取 2×2 block 中与中心 mip 深度偏差最小的样本,保持局部连续性
GTAO 通常使用方法 2,即 Closest Depth,原因是 AO 的 false positive,即多了接触阴影,比 false negative,即丢失接触阴影,在视觉上更可接受。
7.2 上采样策略
半分辨率 AO 需要上采样回全分辨率。常用方法:
7.2.1 Bilateral Upscale
利用全分辨率深度缓冲作为 guide,对半分辨率 AO 做 depth-aware 上采样:
half BilateralUpscale(half2 fullResUV, half fullResDepth)
{
// 在半分辨率纹理中取 2×2 邻域
half2 halfResUV = fullResUV * 0.5;
half4 halfResDepths = GatherDepthHalfRes(halfResUV);
half4 halfResAO = GatherAOHalfRes(halfResUV);
// 基于深度差计算权重
half4 weights;
weights.x = 1.0 / (1.0 + abs(halfResDepths.x - fullResDepth) * _UpsampleDepthThreshold);
weights.y = 1.0 / (1.0 + abs(halfResDepths.y - fullResDepth) * _UpsampleDepthThreshold);
weights.z = 1.0 / (1.0 + abs(halfResDepths.z - fullResDepth) * _UpsampleDepthThreshold);
weights.w = 1.0 / (1.0 + abs(halfResDepths.w - fullResDepth) * _UpsampleDepthThreshold);
half totalWeight = dot(weights, half4(1,1,1,1));
return dot(halfResAO, weights) / totalWeight;
}
这种方法在深度连续区域等效于 bilinear 上采样,在深度不连续边缘选择深度最接近的半分辨率样本,避免跨边界混合。
7.2.2 Checkerboard Reconstruction
另一种策略是每帧只渲染半数像素(棋盘格模式),另一半从邻域重建:
- 偶数帧渲染 (x+y)%2 == 0 的像素
- 奇数帧渲染 (x+y)%2 == 1 的像素
- 未渲染的像素从上一帧的对应位置(通过 motion vector 重投影)或当前帧的邻居插值获得
这种方法相对于直接半分辨率的优势在于:每个像素每两帧都会被直接计算一次,不依赖持续插值。代价是实现复杂度更高,且快速运动时重投影的两帧数据质量下降。
7.3 半分辨率的 Artifact 与缓解
半分辨率 AO 的常见问题有三个。深度不连续处的上采样错误会让 AO 在前景物体边缘"溢出"到背景,产生明暗光晕(Haloing);棋盘格 aliasing 在运动时交替曝光,表现为高频闪烁;小于 2 像素的几何特征在半分辨率下不可见,接触阴影直接丢失。
缓解手段按问题对症:Haloing 用 Bilateral Upscale 配合足够敏感的 depth threshold;闪烁用 Temporal Filter 的 4 帧周期 Jitter,让棋盘格周期被 Temporal 窗口完全覆盖;细节丢失没有 screen-space 解法,只能搭配 Contact Shadow(ray-march 的直接阴影)补充小尺度接触暗面。
八、最终合成
将 AO 和 RO 分别混合到场景的漫反射和镜面反射通道。AO 压暗间接漫反射,RO 调制反射强度。
合成的具体实现:
half3 ApplyAO_RO(half3 diffuseGI, half3 specularGI, half ao, half ro, half3 albedo)
{
// Multi-Bounce 修正后的 AO
half3 aoMultiBounce = MultiBounce(ao, albedo);
// 漫反射通道:乘以 Multi-Bounce AO
half3 finalDiffuse = diffuseGI * aoMultiBounce;
// 镜面反射通道:乘以 RO
half3 finalSpecular = specularGI * ro;
return finalDiffuse + finalSpecular;
}
Multi-Bounce 仅应用于漫反射通道。镜面反射通道使用原始 RO 值,不用 Multi-Bounce 修正值:镜面反射的多次弹射行为与漫反射不同,Multi-Bounce 的拟合公式基于 Lambertian 模型推导,不适用于 Specular。
8.1 AO 与直接光照的交互
严格来说 AO 不应影响直接光照,直接光的遮挡由 Shadow Map 处理。但在某些引擎实现中,会将 AO 的一小部分(如 lerp(1.0, ao, 0.3))乘以直接漫反射光照的 Ambient 分量,以增强地面接触感。这是一种视觉 hack,在以下条件下合理:
- Shadow Map 分辨率不足以捕捉小尺度接触阴影
- 场景中的间接光强度与直接光相比很低,AO 单独作用于间接光时效果不明显
在 PBR pipeline 中不推荐此做法,它破坏了能量守恒,与 Shadow Map 信号叠加会导致接触区域过暗。
九、效果对比
在 4 Circle / 8 Slice 采样配置下,配合 Spatial + Temporal 降噪,我实测的最终效果接近 Ray-Traced AO 的质量。
实测中我以 ClassRoom 场景对比开启 AO + RO 前后的效果。关闭时场景缺少空间深度感,物体之间的接触区域没有自然的阴影过渡;开启后桌椅腿部、墙角等位置产生了柔和的接触阴影,反射探针在遮挡区域的漏光也被 RO 有效抑制。
与 Unity 内置方案的具体差异:
接触阴影精度上,Unity 内置 SSAO(基于 SAO 变体)的采样是球体分布点采样,缺少 Horizon 搜索的角度精确性,几何接触边缘的 AO 过渡不够锐利。能量守恒上,内置方案没有 Multi-Bounce 补偿,白色墙角处会过暗;GTAO 的 Multi-Bounce 拟合让明亮材质的 AO 衰减更接近物理真实。反射遮挡上,内置方案没有 RO 能力,Reflection Probe 在遮挡区域会产生明显漏光;GTAO 的 Cone-Cone RO 能物理正确地压制遮挡区域的反射。
9.1 与 RTAO 的差距
即使 GTAO 在 SSAO 方法中已经接近理论精度上限,它与 RTAO 仍存在结构性差距:
| 维度 | GTAO | RTAO |
|---|---|---|
| 信息来源 | 单层深度缓冲 | 完整 BVH/场景几何 |
| 离屏遮挡 | 完全丢失 | 正确计算 |
| 自遮挡物背面 | 需要 Thickness Heuristic 缓解 | 自然正确 |
| 大尺度遮挡 | 受限于搜索半径 | 光线可达任意距离 |
| 动态几何 | 需要每帧更新 Depth Buffer | BVH 重建/refit |
| 半透明物体 | 无法处理 | 可选择性忽略或穿透 |
在 RTX 硬件可用的平台上,RTAO 可以 1 spp + 时空降噪达到极高质量。GTAO 只依赖单层深度缓冲和屏幕空间采样,不需要 RT Core,运行时开销主要跟随采样配置、不随场景几何复杂度变化,低端硬件上能稳定维持可接受质量。两者在实际引擎中常作为 fallback 关系存在:硬件支持时使用 RTAO,否则 fallback 到 GTAO。
十、性能剖析
完整管线在不同硬件上的 GPU 时间参考(1080P,4 方向 × 8 步):
| Pass | 描述 | 典型开销比例 |
|---|---|---|
| GTAO Main | 深度采样 + Horizon 搜索 + AO/BN 输出 | ~55% |
| Spatial X | 水平 Bilateral | ~12% |
| Spatial Y + RO | 垂直 Bilateral + RO 计算 | ~18% |
| Temporal | Variance Clipping + 混合 | ~15% |
主 Pass 的开销主导地位来自 32 次深度采样的 bandwidth 消耗。在 bandwidth-limited 的 GPU 上(如移动端),降低采样步数或使用半分辨率是最有效的优化手段。在 ALU-limited 的 GPU 上,IntegrateArc 的三角函数可以用多项式近似替代以减少指令数。
10.1 三角函数的多项式近似
IntegrateArc 中的 cos 和 sin 调用在 shader 中通常编译为硬件 SFU(Special Function Unit)指令,吞吐量低于标准 ALU。对于 ALU-limited 场景,可以用 Taylor 展开或 minimax 多项式替代:
// 5 阶 minimax cos 近似,[-π, π] 范围内最大误差 < 0.001
half FastCos(half x)
{
half x2 = x * x;
return 1.0 + x2 * (-0.4999 + x2 * (0.0414 - x2 * 0.0012));
}
// 对应的 sin 近似
half FastSin(half x)
{
half x2 = x * x;
return x * (1.0 + x2 * (-0.1666 + x2 * (0.0083 - x2 * 0.0001)));
}
在 Mali/Adreno GPU 上,这种替代可以将 IntegrateArc 的执行周期从 8-10 cycles 降至 4-5 cycles。代价是引入 < 0.1% 的数值误差,对 AO 的 8-bit 输出精度无可见影响。
10.2 不同平台的性能预算参考
| 平台 | 目标帧时间 | GTAO 预算 | 推荐配置 |
|---|---|---|---|
| PC (RTX 3060) | 16.6ms | 0.5-0.8ms | Full res, 4dir×8step |
| PS5/XSX | 16.6ms | 0.4-0.6ms | Full res, 4dir×6step |
| Switch (Docked) | 33.3ms | 1.0-1.5ms | Half res, 3dir×4step + bilateral upscale |
| Mobile (Adreno 730) | 16.6ms | 0.8-1.2ms | Half res, 2dir×4step + temporal only |
| Mobile (Mali G710) | 16.6ms | 1.0-1.5ms | Half res, 2dir×4step |
这些数据为全流程(Main + Spatial + Temporal)的总 GPU 时间。移动端的瓶颈在 bandwidth 不在 ALU:Mali 的 tile-based 架构对全屏 post-process 的随机纹理读取效率较低,L2 cache miss rate 高于 immediate-mode GPU。




















