07 时空滤波与合成
07 时空滤波与合成
Screen Trace 每个像素只用有限条 Cone 和有限步数查询 Radiance。为了让预算可控,方向和步进必须抖动,结果也就带有单帧噪声;若直接把它叠到相机颜色上,会看到颗粒、闪烁和随视角变化的亮度跳动。本篇按当前 Render Graph 中的顺序说明:先得到当前帧结果,再用 Motion Vector 找回历史,用 YCoCg 邻域统计限制异常历史,之后可选地做深度/法线双边滤波,最后和场景颜色合成。
一、单帧噪声
Screen Trace 的噪声来自离散的空间积分,而不是材质颜色本身。每个像素只追踪有限条 Cone,每条 Cone 又只走有限步;半球方向、首步偏移和蓝噪声/哈希会让相邻像素看到不同的 Radiance 足迹。Cone 数量越少,单帧误差越明显,但当前实现仍会把结果写成一张完整的屏幕纹理,供后续阶段读取。

1.1 抖动序列
ExecuteScreenTrace 从当前相机的 VoxelGICameraContext 读取 JitterIndex,以 GetJitter(cameraContext.JitterIndex++, temporal) 递增它,并把返回的二维值设置为 Shader 全局参数 _VoxelGIJitter。这里的 ++ 意味着本次 Pass 使用旧索引,随后相机上下文保存下一个索引;不同相机各自维护自己的相位,不会共享同一条序列。
当前实现有两层彼此独立、但在 Shader 中会组合使用的抖动。第一层是由 Temporal 设置选择的跨帧序列:GetJitter 依据设置选择 Golden Ratio 或 Halton,负责逐帧更换半球盘面上的采样相位。第二层是屏幕空间噪声:有蓝噪声纹理时,Shader 按像素 UV、_VoxelGIBlueNoiseScale 和 _VoxelGIJitter 取样;没有蓝噪声时,则用像素坐标与 _VoxelGIJitter 输入 VoxelGI_Hash。它负责同一帧内让相邻像素分散到不同的随机值。也就是说,前者回答“下一帧换到哪里”,后者回答“这一帧不同像素如何错开”,不能把蓝噪声/Hash 当成跨帧序列本身。
虽然 Golden Ratio/Halton 参数归在 Temporal 设置组中,GetJitter(cameraContext.JitterIndex++, temporal) 实际位于 ExecuteScreenTrace,并不以 temporalActive 为调用条件。只要本帧执行 Screen Trace,JitterIndex 就会推进;Temporal Pass 未登记时也一样。Temporal Active 的直接影响是是否执行 History 重投影,以及是否让 Screen Trace 强制只发射 1 条 Cone,而不是决定抖动序列是否前进。
两层噪声最终影响两个具体位置。VoxelGI_SampleHemisphere 将噪声分量加到半球盘面的 u、v,改变 Cone 的方向;VoxelGI_TraceCone 又用 noise.x 计算 lerp(0.5, 1.5, noise.x),改变首步 offset。因此 _VoxelGIJitter 既通过屏幕噪声的采样相位影响盘面,也会间接改变 Cone 的首步位置,而不是只改变一个用于混合的颜色权重。
Golden Ratio 分支的实际公式是令 ,以相机上下文的索引 为输入:
源码使用 Mathf.Repeat(index * c, 1f) 和 Mathf.Repeat(index * c * c, 1f) 实现这两个分量;它们等价于对数值取模并落在 。这个序列计算便宜、相邻帧不容易短周期重合,适合持续旋转采样相位,但它不是严格的有限区间低差异设计。
Halton 分支先按设置的 haltonLength 截断索引:
当前 Inspector 设置把 haltonLength 限制在 2–64,因而序列会在指定长度后循环。基数 2 和 3 的 Halton 点在有限前缀中覆盖更均匀,低差异特性更明确;代价是每隔固定长度回到旧相位。Golden Ratio 没有这个显式循环长度,低重复感更自然;Halton 则更容易控制一段已知长度的跨帧采样。两者都只是 Temporal 层,仍需由每像素的 Blue Noise/Hash 负责屏幕空间分散。
| 抖动来源 | 当前实现 | 主要作用 | 空间/时间特征 |
|---|---|---|---|
| Golden Ratio | Mathf.Repeat(i*c, 1f)、Mathf.Repeat(i*c*c, 1f) | 生成跨帧二维相位 | 连续低重复感,无显式长度 |
| Halton | Halton(i % max(2, HaltonLength), 2/3) | 生成跨帧二维相位 | 有限前缀低差异,2–64 帧循环 |
| Blue Noise / Hash | _VoxelGIBlueNoise 或 VoxelGI_Hash(pixel + jitter) | 分散同一帧相邻像素 | 屏幕空间变化,不替代 Temporal 序列 |
首次使用时 JitterIndex 默认为 0。屏幕分辨率变化会让 EnsureHistory 重建 History,并将索引重置为 0;体素资源因分辨率或 Shadow Resolution 变化而重新分配时也会调用 InvalidateHistory。除此之外,UpdateHistoryContinuity 只在 Temporal 重新开启、相机使用出现跨帧中断、当前 Volume 改变、Bounds 改变或体素分辨率改变时请求失效。每帧体素化和重新光照本身不会自动清空 History。
二、Motion Vector 重投影
RecordTemporal 把 Screen Trace 输出作为 Current,读取上一帧 History,并从 URP 的 motionVectorColor 读取运动矢量。对当前像素 UV,Shader 使用:
其中 是该像素的 Motion Vector。若重投影坐标落在 外,历史没有对应像素,直接返回当前结果;若 History 尚未有效,也采用同样的当前帧路径。有效历史才会从 _VoxelGIHistoryIrradiance 采样,并和当前值按 CurrentFrameWeight 混合。
运动量还会改变当前帧权重:
w_0 是设置中的 TemporalCurrentFrameWeight。运动越大,越不应相信重投影历史,当前样本占比随之提高;静止区域则能保留更多跨帧样本,逐渐降低 Screen Trace 的随机噪声。这一步只解决“历史像素在哪里”,还没有判断历史颜色是否属于当前表面。
三、YCoCg 邻域裁剪
几何边界、遮挡变化或错误的 Motion Vector 可能把另一处表面的历史颜色带到当前像素。Shader 不直接在 RGB 中裁剪,而是先把重投影历史转换到 YCoCg,使亮度 Y 与色度 Co/Cg 分开统计:
这里的 是当前帧 3×3 邻域的 YCoCg 值,firstMoment 和 secondMoment 分别累积一阶、二阶矩。按 TemporalClampScale 给历史建立盒状范围:
裁剪后的 history 再转回 RGB,最后与当前 RGB 做 lerp。均值和方差来自当前邻域,因此能允许相似像素共享稳定的历史,同时把明显偏离局部统计的亮斑、拖影值压回合理范围;它不是对场景颜色做曝光或色调映射。
四、History 管理
History 属于 VoxelGICameraContext,按 Camera InstanceID 隔离。启用 Temporal 时,EnsureHistory 按 Render Graph 实际屏幕尺寸分配两张持久 RTHandle:HistoryA 与 HistoryB,格式沿用 VoxelGI 的 HDR 颜色格式,采样为 Bilinear、寻址为 Clamp。尺寸变化会释放并重建两张纹理,同时把 HistoryNeedsClear 设为 true、HistoryWriteA 重置为 true、抖动索引归零。
每一帧根据 HistoryWriteA 选择读写方向:若将写 A,则从 B 读取;否则从 A 读取。Pass 的输出绑定目标纹理,登记完成后翻转 HistoryWriteA,形成 Ping-Pong,避免同一张纹理在一个 Pass 中同时作为输入和输出。这个“写入当前结果”会成为下一帧的可重投影历史。
History 无法跨越数据契约变化继续使用。首次分配、History 尺寸变化、体素资源重建,以及 UpdateHistoryContinuity 检测到 Temporal 重新开启、相机帧中断、Volume/Bounds/体素分辨率变化时,会把 HistoryNeedsClear 设回 true 并重置抖动索引。Temporal Pass 把该标志传给 _VoxelGIHistoryValid;无效时不采样旧内容,直接输出当前 Screen Trace,并在本帧完成后恢复有效状态。
EveryFrame 更新模式不是 History 失效条件。EditMode 测试 HistoryContinuityIgnoresRoutineEveryFrameVoxelization 直接检查了这项代码语义:相邻帧继续使用同一 Volume、Bounds 和体素分辨率时,UpdateHistoryContinuity 不会请求清空 History;Bounds、Volume、体素分辨率或 Temporal 启用状态发生变化时才会触发相应的连续性失效。PlayMode 测试 TemporalShaderWithZeroRequestedWeightAccumulatesDeterministically 则验证了 Temporal Shader 在请求权重为 0 时仍按实现允许的最小当前帧权重稳定累积。
项目另有 SampleScene 端到端测试 SampleSceneTemporalConvergesInEveryFrameMode,目标是检查每帧体素化时的帧差收敛、History 状态和抖动索引。但本次完整 PlayMode 运行中它没有通过:测试以 FindObjectsByType<Camera>(FindObjectsSortMode.None) 返回的 cameras[0] 作为渲染相机,当前场景中该元素是组件已禁用的 Bezier Path Camera,而启用的 Main Camera 位于 cameras[1];绑定到前者的 RenderTexture 没有得到有效画面,检查得到 earlyPixels = 0。因此这次失败不能作为 Temporal 算法不收敛的证据,同样也不能把该端到端测试列为已通过证据;测试需要先显式选择启用的 Main Camera,再重新验证完整场景收敛。Temporal 设置关闭或 Motion Vector 无效时,调度不会登记 Temporal Pass;其中 Temporal 从关闭恢复为开启会由连续性检查触发一次失效。

五、Bilateral Filter
Temporal 之后的结果仍可能跨过深度不连续处,尤其是历史刚失效或当前遮挡快速变化时。可选的 Bilateral Pass 是一个 8×8 线程组的 Compute Pass,每个线程负责一个屏幕像素,中心参考由深度和法线共同提供。深度先用 _ZBufferParams 从非线性值还原为线性眼空间深度,法线从相机 Normals 纹理读取并归一化。
对固定的 8 个 Poisson 邻域,样本 UV 按 BilateralRadius 和屏幕像素尺寸偏移。深度与法线分别给出边缘权重:
样本颜色乘以 累积,最后除以总权重。深度差大的样本和法线夹角大的样本不会像普通模糊那样无条件混合,所以墙边、物体轮廓和不同朝向表面能够保留。Bilateral 只接受时序结果和当前帧 Depth/Normals,输出另一张屏幕纹理;它不修改三维 Radiance 或 History。

六、Composite
合成前,RecordComposite 先把 activeColorTexture 复制到临时的 Scene Copy。这样 Composite 读取的是进入 Voxel GI 前已经完成的不透明物体颜色,不会在同一个颜色目标上发生读写冲突。随后创建 VoxelGI Composite 目标,绑定 Scene Copy 和最后的间接光纹理(可能是 Temporal/Bilateral 的结果),调用全屏 VoxelGI_CompositeFragment:
float3 sceneColor = SAMPLE_TEXTURE2D(_VoxelGISceneColor,
sampler_VoxelGISceneColor, input.uv).rgb;
float3 indirect = SAMPLE_TEXTURE2D(_VoxelGIIndirectIrradiance,
sampler_VoxelGIIndirectIrradiance, input.uv).rgb;
return float4(sceneColor + indirect, 1.0);这里的 indirect 是 Screen Trace 及可选滤波得到的线性屏幕空间间接光近似,不是已经包含场景颜色的替代画面。当前 Composite 不再乘接收表面的 Albedo,也不执行除以 的物理 Irradiance 到出射辐射换算;实现关系就是:
最后再用 Copy Pass 将 Composite 结果写回原来的 activeColorTexture,交还给 URP 后续阶段。完整数据链保持为“当前 Screen Trace → 可选 Temporal → 可选 Bilateral → Scene Copy + Composite”,每一步都在 Render Graph 中声明自己的读写资源,历史纹理的跨帧持久性和场景颜色的当前帧语义不会混在一起。

