文章

Unity URP SSGI:基于 Motion Vector 的时间重投影与动态相机稳定性优化

​ ​ 屏幕空间全局光照(Screen Space Global Illumination,SSGI)可以直接复用相机颜色和深度缓冲,在不建立场景级光照数据结构的情况下估计间接光。它非常适合验证蒙特卡洛积分、时间累积和屏幕空间重投影,但也有三个典型问题:低采样率下收敛慢,相机移动后历史失效,以及过度滤波造成能量损失。

​ ​ 本文记录一套面向 Unity URP 的实用实现。目标不是引入另一种 GI 技术,而是在保留蒙特卡洛 SSGI 的前提下,通过 Motion Vector 重投影、深度验证和受控的历史窗口,提高动态相机下的稳定性,并控制额外显存和全屏 Pass 成本。

ssgi-motion-reprojection-pipeline.png

1. 首先明确两张纹理的职责

​ ​ 整个实现必须先区分 radiance map 和 irradiance map。

纹理 内容 生成时机 用途
Radiance map 当前帧已经完成直接光与上一帧间接光合成后的相机颜色 当前帧 SSGI Pass 开始时复制 屏幕空间射线命中后读取入射辐射亮度
Irradiance map 当前帧 SSGI、时间累积和边缘保持模糊的结果 当前帧 SSGI Pass 结束时生成 下一帧 Forward 着色时作为间接光输入

​ ​ Radiance map 不能替换为时间累积结果。射线命中位置需要读取当前帧可见表面的 radiance,而 irradiance map 则是需要跨帧保存、供下一帧材质使用的低频间接光估计。

​ ​ 因此一帧的核心关系是:

radiance_N = 当前帧渲染颜色
irradiance_N = Blur(TemporalAccumulate(SSGI(radiance_N)))
color_(N+1) = direct_(N+1) + irradiance_N * albedo_(N+1)

​ ​ 这里的公式用于说明数据关系,不承担流程图作用。完整执行顺序见上方 PNG 流程图。

2. 为什么间接光必须乘接收面的 Albedo

​ ​ SSGI 射线命中位置读取的是入射 radiance。对于漫反射接收面,最终输出仍要受到接收面反射率影响。若直接把 irradiance 加到颜色上,不同材质会接收到近似白色的间接光,场景物体自身的颜色不会参与二次反射。

​ ​ Forward Shader 中的合成应保持为:

half4 baseColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv) * _BaseColor;
half3 color = directLighting;
color += indirectIrradiance * baseColor.rgb * indirectConfidence;

​ ​ 命中面的颜色已经包含在 radiance 中,接收面的 baseColor 在最后再乘一次。两者分别表示光从什么表面离开,以及最终被什么表面接收。

3. Motion Vector 重投影

​ ​ 静止相机可以直接在相同屏幕坐标读取上一帧历史。相机或物体移动后,同一个屏幕像素已经不再代表同一个表面,需要使用 Motion Vector 找到上一帧对应位置:

float2 motion = SAMPLE_TEXTURE2D_X(
    _MotionVectorTexture, sampler_LinearClamp, uv).xy;
float2 previousUV = uv - motion;

​ ​ URP Renderer Feature 必须声明 Motion 输入:

ConfigureInput(
    ScriptableRenderPassInput.Depth |
    ScriptableRenderPassInput.Color |
    ScriptableRenderPassInput.Motion);

​ ​ Motion Vector 只解决“上一帧像素在哪里”的问题,不能证明这个历史仍属于当前表面。因此还要进行深度验证。

4. 历史深度必须使用点采样

​ ​ Irradiance 的 RGB 需要双线性采样,以减少重投影抖动;深度则不能使用同样的过滤方式。物体边缘的双线性深度会混合前景和背景,得到一个场景中不存在的中间值,导致大面积历史被错误拒绝。

​ ​ 正确做法是对同一位置分别采样颜色和深度:

float4 history = SAMPLE_TEXTURE2D_X(
    _SSGIHistoryTexture, sampler_LinearClamp, previousUV);
float historyDepth = SAMPLE_TEXTURE2D_X(
    _SSGIHistoryTexture, sampler_PointClamp, previousUV).a;

​ ​ 然后把当前世界坐标转换到上一帧观察空间,计算理论深度:

float3 currentPositionWS = ComputeWorldSpacePosition(
    uv, rawDepth, UNITY_MATRIX_I_VP);
float previousExpectedDepth = -mul(
    _SSGIPreviousWorldToCameraMatrix,
    float4(currentPositionWS, 1.0)).z;

float depthThreshold = max(
    _SSGIHistoryDepthThreshold,
    previousExpectedDepth * 0.01);

bool valid = abs(historyDepth - previousExpectedDepth) <= depthThreshold;

​ ​ 绝对阈值用于近处表面,相对阈值用于远处表面。当前配置使用 0.1 的最小线性深度阈值。

5. 相机移动时不能继续使用 32 帧历史权重

​ ​ 只保存一个全局历史帧数虽然便宜,但有一个容易忽略的问题:相机移动后,新显露像素第一帧会因深度不匹配而正确拒绝历史;下一帧它却会立即继承已经达到上限的全局计数,只用固定 EMA 权重更新。如果权重很小,新区域会长时间保持黑色或高噪声。

​ ​ 解决方法是在视角变化期间限制有效历史长度:

private const int MotionHistoryFrameCap = 4;

if (viewChanged && state.historyValid)
{
    state.historyCount = Mathf.Min(
        state.historyCount,
        MotionHistoryFrameCap);
}

​ ​ 时间权重使用分段策略:

float runningAverageWeight = rcp(_SSGIHistoryFrameCount + 1.0);
float currentFrameWeight = _SSGIHistoryFrameCount < _SSGIMaxHistoryFrames
    ? runningAverageWeight
    : _SSGIHistoryWeight;

​ ​ 这样会产生两个阶段:

状态 历史长度 当前帧权重 行为
相机持续移动 最多 4 帧 至少 0.2 快速响应新显露区域
相机停止 从 5 增长到 32 帧 逐步降低 继续降低蒙特卡洛噪声
达到历史上限 32 帧 固定 0.05 保持稳定并允许光照缓慢变化

​ ​ 这项处理不需要额外纹理,也不增加全屏 Pass。

6. Forward Pass 中的 Disocclusion 回退

​ ​ SSGI 在当前帧不透明物体渲染完成后才计算,但材质在 Forward Pass 中只能读取上一帧 irradiance。因此相机移动产生的新显露区域天然没有历史;即使时间重投影完全正确,这些区域在当前帧也只能得到直接光,表现为明显黑块。

​ ​ 最终实现使用两级策略:

  1. 正常情况下,用上一帧 VP 矩阵把当前世界坐标投影到上一帧,并用点采样深度验证。有效时使用完整置信度。
  2. 重投影越界或深度不匹配时,在当前屏幕 UV 读取上一帧 irradiance,使用 0.35 的低置信度作为短暂回退。

​ ​ 核心逻辑如下:

half3 indirectIrradiance = 0.0;
half indirectConfidence = 0.0;

if (reprojectedDepthIsValid)
{
    indirectIrradiance = reprojectedIrradiance;
    indirectConfidence = 1.0;
}
else if (_SSGIDisocclusionFallback > 0.0)
{
    indirectIrradiance = fallbackIrradiance;
    indirectConfidence = _SSGIDisocclusionFallback;
}

color += indirectIrradiance * baseColor.rgb * indirectConfidence;

​ ​ 回退值不是严格正确的几何对应关系,因此不能使用完整权重。它的任务只是避免一整块区域突然失去间接光。下一帧生成当前视角的真实 irradiance 后,正常重投影会自动取代回退值。

7. 为什么移除方差纹理和自适应射线

​ ​ 早期版本曾增加 moments、normal-count 历史纹理、方差引导滤波和自适应射线。理论上这些信息能帮助判断不稳定区域,但在当前低采样率实现中带来了两个实际问题。

​ ​ 第一,额外的 moments 和 normal-count 更新让 shader 全屏 Pass 从 4 个增加到 6 个,还增加了四张双缓冲历史纹理。Motion Vector 本身已经会让 URP 生成额外缓冲,这时继续叠加全屏 Pass 会明显降低帧率。

​ ​ 第二,低采样率蒙特卡洛结果中,大部分像素暂时为零,少量像素命中高能表面。若用当前帧 3×3 颜色范围裁剪历史,或者按亮度差强烈拒绝邻居,这些有效高能样本会被当成异常值消除,使估计产生向黑色的系统性偏差。

​ ​ 最终版本保留以下四个 SSGI Shader Pass:

  1. SSGI:从当前 radiance map 进行蒙特卡洛射线追踪。
  2. Accumulate:Motion Vector 重投影、点采样深度验证和时间累积。
  3. BlurH:水平方向深度/法线边缘保持滤波。
  4. BlurV:垂直方向深度/法线边缘保持滤波。

​ ​ 滤波只用深度和法线保护几何边界,不再按亮度差删除有效的间接光能量。

8. 性能参数如何取舍

​ ​ 当前测试场景主体尺寸约为 20 个世界单位。原配置使用 1 ray × 256 stepsmaxDistance=256,射线远远超出场景有效范围,而且最坏情况下需要大量深度采样。

​ ​ 最终配置调整为:

参数 当前值 作用
Ray Count 2 每帧增加独立样本,提高初期稳定性
Max Steps 64 控制单条射线最坏深度采样成本
Max Distance 20 覆盖主要场景范围,避免无效超长射线
Blur Spread 1 低成本边缘保持模糊
Temporal Response 0.05 达到历史上限后的当前帧权重
History Frames 32 静止视角下的最大历史长度
Motion History Cap 4 相机移动时的短历史窗口
Depth Threshold 0.1 历史深度最小容差
Disocclusion Fallback 0.35 新显露区域的临时 irradiance 权重

​ ​ 虽然射线数从 1 提高到 2,但最大步数从 256 降到 64。最坏的射线步进预算从 1×256 降到 2×64,约为原来的一半,同时每帧蒙特卡洛方向数量翻倍。再加上移除两个全屏 Pass 和四张辅助历史纹理,整体性能与收敛质量更平衡。

9. 完整帧时序

​ ​ 在当前实现中,每帧按以下时序执行:

  1. BeforeRenderingOpaques 绑定上一帧 irradiance、上一帧矩阵和历史有效标记。
  2. MainLightDirect.shader 计算直接光,静止相机使用当前屏幕 UV,移动相机使用上一帧 VP 重投影。
  3. 重投影失败时启用低置信度 disocclusion 回退。
  4. 间接光乘当前材质 albedo,再与直接光相加。
  5. BeforeRenderingPostProcessing 把当前相机颜色复制为 radiance map。
  6. SSGI 从 radiance map 发射余弦加权半球射线。
  7. 使用 Motion Vector 重投影上一帧历史,并用点采样深度验证。
  8. 根据移动窗口或静止窗口进行时间累积。
  9. 执行两次深度/法线边缘保持滤波,生成当前帧 irradiance map。
  10. 当前 irradiance map 在下一帧重复参与 Forward 着色。

10. 实现时最容易踩的坑

10.1 用双线性过滤读取历史深度

​ ​ 颜色可以双线性采样,深度必须点采样。否则前后景深度会在边缘生成不存在的中间值,历史拒绝区域会比真实 disocclusion 大得多。

10.2 相机移动时仍使用最大历史长度

​ ​ 新显露像素会很快继承过小的 EMA 权重,表现为移动后不收敛。移动期间应缩短历史,停止后再逐步扩展。

10.3 用当前低采样结果强制裁剪历史颜色

​ ​ 1~2 ray/pixel 的当前帧不是稳定参考。强邻域裁剪会把合法高能样本压成零,造成整幅画面向黑色偏移。

10.4 盲目增加自适应射线和辅助 Pass

​ ​ 自适应策略只有在收益大于额外纹理、分支和全屏 Pass 成本时才值得使用。对当前场景,减少无效追踪距离和步数比增加复杂的方差链路更有效。

10.5 把 disocclusion 回退当成真实历史

​ ​ 屏幕 UV 回退只是隐藏单帧黑块,必须使用低权重。完整权重会把错误表面的间接光带到新显露区域,形成明显拖影或漏光。

11. 总结

​ ​ Motion Vector 并不会自动解决 SSGI 的所有时间问题。一个可用的动态相机方案至少需要同时处理像素对应关系、深度有效性、历史长度和新显露区域。

​ ​ 最终实现保留了蒙特卡洛方法的能量估计和跨帧收敛能力,同时避免使用会造成黑偏和高成本的复杂方差链路。其关键原则可以概括为:RGB 平滑采样、深度点采样;移动时缩短历史、静止时扩展历史;真实重投影使用完整权重、disocclusion 仅使用低置信度回退;把性能预算优先投入有效射线,而不是过多的辅助全屏 Pass。

许可协议:  CC BY 4.0