Ray-Tracing Hardware

Ray tracing is the physically honest way to make pictures: follow light, ray by ray, bouncing where it bounces. It is also, from a GPU architect's point of view, a workload designed by an enemy. Everything this course has taught you about what GPUs love — coherent memory streams, warps marching in lock-step, regular tiles of predictable work — ray tracing violates on purpose. Two rays that start as pixel-neighbours bounce off different surfaces and, within one hop, are reading unrelated corners of memory, walking different depths of a pointer-linked tree, and taking different branches. Incoherent access, divergent control flow, pointer chasing: the complete catalogue of GPU poison, and every frame drinks it.

For decades the answer was "so don't do it in real time". The modern answer is more interesting: build fixed-function hardware for exactly the poisonous part, and leave the rest programmable. That hardware is the RT core — and it is the previous module's fixed-versus-programmable split, replayed for light. Just as tensor cores carved the matmul out of the shader lanes, RT cores carve out the ray's search for what it hits.

The data structure that makes rays affordable

Testing a ray against every one of a scene's millions of triangles is hopeless — the trick that makes ray tracing possible at all is the bounding volume hierarchy (BVH). Wrap groups of triangles in boxes; wrap groups of boxes in bigger boxes; repeat until one root box holds the scene. A ray now plays twenty-questions: does it hit the root box? If not, the entire scene is ruled out in one test. If yes, test the two child boxes and descend only into the ones hit, pruning whole subtrees at every level, until a small leaf hands over its handful of triangles for exact intersection tests. The cost falls from linear in the triangle count to roughly logarithmic:

\text{tests} \approx O(\log n) \quad \text{instead of} \quad O(n)

The BVH itself is built (and, for deforming objects, cheaply refitted) by ordinary compute shaders before rendering — and here lives a classic trade: a carefully optimised tree traces faster but costs more to build, so games lean on fast builds and refits while film renderers spend minutes crafting exquisite trees. A refit keeps the tree's shape and only re-inflates the boxes around moved triangles — cheap, but the boxes grow baggy and traversal slows, so engines rebuild periodically.

Watch one ray do it

Here is the whole idea in two dimensions: twelve triangles, a two-level hierarchy, one ray. Step through the traversal and count the work.

Five box tests plus three triangle tests instead of twelve triangle tests — modest at this toy scale, but the gap is exponential in scene size: at a million triangles the BVH path costs a few dozen tests where brute force costs a million. Per ray. At millions of rays per frame, the hierarchy is not an optimisation; it is the difference between possible and absurd.

What the RT core actually hardwires

Look at what traversal consists of: ray-versus-box slab tests, ray-versus-triangle intersection maths, and the bookkeeping of a traversal stack (which subtrees are still pending). It is a tight, fixed algorithm with irregular data — precisely the shape of thing that wastes a SIMT machine (every ray on its own path, lanes idling for each other) and precisely the shape of thing a small dedicated circuit does beautifully. So each SM gains an RT core: a fixed-function unit that, handed a ray, autonomously walks the BVH — fetching nodes, running box tests, managing its own stack, running exact ray-triangle tests at the leaves — and returns the answer: you hit this triangle, at this distance, with these barycentrics.

Meanwhile the SIMT lanes do what they are good at: shading the hit — running the programmable material code, deciding what colour that surface contributes, and possibly spawning new rays. The division of labour is the module's recurring pattern:

Notice how the poison list has been neutralised rather than cured: the pointer chasing and divergent tree-walking still happen — but inside a little machine built for them, off the critical path of the warps, which stay busy shading whatever hits come back.

Count it yourself

This demo builds a tiny two-level BVH over a triangle set and traces rays both ways, counting intersection tests. The geometry is 2-D and the "triangle test" is a bounding-box check — the counting, which is the point, is faithful.

// A 2-D "triangle" is just its bounding box here; tests are what we count. interface Box { x0: number; y0: number; x1: number; y1: number; } function rayHitsBox(ox: number, oy: number, dx: number, dy: number, b: Box): boolean { // Slab test: interval of t where the ray is inside the box on each axis. let t0 = -Infinity, t1 = Infinity; if (dx !== 0) { const a = (b.x0 - ox) / dx, c = (b.x1 - ox) / dx; t0 = Math.max(t0, Math.min(a, c)); t1 = Math.min(t1, Math.max(a, c)); } else if (ox < b.x0 || ox > b.x1) return false; if (dy !== 0) { const a = (b.y0 - oy) / dy, c = (b.y1 - oy) / dy; t0 = Math.max(t0, Math.min(a, c)); t1 = Math.min(t1, Math.max(a, c)); } else if (oy < b.y0 || oy > b.y1) return false; return t1 >= Math.max(t0, 0); } // 12 triangles in 4 spatial clusters of 3. const tris: Box[] = []; const clusters = [[1, 8], [3, 6], [11, 2], [13, 4]]; for (const [cx, cy] of clusters) for (let i = 0; i < 3; i++) tris.push({ x0: cx + i * 0.6, y0: cy, x1: cx + i * 0.6 + 0.5, y1: cy + 0.5 }); const wrap = (list: Box[]): Box => ({ x0: Math.min(...list.map(b => b.x0)), y0: Math.min(...list.map(b => b.y0)), x1: Math.max(...list.map(b => b.x1)), y1: Math.max(...list.map(b => b.y1)), }); // Two-level BVH: root -> 2 children -> 2 leaves each -> 3 triangles. const leaves = [0, 1, 2, 3].map(i => tris.slice(i * 3, i * 3 + 3)); const leafBoxes = leaves.map(wrap); const childBoxes = [wrap(leafBoxes.slice(0, 2)), wrap(leafBoxes.slice(2))]; const root = wrap(childBoxes); function trace(ox: number, oy: number, dx: number, dy: number) { let brute = 0, boxTests = 0, triTests = 0; for (const t of tris) { brute++; rayHitsBox(ox, oy, dx, dy, t); } boxTests++; // root if (rayHitsBox(ox, oy, dx, dy, root)) for (let c = 0; c < 2; c++) { boxTests++; // child if (!rayHitsBox(ox, oy, dx, dy, childBoxes[c])) continue; for (let l = 0; l < 2; l++) { boxTests++; // leaf if (!rayHitsBox(ox, oy, dx, dy, leafBoxes[c * 2 + l])) continue; for (const t of leaves[c * 2 + l]) { triTests++; rayHitsBox(ox, oy, dx, dy, t); } } } console.log(`ray (${ox},${oy})->(${dx},${dy}): brute ${brute} tri tests |` + ` BVH ${boxTests} box + ${triTests} tri = ${boxTests + triTests}`); } trace(0, 8.2, 1, 0); // skims the top cluster trace(0, 2.2, 1, 0.1); // heads for the bottom-right clusters trace(0, 12, 1, 0); // misses everything: ONE box test settles it

The third ray is the quiet superstar: it misses the scene, and the BVH proves it with a single root test where brute force ground through all twelve. Real scenes push the same ratios out to millions.

The hybrid reality, and the next frontier

No shipping game traces every ray for every pixel. The economical recipe is hybrid rendering: rasterise primary visibility the classic way (it is coherent and cheap), then spend rays only where rasterisation lies — shadows, reflections, global illumination — at a frugal budget of one-ish rays per pixel, and let a denoiser (increasingly a neural one, running on the tensor cores next door) hallucinate the smooth image a thousand rays would have given. Denoising is not a garnish; it is the enabler that makes single-sample ray budgets look like film.

And the frontier is exactly where you would guess: coherence. Secondary rays arrive in scrambled order, so the newest hardware and APIs experiment with ray reordering — binning in-flight rays by direction and position, so that rays walking the same part of the BVH run together and the memory system sees streams again instead of noise. It is the oldest GPU lesson, applied to the newest workload: if the work will not arrive coherent, sort it until it is.

A ray-box "slab test" needs no trigonometry and no square roots: for each axis, two subtractions and two multiplies (by a precomputed reciprocal of the ray direction) give the interval of the ray that lies between the box's two planes; intersect the intervals and you are done. Six multiplies, a handful of min/max operations — no divides in the inner loop, no branches worth the name. It pipelines perfectly, which is exactly why it can be etched into fixed-function silicon that runs several tests per clock. The exact ray-triangle test is only modestly dearer. The expensive part of ray tracing was never the geometry maths — it is the memory system serving a million rays that each want a different branch of the tree. That is the wall the RT core's autonomous traversal (and ray reordering) is really built against.

"Add more RT cores" only speeds up the part of the frame that was finding intersections. A scene bound by expensive material shaders sees almost nothing from beefier traversal hardware — the SIMT lanes were the bottleneck all along. A scene bound by incoherent secondary-ray memory traffic is throttled by the cache and DRAM system, and again more box-test throughput barely moves the needle. Before you buy (or design!) silicon, profile which wall you are on: traversal-bound, shading-bound, or bandwidth-bound frames call for three different fixes, and only the first one is "more RT cores". Hardware design is diagnosis before prescription — the accelerator you need is the one aimed at your actual bottleneck.