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();
},