Made by AI
The black hole scene: a dark shadow ringed by light, with a glowing accretion disk arching over and under it
Space Travel · Interactive explainer · Scene 14

Around the Black Hole

No meshes, no textures, no special cases: every pixel follows one ray of light while gravity bends it. The black shadow, the thin ring of fire and the disk arching over the top all come out of a single line of physics — explained from first principles, with the real shader running right here in your browser.

The scene

The previous scene flew us into a spiral galaxy; this one arrives at the black hole at its core. It lasts 32 beats — 15.36 seconds at 125 BPM, cross-fading in from the galaxy over three beats and out into the credits over another three. The camera drifts slowly around the hole, a few degrees above the plane of its glowing disk, and closes in by 30 % over the scene.

What you see: a black disk — the shadow — edged by a razor-thin bright ring. Around it, a disk of hot gas, brighter and whiter on one side than on the other. And, strangest of all, the far half of that disk seems to rise up and arch over the top of the shadow, and to curl underneath it. Near the shadow the background stars are smeared into arcs. Small hot spots near the inner edge of the disk flare on the snare drum.

The core trick, in plain words: for every pixel the shader shoots a ray of light out of the camera and moves it forward in small steps. At each step it nudges the ray's direction toward the hole by an amount given by one formula. Where the bent path crosses the plane of the disk, the disk's light at that point is added. If the path falls into the hole, no background sky is added, but any disk or jet light already gathered remains. If it escapes, the sky along the ray's final direction is added through the remaining transparency. None of the effects above is drawn on purpose. The shadow, the ring, the arc and the distorted stars all emerge from bending the rays.

You don't need a physics or graphics background to follow along. Each section introduces one idea with a small interactive figure. Code appears in two forms: pseudo-code (plain logic) and the real GLSL that runs in the demo. All distances on this page are measured in horizon radii: the event horizon has radius 1.

Live: the real shader

This is the actual black hole scene: the demo's fragment shader, ported to WebGL2 with only the #version line, a precision qualifier and the #included helpers pasted in. The camera path and every uniform come from a JavaScript mirror of the C++ render(), with the same defaults. Drag the time slider to scrub: the frame is a pure function of time. The demo's full post-processing chain (bloom, chromatic aberration, ACES tone mapping, vignette, grain) is replaced by a simple ACES tone mapping and glow.

Try: set lensing to 0 — the arc over the shadow disappears and the hole becomes a plain black ball in front of a flat disk. Slide doppler between 0 (symmetric, as in the film Interstellar) and 1 (physical: one side blazing, the other dim and red). Raise the inclination to look down on the disk, or lower it below 0 to see it from underneath. Drop quality to 0.25 and watch the thin rings break up: too few steps.
Differences from the demo: the snare pulse that makes the hot spots flare is approximated by a hit on beats 2 and 4 of every bar — the demo reads the real note-ons from the music — and the glow is a simple stand-in for the demo's bloom: the browser uses a hard threshold of 1.0 on blurred samples; the demo's soft knee starts admitting light above a brightness of 0.5 at that threshold. The shader code itself is unchanged apart from one line: the stars of sky.glsl are sized in pixels, so here they are sized for the demo's 1080-line frame instead of this smaller canvas (otherwise each star would cover several times more of the picture). If the frame rate is low, lower the render scale (the same knob as in the demo).

Background

Nine ideas make up the whole scene. The first four are about light and gravity; the next four are about the glowing disk; the last one is about the sky behind it.

1 · Rays from the camera

Ray tracing runs backwards. Real light travels from the disk to your eye, and almost all of it misses the eye. The only light that matters is the light that ends up in a pixel — so the shader starts at the eye and follows each pixel's ray out into the scene. Light paths are reversible, even bent ones: the backward path is exactly the path the light took, walked the other way. (One consequence you'll meet in §8: when the shader needs "the direction toward the camera" at a point on the disk, it uses −rayDir.)

A pinhole camera. The camera is a position plus three perpendicular unit vectors: forward, right and up. A pixel at (u, v), measured in screen heights from the center of the screen, looks along

dir = normalize(forward · focal + right · u + up · v)

With focal = 1.6 the screen's height spans 2·atan(0.5 / 1.6) ≈ 35° of sky: a moderate telephoto.

Keeping the hole centered. The C++ builds the camera with Camera::lookAt(position, origin): forward always points at the hole (which sits at the origin), right = forward × worldUp and up = right × forward. However the camera moves, the hole stays in the middle of the frame. The motion itself is three pure functions of the scene time t:

inclination = 9° + 2° · sin(0.2 t)                   // height above the disk plane, gently bobbing
azimuth     = −0.4 + 0.035 t                          // radians: a slow orbit, about 31° over the scene
distance    = 26 · (1 − 0.3 · smoothstep(0, 1, t / 15.36))  // closes in from 26 to 18.2
position    = distance · (cos incl · sin az,  sin incl,  cos incl · cos az)
Left: top view, looking down on the disk (gold ring between radius 3 and 13); the pink curve is the camera's path over the whole scene. Right: side view, true to scale: the camera stays only a few degrees above the disk plane, so we see the disk almost edge-on. The dashed line is the camera's view axis, always aimed at the hole.

2 · Bending light, one step at a time

In empty space light goes straight. Near a mass its path curves. The shader does not solve for the curve in one go; it walks along it, like a hiker who looks at the compass after every few steps. Each step does three things:

  1. compute the pull at the current point: a vector pointing at the hole, of strength 1.5 h² / r⁴ (r = distance to the hole; h is explained below);
  2. add pull × dt to the ray's direction;
  3. move the position by direction × dt.
float dt = clamp((r - kHorizon) * 0.12, 0.012, 1.5) * uStepScale;
vec3 previous = pos;
dir += -1.5 * h2 * pos / pow(r, 5.0) * uLensing * dt;   // pos / r⁵ = (unit vector) / r⁴
pos += dir * dt;

This is Euler's method — the simplest way to follow a differential equation, named after Leonhard Euler — in its semi-implicit form: the direction is updated first, and the new direction moves the position. That small ordering detail makes orbits behave much better than the naive version, which slowly gains energy and spirals outward.

What is h? It is the ray's angular momentum, h = |pos × dir|. For a ray that starts with a unit direction, h equals the impact parameter b: the distance by which the ray would miss the hole if it flew straight on. A pull that always points at the center exerts no twist (torque), so angular momentum never changes along the path. The shader computes h² once per pixel (h2) and reuses it at every step.

Adaptive steps. Far from the hole the path is nearly straight, so steps can be long; near the horizon the path bends sharply and steps must be short. dt = clamp(0.12 · (r − 1), 0.012, 1.5) does that: 1.5 units far away, shrinking in proportion to the distance from the horizon, never below 0.012. A ray from the camera reaches the hole's neighborhood in a few dozen steps. The quality knob multiplies the step budget (220 × quality) and divides the step size.

Three ways to stop. r < 1: the ray fell through the horizon — omit the background sky, retaining any disk or jet light already gathered. r > cameraDistance + 20 while moving outward: it escaped — look up the sky along its final direction. Out of steps: treated like an escape.

Drag up and down on the figure (or use the slider) to aim the gold ray. All rays start 26 units to the left — where the demo's camera is — flying right, each offset by its impact parameter b. They are integrated with the shader's exact loop. Cyan rays escape, red rays are captured. The dots are the integration steps: watch them bunch up near the hole. The dashed circle is the photon sphere (§4) and the gold dashed lines mark the critical impact parameter b = ±2.6.
Why bend the rays in the plane of a 2D figure? Because a pull that always points at the center can never push a ray out of the plane containing its starting direction and the center. Every ray in the 3D shader is secretly a 2D problem like this one, in its own tilted plane.

3 · Where −1.5 h² x / r⁵ comes from

Newton's gravity pulls with a strength that falls as 1/r². Light, though, is described by Einstein's general relativity (1915), and around a non-spinning mass by the solution Karl Schwarzschild found in 1916. Write the path of a ray in polar form — its angle φ around the hole and u = 1/r. The light path then obeys the photon orbit equation:

d²u / dφ² + u = (3/2) · rs · u²

where rs is the horizon radius (the Schwarzschild radius, 2GM/c² — about 3 km for the mass of the Sun; 1 on this page). Without the right-hand side the solutions are straight lines: all the bending lives in that one term.

Now the trick. Pretend the light is an ordinary particle pushed by some central force F(r). Newtonian mechanics gives the shape of its path by Binet's equation: d²u/dφ² + u = −F / (h² u²). Choose the force so both equations match: F = −(3/2) h² u⁴ = −1.5 h² / r⁴. As a vector pointing at the hole, that's

a = −1.5 · h² · x / r⁵

— the line in the shader. The shader comment calls it "the usual approximation of null geodesics" (a null geodesic is the path of light in curved spacetime). It's worth knowing exactly what is approximate: the shape of the path, measured in Schwarzschild's r and φ, is exact. What is approximate is (a) the finite step size, (b) treating Schwarzschild's coordinates as ordinary flat 3D space when placing the camera and the disk, and (c) the speed along the path: the "ray" speeds up near the hole like a falling stone — harmless, because only the path's shape is used. And the hole does not spin: a spinning (Kerr) hole, like Interstellar's Gargantua, has a lopsided shadow.

How much does a ray bend? Far from the hole the bend is small. Einstein's weak-field result is α = 2 rs / b radians — for the Sun, 1.75 arc-seconds at its edge, the deflection measured during the 1919 eclipse expeditions organized by Frank Dyson and Arthur Eddington. Treating light as a Newtonian particle gives exactly half of that (Johann von Soldner published that value in 1804). Close to the hole, the true bend grows without limit:

Total bend of a ray versus its impact parameter. White: the shader's equation, integrated from far away with fine steps. Cyan dashed: Einstein's weak-field formula 2/b. Pink dashed: the Newtonian value 1/b. As b grows, white and cyan converge (at b = 16 they still differ by about 10 %: the next correction falls off as 1/b²); near b = 2.6 the bend shoots past 180° (the ray comes back toward you) and beyond: the ray loops around the hole. Left of the gray line every ray is captured.

4 · Horizon, photon sphere and shadow

Three radii organize everything you see:

So the black disk on screen is not the horizon. It is the set of all directions whose rays are captured, and it appears with radius 2.6 — more than two and a half times the size of the horizon itself. That is why astronomers call it the shadow; the Event Horizon Telescope's 2019 image of the black hole in the galaxy M87 shows exactly such a shadow.

Rays aimed just outside the shadow edge circle the hole once, twice, more, before leaving — and on each loop they can cross the disk again. Each loop shows a thinner, fainter copy of the whole surroundings, squeezed against the shadow's edge: that is the photon ring, the thin bright line hugging the shadow in the capture at the top of the page.

The demo's own steps are coarse enough to matter a little here. With the default quality, the edge of the shadow comes out at b ≈ 2.54 instead of 2.598: a few rays that should be captured escape, and the shadow is about 2 % smaller than the exact one (at quality 4 the edge is at 2.58). Nobody can see the difference — which is what the quality knob trades.

Fine-tune the aim around b = 2.598. The gold path is the shader's equation with steps 100 times finer than the demo's (its shadow edge lands at b ≈ 2.597: the finer the steps, the closer to 2.598); the dashed gray path is the demo's own integration, with its default budget of 220 steps. A gray ✕ marks where the demo's ray runs out of steps: from there the pixel just shows the sky in the ray's current direction. So in the demo, the highest-order loops of the photon ring are cut short — invisible at screen resolution, where the ring is about a pixel wide.

5 · A thin disk, and the arc over the shadow

The accretion disk is gas orbiting the hole and slowly spiraling in. In the shader it is a flat sheet with no thickness: the plane y = 0, between radius inner = 3 and outer = 13. Radius 3 is not arbitrary: it is the innermost stable circular orbit (ISCO) of a Schwarzschild hole. Closer in no stable orbit exists, and gas plunges straight in.

The shader never intersects the disk geometrically. After every step it simply checks whether the step crossed the plane: previous.y and pos.y have opposite signs. It finds the crossing point by linear interpolation along the step, and if that point lies between the two radii, it samples the disk there:

if (previous.y * pos.y < 0.0) {
    vec3 hit = mix(previous, pos, previous.y / (previous.y - pos.y));   // where y = 0
    float rh = length(hit.xz);
    if (rh > uInner && rh < uOuter) { … diskSample(hit, dir) … }
}

Because the ray keeps going after a hit, it can cross the plane again, and again. That is the whole secret of the famous look. A ray aimed just above the hole would, in flat space, sail over it and over the disk behind it into empty sky. Gravity bends it down; it crosses the plane behind the hole and sees the top of the far half of the disk. All those rays together paint the far half as an arc rising over the shadow. A ray aimed just below the hole slips through the gap between horizon and inner edge, is bent upward, and hits the far half from underneath: an arc curling under the shadow. Jean-Pierre Luminet computed the first image of this kind in 1979; the film Interstellar (2014) made it famous.

Layers, front to back. Each disk sample has a color and an opacity a. The shader composites them in the order the ray meets them, nearest first: col += (1 − α) · color · a and α += (1 − α) · a, where α is the opacity accumulated so far. Each new layer is seen through what lies in front of it — the "over" operator of Porter and Duff (1984), run front to back. Even where the gas is densest its opacity is capped at 0.9, so the near half always lets at least 10 % through: in the capture you can see the lower arc faintly through the front of the disk. Once α > 0.99 nothing behind could show, and the loop stops early.

A vertical slice through the middle of the screen. The camera (pink, drag it) fires one ray per screen row; the column on the right is that slice of the image, row by row, colored by what each ray meets first on the disk. Hover the column to follow one ray; its plane crossings are numbered. With the camera above the plane, first-crossing hits on the far half form the arc over the shadow and second-crossing hits form the arc under it (with the camera below the plane, the two swap). Rows on the near half sometimes have a second disk crossing behind it: the lower arc, seen faintly through the semi-transparent near half.
The optional polar jets (off in the demo: jets = 0; try them in the live demo) are not a surface but a glowing volume: at every step the shader adds a little light, glow(pos) × dt, so a ray collects more the longer it travels through the jet — a crude version of the volume integral used for clouds and nebulae.

6 · How hot is the disk?

As gas spirals inward it rubs against itself (viscosity) and heats up. The standard thin-disk model of Nikolai Shakura and Rashid Sunyaev (1973) gives the temperature at radius r:

T(r) ∝ r−3/4 · (1 − √(rin / r))1/4

The first factor makes the disk hotter inward. The second goes to zero at the inner edge, where the gas stops being held by its neighbors and falls freely, so the hottest ring sits a little outside the edge — at r = (49/36) · rin ≈ 1.36 rin, where the expression (written with x = rin/r) equals 0.488. That is why the shader divides by 0.488: its temperature peaks at exactly 1.

float x = uInner / r;
float temperature = pow(x, 0.75) * pow(max(1.0 - sqrt(x), 0.0), 0.25) / 0.488;

Color. A hot body glows with a color set by its temperature (Max Planck's law of 1900): dull red, then orange, white and finally blue-white. blackbody(t) is a hand-tuned four-color ramp that imitates that sequence; its input is T · shift · temp (the shift comes in §8). Brightness grows as T² — gentler than a real blackbody, whose total emission grows as T⁴ (the Stefan–Boltzmann law): an artistic choice that keeps the outer disk visible. Both edges fade out smoothly: smoothstep(inner, 1.12·inner, r) · smoothstep(outer, 0.65·outer, r).

Gold: temperature T(r). Pink: the brightness factor T². Violet dashed: the edge fade. The strip shows the resulting color along a radius, with average turbulence and no Doppler shift, tone-mapped like the live demo. Move the inner radius: the whole profile scales with it.

7 · Spirals from Kepler

Johannes Kepler's third law (1619) says that the square of an orbit's period grows as the cube of its radius. Turned into an angular speed: ω ∝ r−3/2. Gas near the inner edge laps the outer gas many times over; this differential rotation shears any pattern on the disk into trailing spirals, like cream stirred into coffee.

The shader gets this for free by computing the disk's turbulence in a frame that rotates with the gas at each radius:

float omega = pow(r, -1.5) * uDiskSpeed;          // Kepler: ω ∝ r^-1.5 (uDiskSpeed = 6)
float phi = atan(p.z, p.x) - omega * uTime;      // angle measured in the gas's own rotating frame
float spiral = phi + log(r) * 2.5;                // a logarithmic spiral
vec3 q = vec3(cos(spiral) * 2.0, sin(spiral) * 2.0, r * 2.5);
float density = smoothstep(0.25, 0.8, fbm(q) * 0.7 + fbm(q * 2.7 + 5.0) * 0.3);

Subtracting ω t glues the noise pattern to the gas: as time passes each radius carries its pattern around at its own speed. Adding 2.5 · ln r to the angle bends lines of constant angle into logarithmic spirals (spirals that cross every circle at the same angle). Using cos and sin of the angle, rather than the angle itself, avoids a seam where the angle jumps from +180° to −180°. The noise is fbm (fractal Brownian motion): several layers of smooth value noise, each twice as fine and half as strong as the last — the same 3D fbm as in the warp scene.

Hot spots. Three bright blobs, exp(−d² / 0.35), orbit at 1.2, 1.5 and 1.8 times the inner radius, each at its own Keplerian speed. Their strength, 0.6 + 1.6 · pulse, jumps when the snare drum hits and decays as exp(−5 · seconds since the hit).

The disk seen from straight above, computed by the demo's own diskSample() function on the GPU. The faint ring in the middle is the horizon. Let time run and watch the inner disk overtake the outer disk. Set the disk speed to 0 to freeze the pattern; push the snare pulse to see the hot spots flare. Seen from above, the gas moves sideways to the line of sight, so the only color shifts left are the gravitational redshift and a small relativistic one (§8).

8 · Doppler beaming and gravitational redshift

Near the inner edge the gas moves at a good fraction of the speed of light. The shader sets its speed (as a fraction β of light speed) to

β = √(0.5 / (r − 1))    (capped at 0.95)

This is the speed of a circular orbit around a Schwarzschild hole as measured by an observer standing still at that radius: exactly half the speed of light at the ISCO (r = 3), and the speed of light itself at the photon sphere — which is why light can orbit there.

The Doppler effect (Christian Doppler, 1842) makes light from an approaching source bluer and from a receding one redder. At these speeds it needs the relativistic formula:

δ = 1 / (γ · (1 − β · n))     γ = 1 / √(1 − β²)

where β · n is the part of the velocity pointing toward the observer. The γ factor is time dilation — moving clocks run slow — so even gas moving sideways is slightly reddened. Gravitational redshift adds another factor: light climbing out of the gravity well loses energy, g = √(1 − 1/r) (0.82 at the ISCO).

The shader blends the physical shift with "no shift" using the doppler knob (default 0.6), then uses it twice: the color temperature is multiplied by shift (bluer when approaching), and the brightness by shift³. The cube is relativistic beaming: the quantity Iν/ν³ (intensity per frequency over frequency cubed) is unchanged along a ray, so a source whose light arrives with every frequency multiplied by shift looks shift³ times brighter. That is why one side of the disk blazes and the other fades. The film Interstellar deliberately left this asymmetry out to keep the image readable; doppler = 0 reproduces that choice.

float beta = min(sqrt(0.5 / max(r - kHorizon, 0.05)), 0.95);
vec3 velocity = normalize(vec3(-p.z, 0.0, p.x)) * beta;   // along the orbit
vec3 toObserver = -normalize(rayDir);                   // backwards along the traced ray (§1)
float doppler = 1.0 / (gamma * (1.0 - dot(velocity, toObserver)));
float gravity = sqrt(max(1.0 - kHorizon / r, 0.0));
float shift = mix(1.0, doppler * gravity, uDoppler);
Left: one ring of gas seen from above, at temperature 1, colored as the camera at the bottom sees it; arrows show the orbital velocity. Right: around the orbit, the Doppler factor δ, the redshift g, the blended shift and the brightness factor shift³, on a logarithmic scale. Move the ring inward and set doppler to 1: the brightness ratio between the two sides climbs into the tens. Raise the camera to 90° and the asymmetry disappears — the gas no longer moves toward or away from you.

9 · The lensed sky

A ray that escapes reads the background along its final direction: background(normalize(dir)). Since that direction has been bent, the sky near the shadow is distorted like the view through a thick glass lens — gravitational lensing. Stars slide along arcs around the shadow; a star exactly behind the hole would become a complete circle (an Einstein ring, after Einstein's 1936 paper). Every point of the sky shows up at least twice — once on each side of the hole — and then again, ever thinner, in the photon ring.

The sky itself is procedural:

The demo's ray loop without the disk, on the GPU. With the checkerboard, the upper half of the sky is orange and the lower half blue: notice the blue squares above the shadow and orange ones below — light from behind and beneath the hole bent over the top. Lower lensing to 0 to see the undistorted sky; switch to the real sky to see the stars and nebula band of the demo.

The algorithm, step by step

The complete scene, in order, with each step linked to the idea above that explains it. It is a single fullscreen pass: one fragment shader invocation per pixel.

// CPU, once per frame (BlackHoleScene::render)
incl  = (9° + 2°·sin(0.2 t)) in radians
az    = −0.4 + 0.035 t
dist  = 26 · (1 − 0.3·smoothstep(0, 1, t/15.36))
pos   = dist · (cos incl·sin az, sin incl,
                cos incl·cos az)
camera   = lookAt(pos, origin, focal 1.6)
maxSteps = 220 · quality;  stepScale = 1 / quality
pulse    = exp(−5 · seconds since snare)
Everything that changes over time is reduced to a handful of uniforms, all computed directly from t. The camera always looks at the origin, so the hole stays centered (§1). With scale < 1 the pass renders into a smaller target and is stretched with a linear blit.
// per pixel: set up the ray
pos    = camera position
dir    = normalize(fwd·1.6 + right·u + up·v)
h2     = |pos × dir|²       // = b², conserved
escape = |camera| + 20
col = 0;  α = 0;  captured = false
Ray tracing runs backwards from the eye (§1). The squared angular momentum is computed once: a pull toward the center can't change it (§2).
repeat up to maxSteps:
    r = |pos|
    if r < 1: captured = true; break
    if r > escape and moving out: break
    dt   = clamp(0.12·(r − 1), 0.012, 1.5) · stepScale
    prev = pos
    dir += −1.5 · h2 · pos / r⁵ · lensing · dt
    pos += dir · dt
One semi-implicit Euler step of the photon orbit equation, rewritten as a central force (§2, §3). Steps shrink near the horizon. Crossing r = 1 means the pixel belongs to the shadow (§4).
    if prev.y · pos.y < 0:           // crossed y = 0
        hit = lerp(prev, pos) to y = 0
        if inner < |hit.xz| < outer:
            (c, a) = diskSample(hit, dir)
            col += (1 − α) · c
            α   += (1 − α) · a
            if α > 0.99: break
The disk is found by watching for sign changes of y, not by intersecting geometry; later crossings are the arc under the shadow and the photon ring (§4, §5). Layers are composited front to back.
// diskSample, part 1: what the gas is like
T       = (r₀/r)^¾ · (1 − √(r₀/r))^¼ / 0.488
ω       = r^−1.5 · diskSpeed
φ       = atan(z, x) − ω·t
spiral  = φ + 2.5·ln r
q       = (2cos spiral, 2sin spiral, 2.5r)
density = smoothstep(0.25, 0.8,
            0.7·fbm(q) + 0.3·fbm(2.7q + 5))
spots   = Σ₃ exp(−|p − cᵢ(t)|² / 0.35)
          · (0.6 + 1.6·pulse)
Thin-disk temperature peaking at 1 (§6). Turbulence in the frame co-rotating at the Keplerian speed of each radius, wound into spirals (§7). Three hot spots orbit near the inner edge and flare with the snare.
// diskSample, part 2: how it looks from here
β     = min(√(0.5 / (r − 1)), 0.95)
δ     = 1 / (γ · (1 − β·toObserver))
g     = √(1 − 1/r)
shift = mix(1, δ·g, doppler)
edge  = smoothstep(r₀, 1.12r₀, r)
        · smoothstep(outer, 0.65·outer, r)
a     = clamp((1.1·density + spots)·edge) · 0.9
I     = T² · shift³ · (0.4 + 1.6·density + 3·spots)
c     = blackbody(T · shift · temp) · I · 5
return (c · a, a)
Relativistic Doppler factor from the orbital speed, gravitational redshift from the depth in the well, blended by the doppler knob. The shift colors (bluer when approaching) and beams (cube) the light (§8). The returned color is premultiplied by its opacity.
    if jets > 0:                    // off in the demo
        col += (1 − α) · blue · jet(pos) · jets · dt · 0.8
Optional jets along the spin axis, accumulated as emission per unit length (§5 note).
// after the loop
if not captured and α < 0.99:
    col += (1 − α) · background(normalize(dir))
out = col                       // linear HDR
The sky is looked up along the bent final direction: gravitational lensing (§9). The HDR result then goes through the demo's post chain: bloom, chromatic aberration, ACES tone mapping, vignette and grain (default settings, no overrides for this scene).

The real source

These are the files as they are in the demo's repository. The browser version above uses the same shader code, with the #includes pasted in.

shaders/scenes/blackhole/blackhole.frag fragment shader · the whole scene
src/scenes/blackhole/BlackHoleScene.cpp C++ · per frame
shaders/common/camera.glsl pinhole camera rays
src/scene/Camera.h C++ · lookAt
shaders/common/sky.glsl procedural stars
shaders/common/noise.glsl excerpt · hash, value noise, fbm

Glossary

Fragment shader
A program run once per covered pixel (fragment); it decides the pixel's color. This whole scene is one.
Uniform
A value set by the CPU before a draw and shared by every pixel of that draw (camera, time, disk radii…).
Ray tracing
Computing an image by following, for each pixel, the path light would take to reach the eye — traced backwards from the eye.
Euler's method
Following a differential equation in small steps: compute the rate of change here, take a small step with it, repeat. Semi-implicit Euler updates the velocity first and moves with the new velocity.
Schwarzschild radius, event horizon
rs = 2GM/c²: the radius of no return around a non-spinning black hole. The unit of length on this page.
Impact parameter b
The distance by which a ray would miss the hole if it went straight. For a unit-length direction it equals the angular momentum h.
Angular momentum h
|position × direction|; constant along a path under a pull that always points at the center.
Photon orbit equation, Binet's equation
The equation of a light path around a Schwarzschild hole, u'' + u = 1.5 rs u² with u = 1/r; Binet's equation gives the path of a particle under a central force in the same form, which turns the light path into a force: −1.5 h² x / r⁵.
Photon sphere
Radius 1.5 rs, where light can circle the hole on an unstable orbit.
Shadow
The dark region of the image made of all captured directions; radius 3√3/2 ≈ 2.6 rs, larger than the horizon.
Photon ring
The thin bright ring at the shadow's edge, made of rays that loop around the photon sphere before escaping.
Accretion disk
Gas orbiting a black hole and slowly spiraling in, heated by friction until it glows.
ISCO
Innermost stable circular orbit: 3 rs for a Schwarzschild hole. The disk's inner edge.
Keplerian rotation
Orbital angular speed falling as r−3/2, so inner gas laps outer gas and patterns wind into spirals.
Blackbody
An idealized hot object whose glow color depends only on its temperature: red, orange, white, blue-white.
Doppler factor δ
The ratio of received to emitted frequency for a moving source; above 1 when it approaches.
Relativistic beaming
The brightening of an approaching source (and dimming of a receding one), here by shift³.
Gravitational redshift
The loss of energy (reddening) of light climbing out of a gravity well: factor √(1 − rs/r).
Gravitational lensing, Einstein ring
The distortion of the background by bent light; a source exactly behind the lens appears as a ring.
Front-to-back compositing
Adding layers nearest first, each one weighted by how much of it is still visible through the layers in front: col += (1 − α)·c.
Value noise / fbm
Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
HDR
High dynamic range: keeping brightness values above 1.0 until a final tone-mapping step.
Tone mapping (ACES)
A curve compressing HDR brightness into the screen's range while keeping highlights pleasant.
Bloom
Letting very bright areas glow into their neighborhood, like light scattering in a lens or eye.

Part of the Space Travel documentation. Direction & design: blacklizard · Code: Claude. The figures on this page are computed live in your browser; nothing is pre-rendered except the header image.

← 13 · Galaxy  ·  All scenes  ·  15 · Credits →