← Blog

2026-10-23

That time I made a blog, added a few stars... and ended up ray-tracing black holes

This is a long a** post describing equations that define the animations and visual elements of this website's background. Feel free to skim straight to the demo boxes. If something looks cool and you wish to know how it's made, read the details in the corresponding section.

Text-to-speech tools, such as Read Aloud or Siri, read each equation as plain words instead of symbols.

At first the stars were meant to just twinkle behind the text. They learned to dodge the pointer, and it went to their heads: they formed a galaxy. The galaxy dispersed into comets and meteors of reality rejecting colors even though there’s no atmosphere to burn in. They needed somewhere to land, and the obvious choice is for them to crash into a black hole. There are two: the first doesn’t spin, and the second spins at 99.7% of the fastest rate possible, with every pixel’s light ray traced through its warped spacetime at whatever resolution your GPU agrees to.

You can go through the whole sequence on this page:

  1. Hold the background (anywhere outside the text column) for about a second and a half: a galaxy forms where you pressed and stays. A quick click sends it back to the stars.
  2. Scroll to tilt it. Keep scrolling and it gets stranger.
  3. Press S to set off the comet/meteor shower.
  4. Press S again to feed it to a black hole. During the close-up, B switches between the two black holes.
  5. Press S a third time to pull back out to the galaxy.

While the galaxy is on screen:

Most sections have a live demo that shows how the colored symbols in the text relate to what is being drawn, labeling each with its live value. The demo’s Equations button turns the colors and labels off. A vector is written with an arrow, as in a position r⃗=(x,y)\vec{r} = (x, y), and a unit vector (of length 1) as r^\hat{r}. Plain letters are single numbers, except the Pauli matrices and the indexed quantities of relativity, which their sections introduce. A field is a quantity with a value at every point, such as a pattern of noise or the curvature of spacetime. While I won’t derive everything from scratch, the included references point at where complete derivations for some key results can be found.

The idle sky

Before anything is pressed, the background is a 2D canvas of stars that twinkle, scatter from the pointer and now and then gather into a diagram. Each star’s opacity swings around a base value on a sine of its own rate, so no two stars blink together.

Each star carries three vectors in screen pixels: its home h⃗\vec{h}, its position r⃗\vec{r} and its velocity v⃗\eq{v}{\vec{v}}, in pixels per frame. Every frame the code updates the velocity and then the position, with ←\leftarrow meaning “replace with” and Δt\Delta t, one frame, the time step:

v⃗←μ (v⃗+F⃗ Δt),r⃗←r⃗+v⃗ Δt+λ (g⃗−r⃗).\eq{v}{\vec{v}} \leftarrow \eq{mu}{\mu}\,(\eq{v}{\vec{v}} + \eq{F}{\vec{F}}\,\Delta t), \qquad \vec{r} \leftarrow \vec{r} + \eq{v}{\vec{v}}\,\Delta t + \eq{lambda}{\lambda}\,(\eq{g}{\vec{g}} - \vec{r}).

The velocity update adds the pointer’s force F⃗\eq{F}{\vec{F}} over one step to the velocity and multiplies the sum by μ<1\eq{mu}{\mu} < 1, a friction. The position update moves the star by its velocity over one step, and a spring pulls it a fraction λ\eq{lambda}{\lambda} of the remaining way to its anchor g⃗\eq{g}{\vec{g}}, which is normally its home.

The force pushes straight away from the pointer and falls off linearly to zero at a radius R\eq{R}{R}, beyond which it vanishes:

F⃗=F0 R−dR r^(d<R),\eq{F}{\vec{F}} = F_0\,\frac{\eq{R}{R} - d}{\eq{R}{R}}\,\hat{r} \qquad (d < \eq{R}{R}),

where dd is the star’s distance from the pointer, r^\hat{r} the unit vector pointing away from it and F0F_0 the force at the pointer itself.

Friction caps the speed: under a steady push the velocity grows until friction takes away as much each frame as the force adds. A pointer slower than that cap keeps the stars ahead of it in reach and herds them along, and a quick one passes through them and barely moves them. Once the pointer has gone, friction soon stops the star and the spring takes it home. Each frame the spring closes the same fraction of the remaining distance, so the star moves fastest far from home and slows as it arrives.

The diagrams are six quantum circuits that form in a side margin, the first as soon as the page loads. Each is a set of points, and the stars nearest its center swap their anchor g⃗\eq{g}{\vec{g}} from home to one point each. The same spring, with a larger λ\eq{lambda}{\lambda}, pulls them into the drawing. Taking the nearest stars keeps every flight short, and the diagram condenses out of the stars around it. It holds until the pointer comes close or a scroll carries it out of view. Then each star’s anchor returns to its home, the stars fly back, and the next diagram waits until they have all arrived.

The idle starfield. Sweep the pointer through the field. Form a diagram starts one now, centered in the frame instead of in a margin.

The galaxy

The idle sky updates each star on the CPU in screen pixels, which suits a sparse field in two dimensions. The galaxy has thousands of stars that must follow curved arms, turn in three dimensions and react to scrolling, so it divides the work. A star stores no position of its own, only the curve it rides, called its stroke, and its progress along that stroke. The CPU builds the strokes once. Each frame the GPU draws the stars with two small programs, called shaders: a vertex shader finds each star’s place on its stroke and so on the screen, and a fragment shader colors each pixel the star covers. Values that are the same for every star in a draw, such as the time, are called uniforms.

Strokes as paths

Each arm is a logarithmic spiral: its radius rr grows exponentially with the angle θ\theta, from r0r_0 at θ=0\theta = 0, at a rate that drops from b1b_1 to b2b_2 at a break radius r1\eq{r1}{r_1}, which the arm reaches at angle θ1\theta_1:

r(θ)={r0 eb1θ,r≤r1,r1 eb2(θ−θ1),r>r1,b1>b2.r(\theta) = \begin{cases} r_0\, e^{b_1 \theta}, & r \le \eq{r1}{r_1}, \\ \eq{r1}{r_1}\, e^{b_2 (\theta - \theta_1)}, & r > \eq{r1}{r_1}, \end{cases} \qquad b_1 > b_2.

The steep inner segment packs a compact core, and the shallow outer one winds the arm more tightly, in a long curve around it. Three arms start a third of a turn apart, a fourth stroke runs a second outer spiral alongside one of them, and three short spirals fill the core.

A star needs a position at every progress value s∈[0,1]\eq{s}{s} \in [0, 1] along its stroke. The stroke’s points are joined by a Catmull-Rom curve, a smooth curve through all of them. It traces C⃗(u)\vec{C}(\eq{u}{u}) as a parameter u\eq{u}{u} runs from 0 to 1, with an equal share of u\eq{u}{u} for each segment between two points. That parameter does not measure distance: a star moving at a constant rate in u\eq{u}{u} would speed up and slow down along the stroke. The path code therefore measures arc length,

L(u)=∫0u∣C⃗′(w)∣ dw,s=L(u)L(1),L(\eq{u}{u}) = \int_0^{\eq{u}{u}} \lvert \vec{C}'(w) \rvert\, dw, \qquad \eq{s}{s} = \frac{L(\eq{u}{u})}{L(1)},

numerically from a dense polyline, and resamples the curve at equal steps of s\eq{s}{s}. A constant rate in s\eq{s}{s} is then a constant speed.

Each star draws a seed qq uniformly from [0,1][0, 1], which is then warped by an amount f\eq{f}{f} between 0 and 1:

s=q+f2πsin⁡2πq,dsdq=1+fcos⁡2πq.\eq{s}{s} = q + \frac{\eq{f}{f}}{2\pi}\sin 2\pi q, \qquad \frac{d\eq{s}{s}}{dq} = 1 + \eq{f}{f} \cos 2\pi q.

Where ds/dqd\eq{s}{s}/dq is small, many seeds land on a short stretch of stroke. The derivative is smallest at q=1/2q = 1/2, which crowds stars into the middle of each stroke and thins them toward both ends.

Every stroke flows inward by advancing its seeds at its own rate, q˙=c\dot q = c, and inner and outer strokes lap each other. Progress along the stroke then moves at s˙=c (1+fcos⁡2πq)\dot{\eq{s}{s}} = c\,(1 + \eq{f}{f} \cos 2\pi q), slowest in the middle, so stars slow down where they crowd and the crowd stays in place. Far from the center, stars also slow down by a factor

κ(r)=1−12 smoothstep(ra,rb,r),\eq{kappa}{\kappa}(r) = 1 - \tfrac{1}{2}\,\mathrm{smoothstep}(r_a, r_b, r),

where rr is the star’s distance from the center and smoothstep(a,b,x)=y2(3−2y)\mathrm{smoothstep}(a, b, x) = y^2 (3 - 2y), with y=(x−a)/(b−a)y = (x - a)/(b - a) clamped to [0,1][0, 1], rises from 0 at aa to 1 at bb with zero slope at both ends. Between the radii rar_a and rbr_b the speed falls to half. The outer stars trail the inner ones, and the galaxy never turns as a rigid body.

The resampled points of every stroke go into a small texture, an image the shaders can read. The vertex shader computes a star’s position from that texture, the star’s seed and a few uniforms such as the time, the scroll and the intro progress, so steady motion sends the GPU at most one number per star each frame.

The galaxy's strokes. Curve parameter spaces stars by the curve parameter, which bunches them where the stroke's points lie close together. Density warp sets f: higher values crowd the stars toward the middle of each stroke.

Stars on the GPU

Each star twinkles on a sine of its own frequency, as in the 2D field. The galaxy renders into a high-dynamic-range image, whose brightness can exceed what the screen shows. A bloom pass blurs the brightest stars into a glow at several scales, and a filmic tone curve compresses the result into the screen’s range.

When the galaxy appears, every star starts at a random point inside a circle of radius rmaxr_\text{max}. Drawing the radius as r=rmaxwr = r_\text{max}\sqrt{w} with ww uniform on [0,1][0, 1] spreads the stars evenly over its area: the fraction inside radius rr is then (r/rmax)2(r/r_\text{max})^2, exactly the fraction of the area. Each star then slides to its place on its stroke in a time window of its own within the first few seconds of the intro clock tintro\eq{tintro}{t_\text{intro}}, swinging around the center by an angle that rises and falls back to zero on the way, so the field spirals into place instead of dropping straight onto the arms.

Scrolling drives the rest. As the scroll progress χ\eq{chi}{\chi} goes from 0 to 1, the galaxy tilts and then eases back flat. Later in the scroll the stars leave their strokes: each moves a share σ\eq{sigma}{\sigma} of the way from its place on its stroke to a new position,

p⃗=p⃗stroke+σ (p⃗new−p⃗stroke),\vec{p} = \vec{p}_\text{stroke} + \eq{sigma}{\sigma}\,(\vec{p}_\text{new} - \vec{p}_\text{stroke}),

and σ\eq{sigma}{\sigma} rises smoothly from 0 to nearly 1 by the end of the scroll while the galaxy dims. One star in four ends up scattered around the galaxy. The other three form a DNA double helix that winds along an S through the galaxy’s center and turns slowly: two backbones a little under half a turn apart, as in DNA, with base pairs between them.

Some effects run on the CPU and need to know where the stars are: the pointer’s push, the lens flares on the brightest stars, and the meteor shower, which launches from the stars’ current positions. A CPU mirror of the vertex shader therefore recomputes every star’s position with the same formulas, and the two copies have to agree.

For a while they did not, and the push hit the wrong stars: a sweep of the pointer kicked stars it never passed over and missed the ones it did. The shader took each star’s scatter seeds from a popular GPU hash, the fractional part of 43758.5453sin⁡z43758.5453 \sin z, with zz built from two of the star’s attributes. Its author is unknown: it has been copied from shader to shader for at least fifteen years, and Google’s Skia graphics library calls it a function “floating around on the internet”. The large factor amplifies the last digits of the sine. Those are exactly the digits where the GPU’s 32-bit sine and the CPU’s 64-bit one disagree, so the shader and the mirror drew different seeds and put the stars in different places. The seeds are now drawn once on the CPU and passed to the shader with each star.

When the pointer moves by Δp⃗\Delta \vec{p}, every star near its path gets a kick to its velocity along that motion,

J⃗=J0m (1−smoothstep(0,R,d))2 Δp⃗,\eq{J}{\vec{J}} = \frac{J_0}{m}\,\big(1 - \mathrm{smoothstep}(0, \eq{R}{R}, d)\big)^2\,\Delta \vec{p},

where dd is the star’s distance from the pointer’s path, R\eq{R}{R} the reach of the push, J0J_0 its strength and mm a mass that grows with the star’s size. The kick adds to the star’s velocity v⃗\eq{v}{\vec{v}}, and its offset x⃗\eq{x}{\vec{x}} from its place in the galaxy evolves as

x⃗˙=v⃗−x⃗t0,v⃗˙=−γv⃗,γ=γ0m,\dot{\eq{x}{\vec{x}}} = \eq{v}{\vec{v}} - \frac{\eq{x}{\vec{x}}}{t_0}, \qquad \dot{\eq{v}{\vec{v}}} = -\gamma \eq{v}{\vec{v}}, \qquad \gamma = \frac{\gamma_0}{\sqrt{m}},

where dots indicate time derivatives. The velocity dies away at the damping rate γ\gamma, and the offset follows it back to zero over a time t0t_0. Mass sets how a star takes a push: a light one gets a large kick and loses it quickly, a heavy one gets a small kick and glides on longer. Both equations are linear and have an exact solution, and each frame moves every star along it by the time that actually passed. The push therefore plays out at the same pace at any smooth frame rate. The 2D field instead takes one fixed step per frame and runs twice as fast on a 120 Hz screen as on a 60 Hz one.

The WebGL galaxy. Drag to spin, sweep the pointer to push stars, and move the Scroll slider to tilt and scatter the galaxy. Replay intro runs the spiral-in again, and Pauli gates steps through X, Y and Z and back to the galaxy.

The Pauli gates

X, Y and Z gather the galaxy’s stars into that Pauli gate’s matrix, with ii the imaginary unit:

X=(0110),Y=(0−ii0),Z=(100−1).X = \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix}, \qquad Y = \begin{pmatrix} 0 & -i \\ i & 0 \end{pmatrix}, \qquad Z = \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix}.

Each matrix is written in a single-line stroke font whose strokes are resampled by arc length like the galaxy’s strokes. The stars are shared out over the strokes by length, so every glyph comes out evenly dense, and each star is given a point on its stroke, with a small random offset across the stroke that gives each line some width. A spring pulls the star from its place in the galaxy toward its point, and the gap shrinks as e−kte^{-kt}. The spring’s strength kk rises from zero as the gate forms while the points grow from the screen’s center to full size, so the gate grows out of the galaxy.

Once the gate has formed, the points walk along the strokes and the spring keeps each star on its point. A point with phase φ\eq{phi}{\varphi}, a fraction of a lap, on a stroke of length L\eq{L}{L}, moving at speed cc, sits at time tt at arc fraction

s={frac⁡(φ+ct/L),closed stroke,1−∣1−2frac⁡(φ+ct/(2L))∣,open stroke,\eq{s}{s} = \begin{cases} \operatorname{frac}(\eq{phi}{\varphi} + ct/\eq{L}{L}), & \text{closed stroke}, \\ 1 - \lvert 1 - 2 \operatorname{frac}(\eq{phi}{\varphi} + ct/(2\eq{L}{L})) \rvert, & \text{open stroke}, \end{cases}

where frac⁡(x)=x−⌊x⌋\operatorname{frac}(x) = x - \lfloor x \rfloor is the fractional part. On a closed stroke, φ+ct/L\eq{phi}{\varphi} + ct/\eq{L}{L} counts laps, and its fractional part is how far along the current lap the star is. Because the stroke’s end joins its start, the star circles it without a jump. An open stroke’s ends are apart, so the star walks out and back instead: with u=frac⁡(φ+ct/(2L))\eq{u}{u} = \operatorname{frac}(\eq{phi}{\varphi} + ct/(2\eq{L}{L})) the share of the current lap done, 1−∣1−2u∣1 - \lvert 1 - 2\eq{u}{u} \rvert runs from 0 to 1 and back to 0 as u\eq{u}{u} runs through a lap. That round trip covers the stroke twice, hence the 2L2\eq{L}{L}.

The pointer can still knock stars off their points, and the spring draws them back. Releasing the gate lets the stars return to the galaxy.

Stars walking the Y gate's strokes, with each marked star's arc fraction plotted below. The star on the 0 walks a closed stroke, and its arc fraction is a sawtooth. The one on the minus sign walks an open one out and back, so the fold turns the same sawtooth into a triangle. Flow pauses the walk.

Meteors, comets and their trails

So far every star has stayed in the galaxy or on a gate. Pressing S breaks a fraction of them free as a shower of meteors and comets. Each object starts where its star was at that moment, read from the CPU mirror, and flies in a straight line. All the objects travel in roughly the same direction. The scene uses an orthographic camera, which has no perspective of its own, so the code applies a per-object depth D\eq{D}{D} by hand to the sprite size S\eq{S}{S} and the screen speed V\eq{V}{V}:

S=S0D,V=V0D,\eq{S}{S} = \frac{S_0}{\eq{D}{D}}, \qquad \eq{V}{V} = \frac{V_0}{\eq{D}{D}},

where S0S_0 and V0V_0 are the size and speed at depth 1. A pinhole camera produces the same pair: an object twice as far away looks half as large and crosses the screen at half the speed. An object that leaves one edge respawns at the other at a new height.

Most of the shower is white shooting stars. A few objects, picked at random, take the galaxy’s rare star colors (blue, purple and a single orange) and burn as meteors with smoky trails. Three comets fly with them. The GPU draws each object as a sprite, a small square patch of the screen, and the fragment shader sets the brightness of every pixel in it. It lights a thin streak along the direction of motion and leaves the rest of the square dark, so the square’s edges never show. To place the streak, the shader measures each pixel’s position p⃗\vec{p} from the sprite’s center, in sprite widths, along the direction of motion d^\eq{d}{\hat{d}} and across it, along d^⊥\eq{d}{\hat{d}_\perp}:

a=p⃗⋅d^,c=p⃗⋅d^⊥.\eq{a}{a} = \vec{p} \cdot \eq{d}{\hat{d}}, \qquad \eq{c}{c} = \vec{p} \cdot \eq{d}{\hat{d}_\perp}.

The coordinate a\eq{a}{a} runs from −12-\tfrac12 at the tail to 12\tfrac12 at the front. A shooting star’s brightness rises from zero at the tail as a power of a+12\eq{a}{a} + \tfrac12 to a Gaussian head near the front, and a Gaussian in c\eq{c}{c} narrows it to a thin line. A taper takes it to zero before the edge of the square, which keeps the sprite’s outline from showing.

A halo around the head splits into color channels. The shader evaluates one Gaussian gg three times, shifted across the path by a small ϵ\epsilon, different for each object, and sends one copy to each color channel, scaled by that channel of the object’s color (kR,kG,kB)(k_R, k_G, k_B):

(R,G,B)=(kR g(p⃗−ϵd^⊥),  kG g(p⃗),  kB g(p⃗+ϵd^⊥)).(R, G, B) = \big(k_R\, g(\vec{p} - \epsilon \eq{d}{\hat{d}_\perp}),\; k_G\, g(\vec{p}),\; k_B\, g(\vec{p} + \epsilon \eq{d}{\hat{d}_\perp})\big).

Near the center the three copies overlap and return the object’s own color. At the rim one side keeps mostly red and the other mostly blue, the fringe that chromatic aberration leaves around a bright point. The scene has no lens, and the shader adds its flaw anyway. The whole shower blends additively: where two objects cross, their light adds, and the draw order has no effect.

Left: a shooting star, magnified inside its square sprite, from the site's shader. The streak runs along d from a = −½ to ½ and fades out before the square's edge, and the halo's red and blue copies sit ε to either side of the path. Turn rotates its heading, and Halo split sets ε anywhere in the range the shower uses. Right: the same star at depths 1, 2 and 4, half as large and half as fast each time the depth doubles.

Trails that stay in the sky

Meteors and comets replace the streak with a head, a coma (the soft glow around it) and a trail, drawn by one shared function in which they differ only by a stretch factor. A comet’s sprite is larger by that factor, and the shader shrinks the head and the trail’s width back by the same factor, so only the trail lengthens. A meteor’s short, turbulent trail reads as flame, a comet’s long, even one as a tail. Astronomers separate them by what they are: a comet is an icy body in orbit, a meteor a grain burning up in an atmosphere.

The trail is a function of the distance bb behind the head along −d^-\eq{d}{\hat{d}}, the offset cc across the path, and the head’s travel T\eq{T}{T}, how far it has flown so far. With ℓ\ell the trail’s length, the age α=min⁡(b/ℓ,1)\eq{alpha}{\alpha} = \min(b/\ell, 1) runs from 0 at the head to 1 at the end, since smoke farther back left the head earlier. The trail’s width and center line are powers of the age:

w(α)=w0+Wαγ,c0(α)=±Cαη.\eq{w}{w}(\eq{alpha}{\alpha}) = w_0 + W \eq{alpha}{\alpha}^{\gamma}, \qquad \eq{c0}{c_0}(\eq{alpha}{\alpha}) = \pm C \eq{alpha}{\alpha}^{\eta}.

Here w0w_0 is the width at the head, WW the width the trail gains by its end and CC how far its center line has bent by then. At γ=12\gamma = \tfrac12 the smoke spreads like a diffusing puff, whose width grows as the square root of time, and at γ=1\gamma = 1 it spreads linearly, as a turbulent jet does. Each trail picks γ\gamma at random from a range that spans both. An exponent η\eta above 1 keeps the trail straight as it leaves the head and bends it further back, toward a random side.

Inside that outline, the smoke’s texture is noise sampled in coordinates that follow the trail’s shape, with scales s1s_1 along the path and s2s_2 across it and a drift factor κ\eq{kappa}{\kappa}:

ξ⃗=(s1 (b−κT),    s2 c−c0w).\vec{\xi} = \Big( s_1\,(b - \eq{kappa}{\kappa} \eq{T}{T}),\;\; s_2\,\frac{c - \eq{c0}{c_0}}{\eq{w}{w}} \Big).

Dividing by w\eq{w}{w} stretches the noise across the path as the trail widens, and the smoke’s features grow with it. The first coordinate sets how the smoke moves across the sky. Measured in sky positions along the path, the head started at X0X_0 and now sits at Xh=X0+TX_h = X_0 + \eq{T}{T}, and a point bb behind it sits at X=Xh−bX = X_h - b. Substituting,

b−κT=X0−X−(κ−1) T.b - \eq{kappa}{\kappa} \eq{T}{T} = X_0 - X - (\eq{kappa}{\kappa} - 1)\,\eq{T}{T}.

For κ=1\eq{kappa}{\kappa} = 1 the noise coordinate depends only on the sky position XX. The smoke stays where the head released it and the head draws away from it, which is how a real trail behaves in still air. With T\eq{T}{T} frozen, the pattern would instead ride along with the head as a painted streak. Each trail picks a random κ\eq{kappa}{\kappa} close to 1, so its smoke drifts slowly after the head (κ<1\eq{kappa}{\kappa} < 1) or streams back (κ>1\eq{kappa}{\kappa} > 1).

The substitution requires bb and T\eq{T}{T} in the same unit. The shader measures bb in the sprite’s own coordinates, so the CPU accumulates the object’s screen speed VV divided by its sprite size over time tt:

T=∫VS dt.\eq{T}{T} = \int \frac{V}{S}\, dt.

The speed and the sprite size share the factor 1/D1/D, so the integrand does not depend on depth, and near and far objects shed smoke at the same rate relative to their size.

The blue, purple and orange meteors, seen by a fixed camera. With Sky anchor on, the smoke stays where it was released while the heads fly on. With it off, the travel T freezes and the smoke rides along with the heads. Dust, Ion and Filament switch the trail's three smoke layers.

Smoke layers

One sample of gradient noise nn, a smooth random field over the plane, gives smooth blobs. The shader folds them by domain warping [1]: two samples form a displacement e⃗\eq{e}{\vec{e}}, and the final sample ν\eq{nu}{\nu} is read at the displaced point,

e⃗=(n(ξ⃗), n(ξ⃗+ξ⃗0)),ν=n(ξ⃗+λe⃗),\eq{e}{\vec{e}} = \big(n(\eq{xi}{\vec{\xi}}),\, n(\eq{xi}{\vec{\xi}} + \vec{\xi}_0)\big), \qquad \eq{nu}{\nu} = n(\eq{xi}{\vec{\xi}} + \eq{lambda}{\lambda} \eq{e}{\vec{e}}),

where the offset ξ⃗0\vec{\xi}_0 decorrelates the two samples and λ\eq{lambda}{\lambda} sets how far the warp displaces the final one. Where e⃗\eq{e}{\vec{e}} changes quickly, neighboring pixels read distant parts of the noise, and the blobs fold into curls and tendrils. The warp also bends the trail: its first component moves the center line by δ=ex w h(α)\delta = e_x\, \eq{w}{w}\, h(\eq{alpha}{\alpha}), where hh rises from zero near the head, so the smoke meanders more the older it is.

Left: one sample of the site's noise, with arrows for the displacement λe at points across it. Right: the warped sample ν, which at each point reads the left image where that point's arrow ends. The ringed pair follows the pointer. Warp sets λ, and Flow pauses the drift.

Three layers are drawn from this field, named after a real comet’s tails: a broad, curving tail of dust and a thin, straight tail of ions with fine filaments along it. The dust is a Gaussian across the trail, centered on the curled line c0+δ\eq{c0}{c_0} + \delta, and ν\eq{nu}{\nu} breaks it into lumps. The ion strand and the filament are much thinner Gaussian threads that follow the dust’s curl only partly and stay unbroken where the dust breaks up. The filament leans away faster, so it peels off the ion strand as the trail ages.

The three layers are summed with random weights per trail and multiplied by a fade (1−α)ψ(1 - \eq{alpha}{\alpha})^{\psi}, which takes the trail to zero at its end. The dust’s brightness alone is also multiplied by a pattern of bright bands spaced along the trail, as real dust tails show:

P=1−ϵP+ϵPsin⁡ ⁣(fP(b−κPT)+φ),\eq{P}{P} = 1 - \epsilon_P + \epsilon_P \sin\!\big(f_P (b - \kappa_P \eq{T}{T}) + \varphi\big),

with depth ϵP\epsilon_P, spacing 2π/fP2\pi / f_P and phase φ\varphi. By the substitution above, the crests move over the sky at 1−κP1 - \kappa_P of the head’s speed. With κP\kappa_P below 1 they drift forward more slowly than the head, and relative to the head they run back down the trail.

A comet, enlarged, with the camera following its head past fixed stars: the same trail function as the meteors with a larger stretch factor. Dust, Ion and Filament switch its three smoke layers, and Sky anchor works as for the meteors. New comet draws a new seed and color.

From the shower to a black hole

A second S ends the shower by pulling it into a black hole. Every object gets a target orbit radius, spaced with the golden ratio so the orbits spread evenly across the disk instead of bunching up, and an angular speed that follows Kepler’s third law from a speed ω0\omega_0 at radius r0r_0,

ω(r)=ω0(r0r)3/2,\eq{omega}{\omega}(r) = \omega_0 \left(\frac{r_0}{r}\right)^{3/2},

clipped near the center, where Kepler would ask for an infinite speed. Each object’s radius relaxes toward the target.

The objects gather into the accretion disk, the gas orbiting the hole. Gravity bends light, so a camera near the hole sees that disk distorted, its far side lifted over the hole. Each object is drawn where the camera sees its point of the disk.

As an object settles onto its orbit, it hands its mass to a coarse polar grid. Its sprite fades out, and its light passes to a finer map of the disk’s glow, so the object joins the disk as a bright knot that turns with the gas and blends in over a few seconds. While it spirals in, it also paints a fading streak along its path into the same map, so fast objects trail glowing filaments through the disk. The grid holds the mass and decides where the disk is, and the map holds the light that makes the knots and streaks glow.

The grid then turns with the disk: the same law ω(r)\eq{omega}{\omega}(r), with the smaller ω0\omega_0 at which the Schwarzschild model’s gas flows. In a frame of duration Δt\Delta t, each ring shifts by ω(r) Δt\eq{omega}{\omega}(r)\,\Delta t in angle, a fraction δ\eq{delta}{\delta} of a sector, and linear interpolation gives each sector jj of the ring, holding mass mj\eq{m}{m_j}, its new mass mj′\eq{m}{m_j}':

mj′=(1−δ) mj+δ mj−1.\eq{m}{m_j}' = (1 - \eq{delta}{\delta})\, \eq{m}{m_j} + \eq{delta}{\delta}\, \eq{m}{m_{j-1}}.

Each cell’s mass splits between itself and its neighbor, so every ring keeps its total, and a ring that caught nothing stays empty. Both black-hole models draw their disk from this grid, which puts the disk where the matter arrived.

The camera zooms in on the hole and holds the close-up until the next S. The hole and its disk are drawn in a layer of their own, and the pullback takes that layer’s magnification z\eq{z}{z} from its close-up value z0z_0 down by a total factor ZZ,

z(ρ)=z0 Z−ρ,ρ=q2(3−2q),\eq{z}{z}(\eq{rho}{\rho}) = z_0\, Z^{-\eq{rho}{\rho}}, \qquad \eq{rho}{\rho} = \eq{q}{q}^2 (3 - 2\eq{q}{q}),

where q\eq{q}{q} runs from 0 to 1 over the pullback and the smoothstep ρ\eq{rho}{\rho} eases it at both ends. Near the end of the pullback the hole fades out, and the galaxy is left on its own.

During the close-up the galaxy keeps moving behind the hole as its sky. Its stars render into an offscreen texture instead of the screen. The black-hole shader traces each pixel’s ray, and where the ray escapes, it reads that texture where the bent ray points. The sky is magnified along with the view, and so is its motion, which would blur its arms. Its clock runs slower by that magnification instead, which keeps the stars at their usual pace on screen, and speeds back up as the camera pulls out. It’s the only instance of time dilation on the site with an editorial cause.

The full sequence: shower, capture, and the close-up, which holds until Zoom out. Drag to turn the black hole. Schwarzschild and Kerr–Newman switch between the two models, and Replay starts over. Only the capture differs between them, in where each model's camera sees the disk.

Two black holes

Successive captures take the two models in turn, and B switches between them during the close-up. They share everything above: the captured-matter grid, the lensed galaxy sky and the pullback. The Schwarzschild model reads its light paths around a hole without spin from a table of the exact orbit equation, computed once. The Kerr–Newman model integrates the geodesic equations for each ray around a hole with spin and charge, and per traced pixel it costs about sixty times as much.

Tracing and marching

In computer graphics, ray tracing sends a straight line from the camera through each pixel and solves for the first surface it meets. A sphere or a triangle gives an equation in the distance along the line, and its smallest positive root is the hit. Around a black hole neither part works. Light bends, so no formula gives where a ray is after a given distance, and in the Kerr–Newman model the disk is gas, with no surface to hit.

The Kerr–Newman model therefore follows each ray from the camera in steps, each advancing its position and direction a short distance by its equation of motion. Graphics calls stepping along a ray ray marching and uses it for fog, smoke and other volumes, where a ray gathers light from every point it passes. Relativists use ray tracing for the whole procedure of following light back from the camera through curved spacetime, whatever method computes the path. GYOTO, a code that renders black holes, calls itself “a general relativistic ray-tracing code” [2].

Two choices set what a model computes: how it finds the path, and whether its matter is a surface to intersect or a volume to sample, at a cost that grows with the length of gas a ray crosses. The Schwarzschild model’s disk is a thin slab, a surface. Its rays are looked up instead of stepped, and each crossing of the slab is solved for directly: classic ray tracing on a curved path. The Kerr–Newman model’s disk is a volume, sampled at intervals along each straight segment between its steps: tracing for the path, marching for the matter.

Whichever way a model finds its gas, both accumulate light along a ray the same way. Walking from the camera outward, the ray gathers a color CC and an opacity AA, color channel by color channel, and each sample of gas with color cic_i and opacity aia_i adds only what the gas in front of it has not already covered:

C←C+(1−A) ai ci,A←A+(1−A) ai.C \leftarrow C + (1 - A)\, a_i\, c_i, \qquad A \leftarrow A + (1 - A)\, a_i.

Schwarzschild: a table of light paths

The Schwarzschild model. The lensing bends the far side of the disk up over the shadow and shows its underside below it, and the side turning toward the camera is brighter. Drag to turn it.

Around a non-rotating black hole with Schwarzschild radius RsR_s, light follows paths whose inverse radius u=1/ru = 1/r, as a function of the angle φ\varphi around the hole, obeys

d2udφ2+u=32 Rs u2.\frac{d^2 u}{d\varphi^2} + u = \frac{3}{2}\, R_s\, u^2.

A ray stays in the plane through the hole and its starting direction, and the camera’s distance from the hole is fixed, so one number sets the ray’s path in that plane: its impact parameter b\eq{b}{b}, the distance at which its straight line would pass the hole.

Far from the hole the orbit equation’s right side is small. Without it, u=sin⁡φ/bu = \sin\varphi / \eq{b}{b} is a straight line. To first order the right side adds Rs2b2(1+cos⁡2φ)\tfrac{R_s}{2\eq{b}{b}^2}(1 + \cos^2\varphi), which turns both ends of the line, where u=0u = 0, toward the hole by Rs/bR_s/\eq{b}{b}, a total deflection

α=2Rsb=4GMc2 b,\eq{alpha}{\alpha} = \frac{2 R_s}{\eq{b}{b}} = \frac{4GM}{c^2\, \eq{b}{b}},

Einstein’s light bending for a mass MM, since Rs=2GM/c2R_s = 2GM/c^2. At r=32Rsr = \frac{3}{2} R_s, the photon sphere, light can circle the hole on an unstable orbit. A ray with the critical impact parameter b=332Rs\eq{b}{b} = \frac{3\sqrt{3}}{2} R_s winds ever closer to that orbit, rays with smaller b\eq{b}{b} spiral into the hole, and together they form its shadow, whose edge the critical rays mark.

When the page loads, the code integrates the orbit equation outward from the camera for a few hundred impact parameters, crowded toward the critical value, where a small change in b\eq{b}{b} changes the path most. It stores uu and its derivative along each path over two turns, with the angle at which the path escapes or falls in. The shader computes each pixel’s b\eq{b}{b} and the plane its ray turns in, and reads the path from the table, interpolating between neighboring entries. The ray’s plane meets the disk along a line through the hole, so the ray can cross the disk only where it passes that line, once every half turn. The table gives its radius at each crossing.

My first version stepped each ray through an ordinary central force whose paths are the same, as Riccardo Antonelli’s Starless ray tracer does [3]. I used leapfrog, a simple step-by-step integrator, and a ray took dozens of steps. When I replaced the stepping with the table, it drew the hole about four times faster, since a crossing costs four texture reads.

The table also turned out more accurate. I checked both against the same orbit equation integrated in tiny steps for each pixel’s own ray. The table places the disk’s images and the lensed sky within a fraction of a pixel, and the shadow’s edge is exact, since whether a ray falls in follows from b\eq{b}{b} alone. The leapfrog was off by as much as 21 pixels in the sky near the critical value, where lensing squeezes the whole sky into a sliver, and it missed the thin ring.

The lensed sky follows from where each ray ends up. A ray aimed at an angle θ\eq{theta}{\theta} from the hole, with the camera at a distance DD, passes at b≈Dθ\eq{b}{b} \approx D\eq{theta}{\theta} and is bent back by α\eq{alpha}{\alpha}, so it sees the sky at

β=θ−θE2θ,θE2=2RsD.\eq{beta}{\beta} = \eq{theta}{\theta} - \frac{\eq{thetaE}{\theta_E}^2}{\eq{theta}{\theta}}, \qquad \eq{thetaE}{\theta_E}^2 = \frac{2 R_s}{D}.

This is the lens equation. A star directly behind the hole, β=0\eq{beta}{\beta} = 0, appears on the Einstein ring θ=θE\eq{theta}{\theta} = \eq{thetaE}{\theta_E}, smeared all the way around it. The galaxy’s bright core sits exactly behind the hole and would smear into a thin bright ring, so near β=0\eq{beta}{\beta} = 0 the shader reads a blurred copy of the sky.

Because the disk is far thinner than it is wide, the light through it at each crossing has a closed form: a slab of column density Σ\Sigma, crossed at an angle whose cosine to its normal is μ\eq{mu}{\mu}, has optical depth and opacity

τ=κ Σμ,ai=1−e−τ,\eq{tau}{\tau} = \frac{\kappa\, \Sigma}{\eq{mu}{\mu}}, \qquad a_i = 1 - e^{-\eq{tau}{\tau}},

where κ\kappa is the gas’s absorption per unit column density, so each crossing is one sample aia_i of the sum above. The column density comes from the matter grid at the crossing, shaped by a noise texture.

The gas moves on circular Kepler orbits, and its light arrives shifted in frequency. With g\eq{g}{g} the ratio of observed to emitted frequency, above 1 when the light arrives bluer, a photon whose angular momentum about the disk’s axis is ξ\eq{xi}{\xi} per unit energy has

g=1−3M/r1−Ω ξ,Ω=Mr3,M=Rs2.\eq{g}{g} = \frac{\sqrt{1 - 3M/r}}{1 - \eq{Omega}{\Omega}\, \eq{xi}{\xi}}, \qquad \eq{Omega}{\Omega} = \sqrt{\frac{M}{r^3}}, \qquad M = \frac{R_s}{2}.

Here MM is the mass in units where G=c=1G = c = 1 and Ω\eq{Omega}{\Omega} the gas’s angular speed. The numerator is the time dilation of an orbit at radius rr, and the denominator is the Doppler factor of the gas’s motion along the photon, as in Luminet’s 1979 image of a thin disk around such a hole [4].

The disk fades in across the innermost stable circular orbit, rin=3Rs\eq{rin}{r_\text{in}} = 3 R_s: inside it no circular orbit is stable, and gas plunges into the hole instead of orbiting. Its temperature follows the thin-disk profile [5]

T(r)∝(r−3(1−rin/r ))1/4.\eq{T}{T}(r) \propto \Big(r^{-3}\big(1 - \sqrt{\eq{rin}{r_\text{in}}/r}\,\big)\Big)^{1/4}.

Its color comes from a warm palette indexed by the observed temperature gT\eq{g}{g} \eq{T}{T}, relative to the disk’s peak. Its brightness at frequency ν\nu, the specific intensity IνI_\nu, scales as g3\eq{g}{g}^3, because Iν/ν3I_\nu / \nu^3 is conserved along a ray.

The Schwarzschild disk without the lensing, over a checkered sky. The side turning toward the camera is bluer and brighter, and the ring marks rin. Tilt turns the disk toward edge-on, which lowers μ and makes the same gas more opaque, Gas sets κΣ, and Redshift switches g off. The ringed point follows the pointer.

The gas’s noise is a sum of octaves, layers of detail at finer and finer scales. Where one pixel covers a long stretch of disk, seen edge-on, squeezed against the shadow or sheared near the inner edge, the finest octaves would flicker, so the shader fades out those finer than the jump in noise coordinates from one pixel to the next, as a mipmap (a texture’s stack of ever blurrier copies) does.

Rays near the critical impact parameter circle the hole before leaving, and each extra half turn shows another, thinner image of the disk. Each time a ray’s impact parameter gets ee times closer to the critical value, the ray winds one more radian around the hole. The disk’s next image lies another half turn on, π\pi radians, so it takes rays eπ≈23e^\pi \approx 23 times closer, and each image is about 23 times thinner than the one outside it. Above the shadow in the close-up, one image is a few pixels wide, and the next, a ring thinner than a pixel, lies just outside the shadow. One ray per pixel would catch such a ring only here and there, so a pixel whose ray passes near the critical impact parameter averages a grid of extra rays across itself, which also softens the shadow’s edge.

Left: rays from the camera on the model's photon paths, with captured rays in red. Right: the Schwarzschild shader lensing a grid sky. Accretion disk adds the disk.

A disk that does not wind up

The noise texture that gives the gas its structure has to orbit with the matter, fast near the hole and slowly far out. The obvious way is to read the texture at the angle φ=γ−ω(r) t\varphi = \gamma - \eq{omega}{\omega}(r)\,\eq{t}{t}, where γ\gamma is the point’s angle around the hole, ω(r)\eq{omega}{\omega}(r) the texture’s angular speed at radius rr, the matter grid’s rate, and t\eq{t}{t} the time. Two nearby radii then separate in angle at the rate

∂φ∂r=32 ω(r) tr,\frac{\partial \eq{dphi}{\varphi}}{\partial \eq{dphi}{r}} = \frac{3}{2}\,\frac{\eq{omega}{\omega}(r)\, \eq{t}{t}}{r},

which grows without bound. My first attempt at the disk did exactly this: within seconds of a capture, neighboring radii read unrelated noise, and the disk showed hundreds of concentric bands. Real gas winds too. A noise texture wound this far, though, looks like a vinyl record.

The fix bounds the shear. Two texture layers, k=0k = 0 and 11, each rotate at the local rate for a period P\eq{P}{P} and then restart on fresh noise, half a period apart, with tk\eq{tk}{t_k} the time since layer kk last restarted:

φk=γ−ω(r) tk−ψ(r),tk=P frac(tP+k2).\varphi_k = \gamma - \eq{omega}{\omega}(r)\,\eq{tk}{t_k} - \psi(r), \qquad \eq{tk}{t_k} = \eq{P}{P}\, \mathrm{frac}\Big(\frac{\eq{t}{t}}{\eq{P}{P}} + \frac{k}{2}\Big).

The angle a layer turns is then at most ω(r)P\eq{omega}{\omega}(r) \eq{P}{P}, so its shear stays below 32ω(r)P/r\tfrac32 \eq{omega}{\omega}(r) \eq{P}{P} / r. A fixed term ψ(r)∝r−1/2\psi(r) \propto r^{-1/2} gives the texture a trailing spiral that doesn’t grow. Locally the gas still moves at ω(r)\eq{omega}{\omega}(r).

The layers cross-fade with triangular weights w\eq{w}{w} and 1−w1 - \eq{w}{w}, and each layer restarts at the moment its weight is zero. Mixing two independent noises this way lowers the contrast: the mix’s variance is w2+(1−w)2\eq{w}{w}^2 + (1 - \eq{w}{w})^2 times one noise’s, half at the crossover. Dividing the mix by w2+(1−w)2\sqrt{\eq{w}{w}^2 + (1 - \eq{w}{w})^2} restores it, so the texture looks the same throughout a capture, with no pulse at the restarts.

Left: the texture turned at each radius's orbit rate since the start, winding into rings. Right: the bounded two-layer flow the black hole uses. Time scrubs both, and Play runs them.

Kerr–Newman

The Kerr–Newman model, shown as a still image until you press Start live render. Drag to turn the hole, and set its spin and charge with Spin and Charge.

The second model traces light through the Kerr–Newman metric of a hole with mass MM, spin a\eq{a}{a} and electric charge Q\eq{Q}{Q}. The site runs it at a=0.997M\eq{a}{a} = 0.997M and Q=0\eq{Q}{Q} = 0, the Kerr metric of a fast-spinning uncharged hole. Lengths in this section are in units of the mass, MM, in which the Schwarzschild radius above is Rs=2MR_s = 2M.

A spinning hole’s light paths do not stay in a plane, so a ray’s path depends on more than its impact parameter, and no table like the Schwarzschild model’s holds them. The shader integrates the full equations of motion for every ray. A photon’s state is its position xμ\eq{xmu}{x^\mu} and momentum pμ\eq{pmu}{p_\mu}, four numbers each, one for time and three for space. Relativity marks such four-vectors with an index instead of an arrow. The metric gμνg_{\mu\nu}, a field that gives at every point the rule for measuring lengths and durations, sets how they change. Position and momentum obey Hamilton’s equations, with a Hamiltonian H\eq{H}{H} whose value zero says the photon moves at the speed of light:

H=12 gμν(x) pμpν=0,dxμdλ=∂H∂pμ,dpμdλ=−∂H∂xμ.\eq{H}{H} = \tfrac{1}{2}\, g^{\mu\nu}(x)\, \eq{pmu}{p_\mu} p_\nu = 0, \qquad \frac{d\eq{xmu}{x^\mu}}{d\eq{lambda}{\lambda}} = \frac{\partial \eq{H}{H}}{\partial \eq{pmu}{p_\mu}}, \qquad \frac{d\eq{pmu}{p_\mu}}{d\eq{lambda}{\lambda}} = -\frac{\partial \eq{H}{H}}{\partial \eq{xmu}{x^\mu}}.
Rays from the Kerr–Newman camera passing the hole, each with the plane it started in, shaded red or blue by how far it has left that plane. The ray in the disk plane stays flat at any spin. The gold point walks along the marked ray, whose plane is tinted, and the readouts give its state at each step: traced back from the camera, its time runs backward, and H stays near zero. Spin sets the hole's spin, and at zero every ray stays in its plane. Turn pauses the view's rotation.

Here an index written twice in one term is summed over its four values, λ\eq{lambda}{\lambda} is a parameter along the ray, and gμνg^{\mu\nu}, with raised indices, is the inverse of the metric.

The shader writes the metric in Kerr–Schild coordinates [6], where it is the metric of flat spacetime, gμνflatg^\text{flat}_{\mu\nu}, plus a single term. That term is a scalar field f\eq{f}{f} times a direction lμl_\mu multiplied with itself. The field is the strength of the hole’s departure from flat spacetime at each point. The direction is light-like, with zero length in the flat metric, like a light ray’s:

gμν=gμνflat+f lμlν,f=r2 (2Mr−Q2)r4+a2y2.g_{\mu\nu} = g^\text{flat}_{\mu\nu} + \eq{f}{f}\, l_\mu l_\nu, \qquad \eq{f}{f} = \frac{r^2\,(2Mr - \eq{Q}{Q}^2)}{r^4 + \eq{a}{a}^2 y^2}.

Here yy is the height along the spin axis and xx and zz the coordinates across it. The radius rr is constant on spheroids flattened along the spin axis, which round out into spheres far from the hole, where rr becomes the ordinary distance and f≈2M/r\eq{f}{f} \approx 2M/r, so the departure fades as 1/r1/r. Its spatial part l⃗\vec{l} points outward, twisted around the spin axis by the spin, and without spin it points straight out. Both follow from the position in closed form.

Since the direction lμl_\mu is light-like, the inverse metric is gμν=gflatμν−f lμlνg^{\mu\nu} = g_\text{flat}^{\mu\nu} - \eq{f}{f}\, l^\mu l^\nu, and for a photon of energy EE and spatial momentum p⃗\vec{p}, with lμ=(1,l⃗ )l_\mu = (1, \vec{l}\,) and the flat metric’s signs (−,+,+,+)(-, +, +, +), time first,

H=12(∣p⃗∣2−E2−f (E+l⃗⋅p⃗)2),H = \tfrac{1}{2}\Big(\lvert \vec{p} \rvert^2 - E^2 - \eq{f}{f}\,(E + \vec{l} \cdot \vec{p})^2\Big),

so each derivative costs a few dot products and the gradients of f\eq{f}{f} and l⃗\vec{l}. These coordinates behave well at the horizon only for light falling in, and a ray traced back from the camera runs backward in time. The shader therefore uses their time-reversed form: l⃗\vec{l} reversed and a\eq{a}{a} replaced by −a-\eq{a}{a}, since reversing time also reverses the spin. In it a captured ray ends cleanly when it crosses the outer horizon r+\eq{rh}{r_+}, the surface that light inside cannot leave.

Each step of the classic fourth-order Runge–Kutta method moves the photon 0.3 r0.3\,r along its path,

h=0.3 r∣x⃗˙∣,\eq{h}{h} = \frac{0.3\, r}{\lvert \dot{\vec{x}} \rvert},

where dots are derivatives in λ\lambda and x⃗\vec{x} is the photon’s position in space, so the steps are long where the field is weak and short near the hole. A ray from the camera takes about twenty steps unless it winds around the photon orbits, the spinning hole’s counterparts of the photon sphere. At this length the camera’s rays escape within a pixel of where a far finer trace sends them.

A ray ends when it crosses the outer horizon, when it is far away, or when it starts approaching the hole a second time. An escaping ray approaches only once: it comes in to its closest point and then recedes for good. A ray that turns inward again is bound to the hole and joins the shadow.

The traced ray is therefore a polyline: its steps joined by straight segments, which the shader calls chords. Unlike the Schwarzschild model’s thin disk, this disk is a volume, so the shader does not look for a single crossing. It samples the gas at points spaced along each chord, and only where gas can be.

Side view of one ray from the camera, upper left, to
    the hole: purple dots mark the integrator's steps, far apart near the
    camera and close together near the hole. One straight segment, crossing
    the near side of the disk, is highlighted, and an inset magnifies the
    orange sample dots along it. The ray passes under the hole, crosses the
    disk again behind it and leaves upward.
One ray from the site's camera, traced by the CPU port of the integrator and seen from the side, with the disk's profile. Purple: the integrator's steps, each 0.3 of the distance to the hole. Gold: a chord through the near side of the disk. Orange: the gas samples, too close together to tell apart at this scale and magnified in the inset. The ray then passes under the hole, crosses the far side of the disk near its inner edge and escapes.

Charge enters only through f\eq{f}{f}‘s numerator, 2Mr−Q2=2r (M−Q2/2r)2Mr - \eq{Q}{Q}^2 = 2r\,(M - \eq{Q}{Q}^2/2r): at radius rr the geometry is that of a mass M−Q2/2rM - \eq{Q}{Q}^2/2r, since the energy Q2/2r\eq{Q}{Q}^2/2r of the electric field outside rr is part of MM and pulls nothing inward. Charge therefore shrinks the outer horizon, the photon orbits and the shadow: without spin, as Q\eq{Q}{Q} approaches MM, the shadow’s radius falls from 33M≈5.2M3\sqrt{3}M \approx 5.2M to 4M4M. Photons carry no charge and the model’s gas is neutral, so the charge acts through the geometry alone. Holes in nature are close to neutral, since a charged hole draws opposite charge from the gas around it.

The horizons sit at

r±=M±M2−a2−Q2,\eq{rh}{r_\pm} = M \pm \sqrt{M^2 - \eq{a}{a}^2 - \eq{Q}{Q}^2},

which exist only while a2+Q2≤M2\eq{a}{a}^2 + \eq{Q}{Q}^2 \le M^2. At the site’s spin the charge can reach only about 0.08M0.08M, too little to see, so the sliders lower the spin as the charge rises. Outside the outer horizon lies the ergosphere, where the spin drags spacetime around so strongly that nothing can stay still relative to the distant stars.

The shadow’s edge is light that grazes the unstable spherical photon orbits, whose radii run from the prograde to the retrograde orbit in the disk plane. An orbit of radius rr fixes, in closed form, two conserved quantities per unit energy: the photon’s angular momentum ξ\xi about the spin axis, and its Carter constant η\eta, which measures how far its motion carries it out of the disk plane. A distant camera at inclination ii from the spin axis sees that orbit’s light at the screen position (X,Y)(\eq{X}{X}, \eq{Y}{Y}), in units of MM from the hole’s center (the literature writes them α\alpha and β\beta),

X=−ξsin⁡i,Y=±η+a2cos⁡2i−ξ2cot⁡2i.\eq{X}{X} = -\frac{\xi}{\sin i}, \qquad \eq{Y}{Y} = \pm\sqrt{\eta + \eq{a}{a}^2 \cos^2 i - \xi^2 \cot^2 i}.

Prograde orbits lie closest to the hole, so the edge on the side turning toward the camera moves in and the shadow flattens there [7]. The shader never uses these formulas: its shadow is where its traced rays fall in. That makes them an independent test of the trace, and the CPU port of its integrator puts the shadow’s edge within a fraction of a pixel of theirs.

The Kerr–Newman model's geometry, with spin and charge on the sliders. Horizons: a slice through the spin axis, shaded by f, the departure from flat spacetime, with both horizons, the ergosphere and the ring singularity, where the curvature becomes infinite (a ring, cut by the slice at the two dots). Rays: the disk plane from above, traced back from the camera by the model's integrator, with dots on one ray's steps. Shadow: the edge from the spherical photon orbits.

The disk’s light

Where a photon crosses the disk, the gas orbits at the Keplerian angular speed, which without charge is Bardeen, Press and Teukolsky’s [8],

Ω=Mr−Q2r2+aMr−Q2,\eq{Omega}{\Omega} = \frac{\sqrt{Mr - \eq{Q}{Q}^2}}{r^2 + \eq{a}{a}\sqrt{Mr - \eq{Q}{Q}^2}},

and the ratio of observed to emitted frequency is

g=EobsEemit=1ut (1−Ω ξ),\eq{g}{g} = \frac{E_\text{obs}}{E_\text{emit}} = \frac{1}{u^t\,(1 - \eq{Omega}{\Omega}\, \eq{xi}{\xi})},

where utu^t is the gas’s time dilation [9]. The factor 1/ut1/u^t holds the gravitational redshift and the transverse Doppler shift of the orbit, and 1−Ω ξ1 - \eq{Omega}{\Omega}\,\eq{xi}{\xi} the Doppler shift of the gas’s motion along the photon. The Schwarzschild formula above is this case without spin or charge.

Here the innermost stable circular orbit lies at rin≈1.28Mr_\text{in} \approx 1.28M, close to the horizon. For other spins and charges it follows from a closed-form condition [10].

The disk is dim and starved, the kind a massive hole fed slowly has, and two heat sources set its temperature, T4=Tv4+Tl4\eq{T}{T}^4 = \eq{Tv}{T_v}^4 + \eq{Tl}{T_l}^4. Friction in the orbiting gas gives the thin-disk profile of the Schwarzschild model, Tv\eq{Tv}{T_v}. Light from the inner disk falls on the outer disk where it flares, and this heating, Tl\eq{Tl}{T_l}, falls off more slowly with radius:

Tv=8000 K (2Mr)3/4(1−rin/r )1/4,Tl=4200 K (10Mr)1/2,\eq{Tv}{T_v} = 8000\ \text{K}\, \Big(\frac{2M}{r}\Big)^{3/4} \Big(1 - \sqrt{r_\text{in}/r}\,\Big)^{1/4}, \qquad \eq{Tl}{T_l} = 4200\ \text{K}\, \Big(\frac{10M}{r}\Big)^{1/2},

with Tl\eq{Tl}{T_l} switched on smoothly where the disk starts to flare.

Each sample glows as a black body at the observed temperature gT\eq{g}{g} \eq{T}{T}. The shader evaluates Planck’s law for photons of three energies Ec\eq{Ec}{E_c}, the display’s red, green and blue, relative to a black body at the temperature T0T_0 of the display’s white:

Ic=eEc/(kT0)−1eEc/(k gT)−1,\eq{Ic}{I_c} = \frac{e^{\eq{Ec}{E_c} / (k T_0)} - 1}{e^{\eq{Ec}{E_c} / (k\, \eq{g}{g} \eq{T}{T})} - 1},

where kk is Boltzmann’s constant. This contains the Schwarzschild model’s g3\eq{g}{g}^3: a black body’s specific intensity obeys g3Iν(ν/g,T)=Iν(ν,gT)\eq{g}{g}^3 I_\nu(\nu/\eq{g}{g}, \eq{T}{T}) = I_\nu(\nu, \eq{g}{g}\eq{T}{T}), so a black body shifted by g\eq{g}{g} looks exactly like one at gT\eq{g}{g}\eq{T}{T}. The result is linear radiance, and the frame’s bloom and tone curve act as the camera.

At these temperatures the display’s wavelengths lie on the steep, short side of Planck’s curve, where a change in temperature changes the brightness a lot and the color little. The Doppler shift therefore shows mostly as brightness. At 10M10M, gas moving straight toward an edge-on camera looks 150 times brighter in green than gas moving away. The side turning toward the camera glows gold-white and overexposes into the bloom, and the receding side dims to a deep orange that reads as brown. The film Interstellar left these Doppler effects out of its black hole’s disk, which with them would have looked as lopsided as this one [11].

The gas absorbs as well as it emits, more strongly in blue than in red, as dust does: its opacity grows with the photon energy. The accumulation sum C←C+(1−A) ai ciC \leftarrow C + (1 - A)\,a_i\,c_i, from Tracing and marching, therefore runs once per color channel, and the near side of the disk hides and reddens what lies behind it.

Left: the disk's temperature against radius on log scales, with the friction term Tv, the irradiation Tl and their combination T. Right: Planck's law for gas at the chosen radius moving toward the camera (solid) and away (dashed), with the display's three wavelengths marked and the colors they give below. The two sides differ far more in brightness than in color. Radius moves along the disk.

The disk’s flow

The gas orbits at the Newtonian Kepler rate M/r3\sqrt{M/r^3}, close to Ω\Omega except near the inner edge, and drifts inward at a fixed fraction ϵ\epsilon of its orbital speed. Real gas falls in far too slowly to see, so I made it fall faster. Each parcel of gas therefore follows a trailing logarithmic spiral. To move the texture with the gas, the shader reads the noise at two quantities that stay the same for a parcel all along its path:

φ+ln⁡(r/M)ϵ,tin(r)+t,tin(r)=23 r3/2ϵM,\varphi + \frac{\ln (r/M)}{\epsilon}, \qquad t_\text{in}(r) + t, \qquad t_\text{in}(r) = \frac{2}{3}\, \frac{r^{3/2}}{\epsilon \sqrt{M}},

where φ\varphi is the angle around the spin axis in the direction the gas orbits, tt the time, and tin(r)t_\text{in}(r) how long gas at radius rr takes to fall in. The first quantity says which spiral a parcel rides, and the second the moment it will reach the inner edge. Together with the height above the midplane they pick out one parcel, so each feature of the noise travels with its parcel, orbiting and falling in. Unlike a texture carried on circular orbits, it never winds up, so it needs none of the Schwarzschild disk’s restarting layers.

The gas is two octaves of that noise, nn. A smoothstep of nn gathers it into streaks along the spirals, with clear lanes between them. The streaks are densest at the midplane, and never denser than the captured matter allows. A third, coarser octave makes the top of the gas billow. The gas fills a lens-shaped slab, thin at the inner edge and at the rim and thickest about halfway out. Its temperature follows the noise too, so the dense streaks run hotter.

Three hot spots orbit near the inner edge, compact flares like those the GRAVITY instrument tracked around Sagittarius A* [12]. Each lives a few orbits and heats the gas to up to twice the disk’s temperature, so it flashes as it swings toward the camera, and its lensed image sweeps around the shadow.

A thin corona, hot plasma that scatters the inner disk’s light, glows in a ring just outside the photon orbits. It emits light without absorbing any. The shader samples it along every chord, so it is lensed like the disk: its near side crosses the shadow as a thin band, and its light bent around the hole rims the shadow’s edge.

Tracing Kerr–Newman in real time

This part is engineering rather than physics: the shortcuts that let the trace keep up with the frame rate. Integrating every ray through the full metric is the heaviest work on the site.

The traced image’s size follows the computer’s speed. Every few frames the trace compares the median frame time with a target, and it traces more pixels when frames run well ahead of it and fewer when they fall well behind. The target drops once the close-up has settled: a slow computer gives up frame rate for a sharper settled disk, which changes little from frame to frame.

Each frame jitters the rays by a fraction of a pixel and blends the new image CnewC_\text{new} with the previous result Ct−1C_{t-1}, the history [13],

Ct=(1−w) Cnew+w Ct−1,w=0.9,C_t = (1 - w)\, C_\text{new} + w\, C_{t-1}, \qquad w = 0.9,

an exponential average with a time constant of ten frames. Each frame’s rays land at slightly different points in their pixels, so the average covers each pixel’s whole area and smooths its edges. Between frames the view only moves and scales about the hole, which lets a pixel at screen position q⃗\eq{q}{\vec{q}} find its previous value at

q⃗ ′=c⃗ ′+(q⃗−c⃗) s′s,\eq{q}{\vec{q}\,'} = \eq{c}{\vec{c}\,'} + (\eq{q}{\vec{q}} - \eq{c}{\vec{c}})\,\frac{\eq{s}{s'}}{\eq{s}{s}},

where c⃗\eq{c}{\vec{c}} is the hole’s screen position, s\eq{s}{s} the view scale, and primes mark the previous frame. This follows the view, not the gas, so moving gas would leave a trail of its earlier positions, called ghosting. The standard guard clamps the history to the range of colors around each pixel in the current frame [13].

Near the shadow, lensing squeezes a large stretch of disk into a few pixels, and a single ray per pixel turns that gas into grain. With o⃗\eq{o}{\vec{o}} the offset from a pixel to the point of the sky its ray reads, the trace refines a pixel, adding rays to it, wherever

max⁡(∣∂xo⃗∣, ∣∂yo⃗∣)>12,\max\big(\lvert \partial_x \eq{o}{\vec{o}} \rvert,\ \lvert \partial_y \eq{o}{\vec{o}} \rvert\big) > \tfrac{1}{2},

that is, wherever the offset changes by more than half a pixel from one pixel to the next. The test runs only where the previous frame showed disk or shadow nearby, since extra rays refine nothing else, and pixels along the shadow’s edge are always refined. A refined pixel traces its center ray and one of four sub-pixel rays, a different one each frame, and the history blend averages them along with the jitter.

Refinement is where the trace spends instead of saves: it takes about a quarter of the trace’s time. Tracing all four sub-pixel rays every frame on the photon ring (the thin bright ring that hugs the shadow) and at the shadow’s edge, at full resolution, can make a single frame on a laptop take long enough for the browser to reset the GPU, and after a few resets it turns WebGL off.

Most of a ray’s path runs through empty space. Before each sample the shader computes a safe distance to the nearest gas, whether the ray is above the slab, beyond its rim or in the gap inside the innermost stable orbit, and jumps that far at once. No other shortcut saves as much: without it the trace takes twice as long.

A ray that meets the dense midplane stops early, since the gas there turns opaque within a few samples. Without that stop the trace takes about a fifth longer. A ray crossing the thin gas above and below the midplane samples all the way through, and those rays are the expensive ones.

Along each chord the shader spaces the gas samples by how fast the ray crosses the gas’s structure, across the streaks and up through the billows: a fraction of a noise cell apart, and at most a fixed number on each chord. Where that limit leaves them sparser, on chords that graze the disk, the octaves finer than their spacing fade out, as in the Schwarzschild disk, and the gas blurs instead of aliasing into false patterns. Each frame’s jitter shifts where the samples land, and the history blends about ten frames of them.

The noise is cheaper than it looks. Each octave blends random values at the eight corners of a lattice cell. Instead of computing them with a hash, the shader stores the lattice in a small texture, two neighboring slices in its red and green channels. The texture hardware blends neighboring entries for free, so one read returns the blend of a cell’s four corners in both slices. The lattice repeats, with a period the disk’s spirals hide. With an ordinary sine hash at the eight corners instead, the trace takes over a third longer.

The Kerr–Newman trace, as a still until Start live render. Cost colors each pixel by its disk samples per ray, from purple (tens) through orange to pale yellow (hundreds). Refined marks in cyan the pixels that trace a second ray inside the pixel, which near the hole is a large share of the frame. One frame turns the history off: the grain it averages away appears, and the photon ring turns jagged.

The last shortcut acts only while the hole’s image is small. A ray passing farther than 48M48M from the hole cannot reach the disk, so instead of being integrated it takes the weak-field bend of an uncharged hole without spin, to second order [14],

δ≈(2Mb+15πM28b2)(1+cos⁡ψ),\eq{delta}{\delta} \approx \Big(\frac{2M}{\eq{b}{b}} + \frac{15\pi M^2}{8 \eq{b}{b}^2}\Big)(1 + \cos\eq{psi}{\psi}),

where b\eq{b}{b} is the ray’s impact parameter and ψ\eq{psi}{\psi} the angle at the camera between the ray and the direction to the hole. For a distant camera ψ\eq{psi}{\psi} is small at a given b\eq{b}{b}, and the first term tends to 4M/b=2Rs/b4M/\eq{b}{b} = 2R_s/\eq{b}{b}, the bend derived for the Schwarzschild model. The camera is not infinitely far away, so part of the pull lies behind it. The factor 1+cos⁡ψ1 + \cos\eq{psi}{\psi} keeps only the part ahead of it: exactly for the first term, approximately for the second. Spin and charge add terms of the same order to the full bend, ∓4aM/b2\mp 4\eq{a}{a}M/\eq{b}{b}^2 for a ray in the disk plane [15] and −3πQ2/4b2-3\pi\eq{Q}{Q}^2/4\eq{b}{b}^2 [14], which the shader leaves out. At 48M48M both are small. In the held close-up the 48M48M circle lies beyond the screen’s corners, and by the time it comes on screen the error is about two pixels and shrinks with the image.

The trace's shortcuts on the CPU port of its integrator, with the site's camera. Far rays: traced bends minus the weak-field formula on either side of a spinning hole. Reprojection: where each pixel finds its previous value as the hole moves and zooms, with the two frames drawn well apart in time so the motion shows. Refinement: a checkered sky seen through the lens. The tinted pixels pass the test above and gain sub-pixel rays one at a time. The left half shows one ray per pixel, the right half the refined result. Samples: the disk samples on a short stretch of a ray heading in along the midplane, in one frame and over the ten frames the history blends, which fill the gaps between them.

What is physical and what is design

In both black holes, light follows general relativity, to the accuracy measured above. The Schwarzschild model reads the exact orbit equation’s paths from its table, and its disk’s redshift is exact for circular orbits. Its camera takes each pixel’s direction as a straight line, so it shows the image slightly larger than a camera at rest that close to the hole would see it. The Kerr–Newman camera measures directions as one at rest does. The Kerr–Newman model integrates the geodesic equations for any spin and charge a hole can have, its disk fades in across the exact innermost stable orbit, and its gas glows as a black body at the redshifted temperature, sampled at three wavelengths where a camera would weigh the whole spectrum through its color filters. It leaves part of the work to a weak-field bend, which it uses alone for rays that pass farther than 48M48M from the hole. The matter grid conserves mass exactly, however long the disk turns.

Everything else invokes artistic freedom: the starfield and its diagrams, the galaxy, its intro and its helix, the meteors and comets with their trails, the capture, the gas textures, the Schwarzschild disk’s palette and brightness, the Kerr–Newman disk’s temperatures, its quick inflow, its hot spots and its corona. Several of these borrow a physical law as a model, such as the pinhole camera’s depth scaling, a diffusing puff’s width or Kepler’s third law, and depart from it where the picture calls for it.

Where the blog went

The plan was a blog with a few stars behind it. The stars are now about half of the site’s code, nine tenths with the demos that explain them, and this post is entirely about them. The next one will be about something else.

The site's code by what it is for, counted when this post was published.

References

  1. Inigo Quilez, Domain warping.
  2. Frederic H. Vincent, Thibaut Paumard, Eric Gourgoulhon and Guy Perrin, GYOTO: a new general relativistic ray-tracing code, Classical and Quantum Gravity 28, 225011 (2011).
  3. Riccardo Antonelli, Starless: a black hole raytracer.
  4. Jean-Pierre Luminet, Image of a spherical black hole with thin accretion disk, Astronomy & Astrophysics 75, 228 (1979).
  5. Nikolai I. Shakura and Rashid A. Sunyaev, Black holes in binary systems: observational appearance, Astronomy & Astrophysics 24, 337 (1973).
  6. G. C. Debney, R. P. Kerr and A. Schild, Solutions of the Einstein and Einstein–Maxwell equations, Journal of Mathematical Physics 10, 1842 (1969).
  7. Volker Perlick and Oleg Yu. Tsupko, Calculating black hole shadows: review of analytical studies, Physics Reports 947, 1 (2022).
  8. James M. Bardeen, William H. Press and Saul A. Teukolsky, Rotating black holes: locally nonrotating frames, energy extraction, and scalar synchrotron radiation, The Astrophysical Journal 178, 347 (1972).
  9. C. T. Cunningham, The effects of redshifts and focusing on the spectrum of an accretion disk around a Kerr black hole, The Astrophysical Journal 202, 788 (1975).
  10. Daniela Pugliese, Hernando Quevedo and Remo Ruffini, Equatorial circular orbits of neutral test particles in the Kerr–Newman spacetime, Physical Review D 88, 024042 (2013).
  11. Oliver James, Eugénie von Tunzelmann, Paul Franklin and Kip S. Thorne, Gravitational lensing by spinning black holes in astrophysics, and in the movie Interstellar, Classical and Quantum Gravity 32, 065001 (2015).
  12. GRAVITY Collaboration, Detection of orbital motions near the last stable circular orbit of the massive black hole SgrA*, Astronomy & Astrophysics 618, L10 (2018).
  13. Lei Yang, Shiqiu Liu and Marco Salvi, A survey of temporal antialiasing techniques, Computer Graphics Forum 39, 607 (2020).
  14. Charles R. Keeton and A. O. Petters, Formalism for testing theories of gravity using lensing by compact objects: static, spherically symmetric case, Physical Review D 72, 104006 (2005).
  15. Mauro Sereno and Fabiana De Luca, Analytical Kerr black hole lensing in the weak deflection limit, Physical Review D 74, 123009 (2006).