The worldline · The present resting place
Created by Chris Portka
The circle of eternal return · The spinning cycle of time
A scientifically grounded, GPU-accelerated, animated black hole and spacetime visualizer that runs in the browser — event-horizon shadow, photon ring, gravitationally lensed background, and a live relativistic accretion disk. Built on WebGPU (with an automatic WebGL2 fallback) and architected to grow into a general gravitational N-body simulator.
- Ray-traced Schwarzschild black hole — event-horizon shadow, photon ring and gravitational lensing from per-pixel photon geodesics (RK4 integration of the Schwarzschild metric), with an automatic WebGL2 fallback.
- Live relativistic accretion disk — Shakura–Sunyaev temperature → blackbody colour, Doppler beaming and gravitational redshift, over volumetric, turbulent, infalling dust — a participating medium sampled inside the raymarch, so it lenses correctly along the bent rays.
- Gravitational N-body companions — stars, planets and up to four secondary black holes (each with its own lensed accretion disk), simulated and raymarched inside the hole's curved spacetime, so they lens and are occluded for free.
- Time control — scrub from ×1/1000 slow-motion to ×1,000,000. Rather than brute-forcing the dynamics, the representation crossfades: orbits accelerate (bounded) and the fine turbulence averages into a steady disk.
- Selectable lensed skies — Stars, Nebula, Filaments, Lattice — each post-tunable for brightness / saturation / tint.
- Art-directed intro — a scripted and tuned moment of creation and binary star merger intro (all painting before the shader even compiles), crossfading into a camera dolly + disk ignition. Replay melts the live view and plays it all again.
- Filters & polish — named looks (Physical / EHT / Interstellar / Stylized), HDR bloom, adaptive resolution, and a touch-friendly control panel.
Drag to orbit · pinch / scroll to zoom · open the panel (top-right) to add bodies, change the sky, scrub time, and tune the look. Keyboard (press ? for the full list): Esc About · Space Pause/Resume · ← / → Step back / forward · ↑ / ↓ double / halve Speed · R Replay · C Clear · F HUD.
The intro opens on the moment of creation: a seed ignites at a still point and everything outward before folding back to black. Pure CSS (no canvas), instant and identical on every device.
Captured from:
scripts/capture-creation.mjs (npm run capture:creation) — re-run it whenever the burst changes to refresh
assets/creation.gif.
A tiny, art-directed binary-merger splash — all painting before WebGPU, no blank
screen. Two warm stars spiral together through a field of drifting dust, flash and merge,
and the new event horizon settles inside an accretion ring as neon shock waves reverberate
outward — crossfade to the live, formed black hole. Plain CSS + one <canvas> layer (no engine),
targets 200 fps, and starts on the first painted frame so plays in full on mobile. The
full beat-by-beat storyboard (and the screenplay) lives in
docs/intro-script.md.
Captured from:
scripts/capture-splash.mjs (npm run capture:splash) — re-run it whenever the splash changes to refresh
assets/splash.gif.
Actively developed in small, themed phases. See CHANGELOG.md
for the full version history, docs/future-improvements.md
for the roadmap, and docs/ for design notes (the intro script,
screen-recording findings, and performance audits).
- TypeScript + Vite
- Three.js r184 via
three/webgpu(WebGPURenderer, auto WebGL2 fallback) - TSL (
three/tsl) — one shader source compiles to both WGSL and GLSL - OrbitControls for swipe-orbit / pinch-zoom
- lil-gui for the control panel
Requires Node 20.19+ or 22.12+.
npm install
npm run dev # http://localhost:5173 (a secure context, so WebGPU works)npm run lint # eslint
npm run typecheck # tsc --noEmit
npm test # vitest (unit tests for the physics)
npm run validate # CPU physics checks (geodesic / disk / orbit / lensing)
npm run build # typecheck + vite build → dist/
npm run preview # serve the production build locallyLint, typecheck, tests, and the validation scripts run in CI
(.github/workflows/ci.yml) on every push to main and every pull request.
Append ?webgl to the URL to force the WebGL2 fallback path for testing
(e.g. http://localhost:5173/?webgl).
The camera is an input device only. We render a single fullscreen quad and feed the orbit camera's position/orientation into the raymarch shader as uniforms each frame. A guiding constraint: the infalling dust is a volumetric participating medium sampled inside the raymarch, never rasterized particles — only that way does it lens correctly along the bent light rays.
src/
main.ts bootstrap: wire uniforms → camera/loop/pass → render loop
core/
Renderer.ts WebGPURenderer + automatic WebGL2 fallback
CameraRig.ts PerspectiveCamera + OrbitControls → camera uniforms; intro dolly driver
Loop.ts requestAnimationFrame driver → real frame delta
TimeController.ts decouples sim time from wall-clock: scale / pause / step ± / crossfade
History.ts zero-alloc ring buffer of body states — foundation for a scrub bar
FormationSequence.ts the intro: camera dolly + disk "ignition" (skip / replay / reduced-motion)
ResolutionScaler.ts adaptive drawing-buffer scale from frame time
quality.ts device-tier auto-detect (resolution / dust step / DPR cap)
device.ts coarse-pointer / reduced-motion probes (framing, tooltips, intro)
scene/
Scene.ts owns the Body list + PhysicsEngine; spawns companions; prune/absorb/plunge
Body.ts a gravitating body (hole / star / planet)
BlackHole.ts the hole's parameters as uniforms (mass = length scale)
physics/
PhysicsController.ts switches CPU/GPU integrators behind one step(dt)
PhysicsEngine.ts N-body integrator driver (CPU velocity-Verlet, adaptive substeps)
integrators.ts velocity-Verlet + Newtonian accelerations
GPUPhysicsEngine.ts opt-in WebGPU compute N-body (storage buffers + kernels)
ui/
Controls.ts lil-gui panel (lazy-loaded, mounted at idle off the splash); also the persistence wiring
settings.ts one localStorage profile — every control auto-saves / auto-loads
keybindings.ts keyboard shortcuts (Esc About · ? help · Space Pause · ←/→ Step · ↑/↓ Speed · R/C/F)
shortcuts.ts the "?" keyboard-shortcuts cheat-sheet overlay
presets.ts named looks / "filters" (Physical / EHT / Interstellar / Stylized)
stepper.ts the Bodies "− N +" add/remove rows (✓/✗ flash)
about.ts the About modal (author, project link, donations, privacy, animated logo)
share.ts the Share button: hand the last ~5s clip to the OS share sheet, else download it
clipRecorder.ts rolling ~5s 720² mp4 buffer of the canvas (WebCodecs H.264 → mp4-muxer; the Share clip source)
touchTooltips.ts long-press tooltips for touch devices (no native hover)
versionBadge.ts click-to-copy version chip
hud.ts lower-left HUD: FPS + frame-time graph + resolution + debug detail
render/
uniforms.ts the shared uniform "bus" (camera, time, background, resolution)
RaymarchPass.ts fullscreen quad + node material (the colour node plugs in here)
PostPipeline.ts WebGPU node pipeline: HDR bloom → ACES tone-map
bodyUniforms.ts companion render slots (position / radius / colour / appear / absorb)
tsl/
raymarch.ts geodesic loop + volume march + body spheres + secondary-hole disk
schwarzschild.ts photon acceleration + static-observer ray (the metric)
disk.ts flux/temperature profile + Doppler & redshift shift
medium.ts volumetric dust: density, emission, scatter, extinction
secondaryDisk.ts compact accretion disk around an added (secondary) black hole
flow.ts Keplerian Ω(r) + advected (co-rotating) noise coordinate
turbulence.ts fractal (FBM) noise
blackbody.ts temperature (K) → linear RGB
bodies.ts segment–sphere / stretched-ellipsoid tests for companions
starfield.ts procedural lensed star field
background.ts selectable sky (Stars / Nebula / Filaments / Lattice), all lensed
index.html inlines the shared intro overlay (the @osp-intro-overlay marker) and defers the engine bundle
vite.config.ts build config + the introOverlay() plugin that inlines overlay.html into both HTML entries
src/intro/ the intro as one self-contained, forkable unit (its own README.md + filter-repo recipe):
overlay.html (source of truth — markup + inline boot script, inlined by the plugin above),
intro.css (all the intro styles, linked separately), introTimeline.ts (timing, mirrored by
the inline dials), melt.ts (the Replay melt)
scripts/
validate-*.mjs CPU physics checks (geodesic / disk / orbit / lensing) — npm run validate
capture-splash.mjs render the load splash to assets/splash.gif — npm run capture:splash
capture-creation.mjs render the moment of creation to assets/creation.gif — npm run capture:creation
verify-intro.mjs headless visual test of the intro prelude beats — npm run verify:intro
assets/ tracked art: hero.svg (animated mark) + logo.svg (the static Ember Core mark,
also served as public/favicon.svg) + creation.gif + splash.gif (intro loops)
Pushing to main triggers .github/workflows/deploy.yml, which builds with Vite
and publishes dist/ to GitHub Pages. The custom apex domain is set in
Settings → Pages → Custom domain (onestillpoint.app); with artifact-based
deploys no committed CNAME file is needed. vite.config.ts uses base: '/'
because the site serves from the domain root.
MIT © 2026 Chris Portka. Bundled environment assets (star cubemaps / HDRIs), if any, retain their own licenses.

