From db7afe4ea7cc29ac7a201d437e048a9e08aebff2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 12:19:30 -0400 Subject: [PATCH] feat(p10-reframe-w2): CPU wax-blob lava physics + 2D collision; smin metaball render --- .../Controls/MixWaveformVisualizer.razor.cs | 14 +- .../Services/MixVisualizerControlState.cs | 31 +- .../Interop/visualizer/MixVisualizer.ts | 1123 ++++++++++------- 3 files changed, 680 insertions(+), 488 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index cfaedf9..483f498 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -58,7 +58,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable // datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed // `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint // which upstream link is broken when the ribbon stays blank — set false once confirmed healthy. - private static readonly bool Debug = false; + // ON for the Phase 10 reframe Wave R2 lava test (matches the JS-side DEBUG in + // MixVisualizer.ts). Daniel evaluates the physics in-browser; flip back to false at + // reframe close along with the JS flag. + private static readonly bool Debug = true; private const string Tag = "[MixVisualizer]"; private static void DebugLog(string message) @@ -188,7 +191,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable } // The controls row mutated a slider on the shared state and raised Changed. Push all four control - // uniforms (cheap scalar interop; the inert three are no-ops in the parity shader until Wave 3). + // values (cheap scalar interop). In the Phase 10 reframe Wave R2, three of them are re-routed to + // the lava physics inside the JS handle (setBubblyness→gravity, setDetach→heat, + // setColorShiftSpeed→collision) — see MixVisualizer.ts; the bridge contract is unchanged. private void OnControlStateChanged() => InvokeAsync(async () => { await PushControlsAsync(); @@ -198,8 +203,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable /// /// Push all four control values to the module from the shared state. Used to seed on first render - /// and to re-push when the controls row signals a change. Resolution drives the live render; the - /// other three are inert in the parity shader (Wave 3 consumes them). + /// and to re-push when the controls row signals a change. Resolution drives the scroll/zoom; the + /// other three are routed to the lava physics (gravity/heat/collision) by the JS handle in + /// Wave R2 (see MixVisualizer.ts). The bridge contract is unchanged. /// private async Task PushControlsAsync() { diff --git a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs index 4c69f18..3f1731d 100644 --- a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs +++ b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs @@ -27,23 +27,32 @@ public sealed class MixVisualizerControlState /// public const double DefaultVisibleSeconds = 10.0; - /// - /// Default bulge amount. Mirrors DEFAULT_BUBBLYNESS in MixVisualizer.ts. Normalized [0,1]; - /// 0 = straight rectangular bars, 1 = fully rounded liquid silhouettes (still attached). - /// - public const double DefaultBubblyness = 0.35; + // R2 TEMP (Phase 10 reframe Wave R2): the three controls below are re-routed to the new + // lava physics for Daniel's in-browser test — the JS handle setters map them as: + // Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH. + // The defaults are bumped so the lava looks ALIVE on open (heat non-zero). Wave R4 + // replaces this with the proper six-knob set + its own typed properties. Keep these + // mirrored to the DEFAULT_* anchors in MixVisualizer.ts, as the existing sync discipline. /// - /// Default detach amount. Mirrors DEFAULT_DETACH in MixVisualizer.ts. Normalized [0,1]; - /// 0 = fully attached, 1 = blobs separate and float upward. Off by default. + /// Default GRAVITY dial (R2 temp; was bulge). Mirrors DEFAULT_BUBBLYNESS in MixVisualizer.ts. + /// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. /// - public const double DefaultDetach = 0.0; + public const double DefaultBubblyness = 0.5; /// - /// Default color-shift speed. Mirrors DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts. - /// Normalized [0,1], mapped to a gradient-morph cycle period in the shader (slow → quick). + /// Default HEAT dial (R2 temp; was detach). Mirrors DEFAULT_DETACH in MixVisualizer.ts. + /// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = many bubbles rising. + /// Non-zero default so the lamp is alive on open. /// - public const double DefaultColorShiftSpeed = 0.3; + public const double DefaultDetach = 0.45; + + /// + /// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors + /// DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts. Normalized [0,1]; 0 = soft shove, + /// 1 = hard elastic wall. + /// + public const double DefaultColorShiftSpeed = 0.5; /// Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K. public double VisibleSeconds { get; set; } = DefaultVisibleSeconds; diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index e2952dc..171920a 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -1,5 +1,5 @@ /** - * MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 1–3). + * MixVisualizer — the scrolling Mix waveform background (Phase 10 + Lava Reframe). * * What this renders: a *windowed* slice of a mix's loudness profile, scrolling * bottom-to-top, coupled to playback position. New audio enters at the bottom, @@ -7,33 +7,31 @@ * line (vertical centre by default). This is a read-only, ambient lava-lamp * background — there is no seek, no click handling, no write-back to playback. * - * Rendering tech: WebGL2, fragment-shader. This was a wholesale renderer swap from - * the Canvas 2D predecessor (8.K) — the four effects below are all per-pixel, - * per-frame work that Canvas is worst at and a fragment shader is best at (see - * product-notes/mix-visualizer-webgl-renderer.md). + * Rendering tech: WebGL2, fragment-shader, plus (as of the Lava Reframe Wave R2) a + * CPU-side per-frame PHYSICS step that drives the wax lava. The scroll/zoom geometry + * and the loudness-datum-as-texture sampling carry forward from Phase 10 Waves 1–2; + * the playhead wall-clock interpolation + jitter correction carry forward as-is. * - * Wave 3 (this revision) brings the four in-shader effects to life, all driven by - * the control uniforms Wave 2 wired (see the fragment shader's main()): - * 1. A morphing navy↔moss 2-D colour field (uColorShiftSpeed × uTimeSeconds). - * 2. Bubblyness — box→metaball SDF bulge (uBubblyness). - * 3. Detach — lava-lamp pinch-off + rising blobs (uDetach, uTimeSeconds). - * 4. Glass — specular + Fresnel + frosted + refraction, all shader math, no CSS - * backdrop-filter / no per-frame CPU blur (the original perf killer). - * The Wave 1 scroll/zoom geometry and the Wave 2 bridge contract are unchanged; - * the effects layer on top of them. - * - * The pipeline is the textbook "shadertoy-style" full-screen pass: a single quad - * covering the canvas, a trivial pass-through vertex shader, and ALL the work in - * the fragment shader. Per fragment (pixel) the shader asks "which mix-time does - * my screen Y map to, what loudness is there, am I inside the ribbon, and what - * colour am I?" — the same scroll/zoom math the Canvas walked per screen-row, - * evaluated per-pixel in parallel on the GPU instead. + * THE LAVA (Wave R2 — this revision): + * The rejected analytic-metaball "lava" (scripted blobs that read as giant + * disconnected circles) is replaced by a real Lagrangian wax-blob simulation: + * • 16–32 blobs carry position / velocity / temperature / radius and are + * integrated each frame with real dt (gravity, temperature-buoyancy, viscous + * damping, soft floor contact) — see stepPhysics(). + * • 2D ELASTIC COLLISION: blob↔waveform (the ribbon is a read-only boundary the + * wax is pushed out of along its surface normal) and blob↔blob, both with a + * soft↔hard strength dial — see stepPhysics()'s collision passes. + * • The blobs upload as a uBlobs[] uniform array; the fragment shader unions them + * with smin metaballs + the waveform SDF into one liquid surface (liquidSdf). + * Colour is a deliberately SIMPLE theme fill for R2 — the OKLab three-colour + * gradient is Wave R3. No glass, no screen-space noise (removed in R1). * * The Blazor component owns the canvas element and the inputs (datum, playback, - * zoom, theme); this module owns the requestAnimationFrame loop and all the - * GL/scroll/zoom math. The component drives it through the small handle returned - * by `create`. The handle shape is identical to the Canvas predecessor's, so the - * bridge (MixWaveformVisualizer.razor.cs) needs no change. + * zoom, theme, the control dials); this module owns the requestAnimationFrame loop, + * the physics step, and all the GL math. The component drives it through the handle + * returned by `create`. The handle SHAPE is unchanged from Phase 10 — the three + * effect setters are temporarily re-routed to the lava params for this wave (see + * their definitions); Wave R4 gives them proper names + a six-knob UI. */ // ── Tuning anchors (see spec §B). These are the load-bearing constants. ────────── @@ -51,20 +49,34 @@ export const MAX_VISIBLE_SECONDS = 30; /** Default opening window when a mix is first opened. Tunable. */ export const DEFAULT_VISIBLE_SECONDS = 10; -// ── Wave 2 control tuning anchors. These mirror the C#-side defaults in ─────────── +// ── Control tuning anchors. These mirror the C#-side defaults in ────────────────── // MixVisualizerControlState.cs — keep the two in sync, exactly as the // DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are -// normalized [0,1]. They are wired through to GPU uniforms now (Wave 2 plumbing) but -// the parity shader does NOT consume them visually yet — they come alive in Wave 3. +// normalized [0,1]. +// +// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper six-knob set): +// the three existing control knobs are re-purposed to drive the new lava physics so +// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say +// the old thing; the SETTERS below (setBubblyness/setDetach/setColorShiftSpeed) route +// them to the new physics params. Mapping: +// • "Detach" knob (Air icon) → lava HEAT +// • "Bubblyness" knob (BubbleChart) → lava GRAVITY +// • "Color-shift" knob (Palette) → COLLISION STRENGTH +// Blob DENSITY has no live knob this wave; it sits at DEFAULT_BLOB_DENSITY (R4 adds it). +// The defaults below are chosen so the lava looks ALIVE on open (heat non-zero, mid +// gravity, mid collision) — Daniel then tunes on screen. -/** Default bulge amount, normalized [0,1]. Mirrors C# DefaultBubblyness. */ -export const DEFAULT_BUBBLYNESS = 0.35; +/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness. Mid = a settled-but-mobile lamp. */ +export const DEFAULT_BUBBLYNESS = 0.5; -/** Default lava-lamp detach amount, normalized [0,1]. Mirrors C# DefaultDetach. */ -export const DEFAULT_DETACH = 0; +/** Default HEAT dial (was detach). Mirrors C# DefaultDetach. Non-zero so the lamp is alive on open. */ +export const DEFAULT_DETACH = 0.45; -/** Default gradient-morph rate, normalized [0,1]. Mirrors C# DefaultColorShiftSpeed. */ -export const DEFAULT_COLOR_SHIFT_SPEED = 0.3; +/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed. Mid soft↔hard. */ +export const DEFAULT_COLOR_SHIFT_SPEED = 0.5; + +/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */ +export const DEFAULT_BLOB_DENSITY = 0.4; /** * Where the "now" line sits within the window, as a fraction from the top. @@ -73,15 +85,6 @@ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3; */ const NOW_ANCHOR_FROM_TOP = 0.5; -/** - * Background opacity of the whole ribbon. Raised from the parity 0.22 → 0.55 for the - * Wave-3-rework "vivid/glassy" pass: at 0.22 the page (off-white / navy) showed through - * ~78% of every pixel and washed the field toward grey. 0.55 lets the saturated navy/moss - * read as real colour while still keeping it a translucent glass backdrop, not an opaque - * chart. (The rim/Fresnel lift on top pushes edges higher.) - */ -const RIBBON_OPACITY = 0.55; - /** * Half-width of the ribbon at full loudness, as a fraction of half the canvas * width (the predecessor used 0.92). Mirrors the Canvas `maxHalfWidth` factor. @@ -95,6 +98,122 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92; */ const MAX_DPR = 2; +// ════════════════════════════════════════════════════════════════════════════════════ +// R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real +// Lagrangian particle system integrated each frame on the JS side and rendered as +// smin metaballs in the fragment shader. EVERYTHING below is the tuning surface for +// the lava look; Daniel reads + tunes these. +// +// Coordinate convention (shared with the shader): physics runs in HEIGHT-NORMALIZED +// space — every position/velocity/radius is in units of (pixel / canvasHeight). So +// y ∈ [0, 1] top→bottom, x ∈ [0, W/H], and the FLOOR (footer / lava rest line) is the +// bottom edge of the canvas at y = 1 (the canvas is already CSS-clipped to the footer +// top in R1, so its own bottom edge IS the footer line — no extra clip uniform needed). +// Using one isotropic unit keeps blobs round and collisions correct at any aspect. +// ════════════════════════════════════════════════════════════════════════════════════ + +/** + * Hard upper bound on the simulated/rendered blob population. The per-fragment shader + * loops to this constant, so it caps GPU cost; the CPU step is O(MAX_BLOBS²) for + * blob↔blob (≤ ~1k pair tests — trivial). 32 is the spec's upper band (§4g). The live + * count varies with the density dial but never exceeds this. + */ +const MAX_BLOBS = 32; + +/** Lower end of the live blob count (density dial 0 → a few large lazy blobs, §4e). */ +const MIN_BLOB_COUNT = 16; + +/** Blob radius band in height-normalized units. 0.025·H ≈ 20px and 0.13·H ≈ 100px on a + * ~760px-tall canvas — the spec's ~20–100px range (§4b). Each blob picks a fixed radius + * in this band at construction; the density dial biases the average. */ +const BLOB_RADIUS_MIN = 0.025; +const BLOB_RADIUS_MAX = 0.13; + +/** + * Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y). + * Tuned so wax at full gravity falls back to the floor in well under a second from + * mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot. + * The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d). + */ +const GRAVITY_ACCEL_MAX = 2.2; +/** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */ +const GRAVITY_ACCEL_MIN = 0.15; + +/** + * Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T − T_ambient). + * Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's + * core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a + * hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy + * vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint). + */ +const BUOYANCY_COEFF = 4.0; +const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks + +/** + * Heat transfer rates (per second), the engine of the convection cycle: + * - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom), + * - near the TOP it COOLS toward 0 (loses heat at the cold cap), + * - everywhere it relaxes gently toward ambient. + * heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the + * floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant + * and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and + * rise per second (the busy roiling lamp, §4c max endpoint). + */ +const HEAT_FLOOR_RATE = 1.6; // °/s toward T=1 when sitting on the floor (× heatScale) +const HEAT_TOP_RATE = 1.2; // °/s toward T=0 when near the top +const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere +const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "hot zone" +const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone" + +/** + * Viscous (linear) velocity damping per second — the lazy/high-viscosity regime that + * makes it read as wax, not water (spec §4a). Applied as v *= exp(−DAMPING·dt) each + * step, so it is frame-rate independent. High enough that motion is slow and gooey; + * low enough that hot blobs still make the trip up. + */ +const VISCOUS_DAMPING = 1.4; + +/** + * Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up + * by a spring proportional to its penetration below the floor, and its downward velocity + * is killed on contact so pooled wax flattens and settles rather than bouncing forever. + */ +const FLOOR_SPRING = 26.0; // restoring accel per unit penetration (height-units/s²) +const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor contact (settle) + +/** + * Blob↔blob collision: the soft↔hard knob (collision strength) blends a penalty SPRING + * (soft displacement, blobs squish and partially overlap then ease apart) toward an + * elastic IMPULSE (hard, crisp restitution along the centre line). These are the two + * endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the + * hard end; the spring stiffness is the firmness of the soft end. + */ +const BLOB_COLLIDE_SPRING = 14.0; // soft penalty stiffness (height-units/s² per overlap) +const BLOB_RESTITUTION_HARD = 0.9; // elastic restitution at strength = 1 (near-perfect bounce) +const BLOB_RESTITUTION_SOFT = 0.15; // residual restitution at strength = 0 (mostly absorptive) + +/** + * Blob↔waveform collision (always on, independent of heat — §5b). The waveform's + * half-width at a blob's row is sampled CPU-side each frame; a blob whose centre is + * within (halfWidth + radius) of the centre line is penetrating the ribbon and is pushed + * out along the surface normal. Same soft↔hard blend as blob↔blob: a penalty spring at + * the soft end → elastic reflection of the inward velocity at the hard end. The waveform + * is read-only authority: it pushes the fluid, the fluid never moves it. + */ +const WAVE_COLLIDE_SPRING = 20.0; // soft penalty stiffness pushing wax off the ribbon +const WAVE_RESTITUTION_HARD = 0.85; // elastic reflection strength at full collision hardness +const WAVE_RESTITUTION_SOFT = 0.1; + +/** + * Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a + * blob tunnel through the floor or another blob in one step (explosive overlap). We clamp + * dt so the integrator stays stable — a long stall just means the sim advances a little + * slowly that frame, which is invisible. (We also sub-step within this cap below.) + */ +const PHYSICS_MAX_DT = 1 / 30; +/** Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable. */ +const PHYSICS_SUBSTEPS = 2; + /** * Playhead-correction smoothing time constant, in seconds. Governs how fast the * rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push. @@ -250,6 +369,13 @@ interface Datum { sampleCount: number; /** Total mix duration in seconds — needed to map time <-> sample index. */ durationSeconds: number; + /** + * The decoded loudness bytes [0,255], retained for CPU-side sampling by the physics + * step (the waveform-collision boundary is sampled per blob per frame — R2 §5). The + * GPU has its own copy in `texture`; this is the CPU mirror, kept because re-reading + * the texture back from the GPU each frame would be a stall. + */ + samples: Uint8Array; } interface Playback { @@ -276,11 +402,11 @@ export interface MixVisualizerHandle { setDatum(samplesBase64: string, durationSeconds: number): void; setPlayback(positionSeconds: number, isPlaying: boolean): void; setZoom(visibleSeconds: number): void; - /** Bulge amount [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ + /** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */ setBubblyness(value: number): void; - /** Lava-lamp detach [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ + /** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */ setDetach(value: number): void; - /** Gradient-morph rate [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ + /** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */ setColorShiftSpeed(value: number): void; /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */ refreshTheme(): void; @@ -365,11 +491,13 @@ precision highp float; uniform vec2 uResolution; // canvas size in device pixels uniform float uPlayheadSeconds; // current playback position (per-frame) -uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph + blob rise +uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph uniform float uVisibleSeconds; // zoom: window time-span (per change) -uniform float uBubblyness; // bulge amount [0,1] (per change) — box→metaball SDF blend -uniform float uDetach; // lava-lamp detach [0,1] (per change) — pinch-off + rise -uniform float uColorShiftSpeed; // gradient-morph rate [0,1] (per change) — field cycle rate +// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms +// in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old +// uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason; +// the JS handle still receives those control values and routes them to the physics (the +// R2 TEMP knob re-mapping documented at the control-default consts above). uniform float uDurationSeconds; // mix length (per datum) uniform vec3 uColorAccent; // MOSS pole of the field (per theme) uniform vec3 uColorEdge; // NAVY pole of the field (per theme) @@ -378,64 +506,49 @@ uniform sampler2D uDatum; // loudness profile, R8, 2-D grid, NEAREST (tex uniform int uDatumWidth; // datum texture width in texels (samples per row) uniform int uDatumSampleCount; // number of real samples (tail row is padded) +// ── R2 wax-blob uniforms (the CPU physics step uploads these every frame). ────────── +// Each blob is packed as a vec4: xy = centre in HEIGHT-NORMALIZED space (pixel/H, so +// y is 0 at the top edge and 1 at the footer/floor, x spans [0, W/H]); z = radius in +// the SAME height-normalized unit (so circles are round on screen); w = temperature +// 0..1 (drives the warm tint on hot rising wax). uBlobCount is how many of the +// MAX_BLOBS slots are live this frame. Working in height-normalized units keeps the +// metaball SDF isotropic regardless of the canvas aspect ratio. +const int MAX_BLOBS = ${MAX_BLOBS}; +uniform vec4 uBlobs[MAX_BLOBS]; +uniform int uBlobCount; + out vec4 fragColor; const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)}; -const float RIBBON_OPACITY = ${RIBBON_OPACITY.toFixed(4)}; const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)}; -// ── Wave 3 tuning constants (all in-shader; Daniel tunes by editing here). ────────── +// ── R2 in-shader tuning constants (Daniel tunes by editing here). ─────────────────── -// Colour-shift speed → cycle period (seconds). The slider is normalized [0,1]; we map -// it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3 -// rework): the old 60 s slow end made even the default look frozen — Daniel reported the -// slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s -// (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential -// map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3 -// (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated -// poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey. -const float COLORSHIFT_PERIOD_SLOW = 24.0; // s at slider 0 — slow but perceptible drift -const float COLORSHIFT_PERIOD_FAST = 2.0; // s at slider 1 — unmistakably brisk morph +// Background opacity of the wax + waveform fill. Kept simple/serviceable for R2 — the +// beautiful OKLab three-colour gradient is Wave R3. Just enough to read the physics. +const float RIBBON_OPACITY_R2 = 0.62; -// Vividness (W3 rework). The raw theme tokens are muted UI colours (navy text / moss -// secondary, both dark + low-saturation); a naive RGB lerp between them passes through a -// muddy grey midpoint, which is exactly the "mostly grey" Daniel rejected. We mix the -// field in HSL instead (hue/sat/lum interpolate independently, so the path between two -// saturated colours stays saturated — no grey midpoint), and lift saturation + luminance -// of the result so the field reads as rich glassy navy-blue ↔ vivid moss-green. These are -// the punch dials. -const float VIVID_SATURATION_FLOOR = 0.62; // min saturation of any field pixel [0,1] -const float VIVID_SATURATION_BOOST = 0.30; // extra saturation pushed in on top of the lerp -const float VIVID_LUMINANCE_LIFT = 0.14; // lifts the dark poles off black so colour reads +// smin blend radius for the wax metaball union, in height-normalized units. Larger = the +// "necks" where two blobs merge are fatter → a gooier, more-connected wax that splits and +// recombines (the organic non-circular look the spec wants, §4b). This + varied radii are +// what kill the "giant disconnected circles" failure. +const float BLOB_SMOOTHMIN_K = 0.045; -// Bubblyness: how far the metaball field spreads to neighbours at max bulge, as a -// fraction of the half-window. Larger = more liquid coalescence between bars. -const float BUBBLE_SMOOTHMIN_K = 0.18; +// smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax +// reads as continuous with the ribbon surface rather than a disc sitting on a wall. +const float WAVE_SMOOTHMIN_K = 0.03; -// Bubbling motion (W3 rework). Bubblyness used to only thicken the ribbon statically. -// Now it also drives a time-varying swell of the ribbon surface (a lava-lamp roil): a -// low-frequency noise displaces the bar half-width up and down over time, with amplitude -// and churn rate growing with uBubblyness. At 0 the displacement is zero (flat parity -// bars); rising = an increasingly active, undulating surface. -const float BUBBLE_SWELL_AMPLITUDE = 0.35; // max half-width swell (xn units) at bubblyness 1 -const float BUBBLE_SWELL_RATE = 0.55; // churn speed (rad/s scale) of the swell noise -const float BUBBLE_SWELL_FREQ = 2.2; // spatial frequency of the swell along the ribbon +// Low-frequency, blob-tied radius wobble: a slow per-blob breathing so each wax shape is +// organic, not a perfect circle (§4b). This is FLUID-tied noise (keyed to blob identity + +// the wall clock), NOT the screen-space "dirt" R1 removed (§3) — it travels with the wax. +const float BLOB_WOBBLE_AMOUNT = 0.12; // ± fraction of radius +const float BLOB_WOBBLE_RATE = 0.7; // breathing speed (rad/s scale) -// Detach: how many independent rising blobs we evaluate, and how far (in window -// heights) a blob travels over its life before fading + recycling. Bounded so it reads -// as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT -// the waveform surface (where loudness is) and pinch off from it, rather than spawning in -// empty space — see ribbonField's detach block. -const int DETACH_BLOB_COUNT = 6; -const float DETACH_RISE_SPAN = 1.15; // window-heights a blob climbs across its life -const float DETACH_BLOB_DRIFT = 0.05; // horizontal lava-lamp wobble amplitude (xn units) - -// Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic -// (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3 -// rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy"). -const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot -const float GLASS_FRESNEL_POWER = 2.2; // lower = broader, more visible rim glow -const float GLASS_REFRACT_WARP = 0.10; // field-distortion amount at curved surfaces +// Warm tint on hot, rising wax. A hot blob (temperature → 1) shifts slightly toward a +// warm highlight so the eye reads "this one is rising"; cool wax stays the cool field +// colour. Serviceable placeholder until R3's real colour model — kept subtle. +const vec3 HOT_TINT = vec3(0.95, 0.72, 0.45); // warm amber the hottest wax leans toward +const float HOT_TINT_AMOUNT = 0.35; // max lean at temperature 1 (above ambient) // Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D // texture grid (col = i mod width, row = i / width). texelFetch ignores filtering @@ -477,15 +590,15 @@ float sampleAt(float timeSeconds) { } // ════════════════════════════════════════════════════════════════════════════════════ -// WAVE 3 — the four in-shader effects. Helpers first, then main() layers them in the -// spec §6 order: (1) gradient field, (2) bubblyness, (3) detach, (4) glass. +// R2 — wax metaballs + waveform SDF. The blobs are integrated on the CPU (see the JS +// physics step) and uploaded as uBlobs[]; the shader composites them with smin and the +// waveform ribbon into one liquid surface, then shades it with a simple theme fill. // ════════════════════════════════════════════════════════════════════════════════════ -// ── Value-noise (for the flowing colour field + organic blob jitter). ─────────────── +// ── Value-noise (used now only for the organic, blob-tied radius wobble). ──────────── // A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and -// continuous so the field flows rather than flickers. This is the "low-frequency base -// field" the spec §4b calls for — sampled over (time-axis, amplitude-axis) it gives the -// coherent navy↔moss morph; sampled at higher frequency it gives per-bar liveness. +// continuous. Fed blob-identity + the wall clock it gives each wax shape its own slow +// breathing so the silhouette is organic rather than a perfect circle (§4b). float hash21(vec2 p) { p = fract(p * vec2(123.34, 345.45)); p += dot(p, p + 34.345); @@ -503,77 +616,8 @@ float valueNoise(vec2 p) { return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } -// ── HSL conversion (for the VIVID field — see VIVID_* consts). ────────────────────── -// Mixing two saturated colours in linear RGB drags the midpoint through grey; mixing in -// HSL keeps hue/sat/lum independent so the path between navy and moss stays colourful. -// Standard branchless RGB↔HSL. h,s,l ∈ [0,1]. -vec3 rgb2hsl(vec3 c) { - float mx = max(max(c.r, c.g), c.b); - float mn = min(min(c.r, c.g), c.b); - float l = (mx + mn) * 0.5; - float d = mx - mn; - float s = 0.0; - float h = 0.0; - if (d > 1e-5) { - s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn); - if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); - else if (mx == c.g) h = (c.b - c.r) / d + 2.0; - else h = (c.r - c.g) / d + 4.0; - h /= 6.0; - } - return vec3(h, s, l); -} -float hue2rgb(float p, float q, float t) { - if (t < 0.0) t += 1.0; - if (t > 1.0) t -= 1.0; - if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; - if (t < 1.0 / 2.0) return q; - if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; - return p; -} -vec3 hsl2rgb(vec3 hsl) { - float h = hsl.x, s = hsl.y, l = hsl.z; - if (s < 1e-5) return vec3(l); - float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; - float p = 2.0 * l - q; - return vec3(hue2rgb(p, q, h + 1.0 / 3.0), hue2rgb(p, q, h), hue2rgb(p, q, h - 1.0 / 3.0)); -} -// Interpolate two RGB colours through HSL, taking the SHORT way around the hue circle so -// navy↔moss travels the rich teal/blue arc rather than wrapping through red. Returns the -// result back in RGB with no extra vividness applied (the caller adds the punch). -vec3 mixHsl(vec3 a, vec3 b, float t) { - vec3 ha = rgb2hsl(a); - vec3 hb = rgb2hsl(b); - float dh = hb.x - ha.x; - if (dh > 0.5) dh -= 1.0; // go the short way round the hue wheel - if (dh < -0.5) dh += 1.0; - float h = fract(ha.x + dh * t); - float s = mix(ha.y, hb.y, t); - float l = mix(ha.z, hb.z, t); - return hsl2rgb(vec3(h, s, l)); -} -// Push a colour toward vivid: raise saturation (with a floor) and lift luminance off -// black so the dark theme poles actually read as colour rather than near-grey. amp ∈ [0,1] -// (loudness) lifts a loud bar a little further for the "own living thing" read. -vec3 vivify(vec3 rgb, float amp) { - vec3 hsl = rgb2hsl(rgb); - hsl.y = max(hsl.y, VIVID_SATURATION_FLOOR); - hsl.y = clamp(hsl.y + VIVID_SATURATION_BOOST + amp * 0.10, 0.0, 1.0); - hsl.z = clamp(hsl.z + VIVID_LUMINANCE_LIFT + amp * 0.06, 0.0, 0.92); - return hsl2rgb(hsl); -} - // ── Signed-distance primitives + smooth-min (the metaball machinery). ─────────────── -// Box SDF (centred at origin, half-extents b): negative inside, positive outside. -float sdBox(vec2 p, vec2 b) { - vec2 d = abs(p) - b; - return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); -} -// Rounded-box SDF: a box whose corners are rounded by radius r — the "liquid" silhouette. -float sdRoundBox(vec2 p, vec2 b, float r) { - return sdBox(p, b - vec2(r)) - r; -} -// Circle SDF (a metaball centre) — used for detached rising blobs. +// Circle SDF (a metaball centre) — the wax blob primitive. float sdCircle(vec2 p, float r) { return length(p) - r; } @@ -585,154 +629,64 @@ float smin(float a, float b, float k) { return mix(b, a, h) - k * h * (1.0 - h); } -// ── The ribbon/blob signed-distance field at a screen point. ──────────────────────── +// ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ────────── // -// Returns the signed distance (in normalized half-window-width units, negative inside) -// to the liquid surface at screen pixel (px). This is the heart of effects 2+3: it -// blends a sharp box (bubblyness 0) toward swelling metaballs (bubblyness 1), and -// peels detached blobs upward (detach). main() then renders the surface and shades it. +// The waveform is the same symmetric ±loudness ribbon about the centre line as before, +// but evaluated in height-normalized coords (pixel/H) so it shares one space with the +// wax blobs. p = (x, y) where x ∈ [0, W/H] across the canvas and y ∈ [0, 1] top→bottom. +// We map the row's mix-time → loudness → a half-width about the centre x, and return the +// distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked +// here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon. +float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) { + // Mix-time at this row: rows below the now-line are future audio, above are past. + float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; + float amp = sampleAt(t); // loudness 0..1 at this row + float centreX = aspect * 0.5; // canvas centre x in height-norm units + float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; // ribbon half-width here + // Distance to a centred vertical band of half-width halfW: |x − centre| − halfW. + // Negative inside the band, positive outside. (A pure horizontal band; the vertical + // extent is the whole column, which is what the scrolling ribbon is.) + return abs(p.x - centreX) - halfW; +} + +// ── The combined wax + waveform liquid SDF at a height-normalized point. ───────────── // -// Coordinate model: x normalized so |x|=1 is the full ribbon half-width at the canvas -// edge; y is screen-row time as before. Loudness at this row sets the attached -// half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce -// vertically into a continuous liquid column rather than discrete per-row slabs. -float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, out float ampOut) { - float screenYTop = px.y; - float screenX = px.x; +// Unions all live wax blobs (smin metaballs) and the waveform ribbon into one continuous +// surface. The blob radii carry a slow blob-tied wobble so each is organic, not a perfect +// circle. Returns the signed distance and, via out params, the nearest-blob temperature +// (for the warm hot-wax tint) and whether the point is dominated by wax vs. ribbon. +float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight, + out float hotOut) { + // Waveform ribbon first — the always-present base surface. + float field = waveformSdf(p, aspect, nowYn, secondsPerHeight); - // Time + loudness at this row (the geometry from Wave 1, unchanged). - float t = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; - float amp = sampleAt(t); - ampOut = amp; + float hotAccum = 0.0; + float hotWeight = 0.0; - // Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width. - float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth; + // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set. + for (int i = 0; i < MAX_BLOBS; i++) { + if (i >= uBlobCount) break; + vec4 b = uBlobs[i]; + vec2 c = b.xy; // centre, height-norm + float r = b.z; // radius, height-norm + float temp = b.w; // temperature 0..1 - // --- BUBBLING MOTION (§4d rework) ------------------------------------------------- - // Bubblyness now drives a real over-time roil, not just a thicker static ribbon. A - // low-frequency noise sampled over (this row's mix-time, the wall clock) swells the - // bar's half-width up and down continuously — the surface churns like a lava lamp's. - // Amplitude AND churn rate both scale with uBubblyness, so at 0 the term vanishes - // (flat parity bars) and rising = an increasingly active, undulating surface. We key - // the noise to mix-time (not screen-Y) so the swell travels WITH the audio as it - // scrolls, rather than sitting still in screen space. Only applied where there is - // loudness (amp gates it) so silence stays flat. - float swellNoise = valueNoise(vec2(t * BUBBLE_SWELL_FREQ, - uTimeSeconds * BUBBLE_SWELL_RATE)) - 0.5; // ±0.5 - float swell = swellNoise * BUBBLE_SWELL_AMPLITUDE * uBubblyness * amp * 2.0; - float halfWidthN = max(amp + swell, 0.0); // box half-extent in xn units, now animated + // Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so + // the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok). + float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5) + * 2.0 * BLOB_WOBBLE_AMOUNT; + float rr = r * (1.0 + wob); - // --- ATTACHED SHAPE --------------------------------------------------------------- - // At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular - // bar (each row independent, sharp edges). At bubblyness 1: round the slab and let - // it swell outward from the zero-line, and smooth-min with the rows above/below so - // the column fuses into one liquid silhouette (the metaball union, §4d). - // - // Row thickness in xn space: half a screen-row's worth, so a single sampled row is - // a thin horizontal slab. Bubblyness grows the vertical reach (the swell) and the - // corner rounding. - float rowHalfY = 0.5 / max(maxHalfWidth, 1.0); // ~half a pixel in xn units - // Sample neighbour rows (±a few rows) to build a vertical metaball stack. The offset - // grows with bubblyness so bulges reach further and merge more at higher settings. - float reach = mix(rowHalfY, rowHalfY + 0.18, uBubblyness); - vec2 q = vec2(xn, 0.0); + float blob = sdCircle(p - c, rr); + field = smin(field, blob, BLOB_SMOOTHMIN_K); - // Box half-extents: width = loudness, height = the row reach. Rounding radius grows - // with bubblyness (sharp rect → rounded capsule). The swell-from-centre is the box - // width itself scaling with amp, so a loud row bulges wider. - // (cornerR, not "round" — that name shadows the GLSL built-in round() and reads badly.) - float cornerR = mix(0.0, halfWidthN * 0.9 + 0.02, uBubblyness); - vec2 boxB = vec2(max(halfWidthN, 0.001), reach); - float attached = sdRoundBox(q, boxB, min(cornerR, min(boxB.x, boxB.y) - 1e-3)); - - // Vertical coalescence: blend with neighbour rows' loudness so the column is liquid, - // not a stack of disks. We approximate by smooth-min'ing against the loudness one - // "reach" above and below in time — cheap (two extra texture taps) and gives the - // continuous-liquid read the spec wants. Only meaningful when bubbly. - if (uBubblyness > 0.001) { - float dtRow = reach * maxHalfWidth / pixelsPerSecond; // xn-reach back to seconds - float ampUp = sampleAt(t - dtRow); - float ampDn = sampleAt(t + dtRow); - vec2 boxUp = vec2(max(ampUp, 0.001), reach); - vec2 boxDn = vec2(max(ampDn, 0.001), reach); - float up = sdRoundBox(vec2(xn, reach), boxUp, min(cornerR, min(boxUp.x, boxUp.y) - 1e-3)); - float dn = sdRoundBox(vec2(xn, -reach), boxDn, min(cornerR, min(boxDn.x, boxDn.y) - 1e-3)); - float k = BUBBLE_SMOOTHMIN_K * uBubblyness; - attached = smin(attached, smin(up, dn, k), k); - } - - // --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------ - // Reworked from the old "fixed-column blobs floating in empty space that vibrate" to - // bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge - // (where the loudness is) near the now-line, pinches off, and rises smoothly. Two - // fixes for the rejected version: - // 1. ORIGIN AT THE WAVEFORM. A bubble's birth column sits at ±(loudness) — the bar - // EDGE at its birth time — not a hash-picked column in empty space. We sample the - // datum at the birth time so a bubble only exists where there was actually sound, - // and it starts attached to the surface there. - // 2. NO VIBRATION. The vertical scale now matches the horizontal (xn) scale via the - // screen aspect (yAspect below), so blobs are round, not squashed — the old code - // normalised a vertical distance by maxHalfWidth (a HORIZONTAL scale), which - // stretched blobs and made the SDF-gradient normal unstable → shimmer. Motion is - // a single smooth fract(uTimeSeconds·rate); the only hash use is per-index - // identity (time-invariant), so there is no per-frame jitter. - float field = attached; - if (uDetach > 0.001) { - // Map a vertical screen-pixel distance into the same xn units the SDF circle uses, - // so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth - // (≈ half the canvas width); to match, vertical must divide by the same, hence the - // 1.0 here keeps both axes in maxHalfWidth units (screenY already in px like screenX). - float yToXn = 1.0 / maxHalfWidth; - for (int i = 0; i < DETACH_BLOB_COUNT; i++) { - float fi = float(i); - // Per-blob identity from a hash — stable over time (no per-frame term), so the - // blob set is a calm repeating drift, never a random storm. - float seed = hash21(vec2(fi, 7.0)); - float seed2 = hash21(vec2(fi, 19.0)); - float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off - - // Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase - // offset so they don't pulse in unison; rise rate scales gently with detach. - float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach); - float life = fract(uTimeSeconds * rate + seed); - - // Birth time: the mix-time at the now-line, nudged per blob so they're born at - // staggered moments. The bubble emanates from the surface AS IT WAS at birth. - float birthT = uPlayheadSeconds - seed * 0.15; - float birthAmp = sampleAt(birthT); - // No surface there (silence) → no bubble. This is what ties bubbles to the - // waveform: they only appear where there was loudness to shed them. - if (birthAmp < 0.02) continue; - - // Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts - // ON the surface. As it rises it drifts slightly inward/outward (lava wobble). - float birthX = side * birthAmp; - float driftX = birthX + side * DETACH_BLOB_DRIFT * sin(uTimeSeconds * 0.6 + seed * 6.28); - - // Rise: starts at the now-line (the surface) and climbs upward (screen-up = - // decreasing screenYTop), travelling DETACH_RISE_SPAN window-heights over life. - float riseN = life * DETACH_RISE_SPAN; // window-heights climbed - float blobYTop = nowY - riseN * uResolution.y; // screen Y of the blob centre - - // Radius: bigger from a louder birth surface; grows then shrinks across life so - // the bubble swells out of the surface and fades near the top — no hard pop. - float envelope = smoothstep(0.0, 0.15, life) * (1.0 - smoothstep(0.80, 1.0, life)); - float radius = (0.04 + 0.07 * seed) * (0.5 + 0.5 * birthAmp) * uDetach * envelope; - if (radius < 1e-4) continue; // fully faded — skip (also avoids a 0-radius SDF) - - // Blob centre in the (xn, xn) eval frame. Both axes now in maxHalfWidth units - // (driftX already in xn; vertical px scaled by yToXn) → the circle is round. - vec2 pBlob = vec2(xn - driftX, (screenYTop - blobYTop) * yToXn); - float blob = sdCircle(pBlob, radius); - - // Pinch-off neck: while young (low life) and at low detach the bubble stays - // linked to the parent surface via a fat smooth-min neck; as it rises (life→1) - // or detach→1 the neck thins toward a hard union, so it reads as separated. - float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7); - field = smin(field, blob, max(neckK, 0.004)); - } + // Weight this blob's temperature by proximity so the tint follows the nearest wax. + float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0); + hotAccum += temp * prox; + hotWeight += prox; } + hotOut = hotWeight > 1e-3 ? hotAccum / hotWeight : 0.0; return field; } @@ -740,144 +694,59 @@ void main() { float w = uResolution.x; float h = uResolution.y; - // Flip to a top-left, downward-Y frame so the time math matches the Canvas port. - float screenYTop = h - gl_FragCoord.y; - float screenX = gl_FragCoord.x; - - float nowY = h * NOW_ANCHOR_FROM_TOP; - float pixelsPerSecond = h / uVisibleSeconds; - float maxHalfWidth = (w * 0.5) * RIBBON_HALF_WIDTH_FRAC; - // Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note). if (uHasDatum < 0.5) { fragColor = vec4(0.0); return; } - // ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ── - // The gradient of the SDF is the outward surface normal — we need it for the glass - // (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the - // step is one device-pixel mapped into the field's xn/yTop frame. - // (The old playhead-feed hoist was removed in the W3 rework: detach now samples a - // per-blob birth-time loudness inside the loop, so there is no single shared tap to - // lift out. The taps remain uniform-only expressions, the same order of cost as before.) - vec2 px = vec2(screenX, screenYTop); - float amp; - float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, amp); + // Height-normalized fragment coordinate (pixel / H), top-left origin, y down. This is + // the shared space the CPU physics works in — the blob uniforms are already in it. + float aspect = w / h; // canvas width in height units + vec2 p = vec2(gl_FragCoord.x / h, (h - gl_FragCoord.y) / h); - float e = 1.0; // 1px central-difference step - float ignore; - float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); - float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); - float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); - float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); - // Surface normal in screen space (points OUT of the liquid). y flipped because our - // field y is screen-down. Guard the zero-length case at flat interiors. - vec2 grad = vec2(dRx - dLx, dUy - dDy); - vec2 normal = length(grad) > 1e-4 ? normalize(grad) : vec2(0.0, -1.0); + float nowYn = NOW_ANCHOR_FROM_TOP; // now-line, height-norm (y ∈ [0,1]) + float secondsPerHeight = uVisibleSeconds; // one full height spans uVisibleSeconds - // Inside-ness: SDF negative = inside. Feather the boundary (~1px in field units) for - // an anti-aliased, glowy lit edge instead of a hard chart line (spec §5.2, no blur). - float pxFeather = 1.5 / maxHalfWidth; // 1.5px expressed in xn units + // ── Evaluate the combined liquid SDF + its gradient (the surface normal). ────────── + // Central differences in height-norm space; the step is one device pixel = 1/h. + float hot; + float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot); + + float e = 1.0 / h; // one-pixel step in height-norm units + float ig; + float dRx = liquidSdf(p + vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig); + float dLx = liquidSdf(p - vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig); + float dDy = liquidSdf(p + vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig); + float dUy = liquidSdf(p - vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig); + vec2 grad = vec2(dRx - dLx, dDy - dUy); + vec2 normal = length(grad) > 1e-5 ? normalize(grad) : vec2(0.0, -1.0); + + // Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an + // anti-aliased edge instead of a hard chart line (no blur — spec §2/§3). + float pxFeather = 1.2 / h; float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d); + if (inside <= 0.0) { fragColor = vec4(0.0); return; } - // ── EFFECT 1: the morphing navy↔moss 2-D colour field (§4b). ─────────────────────── - // Two composed layers: - // (a) Low-frequency BASE FIELD over (time-axis, amplitude-axis), flowing on the - // uTimeSeconds clock at a rate set by uColorShiftSpeed → coherent across the - // window, never static. - // (b) Higher-frequency PER-BAR modulation keyed off the row's own loudness/phase → - // adjacent bars related but distinct, louder = brighter/greener (its own life). - // - // Colour-shift cycle: exponential map normalized speed → period (see consts), so the - // slider's slow end barely drifts and its fast end morphs briskly, never frozen. - float period = COLORSHIFT_PERIOD_SLOW * pow(COLORSHIFT_PERIOD_FAST / COLORSHIFT_PERIOD_SLOW, uColorShiftSpeed); - float phase = uTimeSeconds * (6.28318 / period); + // ── Simple serviceable theme fill (R3 replaces with the OKLab three-colour gradient). + // Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS + // (uColorAccent) at the extended edge. Just enough colour to read the physics; NOT the + // final colour model. No HSL, no vivify, no glass — those are gone (R3 owns colour). + float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0); + vec3 fill = mix(uColorEdge, uColorAccent, xnAbs); - // Time-axis coordinate of this fragment (where along the mix it sits) drives the - // field's along-scroll dimension; amplitude (|xn|) drives the along-bar dimension. - float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; - float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0); + // Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate). + float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT; + fill = mix(fill, HOT_TINT, hotLean); - // (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit - // sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps - // the whole field navy↔moss once per cycle, so dragging the slider visibly changes - // how fast the field morphs (the old version relied on noise drifting through a - // near-grey lerp, so the morph was invisible — Daniel's "slider does nothing"). - // The noise rides on top for organic, non-repeating variation across the window. - float sweep = 0.5 + 0.5 * sin(phase); // 0→1, one cycle per period - float drift = valueNoise(vec2(tHere * 0.15 + phase * 0.5, phase)); - drift += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); - drift = clamp(drift / 1.5, 0.0, 1.0); - float base = clamp(sweep * 0.6 + drift * 0.4, 0.0, 1.0); // time-sweep dominant + // A soft top-light shade off the surface normal so the wax has form (a single lazy + // gradient, not the old four-part glass). Keeps it from reading flat without competing + // with the (future) colour model. + float lightUp = clamp(dot(normal, vec2(0.0, -1.0)) * 0.5 + 0.5, 0.0, 1.0); + fill *= mix(0.82, 1.12, lightUp); - // (b) Along-bar: blend more toward MOSS at the peak, NAVY near the zero-line — gives - // each bar internal structure (spec §4b axis 1). Per-bar liveness: perturb by a - // noise keyed to this bar's time so neighbours differ. - float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter - float fieldMix = clamp(base * 0.55 + xnAbs * 0.30 + perBar * 0.20 + amp * 0.15, 0.0, 1.0); - - // VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB, - // so the path between them stays saturated instead of passing through the muddy grey - // midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance - // off the dark UI tokens so it reads as rich glassy navy ↔ vivid moss. accent = MOSS - // (peak/lively), edge = NAVY (zero-line/calm). - vec3 baseColor = vivify(mixHsl(uColorEdge, uColorAccent, fieldMix), amp); - // Pre-vivified accent for the glass rim/sheen below, so those highlights are vivid moss - // rather than the dull raw token (the rim is the strongest glass cue — keep it punchy). - vec3 vividAccent = vivify(uColorAccent, 1.0); - - // ── EFFECT 4: glass (§4f) — specular + Fresnel + frosted + refraction, all in-shader. - // Fixed virtual light from the upper-left; view direction is straight at the screen. - vec3 N = vec3(normal, 0.6); // lift normal off the plane so it has a z-face - N = normalize(N); - vec3 L = normalize(vec3(-0.5, 0.7, 0.8)); // light: upper-left, toward viewer - vec3 V = vec3(0.0, 0.0, 1.0); // view: straight on - vec3 Hh = normalize(L + V); // Blinn-Phong half-vector - - // (1) Refraction read: where the surface curves (near the edge), warp the field - // sample coords along the normal so the colour appears bent through the glass. - // Strongest at the rim, vanishing in the flat interior (uses |grad| as curvature). - float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0); - vec2 warp = normal * GLASS_REFRACT_WARP * curvature; - float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0); - // Warped read uses the same VIVID HSL mix as the straight read, so refraction bends a - // saturated colour through the lens rather than revealing the dull raw lerp. - vec3 glassColor = vivify(mixHsl(uColorEdge, uColorAccent, warpMix), amp); - glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read - - // (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the - // half-vector aligns with the normal; drifts as blobs move (normal changes). - float spec = pow(max(dot(N, Hh), 0.0), GLASS_SPECULAR_POWER); - // (3) Broad sheen along the upper edge — a soft secondary gloss band. - float sheen = pow(max(dot(N, L), 0.0), 2.0) * 0.25; - // (4) Fresnel rim glow — brightest at grazing angles (silhouette edges). The single - // most effective "this is glass" cue (spec §4f.4). - float fresnel = pow(1.0 - max(dot(N, V), 0.0), GLASS_FRESNEL_POWER); - - // REMOVED (Phase 10 W1 de-noise, spec §3): the screen-space "frost" alpha noise — - // frost = 0.85 + 0.15 * valueNoise(vec2(screenX*0.05, screenYTop*0.05)). It was a - // static value-noise keyed to SCREEN coordinates (not the moving fluid), so it sat as - // a fixed grainy/dirty film over the whole ribbon — exactly the "static-looking texture - // that makes the screen look dirty" Daniel rejected. Alpha is now the clean backdrop - // opacity with no grain. (Fluid-tied noise — the bubble swell and the colour-field drift, - // both keyed to mix-time — is retained: it moves with the audio and does not read as dirt.) - - // Compose the lit glass colour: field base + warped refraction, lifted by sheen and - // a Fresnel rim toward the VIVID moss accent, plus a white-hot specular dot. Using the - // vivified accent (not the dull raw token) keeps the glass cues punchy and glassy. - vec3 lit = glassColor; - lit += sheen * vividAccent; - lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss - lit += spec * vec3(1.0); // specular is white light - - // Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light. - // (Frost removed — see the de-noise note above.) Pre-multiplied output for the - // ONE/ONE_MINUS_SRC_ALPHA blend. - float alpha = inside * RIBBON_OPACITY; - alpha = clamp(alpha + inside * fresnel * RIBBON_OPACITY * 0.8, 0.0, 1.0); - - fragColor = vec4(lit * alpha, alpha); + float alpha = inside * RIBBON_OPACITY_R2; + fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_ALPHA } `; @@ -972,20 +841,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // Cache uniform locations once. A null here for a uniform we actually upload // means either the name is misspelled or the GLSL compiler dead-stripped it // (it isn't reachable in the shader) — both of which silently break a uniform's - // effect, so surface them. The Wave-3-reserved uniforms (`uTimeSeconds`, - // `uBubblyness`, `uDetach`, `uColorShiftSpeed`) are declared and uploaded but not - // yet consumed by the parity shader, so the compiler is free to dead-strip them; - // we exempt them from the warning to avoid a false alarm. Their values still reach - // the GPU when a location survives (verifiable in Wave 3). - const RESERVED_UNUSED = new Set(['timeSeconds', 'bubblyness', 'detach', 'colorShiftSpeed']); + // effect, so surface them. No reserved-unused exemptions remain: every uniform + // below is genuinely consumed by the R2 shader (the old inert Wave-3 control + // uniforms are gone — the lava params drive the CPU physics, not the shader). const u = { resolution: gl.getUniformLocation(program, 'uResolution'), playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), - bubblyness: gl.getUniformLocation(program, 'uBubblyness'), - detach: gl.getUniformLocation(program, 'uDetach'), - colorShiftSpeed: gl.getUniformLocation(program, 'uColorShiftSpeed'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), colorAccent: gl.getUniformLocation(program, 'uColorAccent'), colorEdge: gl.getUniformLocation(program, 'uColorEdge'), @@ -993,9 +856,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { datum: gl.getUniformLocation(program, 'uDatum'), datumWidth: gl.getUniformLocation(program, 'uDatumWidth'), datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'), + blobs: gl.getUniformLocation(program, 'uBlobs'), + blobCount: gl.getUniformLocation(program, 'uBlobCount'), }; for (const [name, loc] of Object.entries(u)) { - if (loc === null && !RESERVED_UNUSED.has(name)) { + if (loc === null) { console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`); } } @@ -1004,11 +869,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { let datum: Datum | null = null; let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() }; let visibleSeconds = DEFAULT_VISIBLE_SECONDS; - // Wave 2 control values, fed through the handle. Uploaded as uniforms in draw() but inert in the - // parity shader (Wave 3 consumes them). Seeded to the defaults that mirror MixVisualizerControlState. - let bubblyness = DEFAULT_BUBBLYNESS; - let detach = DEFAULT_DETACH; - let colorShiftSpeed = DEFAULT_COLOR_SHIFT_SPEED; + + // ── Lava physics control values (the R2 TEMP knob re-mapping — see the control-default + // consts at the top of this file). These are the dials the existing knobs feed, routed + // here by the handle setters. They drive the CPU physics step below, NOT a shader uniform. + let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat + let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity + let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness + let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it) /** * The *authoritative* playhead for this instant: the last pushed position advanced @@ -1086,10 +954,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); const resolved: ResolvedTheme = { accent: moss, edge: navy }; - // Report BOTH poles the shader will actually use, as 0-255 RGB + relative luminance. - // This is the line Daniel watches to confirm the "grey" cause: if the poles are dull - // here (low luminance / low spread) the fix is the in-shader vivify(); if they look - // saturated here the muddying was the old linear-RGB midpoint lerp (now HSL). + // Report BOTH poles the R2 fill will use, as 0-255 RGB + relative luminance. (The + // rich OKLab colour model is Wave R3; R2 just does a straight A→B theme fill — this + // line confirms the navy/moss poles resolved off the canvas vars in the active mode.) const fmt = (c: [number, number, number]) => `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`; debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`); @@ -1098,9 +965,283 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { let theme: ResolvedTheme = readTheme(); + // ════════════════════════════════════════════════════════════════════════════════ + // R2 — the CPU wax-blob physics. Integrated each frame (real dt), then packed into + // `blobUpload` and sent to the shader as uBlobs[]. Allocation-free per frame: the + // blob pool and the upload buffer are built once here and mutated in place. + // + // Space: height-normalized (pixel / canvasHeight). y ∈ [0,1] top→floor, x ∈ [0, aspect] + // where aspect = canvasWidth/canvasHeight. The FLOOR is y = 1 (the canvas bottom edge, + // already CSS-clipped to the footer top in R1). One isotropic unit → round blobs. + // ════════════════════════════════════════════════════════════════════════════════ + + interface Blob { + x: number; y: number; // centre, height-norm + vx: number; vy: number; // velocity, height-norm/s + r: number; // radius, height-norm (fixed per blob, density-biased) + temp: number; // temperature 0..1 + } + + // The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS, + // driven by the density dial) decides how many we simulate + upload this frame. + const blobs: Blob[] = []; + // The packed upload buffer (vec4 per blob). Reused every frame — no per-frame alloc. + const blobUpload = new Float32Array(MAX_BLOBS * 4); + + /** Cheap deterministic PRNG (mulberry32) so blob spawn is varied but reproducible. */ + function makeRng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rng = makeRng(0x1a2b3c4d); + + /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */ + function seedBlob(b: Blob, aspect: number): void { + // Density biases radius toward the small end as it rises (more, smaller blobs). + const radiusBias = 1 - blobDensity * 0.6; // density 0 → big, density 1 → smaller + const r = (BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN)) * radiusBias; + b.r = r; + b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width + b.y = 1 - r - rng() * 0.1; // pooled near the floor + b.vx = 0; + b.vy = 0; + b.temp = rng() * 0.3; // cool to start (heats at the floor) + } + + /** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */ + function initBlobs(aspect: number): void { + blobs.length = 0; + for (let i = 0; i < MAX_BLOBS; i++) { + const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, temp: 0 }; + seedBlob(b, aspect); + blobs.push(b); + } + } + let blobsInitialized = false; + + /** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */ + function liveBlobCount(): number { + return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT)); + } + + /** + * CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This + * mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision + * boundary matches the rendered waveform exactly. Reads the retained datum.samples. + */ + function sampleLoudnessAt(timeSeconds: number): number { + const d = datum; + if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0; + const n = d.sampleCount; + const p = (timeSeconds / d.durationSeconds) * n - 0.5; + const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1); + const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1); + const f = Math.min(Math.max(p - Math.floor(p), 0), 1); + const s0 = d.samples[i0] / 255; + const s1 = d.samples[i1] / 255; + return s0 + (s1 - s0) * f; + } + + /** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in. + * Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and + * dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square + * toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */ + function heatScaleFromDial(dial: number): number { + const d = Math.min(Math.max(dial, 0), 1); + return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1 + } + + /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive), + * dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to + * use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the + * same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */ + function restitution(soft: number, hard: number): number { + const d = Math.min(Math.max(collisionStrength, 0), 1); + return soft + (hard - soft) * d; + } + + /** + * Advance the physics by dt seconds. Sub-stepped for spring stability. The collision + * model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon + * surface normal, always on), blob↔blob (elastic, soft↔hard via the strength dial). + */ + function stepPhysics(dtTotal: number): void { + if (canvas.height <= 0) return; + const aspect = canvas.width / canvas.height; + + if (!blobsInitialized) { + initBlobs(aspect); + blobsInitialized = true; + } + + const count = liveBlobCount(); + const heatScale = heatScaleFromDial(lavaHeat); + const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN); + const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD); + const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD); + const collideHardness = Math.min(Math.max(collisionStrength, 0), 1); + + // Mix-time mapping at the current playhead (the waveform a blob's row sits over). + const nowYn = NOW_ANCHOR_FROM_TOP; + const secondsPerHeight = visibleSeconds; + const centreX = aspect * 0.5; + const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; + const playhead = effectivePlayhead(); + + const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS; + + for (let s = 0; s < PHYSICS_SUBSTEPS; s++) { + // ── Per-blob: heat exchange, buoyancy, gravity, damping, floor contact. ── + for (let i = 0; i < count; i++) { + const b = blobs[i]; + + // Heat exchange: floor heats (× heat dial), top cools, all relax to ambient. + const distFromFloor = 1 - b.y; + const distFromTop = b.y; + if (distFromFloor < HEAT_FLOOR_ZONE) { + const near = 1 - distFromFloor / HEAT_FLOOR_ZONE; // 1 at floor → 0 at zone edge + b.temp += (1 - b.temp) * HEAT_FLOOR_RATE * heatScale * near * dt; + } + if (distFromTop < HEAT_TOP_ZONE) { + const near = 1 - distFromTop / HEAT_TOP_ZONE; + b.temp += (0 - b.temp) * HEAT_TOP_RATE * near * dt; + } + b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt; + b.temp = Math.min(Math.max(b.temp, 0), 1); + + // Forces: gravity down (+y), buoyancy from temperature (up = −y when hot). + const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT); + b.vy += (gravity - buoyancy) * dt; + + // Viscous damping (lazy wax): frame-rate-independent exponential decay. + const damp = Math.exp(-VISCOUS_DAMPING * dt); + b.vx *= damp; + b.vy *= damp; + + // Integrate position. + b.x += b.vx * dt; + b.y += b.vy * dt; + + // Floor: soft contact spring + extra damping so resting wax pools and flattens. + const floorY = 1 - b.r; + if (b.y > floorY) { + const pen = b.y - floorY; + b.vy -= FLOOR_SPRING * pen * dt; // spring pushes up out of the floor + if (b.vy > 0) b.vy *= Math.exp(-FLOOR_CONTACT_DAMPING * dt); // kill the downward drive + b.y = floorY + pen * 0.5; // ease the penetration out (soft, no snap) + } + // Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools + // at the top and falls back; just keep it inside the box. + const ceilY = b.r; + if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; } + // Side walls: reflect softly so wax stays on screen. + if (b.x < b.r) { b.x = b.r; if (b.vx < 0) b.vx = -b.vx * 0.3; } + if (b.x > aspect - b.r) { b.x = aspect - b.r; if (b.vx > 0) b.vx = -b.vx * 0.3; } + } + + // ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ── + // The waveform is a centred vertical band of half-width = loudness(row). A blob + // whose centre is within (halfWidth + r) of the centre line penetrates it and is + // pushed out along the band's surface normal (horizontal). Read-only authority: + // the waveform is never moved, only the wax responds. + for (let i = 0; i < count; i++) { + const b = blobs[i]; + const t = playhead + (b.y - nowYn) * secondsPerHeight; + const amp = sampleLoudnessAt(t); + if (amp <= 0) continue; + const halfW = amp * maxHalf; + const dx = b.x - centreX; + const sideSign = dx >= 0 ? 1 : -1; // outward surface normal (in x) + const penetration = halfW + b.r - Math.abs(dx); + if (penetration > 0) { + // Soft penalty (the soft end of the dial): a spring proportional to the + // penetration depth pushes the wax out along the normal. Stronger as the + // dial → soft so the soft regime still recovers, just gently. + b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness * 0.5); + + // Hard elastic (the hard end): reflect the velocity component going INTO + // the ribbon back out, scaled by restitution × hardness. inwardSpeed > 0 + // means the blob is moving toward the centre line (into the surface). + const inwardSpeed = -sideSign * b.vx; + if (inwardSpeed > 0) { + // Remove the inward component and add back a restituted outward one. + b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness; + } + + // Positional push-out: firm at the hard end (no penetration allowed), + // partial at the soft end (wax squishes in then eases out via the spring). + b.x += sideSign * penetration * (0.3 + 0.7 * collideHardness); + } + } + + // ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ── + // O(count²) ≤ ~1k pair tests — trivial. Mass ∝ r² so big blobs shove small ones. + for (let i = 0; i < count; i++) { + const a = blobs[i]; + for (let j = i + 1; j < count; j++) { + const c = blobs[j]; + let dx = c.x - a.x; + let dy = c.y - a.y; + let dist = Math.hypot(dx, dy); + const minDist = a.r + c.r; + if (dist >= minDist || dist <= 1e-6) continue; + + const nx = dx / dist, ny = dy / dist; // collision normal a→c + const overlap = minDist - dist; + const ma = a.r * a.r, mc = c.r * c.r; // mass ∝ area + const invSum = 1 / (ma + mc); + + // Positional separation along the normal, mass-weighted (split the overlap). + const sep = overlap * (0.3 + 0.7 * collideHardness); + a.x -= nx * sep * (mc * invSum); + a.y -= ny * sep * (mc * invSum); + c.x += nx * sep * (ma * invSum); + c.y += ny * sep * (ma * invSum); + + // Soft penalty spring along the normal (gentle shove, low strength). + const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness * 0.6) * dt; + a.vx -= nx * springAcc; a.vy -= ny * springAcc; + c.vx += nx * springAcc; c.vy += ny * springAcc; + + // Elastic impulse along the normal (hard end), with restitution + mass. + const rvx = c.vx - a.vx, rvy = c.vy - a.vy; + const velAlongNormal = rvx * nx + rvy * ny; + if (velAlongNormal < 0) { // approaching + const e = collideRest * collideHardness; + const impulse = -(1 + e) * velAlongNormal * invSum; + a.vx -= impulse * mc * nx; a.vy -= impulse * mc * ny; + c.vx += impulse * ma * nx; c.vy += impulse * ma * ny; + } + } + } + } + } + + /** Pack the live blobs into the upload buffer. Returns the live count. */ + function packBlobs(): number { + const count = liveBlobCount(); + for (let i = 0; i < count; i++) { + const b = blobs[i]; + const o = i * 4; + blobUpload[o] = b.x; + blobUpload[o + 1] = b.y; + blobUpload[o + 2] = b.r; + blobUpload[o + 3] = b.temp; + } + return count; + } + let rafId: number | null = null; let disposed = false; const startTimeMs = performance.now(); + // Wall-clock anchor for the physics dt (separate from the playhead decay clock). + let lastPhysicsMs = performance.now(); // FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts // actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes @@ -1194,15 +1335,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no // separate dirty-tracking needed for scalars/vec3s). gl.uniform1f(u.visibleSeconds, visibleSeconds); - // Wave 2 control uniforms. Uploaded every frame (cheap scalars); inert in the parity shader. - // gl.uniform1f with a null location (dead-stripped uniform) is a documented silent no-op, so - // these are safe to set unconditionally even before the Wave 3 shader references them. - gl.uniform1f(u.bubblyness, bubblyness); - gl.uniform1f(u.detach, detach); - gl.uniform1f(u.colorShiftSpeed, colorShiftSpeed); gl.uniform3fv(u.colorAccent, theme.accent); gl.uniform3fv(u.colorEdge, theme.edge); + // Advance the wax-blob physics by the real elapsed time, then upload the blobs. + // Stepping here (rather than in the loop) means idle one-shot redraws also advance + // the sim by their actual dt — clamped by PHYSICS_MAX_DT, so a long paused gap just + // means the lamp barely moves while paused (it animates with playback, spec §E). + const nowMs = performance.now(); + const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000); + lastPhysicsMs = nowMs; + stepPhysics(physicsDt); + const liveCount = packBlobs(); + gl.uniform4fv(u.blobs, blobUpload); + gl.uniform1i(u.blobCount, liveCount); + if (datum) { gl.uniform1f(u.hasDatum, 1); gl.uniform1f(u.durationSeconds, datum.durationSeconds); @@ -1250,8 +1397,8 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() // + the decaying jitter-correction offset), which advances the last pushed position by real time // elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds - // monotonic clock is reserved for Wave 3's field/blob motion and is unused by this parity shader; - // it is NOT what drives the scroll here.) + // monotonic clock drives the blob-radius wobble in the shader; the CPU physics uses its own + // wall-clock dt — neither drives the scroll, which is the playhead alone.) /** Draw one still frame immediately, without scheduling a new rAF. */ function redrawOnce(): void { @@ -1271,6 +1418,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // would collapse the offset in one step. (Offset is 0 at play-start today, so // this is belt-and-braces, but it keeps the decay honest if that ever changes.) lastRenderWallClockMs = performance.now(); + // Re-base the physics clock too, so the first frame's dt is one frame, not the idle + // gap since the last redraw (which would advance the lamp by a clamped jump on resume). + lastPhysicsMs = performance.now(); rafId = requestAnimationFrame(frame); } @@ -1310,6 +1460,24 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (windowMs >= 1000) { const fps = (fpsFrameCount * 1000) / windowMs; debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`); + // Lava diagnostic: the dials in play + how many blobs are currently buoyant + // (temp above ambient) and how many are pooled on the floor. Daniel watches + // this to confirm heat 0 = all-resting and heat-up = rising count climbs. + const live = liveBlobCount(); + let buoyant = 0; + let pooled = 0; + let avgTemp = 0; + for (let i = 0; i < live; i++) { + const b = blobs[i]; + avgTemp += b.temp; + if (b.temp > TEMP_AMBIENT) buoyant++; + if (b.y > 1 - b.r - 0.04) pooled++; + } + debugLog( + `lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` + + `collision=${collisionStrength.toFixed(2)} density=${blobDensity.toFixed(2)} | ` + + `blobs=${live} buoyant=${buoyant} pooled=${pooled} avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)}.`, + ); fpsFrameCount = 0; fpsWindowStartMs = nowMs; } @@ -1406,7 +1574,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); - return { texture, texWidth, texHeight, sampleCount, durationSeconds }; + return { texture, texWidth, texHeight, sampleCount, durationSeconds, samples }; } return { @@ -1482,22 +1650,31 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (idleRedraw) redrawOnce(); }, - // The three Wave 2 controls. Each clamps to [0,1], stores the value (uploaded as a uniform in - // draw()), and forces one still frame while idle — mirroring setZoom — so the new value reaches - // the GPU even when paused. INERT in Wave 2: the parity shader does not read these uniforms, so - // a change does not visibly alter the render; the value is verifiable in Wave 3. + // ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob + // set). The bridge still calls these three setters by their OLD names — the names are + // a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one + // wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel + // can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions + // still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI. + // setBubblyness ← "Bubblyness" knob → lava GRAVITY + // setDetach ← "Detach" knob → lava HEAT + // setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH + // Idle redraw mirrors setZoom so a paused tweak still updates the still frame. setBubblyness(value: number): void { - bubblyness = Math.min(1, Math.max(0, value)); + lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity + debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); }, setDetach(value: number): void { - detach = Math.min(1, Math.max(0, value)); + lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat + debugLog(`setHeat (via setDetach) → ${lavaHeat.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); }, setColorShiftSpeed(value: number): void { - colorShiftSpeed = Math.min(1, Math.max(0, value)); + collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness + debugLog(`setCollisionStrength (via setColorShiftSpeed) → ${collisionStrength.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); },