T.TAO
返回博客
/9 min read/Graphics Engine

Metal #19 光线追踪(三)性能优化

#ComputerGraphics#GraphicsEngine#Metal

这是光线追踪三篇的最后一篇,讲怎么让它跑得动。

发散是主要敌人

GPU 以 SIMD 组(Apple GPU 上是 32 条线程的 simdgroup)为单位执行。组内所有线程共用一个程序计数器:遇到分支时,两条路径串行执行,不走那条路径的线程被屏蔽掉。

光线追踪天然会发散,而且在两个层面上:

执行发散。 同一个 simdgroup 里的射线可能撞到不同材质,走进完全不同的着色分支。最坏情况下,32 条线程执行 32 段互不相同的代码,有效利用率降到 1/32。

内存发散。 相邻像素的射线在几次反弹后就朝向完全不同的方向,访问 BVH 中相距很远的节点。cache 命中率崩塌,延迟无法被隐藏。

这两点决定了光追的优化思路和光栅化完全不同:重点不是减少指令数,而是让一组线程做相似的事。

波前路径追踪

传统的"一个 kernel 跑完整条路径"(megakernel)有两个问题:寄存器占用取所有分支的最大值,导致占用率(occupancy)很低;而且随着反弹次数增加,活跃线程越来越少(有些路径提前终止),SIMD 利用率持续下降。

波前(wavefront)方法把路径追踪拆成多个小 kernel,用队列连接:

Plain Text生成射线 ─> [射线队列] ─> 求交 ─> [命中队列] ─> 按材质分类 ─> 着色 kernel A
                                                            ─> 着色 kernel B
                                                            ─> ...
                              └─> 未命中 ─> 环境光 kernel

好处是:

  • 每个 kernel 的寄存器需求小得多,占用率高。
  • 队列可以压实(compaction):终止的路径被移除,剩下的路径重新打包,SIMD 利用率恢复到接近 100%。
  • 相同材质的命中可以被分到同一个 kernel,着色时的执行发散基本消失。

代价是所有中间状态必须写进显存(射线、throughput、随机数种子),带宽消耗大。所以波前在路径长、材质复杂的场景里收益明显,在只有一两次反弹的混合渲染里往往不如 megakernel。

Metal 的 intersector 支持 intersection_query,可以在 kernel 内部手动控制遍历,这为实现波前提供了必要的灵活性。

加速结构的取舍

构建 BVH 时有一组重要的标志:

Swiftdescriptor.usage = [.preferFastIntersection]   // 或 .preferFastBuild, .refit
  • .preferFastIntersection — 用 SAH(表面积启发式)构建高质量的树,遍历快,构建慢。静态几何应该用这个。
  • .preferFastBuild — 用 LBVH 之类的快速构建法,树质量差一些,遍历慢 10-30%。适合每帧都要重建的动态几何。
  • .refit — 允许后续用 refit 更新。

关于实例数量:TLAS 的遍历成本随实例数增长。一万个各自独立的小物体,不如合并成较少的几个 BLAS。反过来,一个巨大的 BLAS 包含整个场景也不好——任何一点变化都要重建整个结构。经验上,按空间局部性把场景分成几十到几百个 BLAS 是比较健康的。

剔除也适用于光追。 距离摄像机很远的小物体可以从 TLAS 里直接去掉,或者换成低模 BLAS。光追没有视锥剔除(反射可以看到视锥外的东西),但基于距离和大小的剔除仍然有效。

采样序列

蒙特卡洛的收敛速度不只取决于采样数,还取决于采样点的分布质量

纯白噪声的收敛是 O(1/√N)低差异序列(Sobol、Halton)分布更均匀,在低维情况下能接近 O(1/N)。对于 1 spp 的实时渲染,这个差别非常显著。

但更重要的是噪声在屏幕上的分布。同样是 1 spp,蓝噪声分布的误差在视觉上比白噪声好得多——人眼对低频噪声(大块的斑驳)远比高频噪声敏感,而且蓝噪声更容易被后续的空间滤波和 TAA 消除。

实践中最有效的组合是:每像素一个蓝噪声偏移 + 一个低差异序列

MSLfloat2 sample2D(uint2 pixel, uint frameIndex, uint dimension) {
    float2 sobol = sobolSequence(frameIndex, dimension);
    float2 offset = blueNoiseTexture.read(pixel % 128).rg;   // 每像素固定的偏移
    return fract(sobol + offset);                            // Cranley-Patterson 旋转
}

每个像素用同一个 Sobol 序列但加上不同的偏移,这样单像素上是低差异的(收敛快),像素之间是蓝噪声的(视觉上好、易去噪)。这个技巧成本几乎为零,效果却非常明显。

定位瓶颈

Xcode 的 GPU 帧捕获对光追有专门的支持,值得认真用:

Shader Profiler 按行显示每条指令的耗时占比。在光追 kernel 上,看它是卡在遍历(BVH 内存访问)还是着色(ALU),这决定了优化方向完全不同。

Acceleration Structure Viewer 可以可视化 BVH 的层级结构和每个节点的包围盒。树的质量问题(过度重叠的包围盒、深度不均衡)在这里一眼可见。

Occupancy 告诉你有多少线程真正在运行。光追 kernel 的占用率往往受寄存器限制——一个用了太多寄存器的 kernel 可能只能同时跑理论上限一半的线程。减少活跃变量、把大结构体拆开、用 half 代替 float,都能改善这一点。

一个常见的发现:很多人以为光追慢在求交上,profile 之后发现实际瓶颈是着色阶段的纹理采样——因为每条射线撞到的表面各不相同,纹理访问完全随机,cache 全部失效。这种情况下的正确优化是降低纹理分辨率、用更激进的 mip bias,而不是优化 BVH。

什么时候值得用

一个务实的判断顺序:

  1. 阴影——光追阴影在质量和实现复杂度上都优于 shadow map 级联系统,通常是第一个值得替换的。
  2. 反射——SSR 的失效情形(屏幕外、被遮挡)很明显,光追反射能直接解决,且可以只对光滑表面启用。
  3. 环境光遮蔽——光追 AO 比 SSAO 正确得多,但 SSAO 便宜很多,收益取决于场景。
  4. 全局光照——收益最大,成本也最高。需要完整的去噪管线和 ReSTIR 一类的算法才能在实时预算内做出可用的结果。

至此 Metal 系列的主线部分就结束了。两篇附录 #21 计算着色器#22 SwiftUI 中的 Metal 讲的是这条主线之外但同样常用的内容。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal