Loading the page…
Loading the page…
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.

The product
Free · Component
A React hero section with a black hole ray-traced live in WebGL: real light bending, a scroll dive into the event horizon, and light and dark themes.
npx shadcn@latest add https://upfork.dev/r/black-hole-hero.jsonInfall 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.
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.
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:
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:
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.
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:
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.
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.
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:
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:
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:
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.
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:
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:
Every number in this part was measured on a laptop with an Intel Iris Plus, an integrated GPU.
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:
Compiling the shaders was slower still. The disk's thread layers, the star layers and the disk crossings are all loops:
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.
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:
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.
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.
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.
Infall is free. Install it into a Next.js project with Tailwind CSS:
npx shadcn@latest add https://upfork.dev/r/black-hole-hero.jsonFree: no account or token needed.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
Their page and productsSomething wrong in it, or against the rules? Tell UpFork.
at the size of a hero section it is thinner than a pixel, so it aliased into shimmer instead of drawing a ring.
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.
commit 5dd4712, the first milestone; the comment above the crossing loop in infall-shaders.ts.
first milestone (5dd4712): frames checked after up to ten hours of spinning look like the first.
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°.
first milestone (5dd4712); DEFAULT_SETTINGS in infall-settings.ts (roll −14, spinDirection −1).
starLensfirst milestone (5dd4712); lines 481 to 487 of infall-shaders.ts.
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.
commit 180bb10, the second milestone.
commit 00b192a, the third milestone; lines 518 to 523 of infall-shaders.ts.
the noise is baked once on the GPU into textures when the black hole starts, and the shader only reads them.
first milestone (5dd4712): 60 fps at 1440×900 with every pixel traced, on the same machine.
commit 00b192a; lines 405 to 412 and 796 to 800 of infall-core.ts.
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.
third milestone (00b192a): the first draw took 0.2 to 0.4 s after the change, on the same machine.
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.
commit 00b192a, the third milestone: no long tasks from the black hole while the page loads.
commit 00b192a, the third milestone.
commit 00b192a ("Still frames are drawn in an animation frame so reduced motion shows from a worker too").
the code that starts the worker lives in a module of its own (infall-tables.ts) that the worker never imports.
third milestone (00b192a); infall-tables.ts.
commit bc3ce8a, the drawing that stands in.
data shared by server and client lives in a plain module, without "use client" (infall-hero-data.ts).
commit c60810c.
each worker types its scope by what it uses (postMessage), with no lib at all, so it type-checks in any project.
commit 8706fe4, found by UpFork's install check.
d²u/dφ² = 1.5·u² − u/**
* 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.
*//**
* 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 };
} 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;
}
};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]);// 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));
} // 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); // 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); // 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);
} 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); // 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)); // 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;// 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);
}/**
* 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 {}
}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 });
});
} /** 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)),
};
};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) => {
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment