Files
deepdrft/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
T

2179 lines
122 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
* already-played audio exits off the top, and the "now" playhead sits at a fixed
* 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, 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 12;
* the playhead wall-clock interpolation + jitter correction carry forward as-is.
*
* 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:
* • 1632 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 (Wave R3) is the OKLab THREE-COLOUR gradient: A→B linear from the centre line
* outward, A and B rotating among three theme anchors (navy/moss/off-white) at the
* gradient-rotation dial's rate, with a per-segment mix-time sinusoid (colour "waves"
* baked per segment) and a per-bar curve shift (A-dominant low → B-dominant high). OKLab
* keeps the blend faithful — no HSL cyan excursion. No glass, no screen-space noise (R1).
*
* The Blazor component owns the canvas element and the inputs (datum, playback,
* scroll speed, 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`. As of Phase 10 the handle exposes EIGHT dedicated control setters
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount /
* setFluidViscosity / setCollisionStrength / setWaveformWidth) — the single density knob is split into
* fluid-amount + fluid-viscosity. As of Wave R3 the
* gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation.
*
* PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and
* the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio
* is paused; only the waveform scroll/playhead freezes (effectivePlayhead() holds the static pushed
* position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose.
*/
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
/**
* Hard anchor: at maximum zoom the window shows exactly one quarter note at
* 180 BPM = 60 / 180 s = 0.333 s of audio, top to bottom. This is a fixed
* requirement, not a tunable.
*/
export const MIN_VISIBLE_SECONDS = 60 / 180; // 0.3333… s — quarter note @ 180 BPM
/** Slow end of the zoom range — how much of the mix is visible at minimum zoom. Tunable. */
export const MAX_VISIBLE_SECONDS = 30;
/** Default opening window when a mix is first opened. Tunable. */
export const DEFAULT_VISIBLE_SECONDS = 10;
// ── 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 seven are
// normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it
// reaches setScrollSpeed; it arrives here already in seconds).
//
// Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The
// single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping:
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE
// as of Wave R3; drives the OKLab gradient's anchor rotation
// • Lava gravity → gravity dial (setLavaGravity)
// • Lava heat → heat dial (setLavaHeat)
// • Fluid amount → blob count + per-blob volume (setFluidAmount)
// • Fluid viscosity/cohesion → sphere-restoration: smin blend + wobble (setFluidViscosity)
// • Collision strength → collision hardness dial (setCollisionStrength)
// • Waveform width → ribbon half-width uniform (setWaveformWidth)
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he
// tunes on screen from here.
/** Default GRAVITY dial. Mirrors C# DefaultLavaGravity.
* Tuned to Daniel's sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_LAVA_GRAVITY = 0.2;
/** Default HEAT dial. Mirrors C# DefaultLavaHeat.
* Tuned to Daniel's sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_LAVA_HEAT = 1.0;
/** Default COLLISION-STRENGTH dial. Mirrors C# DefaultCollisionStrength.
* Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
export const DEFAULT_COLLISION_STRENGTH = 0.5;
/** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10
* split): how much wax is in the container — blob count + per-blob volume. 0 = few small blobs,
* 1 = many larger blobs (more fluid). */
export const DEFAULT_FLUID_AMOUNT = 0.4;
/** Default FLUID VISCOSITY / COHESION. Mirrors C# DefaultFluidViscosity. The "bubbles" knob's second
* half (Phase 10 split): how strongly the wax holds a spherical shape. 1 = high cohesion (crisp
* spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged under inertia).
* Default leans cohesive so the at-rest look is rounded wax. */
export const DEFAULT_FLUID_VISCOSITY = 0.6;
/**
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
* [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at
* which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z).
*/
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.45;
/**
* Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor
* step X→Y, so 3 ring-units is a full X→Y→Z→X cycle). 0.18 → a full three-colour cycle in
* ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end.
* Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead.
*/
// Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full
// three-colour cycle took ~17 s at full dial and ~49 s at the 0.3 default — below the threshold of
// "this is moving". Raised so the dial has obvious effect: 0.6 → a full cycle in ~5 s at full speed,
// and the default (now 0.45) cycles in ~7 s — clearly rotating, still meditative not strobing.
const GRADIENT_ROTATION_RATE_MAX = 0.6;
const GRADIENT_ROTATION_RATE_MIN = 0.03;
/**
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful
* 10%95% ribbon-extent band (Phase 10 §3.7 — see effectiveWaveformWidth); 0.5 opens mid-band.
*/
export const DEFAULT_WAVEFORM_WIDTH = 0.5;
/**
* Where the "now" line sits within the window, as a fraction from the top.
* 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
* Tunable. NOTE: kept in sync with the GLSL constant NOW_ANCHOR_FROM_TOP below.
*/
const NOW_ANCHOR_FROM_TOP = 0.5;
/**
* 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.
*/
const RIBBON_HALF_WIDTH_FRAC = 0.92;
/**
* Cap device-pixel-ratio at 2. Beyond that the extra fragments cost frame time for
* no visible gain on a soft glassy backdrop — this is the graceful-degrade lever
* (spec §5.1): drop internal resolution before dropping frames.
*/
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 ~20100px 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. Applied as v *= exp(DAMPING·dt) each
* step, so it is frame-rate independent.
*
* R2 tuning (Daniel #2 — "too viscous, needs to melt into a single fluid"): dropped from
* 1.4 to 0.55 so the wax is markedly LESS stiff and flows together instead of holding as
* distinct globs. Combined with the larger smin coalescence (BLOB_SMOOTHMIN_K below) and
* the elastic throw, the surface now reads as a unified fluid body rather than separate
* stiff blobs. Still > water so it stays a lazy lava, not a splash.
*/
const VISCOUS_DAMPING = 0.55;
/**
* Hard speed clamp in height-units/s, applied after every substep. R2 jitter fix (Daniel
* #5): with the lower viscosity + higher elasticity, a deep overlap resolved by the elastic
* impulse could occasionally fling a blob fast enough to tunnel and re-collide next step —
* the buzz. Capping the per-axis speed keeps the integrator stable (no explosive feedback)
* while being far above any speed real convection produces, so it never throttles the look.
*/
const MAX_BLOB_SPEED = 2.5;
/**
* 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 = 9.0; // soft penalty stiffness (height-units/s² per overlap)
const BLOB_RESTITUTION_HARD = 1.15; // elastic restitution at strength = 1 — over-unity = the springy "throw" (Daniel #6)
const BLOB_RESTITUTION_SOFT = 0.05; // residual restitution at strength = 0 (almost pure mush, Daniel #3)
/**
* 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.
*/
// Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax).
// Restitution is now SUB-unity: a real bounce conserves-or-loses energy, never adds it —
// over-unity (the old 1.1) injected energy each contact and read as "explosive". 0.85 at the hard end
// is lively/springy; the soft end stays near-zero (mush).
const WAVE_COLLIDE_SPRING = 10.0; // soft penalty stiffness pushing wax off the ribbon (slightly softer)
const WAVE_RESTITUTION_HARD = 0.85; // springy but energy-bounded reflection at full hardness (no explosion)
const WAVE_RESTITUTION_SOFT = 0.05; // near-pure mush at the soft end (Daniel #3)
/**
* Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates
* the ribbon we add a small UPWARD (y) nudge so loud transients lift bubbles toward the surface
* rather than only shoving them sideways.
*
* Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration ×
* hardness × dt, accumulated on a sustained loud passage and launched bubbles off-screen — the
* "explosive" feel. Cut to a gentle lift and CAPPED per contact (see the clamp in stepPhysics) so a
* deep/sustained overlap can't pump unbounded upward speed. Reads as a bouncy bob, not a rocket.
*/
const WAVE_THROW_UP = 9.0;
/** Hard cap on the per-contact upward throw velocity (height-units/s) so a sustained loud transient
* can never accumulate into an off-screen launch. Well above a natural bob, far below escape speed. */
const WAVE_THROW_UP_MAX = 0.6;
/**
* 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.
* R2 jitter fix (Daniel #5): raised 2 → 4. The lower viscosity + higher restitution make
* the contact response stiffer relative to the step, so each frame's dt is now resolved in
* four smaller passes — the collisions settle smoothly instead of buzzing. 4 × ≤32² pair
* tests ≈ 4k/frame, still trivial, and the frame budget is untouched (FPS holds at 60).
*/
const PHYSICS_SUBSTEPS = 4;
/**
* Energy-coupled dynamics (Daniel #7 — "at higher heat, bubbles are SMALLER and move with
* MORE TURBULENCE"). Heat (the heatScale transfer output) drives two effects each step:
*
* • SIZE: a blob's effective radius shrinks toward HEAT_RADIUS_MIN_SCALE of its base radius
* as it heats. High heat ⇒ a swarm of small lively bubbles; low heat ⇒ fewer, larger,
* calmer wax. The shrink is applied to the SIMULATED radius (so collisions match what's
* drawn) and tracks temperature continuously, so a blob grows back as it cools at the top.
*
* • TURBULENCE: a divergence-free-ish curl of value-noise injects a small random velocity
* each step, scaled by heatScale × the blob's own temperature, so only HOT wax churns.
* This is what makes high heat read as turbulent and low heat as a calm pool.
*/
const HEAT_RADIUS_MIN_SCALE = 0.45; // hottest blob shrinks to 45% of its base radius
const TURBULENCE_ACCEL = 3.2; // peak turbulent accel (height-units/s²) at full heat × full temp
const TURBULENCE_RATE = 1.9; // how fast the turbulence field evolves (rad/s scale)
/**
* Playhead-correction smoothing time constant, in seconds. Governs how fast the
* rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push.
*
* The problem: the player's position reports are irregular at startup (buffering /
* playback ramp-up), so each push lands a position that doesn't match where the
* wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior
* behaviour) made that gap a visible snap every push — the startup jitter.
*
* The fix (classic netcode-style entity reconciliation): the player stays the sole
* source of truth, but instead of rendering the authoritative position directly, we
* render authoritative + a small *correction offset* that decays toward zero every
* frame. On each push we fold the re-anchor discontinuity into that offset so the
* rendered playhead is continuous across the push, then bleed the offset off over
* ~this time constant. This eases the snap into a sub-perceptible glide.
*
* Why an offset that decays to zero, not an absolute lerp toward target: a lerp
* toward the target leaves a steady-state lag proportional to velocity (the render
* always trailing real playback). Decaying the *error* to zero converges the
* rendered playhead back onto the authoritative one, so once pushes steady the
* offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and
* steady-state is unchanged as required.
*
* 0.12 s is a sensible default: long enough to dissolve the worst startup snaps
* (tens of ms of position error), short enough that the correction is imperceptible
* and the render never trails real playback by more than a few ms. Tunable.
*/
const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12;
/**
* Below this absolute correction (seconds) we snap the offset to 0 and stop easing —
* an exponential decay never mathematically reaches zero, and carrying a sub-ms
* residual forever is pointless. ~0.5 ms is well under one frame of motion at any
* real zoom, so collapsing it is invisible.
*/
const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// ── Diagnostics ──────────────────────────────────────────────────────────────────
//
// Set true to surface the init/draw/datum/playback seams to the browser console
// (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this
// flag — they only trigger on the abnormal path. The verbose `log` paths (datum
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
// NOTE: defaults to false; set true temporarily to surface verbose seams in-browser.
const DEBUG = false;
const TAG = '[MixVisualizer]';
function debugLog(...args: unknown[]): void {
if (DEBUG) console.log(TAG, ...args);
}
// ── Theme: the THREE colour anchors (X, Y, Z), read live from the active palette. ──
//
// The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain
// floats. So we read the computed `--mud-palette-*` custom properties straight off
// the canvas element (which inherits them from the page), parse them to RGB, and
// upload them as vec3 colour uniforms. The bespoke light/dark themes swap those vars
// when dark mode toggles, so re-reading + re-uploading them re-themes the field with
// no reload. The component just calls `refreshTheme()` after a dark-mode change.
//
// Wave R3 binding — the THREE anchors of the OKLab gradient (spec §6a/§6b motion 1).
// The gradient's two stops (A = centre/root, B = outer/edge) ROTATE among these three
// over time. The palette's signature triad is navy / moss / off-white — the identity
// `DeepDrftPalettes` is built on (see the class doc comment there). All three are read
// from the live palette vars (single source of truth — spec §6a): no hardcoded hexes.
// - X = NAVY (the dark ground / navy-mid)
// - Y = MOSS (the interactive green)
// - Z = OFF-WHITE (the warm paper ground) — the chosen third anchor (surfaced in handoff)
//
// The cross-mode problem (spec §6a, explicit): navy / moss / off-white are NOT a single
// stable set of CSS vars across both palettes. Navy is `--mud-palette-primary` in LIGHT
// but the `--mud-palette-background` ground in DARK; moss is `--mud-palette-secondary` in
// LIGHT but `--mud-palette-primary` in DARK (where green IS primary); off-white is the
// `--mud-palette-background` ground in LIGHT but `--mud-palette-secondary` in DARK. No one
// var holds a given identity in both modes. So we detect the mode in JS (by the luminance
// of the page background — the bespoke dark ground #0D1B2A is near-black, the light ground
// #FAFAF8 is near-white) and bind the three anchors per mode. refreshTheme re-runs this on
// a dark-mode toggle, so the field re-themes live.
interface ResolvedTheme {
/** Navy anchor (X) RGB [0,1] — uploaded to uColorNavy. */
navy: [number, number, number];
/** Moss-green anchor (Y) RGB [0,1] — uploaded to uColorMoss. */
moss: [number, number, number];
/** Off-white anchor (Z) RGB [0,1] — uploaded to uColorPaper. */
paper: [number, number, number];
}
/** sRGB relative luminance (cheap Rec.709 weights) of a normalized RGB triple. */
function luminance([r, g, b]: [number, number, number]): number {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Read a CSS custom property off an element, falling back if it is empty/undefined.
* An empty value means the var did not inherit onto the canvas (e.g. the palette is
* scoped to a wrapper the canvas isn't under), which would silently swap the ribbon
* colour for the hardcoded default — so warn on it when diagnosing.
*/
function readVar(el: Element, name: string, fallback: string): string {
const v = getComputedStyle(el).getPropertyValue(name).trim();
if (v.length === 0) {
if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`);
return fallback;
}
return v;
}
/**
* Parse a CSS colour string to normalized [0,1] RGB. Handles #rgb / #rrggbb and
* rgb()/rgba() — the only forms MudBlazor emits for these palette vars. Falls back
* to mid-grey on anything unrecognised so a parse miss degrades to a visible
* ribbon rather than black.
*/
function parseColor(css: string): [number, number, number] {
const s = css.trim();
if (s.startsWith('#')) {
let hex = s.slice(1);
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
return [r / 255, g / 255, b / 255];
}
}
}
const m = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
if (m) {
return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255];
}
return [0.5, 0.5, 0.5];
}
// ── Datum: the pre-downloaded loudness profile (spec §F). ────────────────────────
interface Datum {
/**
* GPU texture holding the loudness samples (R8). Laid out as a 2-D grid that
* respects GL_MAX_TEXTURE_SIZE (see uploadDatum) rather than a 1×N row, which
* blows past the max texture width for any mix over ~49 s at the ~333 samples/s
* datum density. The shader reads it with texelFetch (integer addressing), so no
* hardware filtering is used — see sampleAt for the manual interpolation.
*/
texture: WebGLTexture;
/** Texture width in texels (samples per row). */
texWidth: number;
/** Texture height in texels (number of rows). */
texHeight: number;
/** Number of real samples in the datum (≤ texWidth*texHeight; the tail row is padded). */
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 {
/**
* Last playback head pushed from Blazor, in seconds. This is the *authoritative*
* position the player last reported — it updates only on the ~10 Hz setPlayback
* push, NOT every frame. The per-frame scroll uses the interpolated
* effectivePlayhead (see draw()), anchored on this value.
*/
positionSeconds: number;
/** Whether audio is actively playing. Gates whether the playhead ADVANCES (scroll) or HOLDS
* (freeze) — NOT whether the rAF loop runs (the loop is continuous now, Part C). */
isPlaying: boolean;
/**
* performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
* advances the playhead by wall-clock elapsed since this anchor so the ribbon
* scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes,
* instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on
* every push, so each push is a small correction rather than a hard reset.
*/
pushWallClockMs: number;
}
export interface MixVisualizerHandle {
setDatum(samplesBase64: string, durationSeconds: number): void;
setPlayback(positionSeconds: number, isPlaying: boolean): void;
/** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */
setScrollSpeed(visibleSeconds: number): void;
/** [0,1]. Colour anchor-rotation rate — drives the OKLab gradient's Motion 1 (live, R3). */
setGradientRotationSpeed(value: number): void;
/** [0,1]. Downward force on the wax. */
setLavaGravity(value: number): void;
/** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
setLavaHeat(value: number): void;
/** [0,1]. Amount of wax — blob count + per-blob volume. */
setFluidAmount(value: number): void;
/** [0,1]. Fluid viscosity / cohesion — how strongly wax restores to a sphere (1) vs stays
* deformed/gooey (0). Drives the metaball smin blend + wobble; no per-fragment cost change. */
setFluidViscosity(value: number): void;
/** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
setWaveformWidth(value: number): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
dispose(): void;
}
/**
* Decode the base64 loudness datum (bytes [0,255]) into a Uint8Array suitable for
* direct upload as an R8 texture. Done once per datum, off the animation path. We
* keep the bytes as [0,255] and let the GPU normalize to [0,1] on sample (R8
* UNORM), which mirrors the predecessor's /255 and avoids a CPU float pass.
*/
function decodeSamples(base64: string): Uint8Array {
const binary = atob(base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
// ── Shaders. ─────────────────────────────────────────────────────────────────────
//
// Vertex: trivial pass-through. We draw a single triangle that more than covers the
// clip-space box ([-1,1]²) so every pixel of the canvas is rasterized once. (One
// oversized triangle is the standard full-screen trick — cheaper than two and has
// no diagonal seam.) gl_FragCoord in the fragment shader then gives us the pixel's
// screen position directly.
const VERTEX_SHADER = `#version 300 es
// Three clip-space vertices forming one big triangle covering [-1,1]².
const vec2 POSITIONS[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0)
);
void main() {
gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0);
}
`;
// Fragment: THE SCROLL + ZOOM MATH (spec §A, §B), ported intact from the Canvas
// predecessor's per-row loop into a per-fragment evaluation. Read this top to
// bottom to follow how a quarter-note-@-180-BPM becomes 0.333 s becomes a texture
// coordinate becomes a lit pixel.
//
// Coordinate model (matches the Canvas predecessor exactly):
// - gl_FragCoord.xy is in device pixels, origin BOTTOM-left in WebGL. The Canvas
// used a TOP-left origin with Y increasing downward. We flip Y once up front
// (screenYTop) so all the time math below reads identically to the Canvas
// version: screenYTop = 0 at the top edge, = uResolution.y at the bottom.
// - The "now" line is a fixed screen Y: nowY = height * NOW_ANCHOR_FROM_TOP.
// - Audio flows UP: newer audio is drawn lower and scrolls up past the now line.
// * audio BELOW the now line (screenYTop > nowY) is the lead-in (not yet played)
// * audio ABOVE the now line (screenYTop < nowY) is the trail-out (just played)
//
// Zoom -> time-span -> pixels:
// - uVisibleSeconds is the whole window's time span, top to bottom. At max zoom
// this is MIN_VISIBLE_SECONDS (0.333 s); at min zoom MAX_VISIBLE_SECONDS.
// - pixelsPerSecond = height / uVisibleSeconds. Smaller visibleSeconds => more px
// per second => the same audio sweeps the window faster at a fixed playback
// rate. That IS the Guitar-Hero coupling: apparent scroll speed falls straight
// out of the zoom, with no separate speed control.
//
// Time at a given screen Y:
// - At nowY the time is uPlayheadSeconds.
// - Moving DOWN by 1 px (screenYTop +1) adds (1 / pixelsPerSecond) seconds.
// - So: timeAt(y) = playhead + (screenYTop - nowY) / pixelsPerSecond
//
// Sample at a given time:
// - time / durationSeconds is the normalized position along the mix; multiplied by
// the sample count it becomes a continuous sample index. sampleAt interpolates
// between the two bracketing samples by hand (texelFetch + fract lerp) — see the
// note on its definition for WHY we can't use hardware LINEAR with the 2-D layout.
// - Outside [0, durationSeconds] we force loudness to 0. That is what gives the
// "scrolls in from empty / out to empty" behaviour at the very start and end of
// the mix (spec §A) with no special-casing. (CLAMP_TO_EDGE on the texture would
// otherwise repeat the edge sample, so we gate explicitly here.)
const FRAGMENT_SHADER = `#version 300 es
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
uniform float uVisibleSeconds; // zoom: window time-span (per change)
uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
// low = gooey/deformed (drives the smin blend width + wobble below)
// 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)
// R3 — the three OKLab gradient anchors (X/Y/Z), read live from the palette (per theme).
uniform vec3 uColorNavy; // X — navy anchor
uniform vec3 uColorMoss; // Y — moss anchor
uniform vec3 uColorPaper; // Z — off-white anchor
// R3 — gradient anchor-rotation PHASE (radians), integrated CPU-side from the same
// uTimeSeconds clock at the rotation-speed dial's rate (so a speed change never snaps the
// phase). Drives Motion 1: which two of the three anchors A and B are right now (spec §6b).
uniform float uGradientPhase;
uniform float uHasDatum; // 1.0 when a datum texture is bound, else 0.0
uniform sampler2D uDatum; // loudness profile, R8, 2-D grid, NEAREST (texelFetch)
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_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)};
// ── R2 in-shader tuning constants (Daniel tunes by editing here). ───────────────────
// 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;
// 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.
// R2 (Daniel #2 — "melt into a single fluid"): raised 0.045 → 0.085 so neighbouring blobs
// fuse into one continuous fluid body across wider gaps rather than reading as separate globs.
const float BLOB_SMOOTHMIN_K = 0.085;
// 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.
// R2: raised 0.03 → 0.055 to match the fattier wax-union neck (continuous fluid surface).
const float WAVE_SMOOTHMIN_K = 0.055;
// 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)
// 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. Subordinate to the R3 gradient (spec §4f) — 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.25; // max lean at temperature 1 (above ambient)
// ── R3 colour-gradient tuning (the three motions, spec §6b). Daniel tunes by editing here. ──
// Motion 1 — the phase OFFSET between anchor A (centre/root) and anchor B (outer/edge) as
// they rotate among the three anchors. A non-zero offset means A and B sit at different
// points on the X→Y→Z ring, so the gradient always spans two distinct colours rather than
// collapsing to one. 1.0 = a full one-anchor lead (e.g. A on navy while B is on moss).
const float GRADIENT_AB_PHASE_OFFSET = 1.0;
// Motion 2 — per-bar sinusoidal variation, KEYED TO MIX-TIME (spec §6b motion 2, decided
// realization). Because mix-time is fixed for a given segment, the sinusoid is a pure
// function of mix-time and therefore baked-per-segment by construction: it travels with the
// segment as it scrolls, no ring buffer. AMOUNT is the ± phase wobble it adds to the anchor
// rotation (in ring units); FREQ is how many colour "waves" pack into one second of mix.
const float SEG_WAVE_AMOUNT = 0.35; // ± ring-phase wobble per segment
const float SEG_WAVE_FREQ = 1.7; // colour waves per second of mix-time
// Motion 3 — per-bar gradient CURVE shift with scroll height (spec §6b motion 3). A bar is
// mostly A at the bottom and mostly B by the top: we bias the centre→outer A→B mix toward A
// low on screen and toward B high on screen, so colour appears to climb outward as the bar
// scrolls up. This is the max ± shift applied to the A→B mix fraction across the screen.
const float CURVE_SHIFT_AMOUNT = 0.45;
// 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
// and wrap modes — it reads the exact texel — so the row-wrap layout is invisible
// to the caller.
float fetchSample(int i) {
int col = i % uDatumWidth;
int row = i / uDatumWidth;
return texelFetch(uDatum, ivec2(col, row), 0).r;
}
// Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out).
//
// Interpolation note: we cannot lean on hardware LINEAR filtering here. The datum
// is laid across a 2-D grid (1×N would exceed GL_MAX_TEXTURE_SIZE past ~49 s of
// mix), and a hardware 2D-LINEAR read would blend across the row-wrap seam at the
// end of every row — sample[width-1] would wrongly bleed into sample[width] of the
// next row, and bilinear would also pull in the row above/below. So we do the
// linear interpolation by hand along the TIME axis only: bracket the fractional
// sample position with the two neighbouring texels, texelFetch each (each correctly
// mapped to its own 2-D texel), and lerp. Exact, no seam artifact.
//
// Texel-centre convention: this reproduces the predecessor's 1-D LINEAR read bit for
// bit. There, u = t/duration sampled an N-texel LINEAR texture, whose texel centres
// sit at (i+0.5)/N — so u maps to texel-space position u*N - 0.5, interpolating
// between floor() and floor()+1 of that, with CLAMP_TO_EDGE at the ends. We mirror
// exactly that here: the -0.5 and the index clamps to [0, N-1] are the CLAMP_TO_EDGE
// behaviour at both extremes.
float sampleAt(float timeSeconds) {
if (uHasDatum < 0.5) return 0.0;
if (timeSeconds < 0.0 || timeSeconds >= uDurationSeconds) return 0.0;
float n = float(uDatumSampleCount);
// Continuous texel-space position, half-texel shifted to match LINEAR centres.
float p = (timeSeconds / uDurationSeconds) * n - 0.5;
int i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1);
int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1);
float f = clamp(p - floor(p), 0.0, 1.0);
// Smootherstep (C1-continuous Hermite) blend between the two bracketing samples instead of a
// straight linear lerp. Linear reconstruction connects samples with straight segments, so the
// ribbon edge reads as faceted polygons; the Hermite ease gives a smooth sinusoid-shaped contour
// between samples with zero slope at each sample point (Phase 10 tuning — smooth, not polygonal).
float fs = f * f * (3.0 - 2.0 * f);
return mix(fetchSample(i0), fetchSample(i1), fs);
}
// ════════════════════════════════════════════════════════════════════════════════════
// 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 (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. 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);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Smoothstep (Hermite) interpolation of the four lattice corners — C1-continuous.
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i + vec2(0.0, 0.0));
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
// ── Signed-distance primitives + smooth-min (the metaball machinery). ───────────────
// Circle SDF (a metaball centre) — the wax blob primitive.
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
// Polynomial smooth-min (Inigo Quilez). Blends two SDFs into one continuous surface —
// the metaball union. k controls the blend radius (the size of the liquid "neck" where
// two blobs merge). As k→0 it becomes a hard min (discrete shapes); larger k fuses them.
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// ── OKLab colour interpolation (spec §6c — replaces the rejected HSL mixHsl/vivify). ─
//
// OKLab is a perceptually-uniform space (Björn Ottosson). A straight line between two
// colours in OKLab stays perceptually faithful — no hue drift, no saturation pumping, no
// rainbow excursion. That is the structural fix for the navy→moss cyan bug: HSL hue-lerp
// between blue and green passes through cyan; OKLab does not. We convert each anchor
// linear-sRGB → OKLab, mix() in OKLab, convert back — all per-fragment (cheap: a cube
// root and two 3×3 matmuls each way).
//
// The uColor* uniforms arrive as GAMMA sRGB [0,1] (parsed straight from the CSS hex), so
// we linearise on the way in and re-encode gamma on the way out.
float srgbToLinear(float c) {
return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4);
}
float linearToSrgb(float c) {
return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1.0 / 2.4) - 0.055;
}
vec3 srgbToLinear3(vec3 c) {
return vec3(srgbToLinear(c.r), srgbToLinear(c.g), srgbToLinear(c.b));
}
vec3 linearToSrgb3(vec3 c) {
return vec3(linearToSrgb(c.r), linearToSrgb(c.g), linearToSrgb(c.b));
}
// linear-sRGB → OKLab (Ottosson's standard matrices).
vec3 linearToOklab(vec3 c) {
float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
float l_ = pow(l, 1.0 / 3.0);
float m_ = pow(m, 1.0 / 3.0);
float s_ = pow(s, 1.0 / 3.0);
return vec3(
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
);
}
// OKLab → linear-sRGB (the inverse).
vec3 oklabToLinear(vec3 lab) {
float l_ = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
float m_ = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
float s_ = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z;
float l = l_ * l_ * l_;
float m = m_ * m_ * m_;
float s = s_ * s_ * s_;
return vec3(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
);
}
// Chroma (vibrancy) boost in OKLab (Phase 10 — Daniel: "colours too muted, more punch"). OKLab's L
// is lightness; (a,b) is the chroma vector. Scaling (a,b) about the neutral axis raises saturation
// while preserving hue (the a:b ratio) and lightness (L untouched), so the palette-sourced navy/moss/
// off-white stay themselves — just more vivid. No hardcoded hexes: the anchors remain the live palette
// vars (spec §6a), this only amplifies their existing chroma. >1 = more punch.
const float CHROMA_BOOST = 1.45;
vec3 vivifyOklab(vec3 lab) {
return vec3(lab.x, lab.y * CHROMA_BOOST, lab.z * CHROMA_BOOST);
}
// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → boost chroma → lerp → back to gamma
// sRGB. The chroma boost gives the gradient punch (Phase 10) while OKLab keeps the blend faithful.
vec3 mixOklab(vec3 a, vec3 b, float t) {
vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a)));
vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b)));
vec3 m = mix(la, lb, t);
return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0);
}
// One of the three anchors as a continuous function of a phase in [0,3): a triangular
// blend around the ring X→Y→Z→X so the picked anchor travels smoothly through all three
// (OKLab-interpolated, so the transitions stay faithful). phase need not be wrapped —
// we fract it to the ring here.
vec3 anchorAtPhase(float phase) {
float p = fract(phase / 3.0) * 3.0; // [0,3)
float seg = floor(p); // 0,1,2
float f = p - seg; // [0,1) within the segment
if (seg < 0.5) return mixOklab(uColorNavy, uColorMoss, f);
else if (seg < 1.5) return mixOklab(uColorMoss, uColorPaper, f);
else return mixOklab(uColorPaper, uColorNavy, f);
}
// ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ──────────
//
// 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
// Ribbon half-width here, scaled by the waveform-width dial (R2 #8): at width 1 it is the
// full prior band; lower narrows it so the lava fluid gets more room on loud songs.
float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * uWaveformWidth;
// 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. ─────────────
//
// 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);
float hotAccum = 0.0;
float hotWeight = 0.0;
// Phase 10 cohesion (viscosity knob): low cohesion → a wider smin neck (blobs fuse and stay
// gooey/deformed) and more wobble (less sphere-like); high cohesion → a tight neck and minimal
// wobble (crisp spheres that read as "snapped back to round"). Pure uniform scaling of the two
// existing constants — no extra per-fragment loop iterations, so weaker hardware is unaffected.
// Range chosen so cohesion 1 still keeps a small organic neck/wobble (never a hard-edged circle).
float blobK = BLOB_SMOOTHMIN_K * (1.0 + (1.0 - uCohesion) * 1.4); // ×1.0 (crisp) → ×2.4 (gooey)
float wobbleAmt = BLOB_WOBBLE_AMOUNT * (0.35 + (1.0 - uCohesion) * 1.4); // less wobble when cohesive
// 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
// 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).
// Amount scaled by cohesion (low cohesion deforms more — Phase 10 viscosity split).
float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5)
* 2.0 * wobbleAmt;
float rr = r * (1.0 + wob);
float blob = sdCircle(p - c, rr);
field = smin(field, blob, blobK);
// 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;
}
void main() {
float w = uResolution.x;
float h = uResolution.y;
// Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note).
if (uHasDatum < 0.5) {
fragColor = vec4(0.0);
return;
}
// 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 nowYn = NOW_ANCHOR_FROM_TOP; // now-line, height-norm (y ∈ [0,1])
float secondsPerHeight = uVisibleSeconds; // one full height spans uVisibleSeconds
// ── Evaluate the combined liquid SDF only (no normal/gradient shading this wave). ──
// R2 cone fix (Daniel #1): the previous build derived a surface NORMAL from the SDF
// gradient and shaded the fill by it (mix(0.82, 1.12, lightUp)). On a metaball the
// gradient points outward from each blob centre, so that shading lit a bright spot at
// every centre and darkened the rims — the wax read as a CONE with a pointed bright tip,
// not a flat fluid surface. We DROP the normal shading entirely: a metaball / fluid
// surface is FLAT, distinguished only by a soft anti-aliased edge. (Form/colour is the
// Wave R3 job; here we only flatten.) This also removes the 4 extra SDF evaluations the
// central-difference gradient cost — a small frame-budget win.
float hot;
float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot);
// 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). This soft edge
// is the ONLY shading on the surface now — the body is flat.
float pxFeather = 1.2 / h;
float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d);
if (inside <= 0.0) { fragColor = vec4(0.0); return; }
// ── R3 — the three-colour OKLab gradient (the three combined motions, spec §6b). ──
//
// The static structure is a LINEAR A→B from the centre line outward (A at the root, B at
// the extended edge), with A and B drawn from the rotating three-anchor ring. On top of
// that, three motions combine — all OKLab-interpolated, so no rainbow/cyan excursion.
// Centre-outward fraction [0,1] for this fragment: 0 at the centre line (root), 1 at the
// canvas edge. This is the axis the A→B linear runs along (spec §6b: "from the 0 centre
// line outward along the waveform").
float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0);
// Motion 2 — per-bar sinusoid keyed to MIX-TIME (spec §6b motion 2). The mix-time at this
// fragment's row is identical to the waveform's row-time, so the sinusoid is fixed for a
// given segment and travels up with it as it scrolls — baked-per-segment by construction,
// no ring buffer. It nudges the anchor-ring phase, so neighbouring segments sit on slightly
// different colours: "waves" of colour across the waveform rather than one uniform gradient.
float segTime = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
float segWave = sin(segTime * SEG_WAVE_FREQ * 6.2831853) * SEG_WAVE_AMOUNT;
// Motion 1 — anchor rotation among X/Y/Z (spec §6b motion 1). uGradientPhase advances at
// the gradient-rotation-speed dial's rate (CPU-integrated from uTimeSeconds). A leads B by
// a fixed ring offset so the gradient always spans two distinct anchors. The per-segment
// wave (Motion 2) is folded into the phase so each segment is offset on the ring.
float phaseA = uGradientPhase + segWave;
float phaseB = uGradientPhase + GRADIENT_AB_PHASE_OFFSET + segWave;
vec3 colorA = anchorAtPhase(phaseA); // centre/root colour
vec3 colorB = anchorAtPhase(phaseB); // outer/edge colour
// Motion 3 — per-bar curve shift with scroll height (spec §6b motion 3). p.y is 0 at the
// top and 1 at the floor; (0.5 - p.y) is +0.5 at the top, 0.5 at the floor. We shift the
// A→B mix fraction toward A (negative) low on screen and toward B (positive) high on
// screen, so a bar is mostly A at the bottom and mostly B by the top — colour climbs
// outward as the bar scrolls up.
float curveShift = (0.5 - p.y) * 2.0 * CURVE_SHIFT_AMOUNT;
float mixFrac = clamp(xnAbs + curveShift, 0.0, 1.0);
vec3 fill = mixOklab(colorA, colorB, mixFrac);
// Warm tint on hot, rising wax so the eye reads convection (subordinate to the gradient,
// spec §4f). A flat per-blob temperature lean — no spatial falloff, so no cone is reintroduced.
float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT;
fill = mix(fill, HOT_TINT, hotLean);
float alpha = inside * RIBBON_OPACITY_R2;
fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_ALPHA
}
`;
/** Compile one shader stage, throwing with the info log on failure. */
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) throw new Error('MixVisualizer: gl.createShader returned null.');
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`MixVisualizer: shader compile failed: ${log}`);
}
return shader;
}
/** Link the vertex + fragment shaders into a program, throwing on failure. */
function linkProgram(gl: WebGL2RenderingContext): WebGLProgram {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const program = gl.createProgram();
if (!program) throw new Error('MixVisualizer: gl.createProgram returned null.');
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
// Shaders can be deleted after link — the program retains the compiled code.
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`MixVisualizer: program link failed: ${log}`);
}
return program;
}
/** The no-op handle returned when WebGL2 is unavailable or setup fails. */
function noopHandle(): MixVisualizerHandle {
return {
setDatum() {},
setPlayback() {},
setScrollSpeed() {},
setGradientRotationSpeed() {},
setLavaGravity() {},
setLavaHeat() {},
setFluidAmount() {},
setFluidViscosity() {},
setCollisionStrength() {},
setWaveformWidth() {},
refreshTheme() {},
dispose() {},
};
}
export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// premultipliedAlpha so the translucent ribbon composites correctly over the
// page; antialias off (the soft-edge smoothstep handles AA in-shader, and MSAA
// would cost fill rate we don't need for a backdrop).
const maybeGl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
});
if (!maybeGl) {
// No WebGL2 (old engine / disabled): hand back a no-op handle so the
// component still functions as a plain backdrop (mirrors the predecessor's
// no-2d-context fallback, now guarding against no-WebGL2).
console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`);
return noopHandle();
}
// Non-null binding so the closures below keep the narrowing (TS does not carry
// control-flow narrowing of a captured `const` into nested functions).
const gl: WebGL2RenderingContext = maybeGl;
// GL_MAX_TEXTURE_SIZE is a per-context constant — query it once. The datum is
// laid out across a 2-D grid no wider than this (see uploadDatum); a 1×N row
// would exceed it for any mix over ~49 s at the ~333 samples/s datum density,
// and texImage2D would reject the upload (the bug this fix addresses).
const maxTextureSize: number = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
let program: WebGLProgram;
try {
program = linkProgram(gl);
} catch (err) {
// A compile/link failure on an exotic driver should degrade to the plain
// backdrop, not crash the page. Log for diagnosis; return the no-op handle.
console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err);
return noopHandle();
}
// An empty VAO is still required in WebGL2 core to issue a draw; the vertex
// shader sources its positions from gl_VertexID, so no attribute buffers.
const vao = gl.createVertexArray();
// 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. 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'),
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
cohesion: gl.getUniformLocation(program, 'uCohesion'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
colorMoss: gl.getUniformLocation(program, 'uColorMoss'),
colorPaper: gl.getUniformLocation(program, 'uColorPaper'),
gradientPhase: gl.getUniformLocation(program, 'uGradientPhase'),
hasDatum: gl.getUniformLocation(program, 'uHasDatum'),
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) {
console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`);
}
}
// ── Mutable state, fed by the component through the handle. ──────────────────
let datum: Datum | null = null;
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
// ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default
// consts at the top of this file). These are the dials the seven knobs feed, routed here by the
// handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader
// uniform; gradientRotationSpeed drives the OKLab gradient's anchor rotation (live as of R3).
let lavaHeat = DEFAULT_LAVA_HEAT;
let lavaGravity = DEFAULT_LAVA_GRAVITY;
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
// Phase 10 — the split "bubbles" knob: fluidAmount drives count + per-blob volume; fluidViscosity
// (cohesion) drives the shader's sphere-restoration (smin blend + wobble) via uCohesion.
let fluidAmount = DEFAULT_FLUID_AMOUNT;
let fluidViscosity = DEFAULT_FLUID_VISCOSITY;
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
/** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
* travel maps onto the useful 10%95% band (full-width 100% read too wide; sub-10% vanished).
* Both the shader uniform and the CPU collision boundary read this so they stay aligned. */
function effectiveWaveformWidth(): number {
return 0.10 + waveformWidth * 0.85;
}
// ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the
// shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by
// rate·dt, where dt is the delta of (performance.now()startTimeMs)/1000 (== uTimeSeconds).
// Integrating rate·dt (rather than computing phase = t·rate in the shader) keeps the phase
// CONTINUOUS when the dial changes — a rate change alters the slope, never snaps the value.
let gradientPhase = 0;
let lastGradientClockSeconds = 0;
/**
* The *authoritative* playhead for this instant: the last pushed position advanced
* by wall-clock elapsed since the push while playing, or the pushed position while
* idle. The player remains the sole source of truth — this is display-only and is
* never written back (read-only contract, spec §D / §5.10). This is the target the
* rendered playhead converges onto; the shader uploads the *rendered* value (see
* renderedPlayhead) so a re-anchor at a push doesn't snap on screen.
*/
function effectivePlayhead(): number {
if (!playback.isPlaying) return playback.positionSeconds;
const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000;
return playback.positionSeconds + elapsedSeconds;
}
// ── Rendered-playhead reconciliation (startup-jitter fix). ───────────────────────
//
// The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset,
// where correctionOffset decays exponentially toward 0 each frame (time constant
// PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the
// authoritative target; without correction that re-anchor would teleport the
// rendered playhead. Instead we *preserve the rendered position across the push* by
// folding the discontinuity into correctionOffset (see setPlayback), then bleed it
// off — turning each snap into a brief, sub-perceptible glide.
//
// Steady-state: when pushes are regular, the authoritative target barely moves at a
// push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour
// is then identical to uploading effectivePlayhead() directly (the prior renderer).
let correctionOffset = 0;
let lastRenderWallClockMs = performance.now();
/**
* The playhead the shader actually scrolls to this frame. Equals the authoritative
* effectivePlayhead() plus a correction offset that decays to zero, so the rendered
* motion is continuous across the irregular startup pushes. Advances the decay by
* real elapsed time since the previous render, making it frame-rate-independent
* (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn
* frame — it mutates the decay state.
*/
function renderedPlayhead(): number {
const nowMs = performance.now();
const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000);
lastRenderWallClockMs = nowMs;
// Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate
// independent — the fraction retained depends only on wall-clock dt, not frame
// count. Snap tiny residuals to 0 (an exponential never reaches it).
if (correctionOffset !== 0) {
correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS);
if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0;
}
return effectivePlayhead() + correctionOffset;
}
/**
* Resolve the three colour anchors (navy / moss / off-white) from the live palette
* vars on the canvas — the single source of truth (spec §6a).
*
* Detects light vs dark by the page background luminance, then binds each anchor to
* the var that carries that identity in the active palette (see the note above the
* ResolvedTheme interface for why no single var works across both modes):
* LIGHT: navy = --mud-palette-primary (#17283f), moss = --mud-palette-secondary (#3D7A68),
* off-white = --mud-palette-background (#FAFAF8)
* DARK: navy = --mud-palette-background (#0D1B2A), moss = --mud-palette-primary (#3D7A68),
* off-white = --mud-palette-secondary (#FAFAF8)
* This yields the navy / moss / off-white triad the gradient rotates among in either theme.
*/
function readTheme(): ResolvedTheme {
const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8'));
const isDark = luminance(background) < 0.5;
const navy = isDark
? background // the dark ground (#0D1B2A) IS the navy anchor on dark
: parseColor(readVar(canvas, '--mud-palette-primary', '#17283f'));
const moss = isDark
? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68'))
: parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68'));
const paper = isDark
? parseColor(readVar(canvas, '--mud-palette-secondary', '#FAFAF8'))
: background; // the light ground (#FAFAF8) IS the off-white anchor on light
const resolved: ResolvedTheme = { navy, moss, paper };
// Report all THREE anchors the OKLab gradient rotates among, as 0-255 RGB + relative
// luminance — confirms the navy / moss / off-white triad resolved off the canvas vars
// in the active mode (no hardcoded hexes; a dark-mode toggle re-themes live).
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'}) — NAVY(X)=${fmt(navy)} MOSS(Y)=${fmt(moss)} PAPER(Z)=${fmt(paper)}.`);
return resolved;
}
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
r0: number; // UNBIASED base radius, height-norm (fixed per blob — the blob's
// identity size; the density dial scales it LIVE into r each frame)
r: number; // DENSITY-biased base radius this step = r0 × density bias (Daniel
// #1: density's "size" half is live — recomputed each frame, not
// baked at seed, so turning the dial visibly resizes live wax)
er: number; // EFFECTIVE radius this step = r shrunk by heat (Daniel #7); used by
// collisions AND uploaded to the shader so the two always agree
temp: number; // temperature 0..1
noiseSeed: number; // fixed per-blob phase offset so each blob's turbulence is decorrelated
}
// 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);
/** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE
* each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live
* blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean),
* amount 1 → ×1.15 (fat, lots of wax). */
function fluidSizeBias(): number {
return 0.6 + fluidAmount * 0.55;
}
/** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
function seedBlob(b: Blob, aspect: number): void {
// Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame.
const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN);
const r = r0 * fluidSizeBias();
b.r0 = r0;
b.r = r;
b.er = r; // starts at full size (cool); shrinks as it heats
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)
b.noiseSeed = rng() * 100; // decorrelate this blob's turbulence field
}
/** (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, r0: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 };
seedBlob(b, aspect);
blobs.push(b);
}
}
let blobsInitialized = false;
/** Live blob count for the current fluid-amount dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
function liveBlobCount(): number {
return Math.round(MIN_BLOB_COUNT + fluidAmount * (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;
// Smootherstep (Hermite) blend — mirrors the shader's sampleAt so the CPU collision boundary
// follows the same smooth sinusoid contour the ribbon is drawn with (no faceted mismatch).
const fs = f * f * (3 - 2 * f);
return s0 + (s1 - s0) * fs;
}
/** 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);
// Smoothstep toe (gentle at 0) scaled by 1.2 — Phase 10 §3.4: uniform +20% across the curve
// (every non-zero dial position is raised by 20%; the shape of the toe is preserved but the
// overall output is higher at every point).
return d * d * (3 - 2 * d) * 1.2;
}
/** 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;
}
/**
* Cheap continuous 1-D-ish noise in [1,1] for the turbulence field (Daniel #7). A pair of
* out-of-phase sines is enough for an organic, smoothly-evolving churn — far cheaper than a
* lattice value-noise and we only need it twice per hot blob per substep. Decorrelated per
* blob via the seed so neighbouring bubbles don't churn in lock-step.
*/
function turbNoise(seed: number, t: number): number {
return Math.sin(seed + t) * 0.6 + Math.sin(seed * 1.7 + t * 0.53) * 0.4;
}
/**
* 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 + an upward throw, always on), blob↔blob (elastic, soft↔hard via the
* strength dial). Heat shrinks each blob's effective radius and injects turbulence so
* high heat reads as a swarm of small lively bubbles (Daniel #7).
*/
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 sizeBias = fluidSizeBias(); // fluid-amount dial → live size scale (Phase 10, recomputed each step)
const heatScale = heatScaleFromDial(lavaHeat);
// Gravity range remap (Phase 10 §3.3): the knob's full [0,1] travel now covers only the useful
// 0%75% of the old gravity span — the top quarter was too heavy (wax slammed down). So the dial
// is scaled to 0.75 before mapping onto [MIN, MAX], keeping the low/mid feel and dropping the slam.
const gravityDial = lavaGravity * 0.75;
const gravity = GRAVITY_ACCEL_MIN + gravityDial * (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;
// Match the shader's width-dialled ribbon so the collision boundary lines up with what
// is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped
// effective width as the uniform (Phase 10 §3.7) so the boundary never drifts from the ribbon.
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * effectiveWaveformWidth();
const playhead = effectivePlayhead();
const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS;
// Wall-clock seconds for the turbulence field (separate from the playhead/scroll).
const turbTime = (performance.now() - startTimeMs) / 1000 * TURBULENCE_RATE;
for (let s = 0; s < PHYSICS_SUBSTEPS; s++) {
// ── Per-blob: heat exchange, size, buoyancy, gravity, turbulence, damping, floor. ──
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);
// Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid-
// amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the
// "size" half is not baked at seed). r feeds the heat-shrink below and the
// collisions/upload via er, so the dial moves the actual drawn + simulated size.
b.r = b.r0 * sizeBias;
// Energy → SIZE (Daniel #7): the hotter the wax, the smaller it shrinks. Driven by
// heatScale × temperature so it only shrinks when the lamp is actually hot AND this
// blob is hot — at heat 0 every blob stays full size. Tracks temp continuously, so a
// bubble grows back as it cools near the top. Effective radius feeds both collision
// and the upload, so the sim and the render never disagree.
const shrink = 1 - heatScale * b.temp * (1 - HEAT_RADIUS_MIN_SCALE);
b.er = b.r * shrink;
// 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;
// Energy → TURBULENCE (Daniel #7): a small churn injected into hot wax only, scaled
// by heatScale × temperature. High heat ⇒ lots of small bubbles jittering with life;
// low heat ⇒ a calm pool. Two decorrelated noise reads give an x/y churn vector.
const turbAmp = TURBULENCE_ACCEL * heatScale * b.temp;
if (turbAmp > 0) {
b.vx += turbNoise(b.noiseSeed, turbTime) * turbAmp * dt;
b.vy += turbNoise(b.noiseSeed + 50.0, turbTime) * turbAmp * dt;
}
// Viscous damping (lazy wax): frame-rate-independent exponential decay.
const damp = Math.exp(-VISCOUS_DAMPING * dt);
b.vx *= damp;
b.vy *= damp;
// Velocity clamp (Daniel #5 jitter fix): keep the integrator stable under the
// lower viscosity + over-unity restitution so a deep overlap can't fling a blob
// fast enough to tunnel and re-collide (the buzz). Far above real convection speed.
if (b.vx > MAX_BLOB_SPEED) b.vx = MAX_BLOB_SPEED; else if (b.vx < -MAX_BLOB_SPEED) b.vx = -MAX_BLOB_SPEED;
if (b.vy > MAX_BLOB_SPEED) b.vy = MAX_BLOB_SPEED; else if (b.vy < -MAX_BLOB_SPEED) b.vy = -MAX_BLOB_SPEED;
// Integrate position.
b.x += b.vx * dt;
b.y += b.vy * dt;
// Boundaries use the EFFECTIVE radius so shrunk hot bubbles sit correctly.
// Floor: soft contact spring + extra damping so resting wax pools and flattens.
const floorY = 1 - b.er;
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.er;
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.er) { b.x = b.er; if (b.vx < 0) b.vx = -b.vx * 0.3; }
if (b.x > aspect - b.er) { b.x = aspect - b.er; 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.er - Math.abs(dx);
if (penetration <= 0) continue;
// Capture the inward velocity ONCE up front (Daniel #5 jitter fix). The prior
// build read b.vx for the elastic term AFTER the spring had already mutated it,
// so the spring and reflection fought each other within one pass — a buzz source.
// Now each contribution reads the same pre-collision state and they sum cleanly.
const inwardSpeed = -sideSign * b.vx; // >0 means moving toward the centre line
// Soft penalty spring (soft end of the dial): a gentle outward shove proportional
// to penetration. Softened for R2 (Daniel #3) so the low end genuinely mushes the
// wax around. The (1 hardness) factor hands the work to the elastic term as the
// dial climbs, so we never double-drive at the hard end.
b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness);
// Hard elastic reflection (hard end): bounce the inward velocity back out, scaled
// by restitution × hardness. Restitution is sub-unity (≤ 0.85) — bounded reflection,
// no energy added.
if (inwardSpeed > 0) {
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
}
// UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob
// bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the
// accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a
// sustained/deep overlap lifts firmly but never launches the bubble off-screen.
const throwUp = Math.min(WAVE_THROW_UP * penetration * dt * collideHardness, WAVE_THROW_UP_MAX);
b.vy -= throwUp;
// Positional push-out: ejects the wax progressively out of the ribbon along the normal
// (converges across substeps, so no stuck wax). The soft end eases it out gently
// (mushy), the hard end snaps it clean.
b.x += sideSign * penetration * (0.5 + 0.5 * collideHardness);
}
// ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ──
// O(count²) ≤ ~1k pair tests — trivial. Mass ∝ er² so big blobs shove small ones, and
// hot shrunk bubbles are correspondingly lighter (Daniel #7). Geometry uses effective
// radii so collisions match what's drawn.
for (let i = 0; i < count; i++) {
const a = blobs[i];
for (let j = i + 1; j < count; j++) {
const c = blobs[j];
const dx = c.x - a.x;
const dy = c.y - a.y;
const dist = Math.hypot(dx, dy);
const minDist = a.er + c.er;
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.er * a.er, mc = c.er * c.er; // mass ∝ area
const invSum = 1 / (ma + mc);
// Capture the approach velocity ONCE from pre-collision state (Daniel #5 jitter
// fix): the prior build read the relative velocity for the elastic impulse AFTER
// the penalty spring had already mutated it, so spring and impulse fought within
// one pass — buzz. Now both read the same state and sum cleanly.
const velAlongNormal = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny;
// Positional separation along the normal, mass-weighted (split the overlap).
// Soft at low strength (Daniel #3: gentle, blobs squish and overlap), firm at high.
const sep = overlap * (0.15 + 0.6 * 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, dominant at the soft end).
const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness) * 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. Over-unity
// restitution at full hardness gives the springy throw (Daniel #6).
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.er; // effective (heat-shrunk) radius — matches the collision geometry
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 — gated on DEBUG). Counts actual rAF callbacks and logs the
// rate ~once/sec while the loop runs (which is now continuously, playing or paused — Part C). A
// rate near the display refresh (~60) confirms the continuous loop holds frame rate; a paused-but-
// foregrounded lamp should still read ~60 (the cheap sim + one draw), confirming the power cost of
// running while paused is acceptable. Reset when the loop (re)starts.
let fpsFrameCount = 0;
let fpsWindowStartMs = 0;
// One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
// first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
// backing store here means the canvas had no layout box when the first draw ran —
// the ResizeObserver will correct it, but the first paint would be degenerate.
let firstRealDrawLogged = false;
// Backing-store size in device pixels, tracked so we only resize the canvas
// (which clears it) when the CSS box actually changed.
let cssWidth = 0;
let cssHeight = 0;
let dpr = 1;
// ── One-time GL pipeline setup. ──────────────────────────────────────────────
gl.useProgram(program);
gl.disable(gl.DEPTH_TEST);
// Pre-multiplied alpha blend: src already carries colour*alpha (see frag shader).
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// The datum lives in texture unit 0 for the program's lifetime.
gl.uniform1i(u.datum, 0);
// ── ResizeObserver: one-shot redraw when the container changes while idle. ────
//
// The ResizeObserver is the SOLE size writer (spec §2e): we never call
// getBoundingClientRect per frame. While playing, the rAF loop redraws every
// frame anyway and picks up the new backing-store size set here. While idle, the
// observer fires only on an actual size change and triggers a single redraw.
const resizeObserver = new ResizeObserver((entries) => {
if (disposed) return;
const entry = entries[0];
// contentBoxSize is the modern, layout-thrash-free size source. Fall back to
// contentRect for engines that don't populate it.
const box = entry.contentBoxSize?.[0];
const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
applySize(nextCssWidth, nextCssHeight);
// The continuous loop redraws on its next tick. Only force a still frame if the loop is
// stopped (tab hidden) so a resize while hidden is reflected when the tab returns.
if (rafId === null) redrawOnce();
});
resizeObserver.observe(canvas);
/**
* Update the backing store to a CSS size × devicePixelRatio (capped at MAX_DPR)
* and the GL viewport. Only resizes when something changed — resizing clears the
* drawing buffer, so we avoid needless churn. This is the only place the canvas
* size is written (fed by the ResizeObserver, never by a per-frame measure).
*/
function applySize(nextCssWidth: number, nextCssHeight: number): void {
const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
if (nextCssWidth === cssWidth && nextCssHeight === cssHeight && nextDpr === dpr) {
return;
}
cssWidth = nextCssWidth;
cssHeight = nextCssHeight;
dpr = nextDpr;
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
}
/**
* Issue one GL draw with the current uniforms. The fragment shader does all the
* scroll/zoom/ribbon work; here we just push the per-frame uniforms and draw the
* full-screen triangle.
*/
function draw(): void {
if (canvas.width <= 0 || canvas.height <= 0) return;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindVertexArray(vao);
// Per-frame uniforms. The playhead is the wall-clock-interpolated value, not
// the raw last-pushed position — that is what makes the scroll advance every
// animation frame instead of stepping at Blazor's ~10 Hz push cadence.
const clockSeconds = (performance.now() - startTimeMs) / 1000;
gl.uniform2f(u.resolution, canvas.width, canvas.height);
gl.uniform1f(u.playheadSeconds, renderedPlayhead());
gl.uniform1f(u.timeSeconds, clockSeconds);
// Advance the gradient-rotation phase (Motion 1) off the SAME clock as uTimeSeconds — the
// delta since the last drawn frame, scaled by the dial's rate. Integrating rate·dt keeps
// the phase continuous across a dial change (no snap). Idle one-shot redraws advance it by
// their real dt too, so the field keeps morphing while paused (the loop runs continuously).
const gradientDt = Math.max(0, clockSeconds - lastGradientClockSeconds);
lastGradientClockSeconds = clockSeconds;
const rotationRate = GRADIENT_ROTATION_RATE_MIN
+ gradientRotationSpeed * (GRADIENT_ROTATION_RATE_MAX - GRADIENT_ROTATION_RATE_MIN);
gradientPhase += gradientDt * rotationRate;
// 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);
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
gl.uniform1f(u.cohesion, fluidViscosity);
gl.uniform1f(u.gradientPhase, gradientPhase);
gl.uniform3fv(u.colorNavy, theme.navy);
gl.uniform3fv(u.colorMoss, theme.moss);
gl.uniform3fv(u.colorPaper, theme.paper);
// 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);
gl.uniform1i(u.datumWidth, datum.texWidth);
gl.uniform1i(u.datumSampleCount, datum.sampleCount);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, datum.texture);
} else {
gl.uniform1f(u.hasDatum, 0);
gl.uniform1f(u.durationSeconds, 1);
// Keep the divisor safe even though sampleAt early-outs on uHasDatum<0.5.
gl.uniform1i(u.datumWidth, 1);
gl.uniform1i(u.datumSampleCount, 1);
}
// One full-screen triangle (3 vertices), positions from gl_VertexID.
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
// First draw at a real (laid-out) size: report dimensions and any accumulated
// GL error. We hold this log until cssWidth/cssHeight are populated so the
// dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1.
// gl.getError() is a pipeline stall, so we only call it once, never per frame.
if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) {
firstRealDrawLogged = true;
debugLog(
`first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`,
);
const glErr = gl.getError();
if (glErr !== gl.NO_ERROR) {
console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`);
}
}
}
// ── rAF loop lifecycle (lava reframe Part C: sim animates while paused; only scroll freezes). ─
//
// DESIGN (changed in Wave R4): the loop runs whenever the component is ALIVE and the tab is
// VISIBLE — it is NO LONGER gated on playback.isPlaying. A real lava lamp keeps convecting
// regardless of the music, so the fluid sim (physics + render) keeps animating while audio is
// paused; only the waveform SCROLL / playhead freezes. That freeze falls straight out of
// effectivePlayhead(): while !isPlaying it returns the static last-pushed position, so the
// waveform holds at its paused row while the physics dt clock (lastPhysicsMs in draw()) keeps
// advancing the wax. Power-saving is preserved by stopping the loop on tab-hidden (visibilitychange)
// and on dispose — just not merely because audio paused. A foregrounded-but-paused lamp runs only
// the cheap CPU sim + one GL draw per frame, which holds 60 FPS comfortably.
//
// Smoothness (spec §2e / §5.4): while playing, the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating 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
// 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, and the playhead is frozen while paused.)
/** Draw one still frame immediately, without scheduling a new rAF. */
function redrawOnce(): void {
if (disposed) return;
draw();
}
/** Start the rAF loop. No-op if already running or disposed (rafId guard). */
function startLoop(): void {
if (disposed || rafId !== null) return;
// Reset the FPS window so the first measured second reflects the run we're
// starting, not a stale count from a previous play session.
fpsFrameCount = 0;
fpsWindowStartMs = performance.now();
// Re-base the decay clock to now so the first frame's dt is one frame, not the
// (possibly long) idle gap since the last redrawOnce — otherwise a stale dt
// 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);
}
/** Stop the rAF loop. Safe to call when already stopped. */
function stopLoop(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
/**
* The animation loop. Runs continuously while the component is alive and the tab is visible
* (lava reframe Part C) — NOT gated on playback. Each frame advances the wax physics and draws.
* While playing, it draws at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes); while paused, effectivePlayhead() holds the static pushed
* position so the waveform freezes in place while the lava keeps convecting. It reschedules itself
* every frame; the only things that stop it are dispose() and the tab going hidden (the
* visibilitychange handler calls stopLoop). A backgrounded tab also gets rAF throttled by the
* browser, and we stop the loop entirely when hidden, so a backgrounded lamp burns no frames.
*/
function frame(): void {
if (disposed) {
rafId = null;
return;
}
draw();
// FPS tally: count this callback, and once per elapsed second emit the rate.
// performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log
// fires at most once/sec, so this adds no meaningful per-frame cost.
if (DEBUG) {
fpsFrameCount++;
const nowMs = performance.now();
const windowMs = nowMs - fpsWindowStartMs;
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;
let avgShrink = 0; // mean effective/base radius ratio — shows the heat→size coupling
for (let i = 0; i < live; i++) {
const b = blobs[i];
avgTemp += b.temp;
avgShrink += b.r > 0 ? b.er / b.r : 1;
if (b.temp > TEMP_AMBIENT) buoyant++;
if (b.y > 1 - b.er - 0.04) pooled++;
}
debugLog(
`lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` +
`collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} ` +
`fluidAmount=${fluidAmount.toFixed(2)} viscosity=${fluidViscosity.toFixed(2)} | ` +
`blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
`avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`,
);
// Colour diagnostic (R3): the rotation dial + the live gradient phase. Daniel watches
// phase advance (faster at a higher dial, near-static at the low end) to confirm Motion 1
// is live, and that the dial visibly changes the rate. phase mod 3 = the ring position.
debugLog(
`colour — rotationSpeed=${gradientRotationSpeed.toFixed(2)} ` +
`gradientPhase=${gradientPhase.toFixed(2)} (ring ${(gradientPhase % 3).toFixed(2)}/3).`,
);
fpsFrameCount = 0;
fpsWindowStartMs = nowMs;
}
}
// Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is
// stopped only by dispose() or the tab going hidden, never by audio pausing.
rafId = requestAnimationFrame(frame);
}
// ── Tab-visibility gating (lava reframe Part C power-saving). ────────────────────
// The loop runs continuously while alive, but a HIDDEN tab should not animate at all
// (the browser throttles rAF anyway, but we stop outright to be sure). On becoming
// visible again we restart the loop; startLoop re-bases the dt clocks so the wax
// doesn't lurch by the whole hidden gap on the first resumed frame.
function onVisibilityChange(): void {
if (disposed) return;
if (document.hidden) {
stopLoop();
} else {
startLoop();
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// Read the initial size synchronously (one getBoundingClientRect at setup is
// fine — it is the ResizeObserver that must not measure per-frame), draw a still
// frame so the canvas isn't blank, then START the continuous loop (Part C: the lava
// animates from the moment the visualizer mounts, paused or playing) — unless the tab
// is already hidden, in which case the visibilitychange handler will start it later.
{
const rect = canvas.getBoundingClientRect();
applySize(rect.width, rect.height);
redrawOnce();
if (!document.hidden) startLoop();
}
/**
* Upload the loudness samples as a 2-D R8 texture that respects
* GL_MAX_TEXTURE_SIZE, returning the Datum (with the grid dimensions the shader
* needs to map a sample index → texel) or null on empty/invalid input.
*
* Why 2-D and not 1×N: the mix datum runs at ~333 samples/s, so any mix over
* ~49 s produces more samples than GL_MAX_TEXTURE_SIZE (commonly 409616384),
* and `texImage2D(…, width=N, height=1, …)` is rejected outright
* ("Requested size at this level is unsupported"), leaving the waveform texture
* uncreated and the ribbon blank. Laying the N samples row-major across a grid
* of width = min(N, safeWidth) keeps every dimension well within the limit.
*
* Filtering: the shader reads with texelFetch and does its own time-axis
* interpolation (see sampleAt), so NEAREST is correct here — hardware LINEAR on
* a 2-D grid would bleed across the row-wrap seam. The final row is zero-padded
* (texture init is zero-filled, then we overwrite the real samples); padding is
* never read because sampleAt clamps the index to sampleCount-1.
*/
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
if (durationSeconds <= 0 || !samplesBase64) {
// Expected before the player reports a duration: the bridge pushes an empty
// datum until then. Not an error, but worth seeing while diagnosing.
debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
return null;
}
const samples = decodeSamples(samplesBase64);
const sampleCount = samples.length;
if (sampleCount === 0) {
console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
return null;
}
// Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
// under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
// still clamp it to the actual max in case a driver reports something smaller.
const SAFE_WIDTH = 4096;
const texWidth = Math.min(sampleCount, Math.min(SAFE_WIDTH, maxTextureSize));
const texHeight = Math.ceil(sampleCount / texWidth);
debugLog(
`uploadDatum — ${sampleCount} samples for ${durationSeconds.toFixed(2)}s mix ` +
`(${(sampleCount / durationSeconds).toFixed(1)} samples/s); ` +
`datum texture ${texWidth}x${texHeight} for N=${sampleCount} samples, maxTextureSize=${maxTextureSize}.`,
);
// Pad the final partial row with zeros so the full grid uploads in one call.
const padded = texWidth * texHeight === sampleCount
? samples
: (() => {
const buf = new Uint8Array(texWidth * texHeight);
buf.set(samples);
return buf;
})();
const texture = gl.createTexture();
if (!texture) return null;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// R8 rows are 1-byte-per-texel and texWidth is not guaranteed 4-aligned;
// relax the default 4-byte unpack alignment so rows aren't read with stride
// padding the source array doesn't have.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.R8,
texWidth, texHeight, 0,
gl.RED, gl.UNSIGNED_BYTE, padded,
);
// NEAREST: texelFetch ignores the filter anyway, but be honest about it — the
// shader interpolates manually to avoid the row-wrap seam (see sampleAt).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
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, samples };
}
return {
setDatum(samplesBase64: string, durationSeconds: number): void {
debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
// Free the previous datum's GPU texture before replacing it (no leak
// across re-pushes / mix changes — spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — the continuous loop picks it up next frame. Only force
// a still frame if the loop is stopped (tab hidden) so a datum that arrives while hidden is
// reflected the moment the tab becomes visible-and-draws.
if (rafId === null) redrawOnce();
},
setPlayback(positionSeconds: number, isPlaying: boolean): void {
const wasPlaying = playback.isPlaying;
// Preserve on-screen continuity across the re-anchor. The rendered playhead
// right now is effectivePlayhead() (old anchor) + correctionOffset; capture
// it before we replace the anchor. We read effectivePlayhead() without going
// through renderedPlayhead() so we don't advance the decay clock here — the
// decay belongs to the render loop, ticked once per drawn frame.
const renderedBefore = effectivePlayhead() + correctionOffset;
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
// forward from here each frame (effectivePlayhead), so the scroll advances
// smoothly between these ~10 Hz pushes. While paused, effectivePlayhead()
// returns this static position, so the waveform freezes here (Part C) — the
// continuous loop keeps animating the lava, but the scroll holds.
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
// Fold the re-anchor discontinuity into the correction offset so the rendered
// playhead doesn't jump: choose offset such that effectivePlayhead() (new
// anchor) + offset == renderedBefore. The render loop then decays this offset
// to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the
// authoritative position. When pushes are regular the gap is ~0, so offset is
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
//
// Only smooth while continuously playing. On a play/pause edge or while paused
// we want the exact authoritative position, not a glide from a stale render:
// a resume should land on the real position, and a paused frame must be
// truthful (read-only contract — never show a position the player isn't at).
if (isPlaying && wasPlaying) {
correctionOffset = renderedBefore - effectivePlayhead();
} else {
correctionOffset = 0;
}
// NOTE (Part C): we do NOT start/stop the rAF loop on the play/pause edge anymore — the
// loop runs continuously while the tab is visible so the lava keeps convecting when paused.
// The play-state only changes whether effectivePlayhead() advances (scroll) or holds
// (freeze); the loop itself is owned by setup + the visibilitychange handler + dispose.
if (isPlaying !== wasPlaying) {
debugLog(`playback ${isPlaying ? 'resumed' : 'paused'} — position ${positionSeconds.toFixed(2)}s; scroll ${isPlaying ? 'advancing' : 'frozen'}, lava keeps animating.`);
}
},
// ── Wave R4 — the seven dedicated control setters. Each routes its value to the one dial it
// drives; no more R2 temp-remapping. The lava loop now runs continuously (see startLoop /
// the visibility handling), so a paused tweak is already picked up by the next frame — but we
// keep a redrawOnce() guard for the rare fully-stopped case (loop not running, e.g. tab
// hidden) so a tweak still lands a still frame when it resumes-and-draws.
// Scroll speed: arrives already mapped to a visible time-span (seconds) on the C# side. Clamp
// into the supported span so a stray value can't break the scroll math.
setScrollSpeed(seconds: number): void {
visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`);
if (rafId === null) redrawOnce();
},
// Gradient rotation speed: LIVE as of Wave R3 — sets the anchor-rotation rate (Motion 1).
// The phase integrator (draw()) reads this; changing it alters the slope, never snaps the
// phase, so the gradient speeds up/slows down smoothly. redrawOnce guards the fully-stopped
// (tab-hidden) case so a tweak still lands a still frame when it resumes-and-draws.
setGradientRotationSpeed(value: number): void {
gradientRotationSpeed = Math.min(1, Math.max(0, value));
debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setLavaGravity(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value));
debugLog(`setLavaGravity → ${lavaGravity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setLavaHeat(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value));
debugLog(`setLavaHeat → ${lavaHeat.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
// Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount)
// AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it
// visibly adds/removes wax and resizes the already-live blobs.
setFluidAmount(value: number): void {
fluidAmount = Math.min(1, Math.max(0, value));
debugLog(`setFluidAmount → ${fluidAmount.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
// Fluid viscosity / cohesion (Phase 10 — second half of the split knob): drives the shader's
// uCohesion, which scales the metaball smin blend + wobble. High = crisp spheres that snap back;
// low = gooey/deformed wax. Uniform-only — no per-fragment cost change, weaker hardware unaffected.
setFluidViscosity(value: number): void {
fluidViscosity = Math.min(1, Math.max(0, value));
debugLog(`setFluidViscosity → ${fluidViscosity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setCollisionStrength(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value));
debugLog(`setCollisionStrength → ${collisionStrength.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setWaveformWidth(value: number): void {
waveformWidth = Math.min(1, Math.max(0, value));
debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (rafId === null) redrawOnce();
},
dispose(): void {
disposed = true;
stopLoop();
document.removeEventListener('visibilitychange', onVisibilityChange);
resizeObserver.disconnect();
// Release all GL resources so nothing leaks on navigation (spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
if (vao) gl.deleteVertexArray(vao);
gl.deleteProgram(program);
},
};
}