GRAPHICS × PRODUCTION
中EN

皮了个牛

↓

计算机图形学

Ground-Truth Ambient Occlusion 与 Reflection Occlusion 实现

从屏幕空间 AO 的误差与 HBAO 几何出发,推导 GTAO 的 cosine-weighted 积分、horizon angle、BentNormal、采样与噪声策略;随后覆盖空间/时间降噪、variance clipping、multi-bounce、Reflection Occlusion 的三种方法、半分辨率上采样、合成、效果差距和性能剖析。

2018-12-2550 分钟阅读
Ground-Truth Ambient Occlusion 与 Reflection Occlusion 实现

一、背景

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 核心差异

维度HBAOGTAO
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 = 01.0(完全可见)0.25 × [(-cos(π) + 1 + π·0) × 2] = 0.25 × [2 + 2] = 1.0 ✓
h1 = h2 = 0, n = 00.0(完全遮挡)0.25 × [(-1 + 1 + 0) × 2] = 0 ✓
h1 = h2 = π/4, n = 00.50.25 × [(-cos(π/2) + 1) × 2] = 0.25 × [1 + 1] = 0.5 ✓
h1 = π/2, h2 = π/2, n = π/41.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 搜索算法的详细步骤
  1. 确定 Slice 方向在屏幕空间的步进向量:根据当前 Slice 角度 φ,计算屏幕空间步进方向 dir = float2(cos(φ), sin(φ))。步进量需要乘以 texel size 转换为 UV 空间偏移。

  2. 计算 View Space 中心位置:从中心像素的 UV 和 Depth 重建 View Space 坐标 P_center。

  3. 逐步推进并计算仰角:对每个步进位置 i,计算采样 UV、读取深度、重建 View Space 位置 P_sample,然后计算差向量 ΔP = P_sample - P_center。仰角 = atan2(ΔP.z, length(ΔP.xy)),其中 z 轴为 View Space 的深度方向(指向摄像机前方)。

  4. 维护最大仰角:在所有步进中保留最大仰角。此最大值即为该方向的 Horizon Angle。

  5. 应用 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 策略有三种:

  1. 线性衰减:falloff = saturate(dist / radius)。简单但过渡带宽,中距离的 AO 贡献被过度抑制。
  2. 二次衰减:falloff = saturate((dist / radius)²)。更贴近光照衰减的物理特性,但近距离衰减太慢。
  3. 阶梯 + 线性:在 [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 采样参数的调优指南
参数典型范围影响调优建议
numDirections2-8方向覆盖密度。<4 时角度 aliasing 明显PC: 4, Mobile: 2-3
numSteps4-12每方向步进精度。<6 时近距离 AO 边缘锯齿PC: 8, Mobile: 4-6
searchRadius0.5-3.0m (世界空间)遮挡检测范围。过大导致大面积发黑室内: 0.5-1.0, 室外: 1.5-3.0
falloffScale1.0-3.0距离衰减速度与 searchRadius 配合调整
thicknessScale0.05-0.5薄物体剔除阈值细长物体多的场景调小

4.6 Jitter 策略与 Noise Pattern

Jitter 的目的是以低采样数逼近高采样数的积分结果。实现中使用两层 Jitter:

  1. Circle Rotation Jitter:每个像素的起始旋转角度在帧间交替偏移(基于 interleaved gradient noise 或蓝噪声),使得相邻像素和相邻帧采样不同的 Slice 方向。
  2. 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 的工程权衡
特性IGNBlue 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.00.0—
0.2~0.01AO ≈ 0.3
0.5~0.03AO ≈ 0.4
0.8~0.05AO ≈ 0.3
1.0~0.07AO ≈ 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.00°90°
0.2541.4°75.5°
0.560°60°
0.7575.5°41.4°
1.090°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_UNormRGB=BentNormal, A=AOFull
Spatial X PassR8G8B8A8_UNorm同上Full
Spatial Y Pass + ROR8G8B8A8_UNorm × 2RT0=AO+BN, RT1.r=ROFull
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 PassDepth (32 次/像素 × 4B) + Normal (1 次 × 4B)RGBA8 × 1
Spatial XRGBA8 × 17 + Depth × 17RGBA8 × 1
Spatial Y + RORGBA8 × 17 + Depth × 17 + Reflect DataRGBA8 × 2
TemporalRGBA8 × 2 (当前+历史) + Depth × 2 + MV × 1RGBA8 × 2

七、半分辨率执行与上采样策略

在 bandwidth-limited 平台(移动端、Switch 等),全分辨率 GTAO 的深度采样开销无法接受。半分辨率执行将像素数降为 1/4,是最直接的优化手段。

7.1 半分辨率的 Downscale 策略

实测中,深度缓冲的降采样不能用简单的 bilinear filter:bilinear 平均会创造原始深度中不存在的中间值,导致 Horizon 搜索产生幻影遮挡。正确做法:

  1. Checkerboard Min/Max:交替取 2×2 block 中的 min depth 和 max depth,构成半分辨率 depth pair
  2. Closest Depth:取 2×2 block 中距离摄像机最近的值,即 depth 最小,reverse-Z 下最大。保守估计,倾向产生更多 AO(false positive),丢失 AO(false negative)的情况更少
  3. 代表点采样:取 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 仍存在结构性差距:

维度GTAORTAO
信息来源单层深度缓冲完整 BVH/场景几何
离屏遮挡完全丢失正确计算
自遮挡物背面需要 Thickness Heuristic 缓解自然正确
大尺度遮挡受限于搜索半径光线可达任意距离
动态几何需要每帧更新 Depth BufferBVH 重建/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%
TemporalVariance 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.6ms0.5-0.8msFull res, 4dir×8step
PS5/XSX16.6ms0.4-0.6msFull res, 4dir×6step
Switch (Docked)33.3ms1.0-1.5msHalf res, 3dir×4step + bilateral upscale
Mobile (Adreno 730)16.6ms0.8-1.2msHalf res, 2dir×4step + temporal only
Mobile (Mali G710)16.6ms1.0-1.5msHalf res, 2dir×4step

这些数据为全流程(Main + Spatial + Temporal)的总 GPU 时间。移动端的瓶颈在 bandwidth 不在 ALU:Mali 的 tile-based 架构对全屏 post-process 的随机纹理读取效率较低,L2 cache miss rate 高于 immediate-mode GPU。