Unity URP 屏幕空间全局光照:从蒙特卡洛采样到时间累积
在完成屏幕空间反射之后,我想继续沿用同一套屏幕空间 Ray March框架,实现一个可以实时运行的屏幕空间全局光照效果。
这篇文章记录当前版本的实现。它不是一套完整的离线光传输方案,也不会试图解决屏幕外信息、动态物体重投影等所有问题。我们的目标很明确:从相机颜色和深度出发,为每个可见像素生成若干条漫反射采样光线,找到一次反弹的入射辐射,并通过时间累积得到稳定的间接光结果。
全文分为四章。第一章先建立 SSGI、蒙特卡洛积分和时间累积之间的关系;第二章进入 Unity URP 中的实际代码,重点分析半球采样、屏幕空间光线求交和 Temporal累积;第三章再用深度与法线感知的迭代滤波替换普通高斯模糊,减少间接光跨几何边界扩散的问题;第四章区分 irradiance、radiance 与材质反射率,将滤波后的间接照明留给下一帧,并在物体着色阶段乘以目标表面的 albedo。
屏幕空间全局光照
1.1 什么是屏幕空间全局光照
全局光照关注的不只是光源直接照到物体表面的结果,还包括光线在场景中经过一次或多次反弹后带来的间接照明。
例如,一面红墙受到强光照射后,会把一部分红色光反射到附近的白色物体上。只计算直接光时,这部分颜色反弹不会出现;计算全局光照后,白色物体靠近墙面的位置会染上一层红色。
屏幕空间全局光照(Screen Space Global Illumination,SSGI)没有完整的场景几何信息。它只使用当前相机已经生成的缓冲数据,例如:
- 相机深度,用来重建可见表面的位置;
- 相机颜色,用来近似命中位置向外发出的辐射;
- 表面法线,用来限制采样方向;
- 历史帧结果,用来降低低采样数产生的噪声。
它的优势是接入简单,不需要额外维护体素、距离场或硬件光线追踪结构,并且能直接复用延迟渲染或后处理阶段已有的数据。
代价也很明显:屏幕外的物体、被前景遮挡的几何和当前相机看不到的光源都不存在于这些缓冲中。射线离开屏幕后,我们无法继续查询它;命中点虽然有颜色,却不一定包含严格分离后的直接光与材质信息。因此,SSGI 本质上是一种利用当前画面近似间接光的实时方法。
1.2 当前实现计算什么
这次只考虑一次漫反射弹射。
对于屏幕上的一个像素,我们先重建它在观察空间中的位置和法线,再从法线所在的上半球生成一条方向。射线如果在屏幕空间命中另一个可见表面,就读取命中位置的颜色,并把它作为这一条路径带回来的入射辐射近似值。
整个流程可以概括为:
- 从深度重建当前像素的观察空间位置;
- 从位置导数重建法线,并让法线朝向相机;
- 围绕法线生成余弦加权的半球采样方向;
- 沿该方向进行屏幕空间光线追踪;
- 命中后读取命中像素的辐射颜色;
- 对当前像素的多条光线求平均;
- 与历史帧结果进行
Temporal累积; - 对累积结果进行边缘保持滤波,生成供下一帧使用的 irradiance map;
- 下一帧渲染物体时,在屏幕投影点读取 irradiance,再乘以当前物体的 albedo,与直接光相加。
这里的“一次弹射”指的是路径长度,而不是每个像素只能发射一条光线。当前实现允许每像素使用 1 到 128 条光线,默认值为 4。光线数量越多,单帧估计越稳定,但屏幕空间求交和纹理读取的成本也会按比例增加。
1.3 从渲染方程看一次间接光
忽略自发光后,一个表面点沿观察方向离开的辐射,可以写成半球上的积分:
其中:
- x 是当前表面位置;
- \omega_o 是观察方向;
- \omega_i 是半球中的入射方向;
- L_i 是该方向带来的入射辐射;
- f_r 是表面的 BRDF;
- \mathbf n \cdot \omega_i 是入射方向与法线之间的余弦项;
- \Omega^+ 是法线上方的半球。
屏幕空间实现不会真的遍历半球中的无穷多个方向。我们只能挑选少量方向,对积分进行数值估计。
1.4 蒙特卡洛积分
对一般积分
如果按照概率密度 p(\omega) 生成 N 个随机样本,蒙特卡洛估计为:
它包含三个关键部分:
- 用什么概率分布生成方向;
- 每条方向上测得什么值;
- 如何用 PDF,也就是 p(\omega),修正非均匀采样带来的偏差。
将它代入一次漫反射积分,可得:
样本越多,估计的方差通常越小。但在实时渲染中,我们不可能为每个像素发射成百上千条屏幕空间光线,所以单帧结果一定会带有明显噪声。
Temporal的作用,就是把不同帧看成同一个积分估计中的后续样本来源。
1.5 用不同帧扩展样本序列
如果相机和场景保持不变,那么第 t 帧生成的新方向仍然可以用于估计同一个积分。理想情况下,把各帧样本放在一起,可以写成:
X_t 表示第 t 个样本带回来的辐射贡献。单帧只有一条光线时,连续 N 帧就提供了 N 个方向;单帧有 R 条光线时,连续 N 帧理论上提供了 N\times R 个方向。
这要求每一帧不能反复使用完全相同的采样方向。当前代码用一个随帧推进的样本偏移量改变低差异序列,每次有效累积后将 sampleOffset 增加当前 rayCount,并在 16 位范围内循环。
不过,当前实现并没有保存严格的算术平均,而是使用固定响应系数的指数移动平均:
其中:
- C_t 是当前帧的 SSGI 估计;
- H_{t-1} 是上一帧历史;
- H_t 是更新后的历史;
- \alpha 是
Temporal Response,即当前帧权重。
展开后可以看到,越旧的样本权重越小:
所以它仍然在时间维度上组合蒙特卡洛样本,但不是“所有历史样本权重相等”的普通平均。对于平稳、无偏的输入估计,它不会凭空引入持续偏差,但会保留一个非零的方差下限;当真实光照发生变化时,有限记忆还会表现为响应滞后。
1.6 当前 Temporal 的边界
需要特别说明,当前版本没有使用 Motion Vector,也没有把当前像素重投影到上一帧 UV。历史纹理直接在相同的屏幕 UV 上读取。
为避免相机移动后把不同表面混在一起,代码在相机位置、旋转、投影矩阵或 SSGI 配置发生变化时直接使历史失效。换句话说,它主要解决静止相机下的采样收敛,而不是一套完整的动态场景重投影方案。
即使相机静止,物体运动或遮挡关系变化也可能让同一个像素对应不同表面。为此,历史纹理的 Alpha 通道保存线性眼深,累积前会做深度一致性检查。第二章会详细介绍这部分。
基本框架的实现
2.1 RenderFeature 与 Pass 顺序
当前实现通过 ScreenSpaceGlobalIlluminationFeature 接入 URP,并在 BeforeRenderingPostProcessing 阶段执行。Render Pass 请求相机的Depth 和 Color 输入,然后为每个相机维护独立的中间纹理与历史状态。
主要 Render Target 包括:
radianceRT:保存当前相机颜色,作为射线命中后的辐射来源;blurA、blurB:保存两次边缘保持滤波的 Ping-Pong 中间值;historyA、historyB:Temporal 历史的 Ping-Pong 纹理;compositeRT:保存叠加后的最终画面。
一次正常更新的顺序是:
复制相机颜色到 radianceRT
↓
执行 SSGI Trace,写入当前间接光
↓
当前结果与 HistoryRead 累积到 HistoryWrite
↓
小半径边缘保持滤波 → 大半径边缘保持滤波
↓
与原始相机颜色合成
↓
交换 HistoryRead / HistoryWrite
历史纹理使用 R16G16B16A16_SFloat。RGB 保存间接光,Alpha 保存当前像素的线性眼深;天空像素使用 -1 作为无几何标记。
2.2 坐标转换与法线
坐标转换与根据深度重建法线的内容,我已经在 SSR 文章中详细介绍过,这里不再重复推导,只保留阅读后续代码所需的两个约定。
第一,当前工具函数把相机前方深度表示为观察空间的正 Z。投影时则要把 Z 取反,因为 Unity 的投影矩阵按相机前方为负 Z 的约定工作:
float4 clip = mul(UNITY_MATRIX_P, float4(vsPos.xy, -vsPos.z, 1.0));
float2 uv = (clip.xy / clip.w) * 0.5 + 0.5;
第二,法线由观察空间位置的屏幕导数计算:
float3 normal = normalize(cross(ddy(position), ddx(position)));
导数重建出的朝向可能随三角形绕序和平台约定发生翻转,因此 SSGI 在采样前让它朝向相机:
if (dot(normal, -viewPos) <= 0.0)
normal = -normal;
这样生成的半球才位于当前可见表面的外侧。
2.3 BuildHemisphereDirection:为每个像素生成采样方向
当前版本的方向生成代码如下:
static const uint2 SSGI_R2_STEP24 = uint2(12664746u, 9560334u);
static const float SSGI_R2_SCALE24 = 1.0 / 16777216.0;
float2 HashRandom(float2 pixelPosition)
{
float3 p3 = frac(float3(pixelPosition.xyx)
* float3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return frac((p3.xx + p3.yz) * p3.zy);
}
float3 BuildHemisphereDirection(
float3 normal,
float2 pixelPosition,
int rayIndex)
{
uint sampleIndex =
((uint)clamp(_SSGISampleOffset, 0, 65535)
+ (uint)rayIndex) & 0xFFFFu;
uint2 phase24 =
(uint2(sampleIndex, sampleIndex) * SSGI_R2_STEP24)
& uint2(0x00FFFFFFu, 0x00FFFFFFu);
float2 temporalSample = float2(phase24) * SSGI_R2_SCALE24;
float2 xi = frac(HashRandom(pixelPosition) + temporalSample);
return SampleHemisphereCosine(xi.x, xi.y, normal);
}
这段代码包含三层工作:像素哈希负责让相邻像素使用不同的起始相位;R2序列负责让连续样本更均匀地覆盖二维单位正方形;Unity 的 SampleHemisphereCosine 再把二维样本映射到法线半球。
2.3.1 像素哈希中的常数
HashRandom 使用的是 David Hoskins的无三角函数Hash思路。这里的 0.1031、0.1030、0.0973 和 33.33 都不是光照公式中的物理常数。
0.1031、0.1030、0.0973:分别参与pixelPosition.xyx三个分量的经验性混合;33.33:参与点积反馈的经验性偏移;frac:把结果折回 [0,1),得到可作为采样相位的二维值。
这些值继承自所引用的 Hash 构造,用途是混合输入并产生可用的伪随机相位,而不是光照推导出的常数,也不负责定义方向采样的概率密度。真正决定方向概率密度的是最后的 SampleHemisphereCosine。
2.3.2 R2 序列中的常数
R2 低差异序列常用下面的二维增量:
g\approx1.324717957 是满足 g^3=g+1 的塑性常数。代码没有直接存浮点增量,而是把它们量化为 24 位整数:
12664746 / 16777216 ≈ 0.754877686
9560334 / 16777216 ≈ 0.569840312
因此,各常数的作用是:
12664746u:R2 序列第一维增量 1/g 的 24 位定点表示;9560334u:R2 序列第二维增量 1/g^2 的 24 位定点表示;16777216:2^{24},把 24 位整数重新缩放到 [0,1);0x00FFFFFF:低 24 位掩码,相当于对 2^{24} 取模;65535:Inspector 输入允许的最大样本偏移,即 16 位无符号整数的最大值;0xFFFF:低 16 位掩码,让sampleIndex在 0 到 65535 之间循环;rayIndex:保证同一像素在同一帧内的多条光线取得连续但不同的序列项;_SSGISampleOffset:让下一帧从序列的后续位置继续采样。
使用 24 位定点相位还有一个实际好处:2^{24} 是单精度浮点能够连续精确表示整数的边界。先用整数完成乘法和取模,再转换为 float2,可以让序列循环行为明确,不依赖不断累加浮点数产生的漂移。
最后,将 R2 相位加到像素哈希上并再次 frac:
float2 xi = frac(HashRandom(pixelPosition) + temporalSample);
R2 决定“样本之间如何铺开”,像素哈希决定“每个像素从哪个相位开始”。二者结合后,相邻像素不会出现完全相同的方向序列;同一像素在 16 位序列回绕之前,也会持续取得不同的后续样本。
2.4 这些方向是半球均匀分布吗
严格来说,不是。
xi.x 和 xi.y 位于二维单位正方形内,可以把它们看成近似均匀的二维样本;但经过 SampleHemisphereCosine 后,输出方向是余弦加权半球分布,不是按立体角均匀的半球分布。
当前调用的是 Unity Sampling.hlsl 中不构造切线坐标系的重载:
real3 SampleHemisphereCosine(real u1, real u2, real3 normal)
{
real3 pointOnSphere = SampleSphereUniform(u1, u2);
return SafeNormalize(normal + pointOnSphere);
}
Unity 在这段代码上给出的引用是Francesco Cifariello Ciardi 的 Cosine Weighted Hemisphere Sampling without a Tangent Frame。它先在整个单位球面均匀取一点 \mathbf s,再计算:
为什么结果几乎总是落在法线上半球?因为 \mathbf n 和单位球面点 \mathbf s 的夹角最大为 \pi。除 \mathbf s=-\mathbf n 这一零测度特例外,两者相加后的方向位于夹角平分线上,所以输出方向与法线的夹角在 [0,\pi/2] 内。反向点会让向量和变为零,Unity 因此使用 SafeNormalize 处理这一数值边界;下面的概率推导对除此以外的方向成立。
还可以进一步推导它的概率密度。设球面点与法线夹角为 \gamma,相加归一化后输出方向与法线夹角为 \theta,则:
单位球面均匀采样的概率微元是:
代入 \gamma=2\theta 和 \mathrm d\gamma=2\mathrm d\theta:
而半球立体角微元为:
所以最终的方向 PDF 为:
这说明它在方位角 \phi 上没有偏向,但在天顶角上更偏向法线方向。越接近法线,样本越密集;越接近半球边缘,样本越稀疏。
Unity 同一文件里还有一个先采样圆盘、再投影到半球的重载,该重载注释引用了 PBRT v3 第 780 页。当前代码使用的是带 normal 参数的无切线框架版本,因此这里应以前一个网页引用作为直接出处,不能把两个重载的引用混在一起。
2.5 为什么代码里没有 ndl 和 PDF
当前 Trace Pass 命中后只做了下面的累加:
if (hit.hit)
{
float3 hitRadiance = SAMPLE_TEXTURE2D_X(
_SSGIRadianceTexture,
sampler_LinearClamp,
hit.hitUV).rgb;
irradiance += hitRadiance;
}
float3 outgoingRadiance = irradiance / rayCount;
这里没有显式乘上 N\cdot L,也没有除以 PDF。这不是漏写,而是余弦加权重要性采样带来的解析抵消。
对于理想 Lambert 漫反射:
采样方向的 PDF 为:
把它们代回蒙特卡洛估计中的单样本权重:
N\cdot L 与 PDF 中的余弦项抵消,分子和分母中的 \pi 也同时抵消,所以 shader 只需累加命中方向取得的辐射。
当前版本还没有在这里乘当前表面的漫反射率 \rho。它把命中位置的相机颜色作为 hitRadiance,求平均后再乘 _SSGIIntensity,最终按 _SSGIMaxBlend 叠加到原图。因此变量名虽然叫 irradiance,实际输出更接近一个经过简化和美术缩放的一次间接辐射近似,而不是材质分离后严格定义的辐照度。
还有一个容易忽略的细节:结果除以固定的 rayCount,而不是除以命中数量。没有命中的光线等价于贡献零。如果只对命中光线求平均,就会在可见性低的位置人为放大剩余样本,产生偏亮结果。
2.6 从表面发出光线
每条方向生成后,射线起点会沿法线偏移一小段距离:
float3 rayStartVS = viewPos + normal * _SSGIOriginBias;
float3 rayEndVS = rayStartVS + rayDir * _SSGIMaxDistance;
_SSGIOriginBias 默认是 0.01,用于减轻射线一开始就命中当前表面的自相交问题。_SSGIMaxDistance 默认是 4.0,限制一次间接光查询的观察空间距离,也限制屏幕空间缺失造成的影响范围。
在默认关闭 Use HiZ Tracing 时,代码调用 MarchScreenSpaceRayBinary。这里不展开其他步进方法,只介绍这个默认分支:先沿投影后的线段进行有限次数的粗粒度深度检查,找到第一个采样到的表面后方位置,再把它与前一个步进位置组成区间进行二分细化。当前代码没有额外验证前一个位置一定处于表面前方,因此这个区间不能被严格称为一次完整的前后穿越括区。
2.7 MarchScreenSpaceRayBinary 的基本结构
函数接收观察空间起点、终点、最大检查次数、厚度阈值和二分次数:
ScreenSpaceRayHit MarchScreenSpaceRayBinary(
float3 rayStartVS,
float3 rayEndVS,
int maxSteps,
float thickness,
int binarySteps)
它的工作可以分为四步:
- 用
ClipScreenSpaceRaySegment保证观察空间线段可以安全投影; - 将起点和终点投影为屏幕 UV;
- 沿屏幕线段寻找第一个采样到的表面后方位置,并保存它与前一个步进位置;
- 在该区间内进行
binarySteps次二分,最后用thickness验证交点。
观察空间深度没有直接线性插值,而是插值 1/z:
float invZ0 = 1.0 / rayStartVS.z;
float invZ1 = 1.0 / rayEndVS.z;
这是因为透视投影后,屏幕坐标上的线性参数与观察空间 Z 不再保持线性关系,而 1/z 可以在屏幕线段上做透视正确插值。
当射线深度大于场景深度时:
if ((1.0 / currentInvZ) > sceneZ)
说明射线已经走到深度缓冲所代表表面的后方。此时,代码把前一个步进位置记为 lo,当前后方位置记为 hi。二分阶段不断检查中点并收缩区间。由于默认分支没有先确认 lo 一定在表面前方,这个过程是在细化“首个检测到的后方样本附近”,而不是从数学上保证找到了射线的第一次真实穿越。
最终只接受满足下面条件的结果:
float depthDiff = (1.0 / hiInvZ) - sceneZ;
if (depthDiff > 0.0 && depthDiff < thickness)
depthDiff > 0 要求射线确实位于表面后方;depthDiff < thickness 则避免把射线穿过物体后遇到的远处深度误判为当前交点。
当前调用使用 SCREEN_SPACE_RAY_BINARY_STEPS,其值为 10。也就是说,在检测到表面后方位置后,所记录的区间会再被折半十次。
2.8 ClipScreenSpaceRaySegment 为什么必须先执行
在整个求交函数中,ClipScreenSpaceRaySegment 很容易被误认为普通的屏幕边界裁剪,但它真正处理的是观察空间线段与相机近裁剪面之间的关系。
#define SCREEN_SPACE_RAY_NEAR_EPSILON 0.0001
bool ClipScreenSpaceRaySegment(
inout float3 rayStartVS,
inout float3 rayEndVS)
{
float nearPlane = max(
_ProjectionParams.y,
SCREEN_SPACE_RAY_NEAR_EPSILON);
if (rayStartVS.z <= nearPlane)
return false;
float3 ray = rayEndVS - rayStartVS;
if (dot(ray, ray) <=
SCREEN_SPACE_RAY_NEAR_EPSILON
* SCREEN_SPACE_RAY_NEAR_EPSILON)
return false;
if (rayEndVS.z <= nearPlane)
{
float clipT =
(rayStartVS.z - nearPlane)
/ (rayStartVS.z - rayEndVS.z);
rayEndVS = lerp(rayStartVS, rayEndVS, clipT);
rayEndVS.z = nearPlane;
}
return true;
}
它解决三个问题。
2.8.1 起点不在近裁剪面后的有效区间
if (rayStartVS.z <= nearPlane)
return false;
在当前“相机前方为正 Z”的约定下,小于等于近裁剪面深度的位置不在相机接受的有效深度区间内。接近零的 Z 还会让 1 / rayStartVS.z 产生数值问题,所以这里直接拒绝以该位置为起点的线段。
nearPlane 使用 _ProjectionParams.y,也就是相机真实近裁剪面;0.0001 只是安全下限,防止近裁剪面参数异常接近零。
2.8.2 线段长度接近零
float3 ray = rayEndVS - rayStartVS;
if (dot(ray, ray) <= 0.0001 * 0.0001)
return false;
这里比较的是长度平方,因此阈值也要平方。退化线段没有稳定方向,后续计算屏幕跨度、步长和射线参数都没有意义。
2.8.3 终点穿过近裁剪面
起点有效并不代表终点也有效。漫反射方向可能朝向相机,导致 rayEndVS.z 落到近裁剪面之前。此时不能简单拒绝整条光线,因为从起点到近裁剪面之间仍有一段有效线段。
设线段参数为:
要求裁剪点的 Z 等于 z_{near},解得:
这就是代码中的 clipT:
float clipT = (rayStartVS.z - nearPlane)
/ (rayStartVS.z - rayEndVS.z);
然后用 lerp 把终点收缩到近裁剪面:
rayEndVS = lerp(rayStartVS, rayEndVS, clipT);
rayEndVS.z = nearPlane;
最后显式写入 nearPlane,可以消除插值浮点误差,保证后续倒数深度和投影都在预期边界上。
2.8.4 它不是屏幕边界裁剪
工程里另有一个 ClipScreenSpaceRayToViewport,用于把投影后的 UV 线段裁剪到半像素内缩的屏幕范围,并同步插值两端的倒数深度。
这两个函数的空间和职责不同:
ClipScreenSpaceRaySegment:投影前,在观察空间裁剪近裁剪面;ClipScreenSpaceRayToViewport:投影后,在 UV 空间裁剪屏幕边界。
当前 MarchScreenSpaceRayBinary 中,Viewport 裁剪调用处于注释状态;真正始终执行的是近裁剪面线段处理。随后如果 UV 走出 [0,1],循环会直接结束。
2.9 当前帧结果如何累积
Trace Pass 输出:
return float4(
outgoingRadiance * _SSGIIntensity,
min(viewPos.z, 65500.0));
RGB 是当前帧间接光,Alpha 是线性眼深。65500.0 接近半精度浮点可表示的有限上界,用来避免写入 R16G16B16A16_SFloat 时深度溢出。天空则输出 float4(0, 0, 0, -1.0)。
Temporal Pass 首先读取当前值。历史尚未建立时,直接返回当前帧:
if (_SSGIHistoryValid <= 0.0)
return current;
随后比较当前像素和历史像素是否都包含几何。如果一个是天空、另一个是几何,或者当前像素本身是天空,也不使用历史:
bool currentHasGeometry = current.a >= 0.0;
bool historyHasGeometry = history.a >= 0.0;
if (currentHasGeometry != historyHasGeometry || !currentHasGeometry)
return current;
通过有效性与深度检查后,才执行固定 EMA:
float3 accumulated =
history.rgb
+ (current.rgb - history.rgb) * _SSGIHistoryWeight;
在蒙特卡洛积分公式中,Trace Pass 负责产生新的 X_t,而这个 Temporal Pass 负责实现跨帧求和与归一化所对应的“样本聚合”部分。因为这里使用固定 EMA,它不是严格的 1/N 累积器,而是一个对近期样本赋予更高权重的在线估计器。
2.10 Temporal Response
Inspector 中的 Temporal Response 直接传给 _SSGIHistoryWeight,默认值为 0.05,范围是 0.001 到 1.0。
根据混合公式:
它表示的是当前帧权重,而不是历史帧权重。
- 值较小:更多保留历史,静止画面更稳定,但光照变化后的响应更慢,残影风险更高;
- 值较大:更快跟随当前帧,动态响应更好,但单帧噪声也更明显;
- 值为
1:结果完全等于当前帧,相当于不进行时间平滑。
默认 0.05 意味着每次更新只吸收当前结果与历史差值的 5%。这并不等价于“固定累积 20 帧后停止”,而是让旧样本的影响按指数持续衰减。
2.11 Temporal Depth Threshold
Temporal 不能只看相同 UV,因为同一像素在不同帧可能已经对应另一个表面。当前实现用线性眼深判断历史是否仍可复用:
float currentDepth = current.a;
float historyDepth = history.a;
float depthThreshold = max(
_SSGIHistoryDepthThreshold,
currentDepth * 0.01);
if (abs(currentDepth - historyDepth) > depthThreshold)
return current;
Temporal Depth Threshold 默认值为 0.1,最小允许值为 0.001。它使用的是观察空间线性眼深,因此单位与 Unity 场景单位一致。
实际阈值不是始终等于 Inspector 值,而是:
这表示近处至少允许 Temporal Depth Threshold 指定的绝对误差;远处则至少允许当前深度的 1% 误差。这样做是因为远距离深度变化和重建误差通常更大,如果始终使用很小的固定阈值,远处历史会频繁失效。
- 阈值过小:历史容易被拒绝,Temporal 降噪效果减弱,画面更容易闪烁;
- 阈值过大:不同表面可能错误共享历史,在遮挡边缘产生漏光或残影。
当深度差超过阈值时,shader 直接返回当前帧,而不是降低历史权重。这是一种明确的 History Rejection。
2.12 历史何时重置
除了逐像素深度拒绝,C# 侧还会在以下情况使整个相机的 Temporal 历史失效:
- Render Texture 描述发生变化,例如分辨率变化;
- 相机位置或旋转发生变化;
- 非抖动投影矩阵发生变化;
- SSGI 设置发生变化;
- 当前相机第一次参与渲染。
每个相机都有独立的历史纹理、采样偏移和上一帧状态,避免 Scene View、Game View 或其他相机之间互相污染。
这也再次说明当前版本的定位:它通过静止相机下的跨帧采样降低噪声,并用重置与深度拒绝保证历史基本有效;它还不是带运动矢量、法线拒绝、邻域裁剪和动态重投影的完整 Temporal SSGI。
2.13 小结
到这里,当前 SSGI 的基本框架已经串联起来:
- 用深度重建可见表面;
- 用像素哈希和 R2 序列生成跨像素、跨帧变化的二维样本;
- 通过 Unity 的无切线框架余弦采样,把样本映射到法线上半球;
- 用近裁剪面裁剪保证射线线段能够安全投影;
- 在屏幕空间检测到表面后方位置,二分细化所记录的区间,再用厚度阈值验证命中;
- 将命中辐射按固定光线数求平均;
- 用深度拒绝和固定 EMA 聚合跨帧样本;
- 用深度与法线感知的迭代滤波降噪,再将间接光叠加回原始画面。
这里最容易混淆的两点也可以给出明确结论。
第一,BuildHemisphereDirection 并没有生成按立体角均匀的半球方向。它的二维输入近似均匀,方位角也没有偏向,但最终方向服从 p(\omega)=(\mathbf n\cdot\omega)/\pi 的余弦加权分布。
第二,代码没有显式写出 ndl 和 PDF,是因为 Lambert BRDF、余弦项与余弦加权 PDF 已经在估计式中解析抵消,而不是因为蒙特卡洛积分不需要它们。

深度与法线感知的迭代滤波
3.1 为什么不再使用普通高斯模糊
Temporal累积可以降低时间维度上的噪声,但每帧光线数量较少时,画面中仍然会留下局部颗粒。之前的实现使用两个 5-tap 高斯 Pass,先横向模糊,再纵向模糊:
[1, 4, 6, 4, 1] / 16
这组权重只与采样点到中心的空间距离有关。它不知道两个像素是否位于不同物体,也不知道它们的表面朝向是否一致,因此会把墙后的间接光混到前景轮廓上,或者把转角处本应保留的明暗变化抹平。
参考《Unity实现SSGI》中的空间滤波方法,我们把高斯模糊替换为深度与法线联合引导的 5-tap 十字滤波。它仍然会平均邻域中的间接光,但只有与中心像素属于相近深度、相近表面朝向的样本才能取得较大权重。
3.2 五点十字采样核
每次滤波使用中心点和上下左右四个邻居:
(0, 1)
(-1, 0) (0, 0) (1, 0)
(0,-1)
代码中保存的偏移为:
static const float2 SSGI_FILTER_OFFSETS[5] =
{
float2(0.0, 0.0),
float2(1.0, 0.0),
float2(-1.0, 0.0),
float2(0.0, 1.0),
float2(0.0, -1.0)
};
对于中心 UV u、离散偏移 o_i 和滤波半径 r,第 i 个采样位置为:
当前代码复用 Inspector 中已有的 Blur Spread 作为基础半径,不增加新的设置。第一次滤波使用:
第二次滤波使用:
默认 Blur Spread 为 1.5,因此两次采样半径分别为 1.5 和 3.0 像素。两次都是完整的二维十字滤波,不再是一个横向 Pass 加一个纵向 Pass。
参考文章没有规定固定的迭代次数和半径增长序列,只说明后续迭代应扩大 _GIFilterSize。我们的实现把它映射到现有的两个 Ping-Pong Pass,并选择 1×、2× 两级半径,避免增加新的 Render Pass 和配置项。
3.3 深度权重
Temporal Pass 已经把当前像素的线性深度写入 Alpha,因此滤波阶段不需要重新把 Alpha 从设备深度线性化:
float centerDepth = center.a;
float sampleDepth = sample.a;
深度权重定义为:
其中 z_c 是中心像素线性眼深,z_i 是邻居像素线性眼深。对应代码为:
float depthWeight = exp(-SSGI_FILTER_DEPTH_FALLOFF * abs(centerDepth - sampleDepth));
SSGI_FILTER_DEPTH_FALLOFF 固定为 5.0,来自参考文章的实现。深度差为零时权重为 1;深度差增大时,权重按指数快速衰减。例如线性深度相差 1 个 Unity 单位时,深度权重只剩 e^{-5}\approx0.0067。
这比普通高斯模糊多了一条几何约束:屏幕上彼此相邻、实际深度却相差很大的两个像素,不会轻易共享间接光。
3.4 法线权重
只比较深度仍然不够。墙角两侧的像素可能深度接近,但法线方向完全不同。如果仅使用深度,它们仍会互相模糊。
Blur Pass 已经通过 ConfigureInput(Depth | Color) 获得相机深度。我们继续复用第二章中的位置与法线重建函数,在每个采样 UV 上得到观察空间法线:
float3 viewPos = ComputeViewSpacePosition(uv, rawDepth);
float3 normal = GetNormalFromPosition(viewPos);
if (dot(normal, -viewPos) <= 0.0)
normal = -normal;
随后使用中心法线与采样法线的夹角余弦作为权重:
对应代码为:
float normalWeight = saturate(
dot(centerNormal, sampleNormal));
两个法线同向时权重为 1;法线正交或背向时权重为 0。因此,滤波会尽量留在同一个连续表面上,而不是穿过明显折角。
3.5 组合权重与归一化
每个样本的最终权重是深度权重与法线权重的乘积:
最终颜色为加权和除以权重总和:
HLSL 实现为:
float weight = depthWeight * normalWeight;
filtered += sample.rgb * weight;
weightSum += weight;
filtered /= max(0.001, weightSum);
参考文章在每个权重前还写入了 1/N。由于最后还会除以所有权重之和,同一个常数同时出现在分子和分母中,会完全抵消,所以当前代码省略了它,结果不变。
这里也没有使用颜色或亮度差异作为权重。换句话说,它是一个类似双边滤波的几何引导滤波器,但不是包含空间高斯项与颜色高斯项的完整传统双边滤波。
3.6 无效样本与天空处理
采样邻居前,代码会检查 UV 是否仍在屏幕内:
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 ||
sampleUV.y < 0.0 || sampleUV.y > 1.0)
continue;
SSGI 结果使用负 Alpha 表示天空或无几何像素,因此这类邻居也不会加入滤波:
if (sample.a < 0.0)
continue;
此外还会按当前平台的 Z Buffer 方向检查相机原始深度。只有存在有效几何时,才进行位置和法线重建。中心像素本身无效时直接返回中心值,避免天空颜色向几何边缘扩散。
3.7 为什么必须保留中心 Alpha
滤波只处理 RGB,不能对 Alpha 中的线性眼深做加权平均:
return float4(filtered, center.a);
这是两次迭代能够正确工作的关键。第一次滤波的输出会成为第二次滤波的输入;如果第一次同时模糊深度,第二次看到的就不再是真实几何边界,而是已经被平滑过的深度,边缘保持能力会迅速下降。
保留中心 Alpha 后,每次 Pass 都只扩散间接光颜色,几何引导信息始终与当前像素对应。
3.8 与现有渲染流程的结合
这一节先只关注滤波器本身。Pass 1 和 Pass 2 仍然分别执行两次五点十字滤波;第四章会进一步修改滤波结果的最终去向:它不再进入后处理 Composite,而是写入独立的 irradiance map。
Temporal History
↓
Pass 1:5-tap,半径 = Blur Spread
↓
Pass 2:5-tap,半径 = 2 × Blur Spread
↓
Filtered Irradiance
代码中的调用分别为:
return SampleSSGIEdgeAwareFilter(
input.texcoord,
_BlurSpread);
以及:
return SampleSSGIEdgeAwareFilter(
input.texcoord,
_BlurSpread * 2.0);
最终版本的 C# 侧按照 HistoryWrite → blurB → irradianceRT 的顺序进行 Ping-Pong。Temporal 历史管理保持不变,但最终结果不再写入 compositeRT;具体原因与新的帧间数据流将在第四章说明。
3.9 参数与局限
Blur Spread 现在表示基础边缘保持滤波半径,而不再表示高斯采样间距。
- 值较小:更能保留局部细节,但低频噪声消除较弱;
- 值较大:覆盖范围扩大,但深度与法线近似的邻居仍可能被过度平滑;
- 值为
0:五个采样位置都退化到中心点,输出保持不变。
这套滤波解决的是普通高斯模糊跨深度和折角扩散的问题,但它仍有明确边界:法线来自深度导数,在轮廓附近可能不稳定;它没有颜色、亮度或方差权重;固定两次迭代也不是完整的多尺度降噪器。后续如果要继续提高质量,可以在保持当前几何权重的基础上增加亮度方差估计,但那不属于本文当前实现。
3.10 小结
新的空间滤波不再无条件平均周围像素。深度权重阻止间接光跨越前后景边界,法线权重阻止它穿过表面折角,中心 Alpha 保留则保证第二次迭代仍然使用未被污染的几何信息。
最终,我们用现有两个 Ping-Pong Pass 完成 1× 和 2× 半径的十字滤波,在不增加 Render Pass 和配置项的前提下,减少普通高斯模糊造成的细节丢失。

在材质着色阶段应用 Irradiance
4.1 问题:间接光没有经过目标表面的 Albedo
前面的实现把 SSGI、时间累积和滤波结果直接通过 Composite Pass 加到相机颜色上:
Camera Color + Filtered SSGI → Composite → Final Color
这种做法在白色材质上不容易暴露问题。白色表面的 albedo 接近 (1, 1, 1),间接光即使没有经过材质调制,结果看起来也大致合理。但当目标物体是红色、蓝色或较暗材质时,问题就会很明显:间接光仍然以接近白色的形式叠加,表面像被一层白雾照亮,材质本身的颜色被冲淡。
更准确地说,irradiance map 本身呈白色不一定是错误。Irradiance 描述的是到达表面的入射照明,它不应该预先包含“当前接收表面”的材质颜色。真正的问题在于旧实现把 irradiance 当成了已经完成材质响应的 outgoing radiance,直接加到了最终颜色中。
4.2 Irradiance、Radiance 与 Albedo 的关系
对于 Lambert 漫反射表面,间接出射辐射可以写成:
其中:
- E_{indirect} 是到达当前表面的间接 irradiance;
- \rho 是当前表面的 diffuse albedo;
- L_{o,indirect} 是经过材质反射后、最终离开表面的间接 radiance。
本文的 SSGI 使用余弦加权半球采样。正如第 2.5 节所述,Lambert BRDF 中的 1/\pi、余弦项和采样 PDF 会相互约掉,因此纹理里保存的工程量实际上已经对应可直接乘 albedo 的 diffuse illumination,也就是 E_{indirect}/\pi。所以物体 Shader 中只需要:
不应再额外除一次 \pi,否则会把间接光重复缩小。
4.3 为什么不能继续在 Composite Pass 中处理
后处理 Composite 只看到屏幕颜色和全屏 SSGI 纹理。它不知道当前像素由哪个材质生成,也拿不到该材质经过 _MainTex 与 _BaseColor 组合后的准确 albedo。除非额外输出一张全屏 albedo buffer,否则 Composite 阶段无法正确完成目标表面的漫反射响应。
当前项目已经在 MainLightDirect.shader 中计算每个物体的直接光,而且这里天然拥有材质 albedo。因此最直接的处理方式,是把间接光的应用从后处理阶段前移到物体着色阶段:
Direct Lighting Previous Irradiance
│ │
└──────────────┐ ┌──────────┘
▼ ▼
direct + irradiance × albedo
│
▼
Current Radiance
这样直接光和间接光遵循相同的材质颜色,红色物体接收白色 irradiance 后仍然输出红色的漫反射结果,暗色物体也不会被无条件抬亮。
4.4 在 MainLightDirect.shader 中采样上一帧 Irradiance
首先声明由 Renderer Feature 提供的全局纹理和有效性标记:
TEXTURE2D_X(_SSGIIrradianceTexture);
float _SSGIIrradianceValid;
Fragment Shader 中的 SV_POSITION 已经是当前像素的屏幕投影位置。通过 GetNormalizedScreenSpaceUV 得到归一化 UV 后,就可以读取同一投影点的上一帧 irradiance:
if (_SSGIIrradianceValid > 0.0)
{
float2 screenUV = GetNormalizedScreenSpaceUV(input.positionCS);
half3 irradiance = SAMPLE_TEXTURE2D_X(
_SSGIIrradianceTexture,
sampler_LinearClamp,
screenUV).rgb;
color += irradiance * baseColor.rgb;
}
这里的 baseColor.rgb 是 _MainTex 与 _BaseColor 的乘积,也就是当前实现使用的 albedo。最终的 color 由主光、附加光和间接光共同组成:
使用 TEXTURE2D_X 和对应的采样宏,是为了继续兼容 URP 的 XR 纹理维度;Shader 同时初始化了实例与 Stereo 相关宏,确保屏幕 UV 使用正确的眼睛索引。
4.5 为什么需要一个不透明物体之前的 Setup Pass
SSGI Pass 在 BeforeRenderingPostProcessing 执行,此时本帧不透明物体已经渲染完成。如果只在 SSGI Pass 末尾设置全局纹理,单相机静态场景通常也能在下一帧读到它,但多相机渲染时,全局状态可能仍然指向上一个相机的历史纹理。
因此新增 IrradianceHistorySetupPass,并把它安排在:
RenderPassEvent.BeforeRenderingOpaques
这个 Pass 不计算 SSGI,只负责在每个相机开始绘制不透明物体之前,绑定该相机自己的 irradianceRT 和 _SSGIIrradianceValid。历史只有同时满足以下条件时才可用:
- 该相机已经生成过有效历史;
- 相机位置和旋转没有变化;
- 投影矩阵没有变化;
- SSGI 配置没有变化;
- Render Texture 描述没有变化。
首帧或历史失效时,_SSGIIrradianceValid 为 0,物体 Shader 跳过纹理采样,只输出直接光。Renderer Feature 被销毁时也会清零该标记,避免 Shader 继续访问已经释放的 RTHandle。
4.6 SSGI Pass 只生成下一帧的 Irradiance Map
旧流程的最后一步是 Composite,新流程则明确分离两类纹理:
radianceRT:当前帧已经渲染完成的相机颜色;irradianceRT:当前帧 SSGI、Temporal Accumulate 和两次 Blur 的最终结果,供下一帧物体着色使用。
Radiance map 仍然直接复制当前帧渲染结果:
Blitter.BlitCameraTexture(cmd, sourceHandle, state.radianceRT);
cmd.SetGlobalTexture(RadianceTextureID, state.radianceRT);
随后 SSGI 从这张 radiance map 查询射线命中点的出射颜色,再依次执行:
Current Radiance
↓
SSGI Ray March
↓
Temporal Accumulate
↓
Blur Pass 1
↓
Blur Pass 2
↓
irradianceRT(下一帧使用)
对应的关键 Blit 顺序为:
Blitter.BlitCameraTexture(cmd, sourceHandle, state.blurA, material, 0);
Blitter.BlitCameraTexture(cmd, state.blurA, state.HistoryWrite, material, 3);
Blitter.BlitCameraTexture(cmd, state.HistoryWrite, state.blurB, material, 1);
Blitter.BlitCameraTexture(cmd, state.blurB, state.irradianceRT, material, 2);
原来的 Composite Shader Pass、compositeRT 和 _SSGIMaxBlend 因此都可以删除。SSGI Pass 不再修改 sourceHandle,它只读取本帧 radiance 并生产下一帧需要的 irradiance。
4.7 完整的帧间数据流
以第 t 帧为例,新的执行顺序为:
IrradianceHistorySetupPass绑定第 t-1 帧的irradianceRT;MainLightDirect.shader计算本帧直接光,并加入irradiance_{t-1} × albedo;- 得到第 t 帧完整的相机颜色;
- SSGI Pass 把该颜色复制到
radianceRT; - 以
radianceRT为命中点辐射来源,计算本帧 SSGI; - 执行 Temporal Accumulate 与两次边缘保持 Blur;
- 把最终结果写入
irradianceRT,留给第 t+1 帧。
可以写成下面的递推关系:
由于 radianceRT 包含本帧已经应用的上一帧间接光,后续 SSGI 也能看到这部分能量,形成有限的帧间多次反弹近似。它仍然不是严格的多反弹路径追踪,能量稳定性继续由 SSGI Intensity、历史响应和屏幕空间可见性共同决定。
4.8 局限
当前实现按相同屏幕 UV 读取上一帧 irradiance,没有对动态物体执行运动矢量重投影。相机位姿变化时会直接禁用旧历史,避免把明显错位的纹理用于当前帧;但相机静止、物体自身移动时,仍可能短暂读取到旧位置的间接光。后续如果要支持稳定的动态场景,需要保存上一帧矩阵或运动矢量,并结合 irradiance Alpha 中保留的线性深度做重投影与遮挡验证。
此外,这种方案要求接收 SSGI 的物体使用会采样 _SSGIIrradianceTexture 的材质 Shader。其他 URP Lit 材质不会自动获得这部分间接光;若要覆盖整个项目,应把相同逻辑接入统一的 Shader Library、Shader Graph Custom Function,或修改项目使用的通用 Lit Shader。
4.9 小结
这一修改解决的核心问题不是“把白色 irradiance 染色”,而是把光照与材质响应放回正确的阶段。SSGI Pass 负责估计、累积并滤波入射的间接照明,物体 Shader 负责用当前表面的 albedo 把它转换为出射 radiance。最终管线不再在后处理阶段无条件叠加间接光,而是在下一帧的不透明物体渲染阶段计算:
这样既保留了 radiance map 复制当前帧结果的屏幕空间采样方式,也让不同颜色和不同反射率的场景物体得到符合材质属性的间接光响应。

参考资料
- Matt Pharr, Wenzel Jakob, Greg Humphreys, Physically Based Rendering: From Theory to Implementation, 3rd Edition,半球余弦采样见 p.780。
- Francesco Cifariello Ciardi, Cosine Weighted Hemisphere Sampling without a Tangent Frame。
- Martin Roberts, The Unreasonable Effectiveness of Quasirandom Sequences。
- David Hoskins, Hash without Sine。
- Unity Technologies, URP 14.0.11
ShaderLibrary/Sampling/Sampling.hlsl。 - jiaozi158, UnitySSGIURP。
- 傻头傻脑亚古兽,Unity实现SSGI,发布于 2023-04-02,最后编辑于 2024-06-04。