Unity URP SSGI:基于 Motion Vector 的时间重投影与动态相机稳定性优化
屏幕空间全局光照(Screen Space Global Illumination,SSGI)可以直接复用相机颜色和深度缓冲,在不建立场景级光照数据结构的情况下估计间接光。它非常适合验证蒙特卡洛积分、时间累积和屏幕空间重投影,但也有三个典型问题:低采样率下收敛慢,相机移动后历史失效,以及过度滤波造成能量损失。
本文记录一套面向 Unity URP 的实用实现。目标不是引入另一种 GI 技术,而是在保留蒙特卡洛 SSGI 的前提下,通过 Motion Vector 重投影、深度验证和受控的历史窗口,提高动态相机下的稳定性,并控制额外显存和全屏 Pass 成本。

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。因此相机移动产生的新显露区域天然没有历史;即使时间重投影完全正确,这些区域在当前帧也只能得到直接光,表现为明显黑块。
最终实现使用两级策略:
- 正常情况下,用上一帧 VP 矩阵把当前世界坐标投影到上一帧,并用点采样深度验证。有效时使用完整置信度。
- 重投影越界或深度不匹配时,在当前屏幕 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:
SSGI:从当前 radiance map 进行蒙特卡洛射线追踪。Accumulate:Motion Vector 重投影、点采样深度验证和时间累积。BlurH:水平方向深度/法线边缘保持滤波。BlurV:垂直方向深度/法线边缘保持滤波。
滤波只用深度和法线保护几何边界,不再按亮度差删除有效的间接光能量。
8. 性能参数如何取舍
当前测试场景主体尺寸约为 20 个世界单位。原配置使用 1 ray × 256 steps 和 maxDistance=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. 完整帧时序
在当前实现中,每帧按以下时序执行:
BeforeRenderingOpaques绑定上一帧 irradiance、上一帧矩阵和历史有效标记。MainLightDirect.shader计算直接光,静止相机使用当前屏幕 UV,移动相机使用上一帧 VP 重投影。- 重投影失败时启用低置信度 disocclusion 回退。
- 间接光乘当前材质 albedo,再与直接光相加。
BeforeRenderingPostProcessing把当前相机颜色复制为 radiance map。- SSGI 从 radiance map 发射余弦加权半球射线。
- 使用 Motion Vector 重投影上一帧历史,并用点采样深度验证。
- 根据移动窗口或静止窗口进行时间累积。
- 执行两次深度/法线边缘保持滤波,生成当前帧 irradiance map。
- 当前 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。