1885 lines
103 KiB
TypeScript
1885 lines
103 KiB
TypeScript
/**
|
||
* 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 1–2;
|
||
* 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:
|
||
* • 16–32 blobs carry position / velocity / temperature / radius and are
|
||
* integrated each frame with real dt (gravity, temperature-buoyancy, viscous
|
||
* damping, soft floor contact) — see stepPhysics().
|
||
* • 2D ELASTIC COLLISION: blob↔waveform (the ribbon is a read-only boundary the
|
||
* wax is pushed out of along its surface normal) and blob↔blob, both with a
|
||
* soft↔hard strength dial — see stepPhysics()'s collision passes.
|
||
* • The blobs upload as a uBlobs[] uniform array; the fragment shader unions them
|
||
* with smin metaballs + the waveform SDF into one liquid surface (liquidSdf).
|
||
* Colour is a deliberately SIMPLE theme fill for R2 — the OKLab three-colour
|
||
* gradient is Wave R3. No glass, no screen-space noise (removed in R1).
|
||
*
|
||
* The Blazor component owns the canvas element and the inputs (datum, playback,
|
||
* 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 Wave R4 the handle exposes SEVEN dedicated control setters
|
||
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity /
|
||
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. Gradient rotation is
|
||
* stored but inert until Wave R3 builds the OKLab gradient.
|
||
*
|
||
* 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).
|
||
//
|
||
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the
|
||
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping:
|
||
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
|
||
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — INERT
|
||
// until Wave R3 builds the OKLab gradient that consumes it
|
||
// • Lava gravity → gravity dial (setLavaGravity)
|
||
// • Lava heat → heat dial (setLavaHeat)
|
||
// • Blob density/size → density dial (setBlobDensity)
|
||
// • 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 blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */
|
||
export const DEFAULT_BLOB_DENSITY = 0.4;
|
||
|
||
/**
|
||
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
|
||
* [0,1] → slow→fast anchor rotation. INERT until Wave R3 builds the OKLab three-colour gradient
|
||
* that consumes it — stored and round-tripped through the handle so the knob persists, but it
|
||
* drives nothing this wave (the R2 flat placeholder fill ignores it).
|
||
*/
|
||
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
|
||
|
||
/**
|
||
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower
|
||
* values narrow the waveform band so the lava fluid gets more room to move on loud songs.
|
||
*/
|
||
export const DEFAULT_WAVEFORM_WIDTH = 0.6;
|
||
|
||
/**
|
||
* 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 ~20–100px range (§4b). Each blob picks a fixed radius
|
||
* in this band at construction; the density dial biases the average. */
|
||
const BLOB_RADIUS_MIN = 0.025;
|
||
const BLOB_RADIUS_MAX = 0.13;
|
||
|
||
/**
|
||
* Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y).
|
||
* Tuned so wax at full gravity falls back to the floor in well under a second from
|
||
* mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot.
|
||
* The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d).
|
||
*/
|
||
const GRAVITY_ACCEL_MAX = 2.2;
|
||
/** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */
|
||
const GRAVITY_ACCEL_MIN = 0.15;
|
||
|
||
/**
|
||
* Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T − T_ambient).
|
||
* Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's
|
||
* core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a
|
||
* hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy
|
||
* vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint).
|
||
*/
|
||
const BUOYANCY_COEFF = 4.0;
|
||
const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks
|
||
|
||
/**
|
||
* Heat transfer rates (per second), the engine of the convection cycle:
|
||
* - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom),
|
||
* - near the TOP it COOLS toward 0 (loses heat at the cold cap),
|
||
* - everywhere it relaxes gently toward ambient.
|
||
* heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the
|
||
* floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant
|
||
* and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and
|
||
* rise per second (the busy roiling lamp, §4c max endpoint).
|
||
*/
|
||
const HEAT_FLOOR_RATE = 1.6; // °/s toward T=1 when sitting on the floor (× heatScale)
|
||
const HEAT_TOP_RATE = 1.2; // °/s toward T=0 when near the top
|
||
const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere
|
||
const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "hot zone"
|
||
const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone"
|
||
|
||
/**
|
||
* Viscous (linear) velocity damping per second. 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.
|
||
*/
|
||
const WAVE_COLLIDE_SPRING = 12.0; // soft penalty stiffness pushing wax off the ribbon (softened, Daniel #3)
|
||
const WAVE_RESTITUTION_HARD = 1.1; // elastic reflection at full hardness — over-unity for the "throw" (Daniel #4/#6)
|
||
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, in addition to the outward (horizontal) surface-normal push we add
|
||
* an UPWARD (−y) impulse proportional to the penetration depth and the collision-strength
|
||
* dial. At low strength this is ~0 (the ribbon just mushes the wax around horizontally); at
|
||
* high strength a loud transient launches bubbles up and out — the lively "thrown" look. The
|
||
* coefficient is in height-units/s² per unit penetration, scaled by the strength dial.
|
||
*/
|
||
const WAVE_THROW_UP = 26.0;
|
||
|
||
/**
|
||
* 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: ON for the Phase 10 reframe Wave R4 controls pass. Daniel tests in-browser; the FPS lines
|
||
// (which should hold ~60 even while paused, confirming the continuous-loop power cost is acceptable)
|
||
// + the seven-dial lava line confirm the controls + pause fix. Flip back to false at reframe close.
|
||
const DEBUG = true;
|
||
|
||
const TAG = '[MixVisualizer]';
|
||
function debugLog(...args: unknown[]): void {
|
||
if (DEBUG) console.log(TAG, ...args);
|
||
}
|
||
|
||
// ── Theme: the navy↔moss field poles, read live from the active MudBlazor 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 3 binding — the two poles of the morphing colour field (spec §4b/§4c):
|
||
// - `uColorAccent` carries MOSS (the interactive green).
|
||
// - `uColorEdge` carries NAVY (the dark ground / navy-mid).
|
||
// (The names are inherited from the parity two-stop gradient; in Wave 3 they are the
|
||
// two field poles, not a now-line→edge luminance ramp. Kept rather than renamed to
|
||
// avoid touching the bridge's uniform-location cache and the well-tested upload path.)
|
||
//
|
||
// The cross-mode problem (spec §4c, explicit): navy and moss are NOT a single stable
|
||
// pair 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). No one var holds
|
||
// "navy" or "moss" 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 poles per the spec's stated mapping. refreshTheme
|
||
// re-runs this on a dark-mode toggle, so the field re-themes live.
|
||
|
||
interface ResolvedTheme {
|
||
/** Moss-green pole RGB [0,1] — uploaded to uColorAccent. */
|
||
accent: [number, number, number];
|
||
/** Navy pole RGB [0,1] — uploaded to uColorEdge. */
|
||
edge: [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. INERT until Wave R3 (stored + round-tripped only). */
|
||
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/size. */
|
||
setBlobDensity(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)
|
||
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
|
||
// in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old
|
||
// uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason;
|
||
// the JS handle still receives those control values and routes them to the physics (the
|
||
// R2 TEMP knob re-mapping documented at the control-default consts above).
|
||
uniform float uDurationSeconds; // mix length (per datum)
|
||
uniform vec3 uColorAccent; // MOSS pole of the field (per theme)
|
||
uniform vec3 uColorEdge; // NAVY pole of the field (per theme)
|
||
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. Serviceable placeholder until R3's real colour model — kept subtle.
|
||
const vec3 HOT_TINT = vec3(0.95, 0.72, 0.45); // warm amber the hottest wax leans toward
|
||
const float HOT_TINT_AMOUNT = 0.35; // max lean at temperature 1 (above ambient)
|
||
|
||
// Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D
|
||
// texture grid (col = i mod width, row = i / width). texelFetch ignores filtering
|
||
// 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);
|
||
return mix(fetchSample(i0), fetchSample(i1), f);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════════
|
||
// 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);
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
// 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).
|
||
float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5)
|
||
* 2.0 * BLOB_WOBBLE_AMOUNT;
|
||
float rr = r * (1.0 + wob);
|
||
|
||
float blob = sdCircle(p - c, rr);
|
||
field = smin(field, blob, BLOB_SMOOTHMIN_K);
|
||
|
||
// 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; }
|
||
|
||
// ── Simple serviceable FLAT theme fill (R3 replaces with the OKLab three-colour gradient).
|
||
// Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS
|
||
// (uColorAccent) at the extended edge. This horizontal ramp is a gentle field gradient
|
||
// across the whole canvas, NOT a per-blob radial — so the fluid surface reads flat. Just
|
||
// enough colour to read the physics; NOT the final colour model. No glass, no per-blob
|
||
// shading (R3 owns colour).
|
||
float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0);
|
||
vec3 fill = mix(uColorEdge, uColorAccent, xnAbs);
|
||
|
||
// Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate).
|
||
// A flat per-blob temperature lean — no spatial falloff, so it does not reintroduce a cone.
|
||
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() {},
|
||
setBlobDensity() {},
|
||
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'),
|
||
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
|
||
colorAccent: gl.getUniformLocation(program, 'uColorAccent'),
|
||
colorEdge: gl.getUniformLocation(program, 'uColorEdge'),
|
||
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 is stored but INERT until Wave R3 builds the colour gradient.
|
||
let lavaHeat = DEFAULT_LAVA_HEAT;
|
||
let lavaGravity = DEFAULT_LAVA_GRAVITY;
|
||
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
|
||
let blobDensity = DEFAULT_BLOB_DENSITY;
|
||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
|
||
// INERT until Wave R3 — held so the knob round-trips and persists; nothing reads it this wave.
|
||
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
|
||
|
||
/**
|
||
* 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 navy↔moss field poles from the live palette vars on the canvas.
|
||
*
|
||
* Detects light vs dark by the page background luminance, then binds each pole 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: moss = --mud-palette-secondary (#3D7A68), navy = --mud-palette-primary (#17283f)
|
||
* DARK: moss = --mud-palette-primary (#3D7A68), navy = --mud-palette-background (#0D1B2A)
|
||
* This yields the maximal navy↔moss spread the field wants in either theme.
|
||
*/
|
||
function readTheme(): ResolvedTheme {
|
||
const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8'));
|
||
const isDark = luminance(background) < 0.5;
|
||
|
||
const moss = isDark
|
||
? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68'))
|
||
: parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68'));
|
||
const navy = isDark
|
||
? background // the dark ground (#0D1B2A) IS the navy pole on dark
|
||
: parseColor(readVar(canvas, '--mud-palette-primary', '#17283f'));
|
||
|
||
const resolved: ResolvedTheme = { accent: moss, edge: navy };
|
||
// Report BOTH poles the R2 fill will use, as 0-255 RGB + relative luminance. (The
|
||
// rich OKLab colour model is Wave R3; R2 just does a straight A→B theme fill — this
|
||
// line confirms the navy/moss poles resolved off the canvas vars in the active mode.)
|
||
const fmt = (c: [number, number, number]) =>
|
||
`rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`;
|
||
debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`);
|
||
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
|
||
r: number; // BASE radius, height-norm (fixed per blob, density-biased)
|
||
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);
|
||
|
||
/** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
|
||
function seedBlob(b: Blob, aspect: number): void {
|
||
// Density biases radius toward the small end as it rises (more, smaller blobs).
|
||
const radiusBias = 1 - blobDensity * 0.6; // density 0 → big, density 1 → smaller
|
||
const r = (BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN)) * radiusBias;
|
||
b.r = r;
|
||
b.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, r: 0, er: 0, temp: 0, noiseSeed: 0 };
|
||
seedBlob(b, aspect);
|
||
blobs.push(b);
|
||
}
|
||
}
|
||
let blobsInitialized = false;
|
||
|
||
/** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
|
||
function liveBlobCount(): number {
|
||
return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT));
|
||
}
|
||
|
||
/**
|
||
* CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This
|
||
* mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision
|
||
* boundary matches the rendered waveform exactly. Reads the retained datum.samples.
|
||
*/
|
||
function sampleLoudnessAt(timeSeconds: number): number {
|
||
const d = datum;
|
||
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
|
||
const n = d.sampleCount;
|
||
const p = (timeSeconds / d.durationSeconds) * n - 0.5;
|
||
const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1);
|
||
const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1);
|
||
const f = Math.min(Math.max(p - Math.floor(p), 0), 1);
|
||
const s0 = d.samples[i0] / 255;
|
||
const s1 = d.samples[i1] / 255;
|
||
return s0 + (s1 - s0) * f;
|
||
}
|
||
|
||
/** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in.
|
||
* Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and
|
||
* dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square
|
||
* toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */
|
||
function heatScaleFromDial(dial: number): number {
|
||
const d = Math.min(Math.max(dial, 0), 1);
|
||
return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1
|
||
}
|
||
|
||
/** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive),
|
||
* dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to
|
||
* use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the
|
||
* same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */
|
||
function restitution(soft: number, hard: number): number {
|
||
const d = Math.min(Math.max(collisionStrength, 0), 1);
|
||
return soft + (hard - soft) * d;
|
||
}
|
||
|
||
/**
|
||
* 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 heatScale = heatScaleFromDial(lavaHeat);
|
||
const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN);
|
||
const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD);
|
||
const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD);
|
||
const collideHardness = Math.min(Math.max(collisionStrength, 0), 1);
|
||
|
||
// Mix-time mapping at the current playhead (the waveform a blob's row sits over).
|
||
const nowYn = NOW_ANCHOR_FROM_TOP;
|
||
const secondsPerHeight = visibleSeconds;
|
||
const centreX = aspect * 0.5;
|
||
// 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.
|
||
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * waveformWidth;
|
||
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);
|
||
|
||
// 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 (over-unity restitution at the top = the springy throw).
|
||
if (inwardSpeed > 0) {
|
||
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
|
||
}
|
||
|
||
// UPWARD throw (Daniel #4): on top of the outward push, launch the bubble UP. The
|
||
// ribbon only ever drives wax up+out (−y), never down, so loud transients toss
|
||
// bubbles toward the surface. Scaled by penetration × hardness, so at low collision
|
||
// strength it's ~0 (just mushed around) and at high strength it "throws" them up.
|
||
b.vy -= WAVE_THROW_UP * penetration * dt * collideHardness;
|
||
|
||
// Positional push-out: partial at the soft end (wax squishes in then eases out via
|
||
// the spring — Daniel #3 mushy), firm at the hard end (no deep penetration allowed).
|
||
b.x += sideSign * penetration * (0.15 + 0.6 * 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.
|
||
gl.uniform2f(u.resolution, canvas.width, canvas.height);
|
||
gl.uniform1f(u.playheadSeconds, renderedPlayhead());
|
||
gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000);
|
||
// 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, waveformWidth);
|
||
gl.uniform3fv(u.colorAccent, theme.accent);
|
||
gl.uniform3fv(u.colorEdge, theme.edge);
|
||
|
||
// Advance the wax-blob physics by the real elapsed time, then upload the blobs.
|
||
// Stepping here (rather than in the loop) means idle one-shot redraws also advance
|
||
// the sim by their actual dt — clamped by PHYSICS_MAX_DT, so a long paused gap just
|
||
// means the lamp barely moves while paused (it animates with playback, spec §E).
|
||
const nowMs = performance.now();
|
||
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
|
||
lastPhysicsMs = nowMs;
|
||
stepPhysics(physicsDt);
|
||
const liveCount = packBlobs();
|
||
gl.uniform4fv(u.blobs, blobUpload);
|
||
gl.uniform1i(u.blobCount, liveCount);
|
||
|
||
if (datum) {
|
||
gl.uniform1f(u.hasDatum, 1);
|
||
gl.uniform1f(u.durationSeconds, datum.durationSeconds);
|
||
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)} density=${blobDensity.toFixed(2)} | ` +
|
||
`blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
|
||
`avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`,
|
||
);
|
||
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 4096–16384),
|
||
* 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: INERT until Wave R3. Stored so the knob round-trips/persists; the
|
||
// R2 flat placeholder fill ignores it, so there is nothing to redraw.
|
||
setGradientRotationSpeed(value: number): void {
|
||
gradientRotationSpeed = Math.min(1, Math.max(0, value));
|
||
debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)} (inert until R3).`);
|
||
},
|
||
|
||
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();
|
||
},
|
||
|
||
setBlobDensity(value: number): void {
|
||
blobDensity = Math.min(1, Math.max(0, value));
|
||
debugLog(`setBlobDensity → ${blobDensity.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);
|
||
},
|
||
};
|
||
}
|