SVGF:时空方差引导滤波
SVGF:时空方差引导滤波
每像素每帧只追踪一条或少量路径时,Monte Carlo 光照估计可能在相邻像素之间剧烈变化:一个样本恰好命中光源,另一个样本没有获得贡献。直接做大范围模糊会抹掉阴影和反射细节,只做时间累积又会遇到显露、运动和光照变化。SVGF(Spatiotemporal Variance-Guided Filtering)把两者连接起来:先复用可信历史,再估计亮度的不确定程度,用它控制不同尺度上的空间滤波。
SVGF 是低采样光照的重建滤波器,不是新的光照积分算法。射线如何生成、BRDF 如何采样、可见性如何查询,仍由上游渲染器负责;滤波器通过共享时空样本降低可见噪声,同时引入需要控制的偏差。原始工作由 Schied 等人在 HPG 2017 发表,重点面向每像素一条路径的实时重建。NVIDIA Research 论文页
本文以原论文第 3、4 节解释算法,以 NVIDIA 官方 Falcor 的 SVGFPass 对照工程细节。Falcor 链接对应 2026-09-09 查阅的 master 分支,不是固定发布版;文中的资源布局和伪代码是为讲解重新组织的引擎无关方案,不是 Falcor 源码移植。历史裁剪、镜面信号拆分等扩展会单独说明,不能将它们全部归入 2017 年的原始实现。
一、目标与输入假设
1.1 噪声来自照明,主可见性需要稳定
SVGF 的典型输入是低 spp(samples per pixel)Monte Carlo 随机照明,以及与该照明严格对应的 G-Buffer。这里的“稳定”不是要求相机和物体不动,而是要求当前像素的主命中表面有可信的深度、法线和身份信息,可以追踪到前一帧。
| 输入 | 用途 | 必须明确的约定 |
|---|---|---|
| 低 spp HDR 照明或带材质调制的辐射颜色 | 待重建的随机信号 | 线性颜色空间;是否已乘 Albedo、是否包含自发光;每帧采样策略 |
| Motion Vector | 从当前像素定位历史 | 正负方向、UV/像素单位、Y 轴方向、相机抖动是否包含在内 |
| 当前与历史深度 | 历史拒绝、空间边缘停止 | 同一可比较空间;区分硬件深度与线性深度;给出无效值 |
| 当前与历史法线 | 表面一致性、空间邻域筛选 | 世界/物体空间、编码、归一化、是否包含法线贴图 |
| Albedo 与材质信息 | 去调制、重新调制、可选材质拒绝 | 线性 Albedo;粗糙度与信号类型不能混淆 |
| 表面/网格 ID、有效掩码 | 阻止不同表面共享历史 | ID 在帧间稳定;拓扑或实例重建后不误认旧历史 |
| 前帧照明、矩、历史长度 | 跨帧累积与方差估计 | 同一视图、分辨率和信号语义;生命周期一致 |
论文通过光栅化获得无随机噪声的主可见性 G-Buffer,并使用深度、物体/世界空间法线、Mesh ID 与屏幕空间运动矢量。若主射线本身随机采样景深、运动模糊或透明层,一个像素的表面身份也在随机变化,便超出了这套基本假设。极细的亚像素几何同样可能使有效邻居很少,限制滤波能力。原论文,第 3、4、6 节
1.2 它能够改善什么,又不能恢复什么
合适的任务包括低 spp 漫反射间接光、随机软阴影,以及有足够可观测信息的粗糙反射。它并不保证把任意单帧噪声变成无偏真值:如果连续多帧都没有采到某个小光源,滤波器没有证据知道那里应当有光;错误的光照积分、系统性漏光和缺失的屏幕外贡献,也不能靠平滑修复。
原论文把直接光和间接光分开重建,使软阴影边界与间接光可以采用各自的统计信息。这个拆分轴与后文的 diffuse/specular 拆分不同:前者区分路径贡献来源,后者区分材质散射分量。工程中可以组合两种拆分,但每增加一路独立信号,都会增加照明历史、矩和空间滤波的成本。原论文,第 3 节
二、完整数据流与照明去调制
2.1 从带纹理的颜色到可滤波的照明
以可分离的漫反射分量为例,约定上游输出满足:
其中 是线性 Albedo, 是单独处理的自发光, 和分式均表示逐通道运算, 是本文约定的去调制照明。若上游输出已是这种照明,就直接使用,不再除一次 Albedo。
对于 Lambert 漫反射,若输入定义为物理辐照度 ,出射漫反射辐射应为 。因此必须说明 已包含在哪一层;“Irradiance”这个资源名本身不足以证明单位正确。对于多层 BRDF 和镜面分量,也不能把普通 Base Color 直接当成通用可除因子。
去调制的作用是把棋盘格、文字贴图等确定的材质高频从待滤波信号中移开。滤波完成后乘回当前像素的 Albedo,纹理细节便不必依靠照明滤波器保留。黑色或接近黑色的通道需要特殊处理:分母下限可以避免除零,却不能从零反射率颜色反推出可靠照明;上游直接输出去调制信号通常更清楚。
Falcor 在重投影阶段先减自发光再除 Albedo,最终阶段再乘回 Albedo 并加自发光。这是理解其输入契约的关键,不能把带自发光的整张场景颜色直接拿来除材质颜色。SVGFReproject.ps.slang、SVGFFinalModulate.ps.slang
2.2 当前帧与跨帧反馈
实线表示本帧依赖,虚线表示跨帧保存。图中把矩方差及历史不足回退统称为时空方差估计;它不是一个独立于历史有效性的“噪声检测器”。两种空间操作也要区分:历史不足时在邻域收集矩,解决样本缺失;每轮 à-trous 之前对方差做小范围平滑,解决引导值不稳定。
显示用的最终结果与下一帧的照明历史可以取自不同阶段。论文选择第一轮 wavelet 输出作为颜色历史,避免每帧都将最强的空间模糊继续反馈;Falcor 将反馈层做成可配置项,矩历史则沿重投影累积的路径保存。实现时应分别记录三件事:哪一层颜色进入历史、矩从哪里更新、最终哪一层用于显示。原论文,第 4.3 节、SVGFPass.cpp
三、历史重投影、拒绝与长度管理
3.1 先固定运动矢量的方向
本文定义 为“当前 UV 指向上一帧 UV”的偏移,因此:
如果引擎输出的是 ,这里就必须改为减号。Falcor 的相关代码采用加偏移的约定,站内 Voxel GI 文章使用的 URP 约定则采用减号。符号必须由生产运动矢量的一方定义,不能从另一个引擎的代码中直接照搬。
工程接入时先用静态场景平移相机,再用固定相机移动物体,分别验证相机运动和物体运动。仅使用前后相机矩阵无法追踪独立运动的物体;蒙皮、顶点动画和形变还需要前一帧对应的表面位置。若 UV 中已经包含投影抖动,额外补偿一次抖动会导致历史长期偏移。
3.2 逐 tap 校验,再归一化历史
重投影坐标通常不落在整数像素中心。对于参与双线性插值的每个历史样本 ,先计算有效性:
是当前表面在上一帧深度空间中的预期深度, 是转换到校验所用空间后的对应法线。比较不同相机坐标系中的两个线性深度,或者比较物体旋转前后的世界法线,都可能把合法历史误拒绝。阈值可结合深度变化率、距离和像素覆盖范围设置;公式表达的是本文工程契约,不是逐字复现 Falcor 的启发式阈值。
对于原始双线性权重 ,本文采用有效样本重新归一化:
矩使用相同有效样本集合和权重。不能先让硬件插值把前景、背景颜色混在一起,再只检查插值后的一个深度。历史长度则可从有效 tap 中取保守值,例如最小长度;直接读取一个未经相同校验的最近邻长度,可能让新显露像素继承不合理的长历史。
论文先检查 2×2 重投影样本,找不到一致表面时再尝试 3×3 邻域,以改善细几何的历史查找;仍无匹配才判为显露。扩大查找范围只是补救采样对齐,不能放松“同一表面”的判断。Falcor 中也有双线性路径和较大邻域的回退,具体阈值与长度读取方式应视为该版本的实现选择。原论文,第 4.1 节、SVGFReproject.ps.slang
3.3 历史长度如何控制当前样本权重
令 表示当前像素连续有效历史的长度指示量, 为上限:
为照明和矩分别设置最小当前帧权重 、:
这里的 始终表示新样本权重。历史无效时两者都设为 1,并直接写当前值,避免读取未初始化数据后再依赖乘零消除;NaN * 0 不会变成合法值。历史较短时 避免第一个样本长期支配结果,达到权重下限后转为指数移动平均。
Falcor 的重投影代码把历史长度限制为 32,并对颜色与矩分别使用上述形式的权重;32 是其工程常数,不是 SVGF 的数学要求。论文报告的时间混合参数为 0.2,也只应作为原实验设置参考。SVGFReproject.ps.slang、原论文,第 4.1 节
有了 EMA、重投影插值和空间反馈, 就不等于独立 Monte Carlo 样本数。历史上限也不是长度恰好为 的滑动窗口:旧样本按指数衰减,并不会在某一帧整齐移除。响应速度同时受权重下限与历史上限控制。
3.4 显露与全局 reset
Disocclusion 指原先被遮挡的表面现在显露。它通常是逐像素失效:该像素的照明、矩和长度一起重新开始,并启用空间矩回退。Camera cut 则应作为全局失效:直接将当前视图整套历史标记无效,不能期待运动矢量自行解释两个无连续关系的视角。
分辨率或视图布局变化、历史资源重建、相机切换、信号由“间接光”改为“全部光照”、材质拆分方式变化,都需要重建或明确转换历史。普通连续运动不应触发每帧全局 reset,否则时间复用根本无法积累。各相机、各眼睛和各信号需要独立历史,帧中断后也应检查连续性。
四、一阶、二阶矩与时空方差
4.1 统计的是亮度样本,不是颜色均值的平方
令 为去调制线性照明的亮度, 是与工作色域匹配的线性 RGB 到亮度映射。其一阶、二阶原始矩为:
以本帧原始亮度 更新历史矩:
必须先对当前随机样本求平方,再累积到二阶矩。若先时间平均颜色,再对平均值平方,会丢掉样本波动,系统性低估方差。颜色历史可以来自第一轮 à-trous,而矩仍追踪原始随机照明,两者不要求数值上互相等于“颜色的亮度”和“亮度平方”。
用于处理浮点舍入产生的微小负值,不是异常数据清理器。HDR 下 可能超过半精度范围,两个接近的大数相减也可能严重损失精度。工程起点可使用 32 位浮点存储矩,待验证亮度范围、曝光和误差后再决定压缩格式。
SVGF 使用的是时空统计得到的实用方差代理。EMA、样本相关性、历史插值和邻域混合都使它区别于严格独立样本的无偏方差估计,也不应未经推导直接将其除以历史长度,宣称得到均值误差或置信区间。论文专门指出,其面向 1 spp 的估计策略不能直接当作增加样本后必然收敛的通用统计器。原论文,第 4.2、5.6 节
4.2 一帧历史的零方差并不代表没有噪声
历史刚重置时只有一个观测,、,于是 。这只是没有足够观测来估计波动。如果把它理解成“完全可信”,亮度边缘停止函数会把噪点也当成应该保留的细节。
当 时,在当前图像的同表面邻域内收集已经更新的矩,记几何引导权重为 :
要混合一阶、二阶矩后再求方差,不能只平均一片全为零的逐像素方差。邻域内的阴影边界和真实亮度梯度也会提高这个估计,因此空间回退需要深度、法线等约束,且只在短历史阶段承担主要作用。
原论文在历史少于 4 帧时采用 7×7 的深度/法线双边邻域。查阅的 Falcor SVGFFilterMoments 同时预滤波照明和矩,权重还使用亮度差,并对短历史方差乘以 来增强初始滤波。这些细节应分别标明来源,不应把额外增强因子理解为严格统计校正。原论文,第 4.2 节、SVGFFilterMoments.ps.slang
4.3 曝光必须保持统计量一致
本文默认在曝光前的线性域滤波。如果引擎使用 pre-exposure,设上一帧到当前帧的信号缩放比为 ,则历史需要同步转换:
这来自一阶、二阶量的尺度关系,是工程推导。若只缩放 RGB,方差引导就会随曝光跳变;无法确认转换关系时,应重置相关历史。亮度容差与方差下限也需要符合所用信号单位。
五、方差引导的多尺度 à-trous
5.1 固定样本数,逐轮扩大采样间距
À-trous 可以理解为在滤波核的非零采样点之间“插孔”。第 轮的采样间距为:
图像每轮保持原分辨率,不生成逐级缩小的 mip。采用 5×5 非零核时,每轮仍最多读取 25 个照明样本,只是从间距 1、2、4 逐步扩展到更远的位置。论文的一维核为:
| 轮次 | 间距 | 本轮直接访问的最大轴向偏移 | 本轮采样窗口跨度 |
|---|---|---|---|
| 0 | 1 | 2 像素 | 5×5 |
| 1 | 2 | 4 像素 | 9×9 |
| 2 | 4 | 8 像素 | 17×17 |
| 3 | 8 | 16 像素 | 33×33 |
| 4 | 16 | 32 像素 | 65×65 |
表中是本轮访问上一轮图像的跨度,不是每个位置都采样,也不是串联后对原始输入的完整支撑域。多轮卷积会继续组合上一轮的邻域贡献。固定 25 taps 也不代表每轮耗时相同:更大的步长可能改变缓存局部性。原论文,第 4.3 节
5.2 深度、法线和亮度共同决定权重
将核权重与边缘停止权重合并为:
论文的深度项利用局部深度梯度,使同一倾斜表面上的自然深度变化不至于被固定深度阈值全部拒绝:
原式使用论文所定义的 clip-space depth 及其屏幕空间梯度;工程也可选择线性深度,但必须连同梯度、阈值一起重新定义。Falcor 使用线性深度变化率、采样步长与偏移长度构造深度尺度,是一种具体近似,不能只替换公式中的深度纹理就认为两者完全等价。
法线项采用归一化法线的余弦幂:
越大,法线差异相同的邻居权重越低。几何轮廓由这些引导信息保护;同一表面内的阴影、反射和照明边界,则还需要亮度项判断:
方差高时,分母较大,亮度差不再轻易阻止邻域混合;方差低时,同样的亮度差更像真实信号边界。方差通过改变亮度差的尺度发挥作用,并不是独立乘上一个“方差越大权重越大”的额外项。
3×3 高斯平滑只用于得到中心的引导方差,防止单个偶然偏小的估计把噪声锁住;它不能替代下一节的方差传播。每轮结束后输入照明和方差都更新,下一轮重新计算引导值。原论文,第 4.4 节、SVGFCommon.slang、SVGFAtrous.ps.slang
5.3 颜色与方差使用不同的归一化
记 ,则颜色按普通加权平均更新:
在把样本间协方差忽略、将本轮权重视为给定量的近似下,方差传播为:
例如两个相互独立、方差同为 的样本等权平均,输出方差是 。如果把方差像颜色一样平均,结果仍为 ,就没有表达平均降低波动的效果。反过来,只将分子权重平方却仍除以 ,也不符合归一化权重的平方关系。
实际 SVGF 的样本存在时间和空间相关性,权重本身也由信号计算,因此这仍是引导下一层的近似,不是严格误差保证。原论文明确采用不相关假设;Falcor 也以平方权重累积方差,并使用总权重的平方归一化。原论文,第 4.3 节、SVGFAtrous.ps.slang
5.4 降噪强度与真实高频的代价
加大 会放松亮度拒绝,提高平滑程度,也更容易抹掉阴影和反射;减小它可能将亮噪点当作边缘保留。增加迭代层数可处理更大尺度的残余噪声,却扩大了跨边界传播和模糊细节的机会。增加历史复用可以减少空间滤波需求,但光照变化时更容易滞后。
即使每个局部权重都“合理”,多轮结果也可能通过一串相邻像素逐渐泄漏到另一侧。因此要检查每轮输出,而不仅看最终是否平滑。高频纹理通过去调制保护,真实照明高频则必须依靠可信的方差、几何边界和时间响应共同保留。
六、引擎无关资源契约与实现骨架
6.1 先定义资源,再实现 Pass
以下是用于接入的建议布局,格式仅作为起点,不是论文规定。所有颜色为线性 HDR;本文伪代码按一路照明信号描述。
| 资源 | 建议内容/格式 | 生命周期 | 关键约束 |
|---|---|---|---|
RawIllumination | 去调制 RGB,RGBA16F 或 RGBA32F | 当前帧 | 不包含独立自发光;有限数值 |
Albedo、Emission | 线性 RGB | 当前帧 | 与上游信号的调制关系一致 |
GeometryCurrent | 深度、梯度、法线、ID、有效掩码 | 当前帧 | 点采样身份数据;法线解码后归一化 |
GeometryPrevious | 上一帧校验所需几何 | 跨帧 | 在全部历史读取完成前不能覆盖 |
Motion 与前帧表面映射数据 | 二维偏移;可选前帧位置/变换 | 当前帧 | 约定为当前到历史;支持对象运动 |
HistoryIllumination[2] | 选定反馈层 RGB | 跨帧 Ping-Pong | 已去调制;不直接保存显示合成颜色 |
HistoryMoments[2] | ,起点为 RG32F | 跨帧 Ping-Pong | 两个通道使用同一矩更新权重 |
HistoryLength[2] | ,整数或浮点标量 | 跨帧 Ping-Pong | 无效标志与长度一致;设定上限 |
TemporalIllumination | 时间累积 RGB | 当前帧 | 与本帧更新后的矩、长度按像素对应 |
SpatialPing、SpatialPong | RGB 照明 + A 通道方差 | 当前帧多轮复用 | A 是方差,不是透明度;输入输出不得别名 |
ReconstructedOutput | 重新调制后的 HDR 结果 | 当前帧 | 后续曝光、色调映射的输入之一 |
若使用半精度照明缓冲的 A 通道携带方差,也必须验证方差范围;不能因为 RGB 未溢出就推断 或方差同样安全。几何可以共享给不同信号,但 diffuse/specular 等独立统计不能共享同一组矩。
6.2 ReprojectHistory:把引擎差异封装在表面匹配中
下面是 HLSL 风格伪代码,用来说明数据依赖,不是可直接编译的 Shader。HistorySet、tap 集合和 SurfaceMatchPreviousFrame 等抽象需要由引擎实现;后者必须满足第三节的坐标空间、身份和逐 tap 校验契约。示例采用有效 tap 的最小历史长度,不照搬 Falcor 的长度读取方式。
struct HistorySample
{
bool valid;
float3 illumination;
float2 moments;
float length;
};
HistorySample ReprojectHistory(int2 p, HistorySet previous)
{
HistorySample h = EmptyHistory(); // 全字段初始化为零,valid=false
if (GlobalReset || !CurrentSurfaceValid(p)) return h;
float2 previousUV = PixelCenterUV(p) + MotionToPreviousUV[p];
if (!InsideViewport(previousUV)) return h;
float total = 0;
float minLength = HistoryLimit;
for (Tap tap : BilinearTaps(previousUV))
{
// 先检查地址,之后才能 Load 历史资源。
if (!Inside(tap.pixel) || tap.weight <= 0) continue;
if (!SurfaceMatchPreviousFrame(p, tap.pixel)) continue;
HistorySample q = previous.Load(tap.pixel);
if (!q.valid || !FiniteHistory(q) || q.length < 1) continue;
total += tap.weight;
h.illumination += tap.weight * q.illumination;
h.moments += tap.weight * q.moments;
minLength = min(minLength, q.length);
}
if (total < MinReprojectionWeight)
return EmptyHistory(); // 可选:先尝试同表面 3x3 历史查找
h.valid = true;
h.illumination /= total;
h.moments /= total;
h.length = minLength;
return h;
}简化代码在双线性样本失败后直接重置,省略论文/Falcor 的 3×3 历史查找回退;第四节的空间矩回退仍必须执行。重投影补救与空间方差补救发生在不同阶段,不能互相替代。
6.3 UpdateMoments:原始样本、矩和长度一起更新
struct TemporalState
{
float3 illumination;
float2 moments;
float length;
};
TemporalState UpdateMoments(float3 raw, HistorySample history)
{
// 调用前拒绝 NaN/Inf;这里的 raw 已处于一致的线性信号域。
float L = Luminance(raw);
TemporalState state;
if (!history.valid)
{
state.illumination = raw;
state.moments = float2(L, L * L);
state.length = 1;
return state;
}
state.length = min(history.length + 1, HistoryLimit);
float alphaI = max(MinCurrentWeight, rcp(state.length));
float alphaM = max(MinMomentWeight, rcp(state.length));
state.illumination = lerp(history.illumination, raw, alphaI);
state.moments = lerp(history.moments, float2(L, L * L), alphaM);
return state;
}HistoryLimit 至少为 1,两个最小权重限制在 。若当前输入无效,应先明确该像素采用回退信号还是标记为无效,不能静默把缺失样本计作一次真实的零贡献观测。
6.4 EstimateVariance:短历史使用空间矩
此片段采用与 Falcor 类似的短历史增强,同时显式保证非负方差;空间回退的权重只使用几何与空间距离,便于先验证基本流程。所有邻居均读取已经完成写入的本帧时间累积结果。
float VarianceOf(float2 moments)
{
return max(0.0, moments.y - moments.x * moments.x);
}
float4 EstimateVariance(int2 p, TemporalBuffers temporal)
{
TemporalState center = temporal.Load(p);
if (!CurrentSurfaceValid(p)) return float4(center.illumination, 0);
if (center.length >= WarmupFrames)
return float4(center.illumination, VarianceOf(center.moments));
float total = 1;
float3 colorSum = center.illumination;
float2 momentSum = center.moments;
for (int2 q : Neighborhood7x7(p))
{
if (!Inside(q) || all(q == p) || !CurrentSurfaceValid(q)) continue;
float w = SpatialMomentWeight(p, q); // 几何边界与空间距离
TemporalState neighbor = temporal.Load(q);
total += w;
colorSum += w * neighbor.illumination;
momentSum += w * neighbor.moments;
}
float variance = VarianceOf(momentSum / total);
float boost = WarmupFrames / max(center.length, 1.0);
return float4(colorSum / total, boost * variance);
}中心样本先以正权重加入,空邻域仍有定义。该阶段输出空间滤波输入,不把邻域平均后的矩无条件写回时间矩历史,否则会改变后续统计量的含义。WarmupFrames=4 可以用于与参考流程对照,迁移到其他采样率后应重新验证。
6.5 ComputeEdgeWeight 与 AtrousIteration
这里以线性深度及其变化率构造工程近似深度尺度,避免将论文的 clip-space 公式与本节资源格式混用。DepthScale 表示每像素的线性深度变化尺度,SmoothedVariance3x3 在屏幕边界只读取合法坐标并重新归一化。
float ComputeEdgeWeight(int2 p, int2 q,
float3 centerColor, float3 sampleColor,
float guideVariance)
{
if (!CompatibleCurrentSurfaces(p, q)) return 0;
float distancePixels = length(float2(q - p)); // 已包含本轮间距
float depthTolerance = max(
PhiDepth * DepthScale[p] * distancePixels, MinDepthTolerance);
float wz = exp(-abs(LinearDepth[p] - LinearDepth[q]) / depthTolerance);
float wn = pow(saturate(dot(Normal[p], Normal[q])), PhiNormal);
float sigma = sqrt(max(guideVariance, MinVariance));
float wl = exp(-abs(Luminance(centerColor) - Luminance(sampleColor))
/ max(PhiLuminance * sigma, MinLuminanceTolerance));
return wz * wn * wl;
}
float4 AtrousIteration(int2 p, uint iteration, Texture2D<float4> source)
{
float4 center = source[p];
if (!CurrentSurfaceValid(p)) return center;
int step = 1 << iteration;
float guideVariance = SmoothedVariance3x3(source, p);
// B3 核除以中心系数后的等价形式;整体缩放会在归一化中消去。
float k[5] = { 1.0/6.0, 2.0/3.0, 1.0, 2.0/3.0, 1.0/6.0 };
float total = 1;
float3 colorSum = center.rgb;
float varianceSum = max(center.a, 0);
for (int y = -2; y <= 2; ++y)
for (int x = -2; x <= 2; ++x)
{
if (x == 0 && y == 0) continue;
int2 q = p + int2(x, y) * step;
if (!Inside(q)) continue;
float4 sampleValue = source[q];
float w = k[x + 2] * k[y + 2] * ComputeEdgeWeight(
p, q, center.rgb, sampleValue.rgb, guideVariance);
total += w;
colorSum += w * sampleValue.rgb;
varianceSum += w * w * max(sampleValue.a, 0);
}
return float4(colorSum / total, varianceSum / (total * total));
}循环只返回本像素结果,由独立输出纹理接收。中心项使用权重 1,因此除数不会因所有邻居被拒绝而为零。资源载入前仍应保证整张输入没有非有限值,不能指望将非法样本乘零后消失。
6.6 调度、同步和最终合成
本帧 G-Buffer、Motion、随机照明完成
-> 去调制
-> ReprojectHistory + UpdateMoments:写本帧照明、矩和长度
-> 写入对后续读取可见
-> EstimateVariance:写 SpatialPing
-> 对 i = 0 .. IterationCount-1:
AtrousIteration(SpatialPing -> SpatialPong)
若 i 为反馈层,保存这一层 RGB 到本帧照明历史
完成本轮写入依赖后交换 Ping/Pong
-> Albedo * 最终照明 + Emission,写显示输出
-> 本帧全部历史消费者完成后,轮换历史集合与几何快照GPU 的“可见”需要按所用图形 API 正确声明读写依赖、资源状态和必要的屏障。线程组内同步无法替代不同组或不同 Pass 之间的同步;同一 dispatch 中先写本像素矩再立即读取邻居矩,也不能保证邻居已完成。通常把矩更新与空间回退分成独立阶段最容易保证正确性。
选定反馈层的内容必须在其临时纹理被下一轮覆盖前保存,或安排没有别名冲突的资源生命周期。禁用空间迭代时应显式保存时间累积照明作为历史,避免下一帧读到旧反馈。历史轮换指向的始终是上一完整帧的数据,而非仍在写入的本帧资源。
最终合成使用第二节约定的 。如果只重建间接光,则只重新调制这一路,再与其他照明合成;不要将已经包含直接光的场景颜色再次作为同一分量相加。SVGF 也不代替几何抗锯齿,后续 TAA 的历史响应需要和它一起评估。
七、失败模式与工程扩展
7.1 从可观察现象定位问题
| 现象 | 可能原因 | 优先检查与处理 |
|---|---|---|
| 运动物体后方拖影 | 错误历史被接收;新样本权重过低 | 可视化重投影与拒绝原因,再调整历史长度和响应 |
| 新显露区域噪声突然爆发 | 历史失效后方差变成零;空间回退缺失 | 检查 、空间矩及初始方差是否写入 |
| 不同表面串色、亮边 | 双线性 tap 未逐个校验;大尺度滤波跨边界 | 核对深度空间、法线、ID 与每轮邻域 |
| 静止表面上的移动阴影滞后 | 表面仍匹配,但光照已改变 | 降低历史依赖;增加信号变化检测 |
| Firefly 被拖成亮斑或扩散 | 极端样本进入颜色与二阶矩 | 先查采样 PDF、权重、有限值,再评估防火花策略 |
| 快速运动时细节变软 | 重投影反复插值;方差升高;历史频繁丢失 | 检查运动矢量与反馈层,减少不可靠复用 |
| 相机切换后残留上一视角 | 没有整套 reset,或复用其他视图历史 | 同步失效颜色、矩、长度和几何身份 |
| 镜面反射拖动、粗糙度边界模糊 | 表面运动不等于反射内容运动;不同瓣混合 | 分离信号、限制共享范围,单独调整镜面参数 |
| 法线贴图区域噪声锁住 | 高频着色法线拒绝了几乎所有邻居 | 区分几何一致性与着色细节的用途 |
| 纹理模糊或棋盘格进入方差 | 在带 Albedo 的颜色上直接滤波 | 检查去调制;确认材质因子未漏乘或重复相乘 |
| 彩色反射被洗成一片 | 只用亮度无法识别近等亮度的色度边界 | 评估色度约束或分通道统计,接受额外成本 |
移动阴影、极暗区域噪声、镜面和运动细节过度模糊、色度过度平滑,均是原论文明确讨论的限制。调高滤波强度并不能统一解决这些问题。原论文,第 6 节
7.2 Diffuse/specular 分离与按信号设置参数
以下属于从上述限制推导的工程扩展,而不是声称原论文已实现完整的现代镜面降噪器。Diffuse 与 specular 对运动、粗糙度和入射方向的敏感程度不同,共用颜色历史与矩会把这些变化混成一个统计量。可分别保存两路照明、矩和长度,并共享几何输入。
漫反射间接光通常允许较大的空间复用;镜面分量需要更谨慎地限制历史和滤波范围,粗糙度骤变处还可加入材质兼容性判断。反射表面不动并不意味着反射图案不动,仅靠主表面 Motion Vector 无法完整追踪镜中内容。若引入命中距离或针对反射的重投影,需要另外定义输入与验证,不能只加一个粗糙度参数便宣称问题已解决。
法线同样需要分清职责:几何法线适合判定表面连续性,着色法线包含真实 BRDF 高频。完全忽略着色法线可能抹掉真实光照结构,直接用高频法线做强拒绝又可能保留大量噪声。应针对信号选择两者的组合,而不是一张法线纹理承担所有判断。
7.3 History clamp、历史上限与光照响应
History clamp 可以用当前可信邻域限制重投影颜色,减少异常旧值长时间滞留,但低 spp 输入的局部范围本身很不稳定。小光源贡献尚未被本帧采到时,强裁剪可能删除本来正确的历史能量。它应是几何拒绝后的补充,不能替代历史有效性检查。
颜色大幅裁剪后若照旧信任长历史与旧矩,会造成“颜色已更新,统计仍描述旧信号”的不一致。可将强裁剪视为历史可信度下降,缩短历史并提高颜色/矩的当前权重,必要时同步重置;不要简单令二阶矩等于裁剪后颜色的亮度平方,那会人为消除方差。任何策略都需用动态光照和高 spp 参考验证偏差。
历史上限用于限制长期惯性,但有限上限不能自动检测灯光开关。对于几何不变的光照变化,需要适当的新样本权重或额外的变化判据;“深度和法线通过”只证明表面可能一致,不证明照明平稳。
7.4 Firefly 与防火花处理的边界
先检查极亮点是否来自除以极小 PDF、错误路径权重、非有限值或近零 Albedo 去调制。若是合法但极少发生的高能贡献,裁剪原始样本会改变估计的期望,可能压暗焦散或小光源。可选择更可靠的采样,或采用明确标注偏差的鲁棒预处理,并分别观察原始照明、处理后照明和累计能量。
如果防火花修改了输入照明,应由同一个处理后样本生成颜色累积与矩;否则矩会追踪另一种信号。处理后的低方差也不证明光照更准确。不要以“画面不闪了”作为能量正确性的唯一证据。
八、与站内 Voxel GI 时空滤波的关系
Voxel GI:07 时空滤波与合成 描述的流程是:Motion Vector 重投影、当前帧 3×3 YCoCg 邻域裁剪、时序混合,以及可选的固定 Poisson 邻域深度/法线双边滤波。它与 SVGF 共享时间复用和几何保边的思想,但统计量的用途及空间阶段不同。
| 环节 | 该 Voxel GI 文章描述的方案 | 完整 SVGF 核心流程 |
|---|---|---|
| 时间复用 | Motion Vector、历史颜色、运动相关混合权重 | 重投影一致性、颜色/矩历史、长度相关权重 |
| 二阶矩 | 当前帧 YCoCg 邻域计算二阶统计量,用于历史裁剪 | 持续维护去调制亮度的一阶与二阶原始矩 |
| 方差用途 | 用邻域标准差界定可接受的历史颜色范围 | 驱动空间亮度边缘停止,并随尺度传播 |
| 历史不足 | 采用当前帧路径及已有裁剪/滤波逻辑 | 显式通过空间矩回退补充方差估计 |
| 空间滤波 | 固定 8 个 Poisson 邻域,按深度/法线加权 | 多轮 à-trous,间距按 增长 |
| 多尺度统计 | 未描述逐层方差传播 | 方差按平方权重传播,下一层重新引导 |
| 输出语义 | 文章约定为场景颜色加屏幕空间间接光近似 | 按上游契约去调制,再重新调制与合成 |
这里不能笼统地说 Voxel GI“没有二阶矩”:它确实计算了当前邻域的二阶矩。准确的区别是,它没有描述 SVGF 所需的跨帧亮度矩历史、方差驱动和多尺度 à-trous 链路。缺少这些环节时,适合称为带邻域裁剪的时空滤波,不能称为完整 SVGF;仅把固定双边核改名为 à-trous,或新增一张名叫 Variance 的纹理,也不改变这一点。
Voxel Cone Tracing 的近似误差、有限步进及抖动也不自动等同于无偏路径追踪的 Monte Carlo 误差。借用 SVGF 思路之前,要先确定其输出到底是哪种照明量、随机变化来自哪里,再建立矩与调制契约。本篇不提供绑定该 Voxel GI 实现的接入代码。
九、参数调试顺序与验证场景
9.1 按依赖顺序调试
- 验证信号契约。 用常量照明与棋盘格 Albedo 检查去调制/重新调制,排除重复乘材质、重复加直接光、错误曝光和非有限值。
- 验证重投影。 暂停空间滤波,显示历史 UV、有效掩码和拒绝原因,分别测试相机、物体、形变运动;检查所有边界 tap 的地址。
- 验证 reset 和历史长度。 让 camera cut、分辨率变化、新显露区域分别触发预期路径;观察 是否从 1 开始,而静止区域是否能积累。
- 验证矩与方差。 查看 、、 和短历史回退。常量输入方差应接近零,单样本零方差必须由回退接管;亮度乘 后,矩和方差应符合第四节的尺度关系。
- 调整时间响应。 从可解释的 、 和历史上限开始,在降噪与灯光变化滞后之间取舍;先保持反馈层较浅。
- 逐轮启用 à-trous。 先检查深度和法线保边,再调 ,最后增加迭代数。区分“单轮权重太松”与“多轮传播太远”。
- 加入按信号的扩展。 分别验证 diffuse/specular 参数、history clamp、防火花与后续 TAA;每次只改变能解释的一组因素。
- 测量 GPU 成本。 记录分辨率、信号路数、输入 spp、迭代数、格式、GPU、驱动和各 Pass GPU 时间,确认是否受带宽或计算限制。
论文的 、、、五轮滤波是原实验中的参数。尤其深度定义或信号尺度变化后,数值不能原封不动当成通用默认值。原论文,第 4.3、4.4 节
9.2 验证场景与观察量
| 场景 | 主要观察量 | 能揭示的问题 |
|---|---|---|
| 常量照明、黑白 Albedo、近黑材质 | 去调制值、最终颜色、方差 | 调制错误、除零、方差下限污染 |
| 静止场景持续更换随机样本 | 时间累积、矩、逐轮结果、帧差 | 静态残噪、方差锁定、过度空间平滑 |
| 前景遮挡物横移露出背景 | 历史有效掩码、长度、空间回退 | 显露污染、错误长度继承 |
| 相机平移并经过倾斜墙面 | 重投影误差、深度权重 | 深度空间不一致、梯度尺度错误 |
| 相机固定、灯光开关或移动 | 响应曲线、移动阴影、残留时间 | 表面有效但光照过时 |
| 细杆、叶片和轮廓快速运动 | 有效 tap 数量、每轮边界 | 亚像素几何、扩大查找的串色风险 |
| 从粗糙到光滑的材质带 | 各信号方差、反射形状 | 镜面与漫反射混用参数 |
| 法线贴图、彩色近等亮度图案 | 法线权重、色度细节 | 着色高频丢失、亮度引导盲区 |
| 小面积强光源和暗角 | 原始极值、累计能量、HDR 参考 | Firefly、稀少贡献、鲁棒处理偏差 |
| Camera cut、改分辨率、多视图切换 | reset 帧的全部历史资源 | 历史生命周期和视图隔离错误 |
高 spp 参考应使用相同相机、几何、光照积分定义和曝光。比较时既观察空间误差和真实细节,也观察连续帧闪烁及变化后的响应;单看低帧差可能奖励一张严重滞后的模糊图。做动态帧差评估还需剔除显露,或对齐对应表面,避免把正确运动误判为噪声。
可逐项关闭时间复用、空间矩回退、方差引导或后续 à-trous 层,检查质量变化是否符合预期。这类消融验证比一次调整多个参数更容易发现“某个 Pass 实际未生效”或“方差通道被覆盖”的接入错误。
9.3 历史性能数据的边界
2017 年论文第 5 节的实验平台为 NVIDIA TITAN X(Pascal),使用 OpenGL 实现主可见性及 SVGF,OptiX 追踪次级和阴影射线。第 5.3 节报告滤波平均耗时约为 1280×720 下 4.4 ms、1920×1080 下 10.2 ms;这描述的是该论文原型和实验设置中的滤波成本,不能当作整帧路径追踪耗时,也不能推断现代 GPU、当前 Falcor 或某个引擎接入版本的性能。原论文,第 5、5.3 节
本文没有给出新的 Benchmark 或作者实测。迁移时应分别测量重投影、短历史回退、各轮 à-trous、历史保存与合成;直接/间接光或 diffuse/specular 的拆分路数也必须计入预算。
十、资源与 Pass 速查
| 阶段 | 主要读取 | 主要写入 | 下游依赖/排错重点 |
|---|---|---|---|
| 去调制 | 原始颜色、Albedo、自发光 | 原始照明 | 确认线性域、近零材质和信号单位 |
| 重投影与时间累积 | 原始照明、Motion、两帧几何、旧照明/矩/长度 | 时间照明、新矩、新长度 | 逐 tap 校验;无效时三个量一起重置 |
| 方差估计与短历史回退 | 时间照明、新矩、新长度、当前几何 | 初始空间照明与方差 | 一帧零方差不可当成低噪声;必须先完成邻居矩写入 |
| 第 轮 à-trous | 上轮照明/方差、当前几何 | 下一轮照明/方差 | 步长 ;平方权重传播方差;Ping-Pong |
| 选定层历史保存 | 反馈层照明、本帧矩与长度 | 下一帧历史集合 | 反馈资源不能被后续迭代覆盖 |
| 重新调制与合成 | 最终照明、Albedo、自发光及其他独立分量 | HDR 输出 | 不重复计算材质或光照贡献 |
| 帧结束与失效管理 | 当前几何、视图/资源状态 | 几何历史与有效标志 | 分视图隔离;切换与重建时显式 reset |
判断一个实现是否覆盖 SVGF 核心,不看 Pass 名称,而看链路是否闭合:可信重投影提供历史,亮度矩提供方差,短历史有空间回退,方差改变多尺度 à-trous 权重并逐层传播,最后按同一信号契约输出与反馈。
十一、参考资料
以下均为论文、研究机构页面或 NVIDIA 官方仓库;Falcor 源码用于核对数据流和实现选择,本文没有将其接口作为稳定 API。
- NVIDIA Research:Spatiotemporal Variance-Guided Filtering:作者、HPG 2017 出版信息、摘要及相关资源。
- 原始论文作者预印本 PDF:第 3 节管线;第 4.1 节重投影;第 4.2 节矩与方差;第 4.3、4.4 节 à-trous 与边缘权重;第 5、6 节实验和限制。
- Falcor:SVGFPass 目录:核对入口和完整文件结构。
- SVGFReproject.ps.slang:去调制、历史有效性、时间累积、矩与历史长度。
- SVGFFilterMoments.ps.slang:短历史空间矩、照明预滤波与方差增强。
- SVGFAtrous.ps.slang:方差引导、稀疏滤波核与平方权重传播。
- SVGFCommon.slang:深度、法线与亮度的组合权重。
- SVGFFinalModulate.ps.slang:Albedo 重新调制及自发光合成。
- SVGFPass.cpp:Pass 调度、à-trous 步长和反馈层保存。
