# How Infall ray-traces a black hole in the browser, and what we learned

> The physics behind Infall's live black hole: light paths from the Binet equation, worked out once, a Kepler disk in Doppler light, and 16 lessons from making it run at 60 fps in a hero.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Infall — Black Hole Hero Section (React + WebGL)](https://upfork.dev/products/black-hole-hero) (Component, Free). Published 2026-10-25. 14 min read.

## What it is

Infall is a hero section with a black hole that is ray-traced live in the visitor's browser, after a reference picture of a black hole with a glowing disk. Nothing in it is painted: the arch of light over the top, the thin crescent under the shadow and the bent stars all come from following light round a Schwarzschild black hole, and scrolling on falls into it.

[Live preview of Infall — Black Hole Hero Section (React + WebGL)](https://upfork.dev/products/black-hole-hero)

This article explains the physics first, then how it is made to run at 60 fps in a hero section. Each lesson sits beside the part of the build where it was learned: what we got wrong or didn't know, and what fixed it.

## The physics: how light moves round a black hole

Infall works in Schwarzschild units: the black hole's Schwarzschild radius is 1. The event horizon sits at r = 1, the photon sphere (where light can circle the hole) at r = 1.5, and the innermost stable circular orbit of the gas at r = 3.

A ray of light that passes the hole stays in one plane through its centre. Write u = 1/r and let φ be the angle round the hole in that plane. General relativity's equation for the path, the Binet equation for light in Schwarzschild spacetime (d²u/dφ² + u = 3Mu², with G = c = 1 and the Schwarzschild radius 2M = 1), becomes:

```text
d²u/dφ² = 1.5·u² − u
```

Without the 1.5·u² term the solution is a straight line; that term is the bending, and it only matters near the hole. A ray coming in from far away starts at u = 0 with du/dφ = 1/b, where b is its impact parameter: how far from the centre it was aimed. That one number fixes the whole path:

- Below the critical impact parameter b_c = 3√3/2 ≈ 2.598, the ray spirals in and crosses the horizon (u reaches 1).
- Above it, the ray swings round the hole and leaves again (u comes back to 0).
- Close to b_c, it winds round the photon sphere first, once or many times.

So the black hole's shadow on the sky has a radius of b_c, about 2.6 times the horizon's: the hole looks bigger than it is, because light aimed anywhere near it is captured.

`components/infall-geodesics.ts`, lines 1–24:

```ts
/**
 * Light bending around a black hole, worked out once and kept in two tables the shader reads.
 *
 * Units: the Schwarzschild radius is 1, so the horizon sits at r = 1, the photon sphere at 1.5 and the innermost
 * stable orbit at 3. A ray of light stays in one plane through the hole; with u = 1/r and φ the angle in that plane
 * its path obeys the Binet equation for light,
 *
 *   d²u/dφ² = 1.5·u² − u
 *
 * and a whole path is fixed by its impact parameter b. Arriving from infinity it starts at u = 0 with du/dφ = 1/b.
 * Below the critical b_c = 3√3/2 it spirals in and reaches the horizon (u = 1); above, it swings round the hole and
 * leaves again (u back to 0). Paths close to b_c wind round the photon sphere first; that winding is what draws the
 * thin ring at the edge of the shadow.
 *
 * Table layout (rows are paths, columns are positions along a path):
 *  - rows 0 … ROWS-1 are paths that fall in, rows ROWS … 2·ROWS-1 paths that escape. In each half the row stands for
 *    log(|b − b_c|), so the rows crowd in near b_c where paths change fastest.
 *  - column j holds the path at φ = (j / (STEPS-1)) · Φ_end, Φ_end being the angle where the path ends (horizon or
 *    infinity). Neighbouring rows therefore always line up end to end and can be blended.
 *  - `paths` (STEPS × 2·ROWS, two floats per texel): u and du/dφ.
 *  - `ends` (2·ROWS × 1, two floats per texel): Φ_end and φ_start, the angle along the path where the camera sits
 *    (u = 1/distance on the way in). φ_start is the only part that depends on the camera, so moving the camera
 *    nearer or farther redoes just this row.
 */
```

## Worked out once: the light-path tables

Because a path depends only on b, nothing has to be traced per pixel per frame. Infall traces 1,024 paths once, when it starts: 512 that fall in and 512 that escape. In each half the rows are spaced by log|b − b_c|, so they crowd in near b_c, where paths change fastest and where the thin ring at the shadow's edge comes from.

Each path is integrated from infinity with fourth-order Runge–Kutta steps. The step keeps both the angle step and the change in u small, so steep plunges (small b) and slow windings round the photon sphere (b near b_c) are both resolved:

`components/infall-geodesics.ts`, lines 66–111:

```ts
/**
 * Follows one path from infinity with fixed-error RK4 steps and records it. The step keeps both the angle step and
 * the change in u small, so steep plunges (small b) and slow windings (b near b_c) are both resolved.
 */
function tracePath(b: number) {
  const phi: number[] = [0];
  const us: number[] = [0];
  const vs: number[] = [1 / b];
  let p = 0;
  let u = 0;
  let v = 1 / b;
  const falls = b < B_CRIT;
  for (let guard = 0; guard < 200000; guard++) {
    const h = Math.min(0.01, 0.003 / Math.max(Math.abs(v), 1e-9));
    const [k1u, k1v] = deriv(u, v);
    const [k2u, k2v] = deriv(u + 0.5 * h * k1u, v + 0.5 * h * k1v);
    const [k3u, k3v] = deriv(u + 0.5 * h * k2u, v + 0.5 * h * k2v);
    const [k4u, k4v] = deriv(u + h * k3u, v + h * k3v);
    const nu = u + (h / 6) * (k1u + 2 * k2u + 2 * k3u + k4u);
    const nv = v + (h / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
    const np = p + h;
    if (falls && nu >= 1) {
      // reached the horizon inside this step: cut the step where u = 1
      const f = (1 - u) / (nu - u);
      phi.push(p + f * h);
      us.push(1);
      vs.push(v + f * (nv - v));
      break;
    }
    if (!falls && nu <= 0 && nv < 0) {
      // back at infinity: cut where u = 0
      const f = u / (u - nu);
      phi.push(p + f * h);
      us.push(0);
      vs.push(v + f * (nv - v));
      break;
    }
    p = np;
    u = nu;
    v = nv;
    phi.push(p);
    us.push(u);
    vs.push(v);
  }
  return { phi, us, vs };
}
```

Each path is then resampled at 512 evenly spaced angles along its own length, with cubic Hermite interpolation since both u and du/dφ are known, so neighbouring rows line up end to end and the shader can blend between them. Two float textures hold the result: the paths (u and du/dφ at each sample), and for each row the angle where the path ends and the angle where the camera sits on it.

Only that last number depends on the camera. Moving the camera nearer or farther redoes one value per row, found by bisecting the segment where the path passes the camera's distance: about a millisecond in all. That is what lets the hero fall into the hole as the page scrolls.

`components/infall-geodesics.ts`, lines 163–191:

```ts
  const setCameraDistance = (distance: number) => {
    const u0 = 1 / Math.max(distance, 1.01);
    for (let row = 0; row < ROWS * 2; row++) {
      const phiEnd = ends[row * 2];
      const h = phiEnd / (STEPS - 1);
      let start = -1;
      for (let i = 0; i < STEPS - 1; i++) {
        const o = (row * STEPS + i) * 2;
        const ua = paths[o];
        const va = paths[o + 1];
        const ub = paths[o + 2];
        const vb = paths[o + 3];
        if (va < 0) break; // past the closest approach: the camera never sits this close on this path
        if (ub >= u0) {
          // bisect the Hermite segment for u = u0
          let lo = 0;
          let hi = 1;
          for (let it = 0; it < 30; it++) {
            const mid = 0.5 * (lo + hi);
            if (hermite(ua, va, ub, vb, h, mid)[0] < u0) lo = mid;
            else hi = mid;
          }
          start = (i + 0.5 * (lo + hi)) * h;
          break;
        }
      }
      ends[row * 2 + 1] = start;
    }
  };
```

## From a pixel to its path

For each pixel, the shader takes the ray's direction and c, the direction from the hole to the camera. The ray's plane is spanned by c and e, the part of the ray's direction perpendicular to c, and its impact parameter is the camera's distance times the sine of the angle between the ray and the hole: b = D·sin α. b picks the row (with the same log mapping), and which side of b_c it falls on picks the half.

The disk lies in the plane y = 0. Along a path the height above that plane goes like cos(φ − φ₀), so the path crosses the disk's plane every π radians. The shader looks up the radius at each crossing, in the order light meets them, at most three: k = 0 is the disk seen directly; k = 1 is light that went round the hole once, which draws both the arch over the top and the ring under the shadow; k ≥ 2 is the photon ring.

`components/infall-shaders.ts`, lines 454–479:

```ts
void main() {
  vec2 ndc = gl_FragCoord.xy / uRes * 2.0 - 1.0;
  vec3 dir = normalize(uCamFwd + (ndc.x - uShift.x) * uTanHalf.x * uCamRight + (ndc.y - uShift.y) * uTanHalf.y * uCamUp);
  float D = length(uCamPos);
  vec3 c = uCamPos / D;
  float dc = dot(dir, c);
  vec3 eRaw = dir - dc * c;
  float sn = length(eRaw);
  vec3 e = eRaw / max(sn, 1e-9);
  float b = D * sn;
  float bw = fwidth(b);

  bool esc = b >= B_CRIT;
  float row = rowFor(b, esc);
  // the disk is the plane y = 0; along the path the height goes like cos(φ − φ0), so it is crossed every π
  float phi0 = atan(e.y, c.y);
  float first = mod(phi0 + 0.5 * PI, PI);
  first = first < 1e-5 ? first + PI : first;

  float rr[MAX_CROSS];
  float pk[MAX_CROSS];
  float dk[MAX_CROSS];
  float rw[MAX_CROSS];
  float phiOut;
  int n = crossings(row, esc, first, rr, pk, dk, phiOut);
  for (int k = 0; k < MAX_CROSS; k++) rw[k] = fwidth(rr[k]);
```

> **Lesson: A black hole's photon ring is thinner than a pixel at hero size, and only shimmers** (My own mistake; learned by the AI; three@0.186)
> **I assumed:** the photon ring, light that circles the hole more than once before it reaches the camera (the third and later disk crossings), belonged in a physically ray-traced black hole.
> **What happened:** at the size of a hero section it is thinner than a pixel, so it aliased into shimmer instead of drawing a ring.
> **The fix:** it is off by default (the `photonRing` setting); the ring under the shadow is drawn from the light that went round once, kept smooth and warm.
> **Evidence:** commit 5dd4712, the first milestone; the comment above the crossing loop in infall-shaders.ts.

## The disk: Kepler, Doppler and temperature

The gas orbits at Kepler speeds: its angular speed goes like r^(−3/2), so the inner disk turns faster than the outer. The disk is drawn as distinct threads of light with dark gaps between them, and every thread turns rigidly at the Kepler speed of its own radius:

`components/infall-shaders.ts`, lines 229–258:

```ts
// One layer of threads: one thread per cell of the radial coordinate t = y·freq, jittered, each with its own width and
// a brightness that swells and fades along its length (all three come from the baked thread table, one row per
// thread). Each thread is a Gaussian across; it is widened by the pixel footprint (energy kept) so threads thinner than
// a pixel blur into an even glow instead of shimmering. Every thread orbits rigidly at the Kepler speed of its own
// radius, so inner threads slide past outer ones but no pattern is ever sheared: the disk looks the same after an hour
// as after a second.
float threadLayer(float turns, float y, float freq, float layer, float pix) {
  float t = y * freq;
  float cell = floor(t);
  float acc = 0.0;
  float spread = 0.0;
  for (int j = -uReach; j <= uReach; j++) {
    float c = cell + float(j);
    float rc = max((c + 0.5) / (freq * uFilamentScale), 1.5);
    float a = turns - uSpinDir * mod(uSpin * inversesqrt(rc) / rc * uTime, TAU) / TAU;
    vec3 th = textureLod(uThreads, vec2(a, (layer * 256.0 + mod(c, 256.0) + 0.5) / 1024.0), 0.0).rgb;
    float center = c + 0.5 + 0.6 * (th.g - 0.5);
    float w = mix(0.05, 0.15, th.b) * uThreadWidth;
    float sw = sqrt(w * w + 0.25 * pix * pix);
    spread = max(spread, sw);
    float d = (t - center) / sw;
    // light runs along each thread too, a little faster than its gas, so the lines flow as they turn: a few soft
    // pulses per turn, each thread at its own pace
    float h = fract(sin(c * 12.9898 + layer * 78.233) * 43758.5453);
    float run = 1.0 + 0.3 * uShimmer * sin(TAU * (a * (2.0 + floor(h * 4.0)) + h) - uTime * (1.1 + 1.3 * h));
    acc += th.r * run * (w / sw) * exp(-d * d);
  }
  // past half a cell the three nearest threads no longer cover the blur: settle on the layer's average
  return mix(acc, 0.0886 * uThreadWidth, smoothstep(0.3, 0.55, spread));
}
```

> **Lesson: A disk texture turned at Kepler speeds winds into noise; turn each thread rigidly instead** (My own mistake; learned by the AI)
> **I assumed:** the disk's pattern could turn at each radius's own Kepler speed, inner faster than outer, as the gas does.
> **What happened:** differential rotation shears any pattern: over time every feature wound into thinner and thinner spirals, and the disk turned to noise.
> **The fix:** the disk is made of threads, and each thread turns rigidly at the Kepler speed of its own radius, so inner threads slide past outer ones but none is ever sheared; the large features (the threads' meander, bright patches, dust) turn together at the speed of r = 6.
> **Evidence:** first milestone (5dd4712): frames checked after up to ten hours of spinning look like the first.

The colour comes from the gas's temperature, which falls off like r^(−3/4) in a thin disk, shifted by the factor g that light's frequency changes by on its way to the camera. g combines two effects: the gravitational redshift √(1 − 1/r) of light climbing out of the hole's well, and the Doppler shift of gas moving at β = √(0.5 / (r − 1)) of the speed of light (a circular orbit's speed as a static observer measures it), toward or away from us along the ray. The brightness goes with g³, so the side turning toward the camera is brighter and bluer, and the other side dimmer and redder:

`components/infall-shaders.ts`, lines 300–317:

```ts
  // colour from temperature (~ r^-3/4), shifted by the Doppler factor of the orbiting gas
  vec3 vdir = uSpinDir * vec3(-P.z, 0.0, P.x) / r;
  float beta = min(sqrt(0.5 / max(r - 1.0, 0.1)), 0.75);
  float gam = inversesqrt(1.0 - beta * beta);
  float g = sqrt(max(1.0 - 1.0 / r, 0.0)) / (gam * (1.0 - beta * dot(vdir, -tang)));
  float gd = mix(1.0, g, uDoppler);
  // brightness and colour are measured from where the gas is hottest (uRhot), not from where the disk is cut off
  // (uRin), and stay as they are there further in
  float q = min(uRhot / r, 1.15);
  float temp = pow(q, 0.75) * gd;
  vec3 col = mix(uEmber, uWarm, smoothstep(0.22, 0.62, temp));
  col = mix(col, uHot, smoothstep(0.62, 1.08, temp));

  // thin glowing gas: brighter where the line of sight runs through more of it (edge on), threads brighter still
  // a bright inner disk plus a faint, slowly fading tail that carries the threads out across the frame
  float fall = pow(q, uFalloff) + uTail * pow(q, 1.1);
  float limb = pow(min(1.0 / cosI, 14.0), uLimb);
  vec3 rgb = col * (uDiskGain * gd * gd * gd * fall * strand * clump * limb * inner * outer);
```

> **Lesson: Turning the camera round a black hole's disk doesn't move its bright side; the spin direction does** (My own mistake; learned by the AI)
> **I assumed:** to put the Doppler-bright side of the disk on the left, as in the reference, I could turn the camera 180° round the disk's axis.
> **What happened:** the bright side stayed where it was.
> **Why:** the disk is the same all the way round, so from any azimuth at the same inclination the gas turning toward the camera is on the same side of the screen; which side is brighter depends only on which way the gas turns as seen from the camera.
> **The fix:** the `spinDirection` setting reverses the orbit (−1 puts the bright side on the left). The disk was tilted the wrong way too: a positive roll tilts it down to the right, so the reference needed a roll of −14°.
> **Evidence:** first milestone (5dd4712); `DEFAULT_SETTINGS` in infall-settings.ts (roll −14, spinDirection −1).

## The sky: stars, a dust lane and gold dust

Stars are scattered on the six faces of a cube round the scene. A ray that escapes leaves in the direction its path ends, so stars near the shadow are bent round it, and the shader measures how much sky one pixel covers after the bend, so stars squeezed near the rim stay smooth and dim:

`components/infall-shaders.ts`, lines 481–487:

```ts
  // Where the escaping light came from. Seen this close, the whole frame lies inside the Einstein radius, so every
  // star would be smeared into an arc; uStarLens keeps the full bend near the rim and eases it off further out.
  vec3 dOut = esc ? cos(phiOut) * c + sin(phiOut) * e : dir;
  float lensW = mix(uStarLens, 1.0, exp(-max(b - B_CRIT, 0.0) / 1.2));
  vec3 dStar = normalize(mix(dir, dOut, lensW));
  // how much sky one pixel covers after the bend, so squeezed stars near the rim stay smooth (and dim)
  float foot = max(length(fwidth(dStar)) / uPixAngle, 1.0);
```

> **Lesson: Close to a black hole, the whole frame is inside the Einstein radius, so full lensing smears every star into an arc** (My own mistake; learned by the AI)
> **I assumed:** bending every star by its traced light path, as physics does, would give a starfield like the reference's.
> **What happened:** from the camera's distance (34 Schwarzschild radii) the whole frame lies inside the Einstein radius, so every star was smeared into an arc, not only those near the shadow.
> **The fix:** stars take the full bend near the shadow's rim and less further out: the `starLens` setting blends the bent direction with the straight one, more the farther the ray passes from b_c.
> **Evidence:** first milestone (5dd4712); lines 481 to 487 of infall-shaders.ts.

The blue-grey dust lane behind the hole is the one part of the sky that is not traced through the bent paths. It comes from each ray's straight direction: its elevation above a plane turned 6° against the disk's about the line of sight, streaked along its length and cleared away round the hole.

`components/infall-shaders.ts`, lines 525–537:

```ts
  // The dust lane: a broad blue-grey band along the disk's plane, far beyond the gas, the way the Milky Way is a band
  // seen from inside the galaxy. It follows the elevation of each ray above its plane (so it stays straight and swings
  // with the camera), streaked along its length, and clears away round the hole. Its plane may be tilted a little
  // against the disk's (hazeTilt), so it crosses the band at the hole as in the reference.
  if (uHaze > 0.0) {
    float elev = asin(clamp(dot(dir, uHazeNormal), -1.0, 1.0)) - uHazeCenter;
    float band = exp(-elev * elev / (uHazeWidth * uHazeWidth));
    float az = atan(dir.z, dir.x) / TAU;
    vec3 f = textureLod(uFields, vec2(az * 3.0, 0.3 + elev * 2.2), 0.0).rgb;
    float streak = clamp(0.35 + 0.9 * f.b * (0.75 + 0.5 * f.g), 0.0, 1.6);
    float clear = smoothstep(1.6 * B_CRIT, 4.5 * B_CRIT, b);
    col.rgb += uHazeColor * (uHaze * band * streak * clear * uHazeFade);
  }
```

> **Lesson: A background band traced through a black hole's bent light becomes a second arch** (My own mistake; learned by the AI; three@0.186)
> **I assumed:** the blue-grey dust lane behind the hole would look right traced through the same bent light paths as the disk.
> **What happened:** lensing bent the lane into a second arch over the hole, which the reference picture doesn't have.
> **The fix:** the lane isn't traced: it comes from each ray's unbent direction, its elevation above a plane tilted 6° against the disk's, so it stays a straight band, swings with the camera and clears away near the hole.
> **Evidence:** commit 180bb10, the second milestone.

The gold dust is tens of thousands of points on Kepler orbits, projected with the ray tracer's camera. Being points, they can't go through the ray tracer, so the ones behind the hole are bent by the thin-lens equation of a point mass: a source at angle β from the hole appears at θ = (β + √(β² + 4θ_E²)) / 2, pushed out past the Einstein ring θ_E, and brightened by that image's magnification. What still lands on the shadow is hidden. Sparks nearer or farther than the hole open into bokeh discs by their circle of confusion, with the focus on the hole:

`components/infall-sparkles.ts`, lines 74–101:

```ts
  float Dl = length(uCamPos);
  vec2 ang = vec2(dot(d, uCamRight), dot(d, uCamUp)) / z;
  float Dls = z - Dl;
  float mag = 1.0;
  float far = 1.0;
  if (Dls > 0.0) {
    // the far side of the band is lifted over the hole like the disk; keep it quieter so it reads as depth
    far = mix(1.0, uFar, smoothstep(0.0, 6.0, Dls));
    // behind the hole: the brighter image of a point lens, pushed out past the Einstein ring
    float te2 = 2.0 * Dls / (Dl * z);
    float beta = max(length(ang), 1e-5);
    float tp = 0.5 * (beta + sqrt(beta * beta + 4.0 * te2));
    ang *= tp / beta;
    float u = beta / sqrt(te2);
    mag = min((u * u + 2.0) / (2.0 * u * sqrt(u * u + 4.0)) + 0.5, 3.0);
    // what still lands on the shadow is behind it
    if (length(ang) < 1.06 * B_CRIT / Dl) {
      hide();
      return;
    }
  }

  float px = uRes.y / 1080.0;
  float base = look.x * uSize * px;
  // circle of confusion with the focus on the hole: near and far sparks open into discs
  float coc = min(0.5 * uAperture * abs(1.0 / z - 1.0 / Dl) * uRes.y, uMaxBokeh * px);
  float rad = max(base, coc);
  vBokeh = smoothstep(1.5 * base, 4.0 * base, coc);
```

## A light theme: a window onto the night

On a light page Infall inks the picture on paper, but the hole stays a window onto the night: the shadow, and the dark gap round it out to b_c + 0.95, keep the dark render, like an eclipse. The canvas's alpha carries that window:

`components/infall-shaders.ts`, lines 518–523:

```ts
  // the shadow's rim: the pixels it cuts are shared with the black over one pixel, not stair-stepped
  float outside = clamp(0.5 + 0.5 * (b - B_CRIT) / max(bw, 1e-7), 0.0, 1.0);
  if (esc) col.rgb *= outside;
  // Alpha is the hole's "window" for the light theme: the shadow, and the dark gap between it and the disk's lensed
  // inner edge, fading out by b_c + 1. There the light theme keeps this (night) picture instead of inking it on paper.
  col.a = max(1.0 - outside, 1.0 - smoothstep(B_CRIT + 0.3, B_CRIT + 0.95, b));
```

> **Lesson: On a light page, masking only a black hole's shadow leaves a pale ring like the white of an eye** (My own mistake; learned by the AI)
> **I assumed:** for the light theme, keeping the dark render inside the shadow and inking everything else on paper was enough.
> **What happened:** the dark gap between the shadow and the lensed inner edge of the disk turned to paper: a pale ring round the black hole that made it look like an eye.
> **The fix:** the dark render shows through the shadow and the gap round it, fading out by an impact parameter of b_c + 0.95: a night window, like an eclipse.
> **Evidence:** commit 00b192a, the third milestone; lines 518 to 523 of infall-shaders.ts.

## Making it run at 60 fps

Every number in this part was measured on a laptop with an Intel Iris Plus, an integrated GPU.

> **Lesson: Procedural noise per pixel held a full-screen ray-tracing shader at 34 fps; baked into textures once, 60 fps** (My own mistake; learned by the AI; three@0.186)
> **I assumed:** computing the disk's noise (the threads' meander, bright patches and dust) in the shader, for every pixel of every frame, was affordable.
> **What happened:** on an Intel Iris Plus the black hole ran at about 34 fps at 1440×900.
> **The fix:** the noise is baked once on the GPU into textures when the black hole starts, and the shader only reads them.
> **Evidence:** first milestone (5dd4712): 60 fps at 1440×900 with every pixel traced, on the same machine.

On bigger or high-DPI screens a governor trades traced resolution for frame rate. It starts from a pixel budget an integrated GPU holds at 60 fps (1.3 million pixels), trims 15% when a second runs under 50 fps, and climbs back 10% after three good seconds, never under 45% of full and never above the last level that proved too slow:

`components/infall-core.ts`, lines 700–730:

```ts
  // Governor: once a second, a frame rate under 50 trims the traced resolution by 15%; three good seconds in a row
  // raise it by 10%, never above the last level that proved too slow (that ceiling is retried after 20 s). The
  // composite and grain always run at the canvas's full resolution.
  let windowStart = 0;
  let windowFrames = 0;
  let warmup = 2;
  let good = 0;
  let ceiling = 1;
  let ceilingSince = 0;
  const govern = (now: number, fps: number) => {
    if (warmup > 0) {
      warmup--;
      return;
    }
    if (ceiling < 1 && now - ceilingSince > 20000) ceiling = Math.min(1, ceiling * 1.05);
    if (fps < 50 && quality > MIN_QUALITY) {
      ceiling = quality * 0.98;
      ceilingSince = now;
      quality = Math.max(MIN_QUALITY, quality * 0.85);
      good = 0;
      warmup = 1;
      resize();
    } else if (fps >= 57) {
      good++;
      if (good >= 3 && quality < ceiling) {
        quality = Math.min(ceiling, quality * 1.1);
        good = 0;
        warmup = 1;
        resize();
      }
    } else good = 0;
```

> **Lesson: A resolution governor that starts at full quality first draws four times the pixels it needs** (My own mistake; learned by the AI)
> **I assumed:** the governor could start at full quality (1.0) and settle down from there.
> **What happened:** starting at 1.0, it drew four times the pixels the first real canvas size needed.
> **The fix:** the starting quality comes from the pixel budget and the canvas's first real size, not from the 1 × 1 canvas the renderer is created with.
> **Evidence:** commit 00b192a; lines 405 to 412 and 796 to 800 of infall-core.ts.

Compiling the shaders was slower still. The disk's thread layers, the star layers and the disk crossings are all loops:

`components/infall-shaders.ts`, lines 260–278:

```ts
// The disk's fine structure as bright threads with dark gaps between them, ~1 on average. turns = position round the
// disk, warp = the threads' meander here, r = radius, rw = change of r across one pixel. The layers (each 2.2× finer
// and a little fainter) run in a loop whose length is a uniform, so compilers keep it a loop instead of copying the
// whole body four times: that alone makes the shader several times quicker to compile on Direct3D.
float filaments(float turns, float warp, float r, float rw) {
  float y = (r + warp) * uFilamentScale;
  float ys = rw * uFilamentScale;
  float f = 0.0;
  float wsum = 0.0;
  float freq = 2.2;
  float w = 1.0;
  for (int L = 0; L < uThreadLayers; L++) {
    f += w * threadLayer(turns, y, freq, float(L), freq * ys);
    wsum += w;
    freq *= 2.2;
    w *= 0.7;
  }
  return f / (0.0886 * max(wsum, 1e-3) * uThreadWidth);
}
```

> **Lesson: On Windows, WebGL's ANGLE (Direct3D 11) unrolls loops with constant bounds: the first draw took 3.7 s** (The tools and the machine; learned by the AI; three@0.186)
> **I assumed:** loops with constant bounds in a fragment shader (thread layers, star layers, disk crossings) cost nothing extra to compile.
> **What happened:** in Chrome on Windows, where WebGL runs through ANGLE on Direct3D 11, the compiler unrolled them, copying each loop's body, and the first draw stalled for 3.7 s on an Intel Iris Plus.
> **The fix:** loop counts come from `int` uniforms, so the compiler can't unroll them; at most three disk crossings (`MAX_CROSS 3`); and the shaders compile with three.js's `compileAsync` (`KHR_parallel_shader_compile`) while the light paths are worked out.
> **Evidence:** third milestone (00b192a): the first draw took 0.2 to 0.4 s after the change, on the same machine.

## Off the main thread

Everything that draws runs in a web worker on an OffscreenCanvas, and it starts before the hero is even mounted: as soon as the component's module runs in the browser, a worker creates its GPU context, compiles the shaders and works out the light paths while the page is still hydrating. The first black hole that mounts takes the prepared worker over; one nobody claims is stopped after 15 seconds, and where workers can't draw, the engine runs on the main thread instead.

`components/infall-prepare.ts`, lines 35–63:

```ts
/**
 * Starts the slow part before any black hole is mounted: a render worker creates its GPU context, compiles the
 * shaders and works out the light paths while the page is still hydrating, and the first black hole mounted takes
 * it over with its own settings and start time. Call it once, as early as you can, in the browser. A worker no black
 * hole claims within 15 s is stopped. Does nothing where workers cannot draw.
 */
export function prepareBlackHole() {
  if (prepared || typeof window === "undefined" || !workersDraw()) return;
  try {
    const canvas = makeCanvas();
    // held: no first frame until a black hole says when
    const worker = startWorker(canvas, { startAt: Infinity });
    const p: Prepared = { canvas, worker };
    worker.onmessage = (e: MessageEvent) => {
      if (e.data.type === "error") p.error = e.data.message;
    };
    // handled: the black hole that claims it starts afresh
    worker.onerror = (e) => {
      e.preventDefault();
      p.error = e.message || "render worker failed";
    };
    prepared = p;
    setTimeout(() => {
      if (prepared !== p) return;
      prepared = null;
      worker.terminate();
    }, 15000);
  } catch {}
}
```

> **Lesson: A three.js renderer created and drawn on the main thread blocks the page while it loads** (The tools and the machine; learned by the AI; three@0.186)
> **I assumed:** once its shaders compiled asynchronously, the black hole could render on the main thread without slowing the page down.
> **What happened:** creating the `WebGLRenderer` alone cost about 600 ms on the main thread, and loading the page gave long tasks of 300 to 680 ms.
> **The fix:** the renderer runs in a web worker on an OffscreenCanvas, started as soon as the component's module loads; the main thread only hands over the canvas and claims the worker.
> **Evidence:** commit 00b192a, the third milestone: no long tasks from the black hole while the page loads.

> **Lesson: An animated CSS blur on the page delays a WebGL worker's start by up to 2 seconds** (The tools and the machine; learned by the AI)
> **I assumed:** an animated CSS `filter: blur()` on the headline's entrance costs nothing to a canvas drawn in a web worker.
> **What happened:** while the blur animated, the black hole in the worker started 0.5 to 2 seconds later: the blur kept the GPU busy while the worker was setting up.
> **The fix:** the entrance has no blur; the words fade and rise instead.
> **Evidence:** commit 00b192a, the third milestone.

> **Lesson: A still frame drawn on a worker's OffscreenCanvas outside requestAnimationFrame never shows** (The tools and the machine; learned by the AI)
> **I assumed:** for visitors who ask for reduced motion, drawing one frame in the worker was enough to show it.
> **What happened:** the still frame never appeared on the page.
> **The fix:** still frames are drawn inside `requestAnimationFrame`, like every other frame.
> **Evidence:** commit 00b192a ("Still frames are drawn in an animation frame so reduced motion shows from a worker too").

The light paths get a worker of their own when the engine runs on the main thread. The code that starts that worker lives in a small module of its own:

`components/infall-tables.ts`, lines 1–28:

```ts
import type { GeodesicTables } from "@/components/infall-geodesics";

/**
 * Fills `tables` in a worker when one can be started, else here. Resolves once they are filled.
 */
export function fillTablesOffThread(tables: GeodesicTables, cameraDistance: number): Promise<void> {
  return new Promise((resolve) => {
    let worker: Worker | null = null;
    const here = () => {
      worker?.terminate();
      tables.compute();
      resolve();
    };
    try {
      worker = new Worker(new URL("./infall-geodesics.worker.ts", import.meta.url), { type: "module" });
    } catch {
      here();
      return;
    }
    worker.onmessage = (e: MessageEvent<{ paths: Float32Array; ends: Float32Array }>) => {
      tables.adopt(e.data.paths, e.data.ends);
      worker?.terminate();
      resolve();
    };
    worker.onerror = here;
    worker.postMessage({ cameraDistance, bMax: tables.bMax });
  });
}
```

> **Lesson: Next.js 16's build hangs when a web worker imports the module that starts it** (The tools and the machine; learned by the AI; next@16.3)
> **I assumed:** the module holding the light-path code could also start the web worker that computes them, with `new Worker(new URL("./x.worker.ts", import.meta.url), { type: "module" })`, while the worker imports that same module.
> **What happened:** `next build` (Turbopack) hung and had to be killed: the worker imported `infall-geodesics.ts`, which started the worker that imported it.
> **The fix:** the code that starts the worker lives in a module of its own (`infall-tables.ts`) that the worker never imports.
> **Evidence:** third milestone (00b192a); infall-tables.ts.

## The dive

Scrolling through the hero eases a progress value p from 0 to 1. The camera's distance falls from 34 to 3.4 in log space, gently at first and faster as p grows, like a fall: D = 34·(3.4/34)^(p^1.9). It rises over the disk and turns round the hole as it goes, the light reddens and dies near the horizon, and each frame only the camera's value in each row of the tables is redone.

`components/infall-core.ts`, lines 444–467:

```ts
  /** Where the camera is: where the settings put it, swung toward the pointer, and carried along the dive. */
  const camera = () => {
    const s = settings;
    const end = Math.min(DIVE_END, s.distance);
    // in log(distance), gently at first and faster as the pull grows, like a fall; the shadow fills the frame at
    // about 0.85
    const fall = Math.pow(dive, 1.9);
    // rising over the disk and turning round the hole, as matter does on its way in
    const turn = smooth(0.06, 0.95, dive);
    // the pointer swing settles as the fall begins
    const steady = 1 - smooth(0, 0.2, dive);
    return {
      distance: s.distance * Math.pow(end / s.distance, fall),
      inclination: Math.min(
        Math.max(s.inclination + (DIVE_INCLINATION - s.inclination) * turn - swing.y * s.parallaxY * steady, 30),
        89.5,
      ),
      azimuth: s.azimuth + DIVE_TURN * turn + swing.x * s.parallaxX * steady,
      roll: s.roll * (1 - turn),
      // the hole comes to the middle of the frame, so the dark closes in evenly
      shiftX: s.shiftX * (1 - smooth(0, 0.75, dive)),
      shiftY: s.shiftY * (1 - smooth(0, 0.75, dive)),
    };
  };
```

## When there's no WebGL

Without WebGL 2, or when the GPU takes the context away (a driver reset, a laptop waking up), the box shows an SVG drawing of the black hole framed from the same settings, and the engine tries again by itself, three times at most. Visitors who ask for reduced motion get one still frame: no intro, no swing, no fall.

> **Lesson: Chrome's --disable-3d-apis flag doesn't reach web workers; test without WebGL another way** (The tools and the machine; learned by the AI)
> **I assumed:** starting Chrome with `--disable-3d-apis` would show what a visitor without WebGL sees.
> **What happened:** the worker still got a WebGL context, so the drawing that stands in for the black hole never showed.
> **The fix:** test without WebGL with `--disable-gpu --disable-software-rasterizer`, which takes WebGL away from workers too.
> **Evidence:** commit bc3ce8a, the drawing that stands in.

## Shipping it as a component

On UpFork, Infall installs with the shadcn CLI as 17 flat files, written from the source by a script, with its stylesheet as a string the hero renders in a style element, and its two workers addressed next to the files that start them.

> **Lesson: A server page that imports a plain object from a 'use client' module gets a reference, not the object** (My own mistake; learned by the AI; next@16.3)
> **I assumed:** a server page could import a plain data object (the hero's words and settings) from a module marked `"use client"`.
> **What happened:** the page received a client reference, and every field of the object read as undefined.
> **The fix:** data shared by server and client lives in a plain module, without `"use client"` (`infall-hero-data.ts`).
> **Evidence:** commit c60810c.

> **Lesson: The shadcn CLI drops a worker file's reference to TypeScript's webworker lib, so the buyer's build fails** (Not in the docs; learned by the AI; shadcn@4.21)
> **I assumed:** the worker files could type their scope with TypeScript's webworker lib, through a `/// <reference lib="webworker" />` line at their top.
> **What happened:** installed with the real shadcn CLI (4.21) into a fresh Next.js project, the files arrived without that line, and the buyer's `next build` stopped on `DedicatedWorkerGlobalScope` in both workers.
> **The fix:** each worker types its scope by what it uses (`postMessage`), with no lib at all, so it type-checks in any project.
> **Evidence:** commit 8706fe4, found by UpFork's install check.

`components/infall-render.worker.ts`, lines 1–10:

```ts
import { createCore, type CoreApi } from "@/components/infall-core";

// The renderer, running in a worker on an OffscreenCanvas: the page posts sizes, settings and pointer moves, and
// hears back the frame rate and the camera's position.
let core: CoreApi | null = null;
// The worker's scope, typed by what it uses here: not lib "webworker", whose /// <reference> line the shadcn CLI
// leaves out when it installs this file.
const post = (message: unknown) => (self as unknown as { postMessage(message: unknown): void }).postMessage(message);

self.onmessage = (e: MessageEvent) => {
```

## Use it

Infall is free. Install it into a Next.js project with Tailwind CSS:

```bash
npx shadcn@latest add https://upfork.dev/r/black-hole-hero.json
```

---

On UpFork: https://upfork.dev/articles/how-infall-ray-traces-a-black-hole-in-the-browser-and-what-we-learned
