这是光线追踪三篇的最后一篇,讲怎么让它跑得动。
发散是主要敌人
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。
什么时候值得用
一个务实的判断顺序:
- 阴影——光追阴影在质量和实现复杂度上都优于 shadow map 级联系统,通常是第一个值得替换的。
- 反射——SSR 的失效情形(屏幕外、被遮挡)很明显,光追反射能直接解决,且可以只对光滑表面启用。
- 环境光遮蔽——光追 AO 比 SSAO 正确得多,但 SSAO 便宜很多,收益取决于场景。
- 全局光照——收益最大,成本也最高。需要完整的去噪管线和 ReSTIR 一类的算法才能在实时预算内做出可用的结果。
至此 Metal 系列的主线部分就结束了。两篇附录 #21 计算着色器 和 #22 SwiftUI 中的 Metal 讲的是这条主线之外但同样常用的内容。