It Just Works: Ray-Traced Reflections in Battlefield V
项目背景
游戏与团队概况
- Battlefield V 是一款以 二战 为背景的第一人称射击游戏,于 2018 年 11 月 发布
- 演讲者 Johannes Dalianes 是 EA DICE 的 渲染工程师 ,自 2014 年加入,参与过 Battlefield 和 Battlefront 系列
- 光线追踪功能的开发周期约 10 个月 ,团队配置为 4 名全职工程师 ,并有 DICE 和 NVIDIA 的其他人员协助
- 这是第一款搭载 DXR(DirectX Raytracing)发布的游戏
面临的核心挑战
内容已锁定,无法为光追量身定制
- 光追功能是在游戏 全面量产(full production)阶段中期 才决定加入的
- 游戏中的所有美术资源和内容 并非为光线追踪而构建 ,因此所有技术方案都必须 适配已有内容 ,而非反过来要求内容配合技术
引擎改动规模未知
- 加入光线追踪 不像一个简单的后处理 Pass 那样"即插即用"
- 它涉及引擎的深层改动,整体复杂度在开发初期难以准确评估
硬件性能未知
- 开发启动时 NVIDIA Turing 架构显卡尚未发布
- 团队只能在 上一代 GPU 上运行和调试,需要对最终硬件的性能做大量 估算和猜测
- 关键的权衡点包括:
- 光线数量 vs. 像素数量 的比例
- 光线追踪计算量 vs. 降噪(Denoising) 的分配
早期采用者的代价
| 问题类型 | 具体表现 |
|---|---|
| API 不稳定 | DXR API 尚未定稿,持续演进,导致部分系统需要 反复重写 |
| 稳定性差 | 经常遇到 驱动挂起(driver hang) 、 蓝屏 等问题 |
| 工具缺失 | 没有现成的调试/分析工具,团队必须 自行开发辅助工具 |
尽管面临以上所有困难和紧迫的时间限制,团队最终成功发布了游戏。
GPU 光线追踪管线概览
演讲者背景
- Jan(Yan)是 DICE 渲染团队的 技术负责人(Lead) ,在 DICE 工作六年
- 参与过 Battlefield 4、Battlefield 1、Mirror's Edge 等项目的引擎与渲染技术
- 他负责讲解的是光线追踪 周边的所有机制(machinery) ——即 围绕光追本身的非光追部分
简化的光线追踪管线三阶段
Jan 将整个光追反射的处理流程简化为三个关键阶段:
-
光线生成(Ray Generation)
- 决定 从哪里 、 向哪里 发射光线
- 需要执行一些逻辑来确定实际要做什么
-
光线求交(Intersection / Ray Tracing)
- 这是一个 "魔法黑盒" ,由 Johannes 后续详细讲解
- 核心功能:执行实际的光线与场景几何体的 求交测试
- 输出结果:返回命中点的 材质数据 或 G-Buffer 数据
-
着色 / 光照计算
- 基于求交返回的材质信息进行后续处理
延迟光照的设计决策
- Battlefield V 使用 延迟渲染(Deferred Rendering) 架构
- 团队从一开始就决定光追反射也采用 延迟光照(Deferred Lighting) 方式
- 即光线命中后先输出 G-Buffer 风格的材质数据
- 然后在后续阶段统一做光照计算
- 这种设计与引擎现有的延迟渲染管线保持一致,有利于复用已有的光照代码和流程
关键术语速查
| 术语 | 含义 |
|---|---|
| DXR | DirectX Raytracing,DirectX 12 中的光线追踪 API |
| Turing | NVIDIA 图灵架构,首代支持硬件光追的 GPU 架构(RTX 20 系列) |
| Deferred Rendering | 延迟渲染,先将几何/材质信息写入 G-Buffer,再统一做光照 |
| Denoising | 降噪,由于实时光追光线数量有限,产生的噪点需要通过降噪算法平滑 |
| Ray Count vs. Pixel Count | 光线数与像素数的比例,直接影响画质与性能的平衡 |
光线追踪反射管线:详细流程与可变速率追踪优化
光线追踪反射的完整流程(详细版)
三阶段回顾与衔接
整个光追反射管线的三阶段在实际实现中的流转如下:
- 光线生成(Ray Generation) → 确定从哪个像素、朝哪个方向发射光线
- 光线求交(Intersection) → "魔法黑盒",返回命中点的 材质数据(Material Data)
- 光照计算(Lighting) → 对命中点的材质数据执行光照,得到 辐射度(Radiance) ,再与光栅化结果合并
Johannes 在后续 30 分钟里会详细讲解第 2 步(求交)的细节,Jan 这里先聚焦于第 1 步和第 3 步。
光线生成阶段(Ray Generation)
基本设置
- 已有 G-Buffer 中的采样点(Sample Point)和 视线方向(View Vector)
- 因为做的是 反射 ,所以关注的是 BRDF 中的 高光波瓣(Specular Lobe)
- 理论上可以在高光波瓣分布上的很多位置进行采样
BRDF 截断(Tail Clamping)
灵感来源
- 基于 Thomas Trouchaud 和 Justin Uludog 在 Advances in Real-Time Rendering 2015 上的演讲:
- "Stochastic Real-Time Screen Space Reflections"
核心发现
- 不要采样 BRDF 分布的尾部(tail end)
- 尾部采样值极低,但可能命中极亮物体,产生 萤火虫噪点(Fireflies)
- 如果要消除这些噪点,需要 极大量的降噪处理
- 解决方案 :当 BRDF 分布值 低于某个阈值 时,直接 截断(chop off) 并对剩余部分 重新归一化(renormalize)
- 团队从一开始就采用了这个策略
光线选择与查找表
- 使用 Halton 序列(Halton Sequence) 从截断后的 BRDF 分布中 随机选取一条光线
- Halton 序列是一种 低差异序列(Low-Discrepancy Sequence) ,比纯随机采样分布更均匀
- 每个像素只发射 一条光线(1 SPP)
- 同时生成一张 查找纹理(Lookup Texture) :
- 光线求交的结果存储在 一维缓冲区 中
- 查找纹理记录了 "结果缓冲区索引 → 屏幕空间像素坐标" 的映射
- 后续合并阶段需要用它把结果写回正确的像素位置
光照计算阶段(Lighting)
最简单的实现方式
光线命中后拿到材质数据,最直接的做法就是 暴力遍历所有光源 :
radiance = 0
for each point_light:
radiance += calculate_radiance(point_light, hit_material)
for each spot_light:
radiance += calculate_radiance(spot_light, hit_material)
for each reflection_volume:
radiance += calculate_radiance(reflection_volume, hit_material)
// ... 其他光源类型
return radiance
与光栅化结果合并
- Battlefield V 使用 能量守恒的 GDX(Energy-Preserving GDX) 分光模型
- 该模型提供了 高光与漫反射的能量比(Specular-to-Diffuse Ratio)
- 合并公式:
- 利用之前生成的 查找纹理 来定位每条光线结果对应的屏幕像素
初始结果的问题分析
直接结果展示
使用上述"最简管线"(每像素 1 条光线)得到的画面存在严重问题:
| 问题 | 表现 |
|---|---|
| 噪声极大 | 即使是 不太粗糙的水面 也呈现明显噪点 |
| 性能极差 | 仅光线追踪部分就耗时约 18.5 毫秒 ,远超预算,不可能发布 |
| 大量无效光线 | 很多区域发射了光线,但 Specular-to-Diffuse Ratio 很低,最终结果乘以一个极小值后 几乎不可见 ——除非命中天空或极亮物体 |
关键洞察
在那些 光追结果贡献极小的区域 ,反正最终会被低权重压制并交给降噪器模糊掉,那为什么还要在那里发射那么多光线呢?
这直接引出了第一个重要优化。
优化一:可变速率光线追踪(Variable Rate Ray Tracing)
核心思想
- 不同屏幕区域对光追结果的 需求程度不同
- 高 Specular Ratio 的区域 → 需要更多光线(结果贡献大)
- 低 Specular Ratio 的区域 → 可以大幅减少光线(反正贡献微小,降噪器会处理)
- 将 光线预算(Ray Budget) 按需分配
实现步骤
Step 1:计算每个 Tile 的最大比率
- 将屏幕划分为 16×16 像素的 Tile
- 对每个 Tile 内所有像素,计算 Specular-to-Diffuse Ratio 的最大值
Step 2:归一化
- 对所有 Tile 的最大比率求和,然后每个 Tile 的值 除以总和
- 得到每个 Tile 应分配的 光线预算百分比
Step 3:量化到 2 的幂
- 将每个 Tile 的预算 四舍五入到最近的 2 的幂次(便于高效调度)
- 例如:全量(1:1)、半量(1:2)、四分之一(1:4)等
Step 4:抖动修正(Dithering)
- 直接取整会导致总光线数 系统性偏高或偏低
- 使用 随机噪声纹理(Random Noise Texture) 来在"向上取整"和"向下取整"之间做 概率性选择
- 选择依据是取整产生的 误差大小
- 误差越大,越倾向于选择另一个方向来补偿
- 这样在统计意义上,总光线数 趋近于目标预算
效果
- 低贡献区域的光线数量被大幅削减
- 高贡献区域(如水面、金属表面)保持密集采样
- 显著减少了总光线数 ,同时视觉质量损失极小(因为被削减的光线本来就贡献不大)
关键术语速查
| 术语 | 含义 |
|---|---|
| Specular Lobe | BRDF 中的高光波瓣,描述镜面反射方向附近的能量分布 |
| Halton Sequence | 低差异准随机序列,用于在采样空间中均匀分布采样点 |
| Fireflies | 由于极端采样值导致的亮点噪声,是路径追踪中的常见问题 |
| Specular-to-Diffuse Ratio | 表面反射中高光与漫反射的能量分配比例 |
| Variable Rate Tracing | 根据屏幕区域的重要性动态调整光线密度的优化策略 |
| Tile-based | 将屏幕分为固定大小的块(此处 16×16)进行批量处理 |
| Dithering | 通过随机扰动来减少量化误差的技术 |
可变速率追踪的采样分配与光线分箱优化
采样分配模式(Sample Allocation Patterns)
为什么只选择 2 的幂次
- 光线数量只选择 2 的幂次(Power of 2) ,原因是可以用非常简单的方式在 16×16 Tile 内分配采样点
各级别的分配策略
| 光线数 / Tile | 分配方式 |
|---|---|
| 256(满配) | 16×16 = 256,每个像素都发射光线 ,最直接 |
| 128 | 使用 棋盘格模式(Checkerboard Pattern) ,隔一个像素发射一条 |
| 64 | 将棋盘格中的小方块 放大 ,形成更稀疏但规则的采样图案 |
| 更低(32, 16, 8…) | 继续按相同思路 缩放 ,得到越来越少的光线 |
时域积累(Temporal Integration)
- 为了在低采样率下仍能覆盖完整的像素信息,采用 逐帧轮换像素位置 的方式
- 每帧对 像素计数器递增 ,确保不同帧采样不同的像素位置
- 经过多帧累积后,所有像素都能获得采样数据
可变速率追踪的实际效果
可视化结果
演讲者展示了一张 热力图 风格的可视化:
| 颜色 | 含义 |
|---|---|
| 亮红色 | 每 Tile 发射 256 条光线 (满配),对应高反射区域 |
| 黄色 | 中等数量,介于红色与蓝色之间 |
| 蓝色 | 每 Tile 仅发射约 6~8 条光线 ,对应低反射贡献区域 |
观察到的规律
- 高反射率表面 → 反射贡献大 → 分配更多光线(红色区域)
- 低反射率 / 非反射表面 → 仍然发射光线,但 数量显著减少
- 掠射角(Grazing Angles) 区域光线数明显增多
- 原因:菲涅尔效应(Fresnel Effect) 在掠射角处显著增强反射强度,因此需要更多采样
光线发散性问题(Ray Divergence)
问题根源
光线追踪性能中一个巨大瓶颈是 光线的发散程度(Divergence) ——即同一批光线在空间中方向和位置的分散程度越大,GPU 的缓存命中率越低,性能越差。
发散性来自两个方面:
原因一:BRDF 随机采样
- 对 粗糙表面(Rough Surface) 的 BRDF 进行 重要性采样 时,采样出的光线方向天然就很分散
- 这导致同一个 Warp/Wave 中的光线朝完全不同的方向飞去
原因二:复杂几何内容(Art-Driven Divergence)
- 即使表面是 完美反射体(Perfect Mirror) ,复杂几何也会导致极端发散
- 典型案例 :铁艺围栏(Wrought Iron Fence)
- 每根栏杆是一个 扭曲的方形截面
- 由于法线朝向极度混乱,反射光线 几乎朝所有方向发散
- 这类内容导致性能 下降约 4 倍 ——走近围栏时帧率骤降到正常场景的 1/4
为什么必须解决
- 如果不解决,玩家在特定场景会遭遇 严重的帧率波动
- 目标:即使无法让最差情况变得完美,至少要 拉平性能曲线 ,让最好和最差场景之间的差距缩小
光线分箱(Ray Binning / Ray Sorting)
核心思想
将方向和位置 相似 的光线 分组到同一个 Bin 中,使同组光线在追踪时具有更好的 空间和方向一致性(Coherence) ,从而提升 GPU 缓存命中率。
Bin Index 的构造方式
对每条光线计算一个 Bin 索引 ,由两部分组成:
高位:屏幕空间位置(4 bits)
- 2 bits 表示 垂直方向 的屏幕偏移
- 2 bits 表示 水平方向 的屏幕偏移
- 相当于将屏幕空间粗略划分为 4×4 = 16 个区域
低位:光线方向(16 bits)
- 8 bits 编码 经度(Longitude)
- 8 bits 编码 纬度(Latitude)
- 直接取光线方向向量进行球面坐标编码,非常简单
完整 Bin Index 结构
[ 高4位: 屏幕位置 | 低16位: 方向编码 ]
[ 2bit V + 2bit H | 8bit Lon + 8bit Lat ]
可视化效果
- 将 Bin Index 映射为颜色后,可以直观看到:
- 颜色相近的区域 = 光线方向和位置相似 = 被分到同一个 Bin
- 分箱后的光线在 Dispatch 时具有更好的 局部一致性 ,GPU 执行效率显著提升
关键要点总结
| 优化手段 | 解决的问题 | 核心机制 |
|---|---|---|
| 2 的幂次采样分配 | 简化 Tile 内像素选择逻辑 | 棋盘格 + 缩放模式 |
| 时域积累 | 低采样率下信息不完整 | 逐帧轮换采样位置 |
| 可变速率追踪 | 不必要的光线浪费性能 | 按反射贡献动态调整光线数 |
| 光线分箱(Binning) | 光线发散导致 GPU 效率低下 | 按位置+方向编码排序,提升一致性 |
光线分箱实现细节与屏幕空间反射混合管线
光线分箱的具体实现步骤(Ray Binning Implementation)
可视化理解
- 在可视化图中,即使只是普通的 噪声 BRDF 采样 ,也可以清晰看到:颜色相同的像素被归入了同一个桶(Bucket)
- 这意味着方向和位置相近的光线被重新编组在一起
四步分箱算法
已知每条光线都有一个 Bin Index (由屏幕位置 + 光线方向编码而成),接下来的分箱流程如下:
第一步:统计每个 Bin 的大小
- 对每条光线,使用 原子递增(Atomic Increment) 操作累加对应 Bin 的计数器
- 原子递增会 返回递增前的旧值 ,这个旧值就是该光线在 Bin 内的 局部偏移(Local Offset)
- 结果:得到每个 Bin 的 总大小(Size) ,以及每条光线在其 Bin 内的 索引位置
第二步:计算每个 Bin 在输出数组中的起始位置
- 使用 独占式并行前缀和(Exclusive Parallel Prefix Sum)
- 参考 Mark Harris 的经典 CUDA 论文实现
- 本质是:将所有 Bin 的大小做前缀求和,得到每个 Bin 在最终紧凑数组中的 起始偏移量(Bin Offset)
第三步:计算每条光线的最终数组位置
- 将第二步得到的 Bin 起始偏移 加上 第一步得到的局部偏移,即为该光线在输出数组中的最终位置
第四步:生成查找表
- 将映射关系写入一张 查找表(Lookup Table)
- 后续发射光线时,通过查找表按 分箱顺序 依次处理,确保同一 Bin 内的光线被同一 Warp/Wave 执行,最大化缓存一致性
Screen Space Reflections(SSR)混合管线
问题:部分物体无法出现在光追反射中
具体案例
- 场景中有一些 混凝土块(Concrete Blocks) ,在光追反射中 完全看不到
- 原因:这些物体由一个 自定义散布系统(Scattering System) 在每帧 动态放置
- 该系统运行非常晚,且 每帧重新散布
- 没有时间重写系统将这些物体加入 GPU 世界(BVH / 加速结构)
- 如果每帧暴力添加,构建加速结构的开销 过于昂贵
为什么不能忽略这个问题
- 使用传统的 屏幕空间反射(SSR) 时,这些物体 是可见的
- 如果玩家开启 RTX 后反而 看到更少的反射 ,体验会非常糟糕
- 团队认为这 "太烂了(sucked a lot)",必须解决
解决方案:将 SSR 融入光追管线
核心思路:先尝试屏幕空间追踪,失败时再用光线追踪兜底
混合管线流程
对于每条待追踪的光线:
1. 执行【层级式屏幕空间追踪(Hierarchical Screen Space Trace)】
├── 如果未命中(Miss)→ 命中了天空 → 无需光追,直接结束
├── 如果命中且通过验证 → 提取【材质数据】→ 送入光照管线
└── 如果命中但被拒绝(Rejected)→ 执行完整光线追踪(Full Ray Trace)
└── 从 SSR 认为"出错"的位置开始追踪
└── 获取材质数据 → 送入同一个光照管线
关键设计:重新光照(Re-lighting)
为什么不直接取 SSR 的颜色值
- 传统 SSR 直接采样屏幕上另一个像素的 已有颜色(Radiance)
- 问题:高光(Specular)是视角相关的
- 从不同视角看同一个点,高光强度和形状不同
- 直接复用其他像素的颜色会导致 严重的不连续(Severe Discontinuities)
延迟光照的优势
- 因为 BFV 的整个光追管线采用 延迟光照(Deferred Lighting) 架构
- SSR 命中时,不取颜色,而是取 材质数据(Material Data)
- 然后将材质数据送入 完全相同的光照路径 重新计算
- 结果:SSR 与光追反射之间完全没有可见的不连续
- 即使采样位置只有亚像素级别的偏移,经过降噪后也完全不可见
SSR 命中的拒绝判定(Rejection Criteria)
- 在 SSR 的交点位置,采样 深度缓冲(Depth Buffer)
- 比较 SSR 计算得到的交点深度与深度缓冲中的实际深度
- 如果 差异过大 → 说明光线可能穿到了物体后面 → 拒绝此次 SSR 命中 ,改用光线追踪
参考实现:Sakovic & Katal, 2015 的层级式屏幕空间追踪方法
SSR 混合带来的额外好处
| 免费获得的内容 | 说明 |
|---|---|
| 贴花(Decals) | 贴花只存在于屏幕空间中,SSR 可以直接反射它们。移开相机时贴花消失(因为离开屏幕了),但正常游玩时 几乎免费获得 |
| 散布物体 | 动态散布系统生成的物体可以通过 SSR 出现在反射中,无需加入 BVH |
| GPU 生成的几何体 | 任何 GPU 端生成的几何(如粒子、程序化网格等)都可以通过 SSR 反射,不需要为其构建加速结构 |
适用条件
- 这些物体 不能太大 ——如果物体很大,SSR 容易因为遮挡问题导致大面积追踪失败
- 对于小型/中型的散布物和 GPU 几何体,这套方案 工作得非常好
关键术语速查
| 术语 | 含义 |
|---|---|
| Atomic Increment | GPU 原子递增操作,保证多线程并发安全,返回旧值可用作局部索引 |
| Exclusive Parallel Prefix Sum | 独占式前缀和,计算数组中每个元素之前所有元素的累加和,常用于 GPU 并行内存分配 |
| Hierarchical Screen Space Trace | 层级式屏幕空间追踪,利用深度 mipmap 加速屏幕空间光线步进 |
| Scattering System | 散布系统,用于在关卡中批量放置重复物体(如石块、碎片等) |
| BVH(Bounding Volume Hierarchy) | 包围体层级结构,光线追踪的核心加速结构 |
光线结果碎片整理、光照优化与降噪管线
光线追踪结果的碎片整理(Defrag)
问题:Miss 光线导致光照着色器空转
- 光线追踪完成后,部分光线 命中(Hit) 了物体,部分 未命中(Miss) 而击中了天空
- 天空不需要复杂的光照计算
- 如果直接将所有光线送入光照着色器,Miss 的线程无事可做 ,导致 GPU 利用率低下(大量线程空闲等待)
解决方案:碎片整理(Defrag)
与之前光线分箱使用相同的思路,再做一次 紧凑化(Compaction) :
步骤
- 将每条光线标记为:Hit = 1,Miss = 0
- 对标记数组执行 独占式并行前缀和(Exclusive Parallel Prefix Sum)
- 计算出每条 Hit 光线前面有多少条 Hit 光线
- 这就是它在紧凑数组中的 新索引
- 生成新的 查找表(Lookup Table) ,只包含 Hit 光线
- 光照着色器只处理 Hit 光线,每个线程都有实际工作 ,GPU 利用率 100%
这是一个非常标准的 Stream Compaction 操作,在 GPU 计算中广泛使用。
光照计算优化:Per-Cell Light Lists
问题:朴素光照仍然太慢
- 即使碎片整理后光照着色器"满负荷"运行,仍然需要约 2 毫秒
- 原因很明显:之前的做法是 遍历所有光源 ——这就是最朴素的 延迟渲染(Deferred Rendering) 做法的老毛病
解决方案:Purcell 风格的世界空间光源链表
灵感来自 Purcell 等人的光线追踪渲染器 ,使用 世界空间网格 + 链表 来组织光源:
构建过程
- 在 整个世界空间 上铺设一个 均匀网格(Uniform Grid) ,将空间划分为若干 单元格(Cell)
- 遍历所有光源,确定每个光源影响哪些单元格
- 为每个单元格维护一个 链表(Linked List) ,指向该单元格内的光源数据
- 如果一个光源 跨越多个单元格 (如范围较大的光源),则将其 同时添加到所有覆盖的单元格 的链表中
查询过程
对于每个需要光照的命中点:
1. 确定该点位于哪个【世界空间单元格】
2. 遍历该单元格的【链表】中的所有光源
3. 仅对这些光源执行光照计算
效果
- 不再遍历 全场景所有光源 ,只处理 命中点附近的光源
- 有效解决了 2ms 的性能瓶颈
降噪管线(Denoising Pipeline)
问题:1 SPP 的结果极度噪声
- 每像素只发射 1 条光线(甚至更少),结果 非常嘈杂 ,无法直接使用
- 需要降噪,但 绝不能发射更多光线 ——预算不允许
两种数据复用策略
| 策略 | 含义 |
|---|---|
| 空间复用(Spatial Reuse) | 借用 邻近像素 的光线结果 |
| 时域复用(Temporal Reuse) | 借用 前几帧 的光线结果 |
灵感来源:同样基于 Stochastic Screen Space Reflections(2015) 的演讲,团队借鉴并改进了其中的 BRDF 滤波器 和 时域滤波器 。
BRDF 空间滤波器(BRDF Spatial Filter)
核心思想
每条光线采样了 BRDF 高光波瓣上的一个方向。邻近像素的光线也各自采样了它们自己的 BRDF 波瓣。关键洞察:
- 假设反射的目标表面大致是一个 平面(Flat Plane)
- 忽略可见性差异和视角依赖性(有一定近似误差,但实践中可接受)
- 那么可以问:如果邻居的采样方向落在"我的" BRDF 上,它的权重是多少?
- 据此调整邻居采样的贡献权重,做 加权求和
数学表达
本质上是一个 加权平均 :
其中权重 基于邻居采样方向在 当前像素 BRDF 波瓣 上的评估值。
与原始论文的差异
| 方面 | 原始论文(2015) | BFV 实现 |
|---|---|---|
| 采样数量 | 固定 4 个 邻居 | 大幅增加 邻居数量 |
| 原因 | SSR 噪声较低,4 个够用 | 1 SPP 光追噪声极高,4 个远远不够 |
自适应核大小(Adaptive Kernel Size)
问题
- 粗糙表面 → 噪声多 → 需要大核收集更多邻居
- 光滑表面 → 噪声少 → 大核反而会 模糊锐利反射
- 因此需要 每个像素自适应地选择核大小
核大小的计算方法
- 根据像素的 BRDF 波瓣(由 粗糙度 和 表面角度 决定),假设反射目标是一个平面
- 计算该波瓣对应的 锥体(Cone) 在平面上的覆盖范围
- 将这个锥体 投影回屏幕空间 ,得到一个 屏幕空间区域
- 这个区域的大小和形状就是该像素的 滤波核(Filter Kernel)
粗糙度高 → BRDF 波瓣宽 → 锥体大 → 屏幕空间核大 粗糙度低 → BRDF 波瓣窄 → 锥体小 → 屏幕空间核小
核大小的距离估计问题
难点:不知道"想象中的平面"距离多远
- 计算锥体投影需要知道反射目标平面的 距离
- 但我们只有 随机采样到的一个命中距离 ,这个值 噪声极大
- 如果直接使用,核大小会 逐帧剧烈波动 ,导致时域不稳定
两步解决方案
第一步:空间平均
- 取当前像素周围 5×5 邻域 内所有光线的命中距离,做 简单平均
- 得到一个更稳定的 平均命中距离
第二步:时域重投影(Temporal Reprojection)
- 空间平均仍然不够稳定
- 对平均后的距离值再做 时域重投影 ——将当前帧的值与历史帧的值混合
- 这是实时渲染工程师的经典"第二把锤子":当一次过滤不够,就叠加时域累积
关键术语速查
| 术语 | 含义 |
|---|---|
| Stream Compaction | 流压缩,将稀疏数据紧凑化,去除无效元素 |
| Exclusive Prefix Sum | 独占前缀和,第 i 个输出 = 前 i-1 个元素之和 |
| Purcell Light Lists | 基于空间网格 + 链表的光源索引方法 |
| BRDF Filter | 基于 BRDF 权重的空间降噪滤波器 |
| Adaptive Kernel | 根据表面属性动态调整滤波核大小 |
| Temporal Reprojection | 时域重投影,利用历史帧数据进行稳定化 |
降噪管线详解:时域滤波、高斯后处理与最终管线性能
BRDF 空间滤波器的实现细节补充
平均光线距离的时域积累
- 为了计算合适的 滤波核大小(Kernel Size) ,需要知道反射物体的 平均距离
- 使用 虚拟反射点(Virtual Reflection Point) 从前一帧进行 重投影(Reprojection)
- 基于 Stachowiak 等人 的方法
- 在屏幕空间中找到正确的 时域反射对应位置 ,获取前帧的距离数据
- 前帧数据的 混合权重非常高 :约 80%~90%
- 原因:如果前帧数据存在,它对平均距离的估计 非常准确
- 经过几帧积累后,平均距离趋于稳定,核大小也趋于稳定
LDS 数据共享实现
- 当前线程的 BRDF 核可能 非常大 ,覆盖的像素远超当前线程拥有的数据
- 使用 LDS(Local Data Share / 共享内存) 在 Warp/Wave 内共享 额外像素的信息
- 每个线程先将自己的数据写入 LDS
- 然后从 LDS 中读取邻居数据
- 实际支持的最大滤波核可以相当大,但实践中 运行时稍小一些
- 最大支持 81 taps(即 9×9 范围内的采样点)
空间滤波后的结果
- 即使使用 81 taps 的 BRDF 空间滤波,结果 仍然有明显噪声
- 必须继续进行 时域降噪
时域降噪(Temporal Denoising)
核心挑战:Ghosting(鬼影)
- 时域滤波的基本操作:将当前帧与前帧结果 混合(Blend)
- 但必须决定前帧的样本是否仍然 有效 ,否则会产生 鬼影(Ghosting)
典型鬼影场景
- 玩家从远处走近一个反射面时:
- 远处时高光波瓣投影大 → 反射看起来 模糊
- 走近后波瓣投影变小 → 反射应该变 清晰
- 如果不做拒绝,前帧的模糊结果会 持续残留 ,反射永远无法变清晰
拒绝机制:利用 BRDF 滤波器的邻域信息
关键洞察
- 在之前的 BRDF 空间滤波阶段,已经 遍历了当前像素周围的所有邻居样本
- 因此已经知道 当前帧邻域的辐射度分布 是什么样的
具体做法
- 在 BRDF 空间滤波过程中,记录邻域样本的 辐射度(Radiance)分布
- 对该分布拟合一个 轴对齐包围盒(Axis-Aligned Bounding Box, AABB)
- 即 颜色空间中的 Min-Max Box
- 将这个 AABB 传递给 时域滤波器
- 时域滤波器将前帧样本 裁剪(Clip) 到这个 AABB 内
- 如果前帧值在 AABB 内 → 接受
- 如果前帧值在 AABB 外 → 裁剪到最近的边界点
为什么有效
- 当玩家走近反射面时,BRDF 波瓣变窄 → 邻域分布变窄 → AABB 变小 → 自动裁掉过于模糊的旧数据
- 反射会 自然地从模糊过渡到清晰 ,不需要额外的启发式规则
这种方法非常优雅:空间滤波器的"副产品"直接作为时域滤波器的"拒绝依据",一石二鸟。
高斯后处理滤波器(Gaussian Post-Filter)
问题:时域滤波后仍有残余噪声
- 经过 BRDF 空间滤波 + 时域滤波后,噪声大幅减少但 仍未完全消除
- 此时团队决定 放弃物理正确性 ,转向实用主义
高斯模糊方案
核大小的确定
- 已有的 平均光线距离 可以用来估算高光波瓣在屏幕空间的投影大小
- 稍加近似后,这个投影 看起来像一个高斯分布
- 因此直接 用高斯函数拟合 这个核
各向异性支持(Anisotropic)
- 核不是正圆形,而是 椭圆形 :宽度和高度不同
- 根据 反射表面与视线的角度 ,椭圆的长短轴比例会变化
- 掠射角时椭圆拉长
- 正面观察时椭圆趋近于圆
查找表优化
- 拟合高斯参数 计算量较大 ,因此 离线预计算
- 生成一张 2D 查找纹理(Lookup Texture) :
- 输入:角度(Angle) × 粗糙度(Roughness)
- 输出:单位光线长度下的核宽度和高度
- 运行时:
- 其中 RayT 是光线命中距离,乘以查找表值即得屏幕空间下的实际核大小
过度模糊问题与修正
问题
- 现在串联了 两个滤波器 ,每个都大致覆盖了 高光波瓣的投影大小
- 两个滤波器 卷积(Convolve) 后,等效核变得 比高光波瓣更大
- 结果:所有反射看起来 比参考图像更粗糙/模糊
解决方案
- 缩小两个滤波器的核大小 ,使卷积后的等效核 匹配参考结果
- 没有精确的数学推导,而是通过 视觉对比调参 完成
- 虽然不够"优雅",但 实际效果良好
残余伪影
- 在大多数 真实游戏场景 中效果很好
- 仍存在的问题主要出现在 极端病态场景 中:
- 始终采样 Mip 0(最高精度纹理)
- 对于非常粗糙的反射,输入数据本身就 质量很差 ,降噪困难
最终管线性能概览
各阶段耗时
| 管线阶段 | 耗时(ms) | 备注 |
|---|---|---|
| 可变速率追踪(Variable Rate Tracing) | 0.3 ~ 7 | 依场景而异,核心瓶颈阶段 |
| 光线生成(Ray Generation) | ~0.2 | 包括 BRDF 采样、Halton 序列选择、查找表生成 |
后续阶段(分箱、求交、碎片整理、光照、降噪、合并)的耗时将在演讲后续部分给出。
关键技术总结
| 技术点 | 核心思路 |
|---|---|
| 平均距离的时域积累 | 高权重混合前帧数据,快速收敛到稳定估计 |
| LDS 共享 | Warp 内线程互相借用数据,支持大核滤波 |
| AABB 时域拒绝 | 利用空间滤波的邻域分布作为时域滤波的裁剪边界 |
| 各向异性高斯 | 离线拟合 + 查找表,运行时乘以光线距离即得核大小 |
| 核大小缩放 | 两个滤波器串联导致过度模糊,通过同时缩小两者来补偿 |
完整管线性能分析与加速结构构建
完整光追反射管线的性能分解
各阶段耗时一览
| 阶段 | 耗时 (ms) | 备注 |
|---|---|---|
| 光线分箱(Binning) | 0.15 | 非常廉价 |
| 屏幕空间混合追踪(SSR Hybrid) | 0.36 | 消除约 40% 的光线,无需进入硬件光追 |
| 实际光线追踪(Ray Trace) | ~2.00 | 管线中最昂贵的单一步骤 |
| 碎片整理(Defrag) | 0.08 | 极其廉价 |
| 改进后的光照(Improved Lighting) | 0.46 | Per-Cell Light Lists 的效果 |
| 空间滤波(Spatial Filter) | ~1.50 | 昂贵 |
| 时域滤波(Temporal Filter) | ~2.40 | 最昂贵的降噪阶段 |
| 高斯后处理(Image Filter) | ~1.00 | 演讲者认为当前实现"很糟糕",有很大优化空间 |
| 总计 | ~6.3 ms | — |
⚠️ 演讲者不记得这是在哪个 GPU 上测量的,建议将这些数字视为 相对比例 而非绝对值。
关键观察
- 对比原始方案:仅光线求交就需要 18 ms ,现在整个管线(含求交 + 降噪 + 光照)仅 6.3 ms
- 追踪前的所有优化步骤(分箱、SSR 混合)加起来不到 0.5 ms ,却让追踪本身性能大幅提升
- 降噪是第二大开销:空间 + 时域 + 高斯合计约 5 ms ,几乎与追踪本身相当
- 最终画面质量良好,反射中 没有明显的大尺度伪影 ,仅有少量残余瑕疵
进入 DXR 硬件光追部分(Johannes 接手)
两大核心任务
| 任务 | 描述 |
|---|---|
| 构建加速结构 | 找到光线与场景的交点 |
| 提取材质数据 | 将交点的材质信息送入 SSR/光追共享的光照管线 |
加速结构构建(Acceleration Structure Building)
两级加速结构回顾
底层加速结构(BLAS - Bottom Level Acceleration Structure)
- 代表 单个网格(Mesh) :一把椅子、一辆车等
- 将几何数据发送到 GPU,由 GPU 构建内部的 BVH
- 一旦构建完成,只要该网格仍在使用 ,就可以持续复用
顶层加速结构(TLAS - Top Level Acceleration Structure)
- 将多个 BLAS 实例化 并组合,代表 整个游戏世界
- 每个实例包含一个变换矩阵(位置、旋转、缩放)
动态物体的处理
- 对于 非静态物体 (角色、载具、物理交互物体等):
- 运行一个 Compute Shader 变换顶点
- 重建该物体的 BLAS
- 这个过程 每帧重复 ,开销可能非常大
剔除问题(Culling Problem)
传统剔除在光追中失效
| 传统剔除技术 | 为什么对光追无效 |
|---|---|
| 视锥剔除(Frustum Culling) | 光线可以射向 视锥外 的物体(如反射看到身后的东西) |
| 遮挡剔除(Occlusion Culling) | 被遮挡的物体可能通过 反射 被看到 |
| 距离剔除(Distance Culling) | 远处物体可能出现在 近处镜面的反射 中 |
核心矛盾:光线 可以射向场景中的任何位置 ,传统基于摄像机视角的剔除策略全部失效。
"不剔除"的天真尝试
- 最简单的方案:干脆不做任何剔除
- 在 鹿特丹(Rotterdam) 关卡的测试结果:
| 指标 | 数值 |
|---|---|
| 顶层实例数(TLAS Instances) | ~20,000 |
| 每帧底层重建次数(BLAS Rebuilds/Frame) | ~1,000 |
性能灾难
- CPU 端:处理 20,000 个实例 + 1,000 次 BLAS 重建的管理开销 极其昂贵
- GPU 端:执行 1,000 次 BLAS 重建本身消耗 远超预算的毫秒数
- 结论:完全不剔除不可行 ,必须找到适合光追的剔除方案
预期后续内容
根据演讲的叙事节奏,Johannes 接下来应该会讲解:
- 适用于光追的剔除策略 ——如何在"光线可以射向任何地方"的约束下仍然减少物体数量
- 可能的方案:基于 屏幕空间反射方向的扩展视锥 、基于重要性的距离剔除 等
- BLAS 重建优化 ——如何减少每帧需要重建的 BLAS 数量
- 可能涉及 Refit vs. Rebuild 策略
- 材质数据提取 ——命中点后如何高效获取 G-Buffer 级别的材质信息
- 涉及 Closest Hit Shader 的设计
加速结构剔除策略、着色管线与命中着色器实现
加速结构的剔除策略(Culling Heuristic)
问题回顾
- 不做剔除时:20,000 个顶层实例 + 1,000 次 BLAS 重建/帧 → 开销无法承受
- 但传统剔除(视锥、遮挡)对光追 理论上不适用 ,因为光线可以射向任何方向
- 妥协:明知剔除是"错误"的,仍然使用 ,接受少量伪影换取可用性能
启发式剔除:角度阈值法(Culling Angle)
核心思想
- 远处的 小物体 对反射贡献极小,可以安全移除
- 远处的 大物体 仍可能出现在反射中,不应移除
- 需要一个同时考虑 距离 和 尺寸 的指标
具体做法
- 对每个物体计算其 包围球(Bounding Sphere) 的半径
- 计算物体到摄像机的 距离
- 计算 剔除角度(Culling Angle) :
- 如果 低于设定阈值 → 从加速结构中 移除该物体
本质:物体在摄像机视角中的 张角 太小 → 即使被反射也几乎看不到 → 剔除。
不同阈值的效果对比
| 阈值 | 视觉质量 | 性能 |
|---|---|---|
| 无剔除 | 完美,无缺失 | 无法承受 |
| 4° | 卡车轮子消失、部分远处路牌消失,但整体可接受 | 大幅改善 |
| 15° | 明显可见大量物体缺失 | 极端性能但质量太差 |
4° 剔除的实际收益
| 指标 | 无剔除 | 4° 剔除 | 降幅 |
|---|---|---|---|
| BLAS 重建/帧 | ~1,000 | ~100 | 10× |
| TLAS 实例数 | ~20,000 | ~2,000 | 10× |
- CPU 负载大幅降低
- GPU 重建时间可接受
- 偶有 弹出(Popping) 和 物体缺失 ,但考虑到收益,必须接受
BLAS 进一步优化
交错式更新(Staggered Full / Incremental Updates)
| 更新类型 | 描述 |
|---|---|
| 完整重建(Full Rebuild) | 从零开始构建 BVH,质量最高,开销最大 |
| 增量更新(Incremental / Refit) | 仅更新现有 BVH 节点的包围盒,不重建拓扑;质量略低,速度快得多 |
- 策略:交替执行 完整重建和增量更新
- 例如:第 1 帧完整重建,第 2~N 帧增量更新,第 N+1 帧再完整重建……
- 增量更新会导致 BVH 质量 逐步退化 ,但由于每帧都做,退化程度有限
构建偏好:速度优先
- 告诉驱动程序:以最快速度构建,牺牲光追质量
- 对应 DXR 的
D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_PREFER_FAST_BUILD
- 对应 DXR 的
- BVH 质量稍差 → 追踪时多遍历一些节点 → 但构建本身快很多
异步计算(Async Compute)
- 将加速结构的构建放在 异步计算队列 上,与图形渲染 并行执行
- 额外节省 ~0.8 ms
材质数据获取与命中着色器
核心要求:光栅化 vs 光追结果必须完全一致
- 对于同一个三角形上的同一个采样点:
- 光栅化像素着色器 产生的材质输出
- 光追命中着色器 产生的材质输出
- 必须完全相同(Exact Match)
- 否则反射中的表面会与直接可见的表面产生 不一致 ,造成视觉割裂
DXR 着色器类型
| 着色器类型 | 触发条件 |
|---|---|
| Closest Hit Shader | 在离光线原点 最近的命中表面 上执行 |
| Any Hit Shader | 在光线路径上的 每个潜在命中点 上执行(用于半透明、Alpha Test 等) |
Frostbite 的着色器系统
- 美术在 Shader Graph(着色器图) 中工作:
- 输入:纹理、常量参数等
- 输出节点定义:法线、粗糙度、基础色等材质属性
- 这些图在 离线管线阶段 被编译为 HLSL 着色器代码
- 项目中有 数千个 这样的着色器图,且 持续更新
- 手动转换为光追着色器是不可能的 ,必须自动化
命中着色器模板(Hit Shader Template)
// 伪代码:基本命中着色器流程
[shader("closesthit")]
void ClosestHitMain(inout Payload payload, BuiltInTriangleIntersectionAttributes attr)
{
// 1. 解包顶点数据:从顶点缓冲 + 索引缓冲中获取三角形三个顶点
Vertex v0, v1, v2 = UnpackVertexData(PrimitiveIndex());
// 2. 对每个顶点运行顶点着色器(变换到世界空间等)
// 注意:顶点着色器由美术任意定义,可能包含任何逻辑
VSOutput vs0 = RunVertexShader(v0);
VSOutput vs1 = RunVertexShader(v1);
VSOutput vs2 = RunVertexShader(v2);
// 3. 使用重心坐标插值顶点着色器输出
VSOutput interpolated = Interpolate(vs0, vs1, vs2, attr.barycentrics);
// 4. 将插值结果传入 Shader Graph 生成的像素着色器逻辑
MaterialOutput material = RunShaderGraph(interpolated);
// 5. 将材质数据写入 Payload 返回
payload.normal = material.normal;
payload.roughness = material.roughness;
payload.albedo = material.albedo;
// ... 其他属性
}命中着色器的实际问题
屏幕空间梯度不可用
问题
- 传统像素着色器中的
ddx()/ddy()等 屏幕空间梯度指令 依赖于 相邻像素 的信息 - 光追命中着色器中 没有"像素"的概念 ,更没有"相邻像素"
- 因此这些指令 无法直接翻译 到光追着色器
影响
- 梯度主要用于 纹理 MIP 级别选择(Texture LOD)
- 没有梯度 → 不知道该用哪个 MIP 级别
- 如果默认用最高分辨率(MIP 0)→ 纹理缓存暴涨,性能崩溃
- 如果默认用低分辨率 → 反射画面 过度模糊
常见替代方案
| 方案 | 描述 |
|---|---|
| 光线微分(Ray Differentials) | 追踪辅助光线来近似梯度,开销较高 |
| 光线锥(Ray Cones) | 用锥体近似光线的覆盖区域,计算纹理 LOD |
| 固定 MIP 偏移 | 简单粗暴地对所有纹理采样加一个固定 MIP 偏移,质量差但实现简单 |
演讲者接下来可能会详细讨论他们选择了哪种方案以及遇到的其他兼容性问题。
命中着色器实践问题、着色器编译与粒子光追渲染
纹理 Mip Level 问题
问题本质
- 传统光栅化中,GPU 利用 屏幕空间梯度(Screen-Space Gradients) 自动计算纹理的 Mip Level
- 光线追踪中 没有屏幕空间梯度 ——每条光线独立,不存在相邻像素的概念
- 正确计算 Mip Level 需要额外的工作(如光线微分 / Ray Differentials),有人专门研究过,但团队没有时间
团队的解决方案
永远采样 Mip Level 0(最高分辨率)
| 优点 | 缺点 |
|---|---|
| 实现零成本 | 引入额外噪声(高频细节过多) |
| 无需任何额外计算 | 缓存效率低下(Mip 0 访问模式极不连续) |
| 结果"还行" | 带宽浪费 |
实用主义的典型体现——时间不够,先出结果。
Alpha Test 与 Any Hit Shader
问题:忽略 Alpha Test 的灾难
- 像素着色器中有一条关键指令:
clip/discard——当 Alpha 值低于阈值时终止该像素的着色 - 典型场景:树木和植被,叶片是一张带 Alpha 通道的四边形
- 如果在 Closest Hit Shader 中 忽略 Alpha Test:
- 光线击中树叶的透明部分 → 返回 黑色 而非穿透继续追踪
- 视觉效果极差——树木反射变成黑色方块
解决方案:使用 Any Hit Shader
Any Hit Shader 逻辑:
对于光线路径上的每个潜在交点:
1. 采样该三角形在交点处的【不透明度(Opacity)】
2. 如果不透明度 < 阈值:
→ 调用 IgnoreHit(),光线继续穿透
3. 如果不透明度 ≥ 阈值:
→ 接受命中,交给 Closest Hit Shader 处理
性能问题:Any Hit Shader 极其昂贵
- 树木等植被有 大量重叠三角面
- 每个潜在交点都会触发 Any Hit Shader → 纹理采样 → 对比阈值
- 在热力图可视化中:树木和植被是整个场景中最亮(最昂贵)的区域
使用策略总结
| 着色器类型 | 使用方式 |
|---|---|
| Closest Hit Shader | 所有物体都生成,始终使用 |
| Any Hit Shader | 仅 Alpha Test 几何体使用,可选,尽量避免 |
Payload 设计与验证
Payload 格式 = G-Buffer 格式
- Closest Hit Shader 返回的数据(Payload)完全对齐 G-Buffer 格式
- 三大好处:
| 好处 | 说明 |
|---|---|
| 光照一致性 | 光追与光栅化共用同一套光照管线,输入格式必须一致 |
| 紧凑高效 | G-Buffer 已经是高度优化的紧凑格式,Payload 尺寸小 |
| 可验证性 | 可以直接对比光栅化输出与光追输出 |
正确性验证方法
验证流程:
1. 正常光栅化场景 → 生成 G-Buffer
2. 从同一摄像机、同一采样位置发射光线
3. 光追 Closest Hit Shader 返回 Payload
4. 对比 G-Buffer 与 Payload 的差异
5. 如果任何像素的差值 ≠ 0 → 存在 Bug
6. 修复 Bug(或者如果不严重就忽略它)
这是一个非常实用的 回归测试框架 ——自动化验证光追着色器是否与光栅化着色器行为一致。
着色器编译的巨大开销
规模
| 指标 | 数值 |
|---|---|
| 每关卡着色器数量 | ~3,000 |
| 单个着色器编译时间 | >100 ms(部分达数百 ms) |
- 这些着色器需要编译为 Collection(类似光追 PSO) 才能使用
- 在单帧内编译 完全不可能
两种策略
| 策略 | 描述 | 选择 |
|---|---|---|
| 运行时流式编译 | 物体加载时编译着色器,可能出现弹出和闪烁 | ❌ |
| 加载画面预编译 | 在关卡加载时编译该关卡所有着色器 | ✅ 采用 |
实际加载时间
| 场景 | 耗时 |
|---|---|
| 首次启动(冷缓存) | ~1.5 分钟 |
| 后续启动(驱动着色器缓存已热) | ~15 秒 |
首次 1.5 分钟的等待对玩家体验有影响,但相比运行时弹出,团队认为这更可接受。
粒子(透明广告牌)的光追渲染
为什么粒子在 BFV 中极其重要
- 二战题材 → 火焰、烟雾、爆炸 无处不在
- 技术上:粒子 = 透明广告牌(Transparent Billboards) = 始终面向摄像机的平面
基本渲染算法
粒子光追流程:
1. 射出光线 → 在【不透明几何体 TLAS】中追踪
→ 获得最大距离 max_t(不透明物体的命中点)
2. 射出同一条光线 → 在【粒子专用 TLAS】中追踪(max_t 限制)
→ 找到第一个相交的粒子平面
3. 在该平面上:
- 计算 Alpha 值
- 计算颜色(Peak Value)
4. 从该平面继续向前追踪 → 找到下一个粒子平面
5. 重复步骤 3-4,逐层【累积 Alpha 和颜色】
(前到后的 Alpha 混合)
6. 最终与不透明物体的颜色进行 Alpha 合成
为什么需要单独的加速结构
- 粒子 每帧都在变化 (位置、大小、数量)
- 与不透明几何体混在一起会极大增加 BLAS 重建开销
- 单独的 TLAS 允许粒子 独立更新 ,不影响主场景的加速结构
阶段性总结
到目前为止,整个光追反射管线的完整架构已清晰:
┌────────────────────────────────────────────────────────┐
│ 光追反射完整管线 │
├──────────────┬─────────────────────────────────────────┤
│ 追踪前优化 │ 分箱 → SSR 混合 → 剔除 │
│ 加速结构 │ BLAS 构建/更新 → TLAS 组装 → 角度剔除 │
│ 追踪 │ DXR TraceRay (硬件光追) │
│ 着色 │ Closest Hit (所有) + Any Hit (Alpha Test) │
│ 粒子 │ 单独 TLAS + 逐层 Alpha 累积 │
│ 光照 │ Per-Cell Light Lists + 延迟光照 │
│ 降噪 │ BRDF 空间 → 时域 → 高斯后处理 │
└──────────────┴─────────────────────────────────────────┘
粒子光追渲染、性能优化与 Q&A 总结
粒子的朝向问题(Billboard Orientation)
问题
- 粒子是 面向摄像机的公告板(Camera-Aligned Billboards)
- 当光线从 非摄像机方向(如反射方向)击中粒子时,公告板的朝向是"错的"
- 从镜面/反射角度看,粒子会 明显呈现扁平的面片形状 ,破坏体积感
尝试过的方案
| 方案 | 描述 | 结果 |
|---|---|---|
| 面向光线旋转 | 让每个粒子始终正对入射光线方向(90° 垂直) | ❌ 需要 Intersection Shader(昂贵),代码复杂,效果不理想 |
| 交替旋转 90° | 每隔一个粒子绕 Y 轴旋转 90° | ✅ 采用 |
最终方案:交替旋转
对于每个粒子:
if (粒子索引 % 2 == 1):
绕 Y 轴旋转 90°
else:
保持原始朝向(面向摄像机)
- 仅 5 行代码修改
- 效果:反射中的粒子从扁平面片变得 更具体积感
- 零额外性能开销
- 典型的"性价比极高"的工程方案
粒子追踪的性能优化
问题:循环追踪的开销
- 之前的方案:沿光线循环查找所有半透明交点
- 如果一条光线穿过 N 个粒子 → 实际上需要追踪 N 次光线
- 烟雾/火焰场景中开销约 ~1 ms,不可接受
优化方案:Any Hit Shader + 加权混合 OIT
基本思路
- 利用 Any Hit Shader 替代显式循环
- 光线穿过的 每个粒子 都会触发一次 Any Hit Shader
- 在 Any Hit Shader 中直接 累积颜色和透明度
关键问题:执行顺序不确定
- Any Hit Shader 的执行顺序是 未定义的(Undefined Order)
- 无法保证从前到后或从后到前的正确混合顺序
解决方案:加权混合顺序无关透明(Weighted Blended OIT)
- 基于 McGuire & Bavoil 的论文
- 根据以下因素计算权重:
- Alpha 值
- 亮度(Luminance)
- RGB 颜色
- 设计意图:增强火焰/爆炸等高亮粒子的权重,避免被烟雾遮盖
重要陷阱
// 必须使用此标志!
D3D12_RAYTRACING_PIPELINE_FLAG_SKIP_PROCEDURAL_PRIMITIVES
// 或者更关键的是:
// Any Hit Shader 可能被执行【不止一次】
// 这是驱动层的优化行为,非常不直觉
// 你的 Any Hit Shader 必须能正确处理重复执行效果对比
| 方案 | 性能 | 视觉质量 |
|---|---|---|
| 循环追踪 | ~1 ms | 正确顺序混合 |
| Any Hit + OIT | 大幅提升 | 几乎相同,仅近距离仔细观察可见火焰/烟雾权重轻微偏差 |
Q&A 环节精华
Q1:Any Hit Shader 处理粒子时是否限制命中数量?
A:不限制。接受所有命中(All of them)。
Q2:发布后的性能翻倍补丁包含了哪些优化?
| 优化项 | 首发时 | 补丁后 |
|---|---|---|
| 可变光线追踪(Variable Rate RT) | ❌ | ✅ 补丁新增 |
| 屏幕空间混合追踪(SSR Hybrid) | ❌ | ✅ 补丁新增 |
| 树木 LOD 偏移 | 正常 LOD | ✅ 强制使用更低 LOD,降低 Alpha Test 开销 |
| SSR 后光线紧凑化(Compaction) | ❌ 遗漏! | ❌ 仍未修复! |
尚未修复的"愚蠢遗漏"
屏幕空间追踪消除了 ~40% 的光线
但这些被消除的光线【没有从追踪队列中移除】
→ 实际硬件追踪仅 ~60% 占用率
→ 本地测试修复后可再节省 ~1 ms
→ 但截至演讲时仍未发布此修复
一个典型的工程教训——优化链中的一个环节被遗漏,导致前置优化的收益未被充分传递。
Q3:为什么不用 NVIDIA 的机器学习降噪器?
演讲者开始回答但转录在此处中断。常见的业界原因包括:
| 原因 | 说明 |
|---|---|
| 平台中立性 | 游戏需支持多厂商 GPU(AMD/NVIDIA/Intel),不能依赖厂商专属方案 |
| 控制力 | 自研降噪器可以针对特定管线深度定制(如利用 BRDF 滤波器的副产品做时域拒绝) |
| 延迟与集成 | ML 降噪器可能有额外延迟或与现有管线不兼容 |
| 质量调优 | 自研方案可以针对游戏特有的场景特征精细调优 |
Q4:是否考虑过对高 Overdraw 物体(如树木)用"不透明方式"追踪?
- 提问者建议:不做 Alpha Test,而是把树木当不透明物体,利用光线自然被遮挡来实现"完美遮挡剔除"
- 同理对于密集粒子:光线被密集粒子挡住后自然停止,无需处理后方粒子
- 这是一个有趣的思路,但转录在此处中断,未记录完整回答
完整管线总览回顾
┌─────────────────────────────────────────────────────────┐
│ 完整光追反射管线 │
├─────────────┬────────────┬──────────────────────────────┤
│ 光线生成 │ 0.15 ms │ 分箱 (Binning) │
│ 屏幕空间追踪 │ 0.36 ms │ 消除 ~40% 光线 │
│ 硬件光追 │ ~2.00 ms │ DXR TraceRay │
│ 碎片整理 │ 0.08 ms │ Stream Compaction │
│ 光照计算 │ 0.46 ms │ Per-Cell Light Lists │
│ 空间降噪 │ ~1.50 ms │ BRDF Spatial Filter │
│ 时域降噪 │ ~2.40 ms │ Temporal + AABB Rejection │
│ 高斯后处理 │ ~1.00 ms │ 各向异性高斯模糊 │
│ 粒子追踪 │ ~0.30 ms* │ Any Hit + OIT (优化后) │
├─────────────┼────────────┼──────────────────────────────┤
│ 总计 │ ~6.3+ ms │ 原始仅求交需 18 ms │
└─────────────┴────────────┴──────────────────────────────┘
从 18 ms 降至 ~6.3 ms,包含完整的求交、光照、降噪和粒子渲染,这是一个了不起的工程成就。核心哲学:在每个环节做"足够好"的近似,而非追求完美。
最终 Q&A:机器学习降噪、树木追踪难题与 Alpha Test vs 半透明
Q3:为什么没有使用机器学习降噪器(ML Denoiser)?
回答
| 因素 | 说明 |
|---|---|
| 时间线 | 项目始于 2017 年 11 月,当时 ML 降噪器 尚不成熟 |
| 硬件条件 | 具备 Tensor Core 的 GPU(Turing 架构)尚未发布 |
| NVIDIA 降噪器迭代 | 虽然 NVIDIA 后来多次改进了其降噪器,但团队 启动时不可用 |
典型的工程时间线约束——技术选型取决于 开始开发时 的可用技术,而非 发布时 的最新技术。
Q4:树木用光追是否比光栅化更高效(因为光栅化有大量 Overdraw)?
回答:恰恰相反,树木是光追中最大的性能灾难
光栅化中:树木因 Overdraw 而慢
光追中: 树木因 Alpha Test 而【更慢】
→ 树木是光追中【最慢的物体,没有之一】
→ 比光栅化中更差,而非更好
为什么树木对光追如此致命
| 方案 | 问题 |
|---|---|
| 几何方案(真实叶片建模) | 三角形数量爆炸,BVH 节点极多,遍历开销巨大 |
| Alpha Test 方案(面片+透明纹理) | 每次 Any Hit Shader 触发都要采样纹理、判断 Alpha → 反复执行 |
- 两种方案都很糟糕("both are kind of crap")
- 团队询问过 很多非常聪明的人 ,没有人给出好的答案
演讲者的半开玩笑建议: "如果你想做光追,就做没有树的游戏。"
Q5:粒子(半透明)是否也有同样的问题?
回答:粒子反而 没有 树木那么糟糕
关键区别:Alpha Test vs 真正的半透明
| 类型 | 机制 | 光追中的表现 |
|---|---|---|
| Alpha Test(树木) | 必须在每个交点 判断是否命中 → 命中则停止,未命中则继续 → Any Hit Shader 反复执行后仍需找到最终命中点 | 极差 |
| 半透明(粒子) | 所有交点都 直接接受 → Any Hit Shader 中累积颜色/Alpha → 无需反复进出追踪循环 | 可接受 |
核心差异解释
Alpha Test 的痛点:
光线击中面片 → 执行 Any Hit → 采样纹理 → Alpha < 阈值
→ IgnoreHit() → 继续遍历 BVH → 击中下一个面片
→ 再次执行 Any Hit → 再次采样 → 可能再次 IgnoreHit()
→ 反复循环,直到找到真正不透明的命中点
本质:每次 IgnoreHit() 都是"白干活",但仍然消耗了纹理采样和着色器执行
半透明的优势:
光线击中粒子 → 执行 Any Hit → 累积颜色/Alpha → 继续
→ 击中下一个粒子 → 累积 → 继续
→ 所有工作都是"有效工作",没有浪费
→ 且可以使用 Weighted Blended OIT 无需排序
Alpha Test 比真正的半透明对光追更有害 ——这是一个反直觉但重要的结论。
总结:整场演讲的核心收获
| 主题 | 关键信息 |
|---|---|
| 光线生成 | 重要性采样 BRDF + 蓝噪声 + 时域抖动 → 最大化每条光线的信息量 |
| 光线分箱 | 按方向分组 → 提高硬件追踪的缓存命中率 |
| 混合追踪 | 屏幕空间优先 → 消除 ~40% 光线 → 大幅降低硬件追踪负载 |
| 降噪 | BRDF 空间滤波 → 时域滤波(利用空间滤波的邻域分布做拒绝)→ 高斯后处理 |
| 加速结构 | 角度阈值剔除 + 交替重建/增量更新 + 异步计算 |
| 材质着色 | G-Buffer 格式的 Payload + 自动代码生成 + 预编译 |
| 粒子 | 交替旋转增加体积感 + Any Hit + Weighted Blended OIT |
| 树木 | 光追中最大的未解难题 → "做没有树的游戏" |
| 工程哲学 | 务实、接受不完美、先出结果再迭代 |
感谢演讲者 Johannes 和 Tomasz 的精彩分享。这场演讲是工业界光追实践中最坦诚、最接地气的案例分析之一。