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 × 64 | 8×8 像素/Froxel | ~200 万 |
| 高 | ~384 × 216 × 64 | 5×5 像素/Froxel | ~530 万 |
| 低 | ~160 × 90 × 64 | 12×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 内完成
算法详解
- Wavefront = 64 个线程 ,负责一个 4×4×4 的 Froxel 网格
- 为整个 wavefront 计算一个 公共包围球(Common Sphere) ,覆盖所有 64 个线程对应的 Froxel
- 用这个公共球去遍历 KD 树——因为球对所有线程是共同的,所以这是一个 标量操作(Scalar Operation)
- 以标量方式遍历 KD 树,收集最多 192 个探针 ,仅消耗 3 个 VGPR
- 列表组装完成后,以 并行方式 在 3 次迭代 内处理所有探针
- 例如:具有相同遮挡测试(如朝摄像机方向的遮挡)的探针可以批量处理
- 之后每个线程从列表中 挑选自己需要的探针 进行求值
进一步优化: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) :
- 先计算每条 Froxel 射线与哪些光源 相交
- 将所有射线的交叉结果 合并 为该组的 公共光源列表
- 进一步细分:每个 深度切片(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 与光源的交叉范围后:
- 在预积分纹理中采样 起点值 和 终点值
- 取 差值 即为该段的积分结果
I=T(end)−T(start)
- 方向无关: T(end)−T(start) 或 T(start)−T(end) 结果相同
- 视觉效果:光源核心亮,向外柔和衰减,多个点光源叠加效果自然
聚光灯(Spot Light)
- 原理类似,但需要采样 两张纹理(两个三角形切片)
- 具体步骤:
- 确定射线与聚光灯锥体的交叉落在 哪两个切片 之间
- 分别对两个切片做积分(起点值 - 终点值)
- 根据切片间的 分数位置(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)
- 对阴影纹理进行 降采样
- 创建 方差阴影贴图(VSM) 并进行 模糊(Blur)
- 利用 VSM 提供的两个信息:
- 阴影概率 :该 Froxel 被遮挡的概率极高
- 方差 :周围区域的阴影变化极小(方差低)
- 当概率高且方差低时 → 判定 Froxel 及其周围全部在阴影中
- 使用 平滑的范围函数(非阶跃函数) 过渡,避免突变
最终效果
- 问题区域中,被遮挡的光源 不再贡献任何颜色 → 手电筒恢复正常的白/灰色
- 光照边界区域仍保留 完整的时间性滤波 ,光轴平滑无噪点
- 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 结构
- 在 深度 Pass 完成后、沿 G-Buffer 阶段进行所有体积雾处理
- 执行 合成(Compositing)
- 进入 Alpha Pass ,半透明物体查找 3D 体积纹理获取雾信息
- 核心决策:不计算几何体背后的 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
收益链条
特化 Shader→更少 VGPR→更高占用率→更好的异步重叠→更快执行
- 不仅减少了 执行的指令数 ,更关键的是降低了 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 时 → 增加多重采样次数
- 同时 重投影到上一帧的阴影缓存 ,获取额外的阴影采样
- 将当前帧和上一帧的多重采样结果 混合 ,获得更好的初始估计
- 效果:消除了错误信息,但仍有 模糊度不一致 的鬼影(新暴露区域比周围更锐利)
第二步改善:定向模糊
- 关键洞察:问题不是"信息错误",而是"不够模糊"——新暴露区域缺乏时间性滤波的累积模糊效果
实现流程
- 在时间性滤波 Pass 中,将混合因子为 1.0 的 单个 Froxel (非 Froxel 组)加入 待模糊列表
- 列表中同时存储:
- Froxel 在切片内的分数位置
- 模糊方向 :仅向前或仅向后模糊
- 模糊强度
- 所有时间性滤波完成后,运行 专用模糊 Pass 处理列表中的 Froxel
- 最后执行 最终累积(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 之间是否有墙 | 有前景但 抖动严重、不一致 |
- 两种方案都 展现了潜力 ,但因结果不够稳定可靠,最终未被采用
总体开销
- 通常体积雾的开销为 2-3 毫秒,偶尔飙升至 4 毫秒
- 负责帧预算管理的 Wayne 表示:体积雾 从未成为需要关闭的问题功能
- 在以环境光/间接光为主的关卡中,可以 降低分辨率 节省少量开销
- 重要提醒:运行时光照纹理、太阳阴影纹理、环境光纹理等全部被 粒子系统复用
- 粒子光照不需要额外计算,只需采样这些纹理
- 这是一笔巨大的 隐性节省
具体案例分析
案例 1:太阳光场景
| 指标 | 数值 |
|---|
| 网格尺寸 | 6×6 Froxel(屏幕空间) |
| 总 Froxel 数 | ~360 万 |
| 有太阳光的可见 Froxel | 166 万 |
| 无太阳光的可见 Froxel | 73 万 |
| 需要模糊的 Froxel | 1.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)
达成的目标
- ✅ 高度大气感 的视觉效果——环境与特效融为一体
- ✅ 统一的光照与合成 ——体积雾与粒子系统共享光照数据
- ✅ 按需付费(Pay for What You See) 策略——仅处理可见 Froxel,显著节省开销
- ✅ 出色的 GPU 性能 ——分桶调度、特化 Shader、高占用率异步重叠
相关资源
- 同系列 SIGGRAPH 2020 Naughty Dog 演讲:
全文完。 这是一份关于《最后生还者2》中体积雾系统的完整技术演讲,涵盖了从基础理论(Froxel、预积分纹理)到工程实现(分桶调度、时间性滤波、水下雾效)的方方面面。核心贡献在于:将昂贵的体积光照计算通过预计算、分类优化和异步执行,压缩到了可以在 AAA 游戏中实时运行的性能范围内。