在体积雾中实现Temporal Reprojection抗闪烁

解读

国内 Unity 项目普遍把体积雾做成“半分辨率 + 低步进”以节省带宽,代价是高频噪声(dithering 或 blue noise)在时域上表现为剧烈闪烁。Temporal Reprojection(TAA 思路的时域复用)可以把历史帧的体积信息复用到当前帧,用极低的额外开销把噪声平均掉,是移动端 30 ms 帧预算内最可行的抗闪烁方案。面试官真正想确认的是:

  1. 你对 Unity 渲染管线的“半自定义”能力——能否在不修改 SRP 源码的前提下把 Reprojection 插进去;
  2. 对运动向量(motion vector)与深度一致性(depth rejection)的严谨处理,防止“拖影”这种国内发行审核一票否决的明显瑕疵;
  3. 对平台差异的敏感:在华为 Mali-G78 与骁龙 8+ 上如何守住 3.5 ms 的 GPU 预算。

知识点

  • 体积雾的噪声来源:Ray-marching 步长不足、3D Noise 采样频率低、半分辨率上采样。
  • Temporal Reprojection 四步范式:Reproject → History Fetch → Neighbor Clamp → Blend。
  • Unity 运动向量管线:Built-in 需开启 Camera.motionVectorsEnabled;URP/HDRP 用 MotionVector PassFullScreen Motion Blur 贴图;XR 下要分别处理左右眼 prevViewProj[2]
  • 深度一致性拒绝abs(currentDepth – historyDepth) > depthThreshold*max(1.0, linearZ),防止背景遮挡前景时的鬼影。
  • 动态缩放与 Jitter:Halton 2-3 序列做 8 帧循环,与 TAA 共用同一套 camera.SetProjectionMatrix(jitterProj),减少额外开销。
  • 历史帧纹理管理:使用 RenderTexture.GetTemporary 双缓冲,格式 R11G11B10安卓端比 FP16 省 35% 带宽
  • 平台级优化:在OpenGL ES 3.1 下用 framebuffer_fetch 把 Reprojection 合并到体积雾最后的全屏 Blit,节省一次 Resolve;iOS A16 可开 Metal programmable blending 做邻居 clamp 的 on-chip 计算,GPU 时间再降 0.4 ms。
  • 热更新兼容性:整套逻辑放在 CustomVolumeFog.cs 自定义后处理,不触碰 SRP 源码,Addressable 热更时只需更新 Shader 变体,符合国内“版号后热更”政策。

答案

  1. 管线接入
    在 URP 的 ScriptableRendererFeature 里新增 TemporalVolumetricFogPass,插入到 AfterRenderingTransparents 位置,确保已拿到不透明物体的深度与运动向量。
  2. 运动向量校准
    在 Shader 中 float4 motion = SampleMotionVector(uv); 把屏幕空间运动向量转到体积雾的半分辨率 UV,再乘以 unity_CameraInvProjection00/11 分量做非线性修正,确保粒子高速移动时历史坐标不漂移。
  3. 重投影与拒绝
    float3 historyPos = uv - motion;
    float historyDepth = SampleDepth(historyPos);
    float depthTh = 0.02 * max(1.0, LinearEyeDepth(currentDepth));
    float reject = abs(historyDepth - currentDepth) > depthTh;
    
    若拒绝,则 historyColor = currentColor,彻底斩断鬼影。
  4. 邻居 clamp
    取 3×3 邻域的 min/max 把历史颜色 clamp 到当前帧的切空间,防止高速旋转的相机导致亮度跳变。
  5. 混合与反馈
    float blendFactor = lerp(0.92, 0.97, velocity);
    currentColor = lerp(currentColor, historyColor, saturate(blendFactor - reject));
    
    高运动区域降低反馈,静态区域 97% 历史权重,两帧即可把噪声压到肉眼不可见
  6. 平台细节
    • 安卓 Mali:把 3D Noise 从 64³ 降到 32³,再用 4 帧循环的蓝噪声偏移代替 8 帧 Halton,减少 Texture Unit 占用;
    • iOS Metal:用 rg11b10 历史帧 + programmable blend 做 on-chip 混合,GPU 时间 2.8 ms → 2.4 ms;
    • WebGL2:因无 framebuffer_fetch,把体积雾与 Temporal 合并到单 Pass,避免 RGB32F 的昂贵格式,改用 RGBA16F 双缓冲。
  7. 热更策略
    把 Shader 变体拆成 FOG_TEMPORAL_ONFOG_TEMPORAL_OFF,通过 Addressable 分组下载,首次安装包体不膨胀,版号审核通过后云端开启 Temporal 开关,符合国内渠道“先审后开”的合规要求。

拓展思考

  • 与 TAA 的协同:体积雾 Temporal 与相机 TAA 共用同一套 Jitter 序列,但历史帧权重需独立,否则高亮度雾边会被 TAA 过度模糊;可在 PostProcessLayer 里把体积雾标记为 BeforeTAA,让 TAA 只处理几何边缘。
  • XR 下的双眼 Reprojection:左右眼分别维护 prevViewProj[2],但历史纹理可单眼 1024×1024 Atlas 合并,减少 50% 显存;注意 Quest2 的**固定中心渲染(FFR)**会让边缘运动向量失真,需把拒绝阈值放大 1.4 倍。
  • LDR 与 HDR 场景适配:国内买量小游戏仍跑 Gamma 空间,可把历史帧存 LDR RGB10A2,用 2.2 近似 Gamma 做快速转换,比全 FP16 方案在低端骁龙 625 上快 1.1 ms
  • 未来 FSR2/DLSS 替代:一旦 Unity 官方把 FSR2 插件下放移动端,体积雾可直接喂 Motion + Depth + Exposure 给 FSR2,省去自研 Temporal;但国内主流渠道 2025 年前仍要求 GLES 3.1 兼容,自研方案仍是刚需。