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