/** * 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, * zoom, theme, the control dials); this module owns the requestAnimationFrame loop, * the physics step, and all the GL math. The component drives it through the handle * returned by `create`. The handle SHAPE is unchanged from Phase 10 — the three * effect setters are temporarily re-routed to the lava params for this wave (see * their definitions); Wave R4 gives them proper names + a six-knob UI. */ // ── Tuning anchors (see spec §B). These are the load-bearing constants. ────────── /** * Hard anchor: at maximum zoom the window shows exactly one quarter note at * 180 BPM = 60 / 180 s = 0.333 s of audio, top to bottom. This is a fixed * requirement, not a tunable. */ export const MIN_VISIBLE_SECONDS = 60 / 180; // 0.3333… s — quarter note @ 180 BPM /** Slow end of the zoom range — how much of the mix is visible at minimum zoom. Tunable. */ export const MAX_VISIBLE_SECONDS = 30; /** Default opening window when a mix is first opened. Tunable. */ export const DEFAULT_VISIBLE_SECONDS = 10; // ── Control tuning anchors. These mirror the C#-side defaults in ────────────────── // MixVisualizerControlState.cs — keep the two in sync, exactly as the // DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are // normalized [0,1]. // // R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper six-knob set): // the three existing control knobs are re-purposed to drive the new lava physics so // Daniel can feel the system in-browser this wave. The knob NAMES on screen still say // the old thing; the SETTERS below (setBubblyness/setDetach/setColorShiftSpeed) route // them to the new physics params. Mapping: // • "Detach" knob (Air icon) → lava HEAT // • "Bubblyness" knob (BubbleChart) → lava GRAVITY // • "Color-shift" knob (Palette) → COLLISION STRENGTH // Blob DENSITY has no live knob this wave; it sits at DEFAULT_BLOB_DENSITY (R4 adds it). // The defaults below are chosen so the lava looks ALIVE on open (heat non-zero, mid // gravity, mid collision) — Daniel then tunes on screen. /** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness. Mid = a settled-but-mobile lamp. */ export const DEFAULT_BUBBLYNESS = 0.5; /** Default HEAT dial (was detach). Mirrors C# DefaultDetach. Non-zero so the lamp is alive on open. */ export const DEFAULT_DETACH = 0.45; /** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed. Mid soft↔hard. */ export const DEFAULT_COLOR_SHIFT_SPEED = 0.5; /** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */ export const DEFAULT_BLOB_DENSITY = 0.4; /** * Where the "now" line sits within the window, as a fraction from the top. * 0.5 = vertical centre (default): a short lead-in below, a short trail-out above. * Tunable. NOTE: kept in sync with the GLSL constant NOW_ANCHOR_FROM_TOP below. */ const NOW_ANCHOR_FROM_TOP = 0.5; /** * Half-width of the ribbon at full loudness, as a fraction of half the canvas * width (the predecessor used 0.92). Mirrors the Canvas `maxHalfWidth` factor. */ const RIBBON_HALF_WIDTH_FRAC = 0.92; /** * Cap device-pixel-ratio at 2. Beyond that the extra fragments cost frame time for * no visible gain on a soft glassy backdrop — this is the graceful-degrade lever * (spec §5.1): drop internal resolution before dropping frames. */ const MAX_DPR = 2; // ════════════════════════════════════════════════════════════════════════════════════ // R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real // Lagrangian particle system integrated each frame on the JS side and rendered as // smin metaballs in the fragment shader. EVERYTHING below is the tuning surface for // the lava look; Daniel reads + tunes these. // // Coordinate convention (shared with the shader): physics runs in HEIGHT-NORMALIZED // space — every position/velocity/radius is in units of (pixel / canvasHeight). So // y ∈ [0, 1] top→bottom, x ∈ [0, W/H], and the FLOOR (footer / lava rest line) is the // bottom edge of the canvas at y = 1 (the canvas is already CSS-clipped to the footer // top in R1, so its own bottom edge IS the footer line — no extra clip uniform needed). // Using one isotropic unit keeps blobs round and collisions correct at any aspect. // ════════════════════════════════════════════════════════════════════════════════════ /** * Hard upper bound on the simulated/rendered blob population. The per-fragment shader * loops to this constant, so it caps GPU cost; the CPU step is O(MAX_BLOBS²) for * blob↔blob (≤ ~1k pair tests — trivial). 32 is the spec's upper band (§4g). The live * count varies with the density dial but never exceeds this. */ const MAX_BLOBS = 32; /** Lower end of the live blob count (density dial 0 → a few large lazy blobs, §4e). */ const MIN_BLOB_COUNT = 16; /** Blob radius band in height-normalized units. 0.025·H ≈ 20px and 0.13·H ≈ 100px on a * ~760px-tall canvas — the spec's ~20–100px range (§4b). Each blob picks a fixed radius * in this band at construction; the density dial biases the average. */ const BLOB_RADIUS_MIN = 0.025; const BLOB_RADIUS_MAX = 0.13; /** * Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y). * Tuned so wax at full gravity falls back to the floor in well under a second from * mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot. * The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d). */ const GRAVITY_ACCEL_MAX = 2.2; /** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */ const GRAVITY_ACCEL_MIN = 0.15; /** * Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T − T_ambient). * Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's * core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a * hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy * vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint). */ const BUOYANCY_COEFF = 4.0; const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks /** * Heat transfer rates (per second), the engine of the convection cycle: * - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom), * - near the TOP it COOLS toward 0 (loses heat at the cold cap), * - everywhere it relaxes gently toward ambient. * heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the * floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant * and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and * rise per second (the busy roiling lamp, §4c max endpoint). */ const HEAT_FLOOR_RATE = 1.6; // °/s toward T=1 when sitting on the floor (× heatScale) const HEAT_TOP_RATE = 1.2; // °/s toward T=0 when near the top const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "hot zone" const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone" /** * Viscous (linear) velocity damping per second — the lazy/high-viscosity regime that * makes it read as wax, not water (spec §4a). Applied as v *= exp(−DAMPING·dt) each * step, so it is frame-rate independent. High enough that motion is slow and gooey; * low enough that hot blobs still make the trip up. */ const VISCOUS_DAMPING = 1.4; /** * Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up * by a spring proportional to its penetration below the floor, and its downward velocity * is killed on contact so pooled wax flattens and settles rather than bouncing forever. */ const FLOOR_SPRING = 26.0; // restoring accel per unit penetration (height-units/s²) const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor contact (settle) /** * Blob↔blob collision: the soft↔hard knob (collision strength) blends a penalty SPRING * (soft displacement, blobs squish and partially overlap then ease apart) toward an * elastic IMPULSE (hard, crisp restitution along the centre line). These are the two * endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the * hard end; the spring stiffness is the firmness of the soft end. */ const BLOB_COLLIDE_SPRING = 14.0; // soft penalty stiffness (height-units/s² per overlap) const BLOB_RESTITUTION_HARD = 0.9; // elastic restitution at strength = 1 (near-perfect bounce) const BLOB_RESTITUTION_SOFT = 0.15; // residual restitution at strength = 0 (mostly absorptive) /** * Blob↔waveform collision (always on, independent of heat — §5b). The waveform's * half-width at a blob's row is sampled CPU-side each frame; a blob whose centre is * within (halfWidth + radius) of the centre line is penetrating the ribbon and is pushed * out along the surface normal. Same soft↔hard blend as blob↔blob: a penalty spring at * the soft end → elastic reflection of the inward velocity at the hard end. The waveform * is read-only authority: it pushes the fluid, the fluid never moves it. */ const WAVE_COLLIDE_SPRING = 20.0; // soft penalty stiffness pushing wax off the ribbon const WAVE_RESTITUTION_HARD = 0.85; // elastic reflection strength at full collision hardness const WAVE_RESTITUTION_SOFT = 0.1; /** * Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a * blob tunnel through the floor or another blob in one step (explosive overlap). We clamp * dt so the integrator stays stable — a long stall just means the sim advances a little * slowly that frame, which is invisible. (We also sub-step within this cap below.) */ const PHYSICS_MAX_DT = 1 / 30; /** Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable. */ const PHYSICS_SUBSTEPS = 2; /** * Playhead-correction smoothing time constant, in seconds. Governs how fast the * rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push. * * The problem: the player's position reports are irregular at startup (buffering / * playback ramp-up), so each push lands a position that doesn't match where the * wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior * behaviour) made that gap a visible snap every push — the startup jitter. * * The fix (classic netcode-style entity reconciliation): the player stays the sole * source of truth, but instead of rendering the authoritative position directly, we * render authoritative + a small *correction offset* that decays toward zero every * frame. On each push we fold the re-anchor discontinuity into that offset so the * rendered playhead is continuous across the push, then bleed the offset off over * ~this time constant. This eases the snap into a sub-perceptible glide. * * Why an offset that decays to zero, not an absolute lerp toward target: a lerp * toward the target leaves a steady-state lag proportional to velocity (the render * always trailing real playback). Decaying the *error* to zero converges the * rendered playhead back onto the authoritative one, so once pushes steady the * offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and * steady-state is unchanged as required. * * 0.12 s is a sensible default: long enough to dissolve the worst startup snaps * (tens of ms of position error), short enough that the correction is imperceptible * and the render never trails real playback by more than a few ms. Tunable. */ const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12; /** * Below this absolute correction (seconds) we snap the offset to 0 and stop easing — * an exponential decay never mathematically reaches zero, and carrying a sub-ms * residual forever is pointless. ~0.5 ms is well under one frame of motion at any * real zoom, so collapsing it is invisible. */ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005; // ── Diagnostics ────────────────────────────────────────────────────────────────── // // Set true to surface the init/draw/datum/playback seams to the browser console // (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this // flag — they only trigger on the abnormal path. The verbose `log` paths (datum // received/uploaded, first-draw dimensions, GL error after first draw) are gated // here so they can be silenced once the renderer is confirmed healthy. Leave it on // while the runtime fix is being verified through the browser. // NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser; // the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the // look is approved. const DEBUG = true; const TAG = '[MixVisualizer]'; function debugLog(...args: unknown[]): void { if (DEBUG) console.log(TAG, ...args); } // ── Theme: the navy↔moss field poles, read live from the active MudBlazor palette. ─ // // The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain // floats. So we read the computed `--mud-palette-*` custom properties straight off // the canvas element (which inherits them from the page), parse them to RGB, and // upload them as vec3 colour uniforms. The bespoke light/dark themes swap those vars // when dark mode toggles, so re-reading + re-uploading them re-themes the field with // no reload. The component just calls `refreshTheme()` after a dark-mode change. // // Wave 3 binding — the two poles of the morphing colour field (spec §4b/§4c): // - `uColorAccent` carries MOSS (the interactive green). // - `uColorEdge` carries NAVY (the dark ground / navy-mid). // (The names are inherited from the parity two-stop gradient; in Wave 3 they are the // two field poles, not a now-line→edge luminance ramp. Kept rather than renamed to // avoid touching the bridge's uniform-location cache and the well-tested upload path.) // // The cross-mode problem (spec §4c, explicit): navy and moss are NOT a single stable // pair of CSS vars across both palettes. Navy is `--mud-palette-primary` in LIGHT but // the `--mud-palette-background` ground in DARK; moss is `--mud-palette-secondary` in // LIGHT but `--mud-palette-primary` in DARK (where green IS primary). No one var holds // "navy" or "moss" in both modes. So we detect the mode in JS (by the luminance of the // page background — the bespoke dark ground #0D1B2A is near-black, the light ground // #FAFAF8 is near-white) and bind the poles per the spec's stated mapping. refreshTheme // re-runs this on a dark-mode toggle, so the field re-themes live. interface ResolvedTheme { /** Moss-green pole RGB [0,1] — uploaded to uColorAccent. */ accent: [number, number, number]; /** Navy pole RGB [0,1] — uploaded to uColorEdge. */ edge: [number, number, number]; } /** sRGB relative luminance (cheap Rec.709 weights) of a normalized RGB triple. */ function luminance([r, g, b]: [number, number, number]): number { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * Read a CSS custom property off an element, falling back if it is empty/undefined. * An empty value means the var did not inherit onto the canvas (e.g. the palette is * scoped to a wrapper the canvas isn't under), which would silently swap the ribbon * colour for the hardcoded default — so warn on it when diagnosing. */ function readVar(el: Element, name: string, fallback: string): string { const v = getComputedStyle(el).getPropertyValue(name).trim(); if (v.length === 0) { if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`); return fallback; } return v; } /** * Parse a CSS colour string to normalized [0,1] RGB. Handles #rgb / #rrggbb and * rgb()/rgba() — the only forms MudBlazor emits for these palette vars. Falls back * to mid-grey on anything unrecognised so a parse miss degrades to a visible * ribbon rather than black. */ function parseColor(css: string): [number, number, number] { const s = css.trim(); if (s.startsWith('#')) { let hex = s.slice(1); if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } if (hex.length >= 6) { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) { return [r / 255, g / 255, b / 255]; } } } const m = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); if (m) { return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255]; } return [0.5, 0.5, 0.5]; } // ── Datum: the pre-downloaded loudness profile (spec §F). ──────────────────────── interface Datum { /** * GPU texture holding the loudness samples (R8). Laid out as a 2-D grid that * respects GL_MAX_TEXTURE_SIZE (see uploadDatum) rather than a 1×N row, which * blows past the max texture width for any mix over ~49 s at the ~333 samples/s * datum density. The shader reads it with texelFetch (integer addressing), so no * hardware filtering is used — see sampleAt for the manual interpolation. */ texture: WebGLTexture; /** Texture width in texels (samples per row). */ texWidth: number; /** Texture height in texels (number of rows). */ texHeight: number; /** Number of real samples in the datum (≤ texWidth*texHeight; the tail row is padded). */ sampleCount: number; /** Total mix duration in seconds — needed to map time <-> sample index. */ durationSeconds: number; /** * The decoded loudness bytes [0,255], retained for CPU-side sampling by the physics * step (the waveform-collision boundary is sampled per blob per frame — R2 §5). The * GPU has its own copy in `texture`; this is the CPU mirror, kept because re-reading * the texture back from the GPU each frame would be a stall. */ samples: Uint8Array; } interface Playback { /** * Last playback head pushed from Blazor, in seconds. This is the *authoritative* * position the player last reported — it updates only on the ~10 Hz setPlayback * push, NOT every frame. The per-frame scroll uses the interpolated * effectivePlayhead (see draw()), anchored on this value. */ positionSeconds: number; /** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */ isPlaying: boolean; /** * performance.now() (ms) captured when positionSeconds was pushed. The rAF loop * advances the playhead by wall-clock elapsed since this anchor so the ribbon * scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes, * instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on * every push, so each push is a small correction rather than a hard reset. */ pushWallClockMs: number; } export interface MixVisualizerHandle { setDatum(samplesBase64: string, durationSeconds: number): void; setPlayback(positionSeconds: number, isPlaying: boolean): void; setZoom(visibleSeconds: number): void; /** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */ setBubblyness(value: number): void; /** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */ setDetach(value: number): void; /** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */ setColorShiftSpeed(value: number): void; /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */ refreshTheme(): void; dispose(): void; } /** * Decode the base64 loudness datum (bytes [0,255]) into a Uint8Array suitable for * direct upload as an R8 texture. Done once per datum, off the animation path. We * keep the bytes as [0,255] and let the GPU normalize to [0,1] on sample (R8 * UNORM), which mirrors the predecessor's /255 and avoids a CPU float pass. */ function decodeSamples(base64: string): Uint8Array { const binary = atob(base64); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { out[i] = binary.charCodeAt(i); } return out; } // ── Shaders. ───────────────────────────────────────────────────────────────────── // // Vertex: trivial pass-through. We draw a single triangle that more than covers the // clip-space box ([-1,1]²) so every pixel of the canvas is rasterized once. (One // oversized triangle is the standard full-screen trick — cheaper than two and has // no diagonal seam.) gl_FragCoord in the fragment shader then gives us the pixel's // screen position directly. const VERTEX_SHADER = `#version 300 es // Three clip-space vertices forming one big triangle covering [-1,1]². const vec2 POSITIONS[3] = vec2[3]( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0) ); void main() { gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0); } `; // Fragment: THE SCROLL + ZOOM MATH (spec §A, §B), ported intact from the Canvas // predecessor's per-row loop into a per-fragment evaluation. Read this top to // bottom to follow how a quarter-note-@-180-BPM becomes 0.333 s becomes a texture // coordinate becomes a lit pixel. // // Coordinate model (matches the Canvas predecessor exactly): // - gl_FragCoord.xy is in device pixels, origin BOTTOM-left in WebGL. The Canvas // used a TOP-left origin with Y increasing downward. We flip Y once up front // (screenYTop) so all the time math below reads identically to the Canvas // version: screenYTop = 0 at the top edge, = uResolution.y at the bottom. // - The "now" line is a fixed screen Y: nowY = height * NOW_ANCHOR_FROM_TOP. // - Audio flows UP: newer audio is drawn lower and scrolls up past the now line. // * audio BELOW the now line (screenYTop > nowY) is the lead-in (not yet played) // * audio ABOVE the now line (screenYTop < nowY) is the trail-out (just played) // // Zoom -> time-span -> pixels: // - uVisibleSeconds is the whole window's time span, top to bottom. At max zoom // this is MIN_VISIBLE_SECONDS (0.333 s); at min zoom MAX_VISIBLE_SECONDS. // - pixelsPerSecond = height / uVisibleSeconds. Smaller visibleSeconds => more px // per second => the same audio sweeps the window faster at a fixed playback // rate. That IS the Guitar-Hero coupling: apparent scroll speed falls straight // out of the zoom, with no separate speed control. // // Time at a given screen Y: // - At nowY the time is uPlayheadSeconds. // - Moving DOWN by 1 px (screenYTop +1) adds (1 / pixelsPerSecond) seconds. // - So: timeAt(y) = playhead + (screenYTop - nowY) / pixelsPerSecond // // Sample at a given time: // - time / durationSeconds is the normalized position along the mix; multiplied by // the sample count it becomes a continuous sample index. sampleAt interpolates // between the two bracketing samples by hand (texelFetch + fract lerp) — see the // note on its definition for WHY we can't use hardware LINEAR with the 2-D layout. // - Outside [0, durationSeconds] we force loudness to 0. That is what gives the // "scrolls in from empty / out to empty" behaviour at the very start and end of // the mix (spec §A) with no special-casing. (CLAMP_TO_EDGE on the texture would // otherwise repeat the edge sample, so we gate explicitly here.) const FRAGMENT_SHADER = `#version 300 es precision highp float; uniform vec2 uResolution; // canvas size in device pixels uniform float uPlayheadSeconds; // current playback position (per-frame) uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph uniform float uVisibleSeconds; // zoom: window time-span (per change) // NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms // in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old // uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason; // the JS handle still receives those control values and routes them to the physics (the // R2 TEMP knob re-mapping documented at the control-default consts above). uniform float uDurationSeconds; // mix length (per datum) uniform vec3 uColorAccent; // MOSS pole of the field (per theme) uniform vec3 uColorEdge; // NAVY pole of the field (per theme) uniform float uHasDatum; // 1.0 when a datum texture is bound, else 0.0 uniform sampler2D uDatum; // loudness profile, R8, 2-D grid, NEAREST (texelFetch) uniform int uDatumWidth; // datum texture width in texels (samples per row) uniform int uDatumSampleCount; // number of real samples (tail row is padded) // ── R2 wax-blob uniforms (the CPU physics step uploads these every frame). ────────── // Each blob is packed as a vec4: xy = centre in HEIGHT-NORMALIZED space (pixel/H, so // y is 0 at the top edge and 1 at the footer/floor, x spans [0, W/H]); z = radius in // the SAME height-normalized unit (so circles are round on screen); w = temperature // 0..1 (drives the warm tint on hot rising wax). uBlobCount is how many of the // MAX_BLOBS slots are live this frame. Working in height-normalized units keeps the // metaball SDF isotropic regardless of the canvas aspect ratio. const int MAX_BLOBS = ${MAX_BLOBS}; uniform vec4 uBlobs[MAX_BLOBS]; uniform int uBlobCount; out vec4 fragColor; const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)}; const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)}; // ── R2 in-shader tuning constants (Daniel tunes by editing here). ─────────────────── // Background opacity of the wax + waveform fill. Kept simple/serviceable for R2 — the // beautiful OKLab three-colour gradient is Wave R3. Just enough to read the physics. const float RIBBON_OPACITY_R2 = 0.62; // smin blend radius for the wax metaball union, in height-normalized units. Larger = the // "necks" where two blobs merge are fatter → a gooier, more-connected wax that splits and // recombines (the organic non-circular look the spec wants, §4b). This + varied radii are // what kill the "giant disconnected circles" failure. const float BLOB_SMOOTHMIN_K = 0.045; // smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax // reads as continuous with the ribbon surface rather than a disc sitting on a wall. const float WAVE_SMOOTHMIN_K = 0.03; // Low-frequency, blob-tied radius wobble: a slow per-blob breathing so each wax shape is // organic, not a perfect circle (§4b). This is FLUID-tied noise (keyed to blob identity + // the wall clock), NOT the screen-space "dirt" R1 removed (§3) — it travels with the wax. const float BLOB_WOBBLE_AMOUNT = 0.12; // ± fraction of radius const float BLOB_WOBBLE_RATE = 0.7; // breathing speed (rad/s scale) // Warm tint on hot, rising wax. A hot blob (temperature → 1) shifts slightly toward a // warm highlight so the eye reads "this one is rising"; cool wax stays the cool field // colour. Serviceable placeholder until R3's real colour model — kept subtle. const vec3 HOT_TINT = vec3(0.95, 0.72, 0.45); // warm amber the hottest wax leans toward const float HOT_TINT_AMOUNT = 0.35; // max lean at temperature 1 (above ambient) // Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D // texture grid (col = i mod width, row = i / width). texelFetch ignores filtering // and wrap modes — it reads the exact texel — so the row-wrap layout is invisible // to the caller. float fetchSample(int i) { int col = i % uDatumWidth; int row = i / uDatumWidth; return texelFetch(uDatum, ivec2(col, row), 0).r; } // Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out). // // Interpolation note: we cannot lean on hardware LINEAR filtering here. The datum // is laid across a 2-D grid (1×N would exceed GL_MAX_TEXTURE_SIZE past ~49 s of // mix), and a hardware 2D-LINEAR read would blend across the row-wrap seam at the // end of every row — sample[width-1] would wrongly bleed into sample[width] of the // next row, and bilinear would also pull in the row above/below. So we do the // linear interpolation by hand along the TIME axis only: bracket the fractional // sample position with the two neighbouring texels, texelFetch each (each correctly // mapped to its own 2-D texel), and lerp. Exact, no seam artifact. // // Texel-centre convention: this reproduces the predecessor's 1-D LINEAR read bit for // bit. There, u = t/duration sampled an N-texel LINEAR texture, whose texel centres // sit at (i+0.5)/N — so u maps to texel-space position u*N - 0.5, interpolating // between floor() and floor()+1 of that, with CLAMP_TO_EDGE at the ends. We mirror // exactly that here: the -0.5 and the index clamps to [0, N-1] are the CLAMP_TO_EDGE // behaviour at both extremes. float sampleAt(float timeSeconds) { if (uHasDatum < 0.5) return 0.0; if (timeSeconds < 0.0 || timeSeconds >= uDurationSeconds) return 0.0; float n = float(uDatumSampleCount); // Continuous texel-space position, half-texel shifted to match LINEAR centres. float p = (timeSeconds / uDurationSeconds) * n - 0.5; int i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1); int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1); float f = clamp(p - floor(p), 0.0, 1.0); return mix(fetchSample(i0), fetchSample(i1), f); } // ════════════════════════════════════════════════════════════════════════════════════ // R2 — wax metaballs + waveform SDF. The blobs are integrated on the CPU (see the JS // physics step) and uploaded as uBlobs[]; the shader composites them with smin and the // waveform ribbon into one liquid surface, then shades it with a simple theme fill. // ════════════════════════════════════════════════════════════════════════════════════ // ── Value-noise (used now only for the organic, blob-tied radius wobble). ──────────── // A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and // continuous. Fed blob-identity + the wall clock it gives each wax shape its own slow // breathing so the silhouette is organic rather than a perfect circle (§4b). float hash21(vec2 p) { p = fract(p * vec2(123.34, 345.45)); p += dot(p, p + 34.345); return fract(p.x * p.y); } float valueNoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); // Smoothstep (Hermite) interpolation of the four lattice corners — C1-continuous. vec2 u = f * f * (3.0 - 2.0 * f); float a = hash21(i + vec2(0.0, 0.0)); float b = hash21(i + vec2(1.0, 0.0)); float c = hash21(i + vec2(0.0, 1.0)); float d = hash21(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // ── Signed-distance primitives + smooth-min (the metaball machinery). ─────────────── // Circle SDF (a metaball centre) — the wax blob primitive. float sdCircle(vec2 p, float r) { return length(p) - r; } // Polynomial smooth-min (Inigo Quilez). Blends two SDFs into one continuous surface — // the metaball union. k controls the blend radius (the size of the liquid "neck" where // two blobs merge). As k→0 it becomes a hard min (discrete shapes); larger k fuses them. float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } // ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ────────── // // The waveform is the same symmetric ±loudness ribbon about the centre line as before, // but evaluated in height-normalized coords (pixel/H) so it shares one space with the // wax blobs. p = (x, y) where x ∈ [0, W/H] across the canvas and y ∈ [0, 1] top→bottom. // We map the row's mix-time → loudness → a half-width about the centre x, and return the // distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked // here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon. float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) { // Mix-time at this row: rows below the now-line are future audio, above are past. float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; float amp = sampleAt(t); // loudness 0..1 at this row float centreX = aspect * 0.5; // canvas centre x in height-norm units float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; // ribbon half-width here // Distance to a centred vertical band of half-width halfW: |x − centre| − halfW. // Negative inside the band, positive outside. (A pure horizontal band; the vertical // extent is the whole column, which is what the scrolling ribbon is.) return abs(p.x - centreX) - halfW; } // ── The combined wax + waveform liquid SDF at a height-normalized point. ───────────── // // Unions all live wax blobs (smin metaballs) and the waveform ribbon into one continuous // surface. The blob radii carry a slow blob-tied wobble so each is organic, not a perfect // circle. Returns the signed distance and, via out params, the nearest-blob temperature // (for the warm hot-wax tint) and whether the point is dominated by wax vs. ribbon. float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight, out float hotOut) { // Waveform ribbon first — the always-present base surface. float field = waveformSdf(p, aspect, nowYn, secondsPerHeight); float hotAccum = 0.0; float hotWeight = 0.0; // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set. for (int i = 0; i < MAX_BLOBS; i++) { if (i >= uBlobCount) break; vec4 b = uBlobs[i]; vec2 c = b.xy; // centre, height-norm float r = b.z; // radius, height-norm float temp = b.w; // temperature 0..1 // Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so // the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok). float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5) * 2.0 * BLOB_WOBBLE_AMOUNT; float rr = r * (1.0 + wob); float blob = sdCircle(p - c, rr); field = smin(field, blob, BLOB_SMOOTHMIN_K); // Weight this blob's temperature by proximity so the tint follows the nearest wax. float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0); hotAccum += temp * prox; hotWeight += prox; } hotOut = hotWeight > 1e-3 ? hotAccum / hotWeight : 0.0; return field; } void main() { float w = uResolution.x; float h = uResolution.y; // Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note). if (uHasDatum < 0.5) { fragColor = vec4(0.0); return; } // Height-normalized fragment coordinate (pixel / H), top-left origin, y down. This is // the shared space the CPU physics works in — the blob uniforms are already in it. float aspect = w / h; // canvas width in height units vec2 p = vec2(gl_FragCoord.x / h, (h - gl_FragCoord.y) / h); float nowYn = NOW_ANCHOR_FROM_TOP; // now-line, height-norm (y ∈ [0,1]) float secondsPerHeight = uVisibleSeconds; // one full height spans uVisibleSeconds // ── Evaluate the combined liquid SDF + its gradient (the surface normal). ────────── // Central differences in height-norm space; the step is one device pixel = 1/h. float hot; float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot); float e = 1.0 / h; // one-pixel step in height-norm units float ig; float dRx = liquidSdf(p + vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig); float dLx = liquidSdf(p - vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig); float dDy = liquidSdf(p + vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig); float dUy = liquidSdf(p - vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig); vec2 grad = vec2(dRx - dLx, dDy - dUy); vec2 normal = length(grad) > 1e-5 ? normalize(grad) : vec2(0.0, -1.0); // Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an // anti-aliased edge instead of a hard chart line (no blur — spec §2/§3). float pxFeather = 1.2 / h; float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d); if (inside <= 0.0) { fragColor = vec4(0.0); return; } // ── Simple serviceable theme fill (R3 replaces with the OKLab three-colour gradient). // Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS // (uColorAccent) at the extended edge. Just enough colour to read the physics; NOT the // final colour model. No HSL, no vivify, no glass — those are gone (R3 owns colour). float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0); vec3 fill = mix(uColorEdge, uColorAccent, xnAbs); // Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate). float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT; fill = mix(fill, HOT_TINT, hotLean); // A soft top-light shade off the surface normal so the wax has form (a single lazy // gradient, not the old four-part glass). Keeps it from reading flat without competing // with the (future) colour model. float lightUp = clamp(dot(normal, vec2(0.0, -1.0)) * 0.5 + 0.5, 0.0, 1.0); fill *= mix(0.82, 1.12, lightUp); float alpha = inside * RIBBON_OPACITY_R2; fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_ALPHA } `; /** Compile one shader stage, throwing with the info log on failure. */ function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { const shader = gl.createShader(type); if (!shader) throw new Error('MixVisualizer: gl.createShader returned null.'); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const log = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(`MixVisualizer: shader compile failed: ${log}`); } return shader; } /** Link the vertex + fragment shaders into a program, throwing on failure. */ function linkProgram(gl: WebGL2RenderingContext): WebGLProgram { const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) throw new Error('MixVisualizer: gl.createProgram returned null.'); gl.attachShader(program, vert); gl.attachShader(program, frag); gl.linkProgram(program); // Shaders can be deleted after link — the program retains the compiled code. gl.deleteShader(vert); gl.deleteShader(frag); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const log = gl.getProgramInfoLog(program); gl.deleteProgram(program); throw new Error(`MixVisualizer: program link failed: ${log}`); } return program; } /** The no-op handle returned when WebGL2 is unavailable or setup fails. */ function noopHandle(): MixVisualizerHandle { return { setDatum() {}, setPlayback() {}, setZoom() {}, setBubblyness() {}, setDetach() {}, setColorShiftSpeed() {}, refreshTheme() {}, dispose() {}, }; } export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // premultipliedAlpha so the translucent ribbon composites correctly over the // page; antialias off (the soft-edge smoothstep handles AA in-shader, and MSAA // would cost fill rate we don't need for a backdrop). const maybeGl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, }); if (!maybeGl) { // No WebGL2 (old engine / disabled): hand back a no-op handle so the // component still functions as a plain backdrop (mirrors the predecessor's // no-2d-context fallback, now guarding against no-WebGL2). console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`); return noopHandle(); } // Non-null binding so the closures below keep the narrowing (TS does not carry // control-flow narrowing of a captured `const` into nested functions). const gl: WebGL2RenderingContext = maybeGl; // GL_MAX_TEXTURE_SIZE is a per-context constant — query it once. The datum is // laid out across a 2-D grid no wider than this (see uploadDatum); a 1×N row // would exceed it for any mix over ~49 s at the ~333 samples/s datum density, // and texImage2D would reject the upload (the bug this fix addresses). const maxTextureSize: number = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number; let program: WebGLProgram; try { program = linkProgram(gl); } catch (err) { // A compile/link failure on an exotic driver should degrade to the plain // backdrop, not crash the page. Log for diagnosis; return the no-op handle. console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err); return noopHandle(); } // An empty VAO is still required in WebGL2 core to issue a draw; the vertex // shader sources its positions from gl_VertexID, so no attribute buffers. const vao = gl.createVertexArray(); // Cache uniform locations once. A null here for a uniform we actually upload // means either the name is misspelled or the GLSL compiler dead-stripped it // (it isn't reachable in the shader) — both of which silently break a uniform's // effect, so surface them. No reserved-unused exemptions remain: every uniform // below is genuinely consumed by the R2 shader (the old inert Wave-3 control // uniforms are gone — the lava params drive the CPU physics, not the shader). const u = { resolution: gl.getUniformLocation(program, 'uResolution'), playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), colorAccent: gl.getUniformLocation(program, 'uColorAccent'), colorEdge: gl.getUniformLocation(program, 'uColorEdge'), hasDatum: gl.getUniformLocation(program, 'uHasDatum'), datum: gl.getUniformLocation(program, 'uDatum'), datumWidth: gl.getUniformLocation(program, 'uDatumWidth'), datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'), blobs: gl.getUniformLocation(program, 'uBlobs'), blobCount: gl.getUniformLocation(program, 'uBlobCount'), }; for (const [name, loc] of Object.entries(u)) { if (loc === null) { console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`); } } // ── Mutable state, fed by the component through the handle. ────────────────── let datum: Datum | null = null; let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() }; let visibleSeconds = DEFAULT_VISIBLE_SECONDS; // ── Lava physics control values (the R2 TEMP knob re-mapping — see the control-default // consts at the top of this file). These are the dials the existing knobs feed, routed // here by the handle setters. They drive the CPU physics step below, NOT a shader uniform. let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it) /** * The *authoritative* playhead for this instant: the last pushed position advanced * by wall-clock elapsed since the push while playing, or the pushed position while * idle. The player remains the sole source of truth — this is display-only and is * never written back (read-only contract, spec §D / §5.10). This is the target the * rendered playhead converges onto; the shader uploads the *rendered* value (see * renderedPlayhead) so a re-anchor at a push doesn't snap on screen. */ function effectivePlayhead(): number { if (!playback.isPlaying) return playback.positionSeconds; const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000; return playback.positionSeconds + elapsedSeconds; } // ── Rendered-playhead reconciliation (startup-jitter fix). ─────────────────────── // // The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset, // where correctionOffset decays exponentially toward 0 each frame (time constant // PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the // authoritative target; without correction that re-anchor would teleport the // rendered playhead. Instead we *preserve the rendered position across the push* by // folding the discontinuity into correctionOffset (see setPlayback), then bleed it // off — turning each snap into a brief, sub-perceptible glide. // // Steady-state: when pushes are regular, the authoritative target barely moves at a // push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour // is then identical to uploading effectivePlayhead() directly (the prior renderer). let correctionOffset = 0; let lastRenderWallClockMs = performance.now(); /** * The playhead the shader actually scrolls to this frame. Equals the authoritative * effectivePlayhead() plus a correction offset that decays to zero, so the rendered * motion is continuous across the irregular startup pushes. Advances the decay by * real elapsed time since the previous render, making it frame-rate-independent * (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn * frame — it mutates the decay state. */ function renderedPlayhead(): number { const nowMs = performance.now(); const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000); lastRenderWallClockMs = nowMs; // Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate // independent — the fraction retained depends only on wall-clock dt, not frame // count. Snap tiny residuals to 0 (an exponential never reaches it). if (correctionOffset !== 0) { correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS); if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0; } return effectivePlayhead() + correctionOffset; } /** * Resolve the navy↔moss field poles from the live palette vars on the canvas. * * Detects light vs dark by the page background luminance, then binds each pole to * the var that carries that identity in the active palette (see the note above the * ResolvedTheme interface for why no single var works across both modes): * LIGHT: moss = --mud-palette-secondary (#3D7A68), navy = --mud-palette-primary (#17283f) * DARK: moss = --mud-palette-primary (#3D7A68), navy = --mud-palette-background (#0D1B2A) * This yields the maximal navy↔moss spread the field wants in either theme. */ function readTheme(): ResolvedTheme { const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8')); const isDark = luminance(background) < 0.5; const moss = isDark ? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68')) : parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68')); const navy = isDark ? background // the dark ground (#0D1B2A) IS the navy pole on dark : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); const resolved: ResolvedTheme = { accent: moss, edge: navy }; // Report BOTH poles the R2 fill will use, as 0-255 RGB + relative luminance. (The // rich OKLab colour model is Wave R3; R2 just does a straight A→B theme fill — this // line confirms the navy/moss poles resolved off the canvas vars in the active mode.) const fmt = (c: [number, number, number]) => `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`; debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`); return resolved; } let theme: ResolvedTheme = readTheme(); // ════════════════════════════════════════════════════════════════════════════════ // R2 — the CPU wax-blob physics. Integrated each frame (real dt), then packed into // `blobUpload` and sent to the shader as uBlobs[]. Allocation-free per frame: the // blob pool and the upload buffer are built once here and mutated in place. // // Space: height-normalized (pixel / canvasHeight). y ∈ [0,1] top→floor, x ∈ [0, aspect] // where aspect = canvasWidth/canvasHeight. The FLOOR is y = 1 (the canvas bottom edge, // already CSS-clipped to the footer top in R1). One isotropic unit → round blobs. // ════════════════════════════════════════════════════════════════════════════════ interface Blob { x: number; y: number; // centre, height-norm vx: number; vy: number; // velocity, height-norm/s r: number; // radius, height-norm (fixed per blob, density-biased) temp: number; // temperature 0..1 } // The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS, // driven by the density dial) decides how many we simulate + upload this frame. const blobs: Blob[] = []; // The packed upload buffer (vec4 per blob). Reused every frame — no per-frame alloc. const blobUpload = new Float32Array(MAX_BLOBS * 4); /** Cheap deterministic PRNG (mulberry32) so blob spawn is varied but reproducible. */ function makeRng(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(0x1a2b3c4d); /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */ function seedBlob(b: Blob, aspect: number): void { // Density biases radius toward the small end as it rises (more, smaller blobs). const radiusBias = 1 - blobDensity * 0.6; // density 0 → big, density 1 → smaller const r = (BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN)) * radiusBias; b.r = r; b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width b.y = 1 - r - rng() * 0.1; // pooled near the floor b.vx = 0; b.vy = 0; b.temp = rng() * 0.3; // cool to start (heats at the floor) } /** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */ function initBlobs(aspect: number): void { blobs.length = 0; for (let i = 0; i < MAX_BLOBS; i++) { const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, temp: 0 }; seedBlob(b, aspect); blobs.push(b); } } let blobsInitialized = false; /** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */ function liveBlobCount(): number { return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT)); } /** * CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This * mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision * boundary matches the rendered waveform exactly. Reads the retained datum.samples. */ function sampleLoudnessAt(timeSeconds: number): number { const d = datum; if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0; const n = d.sampleCount; const p = (timeSeconds / d.durationSeconds) * n - 0.5; const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1); const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1); const f = Math.min(Math.max(p - Math.floor(p), 0), 1); const s0 = d.samples[i0] / 255; const s1 = d.samples[i1] / 255; return s0 + (s1 - s0) * f; } /** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in. * Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and * dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square * toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */ function heatScaleFromDial(dial: number): number { const d = Math.min(Math.max(dial, 0), 1); return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1 } /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive), * dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to * use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the * same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */ function restitution(soft: number, hard: number): number { const d = Math.min(Math.max(collisionStrength, 0), 1); return soft + (hard - soft) * d; } /** * Advance the physics by dt seconds. Sub-stepped for spring stability. The collision * model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon * surface normal, always on), blob↔blob (elastic, soft↔hard via the strength dial). */ function stepPhysics(dtTotal: number): void { if (canvas.height <= 0) return; const aspect = canvas.width / canvas.height; if (!blobsInitialized) { initBlobs(aspect); blobsInitialized = true; } const count = liveBlobCount(); const heatScale = heatScaleFromDial(lavaHeat); const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN); const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD); const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD); const collideHardness = Math.min(Math.max(collisionStrength, 0), 1); // Mix-time mapping at the current playhead (the waveform a blob's row sits over). const nowYn = NOW_ANCHOR_FROM_TOP; const secondsPerHeight = visibleSeconds; const centreX = aspect * 0.5; const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; const playhead = effectivePlayhead(); const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS; for (let s = 0; s < PHYSICS_SUBSTEPS; s++) { // ── Per-blob: heat exchange, buoyancy, gravity, damping, floor contact. ── for (let i = 0; i < count; i++) { const b = blobs[i]; // Heat exchange: floor heats (× heat dial), top cools, all relax to ambient. const distFromFloor = 1 - b.y; const distFromTop = b.y; if (distFromFloor < HEAT_FLOOR_ZONE) { const near = 1 - distFromFloor / HEAT_FLOOR_ZONE; // 1 at floor → 0 at zone edge b.temp += (1 - b.temp) * HEAT_FLOOR_RATE * heatScale * near * dt; } if (distFromTop < HEAT_TOP_ZONE) { const near = 1 - distFromTop / HEAT_TOP_ZONE; b.temp += (0 - b.temp) * HEAT_TOP_RATE * near * dt; } b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt; b.temp = Math.min(Math.max(b.temp, 0), 1); // Forces: gravity down (+y), buoyancy from temperature (up = −y when hot). const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT); b.vy += (gravity - buoyancy) * dt; // Viscous damping (lazy wax): frame-rate-independent exponential decay. const damp = Math.exp(-VISCOUS_DAMPING * dt); b.vx *= damp; b.vy *= damp; // Integrate position. b.x += b.vx * dt; b.y += b.vy * dt; // Floor: soft contact spring + extra damping so resting wax pools and flattens. const floorY = 1 - b.r; if (b.y > floorY) { const pen = b.y - floorY; b.vy -= FLOOR_SPRING * pen * dt; // spring pushes up out of the floor if (b.vy > 0) b.vy *= Math.exp(-FLOOR_CONTACT_DAMPING * dt); // kill the downward drive b.y = floorY + pen * 0.5; // ease the penetration out (soft, no snap) } // Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools // at the top and falls back; just keep it inside the box. const ceilY = b.r; if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; } // Side walls: reflect softly so wax stays on screen. if (b.x < b.r) { b.x = b.r; if (b.vx < 0) b.vx = -b.vx * 0.3; } if (b.x > aspect - b.r) { b.x = aspect - b.r; if (b.vx > 0) b.vx = -b.vx * 0.3; } } // ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ── // The waveform is a centred vertical band of half-width = loudness(row). A blob // whose centre is within (halfWidth + r) of the centre line penetrates it and is // pushed out along the band's surface normal (horizontal). Read-only authority: // the waveform is never moved, only the wax responds. for (let i = 0; i < count; i++) { const b = blobs[i]; const t = playhead + (b.y - nowYn) * secondsPerHeight; const amp = sampleLoudnessAt(t); if (amp <= 0) continue; const halfW = amp * maxHalf; const dx = b.x - centreX; const sideSign = dx >= 0 ? 1 : -1; // outward surface normal (in x) const penetration = halfW + b.r - Math.abs(dx); if (penetration > 0) { // Soft penalty (the soft end of the dial): a spring proportional to the // penetration depth pushes the wax out along the normal. Stronger as the // dial → soft so the soft regime still recovers, just gently. b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness * 0.5); // Hard elastic (the hard end): reflect the velocity component going INTO // the ribbon back out, scaled by restitution × hardness. inwardSpeed > 0 // means the blob is moving toward the centre line (into the surface). const inwardSpeed = -sideSign * b.vx; if (inwardSpeed > 0) { // Remove the inward component and add back a restituted outward one. b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness; } // Positional push-out: firm at the hard end (no penetration allowed), // partial at the soft end (wax squishes in then eases out via the spring). b.x += sideSign * penetration * (0.3 + 0.7 * collideHardness); } } // ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ── // O(count²) ≤ ~1k pair tests — trivial. Mass ∝ r² so big blobs shove small ones. for (let i = 0; i < count; i++) { const a = blobs[i]; for (let j = i + 1; j < count; j++) { const c = blobs[j]; let dx = c.x - a.x; let dy = c.y - a.y; let dist = Math.hypot(dx, dy); const minDist = a.r + c.r; if (dist >= minDist || dist <= 1e-6) continue; const nx = dx / dist, ny = dy / dist; // collision normal a→c const overlap = minDist - dist; const ma = a.r * a.r, mc = c.r * c.r; // mass ∝ area const invSum = 1 / (ma + mc); // Positional separation along the normal, mass-weighted (split the overlap). const sep = overlap * (0.3 + 0.7 * collideHardness); a.x -= nx * sep * (mc * invSum); a.y -= ny * sep * (mc * invSum); c.x += nx * sep * (ma * invSum); c.y += ny * sep * (ma * invSum); // Soft penalty spring along the normal (gentle shove, low strength). const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness * 0.6) * dt; a.vx -= nx * springAcc; a.vy -= ny * springAcc; c.vx += nx * springAcc; c.vy += ny * springAcc; // Elastic impulse along the normal (hard end), with restitution + mass. const rvx = c.vx - a.vx, rvy = c.vy - a.vy; const velAlongNormal = rvx * nx + rvy * ny; if (velAlongNormal < 0) { // approaching const e = collideRest * collideHardness; const impulse = -(1 + e) * velAlongNormal * invSum; a.vx -= impulse * mc * nx; a.vy -= impulse * mc * ny; c.vx += impulse * ma * nx; c.vy += impulse * ma * ny; } } } } } /** Pack the live blobs into the upload buffer. Returns the live count. */ function packBlobs(): number { const count = liveBlobCount(); for (let i = 0; i < count; i++) { const b = blobs[i]; const o = i * 4; blobUpload[o] = b.x; blobUpload[o + 1] = b.y; blobUpload[o + 2] = b.r; blobUpload[o + 3] = b.temp; } return count; } let rafId: number | null = null; let disposed = false; const startTimeMs = performance.now(); // Wall-clock anchor for the physics dt (separate from the playhead decay clock). let lastPhysicsMs = performance.now(); // FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts // actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes // the two failure modes: a rate near the display refresh (~60) with the playhead // interpolated means motion is smooth; a rate near ~10 would mean the loop is gated // to the playback pushes instead of free-running. Reset when the loop (re)starts. let fpsFrameCount = 0; let fpsWindowStartMs = 0; // One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the // first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default) // backing store here means the canvas had no layout box when the first draw ran — // the ResizeObserver will correct it, but the first paint would be degenerate. let firstRealDrawLogged = false; // Backing-store size in device pixels, tracked so we only resize the canvas // (which clears it) when the CSS box actually changed. let cssWidth = 0; let cssHeight = 0; let dpr = 1; // ── One-time GL pipeline setup. ────────────────────────────────────────────── gl.useProgram(program); gl.disable(gl.DEPTH_TEST); // Pre-multiplied alpha blend: src already carries colour*alpha (see frag shader). gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); // The datum lives in texture unit 0 for the program's lifetime. gl.uniform1i(u.datum, 0); // ── ResizeObserver: one-shot redraw when the container changes while idle. ──── // // The ResizeObserver is the SOLE size writer (spec §2e): we never call // getBoundingClientRect per frame. While playing, the rAF loop redraws every // frame anyway and picks up the new backing-store size set here. While idle, the // observer fires only on an actual size change and triggers a single redraw. const resizeObserver = new ResizeObserver((entries) => { if (disposed) return; const entry = entries[0]; // contentBoxSize is the modern, layout-thrash-free size source. Fall back to // contentRect for engines that don't populate it. const box = entry.contentBoxSize?.[0]; const nextCssWidth = box ? box.inlineSize : entry.contentRect.width; const nextCssHeight = box ? box.blockSize : entry.contentRect.height; applySize(nextCssWidth, nextCssHeight); // While idle, draw one still frame reflecting the new size. While playing, // the running loop will redraw on its next tick — no action needed. if (!playback.isPlaying) redrawOnce(); }); resizeObserver.observe(canvas); /** * Update the backing store to a CSS size × devicePixelRatio (capped at MAX_DPR) * and the GL viewport. Only resizes when something changed — resizing clears the * drawing buffer, so we avoid needless churn. This is the only place the canvas * size is written (fed by the ResizeObserver, never by a per-frame measure). */ function applySize(nextCssWidth: number, nextCssHeight: number): void { const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); if (nextCssWidth === cssWidth && nextCssHeight === cssHeight && nextDpr === dpr) { return; } cssWidth = nextCssWidth; cssHeight = nextCssHeight; dpr = nextDpr; canvas.width = Math.max(1, Math.round(cssWidth * dpr)); canvas.height = Math.max(1, Math.round(cssHeight * dpr)); gl.viewport(0, 0, canvas.width, canvas.height); } /** * Issue one GL draw with the current uniforms. The fragment shader does all the * scroll/zoom/ribbon work; here we just push the per-frame uniforms and draw the * full-screen triangle. */ function draw(): void { if (canvas.width <= 0 || canvas.height <= 0) return; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(program); gl.bindVertexArray(vao); // Per-frame uniforms. The playhead is the wall-clock-interpolated value, not // the raw last-pushed position — that is what makes the scroll advance every // animation frame instead of stepping at Blazor's ~10 Hz push cadence. gl.uniform2f(u.resolution, canvas.width, canvas.height); gl.uniform1f(u.playheadSeconds, renderedPlayhead()); gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000); // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no // separate dirty-tracking needed for scalars/vec3s). gl.uniform1f(u.visibleSeconds, visibleSeconds); gl.uniform3fv(u.colorAccent, theme.accent); gl.uniform3fv(u.colorEdge, theme.edge); // Advance the wax-blob physics by the real elapsed time, then upload the blobs. // Stepping here (rather than in the loop) means idle one-shot redraws also advance // the sim by their actual dt — clamped by PHYSICS_MAX_DT, so a long paused gap just // means the lamp barely moves while paused (it animates with playback, spec §E). const nowMs = performance.now(); const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000); lastPhysicsMs = nowMs; stepPhysics(physicsDt); const liveCount = packBlobs(); gl.uniform4fv(u.blobs, blobUpload); gl.uniform1i(u.blobCount, liveCount); if (datum) { gl.uniform1f(u.hasDatum, 1); gl.uniform1f(u.durationSeconds, datum.durationSeconds); gl.uniform1i(u.datumWidth, datum.texWidth); gl.uniform1i(u.datumSampleCount, datum.sampleCount); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, datum.texture); } else { gl.uniform1f(u.hasDatum, 0); gl.uniform1f(u.durationSeconds, 1); // Keep the divisor safe even though sampleAt early-outs on uHasDatum<0.5. gl.uniform1i(u.datumWidth, 1); gl.uniform1i(u.datumSampleCount, 1); } // One full-screen triangle (3 vertices), positions from gl_VertexID. gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null); // First draw at a real (laid-out) size: report dimensions and any accumulated // GL error. We hold this log until cssWidth/cssHeight are populated so the // dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1. // gl.getError() is a pipeline stall, so we only call it once, never per frame. if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) { firstRealDrawLogged = true; debugLog( `first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`, ); const glErr = gl.getError(); if (glErr !== gl.NO_ERROR) { console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`); } } } // ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ───────────── // // DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames // are scheduled — the GPU is idle. The still slice stays correct via one-shot // redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and // by the ResizeObserver. // // Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not // step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating // the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() // + the decaying jitter-correction offset), which advances the last pushed position by real time // elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds // monotonic clock drives the blob-radius wobble in the shader; the CPU physics uses its own // wall-clock dt — neither drives the scroll, which is the playhead alone.) /** Draw one still frame immediately, without scheduling a new rAF. */ function redrawOnce(): void { if (disposed) return; draw(); } /** Start the rAF loop. No-op if already running or disposed (rafId guard). */ function startLoop(): void { if (disposed || rafId !== null) return; // Reset the FPS window so the first measured second reflects the run we're // starting, not a stale count from a previous play session. fpsFrameCount = 0; fpsWindowStartMs = performance.now(); // Re-base the decay clock to now so the first frame's dt is one frame, not the // (possibly long) idle gap since the last redrawOnce — otherwise a stale dt // would collapse the offset in one step. (Offset is 0 at play-start today, so // this is belt-and-braces, but it keeps the decay honest if that ever changes.) lastRenderWallClockMs = performance.now(); // Re-base the physics clock too, so the first frame's dt is one frame, not the idle // gap since the last redraw (which would advance the lamp by a clamped jump on resume). lastPhysicsMs = performance.now(); rafId = requestAnimationFrame(frame); } /** Stop the rAF loop. Safe to call when already stopped. */ function stopLoop(): void { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } } /** * The animation loop. Runs only while playing. Each frame draws the scrolling * waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing * smoothly between the ~10 Hz pushes), then reschedules itself — unless playback * stopped since this frame was queued, in which case it draws one final still * frame (already done above) and exits the loop. * * A backgrounded tab gets rAF throttled by the browser automatically; on top of * that the loop does not run at all when paused, so a foregrounded-but-paused * mix burns no frames (spec §E / §5.3). */ function frame(): void { if (disposed) { rafId = null; return; } draw(); // FPS tally: count this callback, and once per elapsed second emit the rate. // performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log // fires at most once/sec, so this adds no meaningful per-frame cost. if (DEBUG) { fpsFrameCount++; const nowMs = performance.now(); const windowMs = nowMs - fpsWindowStartMs; if (windowMs >= 1000) { const fps = (fpsFrameCount * 1000) / windowMs; debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`); // Lava diagnostic: the dials in play + how many blobs are currently buoyant // (temp above ambient) and how many are pooled on the floor. Daniel watches // this to confirm heat 0 = all-resting and heat-up = rising count climbs. const live = liveBlobCount(); let buoyant = 0; let pooled = 0; let avgTemp = 0; for (let i = 0; i < live; i++) { const b = blobs[i]; avgTemp += b.temp; if (b.temp > TEMP_AMBIENT) buoyant++; if (b.y > 1 - b.r - 0.04) pooled++; } debugLog( `lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` + `collision=${collisionStrength.toFixed(2)} density=${blobDensity.toFixed(2)} | ` + `blobs=${live} buoyant=${buoyant} pooled=${pooled} avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)}.`, ); fpsFrameCount = 0; fpsWindowStartMs = nowMs; } } if (playback.isPlaying) { rafId = requestAnimationFrame(frame); } else { // Playback stopped between queue and now; final still frame drawn above. rafId = null; } } // Read the initial size synchronously (one getBoundingClientRect at setup is // fine — it is the ResizeObserver that must not measure per-frame), then draw a // still frame so the canvas isn't blank before the first play command. { const rect = canvas.getBoundingClientRect(); applySize(rect.width, rect.height); redrawOnce(); } /** * Upload the loudness samples as a 2-D R8 texture that respects * GL_MAX_TEXTURE_SIZE, returning the Datum (with the grid dimensions the shader * needs to map a sample index → texel) or null on empty/invalid input. * * Why 2-D and not 1×N: the mix datum runs at ~333 samples/s, so any mix over * ~49 s produces more samples than GL_MAX_TEXTURE_SIZE (commonly 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 — refresh the still slice immediately // when idle. If playing, the running loop picks it up next frame. if (!playback.isPlaying) redrawOnce(); }, setPlayback(positionSeconds: number, isPlaying: boolean): void { const wasPlaying = playback.isPlaying; // Preserve on-screen continuity across the re-anchor. The rendered playhead // right now is effectivePlayhead() (old anchor) + correctionOffset; capture // it before we replace the anchor. We read effectivePlayhead() without going // through renderedPlayhead() so we don't advance the decay clock here — the // decay belongs to the render loop, ticked once per drawn frame. const renderedBefore = effectivePlayhead() + correctionOffset; // Anchor the pushed position to wall-clock NOW: the rAF loop interpolates // forward from here each frame (effectivePlayhead), so the scroll advances // smoothly between these ~10 Hz pushes. playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() }; // Fold the re-anchor discontinuity into the correction offset so the rendered // playhead doesn't jump: choose offset such that effectivePlayhead() (new // anchor) + offset == renderedBefore. The render loop then decays this offset // to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the // authoritative position. When pushes are regular the gap is ~0, so offset is // ~0 and steady-state matches the prior hard-anchor behaviour exactly. // // Only smooth while continuously playing. On a play/pause edge or while idle // we want the exact authoritative position, not a glide from a stale render: // a resume should land on the real position, and a paused still frame must be // truthful (read-only contract — never show a position the player isn't at). if (isPlaying && wasPlaying) { correctionOffset = renderedBefore - effectivePlayhead(); } else { correctionOffset = 0; } if (isPlaying && !wasPlaying) { // Transition paused/stopped → playing: start the rAF loop. debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`); startLoop(); } else if (!isPlaying && wasPlaying) { // Transition playing → paused/stopped: the in-flight frame draws the // final still position and exits on its own (frame() checks // playback.isPlaying before rescheduling). We do NOT stopLoop() here — // that would cancel the in-flight frame before it draws, leaving a // stale canvas. Let the frame run out. } // isPlaying unchanged (position-only update): the running loop (if any) // redraws next frame; nothing to do here. }, setZoom(seconds: number): void { // Clamp into the supported span so a stray value can't break the math. visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds)); // While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the // loop is stopped (spec §E), so a zoom change must force one still frame here or the new // span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never. const idleRedraw = !playback.isPlaying; debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`); if (idleRedraw) redrawOnce(); }, // ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob // set). The bridge still calls these three setters by their OLD names — the names are // a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one // wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel // can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions // still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI. // setBubblyness ← "Bubblyness" knob → lava GRAVITY // setDetach ← "Detach" knob → lava HEAT // setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH // Idle redraw mirrors setZoom so a paused tweak still updates the still frame. setBubblyness(value: number): void { lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); }, setDetach(value: number): void { lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat debugLog(`setHeat (via setDetach) → ${lavaHeat.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); }, setColorShiftSpeed(value: number): void { collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness debugLog(`setCollisionStrength (via setColorShiftSpeed) → ${collisionStrength.toFixed(3)}.`); if (!playback.isPlaying) redrawOnce(); }, refreshTheme(): void { theme = readTheme(); if (!playback.isPlaying) redrawOnce(); }, dispose(): void { disposed = true; stopLoop(); resizeObserver.disconnect(); // Release all GL resources so nothing leaks on navigation (spec §5.11). if (datum) { gl.deleteTexture(datum.texture); datum = null; } if (vao) gl.deleteVertexArray(vao); gl.deleteProgram(program); }, }; }