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 当成 Temporal 序列本身。
两层噪声最终影响两个具体位置。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;History 因首次分配而无效时,采样从这一起点开始。屏幕分辨率变化会让 EnsureHistory 重建 History 并将索引重置为 0;体素资源变化、体素重建或重新光照调用 InvalidateHistory,同样令 HistoryNeedsClear = true 并清零 JitterIndex。这样新 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 无法跨越数据契约变化继续使用。首次分配、分辨率变化、体素资源重建,以及体素化或光照重新执行后,Camera Context 都会调用 InvalidateHistory,将 HistoryNeedsClear 设回 true 并重置抖动索引。Temporal Pass 把该标志传给 _VoxelGIHistoryValid;无效时不采样旧内容,直接输出当前 Screen Trace,并在本帧完成后恢复有效状态。Temporal 关闭或 Motion Vector 不可用时,调度也不会登记这个历史读写阶段。
五、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 是独立的线性间接光,不是已经包含场景颜色的替代画面;因此组合关系就是:
最后再用 Copy Pass 将 Composite 结果写回原来的 activeColorTexture,交还给 URP 后续阶段。完整数据链保持为“当前 Screen Trace → 可选 Temporal → 可选 Bilateral → Scene Copy + Composite”,每一步都在 Render Graph 中声明自己的读写资源,历史纹理的跨帧持久性和场景颜色的当前帧语义不会混在一起。
