A rotating supermassive black hole, ray traced through curved spacetime in real time, in a single self contained HTML file. No build step, no dependencies, no asset downloads. Open the file and it runs.
Nothing here is painted in. The halo above the shadow, the band beneath it, the thin bright rings hugging the darkness and the lopsided brightness of the disk are all consequences of tracing light backwards through the metric. Give the renderer a different spin or a different camera and it produces whatever those parameters imply.
Download gargantua.html and open it in a desktop browser. That is the whole
procedure. It needs WebGL2 and a reasonably current Chrome, Edge, Firefox or
Safari.
| Input | Action |
|---|---|
| Drag | Orbit the camera |
| Scroll | Move between 3.9 M and 360 M |
| Leave it alone | Slow automatic drift resumes after a few seconds |
The same simulation, same parameters, different viewpoints.
Pulled far back, the lensing of the background sky becomes the subject: light from stars behind the hole is bent around it into a ring.
Geometric units, M = 1, spin a/M = 0.9. Kerr geometry in Kerr-Schild
Cartesian coordinates, which stay regular across the horizon instead of blowing
up at it. That puts the horizon at r = 1.44 M and the innermost stable
circular orbit at r = 2.32 M; the disk spans from there out to 30 M.
Light transport. Each pixel fires a ray and integrates the null geodesic equations backwards in time to find where that light came from. The integrator switches order by where it is: fourth order Runge-Kutta close in where curvature is strong and the path bends hard, second order further out where it is nearly straight and the extra evaluations would be wasted. Rays are terminated on the horizon or at an escape radius.
The disk. An optically thick, geometrically thin flow with a
Novikov-Thorne emissivity profile, so temperature falls off with radius the way
a real accreting disk does, and colour comes from a blackbody lookup rather than
an artistic gradient. At each sample the local circular orbit four velocity is
constructed and the full frequency shift is computed from it, combining
gravitational redshift with special relativistic Doppler shift. Beaming goes as
g³·²⁵, which is what makes one side of the disk far brighter than the other.
Emission and extinction are integrated with an analytic constant source slab per
step, which removes the step size bias that otherwise brightens or darkens the
optically thick regions depending on how finely you march.
Structure. The turbulence is a stack of log-polar noise octaves, domain warped by the coarsest layer, combined into a ridged filamentary web and a granular emission field, with logarithmic spiral density waves and a few orbiting hot knots layered on. The disk's midplane is warped and precessing, so it is not a perfect plane.
Surroundings. A procedural starfield with physically motivated colour temperatures and a scattering of distant galaxies, all lensed by the same geodesics as everything else, which is why the background visibly streams and arcs near the shadow. A hot, optically thin inner flow hugs the ISCO; because near critical photons wind around the hole several times before escaping, this is what builds the thin nested rings pressed against the shadow's edge.
geodesic + volume trace → temporal accumulation → sharpen → bloom + grade
(at a fraction of (reprojected, (at trace (AgX, auto
display resolution) neighbourhood clamped) resolution) exposure)
Tracing is the expensive part, so it runs below display resolution and the result is reconstructed. Frames are jittered on a Halton sequence and accumulated into a history buffer that is reprojected through the camera's rotation each frame, with a neighbourhood clamp to throw away history the current frame contradicts. Detail recovery happens at trace resolution, where it is cheap, rather than at display resolution, where a wide reconstruction filter measured about 45 ms per frame on integrated graphics and was not worth it.
Grading is a photographic chain: a downsampled average drives exposure adaptation toward a target luminance, bloom is built with a Karis average downsample and tent upsample to keep fireflies out, then AgX tonemapping and a mild vignette.
There is no fixed quality setting. A controller watches frame pacing and walks through eight tiers of internal resolution, geodesic step count and volume step count, remembering any tier that failed as a ceiling so it settles quickly instead of oscillating. It also drops two tiers while you are dragging, so the camera stays responsive, and restores shortly after you let go. GPU timer queries are deliberately not used; several integrated drivers report them inaccurately.
The practical consequence: on integrated graphics it settles at a low tier and the image is softer, on a discrete GPU it climbs and sharpens. Shrinking the window is the fastest way to trade area for detail.
perf.js drives headless Chrome and reports frame pacing, the settled tier and
the internal resolution, capturing two frames seconds apart to confirm the disk
is actually rotating:
node perf.js gargantua.html 1920 1080 14
shots.js renders the gallery above. It pins the top tier and lets the
accumulation converge, because frame rate is irrelevant for a still:
node shots.js gargantua.html screenshots
Time is a time lapse, and that is a choice. A hole of this mass has orbital periods measured in days. Any visible motion is already an enormous speed up, so the rate is a presentation decision, not a physical one. It is set so the pattern sweeps around in roughly 37 seconds while the innermost flow takes about 7.
Differential rotation destroys turbulence if you let it. The inner disk orbits far faster than the outer disk, so any pattern advected with the flow is sheared, and the shear grows without bound. Left alone the turbulence winds up into smooth concentric rings, like grooves on a record, and the flow stops looking like a flow. This is invisible for the first few minutes and then unmistakable.
The fix splits rotation in two. A rigid component carries the whole pattern around and has no radial gradient, so it never winds up. The radius dependent remainder, the part that actually shears, is rebuilt in overlapping generations on roughly an orbital timescale, with a short crossfade between them, which is also how real turbulence behaves: it is continuously regenerated rather than sheared forever. Only the layers that genuinely shear are regenerated, so the second noise lookup is confined to the crossfade window. The flow now stays turbulent indefinitely, and the time rate can be raised freely because it no longer trades against the disk degenerating.
dist/ is generated and untracked, so recreate it before publishing:
mkdir dist
cp gargantua.html dist/index.html
netlify deploy --dir=dist --prod
Publishing only dist/ keeps the rest of the working directory off the public
site.





