Volumetric Fog of The Last of Us Part II

Volumetric Fog of The Last of Us Part II by Artem Kovalovs || SIGGRAPH 2020

项目背景与动机

  • 演讲者:Artem Kovalovs,Naughty Dog 程序员
  • 实现灵感来源于 Ronski Hillaire 以及其他采用类似方案的游戏
  • 核心动机:
    • 需要创建 体积网格(Volumetric Grid) ,使雾效能与世界场景和 半透明物体(Alpha Objects) 正确融合
    • 旧方案仅使用 2D 网格 + 叠加层(2D Overlay) ,通过 ray marching 实现,但非常受限
    • 新方案目标:
      • 更好地创建 参与介质(Participating Media) 的密度
      • 支持来自 太阳光、运行时动态光源、环境光 的光照贡献
      • 复用光照结果给粒子系统 ,实现一致的光照效果

旧版方案的问题

  • 旧方案将所有结果合并到一个 2D 叠加层
  • 与半透明物体的混合效果很差——粒子只能在雾之前或之后渲染,本质上就是另一个 Sprite 叠加
  • 计算结果 无法被其他粒子或光照系统复用
  • 因此必须迁移到 3D 体积网格 方案

数据模型:3D 体积网格

  • 采用业界标准的 Froxel(Frustum-aligned Voxel) 方案
    • 将视锥体划分为 3D 纹理网格
    • 深度方向使用 指数深度分布函数(Exponential Depth Function) ,近处密、远处疏,更符合视觉需求
  • 3D 网格的优势:可以在任意位置 查找 3D 信息 ,当粒子渲染在雾之上时,能正确地将雾效应用于粒子

散射与累积(Scattering and Accumulation)

  • 基本原理 :光线穿过参与介质时发生散射
  • 使用 自定义散射函数 来评估:
    • 光源方向 → Froxel 的散射
    • Froxel → 摄像机方向的散射
    • 这是一个 美术可调节的函数(Artist-driven Function)
  • 出射辐射度(Outgoing Radiance)的计算公式采用 Hillaire 提出的公式 ,基于:
    • 散射光(Scattered Light)
    • 消光系数(Extinction)
    • Froxel 深度(Fractional Depth)

分辨率目标与配置

  • 明确认识到无法达到旧方案的半分辨率 God Rays 效果,但需要支持可调节的高分辨率
  • 三档分辨率配置:
配置分辨率像素对应Froxel 数量
默认240 × 135 × 648×8 像素/Froxel~200 万
~384 × 216 × 645×5 像素/Froxel~530 万
~160 × 90 × 6412×12 像素/Froxel~90 万
  • 虽然支持 4×4 像素的配置,但实际从未使用
  • 高分辨率模式下需要大量优化

纹理数据布局

需要多张纹理协同工作:

纹理用途格式说明
累积纹理(Accumulation Texture)RGBA FP16最终雾纹理;RGB = 预乘散射光照,A = 透射率(Transmittance)
运行时光照纹理RGBA IU8仅存活一帧,用于动态光源贡献
时间性阴影纹理2 通道通道 1 = 时间性太阳阴影(Temporal Sun Shadow) ,通道 2 = 时间性运行时光源阴影
参与介质时间性纹理用于时间性积累参与介质信息
环境光照纹理RGBA FP16(不同分辨率)RGB = 环境颜色 / 球谐光照(Orbit Light) ,A = 正负密度(±Density)
  • 随着分辨率提升, 显存占用显著增加

密度创作方式(Density Authoring)

提供了多种方式来添加雾密度:

层(Layers)

  • 支持设置 起始距离、起始高度、结束高度 等参数
  • 包含 天空雾层(Sky Fog Layer)

雾区域(Fog Regions)

  • 可放置的局部体积雾

体积粒子(Volumetric Particles)

  • 在标准粒子系统中生成粒子,将其写入体积网格
  • 实现方式:
    • 逐 Tile 判断哪些粒子与当前 Tile 相交
    • 对每个 Tile 内的每个粒子,根据当前 Froxel 在粒子球体中的位置计算贡献
    • 额外采样 纹理 来调制密度

高度图雾(Heightmap Fog)

  • 目标:模拟雾 贴着物体表面 的效果,不仅是地面,还包括汽车等物体
  • 用于模拟 雨水飞溅产生的薄雾 效果
  • 效果非常微妙,正因微妙所以看起来自然

高度图雾的实现细节:

  • 仅在 标记过的几何体(Tagged Geometry) 上捕获高度图
  • 高度图本身 分辨率不高 且覆盖范围很大 → 产生 走样(Aliasing) 问题
  • 关键优化 :当 Froxel 与高度图相交时,不使用普通高度值,而是使用 指数高度图(Exponential Height Map)
    • 类似于 指数阴影图(Exponential Shadow Maps, ESM) 的思路
    • 实现更平滑的混合与插值
    • 视觉效果显著改善——从生硬的阶梯状变为自然过渡

默认体积(Default Volumes)

  • 预设的雾体积,作为基础密度填充

雾区域(Fog Regions)

  • 雾区域 是一种可以 添加或移除密度 的复杂形状
    • 最终由一组 凸区域(Convex Regions) 组合而成
    • 同时支持放置 光照探针(Probes) 并控制其内容
  • 所有雾区域的处理都在 环境光照 Pass 中以 较低分辨率 完成
  • 工作原理:每个 Froxel 检测自身是否位于某个区域内部,根据结果决定:
    • 调用对应探针
    • 增加密度移除密度
  • 典型应用场景:
    • 室外有暴风雪,雾密度很高
    • 使用雾区域将 室内的雾完全移除 ,形成"室内清晰、室外白茫茫"的效果

环境光照(Ambient Lighting)

重要性

  • 环境光照(间接光)对本作 极其重要
  • 大量关卡追求强烈的 氛围感(Atmosphere) ,这种氛围感主要来自 间接光对雾的贡献

初始方案与问题

  • 初始思路:复用已有的 KD 树(KD-Tree) 来存储环境探针
    • 对每个 Froxel,遍历 KD 树找到 最近的约 10 个探针 ,然后逐个求值
  • 严重性能问题
    • Froxel 之间的 高度状态分歧(State Divergence) :不同线程走不同的 KD 树路径,导致 wavefront 内线程严重发散
    • VGPR(向量通用寄存器)数量过高 → 导致 低占用率(Low Occupancy)
    • 有时需要大量探针,时间性抖动(Temporal Jitter) 也无法有效改善质量
  • 目标改进方向:
    • 希望评估 Froxel 周围 所有探针 的贡献,而非仅取最近 10 个
    • 需要更好的算法
    • 必须 消除状态分歧问题

优化算法(Optimized Probe Evaluation)

核心思路:Wavefront 级别的标量遍历

  • 一个常规思路是将 KD 树预处理为 BVH 结构 或某种空间哈希,但 Naughty Dog 没有这样做
  • 他们 没有额外的 Pass、额外的纹理、额外的表或空间哈希 ——一切都在 单个 Shader 内完成

算法详解

  1. Wavefront = 64 个线程 ,负责一个 4×4×4 的 Froxel 网格
  2. 为整个 wavefront 计算一个 公共包围球(Common Sphere) ,覆盖所有 64 个线程对应的 Froxel
  3. 用这个公共球去遍历 KD 树——因为球对所有线程是共同的,所以这是一个 标量操作(Scalar Operation)
  4. 以标量方式遍历 KD 树,收集最多 192 个探针 ,仅消耗 3 个 VGPR
  5. 列表组装完成后,以 并行方式3 次迭代 内处理所有探针
    • 例如:具有相同遮挡测试(如朝摄像机方向的遮挡)的探针可以批量处理
  6. 之后每个线程从列表中 挑选自己需要的探针 进行求值

进一步优化:KD 树转定点数

  • 将 KD 树转为 定点数(Fixed-Point)格式 ,即纯整数表示
  • 这样可以完全使用 SALU(标量算术逻辑单元) 执行,因为 SALU 不支持浮点运算,但支持整数运算
  • 指令级并行(Instruction Level Overlap, ILP) 收益显著:
    • SALU 通常不被 GPU 常规执行大量占用,因此 KD 树遍历可以与其他 VALU 操作 重叠执行
    • 充分压榨了 SALU 的性能

性能结果

  • VGPR 数量 大幅下降占用率大幅提升
  • 最终耗时约 0.23 毫秒
  • 效果:大半径范围内考虑 192 个探针,每个 Froxel 从中选取所需探针,结果 视觉质量令人满意

其他实现细节

环境光的多分辨率策略

  • 环境光照使用 低分辨率网格 计算
  • 但对于 护栏等细节物体 ,需要更高分辨率的处理
  • 配合 时间性滤波(Temporal Filtering) 进一步提升质量
  • 低 VGPR 数量使得 shader 可以 高效重叠执行

太阳光照(Sunlight)

  • 太阳光照对本作同样 至关重要
  • 希望复用已有的 级联阴影贴图(Cascade Shadow Map, CSM) 来为 Froxel 提供光照
  • 问题:直接在 Froxel 中 采样级联阴影贴图开销较高
  • 解决方案:沿用并改进了之前已有的 阴影缓存(Shadow Cache) 系统
    • 该系统由 Finch One Weng 开发(已离开 Naughty Dog)

太阳阴影缓存(Sun Shadow Cache)

核心思路

  • 视空间(View Space) 投影为一个 2D 锥形 ,投射到 垂直于光源方向的平面
  • 创建一张 M×N 的纹理 ,其中每一行代表锥体中的一个 切片(Slice) ,用于缓存阴影信息

存储与稳定化

  • 缓存中存储 两帧的阴影数据 ,对两帧取 最大值(Max) 以抵消时间性抖动带来的不稳定
  • 阴影以 指数阴影贴图(Exponential Shadow Map, ESM) 格式存储,方便后续在 Froxel 光照阶段进行高效采样
  • 该方案参考了 Ronski Hillaire 的提议

级联范围外的阴影:Shadow Snapshot

  • 上述 ESM 缓存仅覆盖 级联阴影贴图(Cascade Shadow Map)范围内 的区域
  • 对于级联范围之外的远距离区域,使用 阴影快照(Shadow Snapshot)
    • 本质是一张覆盖 大范围距离 的阴影截图
    • 数据直接写入 同一个阴影缓存 ,只是数据来源从级联切换为快照
    • 定期重新捕获 ,随摄像机移动动态更新,对性能 几乎无额外开销
    • 也可以静态预捕获,但动态捕获更灵活

Froxel 光照采样

  • 对每个 Froxel,从阴影缓存中进行 最多 4 次多重采样(Multi-sample) ,因为 Froxel 在深度方向较深,单次采样不足
  • 初始结果会有明显 噪点

时间性滤波(Temporal Filtering)

  • 深度方向屏幕空间方向 都施加 抖动(Jitter) ,增加有效采样数
  • 在此基础上叠加 时间性滤波 (参考 Karis 等人的工作)
  • 效果:从噪点严重的初始结果 → 非常干净的最终结果

运行时光源照明(Runtime Lighting)

挑战

  • 运行时光源(火把、信号弹、手电筒等)对本作 极其重要 ——大量黑暗场景 + 雾/灰尘环境
  • 无法使用时间性滤波 :光源在移动,时间性滤波会导致严重的 拖影(Ghosting) ,必须在 单帧内 产出结果
  • 光源的 解析积分(Analytical Integration) 非常困难,而多重采样又 非常昂贵
  • 即使多重采样,摄像机前后移动时仍有明显的 不连续性(Discontinuity)

解决方案:预积分(Pre-integration)

  • 核心思想(参考 Hubler 的工作):尽可能多地 预计算 光照贡献
  • 支持三种光源类型:点光源(Point Light)正交光源(Ortho Light)聚光灯(Spot Light)

正交光源交叉(Ortho Light Intersection)

  • 最简单的情况
  • Froxel 射线从摄像机原点出发,沿方向延伸,穿过多个 Froxel 切片
  • 射线与正交光源(盒形)产生 两个交点
  • 直接在两个交点之间进行 纯积分(Pure Integration) 即可

点光源交叉(Point Light Intersection)

  • Froxel 射线与点光源球体相交时,可以构造一个 通过光源中心和两个交点的圆盘(Disk)
  • 关键观察 :无论射线从何方向穿过球体,都能生成这样一个圆盘
  • 由于圆盘总是经过光源中心,可以 只用一个方向 进行预积分
    • 实际采样时,将圆盘 旋转到对应射线方向 即可
  • 因此只需一张 预积分纹理 就能处理所有方向的交叉情况

聚光灯交叉(Spot Light Intersection)

几何分析

  • 射线穿过聚光灯锥体时,无论从何方向穿入,总能构建一个 三角形
    • 三角形的一个顶点在 光源原点
    • 另外两个顶点是射线与锥体的 两个交点
    • 三角形的一条边位于锥体 底部

预积分方案

  • 对于中心交叉:一个三角形可以有 各种入射和出射方向
  • 若只预积分一个方向(如向上),只能处理部分情况
  • 要覆盖所有方向,需要 多张纹理

实际实现

  • 创建一张 3D 纹理 ,尺寸为 64 × 64 × 96 (96 这个值通过测试确定)
    • 预积分了该平面上 所有方向 的交叉情况
  • 锥体由 多个三角形切片 组成,因此需要多个这样的 3D 纹理(或合并为一张)
  • 最终使用 8 个切片 ,最后一个切片为黑色(作为边界)
  • 整套 3D 纹理实现了对 整个聚光灯锥体 的完整预积分

预积分的价值

  • 运行时只需 查表(Texture Lookup) 而非逐 Froxel 多重采样
  • 单帧内 即可获得高质量结果,无需时间性滤波
  • 彻底解决了运动光源的拖影问题

运行时光照算法与预计算(Runtime Lighting Algorithm & Pre-Computation)

整体算法流程

第一步:逐 Froxel 射线求交

  • 对于屏幕空间中每 8×8 的 Froxel 组(Group)
    1. 先计算每条 Froxel 射线与哪些光源 相交
    2. 将所有射线的交叉结果 合并 为该组的 公共光源列表
    3. 进一步细分:每个 深度切片(Slice) 知道自己与哪些光源相交,以及这些光源的 类型

第二步:存储交叉数据

  • 每条 Froxel 射线的交叉数据包含:
    • 交叉深度范围 :起始深度(Start)和结束深度(End)
    • UV 坐标 :在预积分纹理中的采样起点(从摄像机深度 0 开始)
    • UV 变化率(Rate of Change) :沿射线方向 UV 的变化速度
  • 不同光源类型有不同的额外数据:
    • 聚光灯(Spot Light) :方向 + 切片坐标
    • 正交光源(Ortho Light) :到各平面的距离 + 接近速率

第三步:光照执行

  • 执行时,一个 wavefront(64 个线程) 对应一个 8×8 的 Froxel 组
  • 查找当前组在当前切片中与哪些光源相交
  • 标量操作(Scalar Operation) 循环遍历光源列表(保证高效)
  • 利用预存储的交叉数据,快速 重建 每个 Froxel 相对于光源的位置关系

预积分纹理的采样方式

点光源(Point Light)

  • 已知 Froxel 与光源的交叉范围后:
    • 在预积分纹理中采样 起点值终点值
    • 差值 即为该段的积分结果

  • 方向无关: 结果相同
  • 视觉效果:光源核心亮,向外柔和衰减,多个点光源叠加效果自然

聚光灯(Spot Light)

  • 原理类似,但需要采样 两张纹理(两个三角形切片)
  • 具体步骤:
    1. 确定射线与聚光灯锥体的交叉落在 哪两个切片 之间
    2. 分别对两个切片做积分(起点值 - 终点值)
    3. 根据切片间的 分数位置(Fraction) 进行 手动混合(Blend)
  • 视觉效果:中心有明亮核心,向边缘和远距离方向自然衰减

正交光源(Ortho Light)

  • 不需要预积分纹理
  • 因为正交光源使用 线性衰减(Linear Falloff) ,从平面向外递减
  • 线性衰减的积分结果是一个 二次函数(Quadratic) ,可以 解析求解
  • 示例:窗户投射的正交光束穿透雾气,另一个房间也有独立的正交光源

实现细节与优化技巧

纹理压缩与精度

  • 预积分纹理使用 Gamma / 二次幂压缩(Power-of-2 Compression)
    • 目的:让 低值区域获得更高精度 ,因为积分差值在暗部区域对精度要求更高
  • 格式选择的权衡:
    • U8 格式 :需要存储 所有方向 以保证精度
    • FP16 格式 :精度足够,可以只存储 一半方向 ,节省空间

三角形半边优化(Half Triangle)

  • 理论上可以只存储 半个三角形 来节省空间
  • 但当 Froxel 射线 穿过三角形中心 时,需要 两倍采样次数 ,实现复杂度较高,实际未采用

单张 vs 多张 3D 纹理

  • 可以将多张 3D 纹理合并为 一张大的 3D 纹理
  • 但使用 多张独立 3D 纹理 的好处:
    • 每张纹理可以有 不同的分辨率 ,按需分配精度
    • 更灵活地匹配不同光源类型的精度需求

运行时光源阴影(Runtime Light Shadows)

基本方案

  • 对阴影贴图进行 降采样(Downsample)
  • 使用 指数阴影贴图(Exponential Shadow Map, ESM) 格式
  • 在 Froxel 光照计算时进行 多重采样(Multi-sample)

关键问题:能否对光源阴影使用时间性滤波?

  • 已知结论:运行时光源本身 不能使用时间性滤波 (光源移动会导致拖影)
  • 阴影本身可以 :即便光源在移动,阴影的变化幅度远小于光源本身
  • 因此方案是: 仅对阴影项施加时间性滤波 ,光照颜色部分不做时间性处理

阴影与光照的分离存储策略

核心做法

  • 对于每个 Froxel,只要它在某个光源的 作用范围内 (不论是否被遮挡),都 照常计算并写入 RGB 光照值
  • 同时额外计算并存储一个 综合阴影值(Combined Shadow Value)
  • 具体示例:
    • 某 Froxel 被 光源 0 照亮(无遮挡),被 光源 1 遮挡
    • RGB 值仍然包含两个光源的颜色贡献
    • 但阴影值为 0.55 ,反映实际的遮挡比例

优缺点

  • 强度(Intensity)正确 :综合阴影值准确反映了实际遮挡程度
  • 可以叠加时间性滤波 :平滑的光轴效果,消除闪烁噪点
  • 颜色串扰(Light Cross-Talk) :被遮挡光源的颜色 仍然污染了 RGB 值
    • 大多数情况下不明显(因为光源颜色通常接近)
    • 但特定场景下问题很严重

颜色串扰问题的具体表现

  • 场景示例:一个大型锥形光源(暖色)透过栅栏照入,角色 Dina 的手电筒(白/灰色)在完全被遮挡的区域
  • 问题:本应完全无光的区域却出现了 红色色调 ——因为锥形光源虽被遮挡,其颜色仍被写入 RGB
  • 手电筒光本应是白色,却被错误的色调污染

简单方案及其代价

方案做法优点缺点
逐光源禁用阴影对特定光源,被遮挡时写入 (0, 0, 0) 而非颜色值,不使用 Alpha 阴影通道彻底消除串扰丢失时间性滤波 ,结果回到噪点状态
调整光源形状改变光源朝向使其不覆盖问题区域简单治标不治本
忽略问题不处理对 Naughty Dog 的品质标准不可接受

颜色串扰的最终解决方案(Hybrid Solution)

核心思路

  • 保留时间性滤波 的同时,在 确定完全处于阴影中 的 Froxel 处写入 (0, 0, 0, 0) 而非光照值
  • 关键判断:不仅当前 Froxel 在阴影中,而且 周围区域也全部在阴影中 时,才安全地写零
    • 此时时间性滤波无论如何都不需要(因为周围全是阴影,没有有用的历史数据可融合)
    • 视觉上也确实不应有任何光照色调影响

判断方法:方差阴影贴图(Variance Shadow Map)

  1. 对阴影纹理进行 降采样
  2. 创建 方差阴影贴图(VSM) 并进行 模糊(Blur)
  3. 利用 VSM 提供的两个信息:
    • 阴影概率 :该 Froxel 被遮挡的概率极高
    • 方差 :周围区域的阴影变化极小(方差低)
  4. 当概率高且方差低时 → 判定 Froxel 及其周围全部在阴影中
  5. 使用 平滑的范围函数(非阶跃函数) 过渡,避免突变

最终效果

  • 问题区域中,被遮挡的光源 不再贡献任何颜色 → 手电筒恢复正常的白/灰色
  • 光照边界区域仍保留 完整的时间性滤波 ,光轴平滑无噪点
  • VSM 纹理理论上可以 预计算为静态数据 (若不考虑动态遮挡物)

架构约束:单 Pass Uber Shader

原因

  • 光照结果存储在 U8 格式的 UAV 纹理 中(非 FP16)
  • 阴影项只有 8 位精度 ,非常有限
  • 如果逐光源分多次读写同一 Froxel,每次读写都会 累积精度损失
  • 因此必须在 单个 Pass 中处理一个 Froxel 的 所有光源
    • 先汇总所有光源贡献
    • 然后一次性计算综合阴影值
    • 最后一次性写入纹理

性能影响

  • 需要使用 Uber Shader :单个 Shader 中支持所有光源类型和数量的处理
  • 这对 Shader 复杂度和编译带来较大压力,但保证了精度和正确性

时间性调节

  • 由于使用了多重采样,可以 动态调整时间性滤波的参数 ,在不同场景间取得响应速度与平滑度的平衡
  • 最终效果 响应迅速 ,光影变化及时且平滑

优化策略(Optimizations)

性能压力来源

  • 当 Froxel 数量从 100 万以下 增长到 130 万甚至 200 万以上 时,所有操作的开销都开始显著累积
  • 即使是 显存清除(Memory Clear) 这种基础操作,在大纹理上也产生可观成本
  • 因此需要 多管齐下 的优化方案

多维度优化手段

优化手段说明
异步计算(Async Compute)将体积雾计算与 几何处理阶段 重叠执行(参考 Ronski 的建议)
定制化 Shader针对不同情况使用不同 Shader,避免为未使用的功能支付 额外 VGPR 开销
拆分工作到多个小 Shader避免单个庞大 Shader,改善 指令级重叠(Overlap)
时间性滤波(Temporal)摊薄单帧计算量
仅处理可见 Froxel最关键的优化 ——跳过几何体背后不可见的 Froxel
  • 演讲者引用灯光师 Tuana 的话:"这就像在玩一场巨大的 打地鼠游戏 "——每修一个问题就冒出另一个

整体 Pass 结构

  1. 深度 Pass 完成后、沿 G-Buffer 阶段进行所有体积雾处理
  2. 执行 合成(Compositing)
  3. 进入 Alpha Pass ,半透明物体查找 3D 体积纹理获取雾信息
  4. 核心决策:不计算几何体背后的 Froxel ——节省大量无用工作

深度分析(Depth Analysis)

确定可见 Froxel 范围

  • 读取 深度缓冲(Depth Buffer) ,使用 Hi-Z(层级深度) 方案(参考 Ronski 的建议)
  • 根据 Froxel 分辨率,可能需要查看 多个 Hi-Z 层级
  • 最终确定:每列 Froxel 最远需要计算到哪个深度

膨胀处理(Dilation)

  • 不能严格按深度截断——需要对深度范围进行 膨胀
  • 原因:采样雾效时会采样 相邻 Froxel ,即使某个邻居不可见,它仍可能被 双线性混合(Blending) 采样到
  • 因此必须将这些"虽不可见但会被采样"的 Froxel 也纳入计算

分桶调度(Bucket Dispatching)

  • 使用 间接调度(Dispatch Indirect) 代替全量调度
  • 将所有 可能可见或会被采样 的 Froxel 放入各自的 桶(Bucket)
  • 每个桶根据需要执行 不同类型的 Shader

Froxel 分类(Froxel Classification)

分类逻辑

  • 8×8 的 Froxel 组 为单位(对应一个 64 线程的 wavefront)
  • 取组内所有 Froxel 属性的 并集(Union) ,确定该组需要的功能
  • 分类依据来自光照预处理阶段存储的 位掩码(Bit Mask) ,记录了:
    • 光源 类型 :点光源 / 聚光灯
    • 是否需要 阴影
    • 是否受 太阳光 影响

分类示例

桶类型条件
太阳光桶受太阳光照射
聚光灯 + 阴影桶受带阴影的聚光灯影响
点光源 + 阴影桶受带阴影的点光源影响
聚光灯 + 点光源 + 阴影桶同时受多种光源影响
无光照桶不受任何光源影响,无需运行时光照处理

性能收益

核心原理

  • 不再需要 Uber Shader :仅当 Froxel 同时受多种不同光源影响时才使用完整 Uber Shader
  • 大多数 Froxel 只需执行 高度特化的小 Shader
    • 例如:仅受太阳光影响 → 运行专用太阳光 Shader
    • 有阴影 vs 无阴影 → 分别对应不同 Shader

收益链条

  • 不仅减少了 执行的指令数 ,更关键的是降低了 VGPR 占用量
  • VGPR 减少 → 占用率提升 → 异步计算的 重叠效率 大幅改善

实际成果

  • 仅靠 Froxel 分类优化,在 性能不变 的前提下:
    • 10×10 像素 / Froxel、130 万 Froxel 提升到
    • 8×8 像素 / Froxel、200 万 Froxel
  • Froxel 数量增加约 54% ,分辨率显著提升,而 GPU 耗时持平

Froxel 可见性分类延迟(Visibility Classification Delay)

问题

  • 分类 Froxel 需要等待 深度缓冲(Depth Buffer) 完成
  • 这造成了 GPU 空闲等待 ,浪费了宝贵的执行时间

解决方案:重投影预测

  • 将上一帧存储的 逐 Froxel 深度纹理(2D 屏幕空间深度范围) 进行 重投影(Reproject)
  • 保守估计(Conservative Approach) 预测当前帧哪些 Froxel 将可见
  • 预测误差的影响:
    • 预测准确 :完美,无额外开销
    • ⚠️ 预测过远(偏保守) :多做一些无用工作,但结果无害
    • 预测过近 :不会发生(因为使用保守策略)
  • 实际效果:预测精度 非常高 ,接近实际结果

可提前执行的工作

利用预测结果,以下工作可以在 深度 Pass 完成之前 就开始执行:

工作类型说明
密度填充(Density Filling)不依赖任何深度信息
无阴影的运行时光源不需要等待阴影贴图
无太阳光区域的预计算若区域不受太阳光影响,可跳过太阳阴影等待
使用上一帧阴影缓存太阳阴影本身已是时间性的,可直接使用上帧数据(曾实现后移除)
  • 关键收益:这些工作可以与 Depth-Only Pass 重叠执行 ,极大提升 GPU 利用率

仅处理可见 Froxel 与时间性滤波的交互

核心矛盾

  • 不可见 Froxel 完全不做任何处理 → 几何体背后的数据是 垃圾值
  • 时间性滤波需要采样 上一帧数据 ,但上一帧中"可见"的位置在当前帧可能 来自几何体背后

最初方案:用简单 Shader 维护不可见 Froxel 的旧值(保持陈旧但有效的数据) 最终决策:彻底放弃维护 ,不可见区域保持垃圾数据,通过其他手段解决

安全采样策略

在执行时间性滤波时,检查上一帧的 逐 Froxel 深度纹理 ,判断采样位置的可见性状态:

上一帧状态采样策略
完全可见(Visible)深度经过膨胀,邻居也可见 → 正常采样 (包含双线性混合)
膨胀可见(Dilated Visible)仅因膨胀而被计算 → 只能在 Froxel 中心采样 (不能采样邻居,邻居可能无效)
不可见(Not Visible)数据为垃圾 → 不可采样
最后一个可见切片只能采样到 0.5 位置 ,调整采样坐标,避免采样到切片边界外的无效数据
  • 新暴露的 Froxel:时间性混合因子设为 1.0 (完全使用当前帧数据,不混合历史)

新暴露 Froxel 的伪影与修复

问题分析

  • 混合因子为 1.0 的 Froxel 只有 单帧数据 ,缺乏时间性抖动的多帧积累
  • 导致 鬼影(Ghosting) :不同帧的抖动模式产生不同值,这些值在后续时间性混合中持续存在

第一步改善:增强单帧质量

  • 检测到混合因子为 1.0 时 → 增加多重采样次数
  • 同时 重投影到上一帧的阴影缓存 ,获取额外的阴影采样
  • 将当前帧和上一帧的多重采样结果 混合 ,获得更好的初始估计
  • 效果:消除了错误信息,但仍有 模糊度不一致 的鬼影(新暴露区域比周围更锐利)

第二步改善:定向模糊

  • 关键洞察:问题不是"信息错误",而是"不够模糊"——新暴露区域缺乏时间性滤波的累积模糊效果

实现流程

  1. 在时间性滤波 Pass 中,将混合因子为 1.0 的 单个 Froxel (非 Froxel 组)加入 待模糊列表
  2. 列表中同时存储:
    • Froxel 在切片内的分数位置
    • 模糊方向 :仅向前或仅向后模糊
    • 模糊强度
  3. 所有时间性滤波完成后,运行 专用模糊 Pass 处理列表中的 Froxel
  4. 最后执行 最终累积(Final Accumulate)

深度方向模糊的注意事项

  • 不能同时向前后模糊 :由于前后切片深度差异大,双向模糊会产生 沿深度方向的涂抹(Smearing)
  • 必须根据摄像机运动方向,仅向暴露方向模糊

最终效果

  • 新暴露区域与已有区域 几乎无法区分 ,鬼影极小
  • 实现了目标:不可见 Froxel 零处理开销 + 暴露时无感知重建

边缘涂抹(Edge Smearing)

  • 参考 Ronski Hillaire 提出的问题
  • 当时间性滤波采样的位置 完全落在上一帧视锥之外 时:
    • 不能 Clamp 到上一帧纹理最近点 → 会导致边缘值被拉伸涂抹
    • ✅ 应直接使用 1.0 混合因子 (完全使用当前帧数据)
  • 判断阈值:采样点与上一帧纹理边界的距离超过 半个 Froxel 大小 时,视为"在外部"

光照混叠伪影(Light Aliasing vs Froxel Depth)

问题表现

  • 摄像机前后移动时,光源在 Froxel 深度边界上出现 规律性图案(Patterning)
  • 本质原因:光源的空间频率与 Froxel 深度切片的间距 产生混叠 ,随摄像机移动形成莫尔纹

根本原因

  • Froxel 的深度切片是 固定的离散层 ,光源强度在切片间变化剧烈时:
    • 预积分纹理的起止采样恰好落在 不利的切片边界
    • 产生每帧不同的积分结果 → 视觉闪烁/条纹

总结架构图

帧开始
  │
  ├─ [预测可见Froxel] ← 重投影上帧深度
  │     │
  │     ├─ 密度填充 ──────────────────┐
  │     ├─ 无阴影光源预计算 ──────────┤  与 Depth Pass 重叠
  │     └─ 无太阳光区域预计算 ────────┘
  │
  ├─ [Depth Pass 完成]
  │     │
  │     ├─ 深度分析 + 膨胀
  │     ├─ Froxel 分类 + 分桶
  │     └─ 间接调度各类 Shader
  │
  ├─ [光照计算]
  │     ├─ 环境光照 (KD树标量遍历)
  │     ├─ 太阳阴影缓存采样
  │     └─ 运行时光源 (预积分 + 阴影)
  │
  ├─ [时间性滤波]
  │     ├─ 安全采样判断 (可见/膨胀可见/不可见)
  │     ├─ 新暴露Froxel → 增强多重采样
  │     └─ 输出待模糊列表
  │
  ├─ [模糊 Pass] ← 仅处理列表中的Froxel
  │
  └─ [最终累积 + 合成]

伪影:Froxel 与光源的走样问题(Froxel vs Light Aliasing)

问题根源

  • Froxel 的 深度跨度较大 ,而光源(尤其是细窄光轴)可能只 部分穿过 一个 Froxel
  • 光源与 Froxel 的交叉位置不同会导致视觉差异:
    • 光源交叉在 Froxel 中心 → 只影响 1 个 Froxel
    • 光源交叉在 Froxel 边界 → 影响 2 个 Froxel
  • 在累积透射率(Transmittance Accumulation)阶段,这种差异产生可见的 条纹/断裂图案

最坏情况

  • 细窄光轴 穿过 深度较大的 Froxel → 断裂最为明显
  • 示例:薄光轴在某处出现明显 断裂,正是因为光源在该处从"交叉 1 个 Froxel"变为"交叉 2 个 Froxel"

解决方案:扩展采样范围

核心思路

  • 覆盖所有可能的交叉情况:在单个 Froxel 内采样时,范围扩展到 前后各半个 Froxel
  • 即:每个 Froxel 的采样范围覆盖 两个 Froxel 的宽度

时间性方案(Temporal)

  • 简单地将时间性抖动的采样点 稍微扩展到 Froxel 边界之外
  • 多帧累积自然覆盖所有交叉情况

单帧多重采样方案

  • 在两个 Froxel 范围内进行多重采样
  • 关键技巧:加权采样 ——不能等权混合
    • 模拟时间性抖动的效果:某些采样位置在抖动过程中被 命中更多次
    • 边缘采样点 :权重 ×1(只会被命中 1 次)
    • 中心采样点 :权重 ×4(会被命中 4 次)

效果

  • 走样伪影 完全消除
  • 注意:之前展示的图像是 夸张的调试视图 ,实际游戏中伪影更微妙——但即使微妙,玩家仍能察觉

伪影:环境光泄漏(Ambient Light Leaking)

问题

  • 环境光(Ambient Light)穿透墙壁 泄漏到不应被照亮的区域
  • 最明显的场景:明亮室外环境光 穿墙进入 黑暗房间

解决方案一:区域限制探针采样(Region-Constrained Probes)

原理

  • 利用已有的 区域(Region) 系统(此前用于环境光色调调整,使用更大的体素)
  • 规则:任何 位于区域内或紧邻区域边界 的 Froxel,只能采样 该区域内的探针
  • 阻止了跨墙探针对 Froxel 的影响

实现优势

  • 标量探针查找 方案(192 个探针列表)完美配合
  • 探针列表是标量的 → 可以用 64 个线程并行 检查所有探针与区域的关系
  • 避免了每个线程各自处理不同探针导致的低效

实际使用情况

  • 需要 手动设置 区域
  • 但幸运的是:需要解决泄漏的场景中,往往 已经有区域存在 (用于其他光照目的)
  • 因此额外工作量不大

解决方案二:暗探针权重增强(Dark Probe Weighting)

原理

  • 在墙壁附近或墙内放置 暗探针(Dark Probes)
  • 对暗探针赋予 更高权重
  • 效果:暗值向外 扩散 ,将明亮的环境光 推离墙壁

效果示例

  • 处理前:环境光穿墙进入暗区
  • 处理后:暗探针的高权重将亮光推开,墙壁附近自然过渡到黑暗

尝试过但未采用的方案

方案原理结果
深度缓冲方差分析利用深度信息预测探针是否在几何体背后有前景但 结果不稳定
探针遮挡数据使用烘焙到探针中的遮挡信息判断探针与 Froxel 之间是否有墙有前景但 抖动严重、不一致
  • 两种方案都 展现了潜力 ,但因结果不够稳定可靠,最终未被采用

性能表现(Performance)

总体开销

  • 通常体积雾的开销为 2-3 毫秒,偶尔飙升至 4 毫秒
  • 负责帧预算管理的 Wayne 表示:体积雾 从未成为需要关闭的问题功能
  • 在以环境光/间接光为主的关卡中,可以 降低分辨率 节省少量开销
  • 重要提醒:运行时光照纹理、太阳阴影纹理、环境光纹理等全部被 粒子系统复用
    • 粒子光照不需要额外计算,只需采样这些纹理
    • 这是一笔巨大的 隐性节省

具体案例分析

案例 1:太阳光场景

指标数值
网格尺寸6×6 Froxel(屏幕空间)
总 Froxel 数~360 万
有太阳光的可见 Froxel166 万
无太阳光的可见 Froxel73 万
需要模糊的 Froxel1.1 万
串行原始开销3.35 毫秒
异步重叠后的实际 FPS 开销2.1 毫秒

异步重叠的关键洞察

  • 不是简单地将 Compute Shader 放到几何阶段旁边就行
  • 核心在于 分桶 + 特化 Shader 带来的占用率提升
  • 实测数据:ALU 操作/时钟从 0.39 提升到 0.61
    • 即:增加了体积雾的工作量,但 GPU 整体吞吐反而提高了
  • 并非所有 Compute Shader 重叠都能做到这一点——只有高占用率的轻量 Shader 才有效

案例 2:多运行时光源场景

指标数值
网格尺寸8×8 Froxel
聚光灯(带阴影)4 个
点光源(无阴影)3 个
点光源(带阴影)1 个
FPS 开销3.5 毫秒
  • 开销较高的原因:多个光源 正对摄像机
  • 回顾关键原则:离摄像机越近的光源开销越高(因为近处 Froxel 更密集)

水下雾效(Underwater Fog)

核心挑战

  • 水面需要 折射效果(Refraction) → 雾的合成顺序至关重要
  • 需要将雾分为 水上水下 两部分分别处理

渲染流程

1. 每个 Froxel 标记自己是否在水下
2. 仅累积【水下 Froxel】→ 合成水下雾
3. 捕获折射缓冲(Refraction Buffer)
4. 累积【水上 Froxel】→ 合成水上雾
5. 在水面之后应用水上雾

实现细节

  • Alpha 物体 不受影响:因为所有内容按顺序渲染
  • 理论上可以 单次累积到两张纹理,但累积纹理开销大 → 选择 执行两次累积
  • 水下应用 颜色吸收(Color Absorption)消光(Extinction) 以获得真实感

水下焦散(Caustics)

  • 使用 滚动的 2D 纹理(实际是动画 3D 纹理的 2D 切片)模拟焦散
  • 累积 Pass 中应用(而非时间性滤波 Pass)
    • 原因:焦散是动态滚动的 → 不能使用时间性滤波

视觉效果示例

  • 水面上的聚光灯体积光 + 水下的手电筒体积光 同时可见
  • 从水上进入水下时,光轴 无缝延续,水面上方的雾 仍然可见
  • 浑浊水体中的光照效果非常出色

总结(Conclusion)

达成的目标

  1. 高度大气感 的视觉效果——环境与特效融为一体
  2. 统一的光照与合成 ——体积雾与粒子系统共享光照数据
  3. 按需付费(Pay for What You See) 策略——仅处理可见 Froxel,显著节省开销
  4. 出色的 GPU 性能 ——分桶调度、特化 Shader、高占用率异步重叠

相关资源

  • 同系列 SIGGRAPH 2020 Naughty Dog 演讲:
    • GPU 特效相关
    • 其他团队成员的演讲

全文完。 这是一份关于《最后生还者2》中体积雾系统的完整技术演讲,涵盖了从基础理论(Froxel、预积分纹理)到工程实现(分桶调度、时间性滤波、水下雾效)的方方面面。核心贡献在于:将昂贵的体积光照计算通过预计算、分类优化和异步执行,压缩到了可以在 AAA 游戏中实时运行的性能范围内。