← All posts
2026-09-06
GRAPHICS
5 min

Ray-tracing a rotating black hole in the browser

Null geodesics, a WebGPU compute shader, and why the first frame is allowed to look terrible.

The black hole is a WebGPU compute shader that integrates light rays through the Kerr metric, one jittered sample per pixel per pass, and blends each pass into a running average. Hold the camera still and the picture sharpens over a few seconds. Move it and everything starts again.

That is why the first frame is allowed to look terrible. Nobody minds a rough image that is visibly getting better. They mind a frozen tab.

The setup

Geometric units, G = c = M = 1, and Kerr-Schild coordinates, because there the metric is flat space plus a single rank-one term, g = η + f k k, and its inverse is exact in closed form. No matrix inversion per step, which matters when every RK4 step evaluates the metric about 28 times.

The metric does not depend on time, so the time component of a ray's momentum is conserved exactly. It is fixed at p_t = -1 and never integrated. What each ray carries is position and spatial momentum, six numbers. Position advances by an exact closed form, and momentum by the negative gradient of the Hamiltonian, taken by central differences. The step size scales with radius: small near the horizon, large out where nothing is happening. A ray ends when it falls inside the horizon (black), escapes past r = 60 (sample the background), or runs out of steps.

f32, and the term you should not difference

The GPU works in 32-bit floats, and the obvious gradient is a trap. The Hamiltonian is 0.5 (-1 + p·p - f S²). For a light ray it is zero: terms of order one that cancel, while the part that actually varies with position is around 1e-5 of them. A central difference of the whole thing in f32 subtracts two nearly equal numbers to recover something smaller than their rounding error.

The way out is to notice that -1 + p·p does not depend on position at all. Its derivative with respect to x is zero, so the gradient of H is the gradient of -0.5 f S² alone. Difference that and the cancellation disappears. Same math, a different picture.

A plausible wrong picture

A sign error in a geodesic equation does not crash. It renders a black hole. Slightly the wrong black hole, lensed a little wrong, and there is no reference photo to hold it against.

So the physics is checked against invariants rather than looks. Along any ray the null condition H = 0 must hold, and the axial angular momentum L = x·p_y − y·p_x must be conserved. A reference implementation in f64 TypeScript runs under Node and asserts both over thousands of steps, along with the characteristic radii and a frame-dragging asymmetry that has to be exactly zero at zero spin. A sweep over the difference epsilon and the step size separates truncation error, which shrinks as you refine, from a real mistake, which does not.

The reference and the WGSL are line-for-line mirrors. A second check runs the shipped shader on the GPU and compares it with f64 values computed live in the same page, so there are no stored golden numbers to go stale. And a test checks that every constant mirrored between the two files still agrees, because that kind of drift fails at shader compilation in someone's browser, or not at all.

Keeping the tab alive

Tracing a whole image in one submission can keep a modest GPU busy for most of a second. During that time the browser cannot composite or handle input, and the page reads as frozen. So a sample is split across several dispatches, one horizontal band per frame, and the band count tunes itself to keep each dispatch near 16 ms. While the camera moves, the trace also drops to 40% resolution, and it snaps back once the camera has been still for about 180 ms.

Accumulation is double-buffered. That looks like a portability hedge and is not: baseline WebGPU only allows read-write storage textures in single-channel 32-bit formats, and rgba16float read-write sits behind an extension. The blend weight is 1/n, so the first pass after a reset overwrites the buffer and no clear is needed. Samples are jittered with the R2 low-discrepancy sequence plus a per-pixel rotation, and accumulation stops at 1024.

The ragged arc

Right against the shadow there is a thin bright arc, and for a while it rendered ragged and stair-stepped. Raising the step budget from 450 to 2000 changed nothing. Turning off the disk's striation texture changed nothing. Scrambling the sample sequence per pixel changed nothing.

What settled it was painting the disk a flat color. The arcs came out perfectly smooth, so the geometry had never been wrong. That arc is light that wound around the hole one or more times on its way to the camera, and every extra winding compresses the disk's radial profile exponentially. By the second or third image the whole profile fits inside one pixel, and point-sampling it is aliasing a signal with no band limit.

The tracer already counts crossings of the disk plane, so it knows each hit's image order. Past the first image, the radial detail fades to its average. It is the trade a mip level makes for a distant texture: the detail cannot be resolved, so don't pretend to resolve it.

One real bug turned up next door. A ray grazing the disk plane could cross it and come back within a single RK4 step, and since the disk test only looks for a sign change between steps, that crossing vanished. Steps that would jump the plane are now shortened, using a derivative the RK4 loop had already computed, so the fix removed a metric evaluation instead of adding one.

What is not physics

The disk is stylized: a temperature falloff, the standard inner-boundary factor, a rough Doppler beaming term. It is not radiative transfer. Color comes from the observed temperature, the emitted temperature times the Doppler factor, and that single term is why the approaching side burns blue-white while the receding side goes crimson. The geodesics are real. The glow is a good-looking approximation, and the README says which is which.

Written by Martin Dahl. If you want to argue about any of it, say hello.