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

1700 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MixVisualizer — the scrolling Mix waveform background (Phase 10 + Lava Reframe).
*
* What this renders: a *windowed* slice of a mix's loudness profile, scrolling
* bottom-to-top, coupled to playback position. New audio enters at the bottom,
* already-played audio exits off the top, and the "now" playhead sits at a fixed
* line (vertical centre by default). This is a read-only, ambient lava-lamp
* background — there is no seek, no click handling, no write-back to playback.
*
* Rendering tech: WebGL2, fragment-shader, plus (as of the Lava Reframe Wave R2) a
* CPU-side per-frame PHYSICS step that drives the wax lava. The scroll/zoom geometry
* and the loudness-datum-as-texture sampling carry forward from Phase 10 Waves 12;
* the playhead wall-clock interpolation + jitter correction carry forward as-is.
*
* THE LAVA (Wave R2 — this revision):
* The rejected analytic-metaball "lava" (scripted blobs that read as giant
* disconnected circles) is replaced by a real Lagrangian wax-blob simulation:
* • 1632 blobs carry position / velocity / temperature / radius and are
* integrated each frame with real dt (gravity, temperature-buoyancy, viscous
* damping, soft floor contact) — see stepPhysics().
* • 2D ELASTIC COLLISION: blob↔waveform (the ribbon is a read-only boundary the
* wax is pushed out of along its surface normal) and blob↔blob, both with a
* soft↔hard strength dial — see stepPhysics()'s collision passes.
* • The blobs upload as a uBlobs[] uniform array; the fragment shader unions them
* with smin metaballs + the waveform SDF into one liquid surface (liquidSdf).
* Colour is a deliberately SIMPLE theme fill for R2 — the OKLab three-colour
* gradient is Wave R3. No glass, no screen-space noise (removed in R1).
*
* The Blazor component owns the canvas element and the inputs (datum, playback,
* zoom, theme, the control dials); this module owns the requestAnimationFrame loop,
* the physics step, and all the GL math. The component drives it through the handle
* returned by `create`. The handle SHAPE is unchanged from Phase 10 — the three
* effect setters are temporarily re-routed to the lava params for this wave (see
* their definitions); Wave R4 gives them proper names + a six-knob UI.
*/
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
/**
* 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 three are
// normalized [0,1].
//
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper six-knob set):
// the three existing control knobs are re-purposed to drive the new lava physics so
// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say
// the old thing; the SETTERS below (setBubblyness/setDetach/setColorShiftSpeed) route
// them to the new physics params. Mapping:
// • "Detach" knob (Air icon) → lava HEAT
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY
// • "Color-shift" knob (Palette) → COLLISION STRENGTH
// Blob DENSITY has no live knob this wave; it sits at DEFAULT_BLOB_DENSITY (R4 adds it).
// The defaults below are chosen so the lava looks ALIVE on open (heat non-zero, mid
// gravity, mid collision) — Daniel then tunes on screen.
/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness. Mid = a settled-but-mobile lamp. */
export const DEFAULT_BUBBLYNESS = 0.5;
/** Default HEAT dial (was detach). Mirrors C# DefaultDetach. Non-zero so the lamp is alive on open. */
export const DEFAULT_DETACH = 0.45;
/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed. Mid soft↔hard. */
export const DEFAULT_COLOR_SHIFT_SPEED = 0.5;
/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */
export const DEFAULT_BLOB_DENSITY = 0.4;
/**
* Where the "now" line sits within the window, as a fraction from the top.
* 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
* Tunable. NOTE: kept in sync with the GLSL constant NOW_ANCHOR_FROM_TOP below.
*/
const NOW_ANCHOR_FROM_TOP = 0.5;
/**
* Half-width of the ribbon at full loudness, as a fraction of half the canvas
* width (the predecessor used 0.92). Mirrors the Canvas `maxHalfWidth` factor.
*/
const RIBBON_HALF_WIDTH_FRAC = 0.92;
/**
* Cap device-pixel-ratio at 2. Beyond that the extra fragments cost frame time for
* no visible gain on a soft glassy backdrop — this is the graceful-degrade lever
* (spec §5.1): drop internal resolution before dropping frames.
*/
const MAX_DPR = 2;
// ════════════════════════════════════════════════════════════════════════════════════
// R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real
// Lagrangian particle system integrated each frame on the JS side and rendered as
// smin metaballs in the fragment shader. EVERYTHING below is the tuning surface for
// the lava look; Daniel reads + tunes these.
//
// Coordinate convention (shared with the shader): physics runs in HEIGHT-NORMALIZED
// space — every position/velocity/radius is in units of (pixel / canvasHeight). So
// y ∈ [0, 1] top→bottom, x ∈ [0, W/H], and the FLOOR (footer / lava rest line) is the
// bottom edge of the canvas at y = 1 (the canvas is already CSS-clipped to the footer
// top in R1, so its own bottom edge IS the footer line — no extra clip uniform needed).
// Using one isotropic unit keeps blobs round and collisions correct at any aspect.
// ════════════════════════════════════════════════════════════════════════════════════
/**
* Hard upper bound on the simulated/rendered blob population. The per-fragment shader
* loops to this constant, so it caps GPU cost; the CPU step is O(MAX_BLOBS²) for
* blob↔blob (≤ ~1k pair tests — trivial). 32 is the spec's upper band (§4g). The live
* count varies with the density dial but never exceeds this.
*/
const MAX_BLOBS = 32;
/** Lower end of the live blob count (density dial 0 → a few large lazy blobs, §4e). */
const MIN_BLOB_COUNT = 16;
/** Blob radius band in height-normalized units. 0.025·H ≈ 20px and 0.13·H ≈ 100px on a
* ~760px-tall canvas — the spec's ~20100px range (§4b). Each blob picks a fixed radius
* in this band at construction; the density dial biases the average. */
const BLOB_RADIUS_MIN = 0.025;
const BLOB_RADIUS_MAX = 0.13;
/**
* Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y).
* Tuned so wax at full gravity falls back to the floor in well under a second from
* mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot.
* The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d).
*/
const GRAVITY_ACCEL_MAX = 2.2;
/** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */
const GRAVITY_ACCEL_MIN = 0.15;
/**
* Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T T_ambient).
* Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's
* core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a
* hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy
* vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint).
*/
const BUOYANCY_COEFF = 4.0;
const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks
/**
* Heat transfer rates (per second), the engine of the convection cycle:
* - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom),
* - near the TOP it COOLS toward 0 (loses heat at the cold cap),
* - everywhere it relaxes gently toward ambient.
* heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the
* floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant
* and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and
* rise per second (the busy roiling lamp, §4c max endpoint).
*/
const HEAT_FLOOR_RATE = 1.6; // °/s toward T=1 when sitting on the floor (× heatScale)
const HEAT_TOP_RATE = 1.2; // °/s toward T=0 when near the top
const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere
const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "hot zone"
const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone"
/**
* Viscous (linear) velocity damping per second — the lazy/high-viscosity regime that
* makes it read as wax, not water (spec §4a). Applied as v *= exp(DAMPING·dt) each
* step, so it is frame-rate independent. High enough that motion is slow and gooey;
* low enough that hot blobs still make the trip up.
*/
const VISCOUS_DAMPING = 1.4;
/**
* Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up
* by a spring proportional to its penetration below the floor, and its downward velocity
* is killed on contact so pooled wax flattens and settles rather than bouncing forever.
*/
const FLOOR_SPRING = 26.0; // restoring accel per unit penetration (height-units/s²)
const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor contact (settle)
/**
* Blob↔blob collision: the soft↔hard knob (collision strength) blends a penalty SPRING
* (soft displacement, blobs squish and partially overlap then ease apart) toward an
* elastic IMPULSE (hard, crisp restitution along the centre line). These are the two
* endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the
* hard end; the spring stiffness is the firmness of the soft end.
*/
const BLOB_COLLIDE_SPRING = 14.0; // soft penalty stiffness (height-units/s² per overlap)
const BLOB_RESTITUTION_HARD = 0.9; // elastic restitution at strength = 1 (near-perfect bounce)
const BLOB_RESTITUTION_SOFT = 0.15; // residual restitution at strength = 0 (mostly absorptive)
/**
* Blob↔waveform collision (always on, independent of heat — §5b). The waveform's
* half-width at a blob's row is sampled CPU-side each frame; a blob whose centre is
* within (halfWidth + radius) of the centre line is penetrating the ribbon and is pushed
* out along the surface normal. Same soft↔hard blend as blob↔blob: a penalty spring at
* the soft end → elastic reflection of the inward velocity at the hard end. The waveform
* is read-only authority: it pushes the fluid, the fluid never moves it.
*/
const WAVE_COLLIDE_SPRING = 20.0; // soft penalty stiffness pushing wax off the ribbon
const WAVE_RESTITUTION_HARD = 0.85; // elastic reflection strength at full collision hardness
const WAVE_RESTITUTION_SOFT = 0.1;
/**
* Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a
* blob tunnel through the floor or another blob in one step (explosive overlap). We clamp
* dt so the integrator stays stable — a long stall just means the sim advances a little
* slowly that frame, which is invisible. (We also sub-step within this cap below.)
*/
const PHYSICS_MAX_DT = 1 / 30;
/** Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable. */
const PHYSICS_SUBSTEPS = 2;
/**
* Playhead-correction smoothing time constant, in seconds. Governs how fast the
* rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push.
*
* 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 this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser;
// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the
// look is approved.
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 the rAF loop so a paused mix stays cool. */
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;
setZoom(visibleSeconds: number): void;
/** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */
setBubblyness(value: number): void;
/** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */
setDetach(value: number): void;
/** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */
setColorShiftSpeed(value: number): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
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)
// 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.
const float BLOB_SMOOTHMIN_K = 0.045;
// smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax
// reads as continuous with the ribbon surface rather than a disc sitting on a wall.
const float WAVE_SMOOTHMIN_K = 0.03;
// 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
float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; // ribbon half-width here
// Distance to a centred vertical band of half-width halfW: |x centre| halfW.
// Negative inside the band, positive outside. (A pure horizontal band; the vertical
// extent is the whole column, which is what the scrolling ribbon is.)
return abs(p.x - centreX) - halfW;
}
// ── The combined wax + waveform liquid SDF at a height-normalized point. ─────────────
//
// 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 + its gradient (the surface normal). ──────────
// Central differences in height-norm space; the step is one device pixel = 1/h.
float hot;
float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot);
float e = 1.0 / h; // one-pixel step in height-norm units
float ig;
float dRx = liquidSdf(p + vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig);
float dLx = liquidSdf(p - vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig);
float dDy = liquidSdf(p + vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig);
float dUy = liquidSdf(p - vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig);
vec2 grad = vec2(dRx - dLx, dDy - dUy);
vec2 normal = length(grad) > 1e-5 ? normalize(grad) : vec2(0.0, -1.0);
// Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an
// anti-aliased edge instead of a hard chart line (no blur — spec §2/§3).
float pxFeather = 1.2 / h;
float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d);
if (inside <= 0.0) { fragColor = vec4(0.0); return; }
// ── Simple serviceable theme fill (R3 replaces with the OKLab three-colour gradient).
// Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS
// (uColorAccent) at the extended edge. Just enough colour to read the physics; NOT the
// final colour model. No HSL, no vivify, no glass — those are gone (R3 owns colour).
float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0);
vec3 fill = mix(uColorEdge, uColorAccent, xnAbs);
// Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate).
float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT;
fill = mix(fill, HOT_TINT, hotLean);
// A soft top-light shade off the surface normal so the wax has form (a single lazy
// gradient, not the old four-part glass). Keeps it from reading flat without competing
// with the (future) colour model.
float lightUp = clamp(dot(normal, vec2(0.0, -1.0)) * 0.5 + 0.5, 0.0, 1.0);
fill *= mix(0.82, 1.12, lightUp);
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() {},
setZoom() {},
setBubblyness() {},
setDetach() {},
setColorShiftSpeed() {},
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'),
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 (the R2 TEMP knob re-mapping — see the control-default
// consts at the top of this file). These are the dials the existing knobs feed, routed
// here by the handle setters. They drive the CPU physics step below, NOT a shader uniform.
let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat
let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity
let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness
let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it)
/**
* The *authoritative* playhead for this instant: the last pushed position advanced
* 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; // radius, height-norm (fixed per blob, density-biased)
temp: number; // temperature 0..1
}
// The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS,
// driven by the density dial) decides how many we simulate + upload this frame.
const blobs: Blob[] = [];
// The packed upload buffer (vec4 per blob). Reused every frame — no per-frame alloc.
const blobUpload = new Float32Array(MAX_BLOBS * 4);
/** Cheap deterministic PRNG (mulberry32) so blob spawn is varied but reproducible. */
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = makeRng(0x1a2b3c4d);
/** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
function seedBlob(b: Blob, aspect: number): void {
// Density biases radius toward the small end as it rises (more, smaller blobs).
const radiusBias = 1 - blobDensity * 0.6; // density 0 → big, density 1 → smaller
const r = (BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN)) * radiusBias;
b.r = r;
b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width
b.y = 1 - r - rng() * 0.1; // pooled near the floor
b.vx = 0;
b.vy = 0;
b.temp = rng() * 0.3; // cool to start (heats at the floor)
}
/** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */
function initBlobs(aspect: number): void {
blobs.length = 0;
for (let i = 0; i < MAX_BLOBS; i++) {
const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, temp: 0 };
seedBlob(b, aspect);
blobs.push(b);
}
}
let blobsInitialized = false;
/** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
function liveBlobCount(): number {
return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT));
}
/**
* CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This
* mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision
* boundary matches the rendered waveform exactly. Reads the retained datum.samples.
*/
function sampleLoudnessAt(timeSeconds: number): number {
const d = datum;
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
const n = d.sampleCount;
const p = (timeSeconds / d.durationSeconds) * n - 0.5;
const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1);
const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1);
const f = Math.min(Math.max(p - Math.floor(p), 0), 1);
const s0 = d.samples[i0] / 255;
const s1 = d.samples[i1] / 255;
return s0 + (s1 - s0) * f;
}
/** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in.
* Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and
* dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square
* toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */
function heatScaleFromDial(dial: number): number {
const d = Math.min(Math.max(dial, 0), 1);
return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1
}
/** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive),
* dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to
* use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the
* same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */
function restitution(soft: number, hard: number): number {
const d = Math.min(Math.max(collisionStrength, 0), 1);
return soft + (hard - soft) * d;
}
/**
* Advance the physics by dt seconds. Sub-stepped for spring stability. The collision
* model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon
* surface normal, always on), blob↔blob (elastic, soft↔hard via the strength dial).
*/
function stepPhysics(dtTotal: number): void {
if (canvas.height <= 0) return;
const aspect = canvas.width / canvas.height;
if (!blobsInitialized) {
initBlobs(aspect);
blobsInitialized = true;
}
const count = liveBlobCount();
const heatScale = heatScaleFromDial(lavaHeat);
const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN);
const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD);
const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD);
const collideHardness = Math.min(Math.max(collisionStrength, 0), 1);
// Mix-time mapping at the current playhead (the waveform a blob's row sits over).
const nowYn = NOW_ANCHOR_FROM_TOP;
const secondsPerHeight = visibleSeconds;
const centreX = aspect * 0.5;
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC;
const playhead = effectivePlayhead();
const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS;
for (let s = 0; s < PHYSICS_SUBSTEPS; s++) {
// ── Per-blob: heat exchange, buoyancy, gravity, damping, floor contact. ──
for (let i = 0; i < count; i++) {
const b = blobs[i];
// Heat exchange: floor heats (× heat dial), top cools, all relax to ambient.
const distFromFloor = 1 - b.y;
const distFromTop = b.y;
if (distFromFloor < HEAT_FLOOR_ZONE) {
const near = 1 - distFromFloor / HEAT_FLOOR_ZONE; // 1 at floor → 0 at zone edge
b.temp += (1 - b.temp) * HEAT_FLOOR_RATE * heatScale * near * dt;
}
if (distFromTop < HEAT_TOP_ZONE) {
const near = 1 - distFromTop / HEAT_TOP_ZONE;
b.temp += (0 - b.temp) * HEAT_TOP_RATE * near * dt;
}
b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt;
b.temp = Math.min(Math.max(b.temp, 0), 1);
// Forces: gravity down (+y), buoyancy from temperature (up = y when hot).
const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT);
b.vy += (gravity - buoyancy) * dt;
// Viscous damping (lazy wax): frame-rate-independent exponential decay.
const damp = Math.exp(-VISCOUS_DAMPING * dt);
b.vx *= damp;
b.vy *= damp;
// Integrate position.
b.x += b.vx * dt;
b.y += b.vy * dt;
// Floor: soft contact spring + extra damping so resting wax pools and flattens.
const floorY = 1 - b.r;
if (b.y > floorY) {
const pen = b.y - floorY;
b.vy -= FLOOR_SPRING * pen * dt; // spring pushes up out of the floor
if (b.vy > 0) b.vy *= Math.exp(-FLOOR_CONTACT_DAMPING * dt); // kill the downward drive
b.y = floorY + pen * 0.5; // ease the penetration out (soft, no snap)
}
// Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools
// at the top and falls back; just keep it inside the box.
const ceilY = b.r;
if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; }
// Side walls: reflect softly so wax stays on screen.
if (b.x < b.r) { b.x = b.r; if (b.vx < 0) b.vx = -b.vx * 0.3; }
if (b.x > aspect - b.r) { b.x = aspect - b.r; if (b.vx > 0) b.vx = -b.vx * 0.3; }
}
// ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ──
// The waveform is a centred vertical band of half-width = loudness(row). A blob
// whose centre is within (halfWidth + r) of the centre line penetrates it and is
// pushed out along the band's surface normal (horizontal). Read-only authority:
// the waveform is never moved, only the wax responds.
for (let i = 0; i < count; i++) {
const b = blobs[i];
const t = playhead + (b.y - nowYn) * secondsPerHeight;
const amp = sampleLoudnessAt(t);
if (amp <= 0) continue;
const halfW = amp * maxHalf;
const dx = b.x - centreX;
const sideSign = dx >= 0 ? 1 : -1; // outward surface normal (in x)
const penetration = halfW + b.r - Math.abs(dx);
if (penetration > 0) {
// Soft penalty (the soft end of the dial): a spring proportional to the
// penetration depth pushes the wax out along the normal. Stronger as the
// dial → soft so the soft regime still recovers, just gently.
b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness * 0.5);
// Hard elastic (the hard end): reflect the velocity component going INTO
// the ribbon back out, scaled by restitution × hardness. inwardSpeed > 0
// means the blob is moving toward the centre line (into the surface).
const inwardSpeed = -sideSign * b.vx;
if (inwardSpeed > 0) {
// Remove the inward component and add back a restituted outward one.
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
}
// Positional push-out: firm at the hard end (no penetration allowed),
// partial at the soft end (wax squishes in then eases out via the spring).
b.x += sideSign * penetration * (0.3 + 0.7 * collideHardness);
}
}
// ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ──
// O(count²) ≤ ~1k pair tests — trivial. Mass ∝ r² so big blobs shove small ones.
for (let i = 0; i < count; i++) {
const a = blobs[i];
for (let j = i + 1; j < count; j++) {
const c = blobs[j];
let dx = c.x - a.x;
let dy = c.y - a.y;
let dist = Math.hypot(dx, dy);
const minDist = a.r + c.r;
if (dist >= minDist || dist <= 1e-6) continue;
const nx = dx / dist, ny = dy / dist; // collision normal a→c
const overlap = minDist - dist;
const ma = a.r * a.r, mc = c.r * c.r; // mass ∝ area
const invSum = 1 / (ma + mc);
// Positional separation along the normal, mass-weighted (split the overlap).
const sep = overlap * (0.3 + 0.7 * collideHardness);
a.x -= nx * sep * (mc * invSum);
a.y -= ny * sep * (mc * invSum);
c.x += nx * sep * (ma * invSum);
c.y += ny * sep * (ma * invSum);
// Soft penalty spring along the normal (gentle shove, low strength).
const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness * 0.6) * dt;
a.vx -= nx * springAcc; a.vy -= ny * springAcc;
c.vx += nx * springAcc; c.vy += ny * springAcc;
// Elastic impulse along the normal (hard end), with restitution + mass.
const rvx = c.vx - a.vx, rvy = c.vy - a.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal < 0) { // approaching
const e = collideRest * collideHardness;
const impulse = -(1 + e) * velAlongNormal * invSum;
a.vx -= impulse * mc * nx; a.vy -= impulse * mc * ny;
c.vx += impulse * ma * nx; c.vy += impulse * ma * ny;
}
}
}
}
}
/** Pack the live blobs into the upload buffer. Returns the live count. */
function packBlobs(): number {
const count = liveBlobCount();
for (let i = 0; i < count; i++) {
const b = blobs[i];
const o = i * 4;
blobUpload[o] = b.x;
blobUpload[o + 1] = b.y;
blobUpload[o + 2] = b.r;
blobUpload[o + 3] = b.temp;
}
return count;
}
let rafId: number | null = null;
let disposed = false;
const startTimeMs = performance.now();
// Wall-clock anchor for the physics dt (separate from the playhead decay clock).
let lastPhysicsMs = performance.now();
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
// the two failure modes: a rate near the display refresh (~60) with the playhead
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
// to the playback pushes instead of free-running. 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);
// While idle, draw one still frame reflecting the new size. While playing,
// the running loop will redraw on its next tick — no action needed.
if (!playback.isPlaying) 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.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 (spec §E: cool when paused/backgrounded). ─────────────
//
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
// by the ResizeObserver.
//
// Smoothness (spec §2e / §5.4): 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.)
/** 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 only while playing. Each frame draws the scrolling
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
* stopped since this frame was queued, in which case it draws one final still
* frame (already done above) and exits the loop.
*
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
* that the loop does not run at all when paused, so a foregrounded-but-paused
* mix burns no frames (spec §E / §5.3).
*/
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;
for (let i = 0; i < live; i++) {
const b = blobs[i];
avgTemp += b.temp;
if (b.temp > TEMP_AMBIENT) buoyant++;
if (b.y > 1 - b.r - 0.04) pooled++;
}
debugLog(
`lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` +
`collision=${collisionStrength.toFixed(2)} density=${blobDensity.toFixed(2)} | ` +
`blobs=${live} buoyant=${buoyant} pooled=${pooled} avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)}.`,
);
fpsFrameCount = 0;
fpsWindowStartMs = nowMs;
}
}
if (playback.isPlaying) {
rafId = requestAnimationFrame(frame);
} else {
// Playback stopped between queue and now; final still frame drawn above.
rafId = null;
}
}
// Read the initial size synchronously (one getBoundingClientRect at setup is
// fine — it is the ResizeObserver that must not measure per-frame), then draw a
// still frame so the canvas isn't blank before the first play command.
{
const rect = canvas.getBoundingClientRect();
applySize(rect.width, rect.height);
redrawOnce();
}
/**
* Upload the loudness samples as a 2-D R8 texture that respects
* GL_MAX_TEXTURE_SIZE, returning the Datum (with the grid dimensions the shader
* needs to map a sample index → texel) or null on empty/invalid input.
*
* Why 2-D and not 1×N: the mix datum runs at ~333 samples/s, so any mix over
* ~49 s produces more samples than GL_MAX_TEXTURE_SIZE (commonly 409616384),
* and `texImage2D(…, width=N, height=1, …)` is rejected outright
* ("Requested size at this level is unsupported"), leaving the waveform texture
* uncreated and the ribbon blank. Laying the N samples row-major across a grid
* of width = min(N, safeWidth) keeps every dimension well within the limit.
*
* Filtering: the shader reads with texelFetch and does its own time-axis
* interpolation (see sampleAt), so NEAREST is correct here — hardware LINEAR on
* a 2-D grid would bleed across the row-wrap seam. The final row is zero-padded
* (texture init is zero-filled, then we overwrite the real samples); padding is
* never read because sampleAt clamps the index to sampleCount-1.
*/
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
if (durationSeconds <= 0 || !samplesBase64) {
// Expected before the player reports a duration: the bridge pushes an empty
// datum until then. Not an error, but worth seeing while diagnosing.
debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
return null;
}
const samples = decodeSamples(samplesBase64);
const sampleCount = samples.length;
if (sampleCount === 0) {
console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
return null;
}
// Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
// under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
// still clamp it to the actual max in case a driver reports something smaller.
const SAFE_WIDTH = 4096;
const texWidth = Math.min(sampleCount, Math.min(SAFE_WIDTH, maxTextureSize));
const texHeight = Math.ceil(sampleCount / texWidth);
debugLog(
`uploadDatum — ${sampleCount} samples for ${durationSeconds.toFixed(2)}s mix ` +
`(${(sampleCount / durationSeconds).toFixed(1)} samples/s); ` +
`datum texture ${texWidth}x${texHeight} for N=${sampleCount} samples, maxTextureSize=${maxTextureSize}.`,
);
// Pad the final partial row with zeros so the full grid uploads in one call.
const padded = texWidth * texHeight === sampleCount
? samples
: (() => {
const buf = new Uint8Array(texWidth * texHeight);
buf.set(samples);
return buf;
})();
const texture = gl.createTexture();
if (!texture) return null;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// R8 rows are 1-byte-per-texel and texWidth is not guaranteed 4-aligned;
// relax the default 4-byte unpack alignment so rows aren't read with stride
// padding the source array doesn't have.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.R8,
texWidth, texHeight, 0,
gl.RED, gl.UNSIGNED_BYTE, padded,
);
// NEAREST: texelFetch ignores the filter anyway, but be honest about it — the
// shader interpolates manually to avoid the row-wrap seam (see sampleAt).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null);
return { texture, texWidth, texHeight, sampleCount, durationSeconds, samples };
}
return {
setDatum(samplesBase64: string, durationSeconds: number): void {
debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
// Free the previous datum's GPU texture before replacing it (no leak
// across re-pushes / mix changes — spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — refresh the still slice immediately
// when idle. If playing, the running loop picks it up next frame.
if (!playback.isPlaying) 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.
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 idle
// we want the exact authoritative position, not a glide from a stale render:
// a resume should land on the real position, and a paused still 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;
}
if (isPlaying && !wasPlaying) {
// Transition paused/stopped → playing: start the rAF loop.
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
startLoop();
} else if (!isPlaying && wasPlaying) {
// Transition playing → paused/stopped: the in-flight frame draws the
// final still position and exits on its own (frame() checks
// playback.isPlaying before rescheduling). We do NOT stopLoop() here —
// that would cancel the in-flight frame before it draws, leaving a
// stale canvas. Let the frame run out.
}
// isPlaying unchanged (position-only update): the running loop (if any)
// redraws next frame; nothing to do here.
},
setZoom(seconds: number): void {
// Clamp into the supported span so a stray value can't break the math.
visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
// While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the
// loop is stopped (spec §E), so a zoom change must force one still frame here or the new
// span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never.
const idleRedraw = !playback.isPlaying;
debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`);
if (idleRedraw) redrawOnce();
},
// ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob
// set). The bridge still calls these three setters by their OLD names — the names are
// a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one
// wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel
// can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions
// still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI.
// setBubblyness ← "Bubblyness" knob → lava GRAVITY
// setDetach ← "Detach" knob → lava HEAT
// setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH
// Idle redraw mirrors setZoom so a paused tweak still updates the still frame.
setBubblyness(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity
debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
},
setDetach(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat
debugLog(`setHeat (via setDetach) → ${lavaHeat.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
},
setColorShiftSpeed(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness
debugLog(`setCollisionStrength (via setColorShiftSpeed) → ${collisionStrength.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (!playback.isPlaying) redrawOnce();
},
dispose(): void {
disposed = true;
stopLoop();
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);
},
};
}