DEV Community

Cover image for A ray-traced black hole in a landing-page hero: the physics, and the shortcuts
Ramin Valadan
Ramin Valadan

Posted on

A ray-traced black hole in a landing-page hero: the physics, and the shortcuts

Black holes in movies and landing pages are usually a picture: a glowing ring, a dark disc, maybe a bit of blur. This one isn't. Every pixel follows its own ray of light round a black hole, live, so the image reacts when the camera moves:

  • the arch over the top is the far side of the disk, its light bent up and over the hole;
  • the thin crescent under the shadow is light that went round underneath;
  • the stars behind are lensed too.

None of it is painted. Here's how it runs in a hero section on an ordinary laptop, and where I cheated.

▶ Live preview (scroll down in it: the camera falls in)

Light round a black hole, in one equation

Outside a non-spinning (Schwarzschild) black hole, a ray of light stays in one plane through the hole. Measure lengths in Schwarzschild radii (the horizon is at r = 1), write u = 1/r, and the path in that plane obeys the Binet equation for light:

d²u/dφ² = 1.5·u² − u
Enter fullscreen mode Exit fullscreen mode

A whole path is fixed by one number, its impact parameter b: how far from the hole the ray would pass if gravity didn't bend it. Arriving from infinity it starts at u = 0 with du/dφ = 1/b. Then:

  • below the critical value b = 3√3/2 ≈ 2.598, it spirals in and hits the horizon;
  • above it, it swings round and leaves again;
  • close to it, it winds round the photon sphere (r = 1.5) first, and that winding is what draws the thin bright ring at the edge of the shadow.

Don't integrate per pixel. Integrate once.

Solving that ODE for every pixel, every frame, would be wasteful, because every ray with the same b follows the same path. So the paths are worked out once and stored in a texture:

  • 1,024 paths: 512 that fall in and 512 that escape. The rows are spaced in log|b − b_c|, so they crowd in near the critical value, where paths change fastest.
  • 512 samples along each path, at evenly spaced angles from 0 to wherever the path ends (the horizon, or infinity). Neighbouring rows always line up end to end, so the shader can blend between them.

Each path is integrated with RK4, with a step that keeps both the angle step and the change in u small, so steep plunges and slow windings are both resolved:

function deriv(u: number, v: number): [number, number] {
  return [v, 1.5 * u * u - u];
}

// one step of the integration
const h = Math.min(0.01, 0.003 / Math.max(Math.abs(v), 1e-9));
const [k1u, k1v] = deriv(u, v);
const [k2u, k2v] = deriv(u + 0.5 * h * k1u, v + 0.5 * h * k1v);
const [k3u, k3v] = deriv(u + 0.5 * h * k2u, v + 0.5 * h * k2v);
const [k4u, k4v] = deriv(u + h * k3u, v + h * k3v);
Enter fullscreen mode Exit fullscreen mode

and then resampled with cubic Hermite interpolation (u and du/dφ are both known at every step). The whole table takes a fraction of a second on the CPU, in a worker.

One nice property: the only thing that depends on the camera is where along each path the camera sits. Moving the camera nearer or farther (the hero has a scroll "dive" into the hole) recomputes one number per row, not the paths.

A pixel finds the disk every π

The accretion disk lies in a plane through the hole. A ray's own plane also passes through the hole, so the two planes meet in a line through the centre, and the ray crosses the disk plane every π radians along its path. In the fragment shader, each pixel looks up its path and simply reads the radius at each crossing:

for (int k = 0; k < MAX_CROSS; k++) {
  float phiK = first + float(k) * PI;
  float along = en.y + phiK;          // en.y: where the camera sits on this path
  bool ok = along < en.x;             // en.x: where the path ends
  vec2 uv = pathAt(row, esc, along / en.x);
  rr[k] = ok ? 1.0 / max(uv.x, 1e-5) : 1e5;   // radius at this crossing
  ...
}
Enter fullscreen mode Exit fullscreen mode

Three crossings are enough: the direct image of the disk, the image that went round the hole (the arch and the crescent), and the first photon-ring image. Further windings are thinner than a pixel. The crossings are shaded front to back, then the stars where the light finally came from.

So a frame is a handful of texture reads per pixel, not an integration.

Making the disk look alive

  • Kepler speeds. Inner gas laps outer gas. But a pattern painted on a disk that turns at different speeds at different radii shears into noise within seconds. So the disk is made of distinct threads, and each thread orbits rigidly at the Kepler speed of its own radius: the threads slide past each other forever without the image ever winding up.
  • Temperature and Doppler. Colour follows a temperature that falls off as r^-3/4, multiplied by a factor that combines the gravitational redshift with the Doppler shift of gas orbiting at relativistic speed, so one side of the disk runs hotter:
  float beta = min(sqrt(0.5 / max(r - 1.0, 0.1)), 0.75);   // orbital speed
  float gam  = inversesqrt(1.0 - beta * beta);
  float g    = sqrt(max(1.0 - 1.0 / r, 0.0)) / (gam * (1.0 - beta * dot(vdir, -tang)));
Enter fullscreen mode Exit fullscreen mode
  • Gold dust. Tens of thousands of sparks on Kepler orbits, projected with the same camera and bent by the thin-lens equation, so the far side of the band rises over the hole like the disk does and nothing behind the hole shows through its shadow. Sparks far from the focus open into soft bokeh discs.

Where taste overruled physics

It's a hero section, not a simulation, and the README says so:

  • The disk is cut off at r = 1.7, well inside the innermost stable orbit (r = 3) where a real disk would end. That lets the arch hug the shadow and the ring underneath continue the same circle.
  • The photon ring is off by default: at hero size it's thinner than a pixel.
  • Stars are fully lensed near the rim, less further out, because seen this close the whole frame lies inside the Einstein radius and the sky would be unreadable.

Not killing the page

  • Off the main thread. The GPU context, the shader compiles, the light-path table and every frame run in a Web Worker on an OffscreenCanvas. The page's own thread never waits on them. Where a browser can't draw from a worker, it falls back to the main thread by itself.
  • A frame-rate governor. Once a second, under 50 fps trims the traced resolution by 15%; three good seconds in a row raise it by 10%. The final composite and grain always run at full resolution, so text and grain stay sharp.
  • Polite. It pauses off screen, in hidden tabs, and past the horizon in the dive. With prefers-reduced-motion it is one still frame. Without WebGL 2, or if the GPU takes the context away, an SVG drawing of the same black hole stands in.

Try it

The hero (headline, buttons, the scroll dive, light and dark themes) is free to use in your own projects. It's built with React, three.js, Tailwind CSS and Next.js, and you can add it with the shadcn CLI, no account needed:

npx shadcn@latest add https://upfork.dev/r/black-hole-hero.json
Enter fullscreen mode Exit fullscreen mode

Live preview on UpFork, where I publish components like this one. If you know general relativity better than I do, I'd love to hear what you'd do about the spinning (Kerr) case.

Top comments (2)

Collapse
 
williamsj04 profile image
Jessica Williams •

Storing 1,024 light paths in a texture, since every ray with the same impact parameter follows the same path, is a clever way to avoid integrating per pixel. The frame-rate governor that trims the traced resolution while keeping the final composite sharp is a nice touch for a hero section. Did you notice any banding in the Hermite-interpolated rows close to the critical value, where paths change fastest?

Collapse
 
raminvaladan profile image
Ramin Valadan •

Thanks, Jessica! Not that I could see at hero size, and mostly by construction:

  • The rows aren't spaced evenly in b. They're spaced in log|b − b_c|, down to 1e-6 from the critical value, so most of the 512 rows in each half sit exactly where the paths change fastest. Between rows the shader just blends linearly; the Hermite part is only along each path.
  • Captured and escaping paths live in separate halves of the table, so a blend never mixes a ray that falls in with one that escapes.
  • Where it would show is the photon ring (the second and later windings). At hero size that's thinner than a pixel, so it's off by default, and the first lensed image under the shadow is deliberately kept smooth. The shadow's edge is antialiased over one pixel from each ray's b, so it doesn't stair-step either.

If you turn the photon ring on and zoom way in, you'd eventually reach the 1e-6 floor, where the closest rows simply reuse the last one.