Astrophysics Physics WebGL GLSL

Rendering Gargantua: A Real-Time Black Hole in Your Browser


This is not a video. The black hole above is being computed right now, pixel by pixel, by your browser, using whatever graphics hardware your machine has: drag inside the frame to orbit the camera. You don't need a dedicated graphics card; an integrated GPU handles it, and a machine with no GPU acceleration at all falls back to the browser's slow-but-working software renderer. If the frame stutters, open the simulation full-screen and lower the resolution in the controls.

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$.
Performance note: every pixel integrates a differential equation per frame, so this is one of the more expensive things a browser can be asked to do. The control panel exposes a step-count and resolution dial; if you're on integrated graphics, lower both and keep the window small. For scale: everything in this post, the live demo and the 960-frame 4K video, was produced on a laptop with integrated Intel graphics only, no dedicated GPU.

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:

Null geodesic orbit equation (Schwarzschild)
$$\frac{d^2 u}{d\varphi^2} + u = 3Mu^2, \qquad u \equiv \frac{1}{r}$$

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:

The acceleration actually integrated per pixel ($r_s = 2M = 1$)
$$\vec a = \frac{d^2\vec r}{d\lambda^2} = -3M\,h^2\,\frac{\vec r}{r^5} = -\frac{3}{2}\,h^2\,\frac{\vec r}{r^5}, \qquad \vec h = \vec r \times \frac{d\vec r}{d\lambda}$$

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:

Apparent shadow radius
$$r_{\text{shadow}} = \frac{b_c}{r_s} = \frac{3\sqrt{3}}{2} \approx 2.6\,r_s$$

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):

Shakura–Sunyaev temperature profile
$$T(x) = T_{\text{peak}} \cdot x^{-3/4}\left(1 - x^{-1/2}\right)^{1/4} \cdot 2.05$$

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".

Surface brightness law (with the ISCO gap)
$$F(r) \propto \left(\frac{r_{\text{in}}}{r}\right)^{3} \left(1 - \sqrt{\frac{r_{\text{in}}}{r}}\right)^{0.8}$$

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

Circular-orbit speed measured by a local static observer
$$\beta(r) = \sqrt{\frac{M}{r - 2M}} \;\;\xrightarrow{\;r \text{ in } r_s\;}\;\; \sqrt{\frac{0.5}{r_{r_s} - 1}}$$

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:

Combined shift factor and observed intensity
$$g = \underbrace{\sqrt{1 - \tfrac{2M}{r}}}_{\text{gravity}} \cdot \underbrace{\frac{\sqrt{1-\beta^2}}{1 - \beta\cos\theta}}_{\text{Doppler}} = \underbrace{\sqrt{1 - \tfrac{3M}{r}}}_{\text{gravity + orbit}} \cdot \underbrace{\frac{1}{1 - \beta\cos\theta}}_{\text{approaching / receding}}, \qquad I_{\text{obs}} \propto g^{\,3.7} \cdot I_{\text{emit}}$$

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.

Film cheat, revealed: the movie's disk looks nearly symmetric because the Double Negative team deliberately turned the Doppler asymmetry down, a physically wrong but visually calmer image. Toggle the Doppler pill off in the demo above, and you'll see Gargantua the way the film crew chose to show it.

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:

Camera ray per pixel Geodesic integrator a = −1.5·h²·r/r⁵ Disk crossing? T(x) · g · blackbody Accumulate col += T·emission Bloom + film grade
Figure 1: One frame = one fullscreen pass integrating null geodesics, then a bloom/grade composite. No meshes, no acceleration structure: the spacetime is the scene.

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$:

Closed harmonic camera path ($t \in [0, L]$)
$$\varphi(t) = \varphi_0 + A_\varphi \sin\!\left(\frac{2\pi t}{L}\right), \quad \theta(t) = \theta_0 + A_\theta \frac{1 - \cos(2\pi t / L)}{2}, \quad r(t) = r_0 - A_r \frac{1 - \cos(2\pi t / L)}{2}$$

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$:

Self-crossfade loop blend
$$n(t) \;\leftarrow\; \big(1 - w(t)\big)\,n(t - L) \;+\; w(t)\,n(t), \qquad w(t) = 1 - \mathrm{smoothstep}\!\left(0, 1, \tfrac{t}{L}\right)$$

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:

  1. 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.
  2. 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.
  3. Assembly. ffmpeg ingests the numbered JPEGs as a 120 fps sequence and encodes H.264 (x264, slow preset, CRF 18, yuv420p, faststart).
Why not render and encode in one browser session? That was the first attempt: WebCodecs fed straight into an MP4 muxer in-page. It worked until the automation harness recycled the browser tab at frame ~150 and took the encoder's state with it. Long-lived in-page jobs and automated browsing don't mix; files on disk + stateless slices survive anything, including the tab dying mid-run.
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:

Gargantua · seamless loop. 3840×2160 · 120 fps · 8.000 s · 34.6 MB. Rendered offline from the same shader that powers the live demo at the top of this page.

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.