A black hole ray-traced live in the browser. Every pixel follows a bent light path past a non-spinning (Schwarzschild) black hole, so the accretion disk's far side is lensed into an arc over the top and under the bottom of the shadow, with a thin photon ring hugging it and one side of the disk Doppler-brightened. Until you touch it, the camera orbits slowly.
How it works
- Raw WebGL2 and GLSL ES 3.00, no engine and no new dependencies. One full-screen triangle per pass.
- Geodesics: units where the Schwarzschild radius Rs is 1. Each ray starts at the camera and is bent with the photon equation in Cartesian form, a = −1.5 h² r / |r|⁵, where h = |r × v| is conserved along the ray. It is integrated with velocity Verlet at a step proportional to the radius, about seven times finer near the photon sphere, 560 steps at most on the high tier and 200 on the light tier. A ray that drops below the horizon stays black, one that escapes samples the sky in its final direction.
- Lean march loop: the loop only records up to four crossings of the disk plane and buckets jet and probe light between them; the expensive disk shading and the front-to-back compositing run after the loop. With the shading inside the loop the same shader was about 20 times slower on the GPU (it ran out of registers), even though the frame clock still reported 60 fps.
- Photon ring without dots: pixels whose ray came within 3.4 Rs of the hole are traced four times with sub-pixel offsets and averaged (high tier), so the thin secondary and tertiary images of the disk read as smooth rings instead of a dotted edge. Sky light reached by rays that skimmed the photon sphere is faded.
- Disk: a thin slab from just inside the ISCO (3 Rs) to a ragged edge around 12 Rs, fading into a faint wispy halo out to 19 Rs. Every crossing of the disk plane composites disk light front to back, so the lensed images behind the hole show up too; on the high tier each crossing takes two samples through the slab's thickness, and a slanted view looks through more gas. Gas is fbm noise in polar coordinates with filaments stretched along the orbit, a two-armed spiral, rare bright clumps and dark dust lanes, every ring turning at the Keplerian rate (inner faster) so features shear into trailing spirals; two copies of the pattern restart half a period apart and cross-fade so the shear never winds into a smear.
- Light: temperature follows the thin-disk profile (peak at 49/36 of the inner edge) plus a hotter rim at the ISCO, turned into colour through a blackbody lookup texture filled from a unit-tested TypeScript function. Gas orbits at sqrt(M / (r − 2M)) of light speed (0.5 c at the ISCO); the shift g combines special-relativistic Doppler with the gravitational redshift of a static observer, which also dims everything close to the horizon. Brightness scales with g⁴ and the colour temperature with g.
- Jets (switch): faint bipolar jets along the disk axis, widening with height, with brighter knots travelling outwards; they are lensed like everything else.
- Probe (switch): a small hot star on a circular orbit tilted 22 degrees from the disk whose radius breathes between 9.8 and 3.2 Rs. Its light carries its own Doppler and gravitational shift, it shows lensed second images when it passes behind the hole, and a readout gives its radius, clock rate (sqrt(1 − 1.5 Rs / r), the time dilation of a circular orbit) and redshift. The orbit runs faster than real time so a dive takes under a minute.
- Sky: three layers of procedural stars with blackbody colours and a steep magnitude distribution (static, no twinkle), a faint two-tone nebula band with darker dust, and a small spiral galaxy placed straight behind the hole from the opening view, so it smears into arcs and an Einstein ring when you line it up.
- Lensing switch: sets the bending term to zero, so rays run straight and the lensed arc disappears; the horizon stays as a black sphere for comparison.
- Post: the scene renders at 0.6 of the canvas resolution into a half-float buffer, then a soft-knee bright pass, a 13-tap downsample chain and tent upsample for a tight and a wide bloom, an anamorphic horizontal streak (switch), chromatic aberration growing towards the edges, an ACES filmic curve, gamma, a vignette and fine animated grain that also dithers away banding. A guard drops any non-finite pixel so it cannot spread through the bloom.
Performance
- Measured with GPU timer queries (
?debug writes the smoothed GPU time to data-gpu-ms on the canvas): 4 to 11 ms per frame at 1440×900 and about 5 to 8 ms on a phone-sized canvas at pixel ratio 2, on an RTX 4060 laptop GPU. - Pixel ratio capped at 2 (1 on the light tier). The light tier takes longer and fewer steps, one slab sample and no supersampling.
- A governor drops to the light tier once the smoothed frame time stays over 22 ms for 2 seconds;
?quality=low starts there. - The camera stops at 5.5 Rs: nearer than that the shadow grows wider than the field of view and the screen turns black.
- The loop runs only while something moves (idle orbit, disk spin, probe, easing after input) and only while the canvas is on screen and the tab is visible; otherwise it renders on demand. A lost graphics context shows a "Relight the black hole" state that rebuilds the renderer.
- Camera orbit maths, presets, blackbody colour, disk temperature, Doppler factor, the probe's orbit, time dilation and redshift, and the governor are pure functions with unit tests.
Reduced motion
No idle orbit, no disk rotation, no probe movement, no jet knots travelling and no throw momentum; the page opens on a still, composed frame and the view changes only on your input, cutting straight to presets.
Honest limits
Schwarzschild, not Kerr (no spin, so the shadow stays round); a thin, artistic disk; a made-up peak temperature; an approximate Doppler factor; no light travel time.