Rendering Gargantua: A Real-Time Black Hole in Your Browser
In Interstellar, Kip Thorne and the Double Negative team solved the equations of general relativity to render Gargantua, and the result changed what audiences expect a black hole to look like: a razor-thin disk of fire, bent over the top and under the bottom of the shadow, one side glowing brighter than the other.
You don't need a render farm for that. A surprisingly faithful Gargantua fits in a single HTML file and runs in real time in a browser tab. This post walks through the whole build: the geodesic integrator that bends the light, the Shakura–Sunyaev disk model that paints it, the relativistic Doppler effect that makes it lopsided, and finally the trick that turned the real-time sim into a seamless 4K 120 fps loop, rendered offline as 960 frames and shipped as the MP4 at the end of this page.
0. What You're Looking At
The demo above is a fullscreen-quad ray tracer. There is no geometry: no mesh, no disk model in three.js. The GPU runs one GLSL program over every pixel, and each pixel casts a ray backwards from the camera into a curved spacetime. Where that ray crosses the equatorial plane, the shader evaluates an accretion-disk model; where it falls into the horizon, it terminates; where it escapes, it samples a procedural starfield.
- Geometry
- Schwarzschild (non-rotating) black hole in geometric units $G = c = 1$, so the Schwarzschild radius is $r_s = 2M$.
- Integrator
- Exact null-geodesic bending (see §1) with adaptive step size, fine near the hole and coarse at infinity.
- Disk
- Analytic Shakura–Sunyaev thin disk between the ISCO ($r_{\text{in}}$, adjustable 1.4–3.4 $r_s$) and $r_{\text{out}}$.
- Post
- Bloom pass (threshold 0.80, radius 0.32) plus a warm film grade toward the cream-gold of the movie.
- Scene units
- Everything is measured in Schwarzschild radii: the shadow's apparent radius is $\sqrt{27}/2 \approx 2.6\,r_s$.
1. Bending Light: Null Geodesics in Schwarzschild
The whole look of a black hole (the shadow, the lensed disk stacked above and below, the thin photon ring) comes from one fact: in curved spacetime, light does not travel in straight lines. For the Schwarzschild metric, a photon moving in the equatorial plane obeys a remarkably compact orbit equation. Substituting $u = 1/r$ into the null-geodesic condition gives the Binet equation for light:
The $3Mu^2$ term is the entire deviation from flat space: delete it and you recover the straight line $u(\varphi) = \sin(\varphi)/b$ of a photon in Euclidean geometry.
In 3D the same physics can be written as a Newtonian-looking acceleration law (spherical symmetry means every ray is "equatorial" in some plane), and that form is what actually ships in the shader. Setting $r_s = 2M = 1$ (so $M = \tfrac{1}{2}$ in shader units), a photon starting at position $\vec r$ with unit direction $\hat v$ has conserved angular momentum $\vec h = \vec r \times \hat v$, and its trajectory obeys:
This acceleration law is exact for Schwarzschild null geodesics parameterized by an affine parameter $\lambda$ (where $|\dot{\vec r}|^2 = 1 + h^2/r^3$ for a unit-energy ray at infinity); renormalizing $|\dot{\vec r}| = 1$ each step in the shader is a fast arc-length approximation on top of the finite step size.
Two consequences fall straight out of this equation and define the image. First, at $r = 1.5\,r_s$ circular photon orbits exist (the photon sphere), so rays aimed just barely past the hole coil around it, producing the thin bright ring hugging the shadow. Second, the critical impact parameter $b_c = 3\sqrt{3}\,M$ sets the apparent size of the shadow:
The black disk is more than twice the size of the event horizon itself. You are seeing trapped photon orbits, not the horizon.
💻 GLSL: Per-Pixel Geodesic Integration (the heart of the shader)GLSL
vec3 pos = uCamPos;
vec3 vel = rd; // per-pixel camera ray, unit length
vec3 hv = cross(pos, vel);
float h2 = dot(hv, hv); // conserved angular momentum (squared)
for (int i = 0; i < MAX_STEPS; i++){
float r2 = dot(pos, pos);
float r = sqrt(r2);
if (r < 0.98) { absorbed = true; break; } // fell through the horizon
if (r > uEscape && dot(pos, vel) > 0.0) break; // escaped to the sky
// adaptive step: fine near the hole, coarse far away
float dt = clamp(0.055*r*(r - 0.9), 0.022, 1.0);
vec3 prev = pos;
vec3 a = -1.5 * h2 * pos / (r2*r2*r); // Schwarzschild null geodesic
vel = normalize(vel + a*dt);
pos += vel*dt;
if (prev.y * pos.y < 0.0) { /* crossed the disk plane: see §2 */ }
}
Rays that neither escape nor get absorbed but run out of step budget while still near the hole are treated as captured. They were coiling in the photon sphere anyway, so adding them to the shadow is both cheap and physically honest.
2. The Disk: Shakura–Sunyaev Thermodynamics
When the ray crosses the equatorial plane inside the disk's annulus, the shader samples a thin-disk model. The temperature profile comes from the classical Shakura–Sunyaev solution for a geometrically-thin, optically-thick accretion disk. Writing $x = r/r_{\text{in}}$ (radius in ISCO units):
The profile peaks hot at $x = 49/36 \approx 1.36$ (i.e., $r \approx 1.36\,r_{\text{in}}$), not at the inner edge. The factor $2.05 = 1/0.488$ renormalizes the analytic maximum to exactly 1.0, so $T_{\text{peak}}$ (the slider's default 10 000 K) is literally the hottest temperature in the disk.
Temperature alone doesn't set brightness. The surface flux goes as $\sim r^{-3}$ with a characteristic $(1 - \sqrt{r_{\text{in}}/r})$ dissipation term that vanishes at the ISCO. That zero is a gift: it produces the dark gap between the shadow and the inner edge of the disk that makes the image read as "a disk orbiting a hole" instead of "a disk pasted over a circle".
The 0.8 exponent is an artistic flattening of the true thin-disk emission. The gap shape is physics; the softness is taste.
Color comes from a blackbody locus evaluated at the shifted temperature $T \cdot g$ (the factor $g$ is the subject of §3). The classic Tanner Helland approximation converts Kelvin to RGB in a few logs and powers, with the blue channel stiffened so mid-temperatures don't go lavender:
💻 GLSL: Blackbody Color + Disk EmissionGLSL
/* Blackbody color (Tanner Helland locus, blue channel stiffened) */
vec3 blackbody(float t){
t = clamp(t, 1400.0, 32000.0) / 100.0;
vec3 c;
c.r = (t <= 66.0) ? 1.0 : clamp(1.29290*pow(t - 60.0, -0.13320), 0.0, 1.0);
c.g = (t <= 66.0) ? clamp(0.39008*log(t) - 0.63184, 0.0, 1.0)
: clamp(1.12989*pow(t - 60.0, -0.07551), 0.0, 1.0);
c.b = (t >= 66.0) ? 1.0 : ((t <= 19.0) ? 0.0 : clamp(0.54320*log(t - 10.0) - 1.19625, 0.0, 1.0));
c.b = pow(c.b, 1.25); // suppress over-blue mid temperatures
return c;
}
/* --- at a disk-plane crossing, radius rr (ISCO units: x = rr/uRIn) --- */
float x = rr / uRIn;
float crease = pow(max(1.0 - inversesqrt(x), 0.0), 0.25);
float tprof = pow(x, -0.75) * crease * 2.05; // S-S profile, peak 1.0
float Tb = uTpeak * tprof;
float edge = max(1.0 - sqrt(uRIn / max(rr, uRIn + 1e-4)), 0.0); // zero at the ISCO edge
float flux = 2.5 * pow(uRIn / rr, 3.0) * pow(edge, 0.8);
/* relativistic shifts (§3) */
float beta = clamp(sqrt(0.5 / max(rr - 1.0, 0.30)), 0.0, 0.92); // v = sqrt(M/(r-2M))
float grav = sqrt(max(1.0 - 1.5/rr, 0.02)); // circular-orbit dilation
vec3 tangent = uSpin * vec3(p.z, 0.0, -p.x) / rr; // orbital direction
float cosT = dot(tangent, -vel); // photon dir → observer
float dop = 1.0 / max(1.0 - beta*cosT, 0.12); // directional beaming (sqrt(1-beta^2) is in grav)
float g = grav * mix(1.0, dop, uDoppler);
vec3 bb = blackbody(Tb * clamp(g, 0.25, 3.2));
bb = mix(bb, bb*vec3(1.15, 1.00, 0.74), 0.62); // warm film grade → cream-gold
float dens = env * (0.30 + 0.95*n);
float aOp = clamp(dens*1.15, 0.0, 0.92); // mostly opaque, thin at edges
col += T * bb * flux * pow(clamp(g, 0.06, 26.0), 3.7)
* (0.30 + 1.35*n) * 7.0 * uDiskBright; // n = fbm turbulence
T *= 1.0 - aOp; // occlusion for crossings behind the hole
if (T < 0.03) break;
Two more touches complete the disk. A small value-noise fbm field, advected differentially (inner rings orbit faster than outer ones, $\omega \propto r^{-3/2}$), breaks the perfect symmetry into shearing filaments; this is the part that makes motion feel turbulent instead of turntable. And the disk is treated as mostly opaque: light from crossings behind the hole is dimmed but not fully blocked ($a_{\text{op}} \le 0.92$), which leaves just enough translucency for the far side to glow through the near side at grazing angles.
3. Redshift, Doppler Beaming, and the Bright Asymmetry
The single most recognizable feature of Gargantua, one side of the disk glowing while the other side dims, is not artistic license. Disk material moves on nearly circular geodesics at speed
At the ISCO of a Schwarzschild hole this reaches $\beta = 0.5\,c$, half the speed of light, deep enough into the Doppler regime that the two sides of the disk differ by a factor of several in brightness.
Time for the emitting matter runs slow twice over: gravity dilates its clock by $\sqrt{1-2M/r}$, and its orbital motion adds another $\sqrt{1-\beta^2}$. For circular orbits the product is exact and compact, $\sqrt{1-3M/r}$, which is the first factor the shader evaluates. On top of it sits the directional Doppler term between the orbiting matter and the departing photon, with $\theta$ the angle between the disk's motion and the photon direction:
The relativistic invariant $I_\nu/\nu^3$ makes the observed intensity scale as $g^3$; the extra $0.7$ in the exponent is artistic amplification. The same $g$ multiplies the temperature, so the approaching side is not just brighter, it's bluer-hot, exactly what the movie's team computed.
4. The Render Pipeline
Strung together, the frame is produced in one fullscreen pass plus one bloom pass; the three.js scene graph exists only to hold a single quad and the camera's orientation uniforms:
The loop is simple to state: integrate until the ray is absorbed, escapes, or runs out of budget; accumulate disk emission weighted by remaining transmittance $T$ at every plane crossing (so the near side occludes the lensed far side); then hand the HDR buffer to bloom. Bloom matters more than it sounds: the photon ring and the Doppler-bright edge both live at intensities far above 1.0, and bloom is what lets them bleed their energy into the surrounding dark.
5. A Loop That Never Ends
A loop video must return to its exact starting state, position and velocity, or viewers feel the seam. The video uses a closed harmonic camera trajectory combined with turbulence self-crossfading: over the 8-second cycle, the camera swoops from an edge-on inclination ($5.2^\circ$) showing the dual lensed light rings, climbs to $17.0^\circ$ to reveal the full 3D disk plane and ISCO gap, while swaying laterally across $\pm 18^\circ$ in azimuth, returning with zero velocity discontinuity ($C^1$ continuity) at $t = L$.
The camera trajectory is driven by integral harmonics of the loop period $L$:
Because sine and cosine complete whole numbers of cycles, every position coordinate and every velocity component at $t = L$ matches $t = 0$ exactly.
The turbulence is the hard part. The fbm pattern is advected differentially ($\omega \propto r^{-3/2}$), so no time shift can ever realign it; shearing is a one-way street. Instead of looping the field, the shader lets the physical evolution run and crossfades the result with a copy of itself evaluated one loop period back, through a window $w(t)$ that is fully open at $t=0$ and fully closed by $t=L$:
Early in the loop you see the live field. Approaching $t = L$ the window hands over to the field one loop back, which is exactly the state the loop started in, so the wrap from $t = L$ to $t = 0$ is continuous. The seam is invisible because the output is always a valid mix of two physically evolving states.
Together, the closed harmonic camera path and the turbulence crossfade guarantee that both the observer geometry and the differentially shearing disk return to their exact starting state at $t = L$. In the interactive sim the crossfade mode is off and the disk evolves freely. It's enabled only by the offline renderer (uLoop = 1), which is the right trade: interactivity keeps honest physics, the video keeps the guarantee.
6. Rendering 4K/120 Offline: 960 Frames, One MP4
A browser cannot screen-record its own WebGL canvas at 4K/120, but it doesn't have to run in real time when the renderer is deterministic. Every frame $k$ has an explicit timestamp $t = k/120$ and an explicit camera pose, so each frame can be rendered on demand, on any fresh page load. That decoupling is what makes the offline pipeline robust:
- Slice rendering. A browser harness evaluates a small script per slice (5 to 30 frames): set time, set camera, render at 3840×2160, encode the canvas to JPEG at quality 0.95, and POST it to a local server. Each frame is stateless; if anything dies mid-run, the next slice simply resumes from the last numbered file on disk. That resilience was not theoretical: the harness recycled the tab mid-run more than once, and each time the run picked up from the last numbered file with no repeats and no gaps.
- Frame budget. A 4K frame costs ≈0.9 s (integrator steps plus JPEG encode), putting all 960 frames at about fifteen minutes of GPU time.
- Assembly. ffmpeg ingests the numbered JPEGs as a 120 fps sequence and encodes H.264 (x264, slow preset, CRF 18, yuv420p, faststart).
- Frames
- 960 (8.000 s × 120 fps exactly; the loop length divides evenly)
- Resolution
- 3840×2160, rendered at pixel ratio 1.0 with bloom at full resolution
- Frame cost
- ≈0.9 s/frame offline (vs. the 16.7 ms the interactive sim must hit at 60 Hz)
- Encode
- libx264 · preset slow · CRF 18 · 4:2:0 · +faststart
- Result
- 34.6 MB MP4, verified 120/1 fps, 960 frames, 8.000 s duration
💻 Assembly: JPEG sequence → 4K120 MP4bash
ffmpeg -y -framerate 120 -start_number 1 -i fr-%06d.jpg \
-c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p \
-movflags +faststart black-hole-loop.mp4
7. Honest Caveats: Sim vs. Reality
It's worth being precise about what is physics and what is shorthand:
| Aspect | This simulation | The real thing / the film |
|---|---|---|
| Spacetime | Schwarzschild (non-rotating) | Gargantua is near-extremal Kerr; spin drags the shadow off-center and reshapes the disk |
| "Spin" slider | ISCO radius 1.4–3.4 $r_s$ + orbit direction flip | A visual stand-in: it mimics consequences of spin, not spin itself |
| Disk model | Analytic S-S profile + fbm turbulence | Full radiative transfer through magnetized plasma (GRMHD) |
| Doppler beaming | On by default ($g^{3.7}$) | The film suppressed it deliberately for a calmer, symmetric image |
| Starfield | Procedural hash-based stars, lensed correctly | Production renderers traced real star catalogs through the same equations |
None of these compromises touch the headline effect (the lensing, the shadow at $2.6\,r_s$, the stacked images of the disk's far side, the beaming asymmetry), because those come from the geodesic equation, which is integrated exactly. What's approximated is everything on the light paths, not the paths themselves.
8. The Infinite Loop
And here is the payoff: the same spacetime, pre-rendered as a mathematically seamless 4K 120 fps loop. Frame 1 follows frame 960 with no jump; the camera executes a closed harmonic orbit while the disk's turbulence crossfades back to its own past:
The live demo, the shader, and the loop recipe all live in one self-contained HTML file. The spacetime was the scene; the browser was the render farm.