This is the last of the three ray tracing notes, on making it fast enough to use.
Divergence is the enemy
A GPU executes in SIMD groups β 32 threads per simdgroup on Apple GPUs. Every thread in a group shares one program counter, so at a branch both paths execute serially and the threads not taking a path are masked off.
Ray tracing diverges naturally, and on two levels:
Execution divergence. Rays in one simdgroup may hit different materials and enter entirely different shading branches. In the worst case, 32 threads run 32 different pieces of code and effective utilization drops to 1/32.
Memory divergence. After a couple of bounces, rays from adjacent pixels point in completely different directions and touch BVH nodes far apart in memory. Cache hit rates collapse and latency stops being hidden.
Together these mean that optimizing ray tracing is nothing like optimizing rasterization: the goal is not fewer instructions, it is making a group of threads do similar things.
Wavefront path tracing
The traditional "one kernel runs the whole path" β a megakernel β has two problems: register usage is the maximum over every branch, which crushes occupancy; and as bounces accumulate, fewer threads are still active (paths terminate at different depths), so SIMD utilization falls steadily.
The wavefront approach splits path tracing into small kernels connected by queues:
Plain Textgenerate rays β> [ray queue] β> intersect β> [hit queue] β> sort by material β> shade kernel A
β> shade kernel B
β> ...
ββ> misses β> environment kernel
The benefits:
- Each kernel needs far fewer registers, so occupancy is high.
- Queues can be compacted: terminated paths are removed and the survivors repacked, restoring SIMD utilization to near 100%.
- Hits with the same material can be routed to the same kernel, removing execution divergence during shading almost entirely.
The cost is that all intermediate state must be written to device memory β rays, throughput, RNG seeds β which is a lot of bandwidth. Wavefront therefore pays off for long paths and complex materials, and often loses to a megakernel in hybrid rendering with only one or two bounces.
Metal's intersector supports intersection_query, which lets a kernel drive traversal manually, and that is the flexibility a wavefront implementation needs.
Acceleration structure trade-offs
Building a BVH takes an important set of flags:
Swiftdescriptor.usage = [.preferFastIntersection] // or .preferFastBuild, .refit
.preferFastIntersectionβ build a high-quality tree with SAH (the surface area heuristic). Fast traversal, slow build. Use this for static geometry..preferFastBuildβ a fast builder such as LBVH. Lower tree quality, 10-30% slower traversal. Suits geometry rebuilt every frame..refitβ permits later refits.
On instance counts: TLAS traversal cost grows with the number of instances. Ten thousand separate small objects are better merged into a few BLASes. Conversely, one enormous BLAS holding the whole scene is also bad β any change forces a rebuild of the whole thing. Empirically, splitting a scene into tens to hundreds of BLASes by spatial locality is healthy.
Culling applies to ray tracing too. Small objects far from the camera can be removed from the TLAS altogether, or swapped for a low-poly BLAS. There is no frustum culling (a reflection can see outside the frustum), but distance- and size-based culling still works.
Sampling sequences
Monte Carlo convergence depends not only on sample count but on the quality of the sample distribution.
Pure white noise converges at O(1/βN). Low-discrepancy sequences (Sobol, Halton) are more evenly distributed and approach O(1/N) in low dimensions. At 1 spp in real time, that difference is dramatic.
More important still is how the noise is distributed across the screen. At the same 1 spp, error distributed as blue noise looks considerably better than white noise β the eye is far more sensitive to low-frequency noise (large mottled patches) than to high-frequency noise, and blue noise is also much easier for a subsequent spatial filter or TAA to remove.
The most effective combination in practice is a per-pixel blue noise offset plus a low-discrepancy sequence:
MSLfloat2 sample2D(uint2 pixel, uint frameIndex, uint dimension) {
float2 sobol = sobolSequence(frameIndex, dimension);
float2 offset = blueNoiseTexture.read(pixel % 128).rg; // fixed offset per pixel
return fract(sobol + offset); // Cranley-Patterson rotation
}
Every pixel uses the same Sobol sequence with a different offset, so each pixel individually is low-discrepancy (fast convergence) while the error across pixels is blue noise (visually better, easier to denoise). The trick costs essentially nothing and its effect is very visible.
Finding the bottleneck
Xcode's GPU frame capture has dedicated ray tracing support and deserves real use:
The Shader Profiler shows per-line cost. On a ray tracing kernel, whether the time is in traversal (BVH memory access) or shading (ALU) points optimization in completely different directions.
The Acceleration Structure Viewer visualizes the BVH hierarchy and each node's bounding box. Tree quality problems β heavily overlapping boxes, unbalanced depth β are obvious on sight here.
Occupancy tells you how many threads are actually resident. Ray tracing kernels are usually register-limited: a kernel using too many registers may only run half the threads the hardware could otherwise hold. Reducing live variables, splitting large structs, and using half instead of float all help.
A common discovery: people assume ray tracing is slow at intersection, and a profile shows the real bottleneck is texture sampling during shading β because every ray hits a different surface, texture access is effectively random and the cache is useless. The correct response there is lower texture resolution and a more aggressive mip bias, not BVH tuning.
When it is worth it
A pragmatic order of adoption:
- Shadows β traced shadows beat a cascaded shadow map system on both quality and implementation complexity, and are usually the first thing worth replacing.
- Reflections β SSR's failures (off-screen, occluded) are conspicuous, traced reflections fix them directly, and you can enable them only for smooth surfaces.
- Ambient occlusion β traced AO is far more correct than SSAO, but SSAO is much cheaper, so the value depends on the scene.
- Global illumination β the largest payoff and the largest cost. It needs a full denoising pipeline and something like ReSTIR to produce a usable result inside a real-time budget.
That concludes the main line of the Metal series. The two appendices, #21 Compute Shaders and #22 Metal in SwiftUI, cover material outside this line that is just as frequently needed.