From b98991aba360250c9ee33343cf2398dc280e43ef Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:06:42 -0700 Subject: [PATCH 1/3] Will I Dream: the CodePen black-hole lens; Nature's Tomb: an egg cell dividing by mitosis --- HANDOFF.md | 18 +- README.md | 4 +- docs/ENGINE.md | 2 +- docs/ENVIRONMENT.md | 2 +- docs/OVERVIEW.md | 2 +- docs/PROJECTS.md | 3 +- docs/SCENE_CONTRACT.md | 5 +- projects/templates/index.json | 1 + projects/templates/natures-tomb.sway | 60 ++++ src/renderer/engine/scenes/index.js | 3 +- src/renderer/engine/scenes/naturestomb.js | 318 ++++++++++++++++++++++ src/renderer/engine/scenes/willidream.js | 179 ++++-------- 12 files changed, 464 insertions(+), 133 deletions(-) create mode 100644 projects/templates/natures-tomb.sway create mode 100644 src/renderer/engine/scenes/naturestomb.js diff --git a/HANDOFF.md b/HANDOFF.md index c50bfab..446194b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -110,11 +110,27 @@ What was built in this pass: --- +## 2e. Third pass (2026-08-20, late) — compaction note + +Written for whoever picks this up after a context reset. State of play: + +- **Everything from §2d is committed and pushed** (`cockpit` → `origin/cockpit`, eight plain commits `42ebee5`…`cbcf892`, no trailers). A PR link `cockpit → main` exists on GitHub but no PR was opened. +- **Uncommitted at the time of writing** (this pass): `scenes/willidream.js` (black hole swapped), `scenes/naturestomb.js` (new), `scenes/index.js` (16 scenes), `projects/templates/natures-tomb.sway` + `index.json` (ten templates), docs (README, OVERVIEW, ENGINE, ENVIRONMENT, SCENE_CONTRACT inventory rows, PROJECTS table), this file. Build passes; two dev-app runs verified it (below). The installed build (21:27) predates this pass. +- **Will I Dream's black hole is now Darryl Huffman's CodePen lens** — the user's `C:\Users\Cyboman\Downloads\ScifiUI\BLACKHOLE.zip` (`black-hole-webgl-shader`, MIT), by explicit instruction ("use THIS black hole"). Maths kept: `pull = mass / dist²`, the view rotated about the mass by `(pull + held)·π` with upstream's zero sine term (a signed radial scaling that collapses and inverts the sky in rings), darkening `colour − pull·0.25`. Sequence: PAD 7 → mass eases to 0.015 (the pen's 3 %-per-frame ease), then the hold value climbs (the pen's held click, 0.03/frame) while the mass grows to 4.0 so its own darkening swallows the screen from the center out, void, re-emergence, no object. The lens is applied to the streak endpoints and the object quad (`lens()` in `GLSL_COMMON`), the overlay carries the darkening (`lensDark()`); the raymarched Schwarzschild disk is gone. Verified mid-swallow at 60 fps. +- **Nature's Tomb** (`scenes/naturestomb.js`, id `naturestomb`, template `natures-tomb`): an egg cell under dark-field light — zona pellucida (fresnel, mild refraction, thin band), an SDF raymarch of up to sixteen blastomeres with a smooth-min cleavage furrow, granular cytoplasm, wrap light, subsurface glow, wet specular, a nucleus with a nucleolus seen through the cytoplasm by a second short march. One continuous division level 0–4 (1→2→4→8→16, each stage along the next axis; the CPU computes the vec4 cell table per frame); driving it down un-divides. **The control interface, as the user asked, is the user's choice**: KNOB 5 (`io.knobs[4]`) sets the level the moment it moves; any strike steps one division, reversing at 16 and at 1; sway jiggles the membranes, press squeezes, the hand pans (X) and dollies (Y) — nothing rotates. Verified at 60 fps through strikes and knob moves; the capture shows a four-nucleus embryo mid-merge. Realism can go further (real cells flatten against each other and the zona; a thickness-based SSS; a microscope DOF) — noted as a follow-up, not done. +- **Proposal, not done**: a generic `scene:` assignment target (router + assign panel) so any control can drive a scene parameter through the normal assignment UI instead of raw-knob conventions (Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5). +- **The user was using the installed build during this pass** (settings show `Documents\SwayCommand Projects\Hyperspace.sway` opened at 21:32 and the rails + timeline collapsed through the new chips). Their persisted `layout` was therefore left alone. Any dev-app run restores the same layout — that is the feature working, not a regression. +- **Repo description + topics** were drafted in chat (not applied — `gh` is not on the bash PATH): "SwayCommand — a real-time, audio-reactive VJ and live-visuals instrument for the Audima Labs Sway gesture MIDI controller…" with topics vj, vjing, live-visuals, audio-reactive, audio-visualizer, music-visualizer, generative-art, creative-coding, threejs, webgl, webgl2, glsl, shaders, electron, midi, midi-controller, web-midi, gesture-control, audima-sway, wavetable-synth, web-audio, real-time-graphics, procedural-generation, performance-tool, desktop-app, raymarching, particle-system, black-hole, hyperspace, javascript. +- **Next steps in order**: commit this pass (plain messages) and push; repack + install (the installed build is one pass behind); physical session at the Sway (`scripts/midi-session.ps1`) to confirm the new pad order, Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5 and strikes, Will I Dream's warp corner and PADS 7 / 8 / 15; then the `scene:` target proposal if the user wants it; then the open v1 gaps in §4. + +--- + ## 3. What is verified - Code-level: the documentation set was rewritten 2026-08-20 by reading every claim out of this source tree (module by module, including `swayproject.js`, `router.js`, `transport.js`, `projectfile.js`, and the `ui/` set). The docs are the closest thing to a spec; where they and the code disagree, something regressed. - Reconciled after the visuals pass (later on 2026-08-20): `README.md` (rack parameter count, gesture and knob tables, the Wormhole row) and the scene inventory table in `docs/SCENE_CONTRACT.md` (every morph-pass scene and the theDAW ports, re-read from each scene's header and `update()`) now describe the shipped scenes. `ENGINE.md`, `STUDIO.md`, `OVERVIEW.md`, `PROJECTS.md` were already current. -- 15 scenes registered (`scenes/index.js`); all 9 templates parse through `validateProject` with no warnings and carry the expected pools (checked by script against `projects/templates/`; `will-i-dream` carries 1 media, 1 audio clip, 1 visual clip). +- 16 scenes registered (`scenes/index.js`); all 10 templates parse through `validateProject` with no warnings and carry the expected pools (checked by script against `projects/templates/`; `will-i-dream` carries 1 media, 1 audio clip, 1 visual clip; `natures-tomb` opens on `naturestomb` with nothing assigned). +- Third pass, dev app (2026-08-20 ~21:45): Will I Dream with the Huffman lens — 60 fps through warp, open, and mid-swallow (captured: the black core with the streak field collapsing into it); Nature's Tomb — 60 fps at one cell, through two strikes, a KNOB 5 move to 3.4 and back to 1.5 (captured: a four-nucleus embryo mid-merge inside its zona); zero console errors; 16 scenes listed. - The pre-redesign hardware baseline still applies: Sway USB identity `VID 0x0483` / `PID 0x52A4` confirmed on hardware, port name `Audima Labs The Sway`, factory CC map exercised on the installed build. - Second pass (2026-08-20, late evening), dev app on the upgraded stack — three 0.185.1, Electron 43.4.1, esbuild 0.28.2 (target chrome140): three runs, console hooked before any compile — all fifteen scenes compiled as GLSL3 with zero errors or warnings; Will I Dream at 60 fps at rest, through two jumps (galaxy, then the solar system arrival captured: limb-darkened sun, ringed giant, shaded planets, belt), the sway distribution morph, and the black-hole fall (60 fps with the geodesic raymarch at 52 steps; captured mid-fall: lensed shadow, photon ring, edge-on disk, streaks bending in); the `will-i-dream` template opened with all-null knobs and the rack off, the song decoded and played; Beam Sixteen at 60 fps in laser, hologram, and electricity (captured at electricity under a full sway fan); the layout found 5 grips and 6 chips, and the left rail collapsed to 18 px. MIDI access was granted but no port was present during these runs (the Sway was unplugged) — hardware behaviour of the new pad order is still unverified. A note for the next Electron upgrade: npm's allow-scripts gating skipped Electron's postinstall, so `node node_modules/electron/install.js` had to be run by hand before `dist/electron.exe` existed. - Installed build, 2026-08-20 21:27 (supersedes the 19:02 line below): `SwayCommand-Setup-0.1.0.exe` built 21:26 on Electron 43.4.1 / three 0.185.1 (asar checked for GLSL3 materials, `willidream`, `lay-grip`, `reconcileFx`, the new pad constants, zero `AKSWAYJ`), installed with `/S`, probed from the installed exe with the console hooked: 15 scenes compiled with zero errors, every scene at 60 fps in the `cutTo` sweep (Cymatic Orb read 58 in its first-compile window), Will I Dream at full warp 60 fps, 5 grips / 6 chips present, First Flight's knobs hue / fade / kit only, rack off. The probe's rail-collapse was persisted by the layout store and then cleared again from `%APPDATA%\SwayCommand\settings.json` so the next launch opens uncollapsed. diff --git a/README.md b/README.md index f7aa1ab..01c3f82 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ SwayCommand is an independent project. It is not affiliated with or endorsed by ## Feature summary - A single-page cockpit: the stage renders from boot to quit while the scene bank, transport, timeline, assignment panel, and drawers work on top of it — no screen switching, no interrupted frames. -- Fifteen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2 — every shader GLSL3, no autonomous rotation anywhere. -- Projects as `.sway` files: one JSON document carrying palette, engine settings, effects snapshot, synth patch, linked media, kit, timeline, and every control assignment. Save, open, and recent files live in the project menu; nine bundled templates (three tuned to pair with Audima's official Ableton demo packs — Garage, DNB, Hip Hop) provide starting points. +- Sixteen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2 — every shader GLSL3, no autonomous rotation anywhere. +- Projects as `.sway` files: one JSON document carrying palette, engine settings, effects snapshot, synth patch, linked media, kit, timeline, and every control assignment. Save, open, and recent files live in the project menu; ten bundled templates (three tuned to pair with Audima's official Ableton demo packs — Garage, DNB, Hip Hop) provide starting points. - A timeline with an audio lane (waveform clips scheduled sample-accurately on the audio clock) and a visual lane (scene clips with per-clip cut or fade entry), plus loop region, locators, and scrubbing. - An on-screen Sway deck — a line-art schematic of the hardware. Click any pad, knob, button, or gesture chip to assign it: pads fire samples, scene switches, or momentary effect punches; knobs drive any engine, rack, synth, or kit parameter with range and curve; buttons learn a hardware CC and toggle anything; the five gesture dimensions hold modulation routes. Touching a control on the hardware selects it on screen. - Automated scene cycling with palette-synchronized crossfades (Auto-VJ), after the pattern established by Keijiro Takahashi's Akvj. diff --git a/docs/ENGINE.md b/docs/ENGINE.md index af8a251..ed3017e 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -45,7 +45,7 @@ The composite pass is a fullscreen quad under an orthographic camera, drawn with ## Scene management -`scenes/index.js` imports the fifteen scene modules in registry order — `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream` — and derives two exports: `sceneList`, the array of each module's `meta`, and `creators`, a map from scene id to its `createScene` function. The digit keys `1`–`9` index the **active project's pool**, not this registry, so the registry can grow past the digits; the SCENES bank in the left rail lists all fifteen and prints each pooled scene's digit. +`scenes/index.js` imports the sixteen scene modules in registry order — `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb` — and derives two exports: `sceneList`, the array of each module's `meta`, and `creators`, a map from scene id to its `createScene` function. The digit keys `1`–`9` index the **active project's pool**, not this registry, so the registry can grow past the digits; the SCENES bank in the left rail lists all sixteen and prints each pooled scene's digit. Scene instances are created on demand at first use and cached in a map for the rest of the session; the engine never disposes them. Creation receives the context `{ THREE, renderer, width, height, quality }` defined in [SCENE_CONTRACT.md](SCENE_CONTRACT.md). Requesting an unregistered id throws `Unknown scene: `. diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 1e45418..578d1a6 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -52,7 +52,7 @@ Both parameters are consumed once, in `main()` of `src/renderer/app.js`, from `l | Parameter | Value | Behavior | |---|---|---| | `autoplay` | Template id, or a `.sway` file path | A value ending in `.sway` (case-insensitive) is opened as a project file; anything else is opened as a template id. On failure the startup falls through to the normal selection (most recent project, then the `first-flight` template) with a console warning. Any `autoplay` value also skips the SYSTEM modal: the blast door opens immediately and the Doctor still runs in the background, keeping its checks reachable from the CONTROLS modal. | -| `scene` | Scene id | Applied after the boot project loads: Auto-VJ is disabled and the engine switches to the named scene via `setScene(scene, 0.3)`. The value is one of the fifteen ids in the scene registry (`src/renderer/engine/scenes/index.js`): `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`. `setScene` ignores an id with no registered creator, so an unknown value leaves the project's opening scene on stage with Auto-VJ still disabled. | +| `scene` | Scene id | Applied after the boot project loads: Auto-VJ is disabled and the engine switches to the named scene via `setScene(scene, 0.3)`. The value is one of the sixteen ids in the scene registry (`src/renderer/engine/scenes/index.js`): `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb`. `setScene` ignores an id with no registered creator, so an unknown value leaves the project's opening scene on stage with Auto-VJ still disabled. | ## The automation handle diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 29441e9..514541c 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -9,7 +9,7 @@ The application is one page — the cockpit — and it is always live. There is | Region | Contents | |---|---| | Top bar | Wordmark; the project button (opens the project menu: New, Open, Save, Save as, recent files, templates); the transport (play/pause, stop, clock, LOOP); the current scene readout; status pills (`SWAY`/`MIDI`/`KEYS` link, `LOOPBACK`/`LINE`/`GROOVE`/`MUTE` input, fps); the deck buttons SYNTH, RACK, KIT, DOCS, HELP | -| Left rail | The SCENES bank — all fifteen registry scenes, with digit hints on the first nine of the active pool; a click switches the stage, a drag onto the timeline lays a clip. The AUTO group — RUN toggle, HOLD min–max seconds, FADE seconds | +| Left rail | The SCENES bank — all sixteen registry scenes, with digit hints on the first nine of the active pool; a click switches the stage, a drag onto the timeline lays a clip. The AUTO group — RUN toggle, HOLD min–max seconds, FADE seconds | | Center | The stage canvas. The blast door covers it at boot and opens once | | Right rail | The assignment panel for the selected control, and the INPUT box: the analysis-source button, a level meter, and an audio-reactive band display | | Bottom band | The timeline: a ruler (scrub, loop region, locators), a VISUAL lane of scene clips, an AUDIO lane with waveforms, and the playhead | diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index 5bf1af8..a1b3965 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -121,12 +121,13 @@ Clips are kept sorted by start time; validation drops zero-length clips. Editing ## Templates -Nine templates are bundled as full `.sway` documents at `projects/templates/*.sway`: the eight factory presets of earlier builds, produced from the legacy JSON by `legacyToSway()` (everything the legacy shape did not cover sits at defaults, and `meta.template` is `true`), and `will-i-dream`, an authored project whose timeline carries a linked track — the only template with media, linked by the absolute path of the author's machine, so on any other machine the audio clip loads with a missing-file warning and the scene still plays. They appear at the bottom of the project menu; opening one loads a fresh untitled copy — templates are read-only and are addressed by id through a gated channel, never by path. `projects/templates/index.json` fixes the menu order. +Ten templates are bundled as full `.sway` documents at `projects/templates/*.sway`: the eight factory presets of earlier builds, produced from the legacy JSON by `legacyToSway()` (everything the legacy shape did not cover sits at defaults, and `meta.template` is `true`), plus `will-i-dream`, an authored project whose timeline carries a linked track, and `natures-tomb`, which opens on the Nature's Tomb scene with nothing assigned — the only template with media, linked by the absolute path of the author's machine, so on any other machine the audio clip loads with a missing-file warning and the scene still plays. They appear at the bottom of the project menu; opening one loads a fresh untitled copy — templates are read-only and are addressed by id through a gated channel, never by path. `projects/templates/index.json` fixes the menu order. | Id | Name | Vibe | Scene pool | Start | Auto-VJ | Hold (s) | Fade (s) | BPM hint | Pairs with | |---|---|---|---|---|---|---|---|---|---| | `first-flight` | First Flight | welcoming | beams, swarm, ribbons, voxels, warp, nebula, mandelbulb, cymatic, vjshader | beams | on | 20–45 | 5 | — | — | | `will-i-dream` | Will I Dream | lucid | willidream | willidream | off | 60–120 | 4 | — | the track "Will I Dream" on the audio lane (0:00–3:49) with the scene on the visual lane | +| `natures-tomb` | Nature's Tomb | cellular | naturestomb | naturestomb | off | 60–120 | 4 | — | — | | `hyperspace` | Hyperspace | transluminal | warp, vjshader, mandelbulb, lattice, valley, spectra, swarm | warp | on | 22–46 | 3 | — | — | | `chrysanthemum` | Chrysanthemum | hyperreal | mandelbulb, vjshader, ferrofluid, chladni, lattice, nebula, warp, spectra | mandelbulb | on | 26–52 | 6 | — | — | | `beam-sixteen` | Beam Sixteen | anthemic | beams, nebula | beams | off | 30–60 | 6 | — | — | diff --git a/docs/SCENE_CONTRACT.md b/docs/SCENE_CONTRACT.md index c2fbd0d..1c88b31 100644 --- a/docs/SCENE_CONTRACT.md +++ b/docs/SCENE_CONTRACT.md @@ -118,7 +118,7 @@ Strike and sway are morph dimensions, not motion inputs: sway SHOULD continuousl ## Scene inventory -The registry holds fifteen scenes, listed here in registry order. Registry order fixes nothing on the keyboard — the digit keys index the active project's pool ([ENGINE.md](ENGINE.md#scene-management)). +The registry holds sixteen scenes, listed here in registry order. Registry order fixes nothing on the keyboard — the digit keys index the active project's pool ([ENGINE.md](ENGINE.md#scene-management)). | Id | Name | Mood | Mechanism | |---|---|---|---| @@ -136,4 +136,5 @@ The registry holds fifteen scenes, listed here in registry order. Registry order | `chladni` | Cymatic Plate | resonant | theDAW's Chladni/Faraday standing-wave liquid plate (`cymatics-shader.ts`, verbatim): sixteen n/m mode tables excited by a spectrum synthesized from the three bands, with a spectral-centroid mode picker and quadrature traveling waves, cover-framed so the damped rim never shows. A strike jumps the mode index by 3–5 with a decaying amplitude hit, sway bows the mode target across the whole table, press damps the plate (amplitude down, time × 0.4), pulse drives it (amplitude 1 → 2 plus a bloom surge), the hand drifts the eye over the surface. Apache-2.0 port, notice retained. | | `valley` | Chrome Valley | synthwave | theDAW's infinite-scroll chrome valley (`landscape-shader.ts`, verbatim): a mountain-walled terrain of stacked sine octaves under a fbm plasma sun with a fresnel halo, a palette point light, and a 240-star sky, reflecting the shared room environment. Sway crossfades the whole terrain from chrome landscape to golden-angle quasicrystal ferrofluid (`isFerrofluid` 0–1) and biases the scroll speed; a strike is a seismic hit (mountain height +1.2 decaying ~1.5 s, plasma and bloom flaring); press dives the camera between the ridges as the fog pulls in; pulse surges sun and stars; the hand drifts the camera across the valley. Apache-2.0 port, notice retained. | | `lattice` | Quantum Lattice | crystalline | theDAW's lattice engine at full fidelity: 373 instanced nodes and 756 beams with energy filaments and a reactor core, morphing between four geometries — Grand Torus, Cubic Frame, Merkabah Star, Cosmos Cage — with positions pouring toward their targets at 0.06/frame and a morph-energy shockwave, under live bloom (0.2 × per-shape multiplier, plus volume and shockwave). A strike advances the geometry and fires the full shockwave; sway morphs the filament field (wave detail plus a continuous morph push, so the lattice writhes and unravels); pulse surges bloom and the reactor heartbeat; press dives the camera; the hand steers the orbit. MIT-licensed upstream. | -| `willidream` | Will I Dream | lucid | A straight flight through deep space with nothing applied at rest: a palette-tinted star field streams toward a fixed camera, forward motion only. Warp lives on the hand — X sets streak speed, closeness to the sensors (1 − Y) sets density, their product is the amount, peaking at the highest X and lowest Y — and the stars themselves become the streaks: every star is an instanced screen-space capsule from where it is to where it was a shutter ago, radiating from the vanishing point along its own line of flight, random by construction; sway is the distribution control, morphing the field from random scatter toward an ordered lattice. Past 0.55 the jump engages (speed ×30) and under 0.25 it ends with a flash and a new celestial object ahead — pulsar (core, jets, magnetosphere, pulsing in place), spiral galaxy (an inclined plane with log-spiral arms, bulge, dust lanes), solar system (a limb-darkened sun, six shaded planets with atmospheres and one ringed, mutual shadows, a belt, faint orbit lines), layered nebula cloud, or a ringed gas giant — analytically ray-cast on one camera-facing quad, static forms, never the same twice running. PAD 7 opens a Schwarzschild black hole raymarched in the overlay (null geodesics bent by −1.5·h²·p/r⁵, an accretion disk seen nearly edge-on with its far side lensed over and under the shadow, Doppler beaming, a photon-ring glow) and the ship falls in — the shadow swallows the view from the center out while a point lens pushes the star field outward around it — then a beat of void and re-emergence into a re-seeded sky with no object (a jump in progress ends there). PAD 8 banks left and PAD 15 banks right, the one rotation in the scene, levelling out after 2.4 s; any other pad is a thrust surge. The star/streak layer is drawn above the object; the overlay above all. Three draw calls; no bloom requested. | +| `willidream` | Will I Dream | lucid | A straight flight through deep space with nothing applied at rest: a palette-tinted star field streams toward a fixed camera, forward motion only. Warp lives on the hand — X sets streak speed, closeness to the sensors (1 − Y) sets density, their product is the amount, peaking at the highest X and lowest Y — and the stars themselves become the streaks: every star is an instanced screen-space capsule from where it is to where it was a shutter ago, radiating from the vanishing point along its own line of flight, random by construction; sway is the distribution control, morphing the field from random scatter toward an ordered lattice. Past 0.55 the jump engages (speed ×30) and under 0.25 it ends with a flash and a new celestial object ahead — pulsar (core, jets, magnetosphere, pulsing in place), spiral galaxy (an inclined plane with log-spiral arms, bulge, dust lanes), solar system (a limb-darkened sun, six shaded planets with atmospheres and one ringed, mutual shadows, a belt, faint orbit lines), layered nebula cloud, or a ringed gas giant — analytically ray-cast on one camera-facing quad, static forms, never the same twice running. PAD 7 opens the black hole — Darryl Huffman's CodePen lens, maths kept: `pull = mass / dist²`, the view rotated about the mass by `(pull + held)·π` (a signed radial scaling that collapses and inverts the sky in rings) and darkened by `pull·0.25`; the mass eases in, then the hold climbs and the mass grows until its own darkening swallows the view from the center out, a beat of void, and re-emergence into a re-seeded sky with no object (a jump in progress ends there). The lens bends the star streaks and the object quad; the overlay carries the darkening. PAD 8 banks left and PAD 15 banks right, the one rotation in the scene, levelling out after 2.4 s; any other pad is a thrust surge. The star/streak layer is drawn above the object; the overlay above all. Three draw calls; no bloom requested. | +| `naturestomb` | Nature's Tomb | cellular | An egg cell under dark-field light on one fullscreen quad: a glassy zona pellucida (fresnel rim, mild refraction, a thin bright band) around an SDF raymarch of up to sixteen blastomeres — cytoplasm with granular noise, wrap lighting, a subsurface glow, a wet specular, and a nucleus with a nucleolus seen through the cytoplasm by a second short march. One continuous division level 0–4 (1 → 2 → 4 → 8 → 16 cells, each stage splitting along the next axis, centres easing apart as radii shrink by 2^−1/3, a smooth-min neck as the cleavage furrow) — driven by the control the user picks: KNOB 5 (`io.knobs[4]`) sets the level directly the moment it moves, any strike steps one division, reversing at sixteen (un-divide) and at one; the level eases over ~1.3 s so divisions play out, and driving it down merges the cells back. Sway jiggles and softens the membranes, press squeezes the embryo, the hand pans it (X) and dollies the eye (Y) — translation only; bass swells, the beat pulses, treble shimmers the granules. One draw call. | diff --git a/projects/templates/index.json b/projects/templates/index.json index 2f6354a..cd4db3b 100644 --- a/projects/templates/index.json +++ b/projects/templates/index.json @@ -2,6 +2,7 @@ "order": [ "first-flight", "will-i-dream", + "natures-tomb", "hyperspace", "chrysanthemum", "beam-sixteen", diff --git a/projects/templates/natures-tomb.sway b/projects/templates/natures-tomb.sway new file mode 100644 index 0000000..b541ff7 --- /dev/null +++ b/projects/templates/natures-tomb.sway @@ -0,0 +1,60 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "natures-tomb", + "name": "Nature's Tomb", + "description": "An egg cell under dark-field light. KNOB 5 sets the division level from one cell to sixteen and back; any pad steps a division; sway jiggles the membranes, press squeezes, the hand pans and dollies. The rack stays off.", + "author": "", + "vibe": "cellular", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": ["#f2c9a6", "#5b3f9e", "#bfefff", "#ffb26b", "#e88aa8"], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { "enabled": false, "pool": ["naturestomb"], "minHold": 60, "maxHold": 120, "fadeTime": 4 }, + "start": { "scene": "naturestomb" } + }, + "fx": { "params": {} }, + "synth": { "enabled": true, "preset": "Init", "patch": null }, + "media": [], + "sampler": { + "kit": { "version": 1, "pads": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null] }, + "knobs": { "master": 0.5, "cutoff": 0.5, "rate": 0.5, "send": 0.5 } + }, + "timeline": { + "loop": { "enabled": false, "start": 0, "end": 0 }, + "locators": [], + "tracks": [ + { "id": "audio-1", "type": "audio", "name": "Audio", "gain": 1, "muted": false, "clips": [] }, + { "id": "visual-1", "type": "visual", "name": "Scenes", "clips": [] } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { "synth": "unassigned" }, + "pads": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null], + "knobs": [null, null, null, null, null, null, null, null], + "gestures": [], + "buttons": [ + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null }, + { "cc": null, "channel": null, "action": null } + ] + }, + "midiOverrides": {} + } +} diff --git a/src/renderer/engine/scenes/index.js b/src/renderer/engine/scenes/index.js index 9e02bef..8c3e87f 100644 --- a/src/renderer/engine/scenes/index.js +++ b/src/renderer/engine/scenes/index.js @@ -17,10 +17,11 @@ import * as chladni from './chladni.js'; import * as valley from './valley.js'; import * as lattice from './lattice.js'; import * as willidream from './willidream.js'; +import * as naturestomb from './naturestomb.js'; const modules = [ beams, swarm, ribbons, voxels, warp, nebula, mandelbulb, cymatic, - spectra, vjshader, ferrofluid, chladni, valley, lattice, willidream, + spectra, vjshader, ferrofluid, chladni, valley, lattice, willidream, naturestomb, ]; export const sceneList = modules.map((m) => m.meta); diff --git a/src/renderer/engine/scenes/naturestomb.js b/src/renderer/engine/scenes/naturestomb.js new file mode 100644 index 0000000..20731e3 --- /dev/null +++ b/src/renderer/engine/scenes/naturestomb.js @@ -0,0 +1,318 @@ +// Nature's Tomb — an egg cell that divides by mitosis, or un-divides. +// +// THE CELL A zygote inside its zona pellucida, lit dark-field on black: +// a glassy outer shell with a fresnel rim and mild refraction, +// cytoplasm with granular texture, wrap lighting, a subsurface +// glow through the membrane, a wet specular, and a nucleus with +// a nucleolus seen through the cytoplasm (a second short march +// from the membrane hit, attenuated by depth). +// MITOSIS One continuous division LEVEL 0..4: 1 → 2 → 4 → 8 → 16 +// blastomeres. Each stage splits every cell along the next +// axis (X, Y, Z, then a diagonal); centres ease apart while the +// radii shrink (volume conserving, 2^-1/3 per stage) and a +// smooth-min neck forms the cleavage furrow. The level is a +// real number, so a half-finished division is a pinched cell, +// and driving it DOWN un-divides — the cells merge back. +// CONTROL The user chooses the interface: KNOB 5 (io.knobs[4]) sets the +// level directly (0..1 → 0..4) the moment it is moved, and any +// pad STRIKE steps one division further, reversing direction at +// 16 cells (un-divide) and at 1 (divide again). Either wins the +// moment it is used. The level eases toward its target over +// ~1.3 s, so a division plays out rather than snapping. +// GESTURES SWAY is the membrane-tension morph (a noise displacement that +// jiggles and softens the membranes); PRESS squeezes the embryo; +// the hand PANS the embryo (X) and DOLLIES the eye (Y) — +// translation only, nothing rotates; bass swells the +// cytoplasm, the beat pulses it, treble shimmers the granules, +// the level lifts the zona's glow. +// +// One fullscreen quad, one draw call: an SDF raymarch of up to sixteen +// spheres whose centres and radii the CPU computes per frame into a vec4 +// table (uCells), a nucleus field from the same table, and the zona as an +// analytic sphere. GLSL3. Colour: cytoplasm from palette 3/4 lifted toward +// white, nucleus from palette 1, membrane rim palette 0, zona palette 2. + +export const meta = { id: 'naturestomb', name: "Nature's Tomb", mood: 'cellular' }; + +const MAX_CELLS = 16; +const R0 = 1.08; // zygote radius +const ZONA = 1.3; // zona pellucida radius (fixed — the embryo compacts inside it) +const SEP = 0.6; // centre separation per stage, as a fraction of the child radius +const AXES = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0.57735, 0.57735, -0.57735], +]; + +const GLSL = /* glsl */ ` + #define PI 3.14159265359 + uniform vec2 uRes; + uniform vec4 uCells[16]; // xyz centre, w radius + uniform int uCount; + uniform float uTime, uDist, uZona, uJig, uPress, uBeat, uBass, uHigh, uLevelA, uLevel, uIntensity; + uniform vec2 uPan; + uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + in vec2 vUv; + out vec4 fragColor; + + float h31(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } + float noise3(vec3 p) { + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float n000 = h31(i), n100 = h31(i + vec3(1, 0, 0)), n010 = h31(i + vec3(0, 1, 0)), n110 = h31(i + vec3(1, 1, 0)); + float n001 = h31(i + vec3(0, 0, 1)), n101 = h31(i + vec3(1, 0, 1)), n011 = h31(i + vec3(0, 1, 1)), n111 = h31(i + vec3(1, 1, 1)); + return mix(mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), f.z); + } + float smin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); + } + float iSphere(vec3 ro, vec3 rd, vec3 c, float r, out float edge) { + vec3 oc = ro - c; + float b = dot(oc, rd); + float cc = dot(oc, oc) - r * r; + float h = b * b - cc; + edge = h / (r * r); + if (h < 0.0) return -1.0; + return -b - sqrt(h); + } + // the blastomeres: smooth union of the live cells, jiggled by sway, swollen by bass + float mapCells(vec3 p) { + p.y *= 1.0 + uPress * 0.22; // press squeezes the embryo flat + float d = 1e9; + for (int i = 0; i < 16; i++) { + if (i >= uCount) break; + vec4 c = uCells[i]; + float di = length(p - c.xyz) - c.w * (1.0 + uBass * 0.04); + d = (i == 0) ? di : smin(d, di, c.w * 0.22); + } + d += uJig * 0.05 * (noise3(p * 3.0 + vec3(0.0, uTime * 0.35, 0.0)) - 0.5); + return d; + } + float mapNuc(vec3 p) { + p.y *= 1.0 + uPress * 0.22; + float d = 1e9; + for (int i = 0; i < 16; i++) { + if (i >= uCount) break; + vec4 c = uCells[i]; + d = min(d, length(p - c.xyz) - c.w * 0.38); + } + return d; + } + vec3 normalCells(vec3 p) { + const vec2 e = vec2(0.0025, 0.0); + return normalize(vec3( + mapCells(p + e.xyy) - mapCells(p - e.xyy), + mapCells(p + e.yxy) - mapCells(p - e.yxy), + mapCells(p + e.yyx) - mapCells(p - e.yyx))); + } + + void main() { + vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0); + vec3 ro = vec3(0.0, 0.0, uDist); + vec3 rd = normalize(vec3(uv * 1.05, -1.0)); + vec3 E = vec3(uPan.x, uPan.y, 0.0); // the embryo, panned by the hand + vec3 col = vec3(0.0); + + // dark-field halo outside the zona + float dC = length(cross(ro - E, rd)); + vec3 zonaTint = mix(uPal2, vec3(1.0), 0.6); + col += zonaTint * 0.05 * exp(-(dC - uZona) * 5.0) * step(uZona, dC) * (0.6 + 0.4 * uLevelA); + + float ez; + float tz = iSphere(ro, rd, E, uZona, ez); + if (tz > 0.0) { + vec3 pz = ro + rd * tz; + vec3 nz = normalize(pz - E); + float fz = pow(1.0 - max(dot(nz, -rd), 0.0), 3.0); + vec3 rdi = refract(rd, nz, 1.0 / 1.10); + // march the cells inside the zona along the refracted ray + vec3 p = pz; + float t = 0.0; + bool hit = false; + float tExit = 2.0 * uZona + 0.2; + for (int i = 0; i < STEPS; i++) { + float d = mapCells(p - E); + if (d < 0.0015) { hit = true; break; } + t += d * 0.92; + if (t > tExit) break; + p = pz + rdi * t; + } + vec3 inner = vec3(0.0); + if (hit) { + vec3 q0 = p - E; + vec3 n = normalCells(q0); + vec3 L = normalize(vec3(-0.55, 0.7, 0.5)); + vec3 L2 = normalize(vec3(0.7, -0.3, 0.4)); + float gr = noise3(q0 * 14.0 + vec3(0.0, uTime * 0.05, 0.0)); + float gr2 = noise3(q0 * 40.0 + uTime * 0.02); + vec3 cyto = mix(mix(uPal3, uPal4, 0.3), vec3(1.0), 0.25); + cyto *= 0.85 + 0.22 * gr + 0.10 * gr2 * (1.0 + uHigh * 1.5); + float wrap = dot(n, L) * 0.5 + 0.5; + float lit = wrap * wrap; + float rim = pow(1.0 - max(dot(n, -rdi), 0.0), 2.0); + vec3 sss = mix(uPal4, uPal3, 0.5) * (0.35 * rim + 0.25 * pow(max(dot(rdi, L), 0.0), 3.0)); + float spec = pow(max(dot(reflect(-L, n), -rdi), 0.0), 60.0) * 0.45 + pow(max(dot(reflect(-L2, n), -rdi), 0.0), 30.0) * 0.12; + inner = cyto * (0.12 + 0.88 * lit) + sss + vec3(spec) + uPal0 * rim * 0.25; + // the nucleus, seen through the cytoplasm + float tn = 0.02, depth = 0.0; + bool nuc = false; + vec3 q = q0; + for (int j = 0; j < NSTEPS; j++) { + q = q0 + rdi * tn; + float dn = mapNuc(q); + if (dn < 0.003) { nuc = true; depth = tn; break; } + if (mapCells(q) > 0.02) break; + tn += max(dn * 0.9, 0.01); + } + if (nuc) { + float nShade = 0.5 + 0.5 * noise3(q * 9.0); + vec3 nucCol = mix(uPal1, uPal0, 0.2) * (0.6 + 0.4 * nShade); + float nucleolus = smoothstep(0.6, 0.9, noise3(q * 18.0 + 3.0)); + nucCol = mix(nucCol, uPal1 * 0.5, nucleolus * 0.6); + inner = mix(inner, nucCol, exp(-depth * 2.5) * 0.62); + } + inner *= 1.0 + uBeat * 0.12; + } + // the zona: glassy shell, thin bright band, rim + float ringZ = exp(-pow((dC - uZona * 0.965) / (uZona * 0.018), 2.0)); + col += inner * (0.92 - 0.25 * fz) + zonaTint * (fz * 0.55 + 0.035) * (0.6 + 0.4 * uLevelA) + zonaTint * ringZ * 0.22; + col *= smoothstep(0.0, max(fwidth(ez) * 1.5, 0.0001), ez); + } + fragColor = vec4(col * uIntensity, 1.0); + } +`; + +export function createScene(ctx) { + const { THREE, quality } = ctx; + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const tier = quality.tier; + const STEPS = tier === 'low' ? 28 : tier === 'high' ? 56 : 40; + const NSTEPS = tier === 'low' ? 12 : tier === 'high' ? 20 : 16; + + const cells = new Float32Array(MAX_CELLS * 4); + const pal = Array.from({ length: 5 }, () => ({ value: new THREE.Color(1, 1, 1) })); + const U = { + uRes: { value: new THREE.Vector2(ctx.width, ctx.height) }, + uCells: { value: cells }, + uCount: { value: 1 }, + uTime: { value: 0 }, + uDist: { value: 3.4 }, + uZona: { value: ZONA }, + uJig: { value: 0 }, + uPress: { value: 0 }, + uBeat: { value: 0 }, + uBass: { value: 0 }, + uHigh: { value: 0 }, + uLevelA: { value: 0 }, + uLevel: { value: 0 }, + uIntensity: { value: 1 }, + uPan: { value: new THREE.Vector2(0, 0) }, + uPal0: pal[0], uPal1: pal[1], uPal2: pal[2], uPal3: pal[3], uPal4: pal[4], + }; + const mat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, + uniforms: U, + defines: { STEPS, NSTEPS }, + vertexShader: /* glsl */ ` + out vec2 vUv; + void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`, + fragmentShader: GLSL, + depthTest: false, + depthWrite: false, + }); + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat); + quad.frustumCulled = false; + scene.add(quad); + + const approach = (v, to, tau, dt) => v + (to - v) * (1 - Math.exp(-dt / tau)); + const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + const smooth01 = (u) => { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); }; + + // cell table for a continuous level: every cell i takes the side bit j of + // its index along axis j for each completed or in-progress stage + function computeCells(L) { + let count = 1; + for (let j = 0; j < 4; j++) if (L - j > 1e-4) count *= 2; + for (let i = 0; i < MAX_CELLS; i++) { + let x = 0, y = 0, z = 0, rad = R0; + for (let j = 0; j < 4; j++) { + const p = clamp(L - j, 0, 1); + if (p <= 0) break; + const sm = smooth01(p); + const side = (i >> j) & 1 ? 1 : -1; + const rNext = R0 * Math.pow(2, -(j + 1) / 3); + const sep = rNext * SEP * sm; + x += AXES[j][0] * side * sep; + y += AXES[j][1] * side * sep; + z += AXES[j][2] * side * sep; + rad += (rNext - rad) * sm; + } + cells[i * 4] = x; cells[i * 4 + 1] = y; cells[i * 4 + 2] = z; cells[i * 4 + 3] = rad; + } + return count; + } + + let level = 0, target = 0, dir = 1; + let knobPrev = null, strikePrev = 0; + let jig = 0, press = 0, bass = 0, high = 0, lvl = 0, dist = 3.4, panX = 0, panY = 0, pulse = 0, beatPrev = 0; + + return { + scene, + camera, + update(dt, t, io) { + // ---- the control interface: KNOB 5 sets the level, any strike steps it + const k = io.knobs[4]; + if (knobPrev === null) knobPrev = k; + if (Math.abs(k - knobPrev) > 1 / 256) { + knobPrev = k; + target = k * 4; + dir = target >= level ? 1 : -1; + } + if (io.strike > strikePrev + 0.3) { + if (target >= 4 - 1e-3) dir = -1; + else if (target <= 1e-3) dir = 1; + target = clamp(Math.round(target) + dir, 0, 4); + } + strikePrev = io.strike; + level = approach(level, target, 1.3, dt); + U.uCount.value = computeCells(level); + U.uLevel.value = level; + + // ---- gestures: sway jiggles, press squeezes, the hand pans and dollies + jig = approach(jig, io.gestures.sway, 0.4, dt); + press = approach(press, io.gestures.press, 0.15, dt); + panX = approach(panX, (io.xy.x - 0.5) * 1.6, 0.3, dt); + panY = approach(panY, (io.xy.y - 0.5) * 0.9, 0.3, dt); + dist = approach(dist, 4.3 - io.xy.y * 1.9, 0.35, dt); + bass = approach(bass, io.bands.bass, 0.12, dt); + high = approach(high, io.bands.high, 0.1, dt); + lvl = approach(lvl, io.level, 0.25, dt); + if (io.beat > beatPrev + 0.3) pulse = 1; + beatPrev = io.beat; + pulse = Math.max(0, pulse - dt * 3.5); + + const pl = io.palette; + for (let i = 0; i < 5; i++) pal[i].value.copy(pl[i]); + U.uTime.value = t; + U.uDist.value = dist; + U.uJig.value = jig; + U.uPress.value = press; + U.uBeat.value = pulse; + U.uBass.value = bass; + U.uHigh.value = high; + U.uLevelA.value = lvl; + U.uIntensity.value = io.intensity; + U.uPan.value.set(panX, panY); + }, + resize(w, h) { + U.uRes.value.set(w, h); + }, + dispose() { + quad.geometry.dispose(); + mat.dispose(); + }, + }; +} diff --git a/src/renderer/engine/scenes/willidream.js b/src/renderer/engine/scenes/willidream.js index 43b4680..39505ec 100644 --- a/src/renderer/engine/scenes/willidream.js +++ b/src/renderer/engine/scenes/willidream.js @@ -20,17 +20,22 @@ // object ahead: pulsar, spiral galaxy, solar system, nebula, or // ringed planet, never the same twice running. The star/streak // layer is drawn above the object (renderOrder 2 > 1). -// BLACK HOLE PAD 7 opens it at the center: a Schwarzschild black hole -// raymarched in the overlay — null geodesics bent by the -// -1.5·h²·p/r⁵ term, an accretion disk seen nearly edge-on with -// the far side lensed over and under the shadow, Doppler -// beaming (bright, blue-shifted approaching side; dim, red -// receding side), a photon-ring glow — and the ship FALLS IN: -// the virtual camera closes from 80 to 2.2 rs so the shadow -// swallows the view from the center out while the star lens -// pushes the field outward around it, then a beat of void and -// re-emergence into a re-seeded sky with NO celestial object; a -// jump in progress ends into the hole instead of spawning one. +// BLACK HOLE PAD 7 opens it at the center — Darryl Huffman's "Black Hole +// (WebGL Shader)" lens (the user's BLACKHOLE.zip), its maths +// kept: pull = mass / dist², the view is rotated about the mass +// by (pull + held)·π — with the sine term at zero, as upstream +// ships it, that is a signed radial scaling that collapses and +// inverts the sky in concentric rings — and every pixel is +// darkened by pull·0.25, which blacks out the core. The pen's +// mass eases in (cur += (target − cur)·0.03 per frame); then, +// as with its held click, the hold value climbs so the rings +// sweep inward while the mass grows until its own darkening +// covers the screen — the swallow from the center out — then a +// beat of void and re-emergence into a re-seeded sky with NO +// celestial object; a jump in progress ends into the hole +// instead of spawning one. The lens is applied to the stars' +// streak endpoints and the object quad; the overlay carries the +// darkening. // BANKS PAD 8 banks left, PAD 15 banks right: the only rotation in // the scene, a 2.4 s roll-and-yaw that levels out again; the // stars sweep sideways with the turn. @@ -90,13 +95,22 @@ const GLSL_COMMON = /* glsl */ ` return n; } mat2 rot2(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); } - // Black-hole lens on a centred, aspect-corrected screen position (height = 1): - // a point lens pushes images outward by thetaE^2 / theta. hole = (thetaE^2, - // shadow radius). Zero is the identity. + // Darryl Huffman's black-hole lens (CodePen gRZrpv, MIT) on a centred, + // aspect-corrected screen position (height = 1): pull = mass / dist², the + // point is rotated about the mass by (pull + held)·π with the sine term at + // zero exactly as upstream ships it, i.e. a signed radial scaling that + // collapses and inverts the view in rings. hole = (mass, held). Zero mass is + // the identity. vec2 lens(vec2 q, vec2 hole) { if (hole.x <= 0.0) return q; - float d2 = dot(q, q); - return q * (1.0 + hole.x / (d2 + 0.003)); + float pull = hole.x / max(dot(q, q), 0.00001); + return q * cos((pull + hole.y) * PI); + } + // the pen's darkening of what the lens shows: colour − pull·0.25 + float lensDark(vec2 q, vec2 hole) { + if (hole.x <= 0.0) return 0.0; + float pull = hole.x / max(dot(q, q), 0.00001); + return clamp(pull * 0.25, 0.0, 1.0); } `; @@ -133,7 +147,7 @@ const STAR_VERT = /* glsl */ ` float vis = step(0.3, ch.w) * step(0.3, ct.w); vec2 sh = lens(toScreen(ch), uHole); vec2 stl = lens(toScreen(ct), uHole); - float horizon = uHole.y > 0.0 ? 1.0 - step(uHole.y, length(sh)) : 0.0; + float dark = lensDark(toScreen(ch), uHole); vec2 d = sh - stl; float len = length(d); vec2 dir = len > 0.00001 ? d / len : vec2(1.0, 0.0); @@ -150,7 +164,7 @@ const STAR_VERT = /* glsl */ ` float near = smoothstep(uDepth, uDepth * 0.3, dist); float tw = 1.0 - 0.18 * uTwinkle * (0.5 + 0.5 * sin(uTime * (2.0 + aInfo.z * 7.0) + aInfo.z * 40.0)); // a stretched star spreads its light; the jump adds energy back - vA = (0.42 + mag * 1.3) * (0.5 + 0.5 * near) * tw * uBreath * vis * (1.0 - horizon) * (1.0 + uGain) / sqrt(1.0 + vLenR * 0.22); + vA = (0.42 + mag * 1.3) * (0.5 + 0.5 * near) * tw * uBreath * vis * (1.0 - dark) * (1.0 + uGain) / sqrt(1.0 + vLenR * 0.22); vec3 tint = aInfo.y < 0.33 ? uPal0 : (aInfo.y < 0.66 ? uPal1 : uPal2); vCol = mix(vec3(1.0), tint, 0.38 + 0.22 * mag); } @@ -488,98 +502,25 @@ const OVER_VERT = /* glsl */ ` `; const OVER_FRAG = /* glsl */ ` - out vec4 fragColor; ${GLSL_COMMON} uniform vec2 uRes; - uniform float uTime, uWarp, uFlash, uVeil, uSwallow, uIntensity, uRoll, uHoleD, uDisk; + uniform float uTime, uWarp, uFlash, uVeil, uIntensity, uRoll; + uniform vec2 uHole; uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; in vec2 vUv; - - // accretion disk sample: temperature falloff, static streaks + noise, - // Doppler beaming toward the approaching side; v = ray direction at the hit - vec4 diskSample(vec3 hit, vec3 N, vec3 U, vec3 V, vec3 v) { - float rh = length(hit); - float t = (rh - 2.6) / (9.0 - 2.6); - float temp = pow(clamp(1.0 - t, 0.0, 1.0), 0.8); - float phi = atan(dot(hit, V), dot(hit, U)); - float n = vnoise(vec2(phi * 3.0, rh * 1.6)) * 0.6 + vnoise(vec2(phi * 9.0 + 3.0, rh * 4.0)) * 0.4; - float streaks = 0.5 + 0.5 * sin(phi * 30.0 + rh * 7.0 + n * 7.0); - float flick = 0.9 + 0.1 * sin(uTime * 6.0 + rh * 5.0); - float dens = (0.55 + 0.45 * n) * (0.55 + 0.45 * streaks) * smoothstep(0.0, 0.1, t) * (1.0 - smoothstep(0.7, 1.0, t)) * flick; - vec3 vel = normalize(cross(N, hit)); - float beta = sqrt(0.5 / max(rh, 1.0)); - float cosang = dot(vel, -v); - float dop = clamp(pow(1.0 / max(1.0 - beta * cosang * 0.95, 0.15), 3.0), 0.15, 5.0); - vec3 hot = mix(uPal3, vec3(1.0), 0.65); - vec3 warm = uPal4; - vec3 cool = mix(uPal2, uPal0, 0.5); - vec3 c = mix(warm, hot, temp); - c = mix(c, cool, clamp((dop - 1.0) * 0.3, 0.0, 0.6)); - c = mix(c, warm * 0.5, clamp((1.0 - dop) * 0.9, 0.0, 0.7)); - float a = clamp(dens * 0.9, 0.0, 0.95); - return vec4(c * dens * dop * (1.0 + temp * 2.2), a); - } - + out vec4 fragColor; void main() { vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0); uv = rot2(uRoll) * uv; float r = length(uv); vec3 col = vec3(0.0); - float cover = 0.0; // vanishing-point glow of the jump, and the exit flash col += mix(uPal2, vec3(1.0), 0.5) * uWarp * uWarp * 0.22 / (r * 6.0 + 0.25); col += vec3(1.0) * uFlash * 0.35; - - if (uHoleD > 0.0) { - vec3 ro = vec3(0.0, 0.0, uHoleD); - vec3 rd = normalize(vec3(uv * 1.1, -1.0)); - vec3 N = normalize(vec3(0.0, 0.976, 0.22)); - vec3 U = normalize(cross(N, vec3(1.0, 0.0, 0.0))); - vec3 Vv = cross(N, U); - float b = length(cross(ro, rd)); - vec3 disk = vec3(0.0); - float T = 1.0; - float captured = 0.0; - if (b < 14.0 || uHoleD < 4.0) { - vec3 p = ro; vec3 v = rd; - for (int i = 0; i < STEPS; i++) { - float r2 = dot(p, p); - float rr = sqrt(r2); - if (rr < 1.0) { captured = 1.0; break; } - if (rr > 14.0 && dot(p, v) > 0.0) break; - vec3 h = cross(p, v); - float h2 = dot(h, h); - float dt = clamp(rr * 0.16, 0.03, 0.8); - vec3 a = -1.5 * h2 * p / (r2 * r2 * rr); - vec3 pn = p + v * dt + 0.5 * a * dt * dt; - float s0 = dot(p, N), s1 = dot(pn, N); - if (s0 * s1 < 0.0) { - vec3 hit = mix(p, pn, s0 / (s0 - s1)); - float rh = length(hit); - if (rh > 2.6 && rh < 9.0) { - vec4 dc = diskSample(hit, N, U, Vv, v); - disk += dc.rgb * dc.a * T; - T *= (1.0 - dc.a); - if (T < 0.03) break; - } - } - v = normalize(v + a * dt); - p = pn; - } - } - // photon-ring glow just outside the shadow (screen-space approximation) - float shadowR = 2.36 / uHoleD; - float ring = exp(-pow((r - shadowR * 1.02) / (shadowR * 0.05 + 0.002), 2.0)) * (1.0 - captured); - float halo = exp(-(r - shadowR) * (6.0 / (shadowR + 0.05))) * step(shadowR, r) * 0.25; - vec3 glow = (mix(uPal3, vec3(1.0), 0.7) * ring * 1.6 + mix(uPal4, uPal3, 0.5) * halo) * uDisk; - col = col * (1.0 - captured) + disk * uDisk + glow; - cover = max(captured, (1.0 - T) * 0.9); - } - - float fade = uSwallow > 0.0 ? 1.0 - smoothstep(uSwallow - 0.25, uSwallow, r) : 0.0; - float dark = max(fade, uVeil); - cover = max(cover, dark); - col *= (1.0 - dark); + // the pen's darkening: the core goes black, the rings dim toward it + float dark = lensDark(uv, uHole); + float cover = max(dark, uVeil); + col *= (1.0 - cover); fragColor = vec4(col * uIntensity, cover); } `; @@ -591,7 +532,6 @@ export function createScene(ctx) { camera.position.set(0, 0, 0); const tier = quality.tier; const STARS = tier === 'low' ? 4000 : tier === 'high' ? 16000 : 9000; - const STEPS = tier === 'low' ? 36 : tier === 'high' ? 72 : 52; const fovK = 1 / (2 * Math.tan((FOV * Math.PI) / 360)); const pal = () => Array.from({ length: 5 }, () => ({ value: new THREE.Color(1, 1, 1) })); @@ -696,17 +636,14 @@ export function createScene(ctx) { uWarp: { value: 0 }, uFlash: { value: 0 }, uVeil: { value: 0 }, - uSwallow: { value: 0 }, uIntensity: { value: 1 }, uRoll: { value: 0 }, - uHoleD: { value: 0 }, - uDisk: { value: 0 }, + uHole: { value: new THREE.Vector2(0, 0) }, uPal0: vp0, uPal1: vp1, uPal2: vp2, uPal3: vp3, uPal4: vp4, }; const overMat = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, uniforms: overU, - defines: { STEPS }, vertexShader: OVER_VERT, fragmentShader: OVER_FRAG, transparent: true, @@ -736,7 +673,7 @@ export function createScene(ctx) { let seq = 0.618; let bankT = -1, bankSide = 0, roll = 0, yaw = 0, rollT = 0, yawT = 0; let holeT = -1, voidDone = false; - let holeD = 0, disk = 0, lensE = 0, swallow = 0, veil = 0; + let mass = 0, held = 0, veil = 0; let pulse = 0, beatPrev = 0; const padPrev = new Float32Array(PADS); @@ -810,18 +747,19 @@ export function createScene(ctx) { yaw = approach(yaw, yawT, 0.12, dt); camera.rotation.set(0, yaw, roll); - // ---- black hole: open (approach), swallow (fall in), void, emerge - let holeDT = 0, diskT = 0, lensT = 0, swallowT = 0, veilT = 0; + // ---- black hole (Huffman's lens): the mass eases in, the hold takes us in + let massT = 0, heldT = 0, veilT = 0; if (holeT >= 0) { holeT += dt; if (holeT < T_OPEN) { - const u = ease(holeT / T_OPEN); - holeDT = 80 - 60 * u; diskT = u; lensT = 0.004 * u; + massT = 0.015; heldT = 0; } else if (holeT < T_SWALLOW) { const u = (holeT - T_OPEN) / (T_SWALLOW - T_OPEN); - const ue = u * u; - holeDT = 20 - 17.8 * ue; diskT = 1; lensT = 0.004 + 0.03 * ue; - swallowT = 1.3 * smooth(0.55, 1.0, u); + // the pen's held click: its hold value climbs 0.03 per frame and the + // rings sweep inward; the mass grows until its own darkening + // (pull · 0.25) covers the screen — the swallow from the center out + heldT = u * 2.4; + massT = 0.015 + 4.0 * u * u * u; } else if (holeT < T_VOID) { if (!voidDone) { voidDone = true; @@ -840,17 +778,14 @@ export function createScene(ctx) { } } if (holeT >= 0 && holeT < T_SWALLOW) { - holeD = holeD === 0 ? holeDT : approach(holeD, holeDT, 0.08, dt); - disk = approach(disk, diskT, 0.12, dt); - lensE = approach(lensE, lensT, 0.08, dt); - swallow = approach(swallow, swallowT, 0.06, dt); + mass = approach(mass, massT, 0.25, dt); // the pen eases cur toward target 3 % per frame + held = heldT; veil = 0; } else if (holeT >= T_SWALLOW && holeT < T_END) { - holeD = 0; disk = 0; lensE = 0; swallow = 0; veil = veilT; + mass = 0; held = 0; veil = veilT; } else { - holeD = 0; disk = 0; lensE = 0; swallow = 0; veil = approach(veil, 0, 0.1, dt); + mass = 0; held = 0; veil = approach(veil, 0, 0.1, dt); } - const shadowR = holeD > 0 ? (2.36 / holeD) * 1.02 : 0; // ---- flight: forward only; the jump multiplies speed, strikes kick it const targetSpeed = CRUISE * (1 + warpS * WARP_GAIN) + kick * 160 + io.beat * 30; @@ -887,7 +822,7 @@ export function createScene(ctx) { starU.uOrder.value = orderS; starU.uTail.value = tail; starU.uGain.value = warpS * 1.6 + io.bands.bass * 0.3; - starU.uHole.value.set(lensE, shadowR); + starU.uHole.value.set(mass, held); starU.uIntensity.value = io.intensity; objU.uTime.value = t; @@ -896,18 +831,16 @@ export function createScene(ctx) { objU.uAlpha.value = objAlpha; objU.uPulse.value = Math.max(pulse, 0.5 + 0.5 * Math.sin(t * 7.0)); objU.uObjPos.value.set(objX, objY, objZ); - objU.uHole.value.set(lensE, shadowR); + objU.uHole.value.set(mass, held); objU.uIntensity.value = io.intensity; overU.uTime.value = t; overU.uWarp.value = warpS; overU.uFlash.value = flash; overU.uVeil.value = veil; - overU.uSwallow.value = swallow; overU.uIntensity.value = io.intensity; overU.uRoll.value = roll; - overU.uHoleD.value = holeD; - overU.uDisk.value = disk; + overU.uHole.value.set(mass, held); }, resize(w, h) { camera.aspect = w / Math.max(1, h); From 9f7d8b49899db34a06968968ff7e936a8fd0503c Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:04:25 -0700 Subject: [PATCH 2/3] Nature's Tomb: slime mold and mycelium; Miracle Mile: four acts of the atom; scene harness; handoff with prunable delivered-work blocks --- HANDOFF.md | 215 +--- README.md | 2 +- docs/ENGINE.md | 2 +- docs/ENVIRONMENT.md | 2 +- docs/OVERVIEW.md | 2 +- docs/PROJECTS.md | 3 +- docs/SCENE_CONTRACT.md | 7 +- projects/templates/index.json | 1 + projects/templates/miracle-mile.sway | 183 +++ projects/templates/natures-tomb.sway | 167 ++- scripts/handoff-prune.js | 45 + scripts/scene-harness.entry.js | 120 ++ scripts/scene-harness.js | 98 ++ src/renderer/engine/scenes/index.js | 3 +- src/renderer/engine/scenes/miraclemile.js | 1293 +++++++++++++++++++++ src/renderer/engine/scenes/naturestomb.js | 643 +++++++++- 16 files changed, 2561 insertions(+), 225 deletions(-) create mode 100644 projects/templates/miracle-mile.sway create mode 100644 scripts/handoff-prune.js create mode 100644 scripts/scene-harness.entry.js create mode 100644 scripts/scene-harness.js create mode 100644 src/renderer/engine/scenes/miraclemile.js diff --git a/HANDOFF.md b/HANDOFF.md index 446194b..03deccd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,196 +1,109 @@ # SwayCommand handoff -Written 2026-08-20 for the next engineer or agent, after the single-page cockpit redesign. This is an internal engineering note. It is deliberately **not** in `docs/`, not in the documentation index, and not packaged into the app — `electron-builder.yml` ships `docs/**/*.md` and `README.md` by exact name, so a root-level file never reaches an end user. +Internal engineering note for the next engineer or agent. It is deliberately **not** in `docs/`, not in the documentation index, and not packaged — `electron-builder.yml` ships `docs/**/*.md` and `README.md` by exact name, so a root-level file never reaches an end user. Product documentation lives in [`docs/`](docs/); start with [docs/OVERVIEW.md](docs/OVERVIEW.md) for terminology. Last revised 2026-08-21, after the fourth pass. -Product documentation lives in [`docs/`](docs/) and was regenerated against this source tree on 2026-08-20. Start with [docs/OVERVIEW.md](docs/OVERVIEW.md) for terminology; this note assumes it. +**How this note stays short.** Delivered work waits for the user's approval inside `` … `` blocks in §2, one block per task, most recent last. When the user approves a task, run `node scripts/handoff-prune.js ` (`--list` prints the ids, `--all` clears them) and the block goes; its id lands on the ledger line at the end, and git history keeps the detail. Everything outside the blocks is the durable core — state, standing rules, how to verify, known gaps, packaging — and is edited in place, never appended to. --- -## 1. Repository state +## 1. State -Version control exists. The working branch is `cockpit`; `main` holds the pre-redesign import. Recent history on `cockpit`: - -``` -f8f6338 Handoff: record the visuals engine pass and verification discipline -353d0fa Wormhole: hard-science space in three regimes; doc updates -228b9e2 Sway and strike morph the generators: vjshader, spectra -a6aaf47 Sway and strike morph the generators: nebula, mandelbulb, cymatic -1c6ee2e Sway and strike morph the generators: beams, swarm, ribbons, warp -ea3adb2 Cymatic Plate: faithful theDAW cymatics platform -46bf7c8 Ferrofluid Orb: faithful theDAW port with real reflections -7b3f0ea Anaglyph and mosaic in the rack; frame-wrecking knob defaults; real valley -d0c2098 Real Quantum Lattice, HDR pipeline, per-scene UnrealBloom, shared environment -e8856d9 Rename AKSWAYJ to SwayCommand everywhere -709d2b3 Regenerate documentation for the cockpit; select audio clips on the band -710fd61 Retire legacy presets; template scene pads; verification hooks -bbe9649 Single-surface cockpit -``` - -The rename in `e8856d9` is total: product name, wordmarks, `window.swaycommand` (preload API), `window.__swaycommand` (automation handle), the `SWAYCOMMAND_*` env hooks, appId, installer artifact, launcher scripts, and every doc. The Electron userData directory follows the product name, so settings written under the old name (learned MIDI overrides, the legacy kit) did not carry over. A fresh installer — `release/SwayCommand-Setup-0.1.0.exe`, built 2026-08-20 18:58 after the visuals pass and the doc reconciliation — was installed per-user and probed (§3). The `AKSWAYJ-Setup-0.1.0.exe` beside it and the `akswayj` install under `%LOCALAPPDATA%\Programs` are the pre-rename build; neither was removed. - -`node_modules`, `release`, and `dist` are gitignored. Commit in small units; the pre-git era of this project lost history it never had. +- Branch `cockpit` tracks `origin/cockpit`; `main` holds the pre-redesign import (the merge is the user's call). `b98991a` committed the third pass (Will I Dream's CodePen lens, Nature's Tomb's egg cell); the fourth pass — Nature's Tomb's slime mold and mycelium, Miracle Mile, the scene harness, this note's structure — follows it. +- Registry: seventeen scenes (`src/renderer/engine/scenes/index.js`). Templates: eleven (`projects/templates/index.json`), all validating through `validateProject` with no warnings. +- Installed build: see the line the packaging step writes at the end of this section. Before that step the installed build (2026-08-20 21:27) predates the third and fourth passes. +- **Next steps in order:** the user reviews §2 and prunes what they approve; the physical session at the Sway (`scripts/midi-session.ps1`) to confirm the pad order, the button and knob-press CCs, Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5 / KNOB 6 and strikes, Miracle Mile's KNOB 5 / KNOB 6 and strikes, Will I Dream's warp corner and PADS 7 / 8 / 15; then the `scene:` assignment target (§5) if the user wants it; then the remaining §5 gaps. --- -## 2. What the redesign did - -The five full-window screens (boot, projects, studio, docs, perform) are gone. The app is one always-live page: the stage renders from the first frame to quit, and every panel works on top of it. Layout and interaction model: [docs/OVERVIEW.md](docs/OVERVIEW.md#the-cockpit). +## 2. Delivered, awaiting approval -New modules: + +### cockpit-redesign — the single-surface cockpit (2026-08-20) -| Path | Purpose | -|---|---| -| `src/shared/swayproject.js` | The `.sway` document: schema, defaults, `validateProject` (fills defaults, warns, never throws on content), `legacyToSway`. Shared by main and renderer; dependency-free. | -| `src/main/projectfile.js` | `.sway` I/O: read/write with atomic rename, size cap, format gating; id-gated bundled templates; recents pruning; `statAudio` (sha256 + bytes). | -| `src/renderer/project/projectstore.js` | Document lifecycle: open/apply (fixed hydration order), async sequential media loading, collect-on-save, media registration. | -| `src/renderer/control/router.js` | The single dispatch point: pad/button/note events, change-driven knob dispatch, per-frame gesture routes, timeline visual clips, LEARN persistence, touch-to-select. | -| `src/renderer/audio/transport.js` | Timeline playback: `AudioBufferSourceNode` scheduling on the context clock, loop, seek, per-clip fades; visual lane fires the router. | -| `src/renderer/ui/frame.js`, `popover.js`, `wave.js` | Chrome: panel frames, the shared popover, the INPUT box band display. | -| `src/renderer/ui/surface.js` | The Sway deck schematic (SVG, mutated per frame); the control-id grammar; `PAD_CELLS`. | -| `src/renderer/ui/assign.js` | The assignment panel: per-control editors, FOLLOW/LEARN/CLEAR. | -| `src/renderer/ui/drawer.js` | The SYNTH / RACK / KIT drawer shell; lazy first render. | -| `src/renderer/ui/timeline.js` | The timeline band: ruler (scrub/loop/locators), visual lane (DOM clips), audio lane (canvas waveforms), playhead. | +The five full-window screens (boot, projects, studio, docs, perform) became one always-live page: the stage renders from the first frame to quit and every panel works on top of it (layout and interaction model in [docs/OVERVIEW.md](docs/OVERVIEW.md#the-cockpit)). New modules: `src/shared/swayproject.js` (the `.sway` document: schema, defaults, `validateProject`, `legacyToSway`), `src/main/projectfile.js` (`.sway` I/O, id-gated bundled templates, recents, `statAudio`), `src/renderer/project/projectstore.js` (document lifecycle), `src/renderer/control/router.js` (the single dispatch point for pads, buttons, knobs, gestures, timeline clips, LEARN), `src/renderer/audio/transport.js` (timeline playback on the context clock), `src/renderer/ui/` — `frame.js`, `popover.js`, `wave.js`, `surface.js` (the deck schematic and `PAD_CELLS`), `assign.js`, `drawer.js`, `timeline.js`, later `layout.js`. Existing modules gained: engine `cutTo` / `prewarm` / `params` / `setFrameHook` / `applyProject` / fx accessors; fxrack `DECKS` and `DEFAULTS`; midi's full event set; WASAPI loopback in audio analysis; `project:*` IPC and the `SWAYCOMMAND_*` hooks in main; `app.js` rebuilt as cockpit assembly with `window.__swaycommand` as the automation handle. The eight legacy presets became bundled templates. The rename from the former product name was total (code, wordmarks, preload API, env hooks, appId, installer, docs); the Electron userData directory moved with it, so settings written under the old name did not carry over. + -Changes in existing modules: + +### visuals-pass — theDAW ports, morph passes, the Wormhole, rack and knobs (2026-08-20) -- **engine.js** — `cutTo` (instant switch), `prewarm` (one scene per frame after project apply), a `ResizeObserver` on the stage canvas, `params { hue, intensity }` replacing the hardwired knobs 0–2, `setFrameHook` (the router's slot), `applyProject` (the `.sway` shape; replays the fx snapshot through `setFxParam`), and `fx` / `fxEnabled` / `setFxParam` / `resetFx` on the public object. `autoVJ.fadeTime` is no longer overwritten per frame. -- **fxrack.js** — exports `DECKS` beside `RANGES`; the phantom deck keys `zoomPunch` / `slitScan` / `timeDisplace` are dropped and `audioReactive` / `asciiAccent` are surfaced, so every deck key has a range entry and the UI cannot silently skip parameters. -- **midi.js** — emits the full event set (`pad`, `note`, `noteoff`, `bend`, `mod`, `cc` with resolved target); note-offs are no longer swallowed. The router is the one `onEvent` consumer. -- **audio.js** (analysis) — `useSystemAudio()` reads Windows WASAPI loopback correctly (video track dropped on arrival, explicit no-audio-track failure), `state.source` gained `'system'`, `state.deviceId` records the granted input, `releaseInput`/`stopInternal` exported. -- **main.js** — IPC gained `project:*` (openDialog, saveDialog, read, write, recent, templates, readTemplate) and `files:statAudio`; `projects:list` is gone with the picker. `SWAYCOMMAND_AUTOPLAY` now takes a template id or a `.sway` path; `SWAYCOMMAND_WINDOW=WxH` forces the initial window size for layout screenshots. -- **app.js** — rebuilt as cockpit assembly. `window.__swaycommand` is now `{ state, studio, openStudio(tab), openDocs, renderPads, renderSamples, transport, projectStore, router, selectControl, openProject, saveProject }`; `state.screen` no longer exists. +Engine: half-float render targets, a per-scene UnrealBloomPass (`meta.bloom` or a live `instance.bloom`), a PMREM RoomEnvironment handed to scenes as `ctx.environment`, `io.strike` as the strike dimension. Faithful theDAW ports with Apache-2.0 notices: `lattice.js`, `ferrofluid.js`, `chladni.js`, `valley.js`. Morph passes (sway reshapes a generative parameter, strike restructures) over beams, swarm, ribbons, nebula, mandelbulb, cymatic, vjshader, spectra; `warp.js` redesigned as the hard-science Wormhole (DRIFT / WARP / WORMHOLE). Rack: `anaglyph` and `mosaic` (38 params); driving any `fx:` target auto-enables the rack. Every scene screenshot-verified at 60 fps plus a sweep probe. + -Projects: the 8 legacy presets became bundled templates (`projects/templates/*.sway`, converted via `legacyToSway`), reachable from the project menu. Format reference: [docs/PROJECTS.md](docs/PROJECTS.md). + +### will-i-dream — the scene, its v2, and the CodePen lens (2026-08-20) ---- +`scenes/willidream.js` + `projects/templates/will-i-dream.sway` (the song from the user's Music folder linked by absolute path on the audio lane, the scene on the visual lane; rack off, Auto-VJ off, pads unassigned). Built to the user's brief from the MIT CodePen exports in `C:\Users\Cyboman\Downloads\ScifiUI`: nothing applied at rest, hyperspace on the hand peaking at the highest X / lowest Y as a layer above everything, forward motion only, PAD 7 the black hole, PAD 8 / PAD 15 banks, a new celestial object after every jump except a swallowed one. v2 after review: forward motion fixed, the stars ARE the streaks (instanced screen-space capsules), sway = distribution control (scatter ↔ lattice), analytic celestial objects (pulsar, spiral galaxy, solar system, layered nebula, ringed giant). Third pass: the black hole is Darryl Huffman's CodePen lens from the user's `BLACKHOLE.zip` by explicit instruction ("use THIS black hole") — `pull = mass / dist²`, the view rotated about the mass by `(pull + held)·π` with upstream's zero sine term, darkening `colour − pull·0.25`; PAD 7 eases the mass in, the hold climbs, the mass grows until its own darkening swallows the view, void, re-emergence, no object. Verified at 60 fps through warp, banks, jumps and the swallow. + -## 2b. The visuals engine pass (same day, after the docs regen) + +### second-pass — pad numbering, nothing-by-default, no auto-rotation, GLSL3, Beam Sixteen styles, the resizable cockpit (2026-08-20) -The user's directive: use the REAL effects and visuals from theDAW, make striking and swaying morph each visual's own generative parameters (never rotate-the-object responses), and put frame-wrecking effects on the knobs. +The user's review set rules that now bind the whole project (kept in §3): pads numbered 0–15 as the deck is drawn, nothing drives an effect by default (`defaultKnobs()` all null, the router's `reconcileFx`), nothing auto-rotates anywhere (every scene swept, including the one-term orbit in the off-limits `voxels.js`, and the rack's spoke wheel), every shader GLSL3, no AI attribution. Built: Beam Sixteen v2 (HOLOGRAM / LASER / ELECTRICITY on KNOB 4, `io.knobs[3]`, a floor with impact pools, dust), the resizable / collapsible cockpit (`ui/layout.js`: grips, collapse chips, persisted under `settings.layout`). Stack raised to three 0.185.1 / Electron 43.4.1 / esbuild 0.28.2 (target chrome140); npm's allow-scripts gating skipped Electron's postinstall once — `node node_modules/electron/install.js` fixes a missing `dist/electron.exe`. `scripts/midi-session.ps1` + `midi-session-probe.js` prepared for the hardware session. + -**Engine (`engine.js`)** — render targets A/B/comp are now half-float (additive HDR survives), a per-scene **UnrealBloomPass** runs over the composite (`meta.bloom { strength, radius, threshold }` or a live `instance.bloom` that `update()` mutates; strength crossfades with the scene mix; skipped when absent), a **PMREM RoomEnvironment** is generated once and handed to scenes as `ctx.environment` (the reflection source that fixed the invisible chrome), and `io.strike` = max pad energy per frame is the strike dimension. Auto-VJ starts disabled at boot until a project decides (boot-race fix). + +### natures-tomb — the egg cell (third pass), the slime mold and the mycelium (fourth pass) -**Faithful theDAW ports** (upstream at `c:\Users\Cyboman\Documents\Dev\theDAW`): `lattice.js` (full 373-node / 756-beam topology from `lib/quantumLattice.ts`, positions pouring at 0.06/frame, morphEnergy shockwave, per-shape scales and bloom multipliers; strike advances the geometry, sway morphs the filament field), `ferrofluid.js` (`cymatics/sphere-shader.ts` verbatim + RoomEnvironment + three-point rig + backdrop dome; stage luminance ~11% vs the old ~1%; strike cycles spike density, sway glides density/viscosity, press crushes to mirror chrome), `chladni.js` (`cymatics/cymatics-shader.ts` verbatim, spectral-centroid mode index, cover camera; strike jumps the nodal mode with ring-down, sway bows it), `valley.js` (`cymatics/landscape-shader.ts` verbatim + fbm plasma sun + halo + palette point light + 240-star sky; **sway crossfades the whole terrain chrome↔ferrofluid via isFerrofluid**, strike quakes the ridges, press dives between them). All carry Apache-2.0 notices with updated change statements. +`scenes/naturestomb.js`, template `natures-tomb`. The user's brief: "a realistic looking egg cell that divides into more cells via mitosis, or undivides, with a control interface of the user's choosing" — then "add these kinds of visuals to the plan for nature's tomb: slime mold, mycelia". Controls (raw knobs, documented in the SCENE_CONTRACT inventory): **KNOB 6** (`io.knobs[5]`) picks the organism — centre (the 0.5 default) the egg cell, right the slime mold, left the mycelium, a 0.15 s dissolve so the knob's speed is the transition's; **KNOB 5** (`io.knobs[4]`) sets the shared development level 0–4 the moment it moves and **any pad strike** steps it one stage, reversing at the ends, eased over ~1.3 s — the division count for the egg (1 → 2 → 4 → 8 → 16, un-dividing when driven down), the colony's reach for the slime mold, the growth time for the mycelium. Sway morphs each generator (membrane jiggle; sensing — fine lattice to coarse trunk veins; branching angle and tortuosity), press squeezes, the hand pans (X) and dollies (Y), translation only. The slime mold is a real Physarum simulation on the CPU (Jones's agent model on a trail map, including the one-agent-per-cell rule that keeps veins thin — without it the colony collapses into one slug; a spread spawn over the newly gained ground, nine oat flakes as attractants the veins anchor to; the trail uploaded as an 8-bit `DataTexture`, lit as a glossy plasmodium with a shuttle-streaming pulse). The mycelium is a hyphal growth simulation laid down in time order as one instanced mesh of screen-space capsules (flat-butted joints, round apices, septa) and re-run from one random table whenever sway moves so the network deforms instead of re-seeding. Costs on this machine: the slime sim ~4 ms/frame at full reach, the rest negligible. Verified: harness stills of every organism and morph; the app probe at 60 fps through the egg, the slime mold (full reach, sway), the mycelium and a strike. Realism follow-ups not done: cells flattening against each other and the zona, a thickness-based SSS, a microscope DOF. + -**Morph passes over the rest** (voxels untouched — standing constraint): beams (field spread/cross-tilt + phase reseed), swarm (murmuration↔orbiting cells + scatter shock), ribbons (silk↔coils + traveling whipcrack), nebula (fbm spectrum re-weighting + curl rotation + ignition front), mandelbulb (power 6..12 + golden-angle basin jumps), cymatic (resonance-mode lift + jump/ring-down), vjshader (preset-space morph vectors, zero-at-rest; strike kicks + seed-jumps; a Mandelbox scale <~1.6 engulfs the camera — documented in its table), spectra (log↔lin axis warp, fold comb, radial burst; strike = full-band slam through the mel machinery). + +### miracle-mile — four acts of the atom (fourth pass) -**`warp.js` — the Wormhole, redesigned to the user's spec** (hard science, photoreal, one fullscreen quad): three regimes — DRIFT (blackbody starfield via octahedral cell hashing, limb-darkened G-type star with corona under bloom, faint palette nebulosity, slow coast, xy looks around), WARP (STRIKE toggles it; stars streak along great circles toward the travel direction — aberration integration over STREAK_K samples — with Doppler blue/red fore/aft; **SWAY sets velocity**, `2^((sway−0.5)·2.6)`), WORMHOLE (**press ≥ 0.7 held 0.25 s**; thin-lens deflection ~1/b bends the sky around the mouth, Einstein ring at the photon radius, the throat shows a second seed's sky inverted; the ~4.2 s transit swaps seeds — you exit somewhere else — then settles back to drift). Tuning notes: star PSF widens with magnitude and the per-star peak clamps at 1.7 or isolated hot pixels turn UnrealBloom's low mips into squares; bloom base 0.55/0.5/0.7 rising with warp/hole/flash. +`scenes/miraclemile.js` (id `miraclemile`), template `miracle-mile` (palette hot / fire / cyan / magenta / ash; rack off; nothing assigned). The user's brief: "New Scene: Miracle Mile — atom splitting, mushroom cloud, shockwave, cern/particle smashing". Controls: **KNOB 6** (`io.knobs[5]`) picks the act in quarter turns — COLLIDER, FISSION, DETONATION (the 0.5 rest: the mature cloud), SHOCKWAVE — with a 0.7 s dissolve and a little hysteresis at the band edges; **any pad strike** fires the act's event; **KNOB 5** (`io.knobs[4]`) is the yield (multiplicity, prompt neutrons and fragment speed, cloud scale, blast speed and dust); sway morphs (field strength, scission symmetry, the cloud's build, hemisphere ↔ Mach stem); press squeezes (dives to the vertex, compresses the nuclei, flattens the cap, ducks); the hand drives the eye (orbits in the collider and the lattice — the only rotations, all the hand's; dolly and height at the cloud and on the boulevard). Systems: the world quad (analytic backgrounds, the raymarched mushroom SDF with noise flows for the cap's roll and the stem's updraft, the boulevard with lamps, facades and the blast's refraction / condensation band / dust wall / veil), one instanced capsule mesh (detector rings and axials, helix tracks rebuilt per frame so sway's field curls them live, calorimeter bars, neutron streaks, lamp posts), two impostor meshes (solid nucleons with depth; additive bunches, hits, neutrons, lamp heads, embers); live bloom rides the flash. Verified: harness stills of every act and event (the first cut hid the cloud behind a too-tall skyline and left it unlit at maturity — fixed with a lower, farther skyline, a bigger cloud, sky / city / ember light; the shockwave's front was too subtle — taller dust wall, lit veil, longer lamp flare); the app probe at 60 fps through all four acts and their events; the template opens in 44 ms. + -**Rack + knobs**: `fxrack.js` gains `anaglyph` (depth-scaled red/cyan stereo in FRAG_FINAL, counter-rotation at high drive) and `mosaic` (grouted tile quantiser in the geometry pass, per-cell brightness hash) — 38 params now, both arm-gated, free at zero. Default knob table: 1 hue (detent), 2 fade length, 3 intensity, 4 glitch, 5 anaglyph, 6 mosaic, 7 echo trails, 8 kit level; **driving any `fx:` target auto-enables the rack** (router `driveTarget` + punches). Templates regenerated with the new knob table. + +### scene-harness — offscreen verification, and this note's prune mechanism (fourth pass) -**Verification state**: every scene screenshot-verified individually by its author at 60 fps (A/B sway/strike pairs), plus a 14-scene sweep probe — all 60 fps. One sweep anomaly was traced to the live hardware: the physical Sway was connected and a pad strike legitimately cut scenes mid-run. **Verification discipline (user request): keep headless app launches RARE — batch probes into one run, prefer build-only checks, never fire per-tweak screenshot loops.** +`scripts/scene-harness.js` + `scripts/scene-harness.entry.js`: bundles the scene registry with esbuild, opens a **hidden** Electron window (not the app — no focus steal, no hardware interplay), creates scenes with the engine's creation context, drives `update()` with a patched `io` per planned shot (knobs, gestures, bands, strike, palette), reports hooked console errors / warnings, update cost and ms/frame, and saves a PNG still per shot (`node scripts/scene-harness.js plan.json`; the plan format is in the file's header). It is what made the slime mold's collapse, the mycelium's culled quads and the hidden cloud visible without app launches. `scripts/handoff-prune.js` removes approved blocks from this note (see the top). Also noted: the Bash tool in this agent environment fails with ENAMETOOLONG on ~20 KB heredocs — write large files with the file tools. + --- -## 2c. Will I Dream (later on 2026-08-20) - -User brief: use the black hole / wormhole / hyperspace / warp effects added to `C:\Users\Cyboman\Downloads\ScifiUI` (all MIT CodePen exports — Jamie's "Wormhole", Darryl Huffman's "Black Hole (WebGL Shader)", Sean Free's "#codevember 13" event horizon, Techartist's "Cosmic Anomaly Visualizer", Rizki Gunawan's "Threejs SciFi Flight"); a scene with NO effects applied at rest; hyperspace/warp on X and Y with the maximum at highest X, lowest Y (right of the deck, close to the sensors); PAD 7 opens a black hole that radially fades everything from the center out; hyperspace as a layer ABOVE everything else; nothing rotating, forward motion only, except PAD 8 banks left and PAD 15 banks right; a new celestial object at the end of every hyperspace jump except when the black hole was pressed; named "Will I Dream", with the song of that name from this machine's Music folder. +## 3. Standing rules -Delivered: `src/renderer/engine/scenes/willidream.js` — the 15th registry entry; its header documents the four regimes and the state machine; pads follow the deck's 1-based numbering, so PAD 7 / 8 / 15 are indices 6 / 7 / 14 (`PAD_BLACKHOLE`, `PAD_BANK_LEFT`, `PAD_BANK_RIGHT`). Three draw calls: the star field (`Points`, scrolled and wrapped in the vertex shader, forward only), the celestial object (`Points`, one static generator per shape — pulsar, spiral galaxy, solar system, nebula, ringed planet — no time term in any angle), and the overlay quad drawn last (renderOrder 10, premultiplied-additive custom blending so one pass both adds the streak field and occludes with the black disc and the radial swallow). The black-hole lens (pull ~ mass / d²) is applied at vertex level to stars and object and in UV space to the overlay. No `meta.bloom` — the scene asks for no effects. `projects/templates/will-i-dream.sway` (second in `index.json`) links `C:\Users\Cyboman\Music\New Will I Dream Master Style 2 - Live Punchy.mp3` by absolute path (sha256 `510de9de…`, 9,167,722 bytes, 229.008 s, 48 kHz CBR 320) on the audio lane 0:00–3:49 with the scene on the visual lane; rack off, Auto-VJ off, pads unassigned so strikes reach only the scene. Docs updated to fifteen scenes / nine templates: README, OVERVIEW, ENGINE, ENVIRONMENT, SCENE_CONTRACT (inventory row), PROJECTS (template table + the absolute-path note). - -Verified in two dev-app runs (~19:25): the console was hooked before the scene's first shader compile — zero errors or warnings; 60 fps at rest, at full warp, through both banks, at the jump end, and with the hole opening over hyperspace; `projectStore.openTemplate('will-i-dream')` took 25 ms, the media decoded (229.008 s), the transport held the buffer, `play()` advanced the clock and drove the analysis (level 0.99). Screenshots: the arrival (pulsar ahead over the star field) and the black hole mid-swallow over the streak field. **Not yet packaged** — the installed build (18:58) predates this scene; repack before judging installed behavior. +- **Pads are numbered 0–15 as the user drew them on the deck**: top rows first, left cluster 0–3, right cluster 4–7, then the bottom rows, left 8–11, right 12–15 (`PAD_CELLS` in `surface.js`; the assignment panel shows `PAD n` 0-based). The note→index map is unchanged (chromatic base 24). +- **Nothing drives an effect by default — ever.** `defaultKnobs()` is eight nulls; templates carry no `fx:*` or `engine:intensity` knobs (the factory presets keep hue / fade length / kit level; the authored templates map nothing). The router reconciles fx every frame (`reconcileFx`): a key no assignment drives any more resets to its rack default, and a rack a control switched on switches off again when nothing drives it. Clearing a pad / knob / route therefore also stops whatever effect it drove. +- **Nothing auto-rotates, in any scene or effect** (hard rule 8 in [docs/SCENE_CONTRACT.md](docs/SCENE_CONTRACT.md)). Hand-driven orbits, per-particle flow, scrolling, breathing, path travel and noise-domain flows stay. Forward motion only in flight scenes; hyperspace as a layer above the scene. +- **Every shader is GLSL3** (`glslVersion: THREE.GLSL3`, `in` / `out`, `out vec4 fragColor`, `texture()`); `onBeforeCompile` injections into three's built-in materials keep three's conventions by necessity. Keep the stack current (three 0.185.1, Electron 43.4.1, esbuild 0.28.2, target chrome140). +- **No AI attribution anywhere**: no `Co-Authored-By` trailers, no "generated by" lines in headers, docs, or PR bodies. Commit messages are plain. +- **Scenes that read raw knobs say so in the SCENE_CONTRACT inventory**: Beam Sixteen KNOB 4; Nature's Tomb KNOB 5 (level, plus any strike) and KNOB 6 (organism); Miracle Mile KNOB 5 (yield) and KNOB 6 (act, plus any strike). A generic `scene:` assignment target stays a proposal (§5). +- **`voxels.js` is the user's favourite scene and is off-limits** unless they say otherwise (the one-term orbit removal was the only edit, forced by the "any scene" rule). +- **Apache-2.0 ports** (`ferrofluid`, `chladni`, `valley` — theDAW's cymatics shaders) carry the licence notice and statement of changes in their headers; do not strip them. **VJ-9000 has no licence file upstream** (`vjshader`, `spectra`, the fx rack derive from it); add one before third-party distribution. **Audima's terms forbid redistributing their binaries** — the Doctor fetches from `cdn.audima.com.au` onto the user's machine and never bundles. **theDAW alignment**: the synth keeps theDAW's `voiceTrigger` signature and `controlManifest` shape; audio modules are factory functions taking `ctx` and a destination array. +- **UI naming**: no element titled twice, no juvenile wording, no AI-UI tropes; terse instrument labels; the sci-fi cockpit aesthetic from the user's `ScifiUI` packs (the user's memory note `user-naming-constraints` has the full rule). +- **Keep headless app launches rare**: iterate with the scene harness, batch app checks into one probe per pass, reserve screenshots for milestones. The user runs the installed build while work happens; their persisted `settings.layout` is theirs — a dev run restoring collapsed rails is the feature working, not a regression. --- -## 2d. The second pass (2026-08-20, evening) — standing rules changed - -The user reviewed Will I Dream and the cockpit and set rules that now bind the whole project. Read these before touching anything: +## 4. Verifying without a human -- **Pads are numbered 0–15 as the user drew them on the deck**: top rows first, left cluster 0–3, right cluster 4–7, then the bottom rows, left 8–11, right 12–15 (`PAD_CELLS` in `surface.js`; the assignment panel shows `PAD n` 0-based). The note→index map is unchanged (chromatic base 24); `scripts/midi-session.ps1` prints the new strike order. -- **Nothing drives an effect by default — ever.** `defaultKnobs()` is eight nulls; every template lost its `fx:*` and `engine:intensity` knobs (the factory presets keep hue / fade length / kit level; `will-i-dream` maps nothing). The router reconciles fx every frame (`reconcileFx`): a key no assignment drives any more is reset to its rack default, and if a control had switched the rack on (`rackOnByControl`) and nothing drives it, the rack switches off. `fxrack.js` exports `DEFAULTS` for that. Clearing a pad/knob/route therefore also stops whatever effect it was driving. -- **Nothing auto-rotates, in any scene or effect.** Every scene was swept: Wormhole's coast yaw, Nebula's kaleidoscope spin, Mandelbulb's solid spin and haze phase, Cymatic Orb's orb spin / camera drift / azimuthal phase advance, VJ Shader's camera self-drift, Spectra's auto-orbit modes and world-group sway rotation, Swarm's revolving cell ring and camera orbit, Ribbons' camera orbit, Ferrofluid's camera drift and phyllotaxis spin, Cymatic Plate's nodal-spoke drift, Chrome Valley's star-field roll, Voxels' slow camera orbit (the one-term `t * 0.06` — the only edit ever made to the off-limits scene, made because the rule said "any scene"), and the rack's radial-spoke wheel (`baseRotation` held at 0). Hand-driven orbits, per-particle flow, scrolling, breathing, and path travel stay. It is hard rule 8 in the scene contract. -- **Every shader is GLSL3** (`glslVersion: THREE.GLSL3`, `in`/`out`, `out vec4 fragColor`, `texture()`): all fifteen scenes' custom materials, the engine composite pass, and the seven rack passes were converted in place (maths identical; `onBeforeCompile` injections into three's built-in materials stay in three's own conventions by necessity). Hard rule 4 in the contract; the memory file `no-ai-attribution-modern-stack` records it. -- **No AI attribution anywhere**: no `Co-Authored-By` trailers, no "generated by" lines in headers, docs, or PR bodies. The older commits on `cockpit` carry such trailers; they were not rewritten. +No test suite. Two tools: -What was built in this pass: +- **The scene harness** (`node scripts/scene-harness.js plan.json`): compile + render + stills in a hidden Electron window. Use it for any scene work; it does not touch the app, the settings, or the hardware. +- **The app, headlessly**, through env vars read in `src/main/main.js` ([docs/ENVIRONMENT.md](docs/ENVIRONMENT.md) has the reference and working probe examples): `SWAYCOMMAND_SHOT` (capture to PNG, then quit), `SWAYCOMMAND_SHOT_DELAY` (ms, default 5000 — must exceed the probe's own timers), `SWAYCOMMAND_WINDOW` (`WxH`), `SWAYCOMMAND_AUTOPLAY` (template id or `.sway` path; skips the SYSTEM modal), `SWAYCOMMAND_SCENE` (force a scene id — never use it to judge discoverability), `SWAYCOMMAND_PROBE` (JS evaluated 3000 ms after load; an async IIFE returning a promise is awaited; the result prints as `[probe] …`). `window.__swaycommand` is the automation handle: `{ state, studio, openStudio(tab), openDocs, renderPads, renderSamples, transport, projectStore, router, selectControl, openProject, saveProject }`; `state.engine` / `state.midi.control` drive scenes and fake strikes (`control.pads[i] = 1` is one strike; `control.knobs[i]`, `control.gestures.*`, `control.xy`). Launch: `node scripts/build-renderer.js`, then `npx electron .` with the env set (or `npm start`). The packaged exe can be launched the same way and its stdout redirected. `scripts/midi-session.ps1` is the prepared hardware session. -- **Will I Dream v2** (`scenes/willidream.js`): forward motion fixed (the field now streams toward the camera); the stars ARE the streaks — one instanced mesh of screen-space capsules from each star's position to where it was a shutter ago, so hyperspace radiates along each star's own line of flight with no pattern; sway morphs the distribution from random scatter to an ordered lattice; a raymarched Schwarzschild black hole in the overlay (geodesic bending, accretion disk seen nearly edge-on with its far side lensed over and under the shadow, Doppler beaming, photon-ring glow) that the ship falls into (virtual camera 80 → 2.2 rs) while a point lens pushes the star field outward; celestial objects ray-cast analytically on one camera-facing quad — pulsar (core, jets, magnetosphere tori), spiral galaxy (inclined plane, log-spiral arms, bulge, dust), solar system (limb-darkened sun, six shaded planets with atmospheres, one ringed with mutual shadows, a belt, faint orbit lines), layered nebula, ringed gas giant — instead of particle blobs; pads 7 / 8 / 15 as the deck numbers them. Three draw calls, GLSL3, no bloom. -- **Beam Sixteen v2** (`scenes/beams.js`): one instanced mesh of axial-billboard beam quads whose fragment shader crossfades HOLOGRAM / LASER / ELECTRICITY on `io.knobs[3]` (KNOB 4; `s = knob × 2`, τ = 0.15 s so the knob's speed is the transition's speed; 0.5 = pure laser at rest), a floor with impact pools and reflection streaks, dust that catches the beams; sway fan/cross-tilt morph and strike re-seed kept; fixed eye. -- **Resizable / collapsible cockpit** (`ui/layout.js`, wired from `app.js`): grips on the rails, timeline, deck, and the assign/input split (`--rail-w-left`, `--rail-w-right`, `--tl-h`, `--deck-h`, `--input-h`; double-click resets), a collapse chip per region (rails, assign, input, timeline, deck → 18 px strips), persisted in settings under `layout`, composing with solo view. +Standing traps: `ELECTRON_RUN_AS_NODE` may be set in agent shells (Electron then starts as Node and dies on `whenReady`) — clear it every launch; env vars leak between runs (clear `SWAYCOMMAND_AUTOPLAY` / `SWAYCOMMAND_SCENE`); the hardware may be live (a scene changing "by itself" mid-probe is usually a real strike on a template-assigned pad); do not read the WebGL canvas by drawing it into a 2D canvas (no `preserveDrawingBuffer` — it reads black); the Bash tool's command length limit (write large files with the file tools). --- -## 2e. Third pass (2026-08-20, late) — compaction note - -Written for whoever picks this up after a context reset. State of play: - -- **Everything from §2d is committed and pushed** (`cockpit` → `origin/cockpit`, eight plain commits `42ebee5`…`cbcf892`, no trailers). A PR link `cockpit → main` exists on GitHub but no PR was opened. -- **Uncommitted at the time of writing** (this pass): `scenes/willidream.js` (black hole swapped), `scenes/naturestomb.js` (new), `scenes/index.js` (16 scenes), `projects/templates/natures-tomb.sway` + `index.json` (ten templates), docs (README, OVERVIEW, ENGINE, ENVIRONMENT, SCENE_CONTRACT inventory rows, PROJECTS table), this file. Build passes; two dev-app runs verified it (below). The installed build (21:27) predates this pass. -- **Will I Dream's black hole is now Darryl Huffman's CodePen lens** — the user's `C:\Users\Cyboman\Downloads\ScifiUI\BLACKHOLE.zip` (`black-hole-webgl-shader`, MIT), by explicit instruction ("use THIS black hole"). Maths kept: `pull = mass / dist²`, the view rotated about the mass by `(pull + held)·π` with upstream's zero sine term (a signed radial scaling that collapses and inverts the sky in rings), darkening `colour − pull·0.25`. Sequence: PAD 7 → mass eases to 0.015 (the pen's 3 %-per-frame ease), then the hold value climbs (the pen's held click, 0.03/frame) while the mass grows to 4.0 so its own darkening swallows the screen from the center out, void, re-emergence, no object. The lens is applied to the streak endpoints and the object quad (`lens()` in `GLSL_COMMON`), the overlay carries the darkening (`lensDark()`); the raymarched Schwarzschild disk is gone. Verified mid-swallow at 60 fps. -- **Nature's Tomb** (`scenes/naturestomb.js`, id `naturestomb`, template `natures-tomb`): an egg cell under dark-field light — zona pellucida (fresnel, mild refraction, thin band), an SDF raymarch of up to sixteen blastomeres with a smooth-min cleavage furrow, granular cytoplasm, wrap light, subsurface glow, wet specular, a nucleus with a nucleolus seen through the cytoplasm by a second short march. One continuous division level 0–4 (1→2→4→8→16, each stage along the next axis; the CPU computes the vec4 cell table per frame); driving it down un-divides. **The control interface, as the user asked, is the user's choice**: KNOB 5 (`io.knobs[4]`) sets the level the moment it moves; any strike steps one division, reversing at 16 and at 1; sway jiggles the membranes, press squeezes, the hand pans (X) and dollies (Y) — nothing rotates. Verified at 60 fps through strikes and knob moves; the capture shows a four-nucleus embryo mid-merge. Realism can go further (real cells flatten against each other and the zona; a thickness-based SSS; a microscope DOF) — noted as a follow-up, not done. -- **Proposal, not done**: a generic `scene:` assignment target (router + assign panel) so any control can drive a scene parameter through the normal assignment UI instead of raw-knob conventions (Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5). -- **The user was using the installed build during this pass** (settings show `Documents\SwayCommand Projects\Hyperspace.sway` opened at 21:32 and the rails + timeline collapsed through the new chips). Their persisted `layout` was therefore left alone. Any dev-app run restores the same layout — that is the feature working, not a regression. -- **Repo description + topics** were drafted in chat (not applied — `gh` is not on the bash PATH): "SwayCommand — a real-time, audio-reactive VJ and live-visuals instrument for the Audima Labs Sway gesture MIDI controller…" with topics vj, vjing, live-visuals, audio-reactive, audio-visualizer, music-visualizer, generative-art, creative-coding, threejs, webgl, webgl2, glsl, shaders, electron, midi, midi-controller, web-midi, gesture-control, audima-sway, wavetable-synth, web-audio, real-time-graphics, procedural-generation, performance-tool, desktop-app, raymarching, particle-system, black-hole, hyperspace, javascript. -- **Next steps in order**: commit this pass (plain messages) and push; repack + install (the installed build is one pass behind); physical session at the Sway (`scripts/midi-session.ps1`) to confirm the new pad order, Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5 and strikes, Will I Dream's warp corner and PADS 7 / 8 / 15; then the `scene:` target proposal if the user wants it; then the open v1 gaps in §4. - ---- - -## 3. What is verified - -- Code-level: the documentation set was rewritten 2026-08-20 by reading every claim out of this source tree (module by module, including `swayproject.js`, `router.js`, `transport.js`, `projectfile.js`, and the `ui/` set). The docs are the closest thing to a spec; where they and the code disagree, something regressed. -- Reconciled after the visuals pass (later on 2026-08-20): `README.md` (rack parameter count, gesture and knob tables, the Wormhole row) and the scene inventory table in `docs/SCENE_CONTRACT.md` (every morph-pass scene and the theDAW ports, re-read from each scene's header and `update()`) now describe the shipped scenes. `ENGINE.md`, `STUDIO.md`, `OVERVIEW.md`, `PROJECTS.md` were already current. -- 16 scenes registered (`scenes/index.js`); all 10 templates parse through `validateProject` with no warnings and carry the expected pools (checked by script against `projects/templates/`; `will-i-dream` carries 1 media, 1 audio clip, 1 visual clip; `natures-tomb` opens on `naturestomb` with nothing assigned). -- Third pass, dev app (2026-08-20 ~21:45): Will I Dream with the Huffman lens — 60 fps through warp, open, and mid-swallow (captured: the black core with the streak field collapsing into it); Nature's Tomb — 60 fps at one cell, through two strikes, a KNOB 5 move to 3.4 and back to 1.5 (captured: a four-nucleus embryo mid-merge inside its zona); zero console errors; 16 scenes listed. -- The pre-redesign hardware baseline still applies: Sway USB identity `VID 0x0483` / `PID 0x52A4` confirmed on hardware, port name `Audima Labs The Sway`, factory CC map exercised on the installed build. -- Second pass (2026-08-20, late evening), dev app on the upgraded stack — three 0.185.1, Electron 43.4.1, esbuild 0.28.2 (target chrome140): three runs, console hooked before any compile — all fifteen scenes compiled as GLSL3 with zero errors or warnings; Will I Dream at 60 fps at rest, through two jumps (galaxy, then the solar system arrival captured: limb-darkened sun, ringed giant, shaded planets, belt), the sway distribution morph, and the black-hole fall (60 fps with the geodesic raymarch at 52 steps; captured mid-fall: lensed shadow, photon ring, edge-on disk, streaks bending in); the `will-i-dream` template opened with all-null knobs and the rack off, the song decoded and played; Beam Sixteen at 60 fps in laser, hologram, and electricity (captured at electricity under a full sway fan); the layout found 5 grips and 6 chips, and the left rail collapsed to 18 px. MIDI access was granted but no port was present during these runs (the Sway was unplugged) — hardware behaviour of the new pad order is still unverified. A note for the next Electron upgrade: npm's allow-scripts gating skipped Electron's postinstall, so `node node_modules/electron/install.js` had to be run by hand before `dist/electron.exe` existed. -- Installed build, 2026-08-20 21:27 (supersedes the 19:02 line below): `SwayCommand-Setup-0.1.0.exe` built 21:26 on Electron 43.4.1 / three 0.185.1 (asar checked for GLSL3 materials, `willidream`, `lay-grip`, `reconcileFx`, the new pad constants, zero `AKSWAYJ`), installed with `/S`, probed from the installed exe with the console hooked: 15 scenes compiled with zero errors, every scene at 60 fps in the `cutTo` sweep (Cymatic Orb read 58 in its first-compile window), Will I Dream at full warp 60 fps, 5 grips / 6 chips present, First Flight's knobs hue / fade / kit only, rack off. The probe's rail-collapse was persisted by the layout store and then cleared again from `%APPDATA%\SwayCommand\settings.json` so the next launch opens uncollapsed. -- Installed build, 2026-08-20 19:02: `SwayCommand-Setup-0.1.0.exe` (built 18:58 from a bundle with no newer `src/` file; the asar checked for the new code and the refreshed docs, zero `AKSWAYJ` strings) installed with `/S` to `%LOCALAPPDATA%\Programs\swaycommand` and probed headlessly from the installed exe in one run — `SWAYCOMMAND_AUTOPLAY=first-flight`, one batched `SWAYCOMMAND_PROBE`, `SWAYCOMMAND_SHOT` at 30 s. Result: blast door entered, First Flight loaded with no warnings, 14 scenes registered, the new knob table in the assignments, 8 template pads, MIDI on `Audima Labs The Sway` (the hardware was connected; no strikes landed during the run), audio on the default input, and a `cutTo` sweep over all 14 scenes reading 60 fps each at a 1275 × 583 stage; the screenshot shows the Wormhole in DRIFT with the top-bar counter at 60. Window-size budgets remain unmeasured. - ---- - -## 4. Known gaps +## 5. Known gaps | Gap | Detail | Fix path | |---|---|---| -| Loop seam is frame-quantized | `transport.update()` checks the loop boundary once per rAF call, so the seam lands up to one frame late; the audio sources themselves stop at the check, not on the audio clock. Accepted for v1. | Schedule the post-seam sources ahead on the context clock instead of rescheduling at the check (`src/renderer/audio/transport.js`, `update`). | -| Decoded-size pre-flight is an estimate | `projectstore.js` warns above ~600 MB and refuses above ~1.5 GB of estimated decoded size, assuming 48 kHz stereo Float32 — and only when a duration is already cached, so a first-time oversized file has no pre-flight and hits the decoder directly. | Persist duration on first add (already done via `addMedia`) and consider probing the container header for duration before decode. | -| Pad physical order unverified | `PAD_CELLS` in `src/renderer/ui/surface.js` maps screen cells to pad indices assuming bottom rows first, left cluster 0–7, right cluster 8–15. Not yet checked against the hardware. | Run `scripts/midi-session.ps1` with the Sway connected and strike the pads in the order it prints; `padStrikeOrder.pad` in the report must read 0..15, otherwise reorder the `PAD_CELLS` entries — nothing else references the geometry. | -| Sway button CCs are learn-only | Audima has not published the eight buttons' CC numbers; slots ship empty (`cc: null`) until LEARN captures them. | The same `scripts/midi-session.ps1` run lists them as `unmappedCCs` in first-seen order (buttons should show 0 / 127 value pairs; knob presses surface there too); add them to `swaymap.js` as defaults if stable. | -| Dual decode for shared media | A media file used by both the kit and the timeline decodes twice — the sampler decodes internally, the transport needs its own `AudioBuffer` (`projectstore.js`, `loadMediaAsync`). Rare enough that v1 does not share. | Give the sampler a `getBuffer(id)` accessor and hand the same buffer to the transport. | -| ~~Audio-lane clips cannot be selected on the band~~ | Fixed the day this note was written: `#tl-audio` now hit-tests pointerdown — click selects, drag moves, the trailing edge resizes, Delete/arrows apply (`src/renderer/ui/timeline.js`). Kept here because the fix landed after the docs pass. | — | - ---- - -## 5. Packaging hazard - -Hit twice before the redesign: source files edited **after** a build was cut, so the packaged installer did not contain the code that had just been reviewed. It was caught only by comparing file mtimes against the build timestamp. Before packaging: - -1. Confirm no agent or watcher is still writing under `src/`. -2. Run `npm run build:renderer`, then verify nothing under `src/` is newer than `dist/renderer.bundle.js`. -3. Then `npm run dist:win` (or `dist:mac` / `dist:linux`). Output lands in `release/`; the Windows installer is one-click, per-user, `%LOCALAPPDATA%\Programs\swaycommand`, silent with `/S`. - -Docs are not bundled by the renderer build — `docs/**/*.md` and `README.md` are packaged as files — so documentation edits need only a repack, not a bundle. +| Pad physical order unverified | `PAD_CELLS` (`src/renderer/ui/surface.js`) follows the user's drawing; not yet checked against the hardware. | Run `scripts/midi-session.ps1` with the Sway connected; `padStrikeOrder.pad` must read 0..15, otherwise reorder `PAD_CELLS` — nothing else references the geometry. | +| Sway button CCs are learn-only | Audima has not published the eight buttons' CC numbers; slots ship `cc: null` until LEARN captures them. | The same session lists them as `unmappedCCs` (0 / 127 pairs; knob presses surface there too); add them to `swaymap.js` as defaults if stable. | +| Scene parameters ride raw knobs | Beam Sixteen, Nature's Tomb and Miracle Mile read `io.knobs[...]` by convention. | A generic `scene:` assignment target (router + assign panel) so any control can drive a scene parameter through the normal assignment UI. | +| Loop seam is frame-quantized | `transport.update()` checks the loop boundary once per rAF, so the seam lands up to one frame late. Accepted for v1. | Schedule the post-seam sources ahead on the context clock (`src/renderer/audio/transport.js`, `update`). | +| Decoded-size pre-flight is an estimate | `projectstore.js` warns above ~600 MB and refuses above ~1.5 GB of estimated decoded size, and only when a duration is cached. | Probe the container header for duration before decode. | +| Dual decode for shared media | A file used by both the kit and the timeline decodes twice. | Give the sampler a `getBuffer(id)` accessor and hand the same buffer to the transport. | +| Nature's Tomb realism | Cells do not flatten against each other or the zona; no thickness-based SSS; no microscope DOF. | Follow-ups in `naturestomb.js`'s egg raymarch. | +| Window-size budgets unmeasured | The 60 fps figures are at the dev / probe stage sizes. | A sweep at 1080p with `SWAYCOMMAND_WINDOW`. | --- -## 6. Verifying work without a human - -No test suite. Verification drives the real app headlessly through env vars read in `src/main/main.js` ([docs/ENVIRONMENT.md](docs/ENVIRONMENT.md) has the full reference and working probe examples): - -| Variable | Effect | -|---|---| -| `SWAYCOMMAND_SHOT` | Capture the window to a PNG, then quit | -| `SWAYCOMMAND_SHOT_DELAY` | ms before capture, default 5000 | -| `SWAYCOMMAND_WINDOW` | `WxH` initial window size, for narrow-layout screenshots | -| `SWAYCOMMAND_AUTOPLAY` | Template id **or** `.sway` file path; skips the SYSTEM modal | -| `SWAYCOMMAND_SCENE` | Force a scene id. Bypasses navigation — never use it to judge discoverability | -| `SWAYCOMMAND_PROBE` | Run JS in the page 3000 ms after load; result prints as `[probe] …` | - -`window.__swaycommand` is the automation handle (shape in section 2). Two prepared runs live in `scripts/`: `midi-session.ps1` (with `midi-session-probe.js`) is the hardware monitor session for §4 — it runs the dev app with a probe that wraps the MIDI monitor ring buffer for N seconds and prints one JSON report (pad strike order, unmapped CCs, mapped CCs, raw lines), then quits through a late `SWAYCOMMAND_SHOT`. The installed-build probe used for §3 is the pattern for any packaged check: launch `%LOCALAPPDATA%\Programs\swaycommand\SwayCommand.exe` itself with the env vars set — its main-process stdout reaches a redirected file — and batch everything into one async probe expression (`executeJavaScript` awaits a returned promise) with the shot delay past the probe's own timers. Standing traps, all hit during earlier work: - -- **`ELECTRON_RUN_AS_NODE` may be set in agent shells.** Electron then starts as plain Node and dies with `Cannot read properties of undefined (reading 'whenReady')`. Clear it every launch. -- **`SWAYCOMMAND_SHOT_DELAY` must exceed the probe's own timers**, or the app quits mid-probe and prints nothing. -- **Env vars leak between runs.** Clear `SWAYCOMMAND_AUTOPLAY` / `SWAYCOMMAND_SCENE` explicitly. -- **Launch sparingly.** The user has asked for restraint on headless app launches: batch multiple checks into one probe, prefer `node scripts/build-renderer.js` alone for syntax-level verification, and reserve screenshots for milestones. -- **The hardware may be live.** With the Sway connected, its pad strikes and CCs reach the router during headless runs — a scene changing "by itself" mid-probe is usually a real strike on a template-assigned pad, not a bug. -- **Do not read the WebGL canvas by drawing it into a 2D canvas** — the renderer runs without `preserveDrawingBuffer` and reads back pure black. Use `SWAYCOMMAND_SHOT` and measure the PNG. - ---- +## 6. Packaging hazard -## 7. Standing constraints +Hit twice before the redesign: source files edited **after** a build was cut, so the installer did not contain the reviewed code; caught only by comparing file mtimes against the build timestamp. Before packaging: confirm no agent or watcher is still writing under `src/`; run `npm run build:renderer` and verify nothing under `src/` is newer than `dist/renderer.bundle.js`; then `npm run dist:win` (or `dist:mac` / `dist:linux`) — output lands in `release/`; the Windows installer is one-click, per-user, `%LOCALAPPDATA%\Programs\swaycommand`, silent with `/S`. Docs are packaged as files, not bundled, so documentation edits need only a repack. -- **`voxels.js` is the user's favourite scene and is off-limits** unless they say otherwise. -- **Apache-2.0 ports** (`ferrofluid`, `chladni`, `valley` — from theDAW's cymatics shaders) carry the required licence notice and statement of changes in their headers. Do not strip them. -- **VJ-9000 has no licence file upstream** (`vjshader`, `spectra`, the fx rack derive from it). It is the author's own work, but a licence should be added before third-party distribution. -- **Audima's terms forbid redistributing their binaries.** The Doctor fetches from `cdn.audima.com.au` onto the user's machine and never bundles. Keep it that way. -- **theDAW alignment**: the synth keeps theDAW's `voiceTrigger` signature and `controlManifest` shape; audio modules are factory functions taking `ctx` and a destination array. A module that cannot be lifted into theDAW unchanged is a divergence. +Approved and pruned: (none yet) diff --git a/README.md b/README.md index 01c3f82..991e913 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ SwayCommand is an independent project. It is not affiliated with or endorsed by ## Feature summary - A single-page cockpit: the stage renders from boot to quit while the scene bank, transport, timeline, assignment panel, and drawers work on top of it — no screen switching, no interrupted frames. -- Sixteen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2 — every shader GLSL3, no autonomous rotation anywhere. +- Seventeen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2 — every shader GLSL3, no autonomous rotation anywhere. - Projects as `.sway` files: one JSON document carrying palette, engine settings, effects snapshot, synth patch, linked media, kit, timeline, and every control assignment. Save, open, and recent files live in the project menu; ten bundled templates (three tuned to pair with Audima's official Ableton demo packs — Garage, DNB, Hip Hop) provide starting points. - A timeline with an audio lane (waveform clips scheduled sample-accurately on the audio clock) and a visual lane (scene clips with per-clip cut or fade entry), plus loop region, locators, and scrubbing. - An on-screen Sway deck — a line-art schematic of the hardware. Click any pad, knob, button, or gesture chip to assign it: pads fire samples, scene switches, or momentary effect punches; knobs drive any engine, rack, synth, or kit parameter with range and curve; buttons learn a hardware CC and toggle anything; the five gesture dimensions hold modulation routes. Touching a control on the hardware selects it on screen. diff --git a/docs/ENGINE.md b/docs/ENGINE.md index ed3017e..2f40808 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -45,7 +45,7 @@ The composite pass is a fullscreen quad under an orthographic camera, drawn with ## Scene management -`scenes/index.js` imports the sixteen scene modules in registry order — `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb` — and derives two exports: `sceneList`, the array of each module's `meta`, and `creators`, a map from scene id to its `createScene` function. The digit keys `1`–`9` index the **active project's pool**, not this registry, so the registry can grow past the digits; the SCENES bank in the left rail lists all sixteen and prints each pooled scene's digit. +`scenes/index.js` imports the seventeen scene modules in registry order — `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb`, `miraclemile` — and derives two exports: `sceneList`, the array of each module's `meta`, and `creators`, a map from scene id to its `createScene` function. The digit keys `1`–`9` index the **active project's pool**, not this registry, so the registry can grow past the digits; the SCENES bank in the left rail lists all seventeen and prints each pooled scene's digit. Scene instances are created on demand at first use and cached in a map for the rest of the session; the engine never disposes them. Creation receives the context `{ THREE, renderer, width, height, quality }` defined in [SCENE_CONTRACT.md](SCENE_CONTRACT.md). Requesting an unregistered id throws `Unknown scene: `. diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 578d1a6..44c9a4f 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -52,7 +52,7 @@ Both parameters are consumed once, in `main()` of `src/renderer/app.js`, from `l | Parameter | Value | Behavior | |---|---|---| | `autoplay` | Template id, or a `.sway` file path | A value ending in `.sway` (case-insensitive) is opened as a project file; anything else is opened as a template id. On failure the startup falls through to the normal selection (most recent project, then the `first-flight` template) with a console warning. Any `autoplay` value also skips the SYSTEM modal: the blast door opens immediately and the Doctor still runs in the background, keeping its checks reachable from the CONTROLS modal. | -| `scene` | Scene id | Applied after the boot project loads: Auto-VJ is disabled and the engine switches to the named scene via `setScene(scene, 0.3)`. The value is one of the sixteen ids in the scene registry (`src/renderer/engine/scenes/index.js`): `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb`. `setScene` ignores an id with no registered creator, so an unknown value leaves the project's opening scene on stage with Auto-VJ still disabled. | +| `scene` | Scene id | Applied after the boot project loads: Auto-VJ is disabled and the engine switches to the named scene via `setScene(scene, 0.3)`. The value is one of the seventeen ids in the scene registry (`src/renderer/engine/scenes/index.js`): `beams`, `swarm`, `ribbons`, `voxels`, `warp`, `nebula`, `mandelbulb`, `cymatic`, `spectra`, `vjshader`, `ferrofluid`, `chladni`, `valley`, `lattice`, `willidream`, `naturestomb`, `miraclemile`. `setScene` ignores an id with no registered creator, so an unknown value leaves the project's opening scene on stage with Auto-VJ still disabled. | ## The automation handle diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 514541c..47632c6 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -9,7 +9,7 @@ The application is one page — the cockpit — and it is always live. There is | Region | Contents | |---|---| | Top bar | Wordmark; the project button (opens the project menu: New, Open, Save, Save as, recent files, templates); the transport (play/pause, stop, clock, LOOP); the current scene readout; status pills (`SWAY`/`MIDI`/`KEYS` link, `LOOPBACK`/`LINE`/`GROOVE`/`MUTE` input, fps); the deck buttons SYNTH, RACK, KIT, DOCS, HELP | -| Left rail | The SCENES bank — all sixteen registry scenes, with digit hints on the first nine of the active pool; a click switches the stage, a drag onto the timeline lays a clip. The AUTO group — RUN toggle, HOLD min–max seconds, FADE seconds | +| Left rail | The SCENES bank — all seventeen registry scenes, with digit hints on the first nine of the active pool; a click switches the stage, a drag onto the timeline lays a clip. The AUTO group — RUN toggle, HOLD min–max seconds, FADE seconds | | Center | The stage canvas. The blast door covers it at boot and opens once | | Right rail | The assignment panel for the selected control, and the INPUT box: the analysis-source button, a level meter, and an audio-reactive band display | | Bottom band | The timeline: a ruler (scrub, loop region, locators), a VISUAL lane of scene clips, an AUDIO lane with waveforms, and the playhead | diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index a1b3965..3ca5e6d 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -121,13 +121,14 @@ Clips are kept sorted by start time; validation drops zero-length clips. Editing ## Templates -Ten templates are bundled as full `.sway` documents at `projects/templates/*.sway`: the eight factory presets of earlier builds, produced from the legacy JSON by `legacyToSway()` (everything the legacy shape did not cover sits at defaults, and `meta.template` is `true`), plus `will-i-dream`, an authored project whose timeline carries a linked track, and `natures-tomb`, which opens on the Nature's Tomb scene with nothing assigned — the only template with media, linked by the absolute path of the author's machine, so on any other machine the audio clip loads with a missing-file warning and the scene still plays. They appear at the bottom of the project menu; opening one loads a fresh untitled copy — templates are read-only and are addressed by id through a gated channel, never by path. `projects/templates/index.json` fixes the menu order. +Eleven templates are bundled as full `.sway` documents at `projects/templates/*.sway`: the eight factory presets of earlier builds, produced from the legacy JSON by `legacyToSway()` (everything the legacy shape did not cover sits at defaults, and `meta.template` is `true`), plus three authored projects: `will-i-dream`, whose timeline carries a linked track — the only template with media, linked by the absolute path of the author's machine, so on any other machine the audio clip loads with a missing-file warning and the scene still plays; `natures-tomb`, which opens on the Nature's Tomb scene with nothing assigned; and `miracle-mile`, which opens on Miracle Mile with nothing assigned. They appear at the bottom of the project menu; opening one loads a fresh untitled copy — templates are read-only and are addressed by id through a gated channel, never by path. `projects/templates/index.json` fixes the menu order. | Id | Name | Vibe | Scene pool | Start | Auto-VJ | Hold (s) | Fade (s) | BPM hint | Pairs with | |---|---|---|---|---|---|---|---|---|---| | `first-flight` | First Flight | welcoming | beams, swarm, ribbons, voxels, warp, nebula, mandelbulb, cymatic, vjshader | beams | on | 20–45 | 5 | — | — | | `will-i-dream` | Will I Dream | lucid | willidream | willidream | off | 60–120 | 4 | — | the track "Will I Dream" on the audio lane (0:00–3:49) with the scene on the visual lane | | `natures-tomb` | Nature's Tomb | cellular | naturestomb | naturestomb | off | 60–120 | 4 | — | — | +| `miracle-mile` | Miracle Mile | critical | miraclemile | miraclemile | off | 60–120 | 4 | — | — | | `hyperspace` | Hyperspace | transluminal | warp, vjshader, mandelbulb, lattice, valley, spectra, swarm | warp | on | 22–46 | 3 | — | — | | `chrysanthemum` | Chrysanthemum | hyperreal | mandelbulb, vjshader, ferrofluid, chladni, lattice, nebula, warp, spectra | mandelbulb | on | 26–52 | 6 | — | — | | `beam-sixteen` | Beam Sixteen | anthemic | beams, nebula | beams | off | 30–60 | 6 | — | — | diff --git a/docs/SCENE_CONTRACT.md b/docs/SCENE_CONTRACT.md index 1c88b31..f6cb753 100644 --- a/docs/SCENE_CONTRACT.md +++ b/docs/SCENE_CONTRACT.md @@ -84,7 +84,7 @@ The engine defaults to `med`; an unknown quality name also falls back to `med`. | `io.palette` | `THREE.Color[5]` | The current ColorMaster palette. Scenes MUST copy values out and MUST NOT mutate the array or the colors. | | `io.intensity` | 0.25–1.35 | Master brightness: `0.25 + 0.75 × the engine intensity parameter + 0.35 × pulse`. Scenes SHOULD scale emissive output by it. | -Knobs carry no factory assignment: `io.knobs` mirrors the hardware, and nothing is driven until the project assigns it ([STUDIO.md](STUDIO.md)); the factory templates map hue (`engine:hue`, center detent), fade length (`engine:fadeTime`, 1–8 s), and kit level (`sampler:master`) on knobs 1, 2, and 8. A scene that reads a raw knob says so in its inventory entry (Beam Sixteen's beam-style morph on `io.knobs[3]`, KNOB 4). +Knobs carry no factory assignment: `io.knobs` mirrors the hardware, and nothing is driven until the project assigns it ([STUDIO.md](STUDIO.md)); the factory templates map hue (`engine:hue`, center detent), fade length (`engine:fadeTime`, 1–8 s), and kit level (`sampler:master`) on knobs 1, 2, and 8. A scene that reads a raw knob says so in its inventory entry (Beam Sixteen's beam-style morph on `io.knobs[3]`, KNOB 4; Nature's Tomb's development level on `io.knobs[4]`, KNOB 5, and organism on `io.knobs[5]`, KNOB 6; Miracle Mile's yield on `io.knobs[4]`, KNOB 5, and act on `io.knobs[5]`, KNOB 6). ## Bloom @@ -118,7 +118,7 @@ Strike and sway are morph dimensions, not motion inputs: sway SHOULD continuousl ## Scene inventory -The registry holds sixteen scenes, listed here in registry order. Registry order fixes nothing on the keyboard — the digit keys index the active project's pool ([ENGINE.md](ENGINE.md#scene-management)). +The registry holds seventeen scenes, listed here in registry order. Registry order fixes nothing on the keyboard — the digit keys index the active project's pool ([ENGINE.md](ENGINE.md#scene-management)). | Id | Name | Mood | Mechanism | |---|---|---|---| @@ -137,4 +137,5 @@ The registry holds sixteen scenes, listed here in registry order. Registry order | `valley` | Chrome Valley | synthwave | theDAW's infinite-scroll chrome valley (`landscape-shader.ts`, verbatim): a mountain-walled terrain of stacked sine octaves under a fbm plasma sun with a fresnel halo, a palette point light, and a 240-star sky, reflecting the shared room environment. Sway crossfades the whole terrain from chrome landscape to golden-angle quasicrystal ferrofluid (`isFerrofluid` 0–1) and biases the scroll speed; a strike is a seismic hit (mountain height +1.2 decaying ~1.5 s, plasma and bloom flaring); press dives the camera between the ridges as the fog pulls in; pulse surges sun and stars; the hand drifts the camera across the valley. Apache-2.0 port, notice retained. | | `lattice` | Quantum Lattice | crystalline | theDAW's lattice engine at full fidelity: 373 instanced nodes and 756 beams with energy filaments and a reactor core, morphing between four geometries — Grand Torus, Cubic Frame, Merkabah Star, Cosmos Cage — with positions pouring toward their targets at 0.06/frame and a morph-energy shockwave, under live bloom (0.2 × per-shape multiplier, plus volume and shockwave). A strike advances the geometry and fires the full shockwave; sway morphs the filament field (wave detail plus a continuous morph push, so the lattice writhes and unravels); pulse surges bloom and the reactor heartbeat; press dives the camera; the hand steers the orbit. MIT-licensed upstream. | | `willidream` | Will I Dream | lucid | A straight flight through deep space with nothing applied at rest: a palette-tinted star field streams toward a fixed camera, forward motion only. Warp lives on the hand — X sets streak speed, closeness to the sensors (1 − Y) sets density, their product is the amount, peaking at the highest X and lowest Y — and the stars themselves become the streaks: every star is an instanced screen-space capsule from where it is to where it was a shutter ago, radiating from the vanishing point along its own line of flight, random by construction; sway is the distribution control, morphing the field from random scatter toward an ordered lattice. Past 0.55 the jump engages (speed ×30) and under 0.25 it ends with a flash and a new celestial object ahead — pulsar (core, jets, magnetosphere, pulsing in place), spiral galaxy (an inclined plane with log-spiral arms, bulge, dust lanes), solar system (a limb-darkened sun, six shaded planets with atmospheres and one ringed, mutual shadows, a belt, faint orbit lines), layered nebula cloud, or a ringed gas giant — analytically ray-cast on one camera-facing quad, static forms, never the same twice running. PAD 7 opens the black hole — Darryl Huffman's CodePen lens, maths kept: `pull = mass / dist²`, the view rotated about the mass by `(pull + held)·π` (a signed radial scaling that collapses and inverts the sky in rings) and darkened by `pull·0.25`; the mass eases in, then the hold climbs and the mass grows until its own darkening swallows the view from the center out, a beat of void, and re-emergence into a re-seeded sky with no object (a jump in progress ends there). The lens bends the star streaks and the object quad; the overlay carries the darkening. PAD 8 banks left and PAD 15 banks right, the one rotation in the scene, levelling out after 2.4 s; any other pad is a thrust surge. The star/streak layer is drawn above the object; the overlay above all. Three draw calls; no bloom requested. | -| `naturestomb` | Nature's Tomb | cellular | An egg cell under dark-field light on one fullscreen quad: a glassy zona pellucida (fresnel rim, mild refraction, a thin bright band) around an SDF raymarch of up to sixteen blastomeres — cytoplasm with granular noise, wrap lighting, a subsurface glow, a wet specular, and a nucleus with a nucleolus seen through the cytoplasm by a second short march. One continuous division level 0–4 (1 → 2 → 4 → 8 → 16 cells, each stage splitting along the next axis, centres easing apart as radii shrink by 2^−1/3, a smooth-min neck as the cleavage furrow) — driven by the control the user picks: KNOB 5 (`io.knobs[4]`) sets the level directly the moment it moves, any strike steps one division, reversing at sixteen (un-divide) and at one; the level eases over ~1.3 s so divisions play out, and driving it down merges the cells back. Sway jiggles and softens the membranes, press squeezes the embryo, the hand pans it (X) and dollies the eye (Y) — translation only; bass swells, the beat pulses, treble shimmers the granules. One draw call. | +| `naturestomb` | Nature's Tomb | cellular | Three organisms under dark-field light on one plate, picked by the raw knob `io.knobs[5]` (KNOB 6: centre the egg cell, right the slime mold, left the mycelium, a 0.15 s dissolve so the knob's speed is the transition's). A shared development level — KNOB 5 (`io.knobs[4]`) sets it the moment it moves, any strike steps it one stage, reversing at the ends, eased over ~1.3 s — means the division count for the egg (1 → 2 → 4 → 8 → 16 blastomeres along successive axes, a smooth-min cleavage furrow, un-dividing when driven down), the colony's reach for the slime mold, and the growth time for the mycelium. The egg: a glassy zona pellucida around an SDF raymarch of the blastomeres — granular cytoplasm, wrap light, subsurface glow, wet specular, a nucleus with a nucleolus by a second short march. The slime mold: a Physarum plasmodium simulated on the CPU with Jones's agent model (a few thousand agents on a trail map — three sensors ahead, turn toward the strongest, step if the cell is free (one agent per cell), deposit; box-blur and decay each frame; the trail uploaded as an 8-bit texture) and lit as a glossy plasmodium — height from the trail, a normal from its gradient, a shuttle-streaming pulse the beat drives, nine oat flakes that switch on as the reach meets them and anchor the veins. The mycelium: a hyphal growth simulation (germ tubes, tip extension with a radial bias, lateral branching, tips dying at the rim) laid down in time order as one instanced mesh of screen-space capsules — glassy tubes with septa, the growth front glowing as apices — re-run from one random table whenever sway moves so the network deforms instead of re-seeding. Sway morphs each generator (membrane jiggle; sensor angle, turn and reach — fine lattice to coarse trunk veins; branching angle and tortuosity); press squeezes (flattens the embryo, crowds the colony, squashes the mycelium); the hand pans (X) and dollies (Y) — translation only; bass swells, the beat pulses, treble shimmers. Two draw calls. | +| `miraclemile` | Miracle Mile | critical | Four acts of the atom picked by the raw knob `io.knobs[5]` (KNOB 6, quarter turns: COLLIDER, FISSION, DETONATION — the 0.5 rest — SHOCKWAVE; a 0.7 s dissolve), any strike firing the act's event, the raw knob `io.knobs[4]` (KNOB 5) as the yield. COLLIDER: the detector barrel (tracker and calorimeter layers as rings and axial lines, the beam pipe, two bunches crossing at the vertex) and the collision — 40–120 tracks as helices in the solenoid field, rebuilt every frame from pT, φ, η and charge so sway's field strength curls them live, propagating outward in 0.4 s, hits on every layer crossed, energy bars at the barrel, jets about three axes, fading over ~3 s; press dives toward the vertex, the hand orbits. FISSION: thirteen nuclei of packed nucleon impostors on an icosahedral lattice; a strike fires a neutron at the nearest idle nucleus, which swells, oscillates, necks and splits into two hot fragments with two to four prompt neutrons flying to neighbours — the chain reaction crosses the lattice with a gamma flash and an expanding shell per scission, white-out when the last goes, and the lattice re-forms; sway morphs a clean symmetric scission into a wobbling asymmetric one, press compresses the nuclei, the hand orbits. DETONATION: a desert night with a city skyline between the eye and ground zero; at rest the mature cloud churns (the cap's roll and the stem's updraft are flows of the noise domain); a strike detonates — flash, fireball, stem, cap, cooling through orange to ash over ~25 s, a base surge on the ground — a raymarched mushroom SDF broken by value noise, lit by its embers and the fireball with a density probe toward the light; sway morphs squat-and-broad to tall-and-turbulent, press flattens the cap, the hand dollies sideways and raises the eye. SHOCKWAVE: the boulevard — road, lamp rows, lit facades; a strike detonates at the vanishing point and the front comes down the road: a shell that refracts what is behind it, a condensation band inside it, a dust wall where it meets the ground, lamps flaring and dying as it passes, arrival shaking the eye under a dust veil, embers, the boulevard relighting near-to-far; sway morphs hemisphere to Mach stem, press ducks, the hand sets position and height. Every rotation is the hand's. Four draw calls (world quad, capsules, solid impostors, additive impostors); live bloom rides the flash. | diff --git a/projects/templates/index.json b/projects/templates/index.json index cd4db3b..8a785c8 100644 --- a/projects/templates/index.json +++ b/projects/templates/index.json @@ -3,6 +3,7 @@ "first-flight", "will-i-dream", "natures-tomb", + "miracle-mile", "hyperspace", "chrysanthemum", "beam-sixteen", diff --git a/projects/templates/miracle-mile.sway b/projects/templates/miracle-mile.sway new file mode 100644 index 0000000..a4763ff --- /dev/null +++ b/projects/templates/miracle-mile.sway @@ -0,0 +1,183 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "miracle-mile", + "name": "Miracle Mile", + "description": "Four acts of the atom on KNOB 6: the collider, the split atom, the mushroom cloud (the 0.5 rest), the shockwave down the boulevard. Any pad fires the act's event; KNOB 5 is the yield; sway morphs the act, press squeezes, the hand drives the eye. The rack stays off.", + "author": "", + "vibe": "critical", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#ffe9b0", + "#ff6a1a", + "#2de1fc", + "#ff2d95", + "#6b7a99" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": false, + "pool": [ + "miraclemile" + ], + "minHold": 60, + "maxHold": 120, + "fadeTime": 4 + }, + "start": { + "scene": "miraclemile" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + null, + null, + null, + null, + null, + null, + null, + null + ], + "gestures": [], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/natures-tomb.sway b/projects/templates/natures-tomb.sway index b541ff7..f272359 100644 --- a/projects/templates/natures-tomb.sway +++ b/projects/templates/natures-tomb.sway @@ -9,50 +9,173 @@ "meta": { "id": "natures-tomb", "name": "Nature's Tomb", - "description": "An egg cell under dark-field light. KNOB 5 sets the division level from one cell to sixteen and back; any pad steps a division; sway jiggles the membranes, press squeezes, the hand pans and dollies. The rack stays off.", + "description": "Three organisms under dark-field light. KNOB 6 picks the organism: centre the egg cell, right the slime mold, left the mycelium. KNOB 5 sets the development level — cell divisions, the colony's reach, the hyphal growth — and any pad steps it; sway morphs each organism, press squeezes, the hand pans and dollies. The rack stays off.", "author": "", "vibe": "cellular", "bpmHint": 0, "pairsWith": null, "template": true }, - "palette": ["#f2c9a6", "#5b3f9e", "#bfefff", "#ffb26b", "#e88aa8"], + "palette": [ + "#f2c9a6", + "#5b3f9e", + "#bfefff", + "#ffb26b", + "#e88aa8" + ], "engine": { "quality": "med", "fxEnabled": false, - "autoVJ": { "enabled": false, "pool": ["naturestomb"], "minHold": 60, "maxHold": 120, "fadeTime": 4 }, - "start": { "scene": "naturestomb" } + "autoVJ": { + "enabled": false, + "pool": [ + "naturestomb" + ], + "minHold": 60, + "maxHold": 120, + "fadeTime": 4 + }, + "start": { + "scene": "naturestomb" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null }, - "fx": { "params": {} }, - "synth": { "enabled": true, "preset": "Init", "patch": null }, "media": [], "sampler": { - "kit": { "version": 1, "pads": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null] }, - "knobs": { "master": 0.5, "cutoff": 0.5, "rate": 0.5, "send": 0.5 } + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } }, "timeline": { - "loop": { "enabled": false, "start": 0, "end": 0 }, + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, "locators": [], "tracks": [ - { "id": "audio-1", "type": "audio", "name": "Audio", "gain": 1, "muted": false, "clips": [] }, - { "id": "visual-1", "type": "visual", "name": "Scenes", "clips": [] } + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } ] }, "assignments": { "version": 1, - "noteRouting": { "synth": "unassigned" }, - "pads": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null], - "knobs": [null, null, null, null, null, null, null, null], + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + null, + null, + null, + null, + null, + null, + null, + null + ], "gestures": [], "buttons": [ - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null }, - { "cc": null, "channel": null, "action": null } + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } ] }, "midiOverrides": {} diff --git a/scripts/handoff-prune.js b/scripts/handoff-prune.js new file mode 100644 index 0000000..19a5e17 --- /dev/null +++ b/scripts/handoff-prune.js @@ -0,0 +1,45 @@ +// Prune approved work out of HANDOFF.md. Delivered tasks wait for the user's +// approval inside `` … `` blocks; once a +// task is approved, removing its block keeps the note short — git history keeps +// the detail. The ids go onto the one-line ledger at the end of the note. +// +// node scripts/handoff-prune.js --list print the prunable ids +// node scripts/handoff-prune.js […] remove those blocks +// node scripts/handoff-prune.js --all remove every block + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const file = path.join(__dirname, '..', 'HANDOFF.md'); +let text = fs.readFileSync(file, 'utf8'); +const blockRe = /[\s\S]*?\n?/g; +const ids = [...text.matchAll(blockRe)].map((m) => m[1]); +const args = process.argv.slice(2); + +if (!args.length || args.includes('--list')) { + console.log(ids.length ? ids.join('\n') : '(no prunable blocks)'); + process.exit(0); +} +const wanted = args.includes('--all') ? ids : args; +const missing = wanted.filter((id) => !ids.includes(id)); +if (missing.length) { + console.error(`no such block: ${missing.join(', ')}`); + process.exit(1); +} +const removed = []; +text = text.replace(blockRe, (m, id) => { + if (!wanted.includes(id)) return m; + removed.push(id); + return ''; +}); +const ledgerRe = /^Approved and pruned: (.*)$/m; +const prev = (text.match(ledgerRe) || [])[1]; +const prevIds = prev && prev !== '(none yet)' ? prev.split(',').map((s) => s.trim()).filter(Boolean) : []; +const today = new Date().toISOString().slice(0, 10); +const entries = prevIds.concat(removed.map((id) => `${id} (${today})`)); +if (ledgerRe.test(text)) text = text.replace(ledgerRe, `Approved and pruned: ${entries.length ? entries.join(', ') : '(none yet)'}`); +text = text.replace(/\n{3,}/g, '\n\n'); +fs.writeFileSync(file, text); +console.log(removed.length ? `pruned: ${removed.join(', ')}` : 'nothing pruned'); diff --git a/scripts/scene-harness.entry.js b/scripts/scene-harness.entry.js new file mode 100644 index 0000000..8548bed --- /dev/null +++ b/scripts/scene-harness.entry.js @@ -0,0 +1,120 @@ +// Browser side of scripts/scene-harness.js: the scene registry and three.js, +// bundled by esbuild and loaded into a hidden Electron window. Exposes +// window.__h — init(width, height, tier) builds the renderer and the creation +// context the engine would pass (docs/SCENE_CONTRACT.md); run(id, frames, dt, +// patch) drives one scene's update() for a number of frames with a patched +// io snapshot, renders, times a few frames, and returns the still as a PNG +// data URL. Shader compile errors surface through the console hooks. + +import * as THREE from 'three'; +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { creators, sceneList } from '../src/renderer/engine/scenes/index.js'; + +const logs = []; +for (const k of ['error', 'warn']) { + const orig = console[k].bind(console); + console[k] = (...a) => { + logs.push(`${k[0].toUpperCase()}: ${a.map((x) => String((x && x.message) || x)).join(' ').slice(0, 2000)}`); + orig(...a); + }; +} + +const TIERS = { low: 8000, med: 30000, high: 80000 }; +const instances = new Map(); +let renderer = null; +let ctx = null; +let io = null; +let t = 0; + +function init(width, height, tier = 'med') { + const canvas = document.getElementById('c'); + canvas.width = width; + canvas.height = height; + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + const pmrem = new THREE.PMREMGenerator(renderer); + const environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + pmrem.dispose(); + ctx = { THREE, renderer, width, height, quality: { tier, particles: TIERS[tier] || TIERS.med }, environment }; + io = { + level: 0, + bands: { bass: 0, mid: 0, high: 0 }, + beat: 0, + xy: { x: 0.5, y: 0.5 }, + gestures: { pulse: 0, press: 0, sway: 0 }, + knobs: new Array(8).fill(0.5), + pads: new Array(16).fill(0), + lastPad: -1, + strike: 0, + palette: ['#f2c9a6', '#5b3f9e', '#bfefff', '#ffb26b', '#e88aa8'].map((c) => new THREE.Color(c)), + intensity: 1, + }; + return sceneList.map((s) => s.id); +} + +function instance(id) { + if (!instances.has(id)) { + const create = creators[id]; + if (!create) throw new Error(`Unknown scene: ${id}`); + const inst = create({ ...ctx }); + instances.set(id, inst); + renderer.compile(inst.scene, inst.camera); + } + return instances.get(id); +} + +function applyPatch(patch) { + if (!patch) return; + if (patch.palette) patch.palette.forEach((c, i) => io.palette[i].set(c)); + if (patch.knobs) for (const [k, v] of Object.entries(patch.knobs)) io.knobs[Number(k)] = v; + if (patch.xy) Object.assign(io.xy, patch.xy); + if (patch.gestures) Object.assign(io.gestures, patch.gestures); + if (patch.bands) Object.assign(io.bands, patch.bands); + if ('level' in patch) io.level = patch.level; + if ('beat' in patch) io.beat = patch.beat; + if ('intensity' in patch) io.intensity = patch.intensity; +} + +// frames of update() with the patched io; a strike lands on the first frame +// and decays as the engine decays it; then one render, then a timed burst +function run(id, frames, dt, patch) { + const inst = instance(id); + applyPatch(patch); + const strikePad = patch && patch.strike != null ? patch.strike : -1; + const t0 = performance.now(); + for (let f = 0; f < frames; f++) { + if (strikePad >= 0) { + io.pads[strikePad] = f === 0 ? 1 : io.pads[strikePad] * Math.exp(-dt * 5); + io.lastPad = strikePad; + } + let s = 0; + for (let i = 0; i < 16; i++) if (io.pads[i] > s) s = io.pads[i]; + io.strike = s; + inst.update(dt, t, io); + t += dt; + } + const updateMs = (performance.now() - t0) / Math.max(1, frames); + const gl = renderer.getContext(); + renderer.setRenderTarget(null); + renderer.setClearColor(0x000000, 1); + renderer.clear(); + inst.update(dt, t, io); + t += dt; + renderer.render(inst.scene, inst.camera); + gl.finish(); + const N = 20; + const t2 = performance.now(); + for (let f = 0; f < N; f++) { + inst.update(dt, t, io); + t += dt; + renderer.render(inst.scene, inst.camera); + } + gl.finish(); + const msPerFrame = (performance.now() - t2) / N; + for (let i = 0; i < 16; i++) io.pads[i] = 0; + io.strike = 0; + return { updateMs, msPerFrame, png: renderer.domElement.toDataURL('image/png') }; +} + +window.__h = { init, run, logs, ids: () => sceneList.map((s) => s.id) }; diff --git a/scripts/scene-harness.js b/scripts/scene-harness.js new file mode 100644 index 0000000..76f5d34 --- /dev/null +++ b/scripts/scene-harness.js @@ -0,0 +1,98 @@ +// Offscreen scene harness: compiles and renders registry scenes in a hidden +// Electron window — not the app — so shader errors, per-frame cost and stills +// can be checked without a headless app launch (HANDOFF.md asks for those to +// stay rare). Bundles scene-harness.entry.js with esbuild, writes the page +// next to the output, then runs itself under Electron as the main script. +// +// node scripts/scene-harness.js +// +// The plan: +// { +// "out": "", // default: os.tmpdir()/swaycommand-harness +// "width": 1280, "height": 720, "tier": "med", +// "shots": [ +// { "name": "tomb-egg", "scene": "naturestomb", "frames": 120, "dt": 0.01667, +// "io": { "knobs": { "4": 0.75 }, "gestures": { "sway": 0.5 }, "xy": { "x": 0.5, "y": 0.5 }, +// "bands": { "bass": 0.3 }, "level": 0.4, "beat": 0, "strike": 2, "palette": ["#...", ...] } } +// ] +// } +// Shots run in order on cached instances, so a scene's state carries from one +// shot to the next (a strike in shot 2 lands on the state shot 1 left). One +// JSON report prints at the end: per shot the update cost per frame, the +// ms/frame of a timed burst (update + render, GPU finished), and the hooked +// console errors and warnings — a shader that fails to compile shows there. + +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const root = path.join(__dirname, '..'); + +if (process.versions.electron && !process.env.ELECTRON_RUN_AS_NODE) { + // ---- Electron main: drive the hidden window through the plan + const { app, BrowserWindow } = require('electron'); + const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + const out = plan.out; + app.whenReady().then(async () => { + const win = new BrowserWindow({ + show: false, + width: plan.width || 1280, + height: plan.height || 720, + webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }, + }); + const report = { scenes: 0, shots: [], logs: [] }; + try { + await win.loadFile(path.join(out, 'harness.html')); + const ids = await win.webContents.executeJavaScript( + `window.__h.init(${plan.width || 1280}, ${plan.height || 720}, ${JSON.stringify(plan.tier || 'med')})`, + ); + report.scenes = ids.length; + for (const shot of plan.shots || []) { + const js = `(() => { try { return window.__h.run(${JSON.stringify(shot.scene)}, ${shot.frames || 60}, ${shot.dt || 1 / 60}, ${JSON.stringify(shot.io || null)}); } catch (e) { return { error: String((e && e.stack) || e) }; } })()`; + const r = await win.webContents.executeJavaScript(js); + if (r && r.png) fs.writeFileSync(path.join(out, `${shot.name}.png`), Buffer.from(r.png.split(',')[1], 'base64')); + report.shots.push({ name: shot.name, scene: shot.scene, updateMs: r.updateMs, msPerFrame: r.msPerFrame, error: r.error }); + } + report.logs = await win.webContents.executeJavaScript('window.__h.logs'); + } catch (err) { + report.fatal = String((err && err.stack) || err); + } + console.log(JSON.stringify(report, null, 1)); + app.exit(report.fatal ? 1 : 0); + }); +} else { + // ---- Node: bundle, write the page, spawn Electron on this file + const planPath = path.resolve(process.argv[2] || ''); + if (!planPath || !fs.existsSync(planPath)) { + console.error('usage: node scripts/scene-harness.js '); + process.exit(2); + } + const plan = JSON.parse(fs.readFileSync(planPath, 'utf8')); + const out = path.resolve(plan.out || path.join(os.tmpdir(), 'swaycommand-harness')); + fs.mkdirSync(out, { recursive: true }); + plan.out = out; + const esbuild = require('esbuild'); + esbuild.buildSync({ + entryPoints: [path.join(__dirname, 'scene-harness.entry.js')], + bundle: true, + format: 'iife', + platform: 'browser', + target: 'chrome140', + outfile: path.join(out, 'harness.bundle.js'), + logLevel: 'warning', + }); + fs.writeFileSync( + path.join(out, 'harness.html'), + 'scene harness', + ); + const resolved = path.join(out, 'harness.plan.json'); + fs.writeFileSync(resolved, JSON.stringify(plan)); + const { spawnSync } = require('node:child_process'); + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const electron = require('electron'); + const r = spawnSync(electron, [__filename, resolved], { env, stdio: 'inherit', cwd: root }); + process.exit(r.status == null ? 1 : r.status); +} diff --git a/src/renderer/engine/scenes/index.js b/src/renderer/engine/scenes/index.js index 8c3e87f..feaac45 100644 --- a/src/renderer/engine/scenes/index.js +++ b/src/renderer/engine/scenes/index.js @@ -18,10 +18,11 @@ import * as valley from './valley.js'; import * as lattice from './lattice.js'; import * as willidream from './willidream.js'; import * as naturestomb from './naturestomb.js'; +import * as miraclemile from './miraclemile.js'; const modules = [ beams, swarm, ribbons, voxels, warp, nebula, mandelbulb, cymatic, - spectra, vjshader, ferrofluid, chladni, valley, lattice, willidream, naturestomb, + spectra, vjshader, ferrofluid, chladni, valley, lattice, willidream, naturestomb, miraclemile, ]; export const sceneList = modules.map((m) => m.meta); diff --git a/src/renderer/engine/scenes/miraclemile.js b/src/renderer/engine/scenes/miraclemile.js new file mode 100644 index 0000000..371dafd --- /dev/null +++ b/src/renderer/engine/scenes/miraclemile.js @@ -0,0 +1,1293 @@ +// Miracle Mile — four acts of the atom, one knob apart: the collider, the +// split atom, the mushroom cloud, the shockwave down the boulevard. +// +// ACT KNOB 6 (io.knobs[5]) picks it in quarter turns: 0–¼ COLLIDER, +// ¼–½ FISSION, ½–¾ DETONATION, ¾–1 SHOCKWAVE — so the 0.5 +// default rests on the mature mushroom cloud, turning left walks +// back to the split atom and the collider, turning right goes +// to the boulevard. An act change dissolves over 0.7 s. +// EVENT Any pad STRIKE fires the act's event: a collision, a neutron +// into the nucleus, a detonation, a blast down the boulevard. +// YIELD KNOB 5 (io.knobs[4]) is the yield, 0.5 nominal: collision +// multiplicity, prompt-neutron count and fragment speed, the +// cloud's scale, the blast's speed and dust. +// COLLIDER Inside the detector: a barrel of tracker and calorimeter +// layers (thin cyan rings and axial lines, one instanced mesh +// of capsules), the beam pipe on the axis, two bunches racing +// in from either end and crossing at the interaction point. A +// strike is the collision: 40–120 charged tracks spray from the +// vertex as helices in the solenoid field (the helix is rebuilt +// every frame from each track's pT, φ, η and charge, so the +// field is live), propagate outward in 0.4 s, leave hits on +// every layer they cross and dump energy into calorimeter bars +// at the barrel, then the event fades over ~3 s. Jets: most +// tracks cluster about three random axes. SWAY is the field +// strength — the tracks curl tighter as it rises; PRESS dives +// the eye toward the vertex; the hand orbits the detector (a +// hand-driven orbit, the one rotation here). +// FISSION A lattice of thirteen nuclei in a void — one at the centre, +// twelve around it on an icosahedron — each a packed cluster of +// nucleons (sphere impostors, protons warm, neutrons cool) that +// jitter in place. A strike fires a neutron at the nearest idle +// nucleus: on capture the drop swells and oscillates, elongates, +// necks and splits; the two fragments fly apart hot and cooling +// while two to four prompt neutrons leave for neighbouring +// nuclei, which split in turn — the chain reaction runs across +// the lattice with a gamma flash and an expanding shell at +// every scission, white-out when the last one goes, then the +// lattice re-forms. SWAY morphs the split from a clean symmetric +// scission to a wobbling asymmetric one (unequal fragments, a +// long neck); PRESS compresses the nuclei; the hand orbits. +// DETONATION A desert night with a city skyline between the eye and ground +// zero. At rest the cloud stands mature and churns (the cap's +// convective roll and the stem's updraft are flows of the noise +// domain, not rotation of anything). A strike detonates: the +// flash bleaches the world, the fireball swells and lifts, +// drawing the stem up out of the ground dust, the cap forms and +// broadens, glows from within and cools through orange to ash +// over ~25 s, the base surge rolls out across the ground. The +// cloud is a raymarched volume: a mushroom SDF (cap ellipsoid +// and rim torus, stem, bell) broken by three octaves of value +// noise, lit by its own embers and by the fireball, with a +// density probe toward the light for self-shadow. SWAY morphs +// the cloud's build (squat and broad ↔ tall and narrow, calmer +// ↔ more turbulent); PRESS flattens the cap under an inversion; +// the hand dollies the eye sideways (X) and raises it (Y); the +// eye keeps looking at the cloud. +// SHOCKWAVE The boulevard at night: a road running to the vanishing +// point, lit by two rows of lamps, facades with lit windows on +// both sides. A strike detonates at the vanishing point: the +// flash, a fireball dome, and the blast front rushes down the +// boulevard toward the eye — a spherical shell that refracts +// what lies behind it, a white condensation band just inside +// it, a wall of dust torn up where it meets the ground, the +// lamps flaring and dying as it passes them — then it arrives: +// the eye shakes, dust sweeps over the view, embers fall in the +// dark, and the boulevard relights near-to-far to be ready +// again. SWAY morphs the front (a clean hemisphere ↔ a flat +// Mach stem with a taller dust wall); PRESS ducks the eye; the +// hand sets the position across the road (X) and the eye's +// height (Y). +// +// Nothing rotates by itself: the orbits are the hand's, bunches, tracks, +// fragments and the front travel paths, the cloud's roll is a flow. Three +// systems: the world quad (backgrounds, the cloud, the boulevard, the blast +// — GLSL3 raymarch and analytic geometry from the camera's frame), one +// instanced mesh of screen-space capsules (detector lines, tracks, +// calorimeter bars, neutron streaks, lamp posts), two instanced meshes of +// sphere impostors (solid: nucleons; additive glows: bunches, hits, free +// neutrons, lamp heads, embers). Live bloom rides the flash. Colour comes +// from the palette: 0 the hot core, 1 fire, 2 the detector and cool matter, +// 3 the secondary tracks and gamma, 4 ash and dust. + +export const meta = { id: 'miraclemile', name: 'Miracle Mile', mood: 'critical' }; + +const ACTS = 4; // collider, fission, detonation, shockwave +const ACT_FADE = 0.7; +const FOV = 55; + +// the collider +const LAYERS = [1.1, 1.8, 2.6, 3.5, 4.6, 5.8]; +const HALF_Z = 6; +const RING_SEGS = 36; +const AXIALS = 18; +const DET_CAPS = LAYERS.length * (2 * RING_SEGS + AXIALS) + 1; +const MAX_TRACKS = 120; +const TRACK_SEGS = 20; +const TRACK_CAPS = MAX_TRACKS * TRACK_SEGS; +const MAX_HITS = MAX_TRACKS * 4; +const CALO_R = 6.2; +const TRACK_C = 18; // propagation speed, units per second + +// fission +const NUCLEI = 13; +const NUCLEONS = 72; +const NUC_R = 1.0; +const LATTICE_D = 4.0; +const MAX_NEUTRONS = 48; + +// shockwave +const LAMPS = 28; +const LAMP_DZ = 14; +const LAMP_Z0 = -12; +const LAMP_X = 8.5; +const LAMP_H = 7; +const GZ_Z = -160; +const BLAST_V = 60; +const EMBERS = 160; + +const N_CAPS = DET_CAPS + TRACK_CAPS + MAX_TRACKS + MAX_NEUTRONS + LAMPS * 2; +const CAP_TRACK0 = DET_CAPS; +const CAP_CALO0 = CAP_TRACK0 + TRACK_CAPS; +const CAP_NEUT0 = CAP_CALO0 + MAX_TRACKS; +const CAP_LAMP0 = CAP_NEUT0 + MAX_NEUTRONS; +const N_SOLID = NUCLEI * NUCLEONS; +const N_GLOW = 2 + MAX_HITS + MAX_NEUTRONS + LAMPS * 2 + EMBERS + 1; +const GL_BUNCH0 = 0; +const GL_HIT0 = 2; +const GL_NEUT0 = GL_HIT0 + MAX_HITS; +const GL_LAMP0 = GL_NEUT0 + MAX_NEUTRONS; +const GL_EMBER0 = GL_LAMP0 + LAMPS * 2; +const GL_VERTEX = GL_EMBER0 + EMBERS; + +const GLSL_COMMON = /* glsl */ ` + #define PI 3.14159265359 + #define TAU 6.28318530718 + float h11(float n) { return fract(sin(n * 127.1 + 311.7) * 43758.5453); } + float h21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + float h31(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } + float vnoise2(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(h21(i), h21(i + vec2(1.0, 0.0)), f.x), mix(h21(i + vec2(0.0, 1.0)), h21(i + vec2(1.0, 1.0)), f.x), f.y); + } + float vnoise3(vec3 p) { + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float n000 = h31(i), n100 = h31(i + vec3(1, 0, 0)), n010 = h31(i + vec3(0, 1, 0)), n110 = h31(i + vec3(1, 1, 0)); + float n001 = h31(i + vec3(0, 0, 1)), n101 = h31(i + vec3(1, 0, 1)), n011 = h31(i + vec3(0, 1, 1)), n111 = h31(i + vec3(1, 1, 1)); + return mix(mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), f.z); + } + float fbm2(vec2 p) { + float n = 0.0, a = 0.5; + for (int i = 0; i < 4; i++) { n += a * vnoise2(p); p = p * 2.03 + 7.1; a *= 0.5; } + return n; + } + float fbm3(vec3 p) { + float n = 0.0, a = 0.5; + for (int i = 0; i < 3; i++) { n += a * vnoise3(p); p = p * 2.07 + 5.3; a *= 0.5; } + return n; + } + // palette ramp: 0..4 between the five entries, 4..5 toward white + vec3 ramp(vec3 p0, vec3 p1, vec3 p2, vec3 p3, vec3 p4, float t) { + t = clamp(t, 0.0, 5.0); + if (t < 1.0) return mix(p0, p1, t); + if (t < 2.0) return mix(p1, p2, t - 1.0); + if (t < 3.0) return mix(p2, p3, t - 2.0); + if (t < 4.0) return mix(p3, p4, t - 3.0); + return mix(p4, vec3(1.0), t - 4.0); + } +`; + +// ------------------------------------------------------------- the world quad +// Rays come from the camera's frame (uCamPos, uCamFwd/Right/Up, uTanHalf) so +// the analytic backgrounds, the cloud march and the blast agree with the +// capsule and impostor meshes drawn by the same camera. +const WORLD_FRAG = /* glsl */ ` + ${GLSL_COMMON} + uniform vec2 uRes; + uniform vec3 uCamPos, uCamFwd, uCamRight, uCamUp; + uniform float uTanHalf, uTime, uIntensity, uFlash, uBass, uBeat, uHigh; + uniform vec4 uActW; // act weights: collider, fission, detonation, shockwave + uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + // collider + uniform float uVertexGlow, uCollFlash; + // fission + uniform vec4 uNuc[${NUCLEI}]; // xyz centre, w glow (0 gone, 1 intact, >1 hot) + uniform vec4 uGamma[4]; // xyz centre, w radius (<=0 none) + uniform float uGammaA[4]; // the four shells' brightness + // detonation + uniform float uCloudAge, uCloudScale, uCloudMorph, uCloudPress, uSkySeed; + // shockwave + uniform float uBlastAge, uBlastR, uBlastMorph, uBlastFire, uVeil, uDustH; + in vec2 vUv; + out vec4 fragColor; + + // ---- collider: the hall, the beam pipe, the interaction point + vec3 colliderBg(vec3 ro, vec3 rd) { + vec3 c = cross(rd, vec3(0.0, 0.0, 1.0)); + float cl = length(c); + float dAx = cl > 1e-5 ? abs(dot(ro, c)) / cl : length(ro.xy); + float dC = length(cross(ro, rd)); + vec3 col = uPal2 * 0.018 * (0.6 + 0.4 * rd.y); + col += mix(uPal2, vec3(1.0), 0.3) * 0.07 * exp(-dAx * 2.2); + col += mix(uPal2, vec3(1.0), 0.5) * (0.3 + uVertexGlow) * exp(-dC * 1.6) * 0.5; + col += vec3(1.0) * uCollFlash * 0.6 * exp(-dC * 0.35); + return col; + } + + // ---- fission: the void, each nucleus's glow, the gamma shells + vec3 fissionBg(vec3 ro, vec3 rd) { + vec3 col = uPal1 * 0.01 + uPal4 * 0.02 * (0.5 + 0.5 * rd.y); + for (int i = 0; i < ${NUCLEI}; i++) { + vec4 n = uNuc[i]; + vec3 v = n.xyz - ro; + float ahead = step(0.0, dot(v, rd)); + float dC = length(cross(v, rd)); + col += mix(uPal1, uPal0, 0.4) * n.w * 0.1 * exp(-dC * dC * 0.8) * ahead; + } + for (int k = 0; k < 4; k++) { + vec4 g = uGamma[k]; + if (g.w <= 0.0) continue; + float dC = length(cross(g.xyz - ro, rd)); + float ring = exp(-pow((dC - g.w) / (0.06 + g.w * 0.07), 2.0)); + col += mix(uPal3, vec3(1.0), 0.5) * ring * uGammaA[k] * 0.9; + } + return col; + } + + // ---- the mushroom cloud + float sdTorus(vec3 p, float R, float r) { return length(vec2(length(p.xz) - R, p.y)) - r; } + float sdEllipsoid(vec3 p, vec3 r) { float k0 = length(p / r); float k1 = length(p / (r * r)); return k0 * (k0 - 1.0) / max(k1, 1e-5); } + float sdCapCyl(vec3 p, float h, float r) { vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(r, h); return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); } + float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } + + // cap centre height for an age (the march needs it for its bound and light) + float capHeight(float a, float m, float q, float sc) { + float rise = 1.0 - exp(-a / 7.0); + return sc * (1.5 + 9.0 * rise) * (1.0 + 0.35 * m) * (1.0 - 0.25 * q); + } + // density at p (ground zero at the origin, y up); heat is the emissive temperature + float cloudMap(vec3 p, float a, float m, float q, float sc, out float heat) { + float s = smoothstep(0.6, 16.0, a); + float yc = capHeight(a, m, q, sc); + float Rc = sc * (1.6 + 2.8 * s) * (1.0 - 0.3 * m) * (1.0 + 0.35 * q); + float rc = sc * (1.4 + 0.8 * s) * (1.0 - 0.35 * q); + float rs = sc * (0.5 + 0.7 * s) * (1.0 - 0.2 * m); + vec3 pc = p - vec3(0.0, yc, 0.0); + float dCap = sdEllipsoid(pc, vec3(Rc + rc, rc * 1.15, Rc + rc)); + float dRim = sdTorus(pc - vec3(0.0, -rc * 0.25, 0.0), Rc + rc * 0.5, rc * 0.85); + float stemR = rs * (1.0 + 0.4 * (1.0 - clamp(p.y / yc, 0.0, 1.0))); + float dStem = sdCapCyl(p - vec3(0.0, yc * 0.5, 0.0), yc * 0.5, stemR); + float dBell = sdTorus(pc - vec3(0.0, -rc * 1.3, 0.0), Rc * 1.1, rc * 0.3) + (1.0 - s) * sc; + float d = min(smin(min(dCap, dRim), dStem, sc * 0.7), dBell); + // the cap rolls about its core ring, the stem draws upward: flows of the noise domain + float r = length(p.xz); + float phi = atan(p.z, p.x); + float wc = smoothstep(yc - rc * 2.2, yc - rc * 0.8, p.y); + float theta = atan(pc.y, r - Rc) + uTime * (0.3 + 0.5 * m) * wc; + float rho = length(vec2(r - Rc, pc.y)); + float rr = Rc + rho * cos(theta); + vec3 pn = vec3(rr * cos(phi), yc + rho * sin(theta), rr * sin(phi)); + vec3 ps = mix(p - vec3(0.0, uTime * 1.2, 0.0), pn, wc) / sc; + float n = fbm3(ps * (1.1 + 0.6 * m) + vec3(0.0, 0.0, 17.0)); + float dn = d + (0.55 - n) * sc * (0.9 + 0.9 * m); + float dens = smoothstep(0.0, -0.8 * sc, dn); + float fire = exp(-a / 2.2); + float dCore = length(pc) / (sc * (1.6 + 1.6 * s)); + heat = fire * exp(-dCore * dCore * 1.2) * 4.0 + exp(-a / 22.0) * 0.45 * smoothstep(1.0, 0.2, dCore); + return dens; + } + vec4 marchCloud(vec3 ro, vec3 rd, float a, float m, float q, float sc, float tMax) { + float s = smoothstep(0.6, 16.0, a); + float yc = capHeight(a, m, q, sc); + float Rb = max(yc * 0.7, sc * 2.5) + sc * (1.0 + 3.4 * s) * 1.4; + vec3 cb = vec3(0.0, yc * 0.55, 0.0); + vec3 oc = ro - cb; + float b = dot(oc, rd); + float c = dot(oc, oc) - Rb * Rb; + float h = b * b - c; + if (h < 0.0) return vec4(0.0); + h = sqrt(h); + float t0 = max(-b - h, 0.0); + float t1 = min(-b + h, tMax); + if (t1 <= t0) return vec4(0.0); + float stepLen = (t1 - t0) / float(CLOUD_STEPS); + float t = t0 + stepLen * h31(rd * 100.0 + uTime); + vec3 acc = vec3(0.0); + float T = 1.0; + vec3 lightPos = vec3(0.0, yc, 0.0); + vec3 ash = mix(uPal4, vec3(1.0), 0.3); + float fireL = 3.5 * exp(-a / 2.5) + 0.35 * exp(-a / 16.0); + for (int i = 0; i < CLOUD_STEPS; i++) { + vec3 p = ro + rd * t; + float heat; + float dens = cloudMap(p, a, m, q, sc, heat); + if (dens > 0.003) { + vec3 toL = normalize(lightPos - p); + float hp; + float dl = cloudMap(p + toL * sc * 0.7, a, m, q, sc, hp); + float lit = exp(-dl * 2.2); + float dist = length(lightPos - p) / sc; + float fl = fireL / (1.0 + dist * dist * 0.35); + float du = cloudMap(p + vec3(0.0, sc * 0.8, 0.0), a, m, q, sc, hp); + // the night sky from above, the city's glow from below, the fireball from within + float sky = 0.34 * exp(-du * 1.8); + float city = 0.14 * smoothstep(yc, 0.0, p.y); + vec3 emit = mix(uPal1, uPal0, clamp(heat * 0.5, 0.0, 1.0)) * heat; + vec3 col = emit + ash * (fl * lit * mix(uPal1, vec3(1.0), 0.3) + sky) + uPal1 * city; + float al = 1.0 - exp(-dens * stepLen * 1.6 / sc); + acc += T * col * al; + T *= 1.0 - al; + if (T < 0.02) break; + } + t += stepLen; + } + return vec4(acc, 1.0 - T); + } + + // ---- detonation: desert night, the skyline, the cloud behind it + vec3 detonation(vec3 ro, vec3 rd) { + float a = uCloudAge, sc = uCloudScale, m = uCloudMorph, q = uCloudPress; + vec3 GZ = vec3(0.0, 0.0, -40.0); + float fire = 3.5 * exp(-a / 2.0) + 0.3 * exp(-a / 12.0); + vec3 col = mix(uPal4, uPal1, 0.3) * 0.03 * exp(-max(rd.y, 0.0) * 9.0); + vec3 sc3 = rd * 90.0; + vec3 sf = fract(sc3) - 0.5; + col += vec3(1.0) * step(0.9935, h31(floor(sc3))) * exp(-dot(sf, sf) * 40.0) * 0.5 * smoothstep(0.0, 0.2, rd.y); + float tg = rd.y < -0.0005 ? -ro.y / rd.y : -1.0; + float tc = rd.z < -0.0005 ? (-22.0 - ro.z) / rd.z : -1.0; + bool building = false; + if (tc > 0.0 && (tg < 0.0 || tc < tg)) { + vec3 pc = ro + rd * tc; + float cellX = floor(pc.x / 3.0); + float skyH = 0.8 + 3.4 * pow(h11(cellX * 1.7 + uSkySeed), 1.6); + if (pc.y > 0.0 && pc.y < skyH) { + // a backlit silhouette: dark, windows, a rim the fireball throws over its edge + vec2 wuv = vec2(pc.x / 0.5, pc.y / 0.7); + float win = step(0.55, h21(floor(wuv) + uSkySeed)) * step(0.35, fract(wuv.x)) * (1.0 - step(0.8, fract(wuv.x))) * step(0.4, fract(wuv.y)) * (1.0 - step(0.85, fract(wuv.y))); + vec3 b = uPal4 * 0.02 + mix(uPal0, uPal1, 0.5) * win * 0.3; + b += uPal1 * exp(-(skyH - pc.y) * 2.5) * fire * 0.25; + col = b; + building = true; + } + } + float tMax = 1e9; + if (!building && tg > 0.0) { + vec3 pg = ro + rd * tg; + float dist = length(pg - GZ); + float gl = fire * 6.0 / (4.0 + dist * dist * 0.02); + float gn = 0.7 + 0.3 * vnoise2(pg.xz * 0.7); + vec3 gcol = mix(uPal4, uPal1, 0.35) * 0.04 * gn + uPal1 * gl * gn * 0.5; + float ring = 2.0 * sc + a * 1.3 * sc; + float dust = smoothstep(1.6 * sc, 0.0, abs(dist - ring)) * fbm2(pg.xz * 0.5 + a) * smoothstep(30.0, 4.0, a); + gcol += mix(uPal4, uPal1, 0.4) * dust * (0.3 + fire * 0.2); + col = gcol * (1.0 - smoothstep(40.0, 140.0, dist)); + tMax = tg; + } + if (!building) { + vec4 cl = marchCloud(ro - GZ, rd, a, m, q, sc, tMax); + col = col * (1.0 - cl.a) + cl.rgb; + float dC = length(cross(GZ + vec3(0.0, capHeight(a, m, q, sc), 0.0) - ro, rd)); + col += mix(uPal1, uPal0, 0.6) * fire * 0.12 / (1.0 + dC * dC * 0.05); + } + return col; + } + + // ---- shockwave: the boulevard and the blast front + float lampOn(vec3 lp, float a, float rs) { + if (a < 0.0) return 1.0; + float d = length(lp - vec3(0.0, 0.0, ${GZ_Z}.0)); + float passed = step(d, rs); + float flare = passed * exp(-(rs - d) / (${BLAST_V}.0 * 0.35)) * 4.0; + float relight = smoothstep(0.0, 1.0, (a - 7.0) * 0.7 - (-lp.z) / 120.0); + return max((1.0 - passed) + flare, relight); + } + float lampLight(vec3 p, float a, float rs) { + float k0 = floor((-p.z - ${-LAMP_Z0}.0) / ${LAMP_DZ}.0); + float sum = 0.0; + for (int i = -1; i <= 2; i++) { + float k = clamp(k0 + float(i), 0.0, ${LAMPS - 1}.0); + float lz = ${LAMP_Z0}.0 - ${LAMP_DZ}.0 * k; + for (int s = 0; s < 2; s++) { + vec3 lp = vec3(s == 0 ? -${LAMP_X} : ${LAMP_X}, ${LAMP_H}.0, lz); + vec3 dv = p - lp; + float d2 = dot(dv, dv); + float cosT = max(-dv.y, 0.0) / sqrt(d2); + sum += lampOn(lp, a, rs) * 28.0 * cosT / (d2 + 4.0); + } + } + return sum; + } + vec3 boulevardBg(vec3 ro, vec3 rd, float a, float rs, out float tHit) { + tHit = 1e9; + vec3 col = mix(uPal4, uPal1, 0.4) * 0.035 * exp(-max(rd.y, 0.0) * 10.0); + vec3 sc3 = rd * 120.0; + vec3 sf = fract(sc3) - 0.5; + col += vec3(1.0) * step(0.994, h31(floor(sc3))) * exp(-dot(sf, sf) * 40.0) * 0.45 * smoothstep(0.02, 0.3, rd.y); + float tf = abs(rd.x) > 1e-4 ? ((rd.x > 0.0 ? 10.0 : -10.0) - ro.x) / rd.x : -1.0; + float tg = rd.y < -1e-4 ? -ro.y / rd.y : -1.0; + bool done = false; + if (tf > 0.0 && (tg < 0.0 || tf < tg)) { + vec3 p = ro + rd * tf; + float block = floor(-p.z / 18.0); + float hB = 5.0 + 16.0 * pow(h11(block * 3.1 + (rd.x > 0.0 ? 0.0 : 7.0)), 1.4); + if (p.y < hB && p.z < -2.0) { + tHit = tf; + vec2 wuv = vec2(-p.z / 1.4, p.y / 1.1); + float on = step(0.62, h21(floor(wuv) + block)); + float win = on * step(0.3, fract(wuv.x)) * (1.0 - step(0.75, fract(wuv.x))) * step(0.35, fract(wuv.y)) * (1.0 - step(0.8, fract(wuv.y))); + float lit = lampOn(vec3(p.x, 0.0, p.z), a, rs); + vec3 f = uPal4 * 0.015 + mix(uPal0, uPal1, 0.45) * win * 0.3 * clamp(lit, 0.0, 1.0); + f += lampLight(p, a, rs) * mix(uPal4, uPal0, 0.3) * 0.25; + col = f * (1.0 - smoothstep(120.0, 300.0, -p.z)); + done = true; + } + } + if (!done && tg > 0.0) { + vec3 p = ro + rd * tg; + tHit = tg; + float ax = abs(p.x); + float road = 1.0 - smoothstep(8.0, 8.3, ax); + float dash = step(0.5, fract(-p.z / 6.0)) * (1.0 - smoothstep(0.08, 0.14, abs(ax - 4.0))); + float median = 1.0 - smoothstep(0.06, 0.12, ax); + float gn = 0.8 + 0.4 * vnoise2(p.xz * 0.8); + vec3 asphalt = uPal4 * 0.03 * gn; + vec3 paint = mix(uPal2, vec3(1.0), 0.6) * 0.2; + vec3 g = mix(uPal4 * 0.015, asphalt + paint * (dash + median * 0.6), road); + g += lampLight(p, a, rs) * mix(uPal0, uPal1, 0.35) * 0.9 * gn; + col = g * (1.0 - smoothstep(120.0, 320.0, -p.z)); + } + return col; + } + vec3 shockwave(vec3 ro, vec3 rd, vec2 uv) { + float a = uBlastAge; + float rs = uBlastR; + vec3 GZ = vec3(0.0, 0.0, ${GZ_Z}.0); + float fire = uBlastFire; + // the front: where the ray meets the shell (the eye outside it) + float tIn = -1.0; + vec3 nIn = vec3(0.0); + vec3 oc = ro - GZ; + if (a >= 0.0 && dot(oc, oc) > rs * rs) { + float b = dot(oc, rd); + float h = b * b - (dot(oc, oc) - rs * rs); + if (h > 0.0) { tIn = -b - sqrt(h); nIn = normalize(oc + rd * tIn); } + } + float tBg; + vec3 col = boulevardBg(ro, rd, a, rs, tBg); + float bImp = length(cross(GZ - ro, rd)); + if (tIn > 0.0 && tBg > tIn) { + // what lies behind the front is seen through the shell's density jump + float graze = pow(1.0 - abs(dot(nIn, rd)), 2.0); + vec3 rd2 = normalize(rd + nIn * 0.25 * graze * (0.6 + 0.5 * uBlastMorph)); + float t2; + col = boulevardBg(ro, rd2, a, rs, t2); + // the condensation band just inside the front, brightest at the limb + float w = rs * 0.07; + float c1 = sqrt(max(rs * rs - bImp * bImp, 0.0)); + float c2 = sqrt(max((rs - w) * (rs - w) - bImp * bImp, 0.0)); + float band = clamp((c1 - c2) / (w * 3.0), 0.0, 1.0) * smoothstep(0.1, 0.5, a) * exp(-max(a - 0.5, 0.0) / 1.8); + col += mix(uPal2, vec3(1.0), 0.7) * band * 0.9; + col += mix(uPal2, vec3(1.0), 0.5) * exp(-pow((bImp - rs) / (rs * 0.015), 2.0)) * 0.9; + } + if (a >= 0.0) { + // the dust wall torn up where the front meets the ground + vec2 oc2 = ro.xz - GZ.xz; + float bq = dot(oc2, rd.xz); + float cq = dot(oc2, oc2) - rs * rs; + float aq = dot(rd.xz, rd.xz); + float hq = bq * bq - aq * cq; + if (hq > 0.0 && aq > 1e-5) { + float tw = (-bq - sqrt(hq)) / aq; + if (tw < 0.0) tw = (-bq + sqrt(hq)) / aq; + if (tw > 0.0 && tw < tBg) { + vec3 pw = ro + rd * tw; + float hd = uDustH; + if (pw.y > 0.0 && pw.y < hd) { + float dn = fbm3(vec3(pw.x * 0.25, pw.y * 0.3 - a * 1.5, pw.z * 0.25)); + float op = smoothstep(0.22, 0.6, dn) * smoothstep(hd, hd * 0.45, pw.y); + // dark at the foot, fire-lit along its crest + float crest = smoothstep(hd * 0.3, hd * 0.95, pw.y); + vec3 dustCol = mix(uPal4 * 0.35, mix(uPal1, uPal0, 0.35) * (0.4 + fire * 0.6), crest); + col = mix(col, dustCol, clamp(op * 1.4, 0.0, 0.96)); + } + } + } + // the fireball dome at ground zero, and its glow + float rf = 4.0 + 12.0 * (1.0 - exp(-a / 1.5)); + float bq2 = dot(oc, rd); + float hf = bq2 * bq2 - (dot(oc, oc) - rf * rf); + if (hf > 0.0 && -bq2 - sqrt(hf) > 0.0 && -bq2 - sqrt(hf) < tBg) { + vec3 n = normalize(oc + rd * (-bq2 - sqrt(hf))); + float rim = pow(1.0 - max(dot(n, -rd), 0.0), 2.0); + col = mix(uPal1, uPal0, 0.5 + 0.5 * fire) * (0.5 + fire * 1.5) * (0.7 + 0.5 * rim); + } + col += mix(uPal1, uPal0, 0.5) * fire * 0.25 / (1.0 + bImp * bImp * 0.003); + } + // the passage: dust sweeping over the eye, lit by the fire behind it + float veil = uVeil * fbm2(vec2(uv.x * 3.0 + uTime * 6.0, uv.y * 2.0 - uTime * 3.0)); + float streak = uVeil * fbm2(vec2(uv.x * 1.5 + uTime * 14.0, uv.y * 9.0)); + col = col * (1.0 - veil * 0.8) + mix(uPal4, uPal1, 0.4) * (veil * 0.42 + streak * 0.22) * (0.5 + fire * 0.5); + return col; + } + + void main() { + vec2 uv = (vUv - 0.5) * 2.0; + float aspect = uRes.x / uRes.y; + vec3 rd = normalize(uCamFwd + uCamRight * (uv.x * uTanHalf * aspect) + uCamUp * (uv.y * uTanHalf)); + vec3 ro = uCamPos; + vec3 col = vec3(0.0); + if (uActW.x > 0.002) col += colliderBg(ro, rd) * uActW.x; + if (uActW.y > 0.002) col += fissionBg(ro, rd) * uActW.y; + if (uActW.z > 0.002) col += detonation(ro, rd) * uActW.z; + if (uActW.w > 0.002) col += shockwave(ro, rd, uv) * uActW.w; + col = mix(col, vec3(1.0), uFlash); + fragColor = vec4(col * uIntensity, 1.0); + } +`; + +// ---------------------------------------------------- capsules in world space +// Endpoints in world units, projected by the scene camera; the quad is built +// in screen space so a segment is a constant-width capsule at any depth. +// aS = (radius px, alpha, palette index, mode: 0 line, 1 glow, 2 bar). +const CAP_VERT = /* glsl */ ` + uniform vec2 uRes; + in vec2 aQuad; + in vec3 aP0; + in vec3 aP1; + in vec4 aS; + out vec2 vQ; + out float vLenR, vA, vTint, vMode; + vec2 toScreen(vec3 p, out float w) { + vec4 c = projectionMatrix * viewMatrix * vec4(p, 1.0); + w = c.w; + return c.xy / max(c.w, 0.001) * vec2(uRes.x / uRes.y, 1.0) * 0.5; + } + void main() { + float w0, w1; + vec2 s0 = toScreen(aP0, w0); + vec2 s1 = toScreen(aP1, w1); + float vis = step(0.1, w0) * step(0.1, w1) * step(0.001, aS.y); + float aspect = uRes.x / uRes.y; + vec2 d = s1 - s0; + float len = length(d); + vec2 dir = len > 1e-5 ? d / len : vec2(1.0, 0.0); + vec2 nrm = vec2(-dir.y, dir.x); + float glow = step(0.5, aS.w) * (1.0 - step(1.5, aS.w)); + float rad = max(aS.x, 0.75) / uRes.y * (1.0 + glow * 2.0); + float cap = 1.0 - step(1.5, aS.w); + vLenR = len / rad; + vec2 pos = mix(s0 - dir * rad * cap, s1 + dir * rad * cap, aQuad.y) + nrm * aQuad.x * rad; + gl_Position = vec4(pos / vec2(aspect, 1.0) * 2.0 * vis, vis > 0.5 ? 0.0 : 2.0, 1.0); + vQ = vec2(aQuad.x, aQuad.y * (vLenR + 2.0 * cap) - cap); + vA = aS.y; + vTint = aS.z; + vMode = aS.w; + } +`; +const CAP_FRAG = /* glsl */ ` + ${GLSL_COMMON} + uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + uniform float uIntensity; + in vec2 vQ; + in float vLenR, vA, vTint, vMode; + out vec4 fragColor; + void main() { + float u = clamp(vQ.y, 0.0, vLenR); + float dx = vQ.y - u; + float d2 = dx * dx + vQ.x * vQ.x; + if (d2 > 1.0) discard; + float prof; + if (vMode < 0.5) prof = exp(-d2 * 3.0) * (1.0 - d2 * 0.2); + else if (vMode < 1.5) prof = exp(-d2 * 2.2) * 0.35; + else prof = 0.9 * (1.0 - smoothstep(0.7, 1.0, vQ.x * vQ.x)); + vec3 col = ramp(uPal0, uPal1, uPal2, uPal3, uPal4, vTint); + fragColor = vec4(col * prof * vA * uIntensity, 1.0); + } +`; + +// ------------------------------------------------------------ sphere impostors +// A camera-facing quad per sphere; the fragment shader rebuilds the sphere's +// normal from the quad coordinate. aS = (radius world, alpha, palette index, +// heat). The solid mesh writes depth (nucleons occlude each other); the glow +// mesh is additive. +const SPH_VERT = /* glsl */ ` + in vec2 aQuad; + in vec3 aPos; + in vec4 aS; + out vec2 vQ; + out float vA, vTint, vHeat; + void main() { + vec4 mv = viewMatrix * vec4(aPos, 1.0); + float vis = step(0.001, aS.y); + mv.xy += aQuad * aS.x * 1.05; + vec4 clip = projectionMatrix * mv; + gl_Position = vis > 0.5 ? clip : vec4(0.0, 0.0, 2.0, 1.0); + vQ = aQuad * 1.05; + vA = aS.y; + vTint = aS.z; + vHeat = aS.w; + } +`; +const SPH_FRAG_SOLID = /* glsl */ ` + ${GLSL_COMMON} + uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + uniform float uIntensity; + in vec2 vQ; + in float vA, vTint, vHeat; + out vec4 fragColor; + void main() { + float r2 = dot(vQ, vQ); + if (r2 > 1.0) discard; + vec3 n = vec3(vQ, sqrt(1.0 - r2)); + vec3 L = normalize(vec3(-0.45, 0.6, 0.66)); + float lit = max(dot(n, L), 0.0); + float rim = pow(1.0 - n.z, 2.5); + vec3 base = ramp(uPal0, uPal1, uPal2, uPal3, uPal4, vTint); + float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 40.0); + vec3 col = base * (0.22 + 0.78 * lit) + vec3(spec * 0.35) + uPal0 * rim * 0.3; + col += mix(uPal1, uPal0, 0.6) * vHeat * (0.6 + 0.6 * rim); + col *= 1.0 - smoothstep(0.86, 1.0, r2); + fragColor = vec4(col * vA * uIntensity, 1.0); + } +`; +const SPH_FRAG_GLOW = /* glsl */ ` + ${GLSL_COMMON} + uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + uniform float uIntensity; + in vec2 vQ; + in float vA, vTint, vHeat; + out vec4 fragColor; + void main() { + float r2 = dot(vQ, vQ); + if (r2 > 1.0) discard; + float prof = exp(-r2 * 3.5) * (1.0 - r2); + vec3 col = ramp(uPal0, uPal1, uPal2, uPal3, uPal4, vTint); + col = mix(col, vec3(1.0), vHeat * 0.5 * exp(-r2 * 6.0)); + fragColor = vec4(col * prof * vA * uIntensity, 1.0); + } +`; + +export function createScene(ctx) { + const { THREE, quality } = ctx; + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(FOV, ctx.width / Math.max(1, ctx.height), 0.1, 1200); + const tier = quality.tier; + const CLOUD_STEPS = tier === 'low' ? 26 : tier === 'high' ? 60 : 42; + const pal5 = () => Array.from({ length: 5 }, () => ({ value: new THREE.Color(1, 1, 1) })); + const wp = pal5(), cp = pal5(), sp = pal5(), gp = pal5(); + const palUniforms = (p) => ({ uPal0: p[0], uPal1: p[1], uPal2: p[2], uPal3: p[3], uPal4: p[4] }); + + // --- the world quad ----------------------------------------------------------------- + const nucU = new Float32Array(NUCLEI * 4); + const gammaU = new Float32Array(16); + const WU = { + uRes: { value: new THREE.Vector2(ctx.width, ctx.height) }, + uCamPos: { value: new THREE.Vector3() }, + uCamFwd: { value: new THREE.Vector3(0, 0, -1) }, + uCamRight: { value: new THREE.Vector3(1, 0, 0) }, + uCamUp: { value: new THREE.Vector3(0, 1, 0) }, + uTanHalf: { value: Math.tan((FOV * Math.PI) / 360) }, + uTime: { value: 0 }, + uIntensity: { value: 1 }, + uFlash: { value: 0 }, + uBass: { value: 0 }, + uBeat: { value: 0 }, + uHigh: { value: 0 }, + uActW: { value: new THREE.Vector4(0, 0, 1, 0) }, + uVertexGlow: { value: 0 }, + uCollFlash: { value: 0 }, + uNuc: { value: nucU }, + uGamma: { value: gammaU }, + uGammaA: { value: new Float32Array(4) }, + uCloudAge: { value: 30 }, + uCloudScale: { value: 1 }, + uCloudMorph: { value: 0 }, + uCloudPress: { value: 0 }, + uSkySeed: { value: 3.7 }, + uBlastAge: { value: -1 }, + uBlastR: { value: 0 }, + uBlastMorph: { value: 0 }, + uBlastFire: { value: 0 }, + uVeil: { value: 0 }, + uDustH: { value: 4 }, + ...palUniforms(wp), + }; + const worldMat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, + uniforms: WU, + defines: { CLOUD_STEPS }, + vertexShader: /* glsl */ ` + out vec2 vUv; + void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`, + fragmentShader: WORLD_FRAG, + depthTest: false, + depthWrite: false, + }); + const world = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), worldMat); + world.frustumCulled = false; + world.renderOrder = 0; + scene.add(world); + + // --- one quad geometry for every instanced system ---------------------------------- + const quadPos = new Float32Array([-1, 0, 0, 1, 0, 0, -1, 1, 0, 1, 1, 0]); + const quadUV = new Float32Array([-1, 0, 1, 0, -1, 1, 1, 1]); + const sphUV = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + function instancedQuad(uvs) { + const g = new THREE.InstancedBufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(quadPos, 3)); + g.setAttribute('aQuad', new THREE.BufferAttribute(uvs, 2)); + g.setIndex([0, 1, 2, 2, 1, 3]); + return g; + } + const dyn = (arr, n) => { const a = new THREE.InstancedBufferAttribute(arr, n); a.setUsage(THREE.DynamicDrawUsage); return a; }; + + // capsules + const capGeo = instancedQuad(quadUV); + const capP0 = new Float32Array(N_CAPS * 3); + const capP1 = new Float32Array(N_CAPS * 3); + const capS = new Float32Array(N_CAPS * 4); + const capAP0 = dyn(capP0, 3), capAP1 = dyn(capP1, 3), capAS = dyn(capS, 4); + capGeo.setAttribute('aP0', capAP0); + capGeo.setAttribute('aP1', capAP1); + capGeo.setAttribute('aS', capAS); + capGeo.instanceCount = N_CAPS; + const CU = { uRes: { value: new THREE.Vector2(ctx.width, ctx.height) }, uIntensity: { value: 1 }, ...palUniforms(cp) }; + const capMat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, uniforms: CU, vertexShader: CAP_VERT, fragmentShader: CAP_FRAG, + transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, + }); + const caps = new THREE.Mesh(capGeo, capMat); + caps.frustumCulled = false; + caps.renderOrder = 2; + scene.add(caps); + + // spheres: solids (depth) and glows (additive) + function sphereSystem(n, frag, pal, solid, order) { + const geo = instancedQuad(sphUV); + const pos = new Float32Array(n * 3); + const s = new Float32Array(n * 4); + const aPos = dyn(pos, 3), aS = dyn(s, 4); + geo.setAttribute('aPos', aPos); + geo.setAttribute('aS', aS); + geo.instanceCount = n; + const U = { uIntensity: { value: 1 }, ...palUniforms(pal) }; + const mat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, uniforms: U, vertexShader: SPH_VERT, fragmentShader: frag, + transparent: !solid, depthTest: solid, depthWrite: solid, side: THREE.DoubleSide, + blending: solid ? THREE.NormalBlending : THREE.AdditiveBlending, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = false; + mesh.renderOrder = order; + scene.add(mesh); + return { geo, mat, mesh, pos, s, aPos, aS, U }; + } + const solids = sphereSystem(N_SOLID, SPH_FRAG_SOLID, sp, true, 1); + const glows = sphereSystem(N_GLOW, SPH_FRAG_GLOW, gp, false, 3); + + const cap = (i, x0, y0, z0, x1, y1, z1, rad, alpha, tint, mode) => { + const o = i * 3, q = i * 4; + capP0[o] = x0; capP0[o + 1] = y0; capP0[o + 2] = z0; + capP1[o] = x1; capP1[o + 1] = y1; capP1[o + 2] = z1; + capS[q] = rad; capS[q + 1] = alpha; capS[q + 2] = tint; capS[q + 3] = mode; + }; + const sph = (sys, i, x, y, z, r, alpha, tint, heat) => { + const o = i * 3, q = i * 4; + sys.pos[o] = x; sys.pos[o + 1] = y; sys.pos[o + 2] = z; + sys.s[q] = r; sys.s[q + 1] = alpha; sys.s[q + 2] = tint; sys.s[q + 3] = heat; + }; + + // --- the detector: static capsules, their alpha set per frame ------------------------ + { + let i = 0; + for (const R of LAYERS) { + for (let e = 0; e < 2; e++) { + const z = e ? HALF_Z : -HALF_Z; + for (let k = 0; k < RING_SEGS; k++) { + const a0 = (k / RING_SEGS) * Math.PI * 2, a1 = ((k + 1) / RING_SEGS) * Math.PI * 2; + cap(i++, Math.cos(a0) * R, Math.sin(a0) * R, z, Math.cos(a1) * R, Math.sin(a1) * R, z, 1.0, 0, 2, 0); + } + } + for (let k = 0; k < AXIALS; k++) { + const a = (k / AXIALS) * Math.PI * 2 + 0.1; + cap(i++, Math.cos(a) * R, Math.sin(a) * R, -HALF_Z, Math.cos(a) * R, Math.sin(a) * R, HALF_Z, 0.9, 0, 2, 0); + } + } + cap(DET_CAPS - 1, 0, 0, -9, 0, 0, 9, 2.2, 0, 2.4, 1); // the beam pipe + } + + // --- the nuclei: one packed nucleon cloud, the lattice, the states ------------------- + const nucBase = new Float32Array(NUCLEONS * 3); + const nucType = new Uint8Array(NUCLEONS); // 0 proton, 1 neutron + { + for (let i = 0; i < NUCLEONS; i++) { + let x, y, z; + do { x = Math.random() * 2 - 1; y = Math.random() * 2 - 1; z = Math.random() * 2 - 1; } while (x * x + y * y + z * z > 1); + nucBase[i * 3] = x * 0.75; nucBase[i * 3 + 1] = y * 0.75; nucBase[i * 3 + 2] = z * 0.75; + nucType[i] = Math.random() < 0.42 ? 0 : 1; + } + for (let it = 0; it < 60; it++) { + for (let i = 0; i < NUCLEONS; i++) { + let fx = 0, fy = 0, fz = 0; + const xi = nucBase[i * 3], yi = nucBase[i * 3 + 1], zi = nucBase[i * 3 + 2]; + for (let j = 0; j < NUCLEONS; j++) { + if (j === i) continue; + const dx = xi - nucBase[j * 3], dy = yi - nucBase[j * 3 + 1], dz = zi - nucBase[j * 3 + 2]; + const d2 = dx * dx + dy * dy + dz * dz + 1e-4; + if (d2 < 0.25) { const f = (0.25 - d2) * 0.9; fx += dx * f; fy += dy * f; fz += dz * f; } + } + let nx = xi + fx * 0.2, ny = yi + fy * 0.2, nz = zi + fz * 0.2; + const rl = Math.sqrt(nx * nx + ny * ny + nz * nz); + const rMax = NUC_R - 0.22; + if (rl > rMax) { nx *= rMax / rl; ny *= rMax / rl; nz *= rMax / rl; } + nucBase[i * 3] = nx; nucBase[i * 3 + 1] = ny; nucBase[i * 3 + 2] = nz; + } + } + } + const latPos = new Float32Array(NUCLEI * 3); + { + const phi = (1 + Math.sqrt(5)) / 2; + const v = [[0, 1, phi], [0, -1, phi], [0, 1, -phi], [0, -1, -phi], [1, phi, 0], [-1, phi, 0], [1, -phi, 0], [-1, -phi, 0], [phi, 0, 1], [-phi, 0, 1], [phi, 0, -1], [-phi, 0, -1]]; + const n = Math.sqrt(1 + phi * phi); + for (let i = 0; i < 12; i++) { + latPos[(i + 1) * 3] = (v[i][0] / n) * LATTICE_D; + latPos[(i + 1) * 3 + 1] = (v[i][1] / n) * LATTICE_D; + latPos[(i + 1) * 3 + 2] = (v[i][2] / n) * LATTICE_D; + } + } + const nPhase = new Uint8Array(NUCLEI); // 0 idle, 1 excited, 2 split, 3 re-forming + const nT = new Float32Array(NUCLEI); + const nAxis = new Float32Array(NUCLEI * 3); + const nOff = new Float32Array(NUCLEI); // the split plane's offset (asymmetry) + const nFrag = new Float32Array(NUCLEI * 2); // fragment separations (+ side, − side) + const nHeat = new Float32Array(NUCLEI); + const neutAlive = new Uint8Array(MAX_NEUTRONS); + const neutFrom = new Float32Array(MAX_NEUTRONS * 3); + const neutTo = new Float32Array(MAX_NEUTRONS * 3); + const neutT = new Float32Array(MAX_NEUTRONS); + const neutDur = new Float32Array(MAX_NEUTRONS); + const neutTarget = new Int8Array(MAX_NEUTRONS); + const gAge = new Float32Array(4).fill(-1); + let gNext = 0; + + // --- the collider's event ------------------------------------------------------------- + const trkPhi = new Float32Array(MAX_TRACKS), trkTheta = new Float32Array(MAX_TRACKS), trkPt = new Float32Array(MAX_TRACKS); + const trkQ = new Float32Array(MAX_TRACKS), trkTint = new Float32Array(MAX_TRACKS), trkE = new Float32Array(MAX_TRACKS); + let evT = -1, evN = 0; + + // --- the boulevard's embers ------------------------------------------------------------- + const embX = new Float32Array(EMBERS), embY = new Float32Array(EMBERS), embZ = new Float32Array(EMBERS), embV = new Float32Array(EMBERS); + for (let i = 0; i < EMBERS; i++) { embX[i] = (Math.random() - 0.5) * 24; embY[i] = Math.random() * 12; embZ[i] = -Math.random() * 40; embV[i] = 0.6 + Math.random() * 1.4; } + + // --- state ------------------------------------------------------------------------------ + const approach = (v, to, tau, dt) => v + (to - v) * (1 - Math.exp(-dt / tau)); + const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + const smooth01 = (u) => { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); }; + const gauss = () => (Math.random() + Math.random() + Math.random() - 1.5) * 0.8; + const camPos = new THREE.Vector3(0, 2, 0); + const camTarget = new THREE.Vector3(0, 4, -40); + const wantPos = new THREE.Vector3(); + const wantTarget = new THREE.Vector3(); + const jetPhi = new Float32Array(3), jetTheta = new Float32Array(3); + const nucPh = new Float32Array(NUCLEONS * 3); + for (let i = 0; i < NUCLEONS * 3; i++) nucPh[i] = Math.random() * 6.2831853; + let act = 2; + const actW = new Float32Array(4); + actW[2] = 1; + let knob6Prev = null; + let yieldS = 0.5, swayS = 0, pressS = 0, bass = 0, high = 0, pulse = 0, beatPrev = 0, strikePrev = 0, flash = 0; + let hx = 0.5, hy = 0.5; + let vertexGlow = 0, collFlash = 0, bunchT = 0; + let cloudAge = 30; + let blastAge = -1, passAge = -1, veil = 0, shake = 0, emberA = 0, fireS = 0; + let cascadeFlash = 0; + + // --- the collider ---------------------------------------------------------------------- + function fireCollision() { + evT = 0; + evN = Math.floor(40 + yieldS * 80); + for (let k = 0; k < 3; k++) { jetPhi[k] = Math.random() * 6.2831853; jetTheta[k] = 0.6 + Math.random() * 1.9; } + for (let j = 0; j < MAX_TRACKS; j++) { + const inJet = Math.random() < 0.65; + const k = (Math.random() * 3) | 0; + const eta = (Math.random() - 0.5) * 4.8; + trkPhi[j] = inJet ? jetPhi[k] + gauss() * 0.22 : Math.random() * 6.2831853; + trkTheta[j] = inJet ? clamp(jetTheta[k] + gauss() * 0.18, 0.2, 2.94) : 2 * Math.atan(Math.exp(-eta)); + const pt = 0.3 + 5 * Math.pow(Math.random(), 3) + (inJet ? 1.5 * Math.random() : 0); + trkPt[j] = pt; + const r = Math.random(); + trkQ[j] = r < 0.15 ? 0 : r < 0.575 ? 1 : -1; + trkTint[j] = pt < 2.5 ? 3.0 - 0.4 * pt : 4.3 + 0.7 * Math.min(1, (pt - 2.5) / 3); + trkE[j] = pt * (0.8 + Math.random() * 0.6); + } + collFlash = 1; + } + function updateCollider(dt, t, w) { + const base = w * (0.22 + pulse * 0.08); + for (let i = 0; i < DET_CAPS - 1; i++) capS[i * 4 + 1] = base; + capS[(DET_CAPS - 1) * 4 + 1] = w * 0.5; + bunchT += dt; + const u = (bunchT * 0.7) % 1; + sph(glows, GL_BUNCH0, 0, 0, 12 - 24 * u, 0.12, w * 1.1, 4.7, 1); + sph(glows, GL_BUNCH0 + 1, 0, 0, -12 + 24 * u, 0.12, w * 1.1, 4.7, 1); + sph(glows, GL_VERTEX, 0, 0, 0, 0.6, w * (0.25 + bass * 0.6 + collFlash), 2.5, collFlash); + vertexGlow = bass * 0.6 + collFlash; + collFlash = Math.max(0, collFlash - dt * 3); + if (evT < 0) return; + evT += dt; + if (evT > 4.5) { + evT = -1; + for (let i = CAP_TRACK0; i < CAP_NEUT0; i++) capS[i * 4 + 1] = 0; + for (let i = GL_HIT0; i < GL_NEUT0; i++) glows.s[i * 4 + 1] = 0; + return; + } + const B = 0.5 + swayS * 3.5; + const front = TRACK_C * evT; + const fade = (evT < 0.6 ? 1 : Math.exp(-(evT - 0.6) / 1.6)) * w; + const L = 7.5, ds = L / TRACK_SEGS; + for (let j = 0; j < MAX_TRACKS; j++) { + const c0 = CAP_TRACK0 + j * TRACK_SEGS; + if (j >= evN) { + for (let k = 0; k < TRACK_SEGS; k++) capS[(c0 + k) * 4 + 1] = 0; + capS[(CAP_CALO0 + j) * 4 + 1] = 0; + for (let n = 0; n < 4; n++) glows.s[(GL_HIT0 + j * 4 + n) * 4 + 1] = 0; + continue; + } + const phi = trkPhi[j], th = trkTheta[j], q = trkQ[j]; + const st = Math.sin(th), ct = Math.cos(th); + const Rs = q === 0 ? 0 : (q * trkPt[j]) / (0.3 * B); + let px = 0, py = 0, pz = 0, pr = 0, hits = 0, alive = true, exitS = -1; + let ex = 0, ey = 0, ez = 0; + for (let k = 0; k < TRACK_SEGS; k++) { + const s1 = (k + 1) * ds; + let x, y, z; + if (q === 0) { x = s1 * st * Math.cos(phi); y = s1 * st * Math.sin(phi); z = s1 * ct; } + else { const psi = (s1 * st) / Rs; x = Rs * (Math.sin(phi + psi) - Math.sin(phi)); y = -Rs * (Math.cos(phi + psi) - Math.cos(phi)); z = s1 * ct; } + const r = Math.sqrt(x * x + y * y); + const ci = c0 + k; + if (!alive) { capS[ci * 4 + 1] = 0; continue; } + const s0 = k * ds; + const vis = s0 < front ? 1 : 0; + const f = s1 <= front ? 1 : (front - s0) / ds; + cap(ci, px, py, pz, px + (x - px) * f, py + (y - py) * f, pz + (z - pz) * f, 1.3, fade * vis * 0.9, trkTint[j], 0); + // hits where the track crosses a layer + for (let li = 0; li < LAYERS.length && hits < 4; li++) { + const LR = LAYERS[li]; + if (pr < LR && r >= LR) { + const u2 = (LR - pr) / (r - pr); + const hs = s0 + ds * u2; + sph(glows, GL_HIT0 + j * 4 + hits, px + (x - px) * u2, py + (y - py) * u2, pz + (z - pz) * u2, 0.09, hs < front ? fade * 0.9 : 0, 4.6, 0.5); + hits++; + } + } + px = x; py = y; pz = z; pr = r; + if (r >= CALO_R || Math.abs(z) > 6.5) { alive = false; exitS = s1; ex = x; ey = y; ez = z; } + } + for (let n = hits; n < 4; n++) glows.s[(GL_HIT0 + j * 4 + n) * 4 + 1] = 0; + // the calorimeter takes the energy where the track leaves the barrel + if (exitS > 0 && pr >= CALO_R * 0.98) { + const el = Math.sqrt(ex * ex + ey * ey + ez * ez); + const dx = ex / el, dy = ey / el, dz = ez / el; + const len = 0.5 + trkE[j] * 0.35; + cap(CAP_CALO0 + j, dx * CALO_R, dy * CALO_R, dz * CALO_R, dx * (CALO_R + len), dy * (CALO_R + len), dz * (CALO_R + len), 4.5, front >= exitS ? fade * 0.8 : 0, 0.6, 2); + } else { + capS[(CAP_CALO0 + j) * 4 + 1] = 0; + } + } + } + + // --- fission ------------------------------------------------------------------------------ + function exciteNucleus(n) { + nPhase[n] = 1; + nT[n] = 0; + let ax = Math.random() - 0.5, ay = Math.random() - 0.5, az = Math.random() - 0.5; + const l = Math.sqrt(ax * ax + ay * ay + az * az) + 1e-6; + nAxis[n * 3] = ax / l; nAxis[n * 3 + 1] = ay / l; nAxis[n * 3 + 2] = az / l; + nOff[n] = (Math.random() < 0.5 ? 1 : -1) * 0.24 * swayS; + nFrag[n * 2] = 0; nFrag[n * 2 + 1] = 0; + nHeat[n] = 0; + } + function fireNeutron(fx, fy, fz, target) { + for (let i = 0; i < MAX_NEUTRONS; i++) { + if (neutAlive[i]) continue; + neutAlive[i] = 1; + neutFrom[i * 3] = fx; neutFrom[i * 3 + 1] = fy; neutFrom[i * 3 + 2] = fz; + let tx, ty, tz; + if (target >= 0) { tx = latPos[target * 3]; ty = latPos[target * 3 + 1]; tz = latPos[target * 3 + 2]; } + else { const l = Math.sqrt(fx * fx + fy * fy + fz * fz) + 1e-6; tx = fx + (fx / l) * 14; ty = fy + (fy / l) * 14 + 2; tz = fz + (fz / l) * 14; } + neutTo[i * 3] = tx; neutTo[i * 3 + 1] = ty; neutTo[i * 3 + 2] = tz; + const d = Math.sqrt((tx - fx) ** 2 + (ty - fy) ** 2 + (tz - fz) ** 2); + neutDur[i] = Math.max(0.25, d / (8 + yieldS * 4)); + neutT[i] = 0; + neutTarget[i] = target; + return; + } + } + function nearestIdle(x, y, z, exclude) { + let best = -1, bd = 1e9; + for (let n = 0; n < NUCLEI; n++) { + if (n === exclude || nPhase[n] !== 0) continue; + const d = (latPos[n * 3] - x) ** 2 + (latPos[n * 3 + 1] - y) ** 2 + (latPos[n * 3 + 2] - z) ** 2; + if (d < bd) { bd = d; best = n; } + } + return best; + } + function fireFission() { + const target = nearestIdle(0, 0, 0, -1); + if (target < 0) return; + const a = Math.random() * 6.2831853, e = (Math.random() - 0.5) * 1.2; + fireNeutron(Math.cos(a) * Math.cos(e) * 9, Math.sin(e) * 9, Math.sin(a) * Math.cos(e) * 9, target); + } + function scission(n) { + nPhase[n] = 2; + nT[n] = 0; + nHeat[n] = 1; + const k = gNext; gNext = (gNext + 1) & 3; + gammaU[k * 4] = latPos[n * 3]; gammaU[k * 4 + 1] = latPos[n * 3 + 1]; gammaU[k * 4 + 2] = latPos[n * 3 + 2]; gammaU[k * 4 + 3] = 0.5; + gAge[k] = 0; + flash = Math.max(flash, 0.3); + const count = 2 + Math.floor(yieldS * 2.5); + for (let c = 0; c < count; c++) { + const tgt = nearestIdle(latPos[n * 3], latPos[n * 3 + 1], latPos[n * 3 + 2], n); + if (tgt >= 0) nPhase[tgt] = 4; // claimed: a neutron is on its way + fireNeutron(latPos[n * 3], latPos[n * 3 + 1], latPos[n * 3 + 2], tgt); + } + let intact = 0; + for (let m = 0; m < NUCLEI; m++) if (nPhase[m] === 0) intact++; + if (intact === 0) { flash = 1; cascadeFlash = 1; } + } + function updateFission(dt, t, w) { + // neutrons in flight + for (let i = 0; i < MAX_NEUTRONS; i++) { + const gi = GL_NEUT0 + i, ci = CAP_NEUT0 + i; + if (!neutAlive[i]) { glows.s[gi * 4 + 1] = 0; capS[ci * 4 + 1] = 0; continue; } + neutT[i] += dt / neutDur[i]; + const u = Math.min(neutT[i], 1); + const o = i * 3; + const x = neutFrom[o] + (neutTo[o] - neutFrom[o]) * u, y = neutFrom[o + 1] + (neutTo[o + 1] - neutFrom[o + 1]) * u, z = neutFrom[o + 2] + (neutTo[o + 2] - neutFrom[o + 2]) * u; + const dx = neutTo[o] - neutFrom[o], dy = neutTo[o + 1] - neutFrom[o + 1], dz = neutTo[o + 2] - neutFrom[o + 2]; + const dl = Math.sqrt(dx * dx + dy * dy + dz * dz) + 1e-6; + sph(glows, gi, x, y, z, 0.17, w, 4.8, 1); + cap(ci, x - (dx / dl) * 0.9, y - (dy / dl) * 0.9, z - (dz / dl) * 0.9, x, y, z, 2.0, w * 0.7, 4.6, 1); + if (neutT[i] >= 1) { + neutAlive[i] = 0; + const tgt = neutTarget[i]; + if (tgt >= 0 && (nPhase[tgt] === 0 || nPhase[tgt] === 4)) exciteNucleus(tgt); + } + } + // gamma shells + for (let k = 0; k < 4; k++) { + if (gAge[k] < 0) { gammaU[k * 4 + 3] = 0; WU.uGammaA.value[k] = 0; continue; } + gAge[k] += dt; + gammaU[k * 4 + 3] = 0.6 + gAge[k] * 9; + WU.uGammaA.value[k] = Math.exp(-gAge[k] / 0.45) * 1.2; + if (gAge[k] > 1.3) { gAge[k] = -1; gammaU[k * 4 + 3] = 0; } + } + // the nuclei + const rad = 0.23 * (1 - 0.3 * pressS); + const squeeze = 1 - 0.3 * pressS; + const jitAmp = 0.035 * (1 + swayS * 1.2 + pressS * 1.5); + const jitRate = 7 + pressS * 6; + for (let n = 0; n < NUCLEI; n++) { + const ph = nPhase[n]; + let e = 0, sep = 0, alpha = 1, grow = 1, glow = 1; + if (ph === 1) { + nT[n] += dt; + e = smooth01(nT[n] / 0.9) * (1 + 0.5 * swayS); + glow = 1 + e; + if (nT[n] >= 0.95) scission(n); + } else if (ph === 2) { + nT[n] += dt; + sep = 0.45 + (2.5 + yieldS * 2.5) * nT[n]; + nHeat[n] = Math.exp(-nT[n] / 1.2); + alpha = clamp((3.0 - nT[n]) / 0.8, 0, 1); + glow = 0.2 + 2 * nHeat[n]; + if (nT[n] >= 3.0) { nPhase[n] = 3; nT[n] = 0; } + } else if (ph === 3) { + nT[n] += dt; + const r = smooth01(nT[n] / 1.5); + alpha = r; grow = 1 + 2 * (1 - r); glow = r; + if (nT[n] >= 1.5) nPhase[n] = 0; + } + const cx = latPos[n * 3], cy = latPos[n * 3 + 1], cz = latPos[n * 3 + 2]; + const fog = n === 0 ? 1 : 0.78; + const ux = nAxis[n * 3], uy = nAxis[n * 3 + 1], uz = nAxis[n * 3 + 2]; + nucU[n * 4] = cx; nucU[n * 4 + 1] = cy; nucU[n * 4 + 2] = cz; nucU[n * 4 + 3] = glow * w; + for (let i = 0; i < NUCLEONS; i++) { + const b = i * 3; + let x = nucBase[b] * squeeze * grow, y = nucBase[b + 1] * squeeze * grow, z = nucBase[b + 2] * squeeze * grow; + const ja = jitAmp * (ph === 1 ? 1 + e : 1); + x += ja * Math.sin(t * jitRate + nucPh[b]); + y += ja * Math.sin(t * (jitRate + 1.3) + nucPh[b + 1]); + z += ja * Math.sin(t * (jitRate + 2.1) + nucPh[b + 2]); + if (ph === 1 || ph === 2) { + const d = x * ux + y * uy + z * uz - nOff[n]; + if (ph === 1) { + // the liquid drop: elongate along the axis, neck at the plane, wobble + const wob = 0.12 * e * Math.sin(t * 14 + nucPh[b]); + const stretch = e * 0.9 + wob; + const neck = e * 0.45 * Math.exp(-d * d / 0.12); + x += ux * d * stretch - (x - ux * (d + nOff[n])) * neck; + y += uy * d * stretch - (y - uy * (d + nOff[n])) * neck; + z += uz * d * stretch - (z - uz * (d + nOff[n])) * neck; + } else { + const side = d > 0 ? 1 : -1; + x += ux * side * sep; y += uy * side * sep; z += uz * side * sep; + } + } + sph(solids, n * NUCLEONS + i, cx + x, cy + y, cz + z, rad, alpha * fog * w, nucType[i] ? 2.0 : 1.0, nHeat[n]); + } + } + } + + // --- detonation ------------------------------------------------------------------------------ + function updateDetonation(dt) { + cloudAge = Math.min(cloudAge + dt, 60); + WU.uCloudAge.value = cloudAge; + WU.uCloudScale.value = 0.75 + yieldS * 1.1; + WU.uCloudMorph.value = swayS; + WU.uCloudPress.value = pressS; + } + + // --- the shockwave --------------------------------------------------------------------------- + // mirrors the shader's lampOn for the heads and posts + const lampOn = (lz, a, rs) => { + if (a < 0) return 1; + const d = Math.sqrt(LAMP_X * LAMP_X + LAMP_H * LAMP_H + (lz - GZ_Z) * (lz - GZ_Z)); + const passed = d <= rs ? 1 : 0; + const flare = passed * Math.exp(-(rs - d) / (BLAST_V * 0.12)) * 3; + const relight = smooth01((a - 7) * 0.7 - -lz / 120); + return Math.max(1 - passed + flare, relight); + }; + function updateShockwave(dt, t, w) { + const v = BLAST_V * (0.7 + yieldS * 0.6); + let rs = 0, fire = 0; + if (blastAge >= 0) { + blastAge += dt; + rs = v * blastAge; + fire = 3 * Math.exp(-blastAge / 2) + 0.3 * Math.exp(-blastAge / 10); + const dCam = Math.sqrt(camPos.x * camPos.x + camPos.y * camPos.y + (camPos.z - GZ_Z) ** 2); + if (passAge < 0 && rs >= dCam) { passAge = blastAge; shake = 1; emberA = 1; } + if (blastAge > 10) { blastAge = -1; passAge = -1; } + } + const sincePass = passAge >= 0 && blastAge >= 0 ? blastAge - passAge : -1; + const veiling = sincePass >= 0 && sincePass < 2.5; + veil = approach(veil, veiling ? 1 : 0, veiling ? 0.25 : 0.8, dt); + shake = Math.max(0, shake - dt * 1.4); + if (blastAge < 0 || blastAge > 8.5) emberA = approach(emberA, 0, 0.6, dt); + WU.uBlastAge.value = blastAge; + WU.uBlastR.value = rs; + WU.uBlastMorph.value = swayS; + WU.uBlastFire.value = fire; + WU.uVeil.value = veil; + WU.uDustH.value = 6 + 10 * swayS + 4 * yieldS; + for (let k = 0; k < LAMPS; k++) { + const lz = LAMP_Z0 - LAMP_DZ * k; + const on = lampOn(lz, blastAge, rs); + for (let s = 0; s < 2; s++) { + const lx = s ? LAMP_X : -LAMP_X; + const i = k * 2 + s; + cap(CAP_LAMP0 + i, lx, 0, lz, lx, LAMP_H, lz, 1.2, w * 0.25, 4.0, 0); + sph(glows, GL_LAMP0 + i, lx, LAMP_H, lz, 0.7, w * Math.min(on, 3) * 0.9, 0.4, 0.3); + } + } + for (let i = 0; i < EMBERS; i++) { + if (emberA > 0.01) { + embY[i] -= embV[i] * dt * 1.5; + embX[i] += Math.sin(t * 1.3 + i) * dt * 0.6; + if (embY[i] < 0) { embY[i] = 8 + Math.random() * 6; embX[i] = camPos.x + (Math.random() - 0.5) * 24; embZ[i] = camPos.z - Math.random() * 40; } + } + sph(glows, GL_EMBER0 + i, embX[i], embY[i], embZ[i], 0.08 + 0.05 * (i % 3), w * emberA * (0.5 + 0.5 * Math.sin(t * 6 + i)), 1.0, 0.6); + } + } + + const bloom = { strength: 0.45, radius: 0.45, threshold: 0.55 }; + + return { + scene, + camera, + bloom, + update(dt, t, io) { + // ---- the act: KNOB 6 in quarter turns, a little hysteresis at the edges + const k6 = io.knobs[5]; + const band = Math.min(3, Math.floor(k6 * 4)); + if (band !== act) { + const edge = band > act ? band / 4 : (band + 1) / 4; + if (Math.abs(k6 - edge) > 0.012) act = band; + } + for (let i = 0; i < ACTS; i++) actW[i] = approach(actW[i], i === act ? 1 : 0, ACT_FADE / 3, dt); + yieldS = approach(yieldS, io.knobs[4], 0.3, dt); + swayS = approach(swayS, io.gestures.sway, 0.4, dt); + pressS = approach(pressS, io.gestures.press, 0.15, dt); + hx = approach(hx, io.xy.x, 0.3, dt); + hy = approach(hy, io.xy.y, 0.3, dt); + bass = approach(bass, io.bands.bass, 0.12, dt); + high = approach(high, io.bands.high, 0.1, dt); + if (io.beat > beatPrev + 0.3) pulse = 1; + beatPrev = io.beat; + pulse = Math.max(0, pulse - dt * 3.5); + + // ---- the event: any strike fires the act in force + const struck = io.strike > strikePrev + 0.3; + strikePrev = io.strike; + if (struck) { + if (act === 0) fireCollision(); + else if (act === 1) fireFission(); + else if (act === 2) { cloudAge = 0; flash = 1; WU.uSkySeed.value = Math.random() * 10; } + else { blastAge = 0; passAge = -1; flash = 1; } + } + flash = Math.max(0, flash - dt * 2.2); + cascadeFlash = Math.max(0, cascadeFlash - dt * 4.0); + + // ---- the eye: the act in force places it, the hand drives it, the move eases + if (act <= 1) { + const dist0 = act === 0 ? 9 - pressS * 4.5 : 11 - pressS * 4; + const az = (hx - 0.5) * 2.4, el = 0.15 + (hy - 0.5) * 0.9; + wantPos.set(Math.sin(az) * Math.cos(el) * dist0, Math.sin(el) * dist0, Math.cos(az) * Math.cos(el) * dist0); + wantTarget.set(0, 0, 0); + } else if (act === 2) { + wantPos.set((hx - 0.5) * 16, 2.5 + hy * 7, 0); + wantTarget.set(0, 6.0 * (0.75 + yieldS * 1.1), -40); + } else { + wantPos.set((hx - 0.5) * 6, 1.7 + (hy - 0.5) * 1.6 - pressS * 1.1, 0); + wantTarget.set(wantPos.x * 0.3, 1.2 - pressS * 0.6, -60); + } + camPos.x = approach(camPos.x, wantPos.x, 0.35, dt); + camPos.y = approach(camPos.y, wantPos.y, 0.35, dt); + camPos.z = approach(camPos.z, wantPos.z, 0.35, dt); + camTarget.x = approach(camTarget.x, wantTarget.x, 0.35, dt); + camTarget.y = approach(camTarget.y, wantTarget.y, 0.35, dt); + camTarget.z = approach(camTarget.z, wantTarget.z, 0.35, dt); + camera.position.copy(camPos); + if (shake > 0) { + const s = shake * shake * 0.35; + camera.position.x += (Math.random() - 0.5) * s; + camera.position.y += (Math.random() - 0.5) * s; + } + camera.lookAt(camTarget); + camera.updateMatrixWorld(); + const e = camera.matrixWorld.elements; + WU.uCamPos.value.copy(camera.position); + WU.uCamRight.value.set(e[0], e[1], e[2]); + WU.uCamUp.value.set(e[4], e[5], e[6]); + WU.uCamFwd.value.set(-e[8], -e[9], -e[10]); + + // ---- the acts (every act keeps running; only weighted ones show) + updateCollider(dt, t, actW[0]); + updateFission(dt, t, actW[1]); + updateDetonation(dt); + updateShockwave(dt, t, actW[3]); + + // ---- uniforms and buffers + const pl = io.palette; + for (let i = 0; i < 5; i++) { wp[i].value.copy(pl[i]); cp[i].value.copy(pl[i]); sp[i].value.copy(pl[i]); gp[i].value.copy(pl[i]); } + WU.uTime.value = t; + WU.uIntensity.value = io.intensity; + WU.uFlash.value = Math.min(0.97, flash * flash * 0.9 + cascadeFlash * 0.6); + WU.uBass.value = bass; + WU.uBeat.value = pulse; + WU.uHigh.value = high; + WU.uActW.value.set(actW[0], actW[1], actW[2], actW[3]); + WU.uVertexGlow.value = vertexGlow; + WU.uCollFlash.value = collFlash; + CU.uIntensity.value = io.intensity; + solids.U.uIntensity.value = io.intensity; + glows.U.uIntensity.value = io.intensity; + capAP0.needsUpdate = true; capAP1.needsUpdate = true; capAS.needsUpdate = true; + solids.aPos.needsUpdate = true; solids.aS.needsUpdate = true; + glows.aPos.needsUpdate = true; glows.aS.needsUpdate = true; + caps.visible = actW[0] > 0.002 || actW[1] > 0.002 || actW[3] > 0.002; + solids.mesh.visible = actW[1] > 0.002; + glows.mesh.visible = caps.visible; + bloom.strength = 0.45 + flash * 1.2 + collFlash * 0.4 + cascadeFlash * 0.8; + }, + resize(w, h) { + camera.aspect = w / Math.max(1, h); + camera.updateProjectionMatrix(); + WU.uRes.value.set(w, h); + CU.uRes.value.set(w, h); + }, + dispose() { + world.geometry.dispose(); + worldMat.dispose(); + capGeo.dispose(); + capMat.dispose(); + solids.geo.dispose(); + solids.mat.dispose(); + glows.geo.dispose(); + glows.mat.dispose(); + }, + }; +} diff --git a/src/renderer/engine/scenes/naturestomb.js b/src/renderer/engine/scenes/naturestomb.js index 20731e3..1cd1bc9 100644 --- a/src/renderer/engine/scenes/naturestomb.js +++ b/src/renderer/engine/scenes/naturestomb.js @@ -1,36 +1,83 @@ -// Nature's Tomb — an egg cell that divides by mitosis, or un-divides. +// Nature's Tomb — three organisms under dark-field light: an egg cell that +// divides by mitosis (or un-divides), a slime mold foraging across its plate, +// and a mycelium growing out from a spore. One knob picks the organism, one +// knob and the strikes drive its development, sway morphs its generator. // -// THE CELL A zygote inside its zona pellucida, lit dark-field on black: -// a glassy outer shell with a fresnel rim and mild refraction, -// cytoplasm with granular texture, wrap lighting, a subsurface -// glow through the membrane, a wet specular, and a nucleus with -// a nucleolus seen through the cytoplasm (a second short march -// from the membrane hit, attenuated by depth). -// MITOSIS One continuous division LEVEL 0..4: 1 → 2 → 4 → 8 → 16 -// blastomeres. Each stage splits every cell along the next -// axis (X, Y, Z, then a diagonal); centres ease apart while the -// radii shrink (volume conserving, 2^-1/3 per stage) and a -// smooth-min neck forms the cleavage furrow. The level is a -// real number, so a half-finished division is a pinched cell, -// and driving it DOWN un-divides — the cells merge back. -// CONTROL The user chooses the interface: KNOB 5 (io.knobs[4]) sets the -// level directly (0..1 → 0..4) the moment it is moved, and any -// pad STRIKE steps one division further, reversing direction at -// 16 cells (un-divide) and at 1 (divide again). Either wins the -// moment it is used. The level eases toward its target over -// ~1.3 s, so a division plays out rather than snapping. -// GESTURES SWAY is the membrane-tension morph (a noise displacement that -// jiggles and softens the membranes); PRESS squeezes the embryo; -// the hand PANS the embryo (X) and DOLLIES the eye (Y) — -// translation only, nothing rotates; bass swells the -// cytoplasm, the beat pulses it, treble shimmers the granules, -// the level lifts the zona's glow. +// ORGANISM KNOB 6 (io.knobs[5]) picks it: the centre (the 0.5 default) is +// the EGG CELL, turning right dissolves into the SLIME MOLD, +// turning left into the MYCELIUM. The value is smoothed with a +// 0.15 s time constant, so the dissolve runs exactly as fast as +// the knob is turned — a flick cuts, a slow turn cross-fades. +// LEVEL KNOB 5 (io.knobs[4]) sets the development level 0..4 the +// moment it moves, and any pad STRIKE steps it one stage, +// reversing at the top and at the bottom; the level eases over +// ~1.3 s so every stage plays out. Driving it DOWN reverses the +// development. What the level means: +// egg cell the division count 1 → 2 → 4 → 8 → 16 +// blastomeres (each stage along the next axis, +// centres easing apart as radii shrink by 2^-1/3, +// a smooth-min neck as the cleavage furrow); +// driving it down merges the cells back +// slime mold the colony's reach across the plate: the +// plasmodium pours out of its inoculum, forages +// to the rim, finds the oat flakes on the way and +// wires them into its vein network; driving it +// down retracts the colony and the outer veins +// starve and fade +// mycelium the hyphal growth in time: germ tubes leave the +// spore, extend, branch and fill the plate; the +// level scrubs that growth forward and back +// THE EGG A zygote inside its zona pellucida: a glassy outer shell with +// a fresnel rim and mild refraction, cytoplasm with granular +// texture, wrap lighting, a subsurface glow through the +// membrane, a wet specular, and a nucleus with a nucleolus seen +// through the cytoplasm (a second short march from the membrane +// hit, attenuated by depth). An SDF raymarch of up to sixteen +// spheres whose centres and radii the CPU computes per frame. +// SLIME MOLD A Physarum polycephalum plasmodium, simulated rather than +// drawn: the classic agent model (Jones 2010) — a few thousand +// protoplasm agents on a trail map sense the trail ahead at +// three sensors, turn toward the strongest, step, deposit; the +// map diffuses and decays each frame — runs on the CPU and +// uploads the trail as an 8-bit texture; that IS the network +// (thick veins where the flow concentrates, fan-shaped fronts at +// the edge, nodes at the food). The shader lights the trail as +// a glossy yellow plasmodium: height from the trail, a normal +// from its gradient, wrap light, a wet specular, a subsurface +// glow, and a slow pulse running along the veins (the shuttle +// streaming, which the beat drives). Food: nine oat flakes on +// the plate switch on as the colony's reach meets them. +// MYCELIUM Hyphae as one instanced mesh of screen-space capsules: a +// growth simulation (germ tubes from the spore, tip extension +// with persistence and a radial bias, lateral branching, tips +// dying at the plate's rim) lays segments down in time order, +// so the visible prefix of the list is the colony at a point in +// its growth. The simulation is re-run from the same random +// table whenever sway moves, so the network deforms +// continuously instead of re-seeding. The fragment shader draws +// each segment as a glassy tube — bright core, translucent +// walls, septa across it — with the growth front glowing as the +// apices; the plate and the spore come from the quad. +// GESTURES SWAY is each organism's morph: the egg's membrane tension (a +// noise displacement that jiggles and softens the membranes), +// the slime mold's sensing (sensor angle, turn angle and sensor +// reach — a fine lattice becomes a coarse web of thick trunk +// veins, live, the network re-forming as it moves), the +// mycelium's branching angle and tortuosity (a tight radial +// brush opens into a spreading, curling web). PRESS squeezes: +// it flattens the embryo, crowds the colony into a smaller +// reach so the veins thicken, squashes the mycelium. The hand +// PANS the plate (X) and DOLLIES the eye (Y) — translation only, +// nothing rotates. Bass swells the cytoplasm and the veins, the +// beat pulses them, treble shimmers the granules, the level +// lifts the plate's rim glow. // -// One fullscreen quad, one draw call: an SDF raymarch of up to sixteen -// spheres whose centres and radii the CPU computes per frame into a vec4 -// table (uCells), a nucleus field from the same table, and the zona as an -// analytic sphere. GLSL3. Colour: cytoplasm from palette 3/4 lifted toward -// white, nucleus from palette 1, membrane rim palette 0, zona palette 2. +// Two draw calls: the quad (the egg raymarch, the slime mold's plate, the +// mycelium's plate and spore — whichever organisms have weight, blended by +// it) and the hyphae mesh (only while the mycelium has weight). GLSL3. Colour: +// cytoplasm and plasmodium from palette 3/4 lifted toward white, nucleus from +// palette 1, membrane rim and fans palette 0, the plate and hyphae from +// palette 2 lifted toward white, the spore from palette 1/0. export const meta = { id: 'naturestomb', name: "Nature's Tomb", mood: 'cellular' }; @@ -38,12 +85,18 @@ const MAX_CELLS = 16; const R0 = 1.08; // zygote radius const ZONA = 1.3; // zona pellucida radius (fixed — the embryo compacts inside it) const SEP = 0.6; // centre separation per stage, as a fraction of the child radius +const DISH = 1.5; // the plate's radius (world units) for the slime mold and the mycelium const AXES = [ [1, 0, 0], [0, 1, 0], [0, 0, 1], [0.57735, 0.57735, -0.57735], ]; +const FOOD_N = 9; // oat flakes on the plate +const MAX_TIPS = 480; // simultaneous hyphal tips +const MYC_STEP = 0.031; // hyphal extension per growth step (world units) +const MYC_STEPS = 96; // growth steps the scrub covers (the rim stops most tips before) +const RND_LEN = 65536; // the mycelium's random table (power of two) const GLSL = /* glsl */ ` #define PI 3.14159265359 @@ -53,6 +106,10 @@ const GLSL = /* glsl */ ` uniform float uTime, uDist, uZona, uJig, uPress, uBeat, uBass, uHigh, uLevelA, uLevel, uIntensity; uniform vec2 uPan; uniform vec3 uPal0, uPal1, uPal2, uPal3, uPal4; + uniform vec3 uOrg; // organism weights: x egg cell, y slime mold, z mycelium + uniform sampler2D uTrail; // the plasmodium's trail map, 0..1 + uniform float uTexel; // one trail texel in uv + uniform float uDish, uFlow; in vec2 vUv; out vec4 fragColor; @@ -108,13 +165,9 @@ const GLSL = /* glsl */ ` mapCells(p + e.yyx) - mapCells(p - e.yyx))); } - void main() { - vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0); - vec3 ro = vec3(0.0, 0.0, uDist); - vec3 rd = normalize(vec3(uv * 1.05, -1.0)); - vec3 E = vec3(uPan.x, uPan.y, 0.0); // the embryo, panned by the hand + // ---- the egg cell: zona, blastomeres, nuclei + vec3 eggCell(vec3 ro, vec3 rd, vec3 E) { vec3 col = vec3(0.0); - // dark-field halo outside the zona float dC = length(cross(ro - E, rd)); vec3 zonaTint = mix(uPal2, vec3(1.0), 0.6); @@ -180,10 +233,169 @@ const GLSL = /* glsl */ ` col += inner * (0.92 - 0.25 * fz) + zonaTint * (fz * 0.55 + 0.035) * (0.6 + 0.4 * uLevelA) + zonaTint * ringZ * 0.22; col *= smoothstep(0.0, max(fwidth(ez) * 1.5, 0.0001), ez); } + return col; + } + + // ---- the plate: a dark-field disc with a thin bright rim and a halo outside + vec3 plate(float r) { + vec3 tint = mix(uPal2, vec3(1.0), 0.5); + float inside = 1.0 - smoothstep(uDish * 0.985, uDish, r); + float rim = exp(-pow((r - uDish * 0.992) / (uDish * 0.012), 2.0)); + float halo = exp(-(r - uDish) * 6.0) * step(uDish, r); + return tint * (0.012 * inside + rim * 0.26 + halo * 0.04) * (0.6 + 0.4 * uLevelA); + } + + // ---- the slime mold: the trail map lit as a plasmodium on its plate + vec3 slimeMold(vec3 ro, vec3 rd, vec3 E) { + vec3 col = vec3(0.0); + float tp = (E.z - ro.z) / rd.z; + if (tp <= 0.0) return col; + vec2 q = (ro + rd * tp).xy - E.xy; + float r = length(q); + col += plate(r); + float inside = 1.0 - smoothstep(uDish * 0.985, uDish, r); + if (inside <= 0.0) return col; + vec2 uvT = q / (2.0 * uDish) + 0.5; + float tr = texture(uTrail, uvT).r; + float tx = texture(uTrail, uvT + vec2(uTexel, 0.0)).r - texture(uTrail, uvT - vec2(uTexel, 0.0)).r; + float ty = texture(uTrail, uvT + vec2(0.0, uTexel)).r - texture(uTrail, uvT - vec2(0.0, uTexel)).r; + // fans where the trail is faint, veins where the flow concentrates, a + // brighter ridge along the thickest flow + float body = smoothstep(0.025, 0.2, tr); + float vein = pow(smoothstep(0.14, 0.8, tr), 1.3); + float ridge = smoothstep(0.6, 0.98, tr); + vec3 n = normalize(vec3(-tx * 16.0, -ty * 16.0, 1.0)); + vec3 L = normalize(vec3(-0.5, 0.65, 0.6)); + float wrap = dot(n, L) * 0.5 + 0.5; + float spec = pow(max(dot(reflect(-L, n), -rd), 0.0), 48.0); + // granular protoplasm; the shuttle streaming pulses along the veins + float gr = noise3(vec3(q * 26.0, uTime * 0.08)); + float stream = 0.82 + 0.18 * sin(uFlow - tr * 7.0 + gr * 2.5); + vec3 cyto = mix(mix(uPal3, uPal4, 0.2), vec3(1.0), 0.22); + vec3 fan = mix(uPal3, uPal0, 0.55); + vec3 plasm = fan * body * (0.16 + 0.24 * wrap) * (0.8 + 0.3 * gr) + + cyto * vein * (0.3 + 0.55 * wrap * wrap) * stream * (0.9 + 0.2 * gr) * (1.0 + uBass * 0.3) + + mix(cyto, vec3(1.0), 0.4) * ridge * 0.35 * stream + + vec3(1.0) * spec * vein * 0.35 + + mix(uPal4, uPal3, 0.5) * vein * 0.14 * (1.0 + uBeat * 0.9); + plasm *= 1.0 + uHigh * 0.25 * gr; + col += plasm * inside; + return col; + } + + // ---- the mycelium's plate and spore (the hyphae are the second draw call) + vec3 mycPlate(vec3 ro, vec3 rd, vec3 E) { + vec3 col = vec3(0.0); + float tp = (E.z - ro.z) / rd.z; + if (tp > 0.0) col += plate(length((ro + rd * tp).xy - E.xy)); + vec3 sporeTint = mix(uPal1, uPal0, 0.4); + float dC = length(cross(ro - E, rd)); + col += sporeTint * 0.3 * exp(-dC * 9.0) * (1.0 + uBeat * 0.4); + float e; + float ts = iSphere(ro, rd, E, 0.075, e); + if (ts > 0.0) { + vec3 n = normalize(ro + rd * ts - E); + vec3 L = normalize(vec3(-0.5, 0.65, 0.6)); + float lit = max(dot(n, L), 0.0); + float rim = pow(1.0 - max(dot(n, -rd), 0.0), 2.5); + vec3 sc = sporeTint * (0.3 + 0.7 * lit) * (0.9 + 0.2 * noise3(n * 12.0)) + vec3(rim * 0.45); + col = mix(col, sc, smoothstep(0.0, max(fwidth(e) * 1.5, 0.0001), e)); + } + return col; + } + + void main() { + vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0); + vec3 ro = vec3(0.0, 0.0, uDist); + vec3 rd = normalize(vec3(uv * 1.05, -1.0)); + vec3 E = vec3(uPan.x, uPan.y, 0.0); // the specimen, panned by the hand + vec3 col = vec3(0.0); + if (uOrg.x > 0.002) col += eggCell(ro, rd, E) * uOrg.x; + if (uOrg.y > 0.002) col += slimeMold(ro, rd, E) * uOrg.y; + if (uOrg.z > 0.002) col += mycPlate(ro, rd, E) * uOrg.z; fragColor = vec4(col * uIntensity, 1.0); } `; +// ------------------------------------------------------------- the hyphae +// Screen-space capsules from aP0 to aP1 (plate-local, the same pinhole as the +// quad: a point at depth d = uDist − z lands at uv = xy / (1.05 d)), so the +// tubes sit exactly on the plate the quad draws. +const MYC_VERT = /* glsl */ ` + uniform vec2 uRes, uPan; + uniform float uDist, uPress, uBass, uCount, uTotal, uWeight; + in vec2 aQuad; // per vertex: side -1..1, along 0 (start) .. 1 (end) + in vec3 aP0; + in vec3 aP1; + in vec4 aInfo; // generation, tip flag, random, growth step + out vec2 vQ; + out float vLenR, vTip, vA, vGen, vRnd; + vec2 toUv(vec3 p) { + p.y /= 1.0 + uPress * 0.22; // press squashes the colony like the embryo + float d = max(uDist - p.z, 0.3); + return (p.xy + uPan) / (1.05 * d); + } + void main() { + float id = float(gl_InstanceID); + float vis = step(id + 0.5, uCount); + vec2 s0 = toUv(aP0); + vec2 s1 = toUv(aP1); + float aspect = uRes.x / uRes.y; + vec2 d = s1 - s0; + float len = length(d); + vec2 dir = len > 0.00001 ? d / len : vec2(1.0, 0.0); + vec2 nrm = vec2(-dir.y, dir.x); + float depth = uDist - 0.5 * (aP0.z + aP1.z); + float pxH = 1.0 / uRes.y; + // radius in height units: thicker main hyphae, thinner branches, bass swells + float radPx = (2.7 * pow(0.86, aInfo.x)) * (3.4 / depth) * (1.0 + uBass * 0.3); + float rad = max(radPx, 0.9) * pxH; + vLenR = len / rad; + // segments butt flat against each other (no additive doubling at the + // joints); only a hypha's last segment — its apex — gets a round cap + float cap = aInfo.y; + vec2 pos = mix(s0, s1 + dir * rad * cap, aQuad.y) + nrm * aQuad.x * rad; + vec2 ndc = pos / vec2(aspect, 1.0) * 2.0; + gl_Position = vec4(ndc * vis, vis > 0.5 ? 0.0 : 2.0, 1.0); + vQ = vec2(aQuad.x, aQuad.y * (vLenR + cap)); + // the growth front glows as the apices; once grown, the true tips do + float front = smoothstep(uCount - 70.0, uCount, id); + vTip = max(front, aInfo.y * smoothstep(uTotal * 0.97, uTotal, uCount)); + vGen = aInfo.x; + vRnd = aInfo.z; + vA = uWeight * vis * (0.55 + 0.25 * smoothstep(4.6, 2.6, depth)) * pow(0.9, aInfo.x); + } +`; + +const MYC_FRAG = /* glsl */ ` + uniform vec3 uPal0, uPal1, uPal2; + uniform float uIntensity, uBeat, uHigh, uTime; + in vec2 vQ; + in float vLenR, vTip, vA, vGen, vRnd; + out vec4 fragColor; + void main() { + float along = vQ.y; // 0 at the start, vLenR at the end, beyond it only on a capped apex + float u = clamp(along, 0.0, vLenR); + float dx = along - u; + float d2 = dx * dx + vQ.x * vQ.x; + if (d2 > 1.0) discard; + // a glassy tube: bright core, translucent body, brighter walls + float core = exp(-d2 * 5.0); + float body = 1.0 - d2; + float wall = smoothstep(0.3, 0.85, d2) * (1.0 - smoothstep(0.85, 1.0, d2)); + // septa: faint cross-walls along the hypha + float s = fract((u + vRnd * 9.0) / 9.0); + float sept = 1.0 - 0.18 * (1.0 - smoothstep(0.0, 0.05, abs(s - 0.5))) * step(0.5, vLenR); + vec3 hyaline = mix(vec3(1.0), mix(uPal2, uPal0, 0.5), 0.45); + vec3 col = hyaline * (0.22 * body + 0.55 * core + 0.4 * wall) * sept; + // the apex: a swollen, brighter tip at the far end + float apex = exp(-((along - vLenR) * (along - vLenR) + vQ.x * vQ.x) * 1.2) * vTip; + col += mix(uPal0, vec3(1.0), 0.5) * apex * (1.1 + uBeat * 0.8); + col *= 1.0 + uHigh * 0.2 * sin(uTime * 6.0 + vRnd * 40.0); + fragColor = vec4(col * vA * uIntensity, 1.0); + } +`; + export function createScene(ctx) { const { THREE, quality } = ctx; const scene = new THREE.Scene(); @@ -191,9 +403,20 @@ export function createScene(ctx) { const tier = quality.tier; const STEPS = tier === 'low' ? 28 : tier === 'high' ? 56 : 40; const NSTEPS = tier === 'low' ? 12 : tier === 'high' ? 20 : 16; + const SW = tier === 'low' ? 192 : tier === 'high' ? 320 : 256; // trail map side + const MYC_SEGS = tier === 'low' ? 6000 : tier === 'high' ? 16000 : 11000; + // --- the quad: egg, plasmodium, plate ---------------------------------------- const cells = new Float32Array(MAX_CELLS * 4); const pal = Array.from({ length: 5 }, () => ({ value: new THREE.Color(1, 1, 1) })); + const SCELLS = SW * SW; + const trailBytes = new Uint8Array(SCELLS); + const trailTex = new THREE.DataTexture(trailBytes, SW, SW, THREE.RedFormat, THREE.UnsignedByteType); + trailTex.minFilter = THREE.LinearFilter; + trailTex.magFilter = THREE.LinearFilter; + trailTex.generateMipmaps = false; + trailTex.unpackAlignment = 1; + trailTex.needsUpdate = true; const U = { uRes: { value: new THREE.Vector2(ctx.width, ctx.height) }, uCells: { value: cells }, @@ -210,6 +433,11 @@ export function createScene(ctx) { uLevel: { value: 0 }, uIntensity: { value: 1 }, uPan: { value: new THREE.Vector2(0, 0) }, + uOrg: { value: new THREE.Vector3(1, 0, 0) }, + uTrail: { value: trailTex }, + uTexel: { value: 1 / SW }, + uDish: { value: DISH }, + uFlow: { value: 0 }, uPal0: pal[0], uPal1: pal[1], uPal2: pal[2], uPal3: pal[3], uPal4: pal[4], }; const mat = new THREE.ShaderMaterial({ @@ -225,14 +453,67 @@ export function createScene(ctx) { }); const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat); quad.frustumCulled = false; + quad.renderOrder = 0; scene.add(quad); + // --- the hyphae: one instanced mesh of capsules -------------------------------- + const mycGeo = new THREE.InstancedBufferGeometry(); + mycGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, 0, 0, 1, 0, 0, -1, 1, 0, 1, 1, 0]), 3)); + mycGeo.setAttribute('aQuad', new THREE.BufferAttribute(new Float32Array([-1, 0, 1, 0, -1, 1, 1, 1]), 2)); + mycGeo.setIndex([0, 1, 2, 2, 1, 3]); + const mycP0 = new Float32Array(MYC_SEGS * 3); + const mycP1 = new Float32Array(MYC_SEGS * 3); + const mycInfo = new Float32Array(MYC_SEGS * 4); + const attrP0 = new THREE.InstancedBufferAttribute(mycP0, 3); + const attrP1 = new THREE.InstancedBufferAttribute(mycP1, 3); + const attrInfo = new THREE.InstancedBufferAttribute(mycInfo, 4); + attrP0.setUsage(THREE.DynamicDrawUsage); + attrP1.setUsage(THREE.DynamicDrawUsage); + attrInfo.setUsage(THREE.DynamicDrawUsage); + mycGeo.setAttribute('aP0', attrP0); + mycGeo.setAttribute('aP1', attrP1); + mycGeo.setAttribute('aInfo', attrInfo); + mycGeo.instanceCount = 1; + const mpal = Array.from({ length: 3 }, () => ({ value: new THREE.Color(1, 1, 1) })); + const MU = { + uRes: { value: new THREE.Vector2(ctx.width, ctx.height) }, + uPan: { value: new THREE.Vector2(0, 0) }, + uDist: { value: 3.4 }, + uPress: { value: 0 }, + uBass: { value: 0 }, + uCount: { value: 0 }, + uTotal: { value: 1 }, + uWeight: { value: 0 }, + uIntensity: { value: 1 }, + uBeat: { value: 0 }, + uHigh: { value: 0 }, + uTime: { value: 0 }, + uPal0: mpal[0], uPal1: mpal[1], uPal2: mpal[2], + }; + const mycMat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, + uniforms: MU, + vertexShader: MYC_VERT, + fragmentShader: MYC_FRAG, + transparent: true, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, // the capsule's winding follows the segment's direction + blending: THREE.AdditiveBlending, + }); + const hyphae = new THREE.Mesh(mycGeo, mycMat); + hyphae.frustumCulled = false; + hyphae.renderOrder = 1; + hyphae.visible = false; + scene.add(hyphae); + const approach = (v, to, tau, dt) => v + (to - v) * (1 - Math.exp(-dt / tau)); const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); const smooth01 = (u) => { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); }; - // cell table for a continuous level: every cell i takes the side bit j of - // its index along axis j for each completed or in-progress stage + // --- the egg: cell table for a continuous level ------------------------------- + // every cell i takes the side bit j of its index along axis j for each + // completed or in-progress stage function computeCells(L) { let count = 1; for (let j = 0; j < 4; j++) if (L - j > 1e-4) count *= 2; @@ -255,15 +536,252 @@ export function createScene(ctx) { return count; } + // --- the slime mold: Physarum agents on a trail map ----------------------------- + // Jones's model: each agent senses the trail at three sensors ahead, turns + // toward the strongest, steps, deposits; the map is box-blurred and decays. + // The colony's reach is a disc the agents turn back from; the population + // tracks the disc's area, newcomers pouring out of the inoculum at the centre. + const SAGENTS = Math.floor(SCELLS * 0.14); + const trail = new Float32Array(SCELLS); + const trail2 = new Float32Array(SCELLS); + const occ = new Uint8Array(SCELLS); // one agent per cell — the exclusion that keeps veins thin + const agX = new Float32Array(SAGENTS); + const agY = new Float32Array(SAGENTS); + const agH = new Float32Array(SAGENTS); + let agActive = 0; // agents stepping this frame (the first agActive) + let agSpawned = 0; // agents that have ever been placed + let spawnR = 0; // the reach the last placement filled out to + const foodX = new Float32Array(FOOD_N); + const foodY = new Float32Array(FOOD_N); + for (let k = 0; k < FOOD_N; k++) { + const a = k * 2.39996 + 0.7; + const r = (SW * 0.5 - 3) * (0.3 + 0.62 * ((k + 1) / FOOD_N)); + foodX[k] = SW * 0.5 + Math.cos(a) * r; + foodY[k] = SW * 0.5 + Math.sin(a) * r; + } + const TRAIL_MAX = 26; + const DEP = 5; + const DECAY = 0.905; + const BYTE_K = 255 / TRAIL_MAX; + + function slimeStep(swayV, pressV, devel) { + const W = SW; + const SA = 0.39 + swayV * 0.75; + const RA = 0.45 + swayV * 0.5; + const SD = (4.0 + swayV * 9.0) * (W / 256); + const SS = 1.0 * (W / 256); + const cx = W * 0.5, cy = W * 0.5; + const Rmax = W * 0.5 - 3; + const R = Rmax * (0.14 + 0.86 * devel) * (1 - 0.42 * pressV); + const R2 = R * R; + const frac = (R / Rmax) * (R / Rmax) * 1.15 + 0.02; + const nAct = Math.floor(SAGENTS * (frac > 1 ? 1 : frac)); + // newcomers fill the ground the reach has just gained, uniformly by area + // (a spread population is what makes the model wire a network rather + // than pile into one slug); re-activated agents keep their old places + if (nAct > agSpawned) { + const r0sq = spawnR * spawnR, r1sq = R * R; + for (let i = agSpawned; i < nAct; i++) { + const a = Math.random() * 6.2831853; + const rr = Math.sqrt(r0sq + (r1sq - r0sq) * Math.random()); + agX[i] = cx + Math.cos(a) * rr; + agY[i] = cy + Math.sin(a) * rr; + agH[i] = Math.random() * 6.2831853; + occ[(agY[i] | 0) * W + (agX[i] | 0)] = 1; + } + agSpawned = nAct; + spawnR = R; + } + agActive = nAct; + const last = W - 1; + for (let i = 0; i < nAct; i++) { + let x = agX[i], y = agY[i], h = agH[i]; + // sense + let sx = x + Math.cos(h) * SD, sy = y + Math.sin(h) * SD; + const f = trail[(sy < 0 ? 0 : sy > last ? last : sy | 0) * W + (sx < 0 ? 0 : sx > last ? last : sx | 0)]; + sx = x + Math.cos(h + SA) * SD; sy = y + Math.sin(h + SA) * SD; + const l = trail[(sy < 0 ? 0 : sy > last ? last : sy | 0) * W + (sx < 0 ? 0 : sx > last ? last : sx | 0)]; + sx = x + Math.cos(h - SA) * SD; sy = y + Math.sin(h - SA) * SD; + const r = trail[(sy < 0 ? 0 : sy > last ? last : sy | 0) * W + (sx < 0 ? 0 : sx > last ? last : sx | 0)]; + // steer + if (f > l && f > r) { + // straight on + } else if (f < l && f < r) { + h += Math.random() < 0.5 ? RA : -RA; + } else if (l > r) { + h += RA; + } else if (r > l) { + h -= RA; + } + // step, turning back at the colony's reach + let nx = x + Math.cos(h) * SS, ny = y + Math.sin(h) * SS; + const dx = nx - cx, dy = ny - cy; + if (dx * dx + dy * dy > R2) { + h = Math.atan2(cy - y, cx - x) + (Math.random() - 0.5) * 1.6; + nx = x + Math.cos(h) * SS; ny = y + Math.sin(h) * SS; + } + // one agent per cell: a taken cell blocks the move and turns the agent + const from = (y | 0) * W + (x | 0); + const to = (ny | 0) * W + (nx | 0); + if (to !== from) { + if (occ[to]) { + agH[i] = Math.random() * 6.2831853; + trail[from] += DEP; + continue; + } + occ[from] = 0; + occ[to] = 1; + } + agX[i] = nx; agY[i] = ny; agH[i] = h; + trail[to] += DEP; + } + // food within reach keeps attracting: a soft mound of attractant the + // veins find from a distance and anchor to + const Rf = R - 4; + for (let k = 0; k < FOOD_N; k++) { + const fx = foodX[k], fy = foodY[k]; + const ddx = fx - cx, ddy = fy - cy; + if (ddx * ddx + ddy * ddy > Rf * Rf) continue; + const ix = fx | 0, iy = fy | 0; + for (let oy = -5; oy <= 5; oy++) { + for (let ox = -5; ox <= 5; ox++) { + const d2 = ox * ox + oy * oy; + if (d2 > 25) continue; + trail[(iy + oy) * W + ix + ox] += 2.4 * Math.exp(-d2 * 0.12); + } + } + } + // diffuse (3 × 3 box, separable), decay, and pack the bytes + for (let y = 0; y < W; y++) { + const b = y * W; + trail2[b] = trail[b] * 2 + trail[b + 1]; + for (let x = 1; x < last; x++) trail2[b + x] = trail[b + x - 1] + trail[b + x] + trail[b + x + 1]; + trail2[b + last] = trail[b + last - 1] + trail[b + last] * 2; + } + const k9 = DECAY / 9; + for (let y = 0; y < W; y++) { + const b = y * W; + const bu = (y === 0 ? 0 : y - 1) * W; + const bd = (y === last ? last : y + 1) * W; + for (let x = 0; x < W; x++) { + const v = (trail2[bu + x] + trail2[b + x] + trail2[bd + x]) * k9; + trail[b + x] = v; + trailBytes[b + x] = v >= TRAIL_MAX ? 255 : (v * BYTE_K) | 0; + } + } + trailTex.needsUpdate = true; + } + + // --- the mycelium: hyphal growth in time order ---------------------------------- + // Germ tubes leave the spore; every step each live tip extends by MYC_STEP + // with heading persistence, a radial bias (hyphae grow away from the colony) + // and noise; it branches laterally at the branching angle; it dies at the + // plate's rim. Segments are appended in (step, tip) order, so the first N + // are the colony N segments into its growth. The same random table is read + // in the same order every run, so changing the angle or the tortuosity + // deforms the network instead of re-seeding it. + const RND = new Float32Array(RND_LEN); + for (let i = 0; i < RND_LEN; i++) RND[i] = Math.random(); + const tipX = new Float32Array(MAX_TIPS), tipY = new Float32Array(MAX_TIPS), tipZ = new Float32Array(MAX_TIPS); + const tipHx = new Float32Array(MAX_TIPS), tipHy = new Float32Array(MAX_TIPS), tipHz = new Float32Array(MAX_TIPS); + const tipGen = new Uint8Array(MAX_TIPS); + const tipAlive = new Uint8Array(MAX_TIPS); + const tipGap = new Uint16Array(MAX_TIPS); + const tipSeg = new Int32Array(MAX_TIPS); + const stepStart = new Int32Array(MYC_STEPS + 2); + let mycTotal = 0; + let mycSteps = 0; + + function growMycelium(branchAngle, tort) { + let rc = 0; + let nTips = 0; + let seg = 0; + const GERM = 8; + for (let k = 0; k < GERM; k++) { + const a = (k / GERM) * 6.2831853 + RND[rc++] * 0.6; + tipX[k] = Math.cos(a) * 0.06; tipY[k] = Math.sin(a) * 0.06; tipZ[k] = (RND[rc++] - 0.5) * 0.05; + tipHx[k] = Math.cos(a); tipHy[k] = Math.sin(a); tipHz[k] = (RND[rc++] - 0.5) * 0.3; + tipGen[k] = 0; tipAlive[k] = 1; tipGap[k] = 0; tipSeg[k] = -1; + } + nTips = GERM; + const rim = DISH * 0.965; + let step = 0; + for (; step < MYC_STEPS && seg < MYC_SEGS; step++) { + stepStart[step] = seg; + let live = 0; + const nNow = nTips; + for (let i = 0; i < nNow && seg < MYC_SEGS; i++) { + // three draws per tip-step, always, so the branch decisions hold across morphs + const r0 = RND[rc++ & (RND_LEN - 1)], r1 = RND[rc++ & (RND_LEN - 1)], r2 = RND[rc++ & (RND_LEN - 1)]; + if (!tipAlive[i]) continue; + live++; + let x = tipX[i], y = tipY[i], z = tipZ[i]; + let hx = tipHx[i], hy = tipHy[i], hz = tipHz[i]; + // heading: noise, radial bias, the slab's flatness + const c = Math.cos((r0 - 0.5) * tort), s = Math.sin((r0 - 0.5) * tort); + const nhx = hx * c - hy * s, nhy = hx * s + hy * c; + const rl = Math.sqrt(x * x + y * y) + 1e-5; + hx = nhx + (x / rl) * 0.07; hy = nhy + (y / rl) * 0.07; + hz = hz * 0.7 + (r1 - 0.5) * 0.12 - z * 0.4; + const hl = Math.sqrt(hx * hx + hy * hy + hz * hz); + hx /= hl; hy /= hl; hz /= hl; + const nx = x + hx * MYC_STEP, ny = y + hy * MYC_STEP, nz = z + hz * MYC_STEP; + // lay the segment + const o3 = seg * 3, o4 = seg * 4; + mycP0[o3] = x; mycP0[o3 + 1] = y; mycP0[o3 + 2] = z; + mycP1[o3] = nx; mycP1[o3 + 1] = ny; mycP1[o3 + 2] = nz; + mycInfo[o4] = tipGen[i]; mycInfo[o4 + 2] = r2; mycInfo[o4 + 3] = step; + if (tipSeg[i] >= 0) mycInfo[tipSeg[i] * 4 + 1] = 0; + tipSeg[i] = seg; + mycInfo[o4 + 1] = 1; // the newest segment of a hypha is its tip until it extends + seg++; + tipX[i] = nx; tipY[i] = ny; tipZ[i] = nz; + tipHx[i] = hx; tipHy[i] = hy; tipHz[i] = hz; + tipGap[i]++; + // the rim stops the tip + if (nx * nx + ny * ny > rim * rim) { tipAlive[i] = 0; continue; } + // lateral branching + if (tipGap[i] >= 3 && r2 < 0.15 && nTips < MAX_TIPS && tipGen[i] < 8) { + const side = r1 < 0.5 ? 1 : -1; + const ca = Math.cos(branchAngle * side), sa = Math.sin(branchAngle * side); + const j = nTips++; + tipX[j] = nx; tipY[j] = ny; tipZ[j] = nz; + tipHx[j] = hx * ca - hy * sa; tipHy[j] = hx * sa + hy * ca; tipHz[j] = hz * 0.5 + (r0 - 0.5) * 0.2; + tipGen[j] = tipGen[i] + 1; tipAlive[j] = 1; tipGap[j] = 0; tipSeg[j] = -1; + tipGap[i] = 0; + } + } + if (!live) break; + } + mycSteps = Math.max(1, step); + stepStart[step] = seg; + mycTotal = seg; + attrP0.needsUpdate = true; + attrP1.needsUpdate = true; + attrInfo.needsUpdate = true; + MU.uTotal.value = Math.max(1, seg); + } + // the visible prefix for a development level, linear in growth time + function mycCount(devel) { + const u = clamp(devel, 0, 1) * mycSteps; + const s = Math.min(Math.floor(u), mycSteps - 1); + const f = u - s; + const a = stepStart[s], b = stepStart[s + 1]; + return Math.min(mycTotal, Math.max(1, Math.round(a + (b - a) * f))); + } + + // --- state ------------------------------------------------------------------------ let level = 0, target = 0, dir = 1; let knobPrev = null, strikePrev = 0; let jig = 0, press = 0, bass = 0, high = 0, lvl = 0, dist = 3.4, panX = 0, panY = 0, pulse = 0, beatPrev = 0; + let orgS = 0, flow = 0; + let mycSway = -1; // the sway the network was last grown for (−1: never) return { scene, camera, update(dt, t, io) { - // ---- the control interface: KNOB 5 sets the level, any strike steps it + // ---- the level: KNOB 5 sets it, any strike steps it const k = io.knobs[4]; if (knobPrev === null) knobPrev = k; if (Math.abs(k - knobPrev) > 1 / 256) { @@ -278,10 +796,16 @@ export function createScene(ctx) { } strikePrev = io.strike; level = approach(level, target, 1.3, dt); - U.uCount.value = computeCells(level); - U.uLevel.value = level; + const devel = level / 4; + + // ---- the organism: KNOB 6, centre egg, right slime mold, left mycelium + orgS = approach(orgS, (io.knobs[5] - 0.5) * 2, 0.15, dt); + const f = smooth01((Math.abs(orgS) - 0.1) / 0.8); + const wEgg = 1 - f; + const wSlime = orgS > 0 ? f : 0; + const wMyc = orgS < 0 ? f : 0; - // ---- gestures: sway jiggles, press squeezes, the hand pans and dollies + // ---- gestures: sway morphs, press squeezes, the hand pans and dollies jig = approach(jig, io.gestures.sway, 0.4, dt); press = approach(press, io.gestures.press, 0.15, dt); panX = approach(panX, (io.xy.x - 0.5) * 1.6, 0.3, dt); @@ -293,9 +817,26 @@ export function createScene(ctx) { if (io.beat > beatPrev + 0.3) pulse = 1; beatPrev = io.beat; pulse = Math.max(0, pulse - dt * 3.5); + flow += dt * (1.6 + pulse * 4.0); + // ---- the organisms' generators + if (wEgg > 0.002) U.uCount.value = computeCells(level); + if (wSlime > 0.002) slimeStep(jig, press, smooth01(devel)); + if (wMyc > 0.002) { + if (mycSway < 0 || Math.abs(jig - mycSway) > 0.004) { + growMycelium(0.58 + jig * 0.62, 0.22 + jig * 0.55); + mycSway = jig; + } + const n = mycCount(devel); + mycGeo.instanceCount = n; + MU.uCount.value = n; + } + hyphae.visible = wMyc > 0.002; + + // ---- uniforms const pl = io.palette; for (let i = 0; i < 5; i++) pal[i].value.copy(pl[i]); + mpal[0].value.copy(pl[0]); mpal[1].value.copy(pl[1]); mpal[2].value.copy(pl[2]); U.uTime.value = t; U.uDist.value = dist; U.uJig.value = jig; @@ -304,15 +845,31 @@ export function createScene(ctx) { U.uBass.value = bass; U.uHigh.value = high; U.uLevelA.value = lvl; + U.uLevel.value = level; U.uIntensity.value = io.intensity; U.uPan.value.set(panX, panY); + U.uOrg.value.set(wEgg, wSlime, wMyc); + U.uFlow.value = flow; + MU.uPan.value.set(panX, panY); + MU.uDist.value = dist; + MU.uPress.value = press; + MU.uBass.value = bass; + MU.uWeight.value = wMyc; + MU.uIntensity.value = io.intensity; + MU.uBeat.value = pulse; + MU.uHigh.value = high; + MU.uTime.value = t; }, resize(w, h) { U.uRes.value.set(w, h); + MU.uRes.value.set(w, h); }, dispose() { quad.geometry.dispose(); mat.dispose(); + trailTex.dispose(); + mycGeo.dispose(); + mycMat.dispose(); }, }; } From 569f51dd3176a283d8aef571ab397c387ba61b48 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:09:26 -0700 Subject: [PATCH 3/3] Handoff: installed build from 9f7d8b4, probed clean --- HANDOFF.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HANDOFF.md b/HANDOFF.md index 03deccd..6b27446 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -10,7 +10,7 @@ Internal engineering note for the next engineer or agent. It is deliberately **n - Branch `cockpit` tracks `origin/cockpit`; `main` holds the pre-redesign import (the merge is the user's call). `b98991a` committed the third pass (Will I Dream's CodePen lens, Nature's Tomb's egg cell); the fourth pass — Nature's Tomb's slime mold and mycelium, Miracle Mile, the scene harness, this note's structure — follows it. - Registry: seventeen scenes (`src/renderer/engine/scenes/index.js`). Templates: eleven (`projects/templates/index.json`), all validating through `validateProject` with no warnings. -- Installed build: see the line the packaging step writes at the end of this section. Before that step the installed build (2026-08-20 21:27) predates the third and fourth passes. +- Installed build: `release/SwayCommand-Setup-0.1.0.exe` built 2026-08-21 00:06 from commit `9f7d8b4` (the hazard check passed: no `src/` file newer than `dist/renderer.bundle.js`; the asar carries `miraclemile`, the slime and mycelium simulations, zero old-name strings), installed per-user with `/S` while the app was not running, and probed from the installed exe with the console hooked: 17 scenes, zero errors or warnings, the slime mold at full reach, the mycelium, and all four Miracle Mile acts with their events at 60 fps (58 in the detonation's first fireball frames and the blast's flash frames — the same first-compile dip Cymatic Orb shows). The user's persisted `settings.layout` was left alone. - **Next steps in order:** the user reviews §2 and prunes what they approve; the physical session at the Sway (`scripts/midi-session.ps1`) to confirm the pad order, the button and knob-press CCs, Beam Sixteen's KNOB 4, Nature's Tomb's KNOB 5 / KNOB 6 and strikes, Miracle Mile's KNOB 5 / KNOB 6 and strikes, Will I Dream's warp corner and PADS 7 / 8 / 15; then the `scene:` assignment target (§5) if the user wants it; then the remaining §5 gaps. ---