diff --git a/README.md b/README.md index 5154fde..aa16228 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,20 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](tsconfig.json) [![Three.js](https://img.shields.io/badge/Three.js-r185-000000?logo=three.js&logoColor=white)](https://threejs.org) [![Vite](https://img.shields.io/badge/Vite-8-646cff?logo=vite&logoColor=white)](https://vite.dev) -[![Tests](https://img.shields.io/badge/tests-36%20passing-brightgreen)](src/sim/__tests__) +[![Tests](https://img.shields.io/badge/tests-51%20passing-brightgreen)](src/sim/__tests__) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) An interactive black hole visualizer that ray-traces **real Schwarzschild photon geodesics on the GPU**, per pixel, every frame. It renders the event horizon shadow, the photon ring, a Doppler-beamed accretion disc bent over and under the hole (the *Interstellar* look), and a gravitationally lensed starfield. You can launch photons and watch their true paths, drop a planet or star and watch it be tidally shredded, and place a second black hole to watch a gravitational-wave inspiral end in a merger, complete with a LIGO-style audio chirp. -Everything runs in the browser. No backend, no assets, no libraries beyond Three.js: the stars, disc, physics, and sound are all procedural. +Everything runs in the browser. No backend, no textures, no libraries beyond Three.js: the stars, disc, physics, and sound are all procedural. + +![A star being torn apart, its debris stream feeding the disc, against a lensed deep field](assets/images/blackhole_1.png) + +*A disruption in progress. The stream is debris on real orbits, the arcs around the shadow are background galaxies lensed by the same geodesics, and the whole sky is generated at boot.* + +![Flamm's paraboloid drawn as a wireframe funnel under the shadow](assets/images/blackhole_2.png) + +*The curvature grid (`G`): Flamm's paraboloid, the exact embedding of a Schwarzschild slice. During an inspiral the binary's gravitational waves ripple outward across it.* ## Features @@ -18,7 +26,13 @@ Everything runs in the browser. No backend, no assets, no libraries beyond Three - **Interactive photon trajectories**: click to launch fans of photons; escaped, captured, and near-critical rays are drawn as glowing curves computed by the same integrator the shader uses. - **Tidal disruption**: place a planet or star. *Cinematic* mode gives a directable inward spiral with spaghettification and a debris stream that feeds and brightens the disc. *Realistic TDE* mode launches a true zero-energy parabolic plunge, one violent shredding at pericenter, and a physically motivated bound/unbound debris split (roughly half the debris escapes, as in real TDEs). - **Black hole merger**: place a second hole. Its orbit decays by the actual Peters (1964) gravitational-wave equations (the trajectory shape is exact; only wall-clock time is compressed). Both holes lens light. At contact the shadow swells to the merged mass minus the radiated gravitational-wave energy, with a ringdown wobble. +- **Deep sky**: the background is a procedurally baked HDR cubemap. Five layers of stars on a stellar-temperature colour sequence (the bright ones get diffraction spikes), a warped galactic band with dust lanes that actually extinguish the stars behind them, emission nebulae in hydrogen red and doubly-ionised-oxygen teal, globular clusters that crowd extra stars into their cores, and distant galaxies with dust lanes and spiral arms. All of it is lensed by the same geodesics, so it smears into Einstein arcs near the shadow. +- **Feeding outflow**: when a disruption dumps matter on the disc it goes super-Eddington and drives a broad, ragged wind out of the poles. Wide and un-collimated, unlike the jet, it appears and fades with the feeding itself. +- **Relativistic jet**: optional twin polar beams with braided filaments, integrated *inside* the raymarch so the jet bends with the light near the hole. The brightness difference between the two cones is the real Doppler boost, δ³. +- **Curvature grid** (`G`): Flamm's paraboloid, the exact Schwarzschild embedding diagram, drawn as a wireframe funnel. During an inspiral the binary's quadrupole gravitational wave ripples outward across it: two crests per orbit, wound into a trailing spiral, growing as the pair tightens. +- **Presets**: one-click scenes. A star being devoured, slow spaghettification, a binary merger with waves, bare curved spacetime, a jetted quasar, and a clean wallpaper frame. - **Camera flights**: plunge into the horizon, fly past, or circle the hole. +- **Cinematic mode** (`H`): fades the panel and readout for a wallpaper-clean frame. - **Procedural audio**: a Perseus-cluster-style deep drone, matter-rush noise when the disc is feeding, a gravitational-wave chirp that tracks the real orbital frequency, and a merger thump + ringdown tone whose pitch falls with the merged mass. - **Performance**: quality presets, half-resolution raymarch upscaling, temporal accumulation when idle (free anti-aliasing), auto-degrade on slow machines. @@ -72,7 +86,7 @@ You need two things: **Node.js** (version 20 or newer) and this repository. That 1. **Open your terminal.** 2. **Install Node.js 20+**: - - Debian / Ubuntu / Mint: `sudo apt update && sudo apt install -y nodejs npm` — then check `node --version`; if it prints something below 20, install via nvm instead (next line). + - Debian / Ubuntu / Mint: `sudo apt update && sudo apt install -y nodejs npm`, then check `node --version`; if it prints something below 20, install via nvm instead (next line). - Any distro, always current (nvm): `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`, reopen the terminal, then `nvm install --lts` - Fedora: `sudo dnf install -y nodejs npm` · Arch: `sudo pacman -S nodejs npm` 3. **Verify**: `node --version` → 20 or higher. @@ -102,7 +116,7 @@ You need two things: **Node.js** (version 20 or newer) and this repository. That ### Other commands ```bash -npm test # run the 36-test physics/simulation suite +npm test # run the 51-test physics/simulation suite npm run typecheck # strict TypeScript check npm run build # production build into dist/ npm run preview # serve the production build @@ -113,6 +127,9 @@ npm run preview # serve the production build | Action | How | |---|---| | Orbit / zoom | drag / scroll | +| Try a canned scene | **Presets** folder (top): star devoured, spaghettification, merger, curvature, quasar, wallpaper | +| Hide the interface | `H` (cinematic mode) | +| Show curved spacetime | `G` (Flamm funnel + gravitational waves) | | Place a planet or star | **Place** folder → button → click on the disc plane (between the guide rings) | | Choose disruption physics | **Place → Disruption mode**: cinematic spiral vs realistic one-pass TDE | | Place a second black hole | **Place → Place black hole** → click; watch the inspiral chirp in the HUD | @@ -120,8 +137,11 @@ npm run preview # serve the production build | Launch photons | **Light paths → Enabled**, then click anywhere; rays per launch and spread are sliders | | Camera flights | **Camera** folder: fly in (plunge), fly past, circle; `Esc` stops | | Sound | **Sound → Enabled** (browsers require one click on the page first) | +| Change the sky | **Deep sky** folder: star density, nebulae, galaxies, or reseed the whole thing | | Art-direction knobs | append `?debug=1` to the URL for the hidden tuning folder | +Every control carries a hover description, and each folder has a reset button. + ## The physics Geometric units throughout: $G = c = 1$, lengths in Schwarzschild radii ($r_s = 2M = 1$, so $M = \tfrac{1}{2}$). The disc lies in the equatorial plane. @@ -156,7 +176,38 @@ Planets, stars, and debris move in the Paczyński–Wiita potential $$\Phi(r) = -\frac{GM}{r - r_s},$$ -the standard pseudo-Newtonian stand-in that reproduces the correct ISCO at $3\,r_s$, so bodies destabilize and plunge exactly where the disc ends. Realistic-TDE launches are zero-energy (parabolic) orbits in this potential with pericenter at a chosen fraction of the tidal radius; the debris receives an energy spread that splits it into bound and unbound halves, mirroring the real result that roughly half a disrupted star's mass escapes. +the standard pseudo-Newtonian stand-in that reproduces the correct ISCO at $3\,r_s$, so bodies destabilize and plunge exactly where the disc ends. Specific energy and the circular speed follow from it, + +$$\varepsilon = \tfrac{1}{2}v^2 - \frac{GM}{r - r_s}, \qquad v_{\text{circ}}(r) = \frac{\sqrt{GM\,r}}{r - r_s},$$ + +and the sign of $\varepsilon$ is what the simulation reads to decide whether a piece of debris is bound or gone. + +### Tidal disruption: how the stream is built + +A body is disrupted where the hole's tide beats its self-gravity, at the tidal radius $r_T \simeq R_\star (M/m_\star)^{1/3}$. Because $r_T/r_s \propto M^{-2/3}$, the disruption of a solar-type star happens *outside* the ISCO only for supermassive holes, which is the regime this app draws, and why the tidal radii here sit outside the disc. + +Four decisions do all the work, and each one was arrived at by watching the stream fail without it: + +1. **Realistic launches carry no drag.** They are zero-energy (parabolic) orbits with pericenter at a chosen fraction of $r_T$. Bleeding velocity on the way in is not a small correction: it destroys the angular momentum that sets the pericenter, and the "plunge" collapses into the hole. +2. **Mass loss starts before disruption does.** Once the body crosses $r_T$ it spills at a small rate that ramps with depth, so a still-intact star is already connected to the hole by a thin leader before it comes apart. +3. **Debris is launched onto the body's orbit**, not with the body's velocity vector. A particle offset a body-length inward along the strand shares the orbit's $\varepsilon$ and $L$, so its tangential speed is $L/r_p$ and the rest is radial. Copying the velocity vector instead hands that particle the same speed with less angular momentum, and the inner half of every stream dives into the hole. +4. **The tidal energy spread is applied radially.** A radial kick changes $\varepsilon$ while leaving $L$ untouched, so the debris keeps the star's pericenter and swings back out. While the star is falling inward ($\mathbf v\cdot\hat r<0$) an outward kick *cancels* part of that infall and lowers the energy, so the near tip is bound hardest and returns first while the far tip escapes. The split lands near the 50/50 that real disruptions show, and it is read back out of $\varepsilon$ rather than assumed. + +Debris then joins the disc when it has **circularized**, inside the inner edge its speed must fall below $\sqrt{1.3}\,v_{\text{circ}}$, not the first time it crosses that edge. A fresh stream is violently eccentric and whips through pericenter at far above the circular speed; swallowing it there eats the returning ribbon before it can form. (Radial velocity is the wrong test: it passes through zero at every pericenter no matter how eccentric the orbit.) Four tests in `src/sim/__tests__/stream.test.ts` pin the resulting shape: the stream survives its first passage, spreads over a wide range of radii, wraps more than 90° around the hole, and is still feeding hundreds of seconds later. + +The shredded body itself is drawn as a teardrop rather than an ellipsoid, a bright head with a thin tail streaming toward the hole. That is not a quadric, so the raymarcher solves the bounding ellipsoid analytically and then marches the teardrop field inside it. + +### Jets and outflows + +Two different things come out of the poles, and they are not the same physics. + +The **jet** is optically thin, narrow, and relativistic. It is integrated inside the geodesic march, so it bends with the light near the hole. The brightness difference between the approaching and receding cone is the real Doppler boost for an optically thin source, + +$$\delta = \frac{1}{\gamma\,(1 - \beta\cos\theta)}, \qquad I \propto \delta^{3},$$ + +which is why one side of a real AGN jet is bright and the other is nearly invisible. + +The **outflow** is thermal: wide, ragged, un-collimated, and slow. When a disruption dumps matter on the disc the accretion rate goes super-Eddington and radiation pressure drives a wind, so its strength here is tied to the disc's feed rate, it swells during fallback and dies with it. ### Binary inspiral (exact trajectory, compressed clock) @@ -170,23 +221,67 @@ $$E_{\text{rad}} \approx 0.048\, M_{\text{tot}} \cdot \frac{\eta}{0.25}, \qquad normalized to GW150914's ~4.6% at equal mass. The audio chirp frequency tracks $2\times$ the orbital frequency, as gravitational waves do. +### Curvature and gravitational waves (the wireframe) + +The funnel is Flamm's paraboloid, the exact embedding of a Schwarzschild +equatorial slice, + +$$z(r) = 2\sqrt{r_s\,(r - r_s)},$$ + +drawn with a vertical exaggeration and shifted so its rim sits at the disc +plane. The ripple on it is the quadrupole strain pattern of the binary: two +crests per orbit, wound into a trailing spiral by the retarded phase, with +amplitude growing as $1/a$ as the pair tightens and falling as $1/r$ outward. +Only the propagation speed is art-directed, at $c$, under the compressed +inspiral clock, the crests would sit far below one grid cell. + +### Two compressed clocks + +The binary inspiral and the tidal disruption both run on compressed clocks, +because a circular orbit at $12\,r_s$ takes ~370 time units and a real inspiral +from $8\,r_s$ takes ~1600: at 1:1 nothing finishes a lap while you watch. The +binary's compression is exact (its state advances by closed-form Peters and +Kepler quantities). The disruption's is numerical, so `stepWorld` walks it in +substeps of at most 0.1 time units, a unit test asserts the body lands in the +same place at ×1 over 1200 ticks and at ×20 over 60. The accretion disc keeps +the uncompressed clock; it is a background, and shearing it twenty times faster +would turn it into a pinwheel. + +### The deep sky (procedural, but modelled on the real one) + +The background is baked once into an HDR cubemap and then lensed by the same geodesics as everything else. It is invented, but it follows the structure that makes a real deep field look the way it does: + +- a **luminosity function** skewed hard to faint stars ($b \propto u^{9}$ on a uniform draw), with the hot end of the main sequence also the bright end, so blue stars are rare *and* dominant; +- **dust that both glows and blocks**, the galactic band's dark lanes multiply the light of everything behind them, and the far, faint star layers are extinguished more than the near, bright ones; +- **emission regions** in hydrogen red and doubly-ionised-oxygen teal, masked to sparse patches and structured by the same domain-warped field as the gas they sit in; +- **clustering**: stars crowd into the galactic plane and into globular-cluster cores rather than being scattered uniformly; +- **galaxies** with bulges, spiral arms, and a dust lane across the inclined ones. + +Every noise lookup is rotated by a fixed non-axis-aligned frame, because value noise lives on a cubic lattice and a cubemap face is aligned with it, sampled straight, the lattice shows up as angular patches in the sky. + ### Honest limitations - The full spacetime of two holes requires numerical relativity; this app superposes two Schwarzschild deflections, which is qualitatively right (double shadows, eyebrow images) but not exact in the final strong-field moments. The ringdown "breathing" of the shadow is art-directed shorthand for quasi-normal ringing. - The cinematic disruption mode is deliberately directable (drag-driven inspiral, fixed tidal radii): physics-inspired theater, not a simulation. Realistic mode is the honest one. - Debris particles are occluded by the horizon and approximately deflected, but not fully ray-traced. - No black hole spin (Schwarzschild, not Kerr) and no light travel-time delay. +- The disrupted body is drawn as a teardrop, the tidal silhouette, not a hydrodynamic result, and the feeding outflow is likewise a shape and a colour, driven by the disc's feed rate rather than by radiation transport. +- The jet is art direction, not magnetohydrodynamics: a Schwarzschild hole has no Blandford–Znajek spin to tap. Its geometry, filaments and plasma speed are chosen; the Doppler beaming between the two cones and the lensing of the beams are computed. +- The curvature funnel is an embedding diagram of one spatial slice, the standard picture, not a picture of "gravity pulling down". The wave ripple shows the strain pattern, exaggerated far beyond any real $h \sim 10^{-21}$. +- The deep sky is invented. It follows real structure (a luminosity function skewed to faint stars, hotter stars bluer and brighter, dust that reddens and extinguishes, clusters, galaxies) but it is not a star catalogue. ## Architecture ``` src/physics/ constants + CPU null-geodesic integrator (shared numbers with the shader) src/sim/ pure simulation core: PW gravity, tidal phase machine, debris pool, - Peters binary inspiral — zero WebGL/DOM, fully unit-tested (vitest) -src/render/ GPU side: geodesic raymarch pass, starfield cubemap baker, bloom, - composite, horizon-mask occlusion, photon-path tubes, camera tours + Peters binary inspiral, quadrupole wave state, zero WebGL/DOM, + fully unit-tested (vitest) +src/render/ GPU side: geodesic raymarch pass (disc, jet, shredded body), deep-sky + cubemap baker, spacetime wireframe, bloom, composite, horizon-mask + occlusion, photon-path tubes, camera tours src/audio/ procedural WebAudio engine (drone, disc rush, GW chirp, merger) -src/ui/ lil-gui panel + click-to-place controller +src/ui/ lil-gui panel, scene presets, cinematic mode, click-to-place src/main.ts fixed-timestep sim loop + per-frame GPU/audio/HUD sync ``` @@ -194,14 +289,14 @@ The renderer's key trick: everything that must be *truly* lensed (sky, disc, the ## References -- J.-P. Luminet, *Image of a spherical black hole with thin accretion disk*, A&A 75, 228 (1979) — the first computed image of what this app draws. +- J.-P. Luminet, *Image of a spherical black hole with thin accretion disk*, A&A 75, 228 (1979), the first computed image of what this app draws. - O. James, E. von Tunzelmann, P. Franklin, K. S. Thorne, *Gravitational lensing by spinning black holes in astrophysics, and in the movie Interstellar*, Class. Quantum Grav. 32, 065001 (2015). -- P. C. Peters, *Gravitational Radiation and the Motion of Two Point Masses*, Phys. Rev. 136, B1224 (1964) — the inspiral equations. -- N. I. Shakura, R. A. Sunyaev, *Black holes in binary systems. Observational appearance*, A&A 24, 337 (1973) — the disc temperature profile. -- B. Paczyński, P. J. Wiita, *Thick accretion disks and supercritical luminosities*, A&A 88, 23 (1980) — the pseudo-Newtonian potential. -- M. J. Rees, *Tidal disruption of stars by black holes of 10⁶–10⁸ solar masses in nearby galaxies*, Nature 333, 523 (1988) — the bound/unbound debris split. -- LIGO Scientific Collaboration & Virgo Collaboration, *Observation of Gravitational Waves from a Binary Black Hole Merger* (GW150914), Phys. Rev. Lett. 116, 061102 (2016) — the chirp, the radiated-mass numbers. -- NASA Chandra sonifications, *Perseus cluster black hole* (2022) — the inspiration for the drone. +- P. C. Peters, *Gravitational Radiation and the Motion of Two Point Masses*, Phys. Rev. 136, B1224 (1964), the inspiral equations. +- N. I. Shakura, R. A. Sunyaev, *Black holes in binary systems. Observational appearance*, A&A 24, 337 (1973), the disc temperature profile. +- B. Paczyński, P. J. Wiita, *Thick accretion disks and supercritical luminosities*, A&A 88, 23 (1980), the pseudo-Newtonian potential. +- M. J. Rees, *Tidal disruption of stars by black holes of 10⁶–10⁸ solar masses in nearby galaxies*, Nature 333, 523 (1988), the bound/unbound debris split. +- LIGO Scientific Collaboration & Virgo Collaboration, *Observation of Gravitational Waves from a Binary Black Hole Merger* (GW150914), Phys. Rev. Lett. 116, 061102 (2016), the chirp, the radiated-mass numbers. +- NASA Chandra sonifications, *Perseus cluster black hole* (2022), the inspiration for the drone. ## License diff --git a/assets/images/blackhole_1.png b/assets/images/blackhole_1.png new file mode 100644 index 0000000..1f3ea7a Binary files /dev/null and b/assets/images/blackhole_1.png differ diff --git a/assets/images/blackhole_2.png b/assets/images/blackhole_2.png new file mode 100644 index 0000000..bee0ebe Binary files /dev/null and b/assets/images/blackhole_2.png differ diff --git a/index.html b/index.html index 6e59f98..05f85f7 100644 --- a/index.html +++ b/index.html @@ -31,11 +31,42 @@ pointer-events: none; white-space: pre; } + /* Cinematic mode (H): fade the chrome, leave the render untouched. */ + #hud, + .lil-gui { + transition: opacity 260ms ease; + } + body.cinematic #hud, + body.cinematic .lil-gui { + opacity: 0; + pointer-events: none; + } + #toast { + position: fixed; + left: 50%; + bottom: 44px; + transform: translateX(-50%); + padding: 8px 16px; + border-radius: 999px; + background: rgba(10, 12, 22, 0.55); + border: 1px solid rgba(159, 216, 232, 0.18); + color: #cfe8f2; + font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.04em; + opacity: 0; + transition: opacity 400ms ease; + pointer-events: none; + backdrop-filter: blur(6px); + } + #toast.visible { + opacity: 1; + }
+
diff --git a/src/audio/engine.ts b/src/audio/engine.ts index 2b26993..f771f2a 100644 --- a/src/audio/engine.ts +++ b/src/audio/engine.ts @@ -2,7 +2,7 @@ * Procedural audio: an artistic sonification of the black hole, synthesized * entirely with the Web Audio API (no samples). * - * Honesty note — what real black holes "sound" like: the Perseus-cluster + * Honesty note, what real black holes "sound" like: the Perseus-cluster * black hole drives pressure waves through the cluster gas at a B-flat some * 57 octaves below middle C; NASA's sonification transposes it up into a * deep, slowly-breathing drone. LIGO's gravitational-wave detections land in @@ -67,7 +67,7 @@ const NOISE_BUFFER_S = 2; const GATE_RAMP_S = 0.08; export interface AudioFrameState { - /** Accretion-disc feeding boost, 0..2 — drives the "matter rushing in" noise. */ + /** Accretion-disc feeding boost, 0..2, drives the "matter rushing in" noise. */ discBoost: number; /** GW inspiral state, or null when no secondary black hole is present. */ binary: { separation: number; omegaWall: number } | null; // omegaWall = orbital angular velocity, wall-clock rad/s @@ -264,7 +264,7 @@ export class AudioEngine { try { source.stop(); } catch { - // Never started (shouldn't happen) — disconnect below still applies. + // Never started (shouldn't happen), disconnect below still applies. } } g.gate.disconnect(); diff --git a/src/config.ts b/src/config.ts index a2fd49f..0d5f726 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,69 +2,128 @@ * Every tunable "art direction" number in one place, in r_s = 1 units and * sim-seconds. Physics constants (horizon, ISCO, ...) live in * physics/constants.ts; these are the directable knobs layered on top. + * + * Two clocks tick in this file. BODY_TUNING and DEBRIS_TUNING run on the + * compressed *disruption clock* (see BODY_TUNING.timeCompression), so a rate + * of "per second" there is per disruption-second, roughly eight times faster + * than the wall clock at the shipped default. Everything else (the disc, the + * camera, the audio) runs on the plain simulation clock. */ export const BODY_TUNING = { + /** + * Wall-clock compression of the disruption, exactly the trick the binary + * inspiral uses: the trajectories are untouched, the clock is not. A + * circular orbit at 12 r_s takes ~370 time units, so at 1:1 the debris + * never finishes a lap and the stream never reads as a spiral. + */ + timeCompression: 8, /** Velocity drag per second driving the slow inspiral. */ drag: 0.015, /** Launch speed as a fraction of circular speed (sub-circular -> spiral). */ launchSpeedFactor: 0.85, /** Radius where visible stretching begins. */ - rTidal: 6.0, - /** Radius where mass shedding begins. */ - rShed: 4.5, + rTidal: 11.0, + /** Radius where mass shedding begins, outside the disc's outer edge, so + * the stream is drawn against the sky instead of inside the glare. */ + rShed: 7.0, /** Radius of final consumption. */ rConsume: 1.2, /** Mass fraction below which the body counts as consumed. */ massConsumed: 0.05, - /** Maximum axial stretch factor. */ - stretchMax: 6.0, + /** + * Maximum axial stretch factor. Spaghettification is the headline of a + * disruption, so the body draws out into a genuine strand rather than the + * mild ellipsoid a conservative number gives. + */ + stretchMax: 8.0, /** stretchTarget(r) = clamp((rTidal / r)^exponent, 1, stretchMax). */ - stretchExponent: 1.8, + stretchExponent: 2.0, /** Smoothing time constant for the stretch animation, seconds. */ stretchSmoothTime: 0.8, /** Base fractional mass-loss rate per second while shedding. */ massLossBase: 0.08, + /** + * Mass a body spills per second while it is merely stretching, at the moment + * it reaches the shedding radius (it ramps up from zero at the tidal + * radius). Small on purpose: this is the thin stream that connects a + * still-intact body to the hole, not the disruption itself. + */ + stretchSpillRate: 0.012, planetRadius: 0.3, starRadius: 0.6, }; export const DEBRIS_TUNING = { - maxParticles: 16384, - /** Spawn rates in particles per second at full body mass. */ - spawnRatePlanet: 400, - spawnRateStar: 1200, + /** + * The stream has to read as a continuous glowing ribbon, not a dotted line, + * so the pool is large and the sprites overlap. 48k additive points is a + * few ms on the GPU and nothing on the CPU (the sim is pure array math). + */ + maxParticles: 48000, + /** Spawn rates in particles per second of disruption time at full mass. */ + spawnRatePlanet: 700, + spawnRateStar: 1600, /** Particles emitted in the final consumption burst. */ - burstCount: 200, - /** Position jitter sigma as a fraction of body radius. */ - spawnJitter: 0.3, + burstCount: 900, + /** Lateral jitter sigma as a fraction of the strand's (thin) radius. */ + spawnJitter: 0.45, /** Radial kick toward the hole as a fraction of local circular speed. */ spawnKick: 0.2, - /** Tangential inspiral drag per second. */ - drag: 0.05, + /** + * Tangential inspiral drag per second. Very low on purpose: it acts for + * hundreds of disruption-seconds, so 0.012 strips 97% of a particle's + * velocity over one stream lifetime and drops the whole ribbon down the + * hole. At 0.003 the debris circularizes over a few laps, which is what + * turns a trail of particles into the wound ribbon a disruption makes. + */ + drag: 0.003, /** Disc-plane settling spring (s^-2) and damping (s^-1). */ - planeSpring: 0.8, - planeDamping: 1.2, + planeSpring: 0.5, + planeDamping: 1.0, /** Radius where particles start being absorbed into the disc (ISCO). */ absorbRadius: 3.0, + /** + * How circular a particle's orbit must be before the disc takes it: inside + * the ISCO its speed-squared must fall below this multiple of the local + * circular value. Raise it and eccentric material is eaten on its first + * pericenter pass, taking the wrapping ribbon with it. + */ + circularizedSpeedFactor: 1.3, /** Seconds over which an absorbed particle fades out. */ absorbFadeTime: 1.5, /** Hard-kill radius just outside the horizon. */ killRadius: 1.05, - /** Hard-kill age, seconds. */ - maxAge: 30, - /** Heat ramp: heat = clamp01((heatOuterR - r) / (heatOuterR - heatInnerR)). */ - heatOuterR: 8.0, + /** + * Hard-kill age in disruption-clock seconds. An orbit at 8 r_s takes ~200 of + * them, so this is four or five laps, and those laps are the stream. + */ + maxAge: 900, + /** + * Heat ramp: heat = clamp01((heatOuterR - r) / (heatOuterR - heatInnerR)). + * The outer edge sits beyond the stream's apocentre so the far end of the + * ribbon stays deep crimson and only the material falling past the inner + * edge goes white, the colour gradient every disruption image shows. + */ + heatOuterR: 14.0, heatInnerR: 2.0, /** Star debris glows hotter and brighter than rocky debris. */ starHeatFloor: 0.4, - starBrightness: 1.6, - planetBrightness: 1.0, - pointSize: 0.04, + starBrightness: 1.2, + planetBrightness: 0.85, + /** + * Sprite radius in world units. Screen size is this times ~600 / distance, + * so the old 0.075 drew a sub-2-pixel dot at a normal viewing distance and + * the stream read as grit rather than gas. At 0.3 the sprites overlap into + * a continuous ribbon, which is what the brightness above is trimmed for. + */ + pointSize: 0.3, }; export const DISC_TUNING = { - /** Disc boost decay time constant, seconds. */ + /** Disc boost decay time constant, in wall-clock seconds: the flare is a + * visual afterglow, so it fades at a rate the eye reads, not at the + * disruption clock's rate. */ boostDecayTau: 6.0, boostMax: 2.0, /** A fully absorbed body credits about this much total boost. */ @@ -83,7 +142,7 @@ export const BINARY_TUNING = { massRatio: 0.3, /** * The Peters-equation inspiral is exact in geometric time, but geometric - * time from ~8 r_s to merger is ~1600 time units — unwatchable at 1:1. + * time from ~8 r_s to merger is ~1600 time units, unwatchable at 1:1. * The trajectory SHAPE (orbits vs separation, chirp profile) is untouched; * only the clock is compressed by this factor. UI-tunable. */ @@ -103,23 +162,70 @@ export const BINARY_TUNING = { }; export const TDE_TUNING = { - /** Tidal radii for realistic mode, r_s units (mass-ratio story: a star is - * fluffier than a rocky planet, so it shreds farther out). */ - starTidalRadius: 4.0, - planetTidalRadius: 2.5, - /** Pericenter as a fraction of the tidal radius (deep enough to fully shred). */ + /** + * Radius at which a body comes apart in realistic mode, r_s units. A star is + * fluffier than a rocky planet, so it shreds farther out. Both sit outside the disc's outer edge + * on purpose: r_T / r_s grows as M^(-2/3), so for a supermassive hole the + * disruption really does happen well outside the ISCO, and a stream torn + * apart inside the disc is invisible against its glare. + */ + starShedRadius: 9.0, + planetShedRadius: 5.5, + /** Stretching starts this much further out than shedding does. */ + stretchRadiusFactor: 1.5, + /** Pericenter as a fraction of the shed radius (deep enough to fully shred). */ pericenterFraction: 0.7, /** Fractional mass-loss rate per second near pericenter (violent, one pass). */ massLossBase: 0.9, - /** Debris energy spread as a fraction of local circular speed — sets the + /** Debris energy spread as a fraction of local circular speed, sets the * bound/unbound split (real TDEs eject roughly half the star). */ energySpread: 0.35, - /** Bound-debris drag so returning streams eventually feed the disc. */ - boundDrag: 0.02, /** A remnant that makes it back out past this radius has escaped. */ escapeRadius: 30, }; +export const GRID_TUNING = { + /** Wireframe extent, r_s units. The rings crowd toward the throat. */ + innerRadius: 1.6, + outerRadius: 42, + ringCount: 34, + spokeCount: 72, + ringSegments: 160, + /** Vertical exaggeration of Flamm's funnel (1.0 draws the true embedding). */ + depthScale: 0.55, + color: 0x35d6f0, + opacity: 0.4, + /** Strain scale: the ripple height is this times (contact separation / a). */ + waveAmplitude: 24, + /** + * Wave speed for the retarded phase. The spiral pattern and its 1/r decay + * are the real quadrupole solution; only this speed is art-directed, since + * light-speed propagation under the compressed inspiral clock would put the + * crests far below one grid cell. + */ + waveSpeed: 6, + /** Cap the wavenumber so the crests never alias against the ring spacing. */ + maxWavenumber: 1.0, + /** Seconds over which the burst fades once nothing is orbiting. */ + waveDecayTau: 1.4, +}; + +export const SKY_TUNING = { + /** + * Cubemap face resolution. The sky is baked once at boot and again whenever + * a sky control settles, so this trades boot time for star crispness. + */ + faceSize: 1024, + seed: 3.7, + /** Scales how many cells of each star grid hold a star. */ + starDensity: 1.0, + starBrightness: 1.0, + /** Galactic band glow, dust extinction, emission nebulae and the hue wash. */ + nebulaIntensity: 1.0, + /** Globular clusters and distant galaxies. */ + deepSkyIntensity: 1.0, +}; + export const CAMERA_TUNING = { minDistance: 3.0, maxDistance: 60.0, diff --git a/src/main.ts b/src/main.ts index faf5cc8..fa1b7c1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,13 +14,17 @@ import { CameraTour } from './render/cameraTour'; import { DebrisPoints } from './render/debrisPoints'; import { PhotonPathManager } from './render/photonPaths'; import { RenderPipeline, type AccumMode } from './render/pipeline'; -import { generateStarCubemap } from './render/starfield'; +import { SpacetimeGrid } from './render/spacetimeGrid'; +import { Starfield } from './render/starfield'; import { defaultSettings } from './settings'; import { displayRs, orbitalOmegaWall } from './sim/binary'; import { bodyScale } from './sim/body'; -import { clearBody, createWorld, placeBinary, placeBody, stepWorld } from './sim/world'; +import { nextWaveState, restingWave } from './sim/gravitationalWave'; +import { clearBody, createWorld, placeBinary, placeBody, resetScene, stepWorld } from './sim/world'; import type { Body } from './sim/types'; +import { CinematicMode, isTypingIntoControl } from './ui/chrome'; import { buildPanel } from './ui/panel'; +import type { Preset } from './ui/presets'; import { PlacementController } from './ui/placement'; const FIXED_DT = 1 / 60; @@ -52,6 +56,7 @@ function requireElement(id: string): HTMLElement { const app = requireElement('app'); const hud = requireElement('hud'); +const toast = requireElement('toast'); const renderer = new THREE.WebGLRenderer({ antialias: false, @@ -67,9 +72,9 @@ app.appendChild(renderer.domElement); const settings = defaultSettings(); const world = createWorld(); -const sky = generateStarCubemap(renderer, { faceSize: 1024 }); +const starfield = new Starfield(renderer, settings.sky); const rig = new CameraRig(window.innerWidth / window.innerHeight, renderer.domElement); -const bhPass = new BlackHolePass(sky, settings.quality); +const bhPass = new BlackHolePass(starfield.texture, settings.quality); const pipeline = new RenderPipeline(renderer, rig.camera, bhPass, settings.quality); const tour = new CameraTour(); const audio = new AudioEngine(); @@ -77,6 +82,9 @@ const audio = new AudioEngine(); const debrisPoints = new DebrisPoints(world.debris); pipeline.overlayScene.add(debrisPoints.points); +const spacetimeGrid = new SpacetimeGrid(); +pipeline.overlayScene.add(spacetimeGrid.lines); + /** Current effective primary r_s (animated during merger ringdown). */ function currentRs(): number { return displayRs(world.binary, world.primaryRs); @@ -96,6 +104,9 @@ const photonPaths = new PhotonPathManager(gravityCenters); photonPaths.setVisible(settings.photonsEnabled); pipeline.overlayScene.add(photonPaths.group); +/** Gravitational-wave state for the curvature grid; advanced on sim time. */ +let wave = restingWave(); + function endTour(): void { tour.cancel(); rig.controls.enabled = true; @@ -123,7 +134,58 @@ const aiming = new AimingController( }, ); -const gui = buildPanel( +const cinematic = new CinematicMode(document.body, toast); + +/** A re-bake invalidates the converged idle frame, so accumulation restarts. */ +function rebakeSky(): void { + starfield.bake(settings.sky); + pipeline.resetAccumulation(); +} + +const presetPos = new THREE.Vector3(); + +/** + * Build a canned scene: clear whatever is there, place what the preset asks + * for, move the camera, force its look settings. Settings the preset does not + * mention keep their current values. + */ +function applyPreset(preset: Preset): void { + endTour(); + placement.cancel(); + resetScene(world); + wave = restingWave(); + + const { sky, ...look } = preset.look; + Object.assign(settings, look); + if (sky) { + Object.assign(settings.sky, sky); + rebakeSky(); + } + + if (preset.body) { + const { kind, radius, angle } = preset.body; + presetPos.set(radius * Math.cos(angle), 0, radius * Math.sin(angle)); + placeBody(world, kind, presetPos, settings.tdeMode); + } + if (preset.binary) { + const { radius, angle } = preset.binary; + presetPos.set(radius * Math.cos(angle), 0, radius * Math.sin(angle)); + placeBinary(world, presetPos); + } + + rig.moveTo(preset.camera); + photonPaths.setVisible(settings.photonsEnabled); + panel.refreshDisplays(); + if (preset.cinematic) cinematic.hide(); + else cinematic.show(); + if (preset.tour) { + rig.controls.enabled = false; + tour.start(preset.tour, rig.camera, currentRs()); + } + showTransientNote(preset.name); +} + +const panel = buildPanel( settings, { placePlanet: () => placement.enter('planet'), @@ -147,6 +209,20 @@ const gui = buildPanel( audio.setEnabled(enabled); }, onVolumeChange: (volume) => audio.setVolume(volume), + onSkyChange: rebakeSky, + newSky: () => { + settings.sky.seed = Math.random() * 1000; + rebakeSky(); + }, + toggleCinematic: () => cinematic.toggle(), + applyPreset, + refreshFromSettings: () => { + pipeline.setQuality(settings.quality); + photonPaths.setVisible(settings.photonsEnabled); + audio.setEnabled(settings.soundEnabled); + audio.setVolume(settings.volume); + rebakeSky(); + }, }, new URLSearchParams(window.location.search).has('debug'), ); @@ -161,8 +237,22 @@ window.addEventListener( }, { once: true }, ); +// The whole keymap, in one place: Esc ends a camera flight, H hides the +// interface, G toggles the curvature grid. window.addEventListener('keydown', (event) => { - if (event.key === 'Escape') endTour(); + if (isTypingIntoControl(event.target) || event.metaKey || event.ctrlKey || event.altKey) return; + switch (event.key.toLowerCase()) { + case 'escape': + endTour(); + break; + case 'h': + cinematic.toggle(); + break; + case 'g': + settings.gridEnabled = !settings.gridEnabled; + panel.refreshDisplays(); + break; + } }); const drawSize = new THREE.Vector2(); @@ -216,7 +306,6 @@ function syncPlanet(body: Body | null): void { // --- auto-degrade on sustained slow frames --- const frameTimes: number[] = []; let lastDegradeCheck = 0; -let degradeNote = ''; function maybeDegrade(now: number, frameMs: number): void { frameTimes.push(frameMs); @@ -227,18 +316,25 @@ function maybeDegrade(now: number, frameMs: number): void { if (median <= 24 || settings.quality === 'low') return; settings.quality = settings.quality === 'high' ? 'medium' : 'low'; pipeline.setQuality(settings.quality); - gui.controllersRecursive().forEach((c) => c.updateDisplay()); - degradeNote = `quality lowered to ${settings.quality} (slow frames)`; + panel.refreshDisplays(); + showTransientNote(`quality lowered to ${settings.quality} (slow frames)`); frameTimes.length = 0; } // --- HUD --- +const NOTE_VISIBLE_MS = 4500; let lastHudUpdate = 0; let transientNote = ''; let transientUntil = 0; +/** A one-off line in the readout, e.g. "remnant escaped". */ +function showTransientNote(text: string): void { + transientNote = text; + transientUntil = performance.now() + NOTE_VISIBLE_MS; +} + function updateHud(now: number, fps: number): void { - if (now - lastHudUpdate < 250) return; + if (cinematic.isActive || now - lastHudUpdate < 250) return; lastHudUpdate = now; const lines = [`${fps.toFixed(0)} fps · ${settings.quality}`]; const binary = world.binary; @@ -249,7 +345,7 @@ function updateHud(now: number, fps: number): void { `inspiral: separation ${binary.a.toFixed(2)} rₛ · ${orbitsPerSec.toFixed(2)} orbits/s`, ); } else if (binary?.phase === 'ringdown') { - lines.push('merged — ringdown'); + lines.push('merged, ringdown'); } if (world.body) { lines.push( @@ -264,7 +360,6 @@ function updateHud(now: number, fps: number): void { else if (settings.photonsEnabled) lines.push('click to launch photons'); if (aimInfo) lines.push(aimInfo); if (now < transientUntil) lines.push(transientNote); - if (degradeNote) lines.push(degradeNote); hud.textContent = lines.join('\n'); } @@ -275,7 +370,13 @@ let smoothedFps = 60; function frame(now: number): void { requestAnimationFrame(frame); - const frameDt = Math.min((now - last) / 1000, 0.1); + // Clamped at both ends. The ceiling is the usual "don't simulate a whole + // alt-tab in one tick" guard; the floor matters because the first frame's + // timestamp can predate `last`, a browser hands the callback the time the + // frame started, which on a slow GPU is before the startup sky bake + // finished. That negative dt used to drive the accumulator tens of seconds + // into debt, and the simulation stood still until it climbed back out. + const frameDt = Math.min(Math.max((now - last) / 1000, 0), 0.1); last = now; smoothedFps += (1 / Math.max(frameDt, 1e-4) - smoothedFps) * 0.05; @@ -284,13 +385,24 @@ function frame(now: number): void { let mergerNow = false; let shredNow = false; while (acc >= FIXED_DT && steps < MAX_STEPS_PER_FRAME) { - const events = stepWorld(world, FIXED_DT, Math.random, settings.gwTimeCompression); + const events = stepWorld( + world, + FIXED_DT, + Math.random, + settings.gwTimeCompression, + settings.tdeTimeCompression, + ); mergerNow ||= events.mergerNow; shredNow ||= events.shredNow; - if (events.bodyEscaped) { - transientNote = 'remnant escaped with the mass it kept'; - transientUntil = now + 5000; - } + if (events.bodyEscaped) showTransientNote('remnant escaped with the mass it kept'); + // The wave rides the simulation clock, so it freezes when the sim does. + wave = nextWaveState( + wave, + world.binary, + world.primaryRs, + settings.gwTimeCompression, + FIXED_DT, + ); acc -= FIXED_DT; steps++; } @@ -305,6 +417,14 @@ function frame(now: number): void { bhPass.setSecondary( world.binary?.phase === 'inspiral' ? { pos: world.binary.pos, rs: world.binary.rs2 } : null, ); + bhPass.setJetStrength(settings.jetEnabled ? settings.jetStrength : 0); + // The outflow exists only while the hole is being force-fed, so it rises + // with the feeding boost and dies with it, no separate control needed. + bhPass.setWindStrength(settings.windEnabled ? world.discBoost * settings.windStrength : 0); + spacetimeGrid.setVisible(settings.gridEnabled); + spacetimeGrid.setOpacity(settings.gridOpacity); + spacetimeGrid.setPrimaryRs(rs); + spacetimeGrid.setWave(wave); bhPass.setDisc({ inner: R_ISCO * rs, outer: DISC_OUTER * rs, diff --git a/src/physics/geodesic.ts b/src/physics/geodesic.ts index 9766707..4d43335 100644 --- a/src/physics/geodesic.ts +++ b/src/physics/geodesic.ts @@ -8,7 +8,7 @@ * equivalent to the Binet equation u'' = -u + (3/2) r_s u^2 but needing no * per-ray orbital-plane basis and having no singularity for radial rays. * With a second black hole present, the deflections of the two centers are - * superposed (each with its own h about that center) — the standard + * superposed (each with its own h about that center), the standard * approximation, exact for one hole and qualitatively right for two. * The same equations, RK4 scheme, and step-size formula run in the fragment * shader, so paths drawn from these results land exactly on the features the diff --git a/src/render/blackHolePass.ts b/src/render/blackHolePass.ts index fa07883..6a9730b 100644 --- a/src/render/blackHolePass.ts +++ b/src/render/blackHolePass.ts @@ -50,6 +50,8 @@ export class BlackHolePass { uDiscInner: { value: 3 }, uDiscOuter: { value: 9 }, uDiscBrightness: { value: 1 }, + uJetStrength: { value: 0 }, + uWindStrength: { value: 0 }, uPlanetActive: { value: 0 }, uPlanetPos: { value: new THREE.Vector3() }, uPlanetRadii: { value: new THREE.Vector3(1, 1, 1) }, @@ -97,6 +99,16 @@ export class BlackHolePass { this.uniforms.uDiscBrightness!.value = disc.brightness; } + /** Polar jet brightness; 0 removes the jet from the march entirely. */ + setJetStrength(strength: number): void { + this.uniforms.uJetStrength!.value = strength; + } + + /** Super-Eddington outflow; 0 removes it from the march entirely. */ + setWindStrength(strength: number): void { + this.uniforms.uWindStrength!.value = strength; + } + setPlanet(planet: PlanetState | null): void { if (!planet) { this.uniforms.uPlanetActive!.value = 0; @@ -115,7 +127,7 @@ export class BlackHolePass { camera.updateMatrixWorld(); const e = camera.matrixWorld.elements; (this.uniforms.uCamPos!.value as THREE.Vector3).setFromMatrixPosition(camera.matrixWorld); - // Columns: camera right, up, back — the shader marches along -back. + // Columns: camera right, up, back, the shader marches along -back. (this.uniforms.uCamBasis!.value as THREE.Matrix3).set( e[0]!, e[4]!, e[8]!, e[1]!, e[5]!, e[9]!, diff --git a/src/render/bloom.ts b/src/render/bloom.ts index ce9fb78..7f03328 100644 --- a/src/render/bloom.ts +++ b/src/render/bloom.ts @@ -1,5 +1,5 @@ /** - * Pass 3: dual-filter Kawase bloom — threshold prefilter, a downsample chain, + * Pass 3: dual-filter Kawase bloom, threshold prefilter, a downsample chain, * then tent upsampling that accumulates each level back up. */ import * as THREE from 'three'; @@ -8,7 +8,13 @@ import prefilterFrag from './shaders/prefilter.frag'; import downFrag from './shaders/bloomDown.frag'; import upFrag from './shaders/bloomUp.frag'; -const LEVELS = 4; +/** + * Six levels, not four. The dual-filter kernel is a diamond; with only four + * levels its widest tail is still bright enough after tonemapping that the + * diamond's isoline shows up as straight-edged polygons wherever a very bright + * region borders a black one, most visibly around the shadow itself. + */ +const LEVELS = 6; function makeTarget(width: number, height: number): THREE.WebGLRenderTarget { return new THREE.WebGLRenderTarget(Math.max(width, 1), Math.max(height, 1), { diff --git a/src/render/cameraRig.ts b/src/render/cameraRig.ts index 32d90ec..cd48eff 100644 --- a/src/render/cameraRig.ts +++ b/src/render/cameraRig.ts @@ -8,6 +8,15 @@ import { CAMERA_TUNING } from '../config'; const IDLE_AFTER_MS = 250; +/** Where the camera stands, in the orbit terms the rig actually thinks in. */ +export interface CameraPose { + distance: number; + /** Elevation above the disc plane, radians. */ + elevation: number; + /** Azimuth around the hole, radians. */ + azimuth: number; +} + export class CameraRig { readonly camera: THREE.PerspectiveCamera; readonly controls: OrbitControls; @@ -38,6 +47,20 @@ export class CameraRig { return performance.now() - this.lastMoveAt > IDLE_AFTER_MS; } + /** Jump to a pose expressed in orbit terms (used by the scene presets). */ + moveTo(pose: CameraPose): void { + const { distance, elevation, azimuth } = pose; + const horizontal = distance * Math.cos(elevation); + this.camera.position.set( + horizontal * Math.cos(azimuth), + distance * Math.sin(elevation), + horizontal * Math.sin(azimuth), + ); + this.controls.target.set(0, 0, 0); + this.controls.enabled = true; + this.controls.update(); + } + setAspect(aspect: number): void { this.camera.aspect = aspect; this.camera.updateProjectionMatrix(); diff --git a/src/render/debrisPoints.ts b/src/render/debrisPoints.ts index b1f8a3a..100318d 100644 --- a/src/render/debrisPoints.ts +++ b/src/render/debrisPoints.ts @@ -1,6 +1,6 @@ /** * GPU rendering of the sim's debris pool: one THREE.Points wrapping the - * pool's Float32Arrays directly (zero copy) — each frame just flips + * pool's Float32Arrays directly (zero copy), each frame just flips * needsUpdate and sets drawRange to the live count. */ import * as THREE from 'three'; diff --git a/src/render/photonPaths.ts b/src/render/photonPaths.ts index 5d6d1c0..4b10ea0 100644 --- a/src/render/photonPaths.ts +++ b/src/render/photonPaths.ts @@ -1,6 +1,6 @@ /** * Photon trajectory rendering: CPU-integrated geodesics drawn as glowing - * tubes. Paths are true spatial curves (deliberately not re-lensed — the + * tubes. Paths are true spatial curves (deliberately not re-lensed, the * point is to show the actual trajectory geometry, not its lensed image), * occluded by the horizon via the shared mask. */ @@ -90,7 +90,7 @@ export class PhotonPathManager { /** * Launch a horizontal fan of `count` rays around `baseDir` (spread in - * degrees, rotated about the camera's up axis) — aiming near the shadow + * degrees, rotated about the camera's up axis), aiming near the shadow * edge makes the capture/escape bifurcation at b_crit visible. */ launchFan( diff --git a/src/render/shaders/bloomUp.frag b/src/render/shaders/bloomUp.frag index 4126fa9..2029022 100644 --- a/src/render/shaders/bloomUp.frag +++ b/src/render/shaders/bloomUp.frag @@ -16,5 +16,9 @@ void main() { sum += texture2D(tSrc, uv + vec2(uHalfPixel.x, -uHalfPixel.y)).rgb * 2.0; sum += texture2D(tSrc, uv + vec2(0.0, -uHalfPixel.y * 2.0)).rgb; sum += texture2D(tSrc, uv + vec2(-uHalfPixel.x, -uHalfPixel.y)).rgb * 2.0; - gl_FragColor = vec4(sum / 12.0 + texture2D(tAdd, uv).rgb, 1.0); + // Blend the coarser level into this one instead of summing it: with six + // levels a straight sum multiplies the total bloom energy by the level count + // and washes the frame out. A lerp keeps the energy of a single level while + // still inheriting the wide, smooth tail that hides the kernel's shape. + gl_FragColor = vec4(mix(texture2D(tAdd, uv).rgb, sum / 12.0, 0.6), 1.0); } diff --git a/src/render/shaders/debris.frag b/src/render/shaders/debris.frag index 43ea7eb..a09e896 100644 --- a/src/render/shaders/debris.frag +++ b/src/render/shaders/debris.frag @@ -9,14 +9,21 @@ varying float vHeat; varying float vLife; varying vec3 vWorldPos; +// The far end of a tidal stream is cool, dim and deep crimson; it heats +// through red and gold into white as it falls, which is the colour gradient +// every observed disruption image shows along the ribbon. vec3 heatRamp(float t) { - vec3 c = mix(vec3(0.35, 0.03, 0.0), vec3(1.0, 0.42, 0.1), smoothstep(0.0, 0.5, t)); - return mix(c, vec3(1.0, 0.93, 0.8), smoothstep(0.5, 1.0, t)); + vec3 c = mix(vec3(0.42, 0.05, 0.10), vec3(0.98, 0.22, 0.07), smoothstep(0.0, 0.34, t)); + c = mix(c, vec3(1.0, 0.52, 0.14), smoothstep(0.30, 0.62, t)); + return mix(c, vec3(1.0, 0.93, 0.82), smoothstep(0.62, 1.0, t)); } void main() { float d = length(gl_PointCoord - 0.5) * 2.0; - float sprite = exp(-d * d * 4.5) * smoothstep(1.0, 0.65, d); + // Tight core plus a wide dim skirt: overlapping skirts fuse thousands of + // particles into one continuous glowing ribbon with a diffuse halo, instead + // of reading as a dotted line. + float sprite = (exp(-d * d * 9.0) + 0.22 * exp(-d * d * 1.6)) * smoothstep(1.0, 0.55, d); float a = sprite * clamp(vLife, 0.0, 1.0) * horizonVisibility(vWorldPos); if (a < 0.002) discard; vec3 col = heatRamp(vHeat) * (0.6 + 5.0 * vHeat * vHeat) * uBrightness; diff --git a/src/render/shaders/geodesic.frag b/src/render/shaders/geodesic.frag index 0e30166..50da593 100644 --- a/src/render/shaders/geodesic.frag +++ b/src/render/shaders/geodesic.frag @@ -26,6 +26,8 @@ uniform float uBh2Rs; uniform float uDiscInner; uniform float uDiscOuter; uniform float uDiscBrightness; // 0 disables the disc entirely +uniform float uJetStrength; // 0 disables the polar jet +uniform float uWindStrength; // super-Eddington outflow; rises as the disc is fed uniform int uPlanetActive; uniform vec3 uPlanetPos; uniform vec3 uPlanetRadii; @@ -137,8 +139,116 @@ vec4 discEmission(vec3 hit, vec3 marchDir) { return vec4(col * alpha, alpha); } -// Segment-vs-ellipsoid intersection in the planet's local frame. -bool hitPlanet(vec3 a, vec3 b, out vec3 pos, out vec3 nrm, out float tHit) { +// Optically thin polar jet: two narrow cones along the disc axis, filled with +// braided filaments that twist with height and drift outward. Emission only, +// so it never hides what is behind it, but it is integrated inside the +// geodesic march, so the jet bends with the light near the hole. +// +// The plasma speed is art-directed (no MHD here), but the brightness contrast +// between the two cones is the real relativistic Doppler boost: an approaching +// jet is beamed by delta^3, which is why one side of a real AGN is faint. +const float JET_SPEED = 0.75; +/** Emission per unit march length. The march crosses these volumes in dozens + * of steps, so these are far below the disc's surface-emission scale. */ +const float JET_EMISSION = 5.0; +const float JET_TWIST_RATE = 0.85; // radians of braid per r_s of height +const float JET_DRIFT_SPEED = 0.6; // filaments crawling outward +const float JET_NOISE_SCALE = 2.4; +const float JET_BASE_RADIUS = 0.45; +const float JET_OPENING = 0.10; +const float JET_LENGTH = 30.0; +const float JET_FADE = 13.0; + +vec3 jetEmission(vec3 p, vec3 marchDir, float dt) { + float height = abs(p.y); + // Clamped to the escape sphere: past a merger uRs grows, and an unclamped + // reach would put the jet's tip outside the volume the march ever visits. + float reach = min(JET_LENGTH * uRs, R_ESCAPE); + if (uJetStrength <= 0.0 || height < 0.5 * uRs || height > reach) return vec3(0.0); + + float radius = uRs * (JET_BASE_RADIUS + JET_OPENING * height / uRs); + float rho = length(p.xz); + if (rho > radius) return vec3(0.0); + + // Twist the sampling plane with height: braided filaments, no atan seam. + float twist = height * JET_TWIST_RATE - uTime * JET_DRIFT_SPEED; + float ct = cos(twist); + float st = sin(twist); + vec2 q = vec2(p.x * ct - p.z * st, p.x * st + p.z * ct); + float filaments = fbm2(q * JET_NOISE_SCALE + vec2(0.0, height * 0.7)); + + float axis = 1.0 - rho / radius; // 1 on the axis, 0 at the cone wall + // The collimation region right above the horizon is the brightest part. + float launch = 1.0 + 2.5 * exp(-height / (2.0 * uRs)); + float density = smoothstep(0.22, 0.80, filaments) * pow(axis, 1.3) * launch * + exp(-height / (JET_FADE * uRs)); + + // Doppler beaming toward the observer, who lies along -marchDir. + float gamma = inversesqrt(1.0 - JET_SPEED * JET_SPEED); + float cosTheta = dot(vec3(0.0, sign(p.y), 0.0), -marchDir); + float delta = 1.0 / (gamma * (1.0 - JET_SPEED * cosTheta)); + float beaming = clamp(pow(delta, BEAM_EXP), 0.05, 6.0); + + vec3 tint = mix(vec3(0.38, 0.60, 1.0), vec3(0.86, 0.94, 1.0), axis); + return tint * density * beaming * uJetStrength * dt * JET_EMISSION; +} + +// When a disruption dumps matter on the disc it goes super-Eddington, and +// radiation pressure drives a broad, ragged, un-collimated outflow, the wide +// red cones in every tidal-disruption illustration. Unlike the jet this is a +// thermal wind: slow, wide-angle, and not beamed. It only exists while the +// disc is actually being fed, so `uWindStrength` is driven by the feed itself. +const float WIND_REACH = 24.0; +const float WIND_FADE = 8.0; +/** Emission per unit march length, and it has to be small: at anything like + * the disc's scale the wind fills the whole frame with orange fog. */ +const float WIND_EMISSION = 0.09; + +vec3 windEmission(vec3 p, float dt) { + if (uWindStrength <= 0.0) return vec3(0.0); + float r = length(p); + if (r < 2.0 * uRs || r > min(WIND_REACH * uRs, R_ESCAPE)) return vec3(0.0); + + // Bipolar cone: full strength on the axis, gone by roughly 45 degrees. + float cone = smoothstep(0.72, 0.99, abs(p.y) / max(r, 1e-4)); + if (cone <= 0.0) return vec3(0.0); + + // Two octaves of drifting noise: ragged shells, not a smooth glow. Kept + // cheap because this runs per march step for every ray inside the cone. + vec3 q = p * 0.5 - vec3(0.0, sign(p.y) * uTime * 0.4, 0.0); + float clumps = valueNoise3(q) * 0.65 + valueNoise3(q * 2.3 + 4.1) * 0.35; + clumps = smoothstep(0.34, 0.85, clumps); + + float density = cone * exp(-r / (WIND_FADE * uRs)) * clumps; + vec3 tint = mix(vec3(1.0, 0.18, 0.05), vec3(1.0, 0.58, 0.26), clumps); + return tint * density * uWindStrength * dt * WIND_EMISSION; +} + +// A body under tides is not an ellipsoid: it is drawn into a teardrop, a full +// bulb trailing away from the hole and a thin tip pulled toward it, which is +// the shape every image of a disruption shows. Signed field in unit local +// coordinates, negative inside. +y is the near-hole tip throughout this file. +const float TAIL_TAPER = 0.72; + +float teardropField(vec3 local) { + float y = clamp(local.y, -1.0, 1.0); + float girth = sqrt(max(1.0 - y * y, 0.0)) * (1.0 - TAIL_TAPER * (0.5 + 0.5 * y)); + return length(local.xz) - girth; +} + +vec3 teardropNormal(vec3 local) { + const vec2 e = vec2(0.012, 0.0); + vec3 gradient = vec3( + teardropField(local + e.xyy) - teardropField(local - e.xyy), + teardropField(local + e.yxy) - teardropField(local - e.yxy), + teardropField(local + e.yyx) - teardropField(local - e.yyx)); + return normalize(uPlanetRot * (gradient / uPlanetRadii)); +} + +// The teardrop is not a quadric, so the bounding ellipsoid is solved +// analytically and the surface is found by a short march inside it, only for +// the handful of rays that reach the bounding volume at all. +bool hitPlanet(vec3 a, vec3 b, out vec3 pos, out vec3 nrm, out vec3 local, out float tHit) { vec3 la = uPlanetInvRot * (a - uPlanetPos) / uPlanetRadii; vec3 lb = uPlanetInvRot * (b - uPlanetPos) / uPlanetRadii; vec3 d = lb - la; @@ -148,27 +258,79 @@ bool hitPlanet(vec3 a, vec3 b, out vec3 pos, out vec3 nrm, out float tHit) { float C = dot(la, la) - 1.0; float disc = B * B - 4.0 * A * C; if (disc < 0.0) return false; - float t = (-B - sqrt(disc)) / (2.0 * A); - if (t < 0.0 || t > 1.0) return false; - vec3 lp = la + d * t; - pos = mix(a, b, t); - nrm = normalize(uPlanetRot * (lp / uPlanetRadii)); - tHit = t; - return true; + float root = sqrt(disc); + float tEnter = max((-B - root) / (2.0 * A), 0.0); + float tExit = min((-B + root) / (2.0 * A), 1.0); + if (tExit <= tEnter) return false; + + vec3 entry = la + d * tEnter; + if (teardropField(entry) < 0.0) { + pos = mix(a, b, tEnter); + local = entry; // unit-sphere coords: +y is the near-hole tip + nrm = teardropNormal(entry); + tHit = tEnter; + return true; + } + + float tPrev = tEnter; + for (int i = 1; i <= 8; i++) { + float t = mix(tEnter, tExit, float(i) / 8.0); + if (teardropField(la + d * t) >= 0.0) { + tPrev = t; + continue; + } + // Straddled the surface: bisect to it. + float outside = tPrev; + float inside = t; + for (int k = 0; k < 4; k++) { + float mid = 0.5 * (outside + inside); + if (teardropField(la + d * mid) < 0.0) inside = mid; + else outside = mid; + } + vec3 lp = la + d * inside; + pos = mix(a, b, inside); + local = lp; // unit-sphere coords: +y is the near-hole tip + nrm = teardropNormal(lp); + tHit = inside; + return true; + } + return false; } -vec3 shadePlanet(vec3 pos, vec3 nrm) { - // Lit by the disc's glow surrounding the hole; warm key light. +// A rocky body is lit by the disc's glow; a star lights itself. Tidal work is +// done hardest on the end nearest the hole, so a shredding star runs white-hot +// at the near-hole tip (+y) and deep orange at the trailing bulb, with +// convection granulation sheared along the stretch axis by the flow. +vec3 shadePlanet(vec3 pos, vec3 nrm, vec3 local) { vec3 toHole = normalize(-pos); float ndl = max(dot(nrm, toHole), 0.0); vec3 toCam = normalize(uCamPos - pos); float rim = pow(1.0 - abs(dot(nrm, toCam)), 3.0); + vec3 col = uPlanetColor * (0.07 + ndl * vec3(1.1, 0.62, 0.34) * min(uDiscBrightness + 0.25, 2.0)); col += uPlanetColor * rim * 0.15; - col += uPlanetColor * uPlanetEmissive; + if (uPlanetEmissive <= 0.0) return col; + + float grain = fbm3(local * vec3(7.0, 2.5, 7.0) + vec3(0.0, uTime * 0.4, 0.0)); + float heat = smoothstep(-0.9, 1.0, local.y) * 0.75 + 0.35 * grain; + vec3 hot = mix(vec3(1.0, 0.38, 0.08), vec3(1.0, 0.94, 0.82), smoothstep(0.25, 0.95, heat)); + hot = mix(hot, vec3(0.86, 0.93, 1.0), smoothstep(0.95, 1.25, heat)); + col += hot * uPlanetEmissive * (0.40 + 1.0 * heat) * (0.75 + 0.5 * grain); + col += hot * uPlanetEmissive * rim * 0.5; // limb flare return col; } +// Corona hugging the body, measured by the same teardrop field the surface +// uses, so it tapers along the tail instead of ballooning around a fat +// ellipsoid. Optically thin: it glows, it never occludes. +const float CORONA_EMISSION = 0.05; + +vec3 coronaEmission(vec3 p, float dt) { + if (uPlanetActive == 0 || uPlanetEmissive <= 0.0) return vec3(0.0); + float outside = max(teardropField(uPlanetInvRot * (p - uPlanetPos) / uPlanetRadii), 0.0); + return uPlanetColor * uPlanetEmissive * CORONA_EMISSION * dt * exp(-outside * outside * 6.0); +} + void main() { vec2 ndc = (gl_FragCoord.xy + uJitter) / uResolution * 2.0 - 1.0; vec3 x = uCamPos; @@ -212,12 +374,19 @@ void main() { } vec3 pPos; vec3 pNrm; + vec3 pLocal; float sPlanet = 2.0; if (uPlanetActive == 1) { float t; - if (hitPlanet(xPrev, x, pPos, pNrm, t)) sPlanet = t; + if (hitPlanet(xPrev, x, pPos, pNrm, pLocal, t)) sPlanet = t; } + // Volumetric, so these accumulate every step rather than at a crossing. + vec3 mid = mix(xPrev, x, 0.5); + col += T * jetEmission(mid, normalize(mix(vPrev, v, 0.5)), dt); + col += T * windEmission(mid, dt); + col += T * coronaEmission(mid, dt); + if (sDisc < sPlanet) { vec4 e = discEmission(mix(xPrev, x, sDisc), normalize(mix(vPrev, v, sDisc))); col += T * e.rgb; @@ -225,7 +394,7 @@ void main() { } if (sPlanet <= 1.0) { // Opaque: anything behind the planet in this segment is hidden. - col += T * shadePlanet(pPos, pNrm); + col += T * shadePlanet(pPos, pNrm, pLocal); T = 0.0; opaqueHit = true; break; diff --git a/src/render/shaders/grid.frag b/src/render/shaders/grid.frag new file mode 100644 index 0000000..dc282f1 --- /dev/null +++ b/src/render/shaders/grid.frag @@ -0,0 +1,28 @@ +// Spacetime wireframe: cyan lines that brighten on wave crests, fade out at +// the rim, and are occluded by the event horizon like every overlay object. + +#include ./horizonMask.glsl; + +uniform vec3 uColor; +uniform float uOpacity; +uniform float uFadeStart; +uniform float uFadeEnd; + +/** Ripple height at which a crest is fully lit, and how much brighter than the + * flat grid it gets there. Strain falls as 1/r, so without a steep response + * the outer crests are invisible. */ +const float CREST_FULL_AT = 1.0 / 7.0; +const float CREST_GAIN = 1.6; + +varying float vRipple; +varying vec3 vWorldPos; + +void main() { + float r = length(vWorldPos.xz); + float rim = 1.0 - smoothstep(uFadeStart, uFadeEnd, r); + float crest = clamp(abs(vRipple) / CREST_FULL_AT, 0.0, 1.0); + float alpha = uOpacity * rim * horizonVisibility(vWorldPos); + if (alpha < 0.004) discard; + vec3 col = mix(uColor, vec3(0.80, 0.96, 1.0), crest) * (0.7 + CREST_GAIN * crest); + gl_FragColor = vec4(col * alpha, alpha); +} diff --git a/src/render/shaders/grid.vert b/src/render/shaders/grid.vert new file mode 100644 index 0000000..f9b4312 --- /dev/null +++ b/src/render/shaders/grid.vert @@ -0,0 +1,33 @@ +// Flamm's paraboloid, the exact Schwarzschild embedding diagram, drawn as a +// polar wireframe below the disc plane, plus the binary's quadrupole +// gravitational wave rippling across it. +// +// The surface satisfies z(r) = 2*sqrt(rs*(r - rs)); it is shifted so the outer +// rim sits at y = 0 and negated so the funnel descends toward the throat. + +uniform float uRs; +uniform float uRimDepth; // z(rOuter), the shift that puts the rim at y = 0 +uniform float uDepthScale; // vertical exaggeration of the funnel +uniform float uWaveAmp; // 0 whenever nothing is radiating +uniform float uWaveNumber; // radial wavenumber of the outgoing wave +uniform float uWavePhase; // twice the orbital phase, accumulated + +varying float vRipple; +varying vec3 vWorldPos; + +void main() { + float r = length(position.xz); + float depth = 2.0 * sqrt(max(uRs * (r - uRs), 0.0)); + float y = -(uRimDepth - depth) * uDepthScale; + + // Quadrupole radiation: two crests per turn, wound into the trailing spiral + // by the retarded phase, with the 1/r falloff of a real strain amplitude. + float phi = atan(position.z, position.x); + float ripple = uWaveAmp * sin(2.0 * phi - uWavePhase + uWaveNumber * r) / max(r, 1.5); + y += ripple; + + vec4 world = modelMatrix * vec4(position.x, y, position.z, 1.0); + vWorldPos = world.xyz; + vRipple = ripple; + gl_Position = projectionMatrix * viewMatrix * world; +} diff --git a/src/render/shaders/prefilter.frag b/src/render/shaders/prefilter.frag index 50d4f04..a8689f7 100644 --- a/src/render/shaders/prefilter.frag +++ b/src/render/shaders/prefilter.frag @@ -4,10 +4,23 @@ uniform sampler2D tSrc; uniform vec2 uOutRes; uniform float uThreshold; +/** + * Ceiling on what may enter the blur chain. A relativistically beamed disc + * sample can reach a few thousand; once the downsample chain sums those in a + * half-float target it overflows to Inf, and Inf spread by the tent upsample + * turns into NaN, which the tonemap then renders as flat black polygons + * around the brightest parts of the image. + */ +const float BLOOM_CEILING = 32.0; + void main() { vec2 uv = gl_FragCoord.xy / uOutRes; - vec3 c = texture2D(tSrc, uv).rgb; + vec3 c = min(texture2D(tSrc, uv).rgb, vec3(BLOOM_CEILING)); float l = max(max(c.r, c.g), c.b); + if (!(l < BLOOM_CEILING + 1.0)) { // NaN in, black hole out, literally + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } float k = max(l - uThreshold, 0.0) / max(l, 1e-4); gl_FragColor = vec4(c * k, 1.0); } diff --git a/src/render/shaders/sky.frag b/src/render/shaders/sky.frag index c03ecda..42b1f25 100644 --- a/src/render/shaders/sky.frag +++ b/src/render/shaders/sky.frag @@ -1,46 +1,283 @@ -// Procedural starfield, rendered once onto the faces of a cubemap at boot. -// Three grid layers of gaussian point stars plus a faint tilted nebula band. +// Procedural deep sky, rendered once onto the faces of a cubemap at boot: +// a warped galactic band with dust lanes and extinction, multi-hue emission +// nebulae, five layers of stars (the bright ones get diffraction spikes), +// globular clusters and a few distant galaxies. +// +// The geodesic shader samples this along each escaped ray's bent direction, +// so every feature here is lensed into arcs near the shadow for free. The +// palette is deliberately art-directed (wallpaper, not a survey plate): +// magenta hydrogen, teal oxygen, gold core, with a slow hue wash across the +// whole sphere so no two parts of the sky read the same. #include ./noise.glsl; uniform float uSeed; +uniform float uStarDensity; +uniform float uStarBrightness; +uniform float uNebulaIntensity; +uniform float uDeepSkyIntensity; varying vec3 vWorldPos; -vec3 starLayer(vec3 dir, float scale, float density, float brightScale) { - vec3 p = dir * scale; +#define GALAXY_COUNT 16 +#define CLUSTER_COUNT 6 +#define TAU 6.2831853 + +// Galactic plane orientation and an in-plane direction for the core bulge. +// Precomputed because GLSL const initializers must be constant expressions: +// GALACTIC_NORMAL = normalize(vec3(0.35, 1.0, 0.18)), GALACTIC_CORE is the +// part of +X perpendicular to it. +const vec3 GALACTIC_NORMAL = vec3(0.3257, 0.9305, 0.1675); +const vec3 GALACTIC_CORE = vec3(0.9455, -0.3205, -0.0577); + +// Value noise lives on a cubic lattice, and a cubemap face is axis-aligned +// with it: sampled straight, the cells line up into visible angular patches. +// Every noise lookup is rotated by this fixed frame so the lattice never +// agrees with the faces (or with the other layers, which use its powers). +const mat3 NOISE_ROT = mat3( + 0.80, 0.60, 0.00, + -0.36, 0.48, 0.80, + 0.48, -0.64, 0.60); + +struct StarLayer { + float cellScale; // grid cells per unit direction: larger = finer, fainter, more distant + float density; // fraction of cells holding a star + float brightness; // peak brightness of this layer's brightest stars + float sharpness; // gaussian core falloff + float spikes; // diffraction-spike strength (0 for the far layers) +}; + +/** Dust does two things at once: it glows, and it hides what lies behind it. */ +struct Medium { + vec3 glow; + float extinction; // multiplier for light arriving from behind the dust +}; + +/** Clusters both add unresolved glow and crowd extra stars into their core. */ +struct ClusterField { + vec3 glow; + float densityBoost; +}; + +/** Uniformly distributed direction from two hash values (never degenerate). */ +vec3 directionFromHash(vec2 h) { + float z = h.x * 2.0 - 1.0; + float phi = h.y * TAU; + float r = sqrt(max(1.0 - z * z, 0.0)); + return vec3(r * cos(phi), z, r * sin(phi)); +} + +/** World-fixed tangent frame, so spikes and galaxy discs stay consistent + * across cube faces instead of pivoting per face. */ +void tangentFrame(vec3 dir, out vec3 e1, out vec3 e2) { + vec3 up = abs(dir.y) > 0.95 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + e1 = normalize(cross(up, dir)); + e2 = cross(dir, e1); +} + +/** Stellar color from a temperature draw: cool orange dwarfs through the + * white middle of the main sequence to rare blue-white giants. */ +vec3 starTint(float temperature) { + vec3 c = mix(vec3(1.0, 0.52, 0.32), vec3(1.0, 0.84, 0.62), smoothstep(0.0, 0.45, temperature)); + c = mix(c, vec3(1.0, 0.98, 0.96), smoothstep(0.40, 0.76, temperature)); + c = mix(c, vec3(0.66, 0.80, 1.0), smoothstep(0.74, 1.0, temperature)); + return c; +} + +// One cubic grid of stars, sampled over the 2x2x2 cells around the view +// direction. `clustering` scales how many cells fire, so the galactic plane +// and globular cluster cores can crowd stars together. +vec3 starLayer(StarLayer layer, vec3 dir, float clustering) { + vec3 p = dir * layer.cellScale; vec3 base = floor(p - 0.5); + vec3 e1, e2; + tangentFrame(dir, e1, e2); + vec3 col = vec3(0.0); for (int ix = 0; ix <= 1; ix++) for (int iy = 0; iy <= 1; iy++) for (int iz = 0; iz <= 1; iz++) { vec3 id = base + vec3(float(ix), float(iy), float(iz)); vec3 h = hash33(id + uSeed); - if (h.x > density) continue; - vec3 starPos = id + 0.2 + h * 0.6; - float d = length(p - starPos); - float b = pow(hash13(id + 17.31 + uSeed), 9.0) * brightScale + 0.02; - float tt = hash13(id + 29.7 + uSeed); - vec3 tint = mix(vec3(1.0, 0.72, 0.55), vec3(0.68, 0.78, 1.0), tt); - tint = mix(tint, vec3(1.0, 0.96, 0.9), 0.5); - col += tint * b * exp(-d * d * 220.0); + if (h.x > layer.density * clustering) continue; + + vec3 delta = p - (id + 0.2 + h * 0.6); + float d2 = dot(delta, delta); + if (d2 > 0.3) continue; // outside this star's footprint entirely + + // Luminosity function: faint stars vastly outnumber bright ones, and + // the hot end of the main sequence is the bright end. + float magnitude = pow(hash13(id + 17.31 + uSeed), 9.0); + float temperature = hash13(id + 29.7 + uSeed); + float flux = (magnitude * mix(0.55, 1.9, temperature) * layer.brightness + 0.015) * + uStarBrightness; + + // Core plus a wide faint halo: the halo is what survives minification + // and keeps the field from scintillating when the lensing stretches it. + float glow = exp(-d2 * layer.sharpness) + 0.06 * exp(-d2 * 22.0); + + if (layer.spikes > 0.0) { + vec2 q = vec2(dot(delta, e1), dot(delta, e2)); + float spike = exp(-abs(q.x) * 7.0 - q.y * q.y * 1600.0) + + exp(-abs(q.y) * 7.0 - q.x * q.x * 1600.0); + glow += spike * layer.spikes * smoothstep(0.25, 0.75, magnitude) * 0.30; + } + col += starTint(temperature) * flux * glow; } return col; } +/** The galactic band: a great circle around the plane whose center line is + * warped by low-frequency noise, so it never reads as a perfect ring. */ +float galacticBand(vec3 dir) { + vec3 n = NOISE_ROT * dir; + float height = dot(dir, GALACTIC_NORMAL) + 0.06 * (valueNoise3(n * 2.2 + 11.0) - 0.5); + return exp(-height * height * 13.0); +} + +/** A slow hue wash over the whole sphere: one side drifts violet, the other + * teal. Deliberately near-black, deep-field plates are black except where + * something is actually emitting, and gamma lifts even 0.01 into a haze. */ +vec3 skyWash(vec3 dir) { + float t = valueNoise3(NOISE_ROT * dir * 1.1 + 3.9); + vec3 violet = vec3(0.0040, 0.0030, 0.0095); + vec3 teal = vec3(0.0012, 0.0050, 0.0072); + return mix(violet, teal, smoothstep(0.25, 0.75, t)); +} + +// Interstellar medium along the band: unresolved starlight, a reddened core +// bulge, dust that both scatters and blocks, and sparse emission regions +// (hydrogen magenta, doubly ionised oxygen teal, dusty reflection gold). +Medium interstellarMedium(vec3 dir, float band) { + vec3 n = NOISE_ROT * dir; + // Domain warp: straight fbm reads as cotton wool, warped fbm reads as gas. + vec3 warp = vec3(valueNoise3(n * 2.0 + 5.0), + valueNoise3(n * 2.0 + 9.0), + valueNoise3(n * 2.0 + 13.0)) - 0.5; + float cloud = fbm3(n * 3.6 + warp * 1.6 + 7.3); + float fine = fbm3(n * 11.0 - 3.1); + float core = pow(max(dot(dir, GALACTIC_CORE), 0.0), 6.0); + // Filaments: the same cloud pushed to high contrast, so emission sits in + // wisps and shells instead of an even fog. + float filament = smoothstep(0.34, 0.80, cloud); + + // Dust lanes: the dark rifts that split the band lengthwise. The ramp stays + // wide on purpose, a tight one turns the lattice of the underlying noise + // into hard-edged black patches. + float dust = smoothstep(0.30, 0.85, fbm3(n * 5.0 + 23.0) * 0.7 + fine * 0.5); + float extinction = 1.0 - 0.6 * band * dust * (0.55 + 0.45 * core); + + // The band's unresolved starlight shifts hue along its length: violet on + // one side, teal on the other, gold through the bulge. + vec3 bandTint = mix(vec3(0.45, 0.48, 1.0), vec3(0.30, 0.75, 0.95), + smoothstep(0.3, 0.7, valueNoise3(n * 1.6 - 4.4))); + vec3 glow = band * (0.010 + 0.060 * cloud) * bandTint; + glow += band * core * 0.13 * vec3(1.0, 0.76, 0.48); + glow += band * 0.030 * fine * vec3(0.95, 0.55, 0.75); + glow *= extinction; // the band's own light is dimmed by the dust in front of it + + // Emission regions: masked to sparse patches, then structured by the same + // warped cloud field so they sit inside the gas rather than on top of it. + float region = smoothstep(0.54, 0.84, fbm3(n * 1.5 + 41.0)); + float hydrogen = region * (0.25 + 0.75 * band) * filament; + float oxygen = smoothstep(0.58, 0.90, fbm3(n * 2.6 - 27.0)) * (0.22 + 0.78 * band) * cloud; + float reflection = region * band * smoothstep(0.45, 0.85, fine); + + glow += hydrogen * 0.34 * vec3(1.0, 0.17, 0.52); + glow += oxygen * 0.20 * vec3(0.16, 0.85, 0.80); + glow += reflection * 0.09 * vec3(1.0, 0.72, 0.42); + + return Medium(glow * uNebulaIntensity, mix(1.0, extinction, clamp(uNebulaIntensity, 0.0, 1.0))); +} + +// Globular clusters: an unresolved core glow plus a sharp local spike in star +// density, so the fine layers resolve individual members near the center. +ClusterField globularClusters(vec3 dir) { + ClusterField field = ClusterField(vec3(0.0), 0.0); + for (int i = 0; i < CLUSTER_COUNT; i++) { + vec3 h = hash33(vec3(float(i) * 4.1 + 2.7) + uSeed); + vec3 center = directionFromHash(h.xy); + float cosAngle = dot(dir, center); + if (cosAngle < 0.995) continue; + + float extent = mix(0.012, 0.030, h.z); + float r = length(dir - center * cosAngle) / extent; + field.glow += vec3(1.0, 0.93, 0.80) * exp(-r * r * 0.8) * 0.06 * uDeepSkyIntensity; + field.densityBoost += 6.0 * exp(-r * r * 0.5); + } + return field; +} + +// Distant galaxies, deep-field style: mostly small warm ellipticals with a +// few larger inclined spirals whose dust lane cuts the disc in half (the +// edge-on look of Hubble's NGC plates). Small on the sky, but the lensing +// near the photon ring stretches them into arcs, which is the point. +vec3 distantGalaxies(vec3 dir) { + vec3 col = vec3(0.0); + for (int i = 0; i < GALAXY_COUNT; i++) { + vec3 h = hash33(vec3(float(i) * 7.3 + 1.9) + uSeed); + vec3 center = directionFromHash(h.xy); + float cosAngle = dot(dir, center); + if (cosAngle < 0.99) continue; + + vec3 e1, e2; + tangentFrame(center, e1, e2); + vec3 offset = dir - center * cosAngle; + float roll = h.z * TAU; + float ca = cos(roll); + float sa = sin(roll); + vec2 local = vec2(dot(offset, e1), dot(offset, e2)); + local = vec2(ca * local.x + sa * local.y, -sa * local.x + ca * local.y); + + vec3 g = hash33(vec3(float(i) * 2.3 + 11.0) + uSeed); + float inclination = mix(0.14, 1.0, g.x); // 1 = face-on, small = edge-on + float extent = mix(0.006, 0.026, g.y * g.y); // a few big ones, many small + vec2 disc = vec2(local.x, local.y / inclination) / extent; + float r = length(disc); + if (r > 4.0) continue; + float angle = atan(disc.y, disc.x + 1e-6); + + // Spiral arms for the face-on ones; a dark lane across the edge-on ones. + float arms = 0.55 + 0.45 * cos(2.0 * (angle + r * 3.2)); + float lane = 1.0 - 0.7 * (1.0 - inclination) * exp(-pow(disc.y * 1.6, 2.0)) * + smoothstep(0.2, 0.9, r); + float profile = exp(-r * 5.0) * 1.5 + exp(-r * 1.9) * arms * lane * 0.6; + + // Ellipticals run warm and old, spirals run blue toward their arms. + vec3 nucleus = mix(vec3(1.0, 0.80, 0.52), vec3(1.0, 0.92, 0.80), g.z); + vec3 halo = mix(vec3(1.0, 0.72, 0.45), vec3(0.60, 0.74, 1.0), smoothstep(0.2, 0.8, g.x)); + col += mix(nucleus, halo, smoothstep(0.1, 1.4, r)) * profile * mix(0.03, 0.10, g.z); + } + return col * uDeepSkyIntensity; +} + void main() { vec3 dir = normalize(vWorldPos); - vec3 col = starLayer(dir, 22.0, 0.22, 4.0) + - starLayer(dir, 51.0, 0.18, 1.5) + - starLayer(dir, 113.0, 0.15, 0.6); - - // Faint tilted "milky way" band with fbm structure. - vec3 bandNormal = normalize(vec3(0.35, 1.0, 0.18)); - float band = exp(-pow(dot(dir, bandNormal), 2.0) * 14.0); - float neb = fbm3(dir * 4.0 + 7.3); - float dust = fbm3(dir * 9.0 - 3.1); - col += band * (0.010 + 0.055 * neb) * vec3(0.55, 0.62, 0.95); - col += band * 0.028 * dust * vec3(0.9, 0.62, 0.5); + + float band = galacticBand(dir); + Medium medium = interstellarMedium(dir, band); + ClusterField clusters = globularClusters(dir); + + // Stars crowd into the galactic plane and into cluster cores. + float clustering = (1.0 + 2.2 * band + clusters.densityBoost) * uStarDensity; + + StarLayer giants = StarLayer(24.0, 0.22, 4.2, 260.0, 1.0); + StarLayer nearField = StarLayer(52.0, 0.24, 1.9, 250.0, 0.35); + StarLayer midField = StarLayer(112.0, 0.26, 0.90, 240.0, 0.0); + StarLayer farField = StarLayer(190.0, 0.28, 0.45, 230.0, 0.0); + StarLayer faintSea = StarLayer(310.0, 0.30, 0.22, 220.0, 0.0); + + // Nearby stars sit mostly in front of the dust; the faint sea is all behind it. + vec3 col = starLayer(giants, dir, clustering) * mix(1.0, medium.extinction, 0.35); + col += starLayer(nearField, dir, clustering) * mix(1.0, medium.extinction, 0.7); + col += (starLayer(midField, dir, clustering) + + starLayer(farField, dir, clustering) + + starLayer(faintSea, dir, clustering)) * medium.extinction; + + col += medium.glow; + col += clusters.glow * medium.extinction; + col += distantGalaxies(dir) * medium.extinction; + col += skyWash(dir) * uNebulaIntensity * medium.extinction; gl_FragColor = vec4(col, 1.0); } diff --git a/src/render/spacetimeGrid.ts b/src/render/spacetimeGrid.ts new file mode 100644 index 0000000..ce43c26 --- /dev/null +++ b/src/render/spacetimeGrid.ts @@ -0,0 +1,108 @@ +/** + * The curvature of the spacetime around the hole, drawn the textbook way: a + * polar wireframe lying on Flamm's paraboloid (the exact Schwarzschild + * embedding diagram), with the binary's gravitational waves rippling outward + * across it. + * + * The mesh is built once and never touched again, the vertex shader places + * every vertex from r, phi and a handful of uniforms, so an inspiral costs + * nothing on the CPU. + */ +import * as THREE from 'three'; +import { GRID_TUNING } from '../config'; +import type { WaveState } from '../sim/gravitationalWave'; +import { maskUniforms } from './horizonMask'; +import gridVert from './shaders/grid.vert'; +import gridFrag from './shaders/grid.frag'; + +/** z(r) of Flamm's paraboloid in r_s = 1 units. */ +function embeddingDepth(radius: number, rs: number): number { + return 2 * Math.sqrt(Math.max(rs * (radius - rs), 0)); +} + +/** + * Rings spaced geometrically (dense where the funnel actually bends) crossed + * by straight spokes, emitted as line segments on the flat plane; the vertex + * shader lifts them onto the funnel. + */ +function buildWireframe(): THREE.BufferGeometry { + const { innerRadius, outerRadius, ringCount, spokeCount, ringSegments } = GRID_TUNING; + const growth = (outerRadius / innerRadius) ** (1 / (ringCount - 1)); + const radii = Array.from({ length: ringCount }, (_, i) => innerRadius * growth ** i); + const points: number[] = []; + + const pushSegment = (r0: number, a0: number, r1: number, a1: number): void => { + points.push(r0 * Math.cos(a0), 0, r0 * Math.sin(a0)); + points.push(r1 * Math.cos(a1), 0, r1 * Math.sin(a1)); + }; + + for (const radius of radii) { + for (let s = 0; s < ringSegments; s++) { + const a0 = (s / ringSegments) * Math.PI * 2; + const a1 = ((s + 1) / ringSegments) * Math.PI * 2; + pushSegment(radius, a0, radius, a1); + } + } + for (let s = 0; s < spokeCount; s++) { + const angle = (s / spokeCount) * Math.PI * 2; + for (let i = 0; i < radii.length - 1; i++) { + pushSegment(radii[i]!, angle, radii[i + 1]!, angle); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); + return geometry; +} + +export class SpacetimeGrid { + readonly lines: THREE.LineSegments; + private readonly material: THREE.ShaderMaterial; + + constructor() { + this.material = new THREE.ShaderMaterial({ + vertexShader: gridVert, + fragmentShader: gridFrag, + uniforms: { + ...maskUniforms, + uRs: { value: 1 }, + uRimDepth: { value: embeddingDepth(GRID_TUNING.outerRadius, 1) }, + uDepthScale: { value: GRID_TUNING.depthScale }, + uWaveAmp: { value: 0 }, + uWaveNumber: { value: 0 }, + uWavePhase: { value: 0 }, + uColor: { value: new THREE.Color(GRID_TUNING.color) }, + uOpacity: { value: GRID_TUNING.opacity }, + uFadeStart: { value: GRID_TUNING.outerRadius * 0.72 }, + uFadeEnd: { value: GRID_TUNING.outerRadius }, + }, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false, + }); + this.lines = new THREE.LineSegments(buildWireframe(), this.material); + this.lines.frustumCulled = false; + this.lines.visible = false; + } + + setVisible(visible: boolean): void { + this.lines.visible = visible; + } + + /** The funnel deepens as the hole grows (after a merger, say). */ + setPrimaryRs(rs: number): void { + this.material.uniforms.uRs!.value = rs; + this.material.uniforms.uRimDepth!.value = embeddingDepth(GRID_TUNING.outerRadius, rs); + } + + setOpacity(opacity: number): void { + this.material.uniforms.uOpacity!.value = opacity; + } + + setWave(wave: WaveState): void { + this.material.uniforms.uWaveAmp!.value = wave.amplitude; + this.material.uniforms.uWaveNumber!.value = wave.wavenumber; + this.material.uniforms.uWavePhase!.value = wave.phase; + } +} diff --git a/src/render/starfield.ts b/src/render/starfield.ts index 2333c12..1a843fa 100644 --- a/src/render/starfield.ts +++ b/src/render/starfield.ts @@ -1,44 +1,70 @@ /** - * Bakes the procedural starfield into an HDR cubemap once at startup. The - * geodesic shader samples it along each escaped ray's bent direction, which - * is what smears stars into lensed arcs near the shadow for free. + * Bakes the procedural deep sky (stars, galactic band, nebulae, clusters, + * distant galaxies) into an HDR cubemap. The geodesic shader samples it along + * each escaped ray's bent direction, which is what smears the whole sky into + * lensed arcs near the shadow for free. + * + * Baking is six faces of a heavy noise shader, so it runs at boot and again + * only when a sky control settles, never per frame. */ import * as THREE from 'three'; +import { SKY_TUNING } from '../config'; +import type { SkySettings } from '../settings'; import skyVert from './shaders/sky.vert'; import skyFrag from './shaders/sky.frag'; -export interface StarfieldOptions { - faceSize?: number; - seed?: number; -} +export class Starfield { + /** Stable across re-bakes, so uniforms holding it never need updating. */ + readonly texture: THREE.CubeTexture; -export function generateStarCubemap( - renderer: THREE.WebGLRenderer, - options: StarfieldOptions = {}, -): THREE.CubeTexture { - const faceSize = options.faceSize ?? 1024; - const seed = options.seed ?? 3.7; + private readonly target: THREE.WebGLCubeRenderTarget; + private readonly scene = new THREE.Scene(); + private readonly geometry = new THREE.BoxGeometry(10, 10, 10); + private readonly material: THREE.ShaderMaterial; + private readonly cubeCamera: THREE.CubeCamera; - const target = new THREE.WebGLCubeRenderTarget(faceSize, { - type: THREE.HalfFloatType, - generateMipmaps: false, - }); - const scene = new THREE.Scene(); - const material = new THREE.ShaderMaterial({ - vertexShader: skyVert, - fragmentShader: skyFrag, - uniforms: { uSeed: { value: seed } }, - side: THREE.BackSide, - depthTest: false, - depthWrite: false, - }); - const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 10, 10), material); - scene.add(mesh); + constructor( + private readonly renderer: THREE.WebGLRenderer, + sky: SkySettings, + ) { + this.target = new THREE.WebGLCubeRenderTarget(SKY_TUNING.faceSize, { + type: THREE.HalfFloatType, + generateMipmaps: false, + }); + this.texture = this.target.texture; + this.material = new THREE.ShaderMaterial({ + vertexShader: skyVert, + fragmentShader: skyFrag, + uniforms: { + uSeed: { value: sky.seed }, + uStarDensity: { value: sky.starDensity }, + uStarBrightness: { value: sky.starBrightness }, + uNebulaIntensity: { value: sky.nebulaIntensity }, + uDeepSkyIntensity: { value: sky.deepSkyIntensity }, + }, + side: THREE.BackSide, + depthTest: false, + depthWrite: false, + }); + this.scene.add(new THREE.Mesh(this.geometry, this.material)); + this.cubeCamera = new THREE.CubeCamera(0.1, 100, this.target); + this.bake(sky); + } - const cubeCamera = new THREE.CubeCamera(0.1, 100, target); - cubeCamera.update(renderer, scene); + /** Re-renders all six faces in place with the given sky settings. */ + bake(sky: SkySettings): void { + const uniforms = this.material.uniforms; + uniforms.uSeed!.value = sky.seed; + uniforms.uStarDensity!.value = sky.starDensity; + uniforms.uStarBrightness!.value = sky.starBrightness; + uniforms.uNebulaIntensity!.value = sky.nebulaIntensity; + uniforms.uDeepSkyIntensity!.value = sky.deepSkyIntensity; + this.cubeCamera.update(this.renderer, this.scene); + } - mesh.geometry.dispose(); - material.dispose(); - return target.texture; + dispose(): void { + this.geometry.dispose(); + this.material.dispose(); + this.target.dispose(); + } } diff --git a/src/settings.ts b/src/settings.ts index 4ebdf43..c24472d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,16 +1,38 @@ /** UI-mutable settings: one plain object, read by sim and render each frame. */ +import { BINARY_TUNING, BODY_TUNING, GRID_TUNING, SKY_TUNING } from './config'; + export type QualityPreset = 'low' | 'medium' | 'high'; /** 'cinematic' = directable slow spiral; 'realistic' = one violent * parabolic pass with a bound/unbound debris split. */ export type TdeMode = 'cinematic' | 'realistic'; +/** + * Bake-time sky knobs. Changing any of these re-bakes the star cubemap, so + * they are applied when a control settles rather than on every drag frame. + */ +export interface SkySettings { + seed: number; + starDensity: number; + starBrightness: number; + nebulaIntensity: number; + deepSkyIntensity: number; +} + export interface Settings { paused: boolean; timeScale: number; discEnabled: boolean; discBrightness: number; + jetEnabled: boolean; + jetStrength: number; + /** Super-Eddington outflow driven by the disc being fed. */ + windEnabled: boolean; + windStrength: number; + /** Flamm-paraboloid wireframe with the gravitational-wave ripple. */ + gridEnabled: boolean; + gridOpacity: number; photonsEnabled: boolean; photonCount: number; photonSpreadDeg: number; @@ -19,8 +41,11 @@ export interface Settings { tdeMode: TdeMode; /** Wall-clock compression of the GW inspiral (trajectory shape is exact). */ gwTimeCompression: number; + /** Wall-clock compression of a disruption and its debris (same trick). */ + tdeTimeCompression: number; soundEnabled: boolean; volume: number; + sky: SkySettings; } export function defaultSettings(): Settings { @@ -29,14 +54,28 @@ export function defaultSettings(): Settings { timeScale: 1.0, discEnabled: true, discBrightness: 1.0, + jetEnabled: false, + jetStrength: 1.0, + windEnabled: true, + windStrength: 1.0, + gridEnabled: false, + gridOpacity: GRID_TUNING.opacity, photonsEnabled: false, photonCount: 8, photonSpreadDeg: 10, bloomStrength: 1.2, quality: 'medium', tdeMode: 'cinematic', - gwTimeCompression: 40, + gwTimeCompression: BINARY_TUNING.timeCompression, + tdeTimeCompression: BODY_TUNING.timeCompression, soundEnabled: false, volume: 0.6, + sky: { + seed: SKY_TUNING.seed, + starDensity: SKY_TUNING.starDensity, + starBrightness: SKY_TUNING.starBrightness, + nebulaIntensity: SKY_TUNING.nebulaIntensity, + deepSkyIntensity: SKY_TUNING.deepSkyIntensity, + }, }; } diff --git a/src/sim/__tests__/debris.test.ts b/src/sim/__tests__/debris.test.ts index 848f8a8..cc1a2fe 100644 --- a/src/sim/__tests__/debris.test.ts +++ b/src/sim/__tests__/debris.test.ts @@ -21,6 +21,7 @@ function sheddingBody(): Body { rShed: 4.5, lossBase: 0.08, energySpread: 0, + drag: 0.015, }; } @@ -94,12 +95,15 @@ describe('debris pool', () => { pool.life[0] = 1; pool.flags[0] = 1; - let r = 5; - for (let t = 0; t < 20 && pool.alive > 0; t += DT) { + // Drag is deliberately gentle because a debris orbit has to survive several + // laps, so the inward spiral shows over hundreds of seconds, not tens. + const startRadius = Math.hypot(5, 1.5, 0); + let r = startRadius; + for (let t = 0; t < 300 && pool.alive > 0; t += DT) { updatePool(pool, DT, 0); r = Math.hypot(pool.pos[0]!, pool.pos[1]!, pool.pos[2]!); } - expect(r).toBeLessThan(5); + expect(r).toBeLessThan(startRadius); if (pool.alive > 0) expect(Math.abs(pool.pos[1]!)).toBeLessThan(0.3); }); diff --git a/src/sim/__tests__/disruptionClock.test.ts b/src/sim/__tests__/disruptionClock.test.ts new file mode 100644 index 0000000..8d98d5e --- /dev/null +++ b/src/sim/__tests__/disruptionClock.test.ts @@ -0,0 +1,40 @@ +/** + * The disruption clock claims to compress wall time without changing the + * trajectory. That only holds because stepWorld substeps the integrator, so + * this is the test that keeps the claim honest. + */ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { createWorld, placeBody, stepWorld } from '../world'; + +const DT = 1 / 60; + +function runFor(ticks: number, compression: number): Vector3 { + const world = createWorld(4096); + placeBody(world, 'star', new Vector3(12, 0, 0), 'cinematic'); + // A fixed generator keeps debris spawning identical between the two runs. + let seed = 1; + const rng = (): number => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + for (let i = 0; i < ticks; i++) stepWorld(world, DT, rng, 40, compression); + return world.body!.pos.clone(); +} + +describe('disruption time compression', () => { + it('lands the body in the same place whether the clock is fast or slow', () => { + const slow = runFor(1200, 1); + const fast = runFor(60, 20); // same 20 simulated seconds + expect(fast.distanceTo(slow)).toBeLessThan(0.05 * slow.length()); + }); + + it('advances 20x further per tick than the uncompressed clock', () => { + const oneTickSlow = runFor(1, 1); + const oneTickFast = runFor(1, 20); + const start = new Vector3(12, 0, 0); + expect(oneTickFast.distanceTo(start)).toBeGreaterThan( + 10 * oneTickSlow.distanceTo(start), + ); + }); +}); diff --git a/src/sim/__tests__/gravitationalWave.test.ts b/src/sim/__tests__/gravitationalWave.test.ts new file mode 100644 index 0000000..f947142 --- /dev/null +++ b/src/sim/__tests__/gravitationalWave.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { GRID_TUNING } from '../../config'; +import { nextWaveState, restingWave } from '../gravitationalWave'; +import type { BinaryState } from '../types'; + +function inspiralAt(separation: number): BinaryState { + return { + phase: 'inspiral', + a: separation, + angle: 0.7, + rs2: 0.3, + pos: new Vector3(separation, 0, 0), + ringdownT: 0, + rsBefore: 1, + rsFinal: 1, + }; +} + +describe('gravitational wave state', () => { + it('grows as the pair tightens: amplitude scales as 1/a', () => { + const wide = nextWaveState(restingWave(), inspiralAt(20), 1, 40, 1 / 60); + const tight = nextWaveState(restingWave(), inspiralAt(5), 1, 40, 1 / 60); + expect(tight.amplitude / wide.amplitude).toBeCloseTo(4, 6); + }); + + it('caps the wavenumber so crests never alias against the ring spacing', () => { + // Near contact the orbital frequency diverges; the drawn wavelength must not. + const wave = nextWaveState(restingWave(), inspiralAt(1.31), 1, 200, 1 / 60); + expect(wave.wavenumber).toBe(GRID_TUNING.maxWavenumber); + }); + + it('reads the phase straight off the orbit, two crests per turn', () => { + const binary = inspiralAt(10); + const wave = nextWaveState(restingWave(), binary, 1, 40, 1 / 60); + expect(wave.phase).toBeCloseTo(2 * binary.angle, 12); + }); + + it('keeps travelling and fades once nothing is orbiting', () => { + const emitted = nextWaveState(restingWave(), inspiralAt(6), 1, 40, 1 / 60); + const after = nextWaveState(emitted, null, 1, 40, GRID_TUNING.waveDecayTau); + expect(after.amplitude).toBeCloseTo(emitted.amplitude * Math.exp(-1), 6); + expect(after.phase).toBeGreaterThan(emitted.phase); + expect(after.wavenumber).toBe(emitted.wavenumber); + }); + + it('settles to silence', () => { + let wave = nextWaveState(restingWave(), inspiralAt(6), 1, 40, 1 / 60); + for (let i = 0; i < 200; i++) wave = nextWaveState(wave, null, 1, 40, 0.1); + expect(wave.amplitude).toBeLessThan(1e-4); + }); +}); diff --git a/src/sim/__tests__/orbit.test.ts b/src/sim/__tests__/orbit.test.ts new file mode 100644 index 0000000..4be579e --- /dev/null +++ b/src/sim/__tests__/orbit.test.ts @@ -0,0 +1,69 @@ +/** + * The property the tidal stream depends on: a particle launched onto the + * body's orbit at a different radius carries the same specific energy and the + * same specific angular momentum, so it keeps the body's pericenter and swings + * back out instead of diving in. + */ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { orbitOf, velocityOnOrbit } from '../orbit'; +import { parabolicVelocity } from '../placement'; + +const RS = 1; + +function energyAndMomentum(pos: Vector3, vel: Vector3): { energy: number; momentum: number } { + const r = pos.length(); + return { + energy: 0.5 * vel.lengthSq() - RS / 2 / (r - RS), + momentum: new Vector3().crossVectors(pos, vel).length(), + }; +} + +function launchAt(orbitPos: Vector3, orbitVel: Vector3, launchPos: Vector3): Vector3 { + const orbit = orbitOf(orbitPos, orbitVel, RS); + const r = launchPos.length(); + const out = { x: 0, y: 0, z: 0 }; + velocityOnOrbit(orbit, launchPos.x / r, launchPos.y / r, launchPos.z / r, r, RS, out); + return new Vector3(out.x, out.y, out.z); +} + +describe('launching onto an orbit', () => { + it('reproduces the velocity exactly at the launch radius', () => { + const pos = new Vector3(14, 0, 0); + const vel = parabolicVelocity(pos, 6.3, RS); + expect(launchAt(pos, vel, pos).distanceTo(vel)).toBeLessThan(1e-9); + }); + + it('carries the same energy and angular momentum from a different radius', () => { + const pos = new Vector3(14, 0, 0); + const vel = parabolicVelocity(pos, 6.3, RS); + const body = energyAndMomentum(pos, vel); + + // Two body-lengths inward along the strand, which is where the near tip of + // a stretched star sits. + const launchPos = new Vector3(10, 0, 0); + const launchVel = launchAt(pos, vel, launchPos); + const debris = energyAndMomentum(launchPos, launchVel); + + expect(debris.energy).toBeCloseTo(body.energy, 9); + expect(debris.momentum).toBeCloseTo(body.momentum, 9); + }); + + it('keeps falling inward while the body is falling inward', () => { + const pos = new Vector3(14, 0, 0); + const vel = parabolicVelocity(pos, 6.3, RS); + const launchPos = new Vector3(11, 0, 0); + const launchVel = launchAt(pos, vel, launchPos); + expect(launchVel.dot(launchPos)).toBeLessThan(0); + }); + + it('goes purely tangential where the orbit cannot reach any deeper', () => { + // At pericenter the radial term is zero, and below it the energy leaves + // nothing for radial motion; the clamp must not produce a NaN. + const pos = new Vector3(14, 0, 0); + const vel = parabolicVelocity(pos, 6.3, RS); + const launchVel = launchAt(pos, vel, new Vector3(3, 0, 0)); + expect(Number.isFinite(launchVel.length())).toBe(true); + expect(Math.abs(launchVel.x)).toBeLessThan(1e-6); + }); +}); diff --git a/src/sim/__tests__/stream.test.ts b/src/sim/__tests__/stream.test.ts new file mode 100644 index 0000000..01b30ec --- /dev/null +++ b/src/sim/__tests__/stream.test.ts @@ -0,0 +1,83 @@ +/** + * A tidal stream has to survive as a stream. Debris that is swallowed the + * first time it dips inside the inner edge never gets to swing back out, and + * the wound ribbon every disruption image shows never forms, so these are + * the properties that keep it: the stream outlives the star, it spans a wide + * range of radii, and it wraps around the hole rather than pointing one way. + */ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { createWorld, placeBody, stepWorld } from '../world'; +import { mulberry32 } from './rng'; + +const DT = 1 / 60; +const COMPRESSION = 20; + +interface StreamShape { + count: number; + minRadius: number; + maxRadius: number; + /** Angular span the debris covers around the hole, radians. */ + azimuthSpan: number; +} + +function shapeOf(world: ReturnType): StreamShape { + const pool = world.debris; + const angles: number[] = []; + let minRadius = Infinity; + let maxRadius = 0; + for (let i = 0; i < pool.alive; i++) { + const x = pool.pos[i * 3]!; + const z = pool.pos[i * 3 + 2]!; + const r = Math.hypot(x, pool.pos[i * 3 + 1]!, z); + minRadius = Math.min(minRadius, r); + maxRadius = Math.max(maxRadius, r); + angles.push(Math.atan2(z, x)); + } + angles.sort((a, b) => a - b); + // Widest gap between neighbouring angles; the span is what is left of the + // circle once that gap is removed. + let widestGap = angles.length > 1 ? angles[0]! + 2 * Math.PI - angles[angles.length - 1]! : 0; + for (let i = 1; i < angles.length; i++) { + widestGap = Math.max(widestGap, angles[i]! - angles[i - 1]!); + } + return { + count: pool.alive, + minRadius, + maxRadius, + azimuthSpan: angles.length > 1 ? 2 * Math.PI - widestGap : 0, + }; +} + +function disrupt(ticks: number): StreamShape { + const world = createWorld(); + const rng = mulberry32(7); + placeBody(world, 'star', new Vector3(14, 0, 0), 'realistic'); + for (let i = 0; i < ticks; i++) stepWorld(world, DT, rng, 40, COMPRESSION); + return shapeOf(world); +} + +describe('tidal stream', () => { + it('survives its first pericenter passage', () => { + // The star is gone by ~35 disruption-seconds; this is long after. + const shape = disrupt(600); + expect(shape.count).toBeGreaterThan(1000); + }); + + it('draws itself out over a wide range of radii', () => { + const shape = disrupt(600); + expect(shape.maxRadius - shape.minRadius).toBeGreaterThan(6); + }); + + it('wraps around the hole instead of pointing one way', () => { + const shape = disrupt(600); + // Eaten at first pericenter, only the unbound fan is left, and that covers + // a narrow wedge of sky. + expect(shape.azimuthSpan).toBeGreaterThan(Math.PI / 2); + }); + + it('is still feeding the hole hundreds of seconds later', () => { + const late = disrupt(900); + expect(late.count).toBeGreaterThan(200); + }); +}); diff --git a/src/sim/__tests__/tidal.test.ts b/src/sim/__tests__/tidal.test.ts index 7d5df26..a1b29f5 100644 --- a/src/sim/__tests__/tidal.test.ts +++ b/src/sim/__tests__/tidal.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import { BODY_TUNING } from '../../config'; import { massLossRate, nextPhase, phaseFor, stretchTarget } from '../tidal'; -const { rTidal, rShed, massLossBase } = BODY_TUNING; +// Fixed thresholds, not the shipped ones: this is the phase machine's contract +// for the two radii it takes as parameters, so retuning them cannot change what +// these assertions mean. (phaseFor still reads rConsume and massConsumed from +// BODY_TUNING, so those two are exercised at their shipped values.) +const rTidal = 7; +const rShed = 4.5; +const { massLossBase } = BODY_TUNING; describe('tidal phase machine', () => { it('advances at the exact thresholds', () => { diff --git a/src/sim/__tests__/world.test.ts b/src/sim/__tests__/world.test.ts index 323bd8e..12d0c4b 100644 --- a/src/sim/__tests__/world.test.ts +++ b/src/sim/__tests__/world.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from 'vitest'; import { Vector3 } from 'three'; +import { TDE_TUNING } from '../../config'; import { createWorld, placeBody, stepWorld } from '../world'; import type { BodyPhase } from '../types'; import { mulberry32 } from './rng'; const DT = 1 / 60; +// Both clocks stated explicitly: these tests count sim-seconds, so a default +// compression would silently multiply every duration they assert. +const GW_COMPRESSION = 40; +const TDE_COMPRESSION = 1; describe('world end-to-end (tuning harness)', () => { it('a placed planet is disrupted through the phases and consumed', () => { @@ -18,7 +23,7 @@ describe('world end-to-end (tuning harness)', () => { const maxTicks = 400 * 60; while (world.body && tick < maxTicks) { if (!firstSeen.has(world.body.phase)) firstSeen.set(world.body.phase, tick); - stepWorld(world, DT, rng); + stepWorld(world, DT, rng, GW_COMPRESSION, TDE_COMPRESSION); peakBoost = Math.max(peakBoost, world.discBoost); tick++; } @@ -30,7 +35,7 @@ describe('world end-to-end (tuning harness)', () => { expect(peakBoost).toBeGreaterThan(0.3); // Debris drains and the boost decays after consumption. - for (let t = 0; t < 40; t += DT) stepWorld(world, DT, rng); + for (let t = 0; t < 40; t += DT) stepWorld(world, DT, rng, GW_COMPRESSION, TDE_COMPRESSION); expect(world.debris.alive).toBe(0); expect(world.discBoost).toBeLessThan(0.05); }); @@ -49,7 +54,7 @@ describe('world end-to-end (tuning harness)', () => { let tick = 0; const maxTicks = 120 * 60; while (world.body && tick < maxTicks) { - const { shredNow } = stepWorld(world, DT, rng); + const { shredNow } = stepWorld(world, DT, rng, GW_COMPRESSION, TDE_COMPRESSION); if (shredNow && world.body) { shredTick = tick; shredR = world.body.pos.length(); @@ -62,9 +67,9 @@ describe('world end-to-end (tuning harness)', () => { } expect(shredTick).toBeGreaterThan(0); - // Shredding starts near pericenter, at the star's tidal radius (4.0). + // Shredding starts as the star crosses its own tidal radius on the way in. expect(shredR).toBeGreaterThan(0); - expect(shredR).toBeLessThan(4.1); + expect(shredR).toBeLessThan(TDE_TUNING.starShedRadius * 1.05); expect(sawBound).toBe(true); expect(sawUnbound).toBe(true); // The body is gone (consumed or escaped) within 120 sim-seconds. diff --git a/src/sim/body.ts b/src/sim/body.ts index 4b982de..7040787 100644 --- a/src/sim/body.ts +++ b/src/sim/body.ts @@ -21,6 +21,29 @@ export interface BodyStepResult { const scratch = new Vector3(); +/** + * What fraction of the full shedding rate this body is losing right now. One + * function so the mass debited here and the particles spawned by world.ts can + * never drift apart. + * + * While merely stretching, a body still spills: tides have already beaten its + * self-gravity at the near tip, and that thin leader (still-intact body at one + * end, hole at the other) is the connecting stream every disruption image + * shows. The fraction is scaled by the mode's own shedding rate so the leader + * comes out at `stretchSpillRate` either way: a realistic TDE sheds at 0.9 per + * second against a cinematic spiral's 0.08, and a fixed fraction of the former + * would strip the star before it reached the shedding radius. `massLossRate` + * then applies its own radial ramp on top, so the leader thickens as the body + * falls, which is the intent. + */ +export function shedRateFraction(r: number, body: Pick): number { + if (body.phase === 'shedding') return 1; + if (body.phase !== 'stretching') return 0; + const span = Math.max(body.rTidal - body.rShed, 1e-6); + const depth = Math.min(Math.max((body.rTidal - r) / span, 0), 1); + return Math.min((BODY_TUNING.stretchSpillRate * depth) / Math.max(body.lossBase, 1e-6), 1); +} + export function stepBody(body: Body, dt: number, env: GravityEnv): BodyStepResult { if (env.bh2) { // Newtonian pull from the secondary hole. @@ -28,7 +51,7 @@ export function stepBody(body: Body, dt: number, env: GravityEnv): BodyStepResul const d = Math.max(scratch.length(), 1e-6); body.vel.addScaledVector(scratch, (-env.bh2.m / (d * d * d)) * dt); } - stepOrbit(body.pos, body.vel, dt, BODY_TUNING.drag, scratch, env.rs); + stepOrbit(body.pos, body.vel, dt, body.drag, scratch, env.rs); const r = body.pos.length(); const before = body.phase; @@ -40,11 +63,9 @@ export function stepBody(body: Body, dt: number, env: GravityEnv): BodyStepResul const blend = Math.min(1, dt / BODY_TUNING.stretchSmoothTime); body.stretch += (target - body.stretch) * blend; - let massShed = 0; - if (body.phase === 'shedding') { - massShed = Math.min(body.mass, massLossRate(body.mass, r, body.rShed, body.lossBase) * dt); - body.mass -= massShed; - } + const fullRate = massLossRate(body.mass, r, body.rShed, body.lossBase); + const massShed = Math.min(body.mass, fullRate * shedRateFraction(r, body) * dt); + body.mass -= massShed; const torn = body.phase === 'stretching' || body.phase === 'shedding'; const escaped = diff --git a/src/sim/debris.ts b/src/sim/debris.ts index 12d125c..c2dc6d2 100644 --- a/src/sim/debris.ts +++ b/src/sim/debris.ts @@ -16,6 +16,7 @@ */ import { DEBRIS_TUNING } from '../config'; import { vCircular, type GravityEnv } from './gravity'; +import { orbitOf, velocityOnOrbit } from './orbit'; import { bodyRadius } from './body'; import type { Body, DebrisPool } from './types'; @@ -27,6 +28,9 @@ const BALLISTIC_CULL_RADIUS = 60; const DEFAULT_ENV: GravityEnv = { rs: 1, bh2: null }; +/** Scratch for the spawn loop, which must not allocate per particle. */ +const launchVelocity = { x: 0, y: 0, z: 0 }; + export function createPool(capacity: number): DebrisPool { return { pos: new Float32Array(capacity * 3), @@ -98,7 +102,12 @@ export function spawnFromBody( const tip = 0.9 * radius * body.stretch; const vCirc = vCircular(Math.max(r, 1.5 * rs), rs); const cinematicKick = spawnKick * vCirc; - const jitter = spawnJitter * radius; + + // Every particle is launched onto the body's own orbit at its own radius. + const orbit = orbitOf(body.pos, body.vel, rs); + // Jitter across the strand, which is thin: a stretched body conserves + // volume, so its lateral radius shrinks as 1/sqrt(stretch). + const jitter = (spawnJitter * radius) / Math.sqrt(body.stretch); let spawned = 0; for (let n = 0; n < count; n++) { @@ -109,19 +118,38 @@ export function spawnFromBody( // Alternate tips in realistic mode; cinematic always uses the near tip. const nearTip = energySpread === 0 || n % 2 === 0; const side = nearTip ? 1 : -1; - // Radial kick: cinematic = fixed nudge toward the hole; realistic = - // energy spread, inward at the near tip, outward at the far tip. - const kick = - energySpread === 0 - ? cinematicKick - : side * energySpread * vCirc * (0.3 + Math.abs(gaussian(rng))); + // Material peels off all along the outer half of the strand, not from a + // single point at its tip: launching from one point makes particles appear + // out of nowhere a body-length away instead of streaming off the ribbon. + const alongStrand = 0.45 + 0.55 * rng(); + // Cinematic mode: a fixed nudge toward the hole, aimed radially. + // + // Realistic mode: the tidal energy spread, aimed radially and rising + // smoothly along the strand. Radial matters, a radial kick leaves the + // angular momentum untouched, so the debris keeps the star's pericenter + // and swings back out. Kicking along the direction of motion instead + // strips angular momentum too, and most of the bound half falls straight + // down the hole instead of forming a returning stream. + // + // While the star is falling inward (v·r̂ < 0), an outward kick cancels + // part of that infall and *lowers* the specific energy: the near tip ends + // up bound hardest and returns first, the far tip gains energy and leaves. + // `boundFlag` then reads the energy back out to split the two populations. + const spread = energySpread * vCirc * alongStrand * (0.85 + 0.3 * rng()); + const kx = energySpread === 0 ? dx * cinematicKick : -side * dx * spread; + const ky = energySpread === 0 ? dy * cinematicKick : -side * dy * spread; + const kz = energySpread === 0 ? dz * cinematicKick : -side * dz * spread; + + const along = tip * alongStrand; + const px = bp.x + side * dx * along + gaussian(rng) * jitter; + const py = bp.y + side * dy * along + gaussian(rng) * jitter; + const pz = bp.z + side * dz * along + gaussian(rng) * jitter; - const px = bp.x + side * dx * tip + gaussian(rng) * jitter; - const py = bp.y + side * dy * tip + gaussian(rng) * jitter; - const pz = bp.z + side * dz * tip + gaussian(rng) * jitter; - const vx = body.vel.x + dx * kick + gaussian(rng) * 0.01; - const vy = body.vel.y + dy * kick + gaussian(rng) * 0.01; - const vz = body.vel.z + dz * kick + gaussian(rng) * 0.01; + const rp = Math.max(Math.hypot(px, py, pz), 1.05 * rs); + velocityOnOrbit(orbit, px / rp, py / rp, pz / rp, rp, rs, launchVelocity); + const vx = launchVelocity.x + kx + gaussian(rng) * 0.01; + const vy = launchVelocity.y + ky + gaussian(rng) * 0.01; + const vz = launchVelocity.z + kz + gaussian(rng) * 0.01; pool.pos[i3] = px; pool.pos[i3 + 1] = py; pool.pos[i3 + 2] = pz; @@ -184,7 +212,7 @@ export interface PoolStepResult { * Managed particles get PW gravity, inspiral drag, disc-plane settling, an * absorption fade at the inner edge (absorbRadius * rs), and a hard kill at * killRadius * rs. Ballistic particles get gravity only and die far out - * (r > 60), at max age, or inside the capture radius — never absorbed. + * (r > 60), at max age, or inside the capture radius, never absorbed. * Any particle that strays inside 1.05x the secondary hole's Schwarzschild * radius is swallowed by it: killed with no disc credit. */ @@ -194,7 +222,8 @@ export function updatePool( heatFloor: number, env: GravityEnv = DEFAULT_ENV, ): PoolStepResult { - const { drag, planeSpring, planeDamping, absorbFadeTime, maxAge } = DEBRIS_TUNING; + const { drag, planeSpring, planeDamping, absorbFadeTime, maxAge, circularizedSpeedFactor } = + DEBRIS_TUNING; const rs = env.rs; const bh2 = env.bh2; const bh2CaptureR = bh2 ? 1.05 * 2 * bh2.m : 0; @@ -255,7 +284,26 @@ export function updatePool( pool.age[i] = pool.age[i]! + dt; pool.heat[i] = heatAt(r, heatFloor); - if (managed && r < absorbRadius) pool.life[i] = pool.life[i]! - dt / absorbFadeTime; + + // Debris joins the disc when it *circularizes*, not the first time it + // crosses the inner edge. A freshly torn stream is violently eccentric: it + // whips through pericenter and straight back out, and that return swing is + // the wide ribbon wrapping the hole in every observed disruption image. + // Swallowing everything that dipped inside the ISCO ate the ribbon before + // it could form. + // + // The test is speed, not radial motion: radial velocity passes through + // zero at every pericenter no matter how eccentric the orbit is, while a + // particle whipping through pericenter is moving far faster than the local + // circular speed. Only material that has shed that excess, by drag, over + // several passes, is taken. + if (managed && r < absorbRadius) { + const circular = vCircular(Math.max(r, 1.5 * rs), rs); + const speedSq = vx * vx + vy * vy + vz * vz; + if (speedSq < circularizedSpeedFactor * circular * circular) { + pool.life[i] = pool.life[i]! - dt / absorbFadeTime; + } + } const dead = managed ? captured || r < killRadius || pool.life[i]! <= 0 || pool.age[i]! > maxAge diff --git a/src/sim/gravitationalWave.ts b/src/sim/gravitationalWave.ts new file mode 100644 index 0000000..165bf7c --- /dev/null +++ b/src/sim/gravitationalWave.ts @@ -0,0 +1,59 @@ +/** + * The gravitational wave the curvature grid draws. + * + * The pattern is the real quadrupole solution: two crests per orbit, wound + * into a trailing spiral by the retarded phase, amplitude growing as 1/a while + * the pair tightens and falling as 1/r on the way out. Only the propagation + * speed is art-directed, at light speed under the compressed inspiral clock + * the crests would sit far below one grid cell. + * + * Pure: no rendering, no globals, one state in and one state out. + */ +import { GRID_TUNING } from '../config'; +import { orbitalOmegaWall } from './binary'; +import type { BinaryState } from './types'; + +export interface WaveState { + /** Strain scale; 0 leaves the funnel perfectly still. */ + amplitude: number; + /** Radial wavenumber of the outgoing wave. */ + wavenumber: number; + /** Twice the source's orbital phase, accumulated. */ + phase: number; + /** Angular frequency the last source was orbiting at, for the ringdown. */ + omega: number; +} + +export function restingWave(): WaveState { + return { amplitude: 0, wavenumber: 0, phase: 0, omega: 0 }; +} + +/** + * Advance by `dt` simulation seconds. While a binary is inspiraling the wave + * is driven by its orbit; once nothing is orbiting, the last burst keeps + * travelling outward at the same frequency and fades. + */ +export function nextWaveState( + previous: WaveState, + binary: BinaryState | null, + primaryRs: number, + gwCompression: number, + dt: number, +): WaveState { + if (binary?.phase === 'inspiral') { + const omega = orbitalOmegaWall(binary, primaryRs, gwCompression); + const contactSeparation = primaryRs + binary.rs2; + return { + amplitude: GRID_TUNING.waveAmplitude * (contactSeparation / binary.a), + wavenumber: Math.min((2 * omega) / GRID_TUNING.waveSpeed, GRID_TUNING.maxWavenumber), + phase: 2 * binary.angle, + omega, + }; + } + return { + amplitude: previous.amplitude * Math.exp(-dt / GRID_TUNING.waveDecayTau), + wavenumber: previous.wavenumber, + phase: previous.phase + 2 * previous.omega * dt, + omega: previous.omega, + }; +} diff --git a/src/sim/orbit.ts b/src/sim/orbit.ts new file mode 100644 index 0000000..c393394 --- /dev/null +++ b/src/sim/orbit.ts @@ -0,0 +1,73 @@ +/** + * An orbit as an orbit, rather than as one position and one velocity. + * + * Debris has to be launched *onto the body's orbit* at its own radius. Copying + * the body's velocity vector instead hands a particle displaced a body-length + * inward the same speed with far less angular momentum, so the near half of + * every tidal stream dives into the hole rather than swinging back out, and the + * returning ribbon never forms. + * + * Pseudo-Newtonian throughout, matching the rest of the simulation: + * eps = v^2/2 - (rs/2)/(r - rs). + */ +import type { Vector3 } from 'three'; + +export interface Orbit { + /** Specific orbital energy; negative is bound. */ + energy: number; + /** Specific angular momentum |r x v|. */ + angularMomentum: number; + /** Unit vector along the tangential part of the motion. */ + tangentX: number; + tangentY: number; + tangentZ: number; + /** -1 while falling inward, +1 while climbing out. */ + radialSign: number; +} + +/** Read the orbit a body is currently on. */ +export function orbitOf(pos: Vector3, vel: Vector3, rs: number): Orbit { + const r = Math.max(pos.length(), 1e-6); + const radialX = pos.x / r; + const radialY = pos.y / r; + const radialZ = pos.z / r; + const radialSpeed = vel.x * radialX + vel.y * radialY + vel.z * radialZ; + + const tangentX = vel.x - radialSpeed * radialX; + const tangentY = vel.y - radialSpeed * radialY; + const tangentZ = vel.z - radialSpeed * radialZ; + const tangentialSpeed = Math.max(Math.hypot(tangentX, tangentY, tangentZ), 1e-9); + + return { + energy: 0.5 * vel.lengthSq() - rs / 2 / Math.max(r - rs, 1e-3), + angularMomentum: r * tangentialSpeed, + tangentX: tangentX / tangentialSpeed, + tangentY: tangentY / tangentialSpeed, + tangentZ: tangentZ / tangentialSpeed, + radialSign: radialSpeed < 0 ? -1 : 1, + }; +} + +/** + * The velocity a particle needs to sit on `orbit` at radius `r`: angular + * momentum fixes the tangential speed, energy fixes the total, and the + * remainder is radial, in the direction the orbit is already going. + * + * Writes into `out` to keep the spawn loop allocation-free. + */ +export function velocityOnOrbit( + orbit: Orbit, + radialX: number, + radialY: number, + radialZ: number, + r: number, + rs: number, + out: { x: number; y: number; z: number }, +): void { + const tangential = orbit.angularMomentum / r; + const totalSq = 2 * (orbit.energy + rs / 2 / Math.max(r - rs, 1e-3)); + const radial = orbit.radialSign * Math.sqrt(Math.max(totalSq - tangential * tangential, 0)); + out.x = orbit.tangentX * tangential + radialX * radial; + out.y = orbit.tangentY * tangential + radialY * radial; + out.z = orbit.tangentZ * tangential + radialZ * radial; +} diff --git a/src/sim/placement.ts b/src/sim/placement.ts index 2ea17b8..4df41d4 100644 --- a/src/sim/placement.ts +++ b/src/sim/placement.ts @@ -1,6 +1,6 @@ /** * Pure placement math: clamp a requested spawn point onto the allowed ring - * in the disc plane, and build the initial orbital velocity — sub-circular + * in the disc plane, and build the initial orbital velocity, sub-circular * prograde for the cinematic spiral, zero-energy parabolic for a realistic * one-pass tidal disruption. */ diff --git a/src/sim/tidal.ts b/src/sim/tidal.ts index 77df9e8..427e30e 100644 --- a/src/sim/tidal.ts +++ b/src/sim/tidal.ts @@ -1,7 +1,7 @@ /** * Pure tidal-disruption rules: the phase ratchet, the stretch profile, and * the mass-loss rate. Thresholds are parameters because they depend on the - * body's mode (cinematic vs realistic TDE); no state — body.ts applies + * body's mode (cinematic vs realistic TDE); no state, body.ts applies * these each tick. */ import { BODY_TUNING } from '../config'; diff --git a/src/sim/types.ts b/src/sim/types.ts index eed5226..999a0f4 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -1,6 +1,6 @@ /** * Simulation state types. The sim layer is pure TypeScript: it may use - * Three's math classes (Vector3 — plain JS, Node-safe) but nothing that + * Three's math classes (Vector3, plain JS, Node-safe) but nothing that * touches a renderer, scene, or the DOM. */ import { Vector3 } from 'three'; @@ -31,6 +31,13 @@ export interface Body { lossBase: number; /** Debris energy spread (bound/unbound split); 0 in cinematic mode. */ energySpread: number; + /** + * Velocity drag per second. It is what drives the cinematic spiral, and it + * is exactly zero in realistic mode: a zero-energy parabolic plunge that + * bleeds velocity on the way in is no longer parabolic, and its pericenter + * collapses into the hole, taking the debris stream with it. + */ + drag: number; } /** Secondary black hole on a gravitational-wave inspiral (see binary.ts). */ @@ -85,6 +92,11 @@ export interface World { discBoost: number; /** Fractional particle-spawn accumulator. */ spawnAcc: number; + /** + * Particles emitted per unit of shed mass (set when a body is placed). The + * spawn rule lives entirely in stepBody's mass loss; this only converts it. + */ + particlesPerMass: number; /** Boost credited per absorbed particle (set when a body is placed). */ feedPerParticle: number; /** Emission brightness for debris spawned by the current body. */ diff --git a/src/sim/world.ts b/src/sim/world.ts index b891592..582e26c 100644 --- a/src/sim/world.ts +++ b/src/sim/world.ts @@ -2,7 +2,7 @@ * The whole simulation API: create the world, advance it one fixed tick, * place or clear a body, place a secondary hole. Orchestrates the binary * inspiral, body integration, debris spawning, particle updates, and disc - * feeding — all pure CPU math. + * feeding, all pure CPU math. */ import { Vector3 } from 'three'; import { BINARY_TUNING, BODY_TUNING, DEBRIS_TUNING, DISC_TUNING, TDE_TUNING } from '../config'; @@ -34,6 +34,7 @@ export function createWorld(maxParticles: number = DEBRIS_TUNING.maxParticles): debris: createPool(maxParticles), discBoost: 0, spawnAcc: 0, + particlesPerMass: 0, feedPerParticle: 0, debrisBrightness: 1, debrisHeatFloor: 0, @@ -66,19 +67,22 @@ export function placeBody( let rShed: number; let lossBase: number; let energySpread: number; + let drag: number; let vel: Vector3; if (mode === 'realistic') { - const rt = isStar ? TDE_TUNING.starTidalRadius : TDE_TUNING.planetTidalRadius; - rTidal = 1.5 * rt; - rShed = rt; + const shedRadius = isStar ? TDE_TUNING.starShedRadius : TDE_TUNING.planetShedRadius; + rTidal = TDE_TUNING.stretchRadiusFactor * shedRadius; + rShed = shedRadius; lossBase = TDE_TUNING.massLossBase; energySpread = TDE_TUNING.energySpread; - vel = parabolicVelocity(pos, TDE_TUNING.pericenterFraction * rt, rs); + drag = 0; + vel = parabolicVelocity(pos, TDE_TUNING.pericenterFraction * shedRadius, rs); } else { rTidal = BODY_TUNING.rTidal; rShed = BODY_TUNING.rShed; lossBase = BODY_TUNING.massLossBase; energySpread = 0; + drag = BODY_TUNING.drag; vel = initialVelocity(pos, rs); } @@ -95,12 +99,14 @@ export function placeBody( rShed, lossBase, energySpread, + drag, }; world.spawnAcc = 0; - // Normalize feeding so one fully absorbed body credits ~boostPerBody: - // shedding lasts roughly 1/lossBase seconds at full rate. - const expectedParticles = spawnRateFor(kind) / lossBase; - world.feedPerParticle = DISC_TUNING.boostPerBody / expectedParticles; + // One particle per this much shed mass. The body starts at mass 1, so a body + // that sheds completely emits this many particles, which is what normalizes + // the disc feed to ~boostPerBody however fast or slow the mode sheds. + world.particlesPerMass = spawnRateFor(kind) / lossBase; + world.feedPerParticle = DISC_TUNING.boostPerBody / world.particlesPerMass; world.debrisBrightness = isStar ? DEBRIS_TUNING.starBrightness : DEBRIS_TUNING.planetBrightness; world.debrisHeatFloor = isStar ? DEBRIS_TUNING.starHeatFloor : 0; } @@ -110,6 +116,22 @@ export function clearBody(world: World): void { world.body = null; } +/** + * Empty the scene: no body, no secondary, no debris, no leftover feeding. + * A ringdown in progress is settled first, so the primary keeps the mass it + * gained in the merger instead of snapping back to its pre-merger radius. + */ +export function resetScene(world: World): void { + if (world.binary?.phase === 'ringdown') world.primaryRs = world.binary.rsFinal; + world.body = null; + world.binary = null; + world.debris.alive = 0; + world.spawnAcc = 0; + world.discBoost = 0; + world.debrisBrightness = 1; + world.debrisHeatFloor = 0; +} + /** * Place (or replace) the secondary hole. If a ringdown is still playing, * settle it first so the new binary orbits the fully grown primary. @@ -121,12 +143,42 @@ export function placeBinary(world: World, requested: Vector3): void { world.binary = createBinary(world.primaryRs, requested); } -/** Advance the world by one fixed timestep `dt` (sim seconds). */ +/** + * Ceiling on a single body/debris integration step. The body and the debris + * are integrated numerically (semi-implicit Euler), so a compressed clock has + * to be walked in substeps. Hand the integrator one 1-second jump and the + * trajectory itself starts to depend on the compression slider. + */ +const MAX_BODY_STEP = 0.1; + +/** + * Ceiling on the substep count. `updatePool` walks every live particle, so at + * the compression slider's maximum an uncapped count would sweep 48k particles + * ten times per frame. Six keeps the step at or below 0.17 even there, which + * still holds the trajectory, and bounds the per-frame cost. + */ +const MAX_BODY_SUBSTEPS = 6; + +/** + * One tick. Two clocks run inside it: + * + * - `gwCompression` for the binary inspiral. Exact: `stepBinary` advances + * closed-form Peters and Kepler quantities, so only the clock changes. + * - `tdeCompression` for the body and its debris. A circular orbit at 12 r_s + * takes ~370 time units, so at 1:1 a disruption unfolds over ten minutes and + * the debris never completes a turn, so the stream reads as a scattering of + * dots instead of the wound spiral it is. Substepped to `MAX_BODY_STEP` so + * the compressed trajectory matches the uncompressed one. + * + * The disc keeps the uncompressed clock: it is a background, and shearing it + * twenty times faster would turn it into a pinwheel. + */ export function stepWorld( world: World, dt: number, rng: Rng = Math.random, gwCompression: number = BINARY_TUNING.timeCompression, + tdeCompression: number = BODY_TUNING.timeCompression, ): WorldEvents { const events: WorldEvents = { mergerNow: false, shredNow: false, bodyEscaped: false }; @@ -146,30 +198,40 @@ export function stepWorld( : null, }; - const body = world.body; - if (body) { - const phaseBefore = body.phase; - const { consumedNow, escaped } = stepBody(body, dt, env); - events.shredNow = body.phase === 'shedding' && phaseBefore !== 'shedding'; - const spawnOpts = { energySpread: body.energySpread, rs: world.primaryRs }; - if (body.phase === 'shedding') { - world.spawnAcc += spawnRateFor(body.kind) * body.mass * dt; + const bodyDt = dt * tdeCompression; + const substeps = Math.min(Math.max(1, Math.ceil(bodyDt / MAX_BODY_STEP)), MAX_BODY_SUBSTEPS); + const subDt = bodyDt / substeps; + let absorbed = 0; + for (let step = 0; step < substeps; step++) { + const body = world.body; + if (body) { + const phaseBefore = body.phase; + const { massShed, consumedNow, escaped } = stepBody(body, subDt, env); + events.shredNow ||= body.phase === 'shedding' && phaseBefore !== 'shedding'; + const spawnOpts = { energySpread: body.energySpread, rs: world.primaryRs }; + // Particles are spawned strictly in proportion to the mass that actually + // left the body, so the phase rule lives in exactly one place (stepBody) + // and the disc-feed accounting below stays exact. + world.spawnAcc += massShed * world.particlesPerMass; const count = Math.floor(world.spawnAcc); if (count > 0) { + // Debited whether or not the pool had room: the mass left the body + // either way, and a full pool simply means the oldest debris is still + // on screen. world.spawnAcc -= count; spawnFromBody(world.debris, body, count, world.debrisHeatFloor, rng, spawnOpts); } + if (consumedNow) { + spawnFromBody(world.debris, body, DEBRIS_TUNING.burstCount, world.debrisHeatFloor, rng, spawnOpts); + world.body = null; + } else if (escaped) { + world.body = null; + events.bodyEscaped = true; + } } - if (consumedNow) { - spawnFromBody(world.debris, body, DEBRIS_TUNING.burstCount, world.debrisHeatFloor, rng, spawnOpts); - world.body = null; - } else if (escaped) { - world.body = null; - events.bodyEscaped = true; - } + absorbed += updatePool(world.debris, subDt, world.debrisHeatFloor, env).absorbed; } - const { absorbed } = updatePool(world.debris, dt, world.debrisHeatFloor, env); if (absorbed > 0) { world.discBoost = creditFeed(world.discBoost, absorbed * world.feedPerParticle); } diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts new file mode 100644 index 0000000..f2548d1 --- /dev/null +++ b/src/ui/chrome.ts @@ -0,0 +1,61 @@ +/** + * Cinematic mode: the control panel and HUD fade away so the render fills the + * screen like a wallpaper. One class on drives the CSS; a toast (which + * stays visible in either mode) says how to get the controls back. + */ + +const CINEMATIC_CLASS = 'cinematic'; +const TOAST_VISIBLE_MS = 2600; + +export class CinematicMode { + private hidden = false; + private toastTimer = 0; + + constructor( + private readonly body: HTMLElement, + private readonly toast: HTMLElement, + ) {} + + /** True while the panel and HUD are hidden. */ + get isActive(): boolean { + return this.hidden; + } + + toggle(): void { + this.hidden = !this.hidden; + this.apply(); + } + + /** Presets state whether they want the chrome hidden, rather than flipping it. */ + hide(): void { + if (this.hidden) return; + this.hidden = true; + this.apply(); + } + + show(): void { + if (!this.hidden) return; + this.hidden = false; + this.apply(); + } + + private apply(): void { + this.body.classList.toggle(CINEMATIC_CLASS, this.hidden); + this.flash(this.hidden ? 'cinematic mode · press H for the controls' : 'controls restored'); + } + + private flash(message: string): void { + this.toast.textContent = message; + this.toast.classList.add('visible'); + window.clearTimeout(this.toastTimer); + this.toastTimer = window.setTimeout( + () => this.toast.classList.remove('visible'), + TOAST_VISIBLE_MS, + ); + } +} + +/** Keyboard shortcuts must not fire while the user is typing into the panel. */ +export function isTypingIntoControl(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.matches('input, select, textarea'); +} diff --git a/src/ui/panel.ts b/src/ui/panel.ts index 3c2afce..eedeb9f 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -1,12 +1,19 @@ /** * The lil-gui control panel, bound directly to the plain settings object. + * + * Folders are ordered the way someone actually uses the app: first put + * something in the scene, then control time, then the camera, then how it all + * looks. Only the first two are open on load; everything else is one click + * away, and every control carries a hover description of what it does. + * * A hidden tuning folder for art-directing the debris stream appears with * `?debug=1`. */ -import GUI from 'lil-gui'; +import GUI, { type Controller } from 'lil-gui'; import { BODY_TUNING, DEBRIS_TUNING, DISC_TUNING } from '../config'; -import type { QualityPreset, Settings } from '../settings'; +import { defaultSettings, type QualityPreset, type Settings } from '../settings'; import type { TourKind } from '../render/cameraTour'; +import { PRESETS, type Preset } from './presets'; export interface PanelActions { placePlanet(): void; @@ -20,64 +27,327 @@ export interface PanelActions { stopTour(): void; onSoundToggled(enabled: boolean): void; onVolumeChange(volume: number): void; + /** Re-bake the sky cubemap after a sky control settles. */ + onSkyChange(): void; + /** Reseed and re-bake: a whole new sky. */ + newSky(): void; + toggleCinematic(): void; + /** Build one of the canned scenes (see ui/presets.ts). */ + applyPreset(preset: Preset): void; + /** Re-run the side effects of settings that changed without their control. */ + refreshFromSettings(): void; } -export function buildPanel(settings: Settings, actions: PanelActions, debug: boolean): GUI { - const gui = new GUI({ title: 'Black Hole' }); +/** Hover text: the panel has no room to explain itself, the tooltip does. */ +function explain(controller: Controller, text: string): Controller { + controller.domElement.title = text; + return controller; +} + +/** + * A "reset this section" button. `restore` both writes the shipped defaults + * back and re-fires whatever side effects those settings drive (a sky re-bake, + * a quality change); this only repaints the folder afterwards. + */ +function addResetButton(folder: GUI, restore: () => void, tooltip: string): void { + const action = { + reset: () => { + restore(); + folder.controllersRecursive().forEach((controller) => controller.updateDisplay()); + }, + }; + explain(folder.add(action, 'reset').name('↺ Reset this section'), tooltip); +} + +export interface ControlPanel { + gui: GUI; + /** Repaint every control from the settings object it is bound to. */ + refreshDisplays(): void; +} - const body = gui.addFolder('Place'); - body.add(actions, 'placePlanet').name('Place planet'); - body.add(actions, 'placeStar').name('Place star'); - body.add(actions, 'placeBlackHole').name('Place black hole'); - body.add(actions, 'clearBody').name('Clear body'); - body - .add(settings, 'tdeMode', { 'Cinematic spiral': 'cinematic', 'Realistic TDE': 'realistic' }) - .name('Disruption mode'); +export function buildPanel( + settings: Settings, + actions: PanelActions, + debug: boolean, +): ControlPanel { + const gui = new GUI({ title: 'Black Hole · H hides this' }); + const shipped = defaultSettings(); - const sim = gui.addFolder('Simulation'); - sim.add(settings, 'paused').name('Paused'); - sim.add(settings, 'timeScale', 0.1, 3, 0.05).name('Time scale'); - sim.add(settings, 'gwTimeCompression', 1, 200, 1).name('GW time ×'); + const presets = gui.addFolder('0 · Presets'); + for (const preset of PRESETS) { + explain( + presets.add({ run: () => actions.applyPreset(preset) }, 'run').name(preset.name), + preset.description, + ); + } + + const scene = gui.addFolder('1 · Drop something in'); + explain( + scene.add(actions, 'placePlanet').name('Planet'), + 'Arms placement, then click the disc plane. A rocky body: it stretches, then sheds a thin debris stream.', + ); + explain( + scene.add(actions, 'placeStar').name('Star'), + 'Arms placement, then click the disc plane. A star shreds into a bright glowing stream that feeds and brightens the disc.', + ); + explain( + scene.add(actions, 'placeBlackHole').name('Second black hole'), + 'Arms placement, then click the disc plane. Its orbit decays by gravitational waves until the pair merges; both holes bend light.', + ); + explain( + scene.add(actions, 'clearBody').name('Remove body'), + 'Removes the body immediately. Debris already shed keeps draining into the disc.', + ); + explain( + scene + .add(settings, 'tdeMode', { + 'Cinematic spiral (slow, directable)': 'cinematic', + 'Realistic TDE (one violent pass)': 'realistic', + }) + .name('Disruption physics'), + 'Cinematic: a slow inward spiral you can watch. Realistic: a parabolic plunge shredded at pericenter, with about half the debris flung back out, what real tidal disruptions do.', + ); + + const playback = gui.addFolder('2 · Time'); + explain( + playback.add(settings, 'paused').name('Pause'), + 'Freezes the simulation. The camera still works, and a still frame keeps refining itself while nothing moves.', + ); + explain( + playback.add(settings, 'timeScale', 0.1, 3, 0.05).name('Speed'), + 'Simulation speed multiplier: 1 is real time for this scene, 3 makes a disruption unfold three times faster.', + ); + explain( + playback.add(settings, 'gwTimeCompression', 1, 600, 1).name('Inspiral speed'), + 'Wall-clock compression of a binary inspiral. Does nothing until a second black hole is in the scene. The orbit rate responds immediately, but the separation decays as 1/a³, so a wide pair barely moves at any setting and then falls together very fast at the end. That acceleration is the chirp, and it is the real Peters solution.', + ); + explain( + playback.add(settings, 'tdeTimeCompression', 1, 60, 1).name('Disruption speed'), + 'Wall-clock compression of a disruption and its debris. Same trick as the inspiral clock: the orbits are exact, the clock is fast, at 1:1 the debris would take ten minutes to complete one lap around the hole.', + ); + addResetButton( + playback, + () => { + settings.paused = shipped.paused; + settings.timeScale = shipped.timeScale; + settings.gwTimeCompression = shipped.gwTimeCompression; + settings.tdeTimeCompression = shipped.tdeTimeCompression; + }, + 'Back to running, speed ×1, and the default inspiral and disruption clocks.', + ); const camera = gui.addFolder('Camera'); - camera.add({ flyIn: () => actions.startTour('flyin') }, 'flyIn').name('Fly in (plunge)'); - camera.add({ flyBy: () => actions.startTour('flyby') }, 'flyBy').name('Fly past'); - camera.add({ circle: () => actions.startTour('circle') }, 'circle').name('Circle orbit'); - camera.add(actions, 'stopTour').name('Stop flight'); + explain( + camera.add({ flyIn: () => actions.startTour('flyin') }, 'flyIn').name('Fly in (plunge)'), + 'Dives from the current view toward the horizon and fades out. Esc stops any flight.', + ); + explain( + camera.add({ flyBy: () => actions.startTour('flyby') }, 'flyBy').name('Fly past'), + 'Sweeps past the hole on a close trajectory, so the lensing shears across the frame.', + ); + explain( + camera.add({ circle: () => actions.startTour('circle') }, 'circle').name('Orbit the hole'), + 'A slow circular pass around the disc.', + ); + explain( + camera.add(actions, 'stopTour').name('Stop flight (Esc)'), + 'Ends the flight and hands the camera back to mouse control.', + ); + explain( + camera.add(actions, 'toggleCinematic').name('Hide the interface (H)'), + 'Cinematic mode: fades this panel and the readout for a clean, wallpaper-like frame. Press H again to bring them back.', + ); + camera.close(); + + const disc = gui.addFolder('Accretion disc'); + explain( + disc.add(settings, 'discEnabled').name('Show'), + 'The gas orbiting the hole. Turning it off leaves the bare shadow, the photon ring and the lensed sky.', + ); + explain( + disc.add(settings, 'discBrightness', 0, 3, 0.05).name('Brightness'), + 'Overall disc emission. Debris falling in adds a temporary boost on top of this.', + ); + explain( + disc.add(settings, 'windEnabled').name('Feeding outflow'), + 'When a disruption dumps matter on the disc it goes super-Eddington and drives a broad, ragged wind out of the poles. It appears only while the disc is being fed, and fades as the feeding does.', + ); + explain( + disc.add(settings, 'windStrength', 0, 3, 0.05).name('Outflow brightness'), + 'How bright that wind is at full feeding.', + ); + addResetButton( + disc, + () => { + settings.discEnabled = shipped.discEnabled; + settings.discBrightness = shipped.discBrightness; + settings.windEnabled = shipped.windEnabled; + settings.windStrength = shipped.windStrength; + }, + 'Disc and outflow back to defaults.', + ); + disc.close(); + + const jet = gui.addFolder('Relativistic jet'); + explain( + jet.add(settings, 'jetEnabled').name('Show'), + 'Twin polar beams along the disc axis. The brightness difference between the two is real Doppler beaming: the cone pointing at you is boosted, the far one is dimmed.', + ); + explain( + jet.add(settings, 'jetStrength', 0, 3, 0.05).name('Brightness'), + 'Jet emission. The plasma speed is art-directed; the beaming and the lensing of the beams are not.', + ); + addResetButton( + jet, + () => { + settings.jetEnabled = shipped.jetEnabled; + settings.jetStrength = shipped.jetStrength; + }, + 'Jet off, default brightness.', + ); + jet.close(); - const disc = gui.addFolder('Disc'); - disc.add(settings, 'discEnabled').name('Enabled'); - disc.add(settings, 'discBrightness', 0, 3, 0.05).name('Brightness'); + // Sky sliders re-bake a cubemap, so they fire on release, not on drag. + const sky = gui.addFolder('Deep sky'); + explain( + sky + .add(settings.sky, 'starDensity', 0.2, 3, 0.05) + .name('Star density') + .onFinishChange(actions.onSkyChange), + 'How crowded the star field is. Stars pile up in the galactic band and in cluster cores. Rebuilds the sky cubemap when you let go.', + ); + explain( + sky + .add(settings.sky, 'starBrightness', 0.2, 3, 0.05) + .name('Star brightness') + .onFinishChange(actions.onSkyChange), + 'Brightness of every star layer. The brightest stars grow diffraction spikes and bloom.', + ); + explain( + sky + .add(settings.sky, 'nebulaIntensity', 0, 3, 0.05) + .name('Nebulae & dust') + .onFinishChange(actions.onSkyChange), + 'The galactic band, its dark dust lanes and the emission nebulae. Set it to 0 for a plain black sky.', + ); + explain( + sky + .add(settings.sky, 'deepSkyIntensity', 0, 3, 0.05) + .name('Galaxies & clusters') + .onFinishChange(actions.onSkyChange), + 'Distant galaxies and globular clusters. Near the shadow the lensing stretches them into arcs.', + ); + explain( + sky.add(actions, 'newSky').name('Generate a new sky'), + 'Reseeds every procedural layer: a completely different star field, band and set of galaxies.', + ); + addResetButton( + sky, + () => { + Object.assign(settings.sky, shipped.sky, { seed: settings.sky.seed }); + actions.onSkyChange(); + }, + 'Default sky intensities, keeping the sky you are looking at.', + ); + sky.close(); - const paths = gui.addFolder('Light paths'); - paths.add(settings, 'photonsEnabled').name('Enabled').onChange(actions.onPhotonsToggled); - paths.add(settings, 'photonCount', 1, 32, 1).name('Rays per launch'); - paths.add(settings, 'photonSpreadDeg', 0, 30, 0.5).name('Spread (deg)'); - paths.add(actions, 'clearPaths').name('Clear paths'); + const spacetime = gui.addFolder('Spacetime'); + explain( + spacetime.add(settings, 'gridEnabled').name('Curvature grid (G)'), + "Flamm's paraboloid, the standard embedding diagram of the curvature around the hole. During an inspiral the binary's gravitational waves ripple outward across it.", + ); + explain( + spacetime.add(settings, 'gridOpacity', 0, 1, 0.02).name('Grid opacity'), + 'How strongly the wireframe is drawn over the scene.', + ); + spacetime.close(); + + const paths = gui.addFolder('Light rays'); + explain( + paths.add(settings, 'photonsEnabled').name('Show').onChange(actions.onPhotonsToggled), + 'Then click anywhere to launch a fan of photons and watch their true paths: escaped, captured, or trapped near the photon ring.', + ); + explain( + paths.add(settings, 'photonCount', 1, 32, 1).name('Rays per launch'), + 'How many rays each click fires.', + ); + explain( + paths.add(settings, 'photonSpreadDeg', 0, 30, 0.5).name('Spread (deg)'), + 'Angular width of the fan. A narrow fan near the critical impact parameter shows rays splitting between capture and escape.', + ); + explain(paths.add(actions, 'clearPaths').name('Clear rays'), 'Removes every drawn ray.'); + paths.close(); const sound = gui.addFolder('Sound'); - sound.add(settings, 'soundEnabled').name('Enabled').onChange(actions.onSoundToggled); - sound.add(settings, 'volume', 0, 1, 0.01).name('Volume').onChange(actions.onVolumeChange); + explain( + sound.add(settings, 'soundEnabled').name('Enabled').onChange(actions.onSoundToggled), + 'A deep drone, matter rush while the disc is feeding, and a LIGO-style chirp that tracks the real orbital frequency during an inspiral. Browsers need one click on the page first.', + ); + explain( + sound.add(settings, 'volume', 0, 1, 0.01).name('Volume').onChange(actions.onVolumeChange), + 'Master volume for the procedural audio.', + ); + sound.close(); + + const render = gui.addFolder('Display'); + explain( + render.add(settings, 'bloomStrength', 0, 3, 0.05).name('Glow'), + 'Strength of the bloom around bright things. Lower it to see more structure in the disc.', + ); + explain( + render + .add(settings, 'quality', ['low', 'medium', 'high']) + .name('Quality') + .onChange(actions.onQualityChange), + 'Raymarch steps and internal resolution. It drops automatically if frames get slow.', + ); + addResetButton( + render, + () => { + settings.bloomStrength = shipped.bloomStrength; + settings.quality = shipped.quality; + actions.onQualityChange(settings.quality); + }, + 'Default glow and quality preset.', + ); + render.close(); + + const refreshDisplays = (): void => { + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()); + }; - const render = gui.addFolder('Render'); - render.add(settings, 'bloomStrength', 0, 3, 0.05).name('Bloom'); - render - .add(settings, 'quality', ['low', 'medium', 'high']) - .name('Quality') - .onChange(actions.onQualityChange); + explain( + gui.add( + { + resetAll: () => { + // In place: every controller holds a reference to `settings` and to + // `settings.sky`, so replacing either object orphans half the panel. + const { sky, ...flat } = shipped; + Object.assign(settings, flat); + Object.assign(settings.sky, sky, { seed: settings.sky.seed }); + refreshDisplays(); + actions.refreshFromSettings(); + }, + }, + 'resetAll', + ).name('↺ Reset everything'), + 'Every setting back to how the app ships. Whatever is in the scene stays.', + ); if (debug) { + // Only knobs that are read live belong here. The body's own thresholds + // (tidal radius, shed radius, drag, mass-loss rate) are snapshotted into + // the Body when it is placed, so a slider for them would move nothing. const tuning = gui.addFolder('Tuning (debug)'); - tuning.add(BODY_TUNING, 'drag', 0, 0.1, 0.001).name('Body drag'); - tuning.add(BODY_TUNING, 'rTidal', 3, 10, 0.1).name('Tidal radius'); - tuning.add(BODY_TUNING, 'rShed', 2, 8, 0.1).name('Shed radius'); - tuning.add(BODY_TUNING, 'massLossBase', 0.01, 0.5, 0.005).name('Mass loss rate'); - tuning.add(DEBRIS_TUNING, 'drag', 0, 0.3, 0.005).name('Debris drag'); - tuning.add(DEBRIS_TUNING, 'planeSpring', 0, 3, 0.05).name('Plane spring'); - tuning.add(DEBRIS_TUNING, 'planeDamping', 0, 4, 0.05).name('Plane damping'); - tuning.add(DEBRIS_TUNING, 'spawnJitter', 0, 1, 0.01).name('Spawn jitter'); - tuning.add(DEBRIS_TUNING, 'spawnKick', 0, 1, 0.01).name('Spawn kick'); - tuning.add(DISC_TUNING, 'boostDecayTau', 1, 30, 0.5).name('Boost decay tau'); + explain(tuning.add(BODY_TUNING, 'stretchMax', 2, 30, 0.5).name('Max stretch'), 'Longest the body is drawn out.'); + explain(tuning.add(DEBRIS_TUNING, 'drag', 0, 0.3, 0.005).name('Debris drag'), 'How fast debris circularizes and feeds the disc.'); + explain(tuning.add(DEBRIS_TUNING, 'planeSpring', 0, 3, 0.05).name('Plane spring'), 'How hard debris is pulled into the disc plane.'); + explain(tuning.add(DEBRIS_TUNING, 'planeDamping', 0, 4, 0.05).name('Plane damping'), 'Damping on that settling.'); + explain(tuning.add(DEBRIS_TUNING, 'spawnJitter', 0, 1, 0.01).name('Spawn jitter'), 'Spread of debris across the strand.'); + explain(tuning.add(DEBRIS_TUNING, 'spawnKick', 0, 1, 0.01).name('Spawn kick'), 'Inward kick given to fresh cinematic-mode debris.'); + explain(tuning.add(DEBRIS_TUNING, 'pointSize', 0.02, 0.8, 0.01).name('Debris size'), 'Sprite radius in world units; affects newly spawned particles.'); + explain(tuning.add(DISC_TUNING, 'boostDecayTau', 1, 30, 0.5).name('Boost decay tau'), 'How long a disc feeding boost lingers.'); + tuning.close(); } - return gui; + return { gui, refreshDisplays }; } diff --git a/src/ui/presets.ts b/src/ui/presets.ts new file mode 100644 index 0000000..d48de70 --- /dev/null +++ b/src/ui/presets.ts @@ -0,0 +1,128 @@ +/** + * One-click scenes. Each preset is plain data: what to put in the world, where + * to stand, and which look settings to force. The panel lists them; main.ts + * knows how to apply one (it owns the world and the camera). + * + * Anything a preset does not mention is left exactly as the user had it. + */ +import type { BodyKind } from '../sim/types'; +import type { Settings, SkySettings } from '../settings'; +import type { CameraPose } from '../render/cameraRig'; +import type { TourKind } from '../render/cameraTour'; + +/** Where to place a body: polar coordinates on the disc plane, r_s units. */ +export interface Placement { + radius: number; + /** Angle around the disc plane, radians. */ + angle: number; +} + +export interface Preset { + id: string; + name: string; + /** Hover text in the panel: what you are about to see. */ + description: string; + /** + * Look settings forced by this preset; everything else is left alone. + * + * Deliberately narrowed to the settings the frame loop reads every frame. + * Anything with a side effect behind it (quality, sound, photon paths) would + * need its callback re-fired, and a preset that set one would silently do + * nothing, so the type refuses it instead. + */ + look: Partial< + Pick< + Settings, + | 'tdeMode' + | 'timeScale' + | 'gwTimeCompression' + | 'tdeTimeCompression' + | 'discEnabled' + | 'discBrightness' + | 'jetEnabled' + | 'jetStrength' + | 'windEnabled' + | 'windStrength' + | 'gridEnabled' + | 'gridOpacity' + | 'bloomStrength' + > + > & { sky?: Partial }; + body: ({ kind: BodyKind } & Placement) | null; + binary: Placement | null; + camera: CameraPose; + /** Start a camera flight once the scene is set. */ + tour?: TourKind; + /** Whether the panel and readout should be hidden. Required, so adding a + * preset forces a decision instead of inheriting whatever was on screen. */ + cinematic: boolean; +} + +export const PRESETS: readonly Preset[] = [ + { + id: 'devoured', + name: 'A star being devoured', + description: + 'A star on a realistic plunge: one violent pass, shredded at pericenter, half the debris flung back out and the rest wound into a glowing stream that feeds the disc.', + look: { tdeMode: 'realistic', discEnabled: true, discBrightness: 1.0, jetEnabled: false, gridEnabled: false, timeScale: 1, bloomStrength: 1.1 }, + body: { kind: 'star', radius: 14, angle: 2.5 }, + binary: null, + camera: { distance: 27, elevation: 0.25, azimuth: 1.4 }, + cinematic: false, + }, + { + id: 'spaghetti', + name: 'Spaghettification, slowly', + description: + 'A planet on the cinematic spiral, drawn out into a strand as it crosses the tidal radius. Slow enough to watch the stretch build.', + look: { tdeMode: 'cinematic', discEnabled: true, discBrightness: 0.8, jetEnabled: false, gridEnabled: false, timeScale: 1.4, bloomStrength: 1.0 }, + body: { kind: 'planet', radius: 11, angle: 0.4 }, + binary: null, + camera: { distance: 22, elevation: 0.30, azimuth: 1.9 }, + cinematic: false, + }, + { + id: 'merger', + name: 'Binary merger with waves', + description: + 'A second hole spiralling in by the Peters equations, with the curvature grid on so you can watch the gravitational waves spiral outward and chirp.', + look: { discEnabled: true, discBrightness: 0.55, jetEnabled: false, gridEnabled: true, gridOpacity: 0.5, gwTimeCompression: 40, timeScale: 1, bloomStrength: 1.0 }, + body: null, + binary: { radius: 13, angle: 0.0 }, + camera: { distance: 30, elevation: 0.5, azimuth: 0.9 }, + cinematic: false, + }, + { + id: 'curvature', + name: 'Curved spacetime', + description: + "The embedding diagram on its own: Flamm's paraboloid funnelling into the throat, disc off, seen from above the plane.", + look: { discEnabled: false, jetEnabled: false, gridEnabled: true, gridOpacity: 0.6, bloomStrength: 0.8 }, + body: null, + binary: null, + camera: { distance: 34, elevation: 0.62, azimuth: 0.4 }, + cinematic: false, + }, + { + id: 'quasar', + name: 'Quasar with jets', + description: + 'Bright disc, twin relativistic jets, seen nearly edge-on. The near jet is Doppler-boosted, the far one dimmed.', + look: { discEnabled: true, discBrightness: 1.3, jetEnabled: true, jetStrength: 1.3, gridEnabled: false, bloomStrength: 1.2 }, + body: null, + binary: null, + camera: { distance: 26, elevation: 0.12, azimuth: 2.2 }, + cinematic: false, + }, + { + id: 'wallpaper', + name: 'Wallpaper (hides the UI)', + description: + 'Clean cinematic frame: no bodies, rich deep sky, interface hidden. Press H to bring the controls back.', + look: { discEnabled: true, discBrightness: 1.0, jetEnabled: false, gridEnabled: false, bloomStrength: 1.25, sky: { nebulaIntensity: 1.2, deepSkyIntensity: 1.2 } }, + body: null, + binary: null, + camera: { distance: 19, elevation: 0.16, azimuth: 1.2 }, + cinematic: true, + }, +];