1368 lines
72 KiB
TypeScript
1368 lines
72 KiB
TypeScript
/**
|
||
* MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 1–3).
|
||
*
|
||
* 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. This was a wholesale renderer swap from
|
||
* the Canvas 2D predecessor (8.K) — the four effects below are all per-pixel,
|
||
* per-frame work that Canvas is worst at and a fragment shader is best at (see
|
||
* product-notes/mix-visualizer-webgl-renderer.md).
|
||
*
|
||
* Wave 3 (this revision) brings the four in-shader effects to life, all driven by
|
||
* the control uniforms Wave 2 wired (see the fragment shader's main()):
|
||
* 1. A morphing navy↔moss 2-D colour field (uColorShiftSpeed × uTimeSeconds).
|
||
* 2. Bubblyness — box→metaball SDF bulge (uBubblyness).
|
||
* 3. Detach — lava-lamp pinch-off + rising blobs (uDetach, uTimeSeconds).
|
||
* 4. Glass — specular + Fresnel + frosted + refraction, all shader math, no CSS
|
||
* backdrop-filter / no per-frame CPU blur (the original perf killer).
|
||
* The Wave 1 scroll/zoom geometry and the Wave 2 bridge contract are unchanged;
|
||
* the effects layer on top of them.
|
||
*
|
||
* The pipeline is the textbook "shadertoy-style" full-screen pass: a single quad
|
||
* covering the canvas, a trivial pass-through vertex shader, and ALL the work in
|
||
* the fragment shader. Per fragment (pixel) the shader asks "which mix-time does
|
||
* my screen Y map to, what loudness is there, am I inside the ribbon, and what
|
||
* colour am I?" — the same scroll/zoom math the Canvas walked per screen-row,
|
||
* evaluated per-pixel in parallel on the GPU instead.
|
||
*
|
||
* The Blazor component owns the canvas element and the inputs (datum, playback,
|
||
* zoom, theme); this module owns the requestAnimationFrame loop and all the
|
||
* GL/scroll/zoom math. The component drives it through the small handle returned
|
||
* by `create`. The handle shape is identical to the Canvas predecessor's, so the
|
||
* bridge (MixWaveformVisualizer.razor.cs) needs no change.
|
||
*/
|
||
|
||
// ── 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;
|
||
|
||
// ── Wave 2 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]. They are wired through to GPU uniforms now (Wave 2 plumbing) but
|
||
// the parity shader does NOT consume them visually yet — they come alive in Wave 3.
|
||
|
||
/** Default bulge amount, normalized [0,1]. Mirrors C# DefaultBubblyness. */
|
||
export const DEFAULT_BUBBLYNESS = 0.35;
|
||
|
||
/** Default lava-lamp detach amount, normalized [0,1]. Mirrors C# DefaultDetach. */
|
||
export const DEFAULT_DETACH = 0;
|
||
|
||
/** Default gradient-morph rate, normalized [0,1]. Mirrors C# DefaultColorShiftSpeed. */
|
||
export const DEFAULT_COLOR_SHIFT_SPEED = 0.3;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */
|
||
const RIBBON_OPACITY = 0.22;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* 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.
|
||
const DEBUG = false;
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
/** Bulge amount [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
|
||
setBubblyness(value: number): void;
|
||
/** Lava-lamp detach [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
|
||
setDetach(value: number): void;
|
||
/** Gradient-morph rate [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
|
||
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 + blob rise
|
||
uniform float uVisibleSeconds; // zoom: window time-span (per change)
|
||
uniform float uBubblyness; // bulge amount [0,1] (per change) — box→metaball SDF blend
|
||
uniform float uDetach; // lava-lamp detach [0,1] (per change) — pinch-off + rise
|
||
uniform float uColorShiftSpeed; // gradient-morph rate [0,1] (per change) — field cycle rate
|
||
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)
|
||
|
||
out vec4 fragColor;
|
||
|
||
const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)};
|
||
const float RIBBON_OPACITY = ${RIBBON_OPACITY.toFixed(4)};
|
||
const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)};
|
||
|
||
// ── Wave 3 tuning constants (all in-shader; Daniel tunes by editing here). ──────────
|
||
|
||
// Colour-shift speed → cycle period (seconds). The slider is normalized [0,1]; we map
|
||
// it onto a PERIOD that the field's time-axis phase cycles over. Spec §3a control 4:
|
||
// ~60 s (barely-perceptible drift) at 0 → ~4 s (briskly morphing) near 1. We never let
|
||
// the period go infinite, so even at 0 the field still drifts (spec §4b "never static").
|
||
// Exponential interpolation gives perceptually even slider feel (a log control over
|
||
// rate): period = 60 * (4/60)^speed. At speed 0 → 60 s, speed 0.3 (default) → ~22 s,
|
||
// speed 1 → 4 s. Phase rate = 2π / period.
|
||
const float COLORSHIFT_PERIOD_SLOW = 60.0; // s at slider 0 — slow drift, never frozen
|
||
const float COLORSHIFT_PERIOD_FAST = 4.0; // s at slider 1 — brisk morph
|
||
|
||
// Bubblyness: how far the metaball field spreads to neighbours at max bulge, as a
|
||
// fraction of the half-window. Larger = more liquid coalescence between bars.
|
||
const float BUBBLE_SMOOTHMIN_K = 0.18;
|
||
|
||
// Detach: how many independent rising blobs we evaluate, and how far (in window
|
||
// heights) a blob travels over its life before fading + recycling. Bounded so it reads
|
||
// as a hypnotic drift, not a particle storm (spec §4e).
|
||
const int DETACH_BLOB_COUNT = 7;
|
||
const float DETACH_RISE_SPAN = 1.25; // window-heights a blob climbs across its life
|
||
|
||
// Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic
|
||
// (spec §4f open item) — these are the dials for "maximum style".
|
||
const float GLASS_SPECULAR_POWER = 32.0; // higher = tighter hotspot
|
||
const float GLASS_FRESNEL_POWER = 3.0; // higher = thinner rim glow
|
||
const float GLASS_REFRACT_WARP = 0.06; // field-distortion amount at curved surfaces
|
||
|
||
// 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);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════════
|
||
// WAVE 3 — the four in-shader effects. Helpers first, then main() layers them in the
|
||
// spec §6 order: (1) gradient field, (2) bubblyness, (3) detach, (4) glass.
|
||
// ════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Value-noise (for the flowing colour field + organic blob jitter). ───────────────
|
||
// A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and
|
||
// continuous so the field flows rather than flickers. This is the "low-frequency base
|
||
// field" the spec §4b calls for — sampled over (time-axis, amplitude-axis) it gives the
|
||
// coherent navy↔moss morph; sampled at higher frequency it gives per-bar liveness.
|
||
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). ───────────────
|
||
// Box SDF (centred at origin, half-extents b): negative inside, positive outside.
|
||
float sdBox(vec2 p, vec2 b) {
|
||
vec2 d = abs(p) - b;
|
||
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
|
||
}
|
||
// Rounded-box SDF: a box whose corners are rounded by radius r — the "liquid" silhouette.
|
||
float sdRoundBox(vec2 p, vec2 b, float r) {
|
||
return sdBox(p, b - vec2(r)) - r;
|
||
}
|
||
// Circle SDF (a metaball centre) — used for detached rising blobs.
|
||
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 ribbon/blob signed-distance field at a screen point. ────────────────────────
|
||
//
|
||
// Returns the signed distance (in normalized half-window-width units, negative inside)
|
||
// to the liquid surface at screen pixel (px). This is the heart of effects 2+3: it
|
||
// blends a sharp box (bubblyness 0) toward swelling metaballs (bubblyness 1), and
|
||
// peels detached blobs upward (detach). main() then renders the surface and shades it.
|
||
//
|
||
// Coordinate model: x normalized so |x|=1 is the full ribbon half-width at the canvas
|
||
// edge; y is screen-row time as before. Loudness at this row sets the attached
|
||
// half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce
|
||
// vertically into a continuous liquid column rather than discrete per-row slabs.
|
||
float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, float playheadFeed, out float ampOut) {
|
||
float screenYTop = px.y;
|
||
float screenX = px.x;
|
||
|
||
// Time + loudness at this row (the geometry from Wave 1, unchanged).
|
||
float t = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
|
||
float amp = sampleAt(t);
|
||
ampOut = amp;
|
||
|
||
// Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width.
|
||
float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth;
|
||
float halfWidthN = amp; // amp ∈ [0,1] already, so the box half-extent in xn units IS amp
|
||
|
||
// --- ATTACHED SHAPE ---------------------------------------------------------------
|
||
// At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular
|
||
// bar (each row independent, sharp edges). At bubblyness 1: round the slab and let
|
||
// it swell outward from the zero-line, and smooth-min with the rows above/below so
|
||
// the column fuses into one liquid silhouette (the metaball union, §4d).
|
||
//
|
||
// Row thickness in xn space: half a screen-row's worth, so a single sampled row is
|
||
// a thin horizontal slab. Bubblyness grows the vertical reach (the swell) and the
|
||
// corner rounding.
|
||
float rowHalfY = 0.5 / max(maxHalfWidth, 1.0); // ~half a pixel in xn units
|
||
// Sample neighbour rows (±a few rows) to build a vertical metaball stack. The offset
|
||
// grows with bubblyness so bulges reach further and merge more at higher settings.
|
||
float reach = mix(rowHalfY, rowHalfY + 0.18, uBubblyness);
|
||
vec2 q = vec2(xn, 0.0);
|
||
|
||
// Box half-extents: width = loudness, height = the row reach. Rounding radius grows
|
||
// with bubblyness (sharp rect → rounded capsule). The swell-from-centre is the box
|
||
// width itself scaling with amp, so a loud row bulges wider.
|
||
// (cornerR, not "round" — that name shadows the GLSL built-in round() and reads badly.)
|
||
float cornerR = mix(0.0, halfWidthN * 0.9 + 0.02, uBubblyness);
|
||
vec2 boxB = vec2(max(halfWidthN, 0.001), reach);
|
||
float attached = sdRoundBox(q, boxB, min(cornerR, min(boxB.x, boxB.y) - 1e-3));
|
||
|
||
// Vertical coalescence: blend with neighbour rows' loudness so the column is liquid,
|
||
// not a stack of disks. We approximate by smooth-min'ing against the loudness one
|
||
// "reach" above and below in time — cheap (two extra texture taps) and gives the
|
||
// continuous-liquid read the spec wants. Only meaningful when bubbly.
|
||
if (uBubblyness > 0.001) {
|
||
float dtRow = reach * maxHalfWidth / pixelsPerSecond; // xn-reach back to seconds
|
||
float ampUp = sampleAt(t - dtRow);
|
||
float ampDn = sampleAt(t + dtRow);
|
||
vec2 boxUp = vec2(max(ampUp, 0.001), reach);
|
||
vec2 boxDn = vec2(max(ampDn, 0.001), reach);
|
||
float up = sdRoundBox(vec2(xn, reach), boxUp, min(cornerR, min(boxUp.x, boxUp.y) - 1e-3));
|
||
float dn = sdRoundBox(vec2(xn, -reach), boxDn, min(cornerR, min(boxDn.x, boxDn.y) - 1e-3));
|
||
float k = BUBBLE_SMOOTHMIN_K * uBubblyness;
|
||
attached = smin(attached, smin(up, dn, k), k);
|
||
}
|
||
|
||
// --- DETACH: pinch off rising blobs (§4e) -----------------------------------------
|
||
// Building on the attached field: as detach rises, a set of bounded metaballs lift
|
||
// off and climb on the uTimeSeconds clock, fading near the top. We weaken the link
|
||
// to the parent by reducing the smooth-min k as detach→1 (the liquid "neck" thins
|
||
// and breaks), and add the free blobs as independent metaball centres.
|
||
float field = attached;
|
||
if (uDetach > 0.001) {
|
||
// Each blob has a stable per-index identity (its column, size, phase) so the set
|
||
// is a calm, repeating drift rather than a random storm. We loop a fixed small
|
||
// count (DETACH_BLOB_COUNT) — bounded cost, bounded visuals.
|
||
for (int i = 0; i < DETACH_BLOB_COUNT; i++) {
|
||
float fi = float(i);
|
||
// Per-blob constants from a hash so blobs differ but are deterministic.
|
||
float seed = hash21(vec2(fi, 7.0));
|
||
// Spawn column: spread across the ribbon width, biased by loudness presence.
|
||
float colX = (seed * 2.0 - 1.0) * 0.8;
|
||
// Loudness feeding this blob's column at the now line — a louder mix sheds
|
||
// bigger blobs. playheadFeed is pre-computed once in main() (fragment-invariant).
|
||
float feed = playheadFeed;
|
||
float radius = (0.05 + 0.10 * seed) * (0.4 + 0.6 * feed) * uDetach;
|
||
// Rise phase: 0→1 over the blob's life, looping. Different phase offset per
|
||
// blob so they don't pulse in unison. Speed scales mildly with detach.
|
||
float life = fract(uTimeSeconds * (0.06 + 0.05 * seed) + seed);
|
||
// Vertical position: starts near the zero-line, climbs DETACH_RISE_SPAN
|
||
// window-heights upward (screen-up = decreasing screenYTop). Slight sinus
|
||
// horizontal drift for the lava-lamp wobble.
|
||
float riseN = life * DETACH_RISE_SPAN; // in window-heights
|
||
float blobYTop = nowY - riseN * uResolution.y;
|
||
float driftX = colX + 0.06 * sin(uTimeSeconds * 0.7 + seed * 6.28);
|
||
// Blob centre offset into our (xn, yTop) eval frame. driftX is already in xn
|
||
// units (it's a fraction of the ribbon half-width), so it subtracts directly.
|
||
vec2 pBlob = vec2(xn - driftX,
|
||
(screenYTop - blobYTop) / maxHalfWidth);
|
||
float blob = sdCircle(pBlob, radius);
|
||
// Fade the blob in at birth and out near the top (life→1): scale its radius
|
||
// envelope so it grows, holds, then shrinks away — no hard pop.
|
||
float envelope = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.78, 1.0, life));
|
||
blob += (1.0 - envelope) * 0.2; // shrink/erase by pushing the SDF positive
|
||
// Link strength: blobs near the bar still smooth-min into it (the neck);
|
||
// higher detach thins the neck. Free-risen blobs (high life) merge with the
|
||
// overall field weakly so they read as separate.
|
||
float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - uDetach) * (1.0 - life);
|
||
field = smin(field, blob, max(neckK, 0.004));
|
||
}
|
||
}
|
||
|
||
return field;
|
||
}
|
||
|
||
void main() {
|
||
float w = uResolution.x;
|
||
float h = uResolution.y;
|
||
|
||
// Flip to a top-left, downward-Y frame so the time math matches the Canvas port.
|
||
float screenYTop = h - gl_FragCoord.y;
|
||
float screenX = gl_FragCoord.x;
|
||
|
||
float nowY = h * NOW_ANCHOR_FROM_TOP;
|
||
float pixelsPerSecond = h / uVisibleSeconds;
|
||
float maxHalfWidth = (w * 0.5) * RIBBON_HALF_WIDTH_FRAC;
|
||
|
||
// Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note).
|
||
if (uHasDatum < 0.5) {
|
||
fragColor = vec4(0.0);
|
||
return;
|
||
}
|
||
|
||
// Hoist the playhead-feed tap: sampleAt(uPlayheadSeconds) is fragment-invariant
|
||
// (depends only on a uniform) and would otherwise run inside the 7-iteration detach
|
||
// blob loop × 5 ribbonField calls = up to 35× per pixel. Compute it once here.
|
||
float playheadFeed = sampleAt(uPlayheadSeconds);
|
||
|
||
// ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ──
|
||
// The gradient of the SDF is the outward surface normal — we need it for the glass
|
||
// (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the
|
||
// step is one device-pixel mapped into the field's xn/yTop frame.
|
||
vec2 px = vec2(screenX, screenYTop);
|
||
float amp;
|
||
float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, amp);
|
||
|
||
float e = 1.0; // 1px central-difference step
|
||
float ignore;
|
||
float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||
float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||
float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||
float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||
// Surface normal in screen space (points OUT of the liquid). y flipped because our
|
||
// field y is screen-down. Guard the zero-length case at flat interiors.
|
||
vec2 grad = vec2(dRx - dLx, dUy - dDy);
|
||
vec2 normal = length(grad) > 1e-4 ? normalize(grad) : vec2(0.0, -1.0);
|
||
|
||
// Inside-ness: SDF negative = inside. Feather the boundary (~1px in field units) for
|
||
// an anti-aliased, glowy lit edge instead of a hard chart line (spec §5.2, no blur).
|
||
float pxFeather = 1.5 / maxHalfWidth; // 1.5px expressed in xn units
|
||
float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d);
|
||
|
||
// ── EFFECT 1: the morphing navy↔moss 2-D colour field (§4b). ───────────────────────
|
||
// Two composed layers:
|
||
// (a) Low-frequency BASE FIELD over (time-axis, amplitude-axis), flowing on the
|
||
// uTimeSeconds clock at a rate set by uColorShiftSpeed → coherent across the
|
||
// window, never static.
|
||
// (b) Higher-frequency PER-BAR modulation keyed off the row's own loudness/phase →
|
||
// adjacent bars related but distinct, louder = brighter/greener (its own life).
|
||
//
|
||
// Colour-shift cycle: exponential map normalized speed → period (see consts), so the
|
||
// slider's slow end barely drifts and its fast end morphs briskly, never frozen.
|
||
float period = COLORSHIFT_PERIOD_SLOW * pow(COLORSHIFT_PERIOD_FAST / COLORSHIFT_PERIOD_SLOW, uColorShiftSpeed);
|
||
float phase = uTimeSeconds * (6.28318 / period);
|
||
|
||
// Time-axis coordinate of this fragment (where along the mix it sits) drives the
|
||
// field's along-scroll dimension; amplitude (|xn|) drives the along-bar dimension.
|
||
float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
|
||
float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0);
|
||
|
||
// (a) Base field: layered value-noise flowing in time → smooth navy↔moss blend in
|
||
// [0,1]. Two octaves give organic, non-repeating morph without being busy.
|
||
float base = valueNoise(vec2(tHere * 0.15, phase));
|
||
base += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0));
|
||
base = clamp(base / 1.5, 0.0, 1.0);
|
||
|
||
// (b) Along-bar: blend more toward MOSS at the peak, NAVY near the zero-line — gives
|
||
// each bar internal structure (spec §4b axis 1). Per-bar liveness: perturb by a
|
||
// noise keyed to this bar's time so neighbours differ, and lift saturation with
|
||
// loudness so a loud bar reads more vivid than a quiet one.
|
||
float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter
|
||
float fieldMix = clamp(base * 0.55 + xnAbs * 0.45 + perBar * 0.20, 0.0, 1.0);
|
||
|
||
// accent = MOSS, edge = NAVY. Peak/lively → moss; zero-line/calm → navy.
|
||
vec3 baseColor = mix(uColorEdge, uColorAccent, fieldMix);
|
||
// Loudness vivifies: louder bars push toward moss + brighten slightly ("own thing").
|
||
baseColor = mix(baseColor, uColorAccent, amp * 0.25);
|
||
|
||
// ── EFFECT 4: glass (§4f) — specular + Fresnel + frosted + refraction, all in-shader.
|
||
// Fixed virtual light from the upper-left; view direction is straight at the screen.
|
||
vec3 N = vec3(normal, 0.6); // lift normal off the plane so it has a z-face
|
||
N = normalize(N);
|
||
vec3 L = normalize(vec3(-0.5, 0.7, 0.8)); // light: upper-left, toward viewer
|
||
vec3 V = vec3(0.0, 0.0, 1.0); // view: straight on
|
||
vec3 Hh = normalize(L + V); // Blinn-Phong half-vector
|
||
|
||
// (1) Refraction read: where the surface curves (near the edge), warp the field
|
||
// sample coords along the normal so the colour appears bent through the glass.
|
||
// Strongest at the rim, vanishing in the flat interior (uses |grad| as curvature).
|
||
float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0);
|
||
vec2 warp = normal * GLASS_REFRACT_WARP * curvature;
|
||
float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0);
|
||
vec3 glassColor = mix(uColorEdge, uColorAccent, warpMix);
|
||
glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read
|
||
|
||
// (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the
|
||
// half-vector aligns with the normal; drifts as blobs move (normal changes).
|
||
float spec = pow(max(dot(N, Hh), 0.0), GLASS_SPECULAR_POWER);
|
||
// (3) Broad sheen along the upper edge — a soft secondary gloss band.
|
||
float sheen = pow(max(dot(N, L), 0.0), 2.0) * 0.25;
|
||
// (4) Fresnel rim glow — brightest at grazing angles (silhouette edges). The single
|
||
// most effective "this is glass" cue (spec §4f.4).
|
||
float fresnel = pow(1.0 - max(dot(N, V), 0.0), GLASS_FRESNEL_POWER);
|
||
|
||
// Frosted translucency: a subtle noise modulation of alpha so edges read soft/lit
|
||
// rather than hard — the "frosted glass" cue done in-shader (no CSS blur, §4f.3).
|
||
float frost = 0.85 + 0.15 * valueNoise(vec2(screenX * 0.05, screenYTop * 0.05));
|
||
|
||
// Compose the lit glass colour: field base + warped refraction, lifted by sheen and
|
||
// a Fresnel rim toward the moss accent, plus a white-hot specular dot.
|
||
vec3 lit = glassColor;
|
||
lit += sheen * uColorAccent;
|
||
lit = mix(lit, uColorAccent * 1.3, fresnel * 0.6); // rim glows mossy
|
||
lit += spec * vec3(1.0); // specular is white light
|
||
|
||
// Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light, and
|
||
// softened by the frost. Pre-multiplied output for the ONE/ONE_MINUS_SRC_ALPHA blend.
|
||
float alpha = inside * RIBBON_OPACITY * frost;
|
||
alpha = clamp(alpha + inside * fresnel * RIBBON_OPACITY * 0.8, 0.0, 1.0);
|
||
|
||
fragColor = vec4(lit * alpha, 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. The Wave-3-reserved uniforms (`uTimeSeconds`,
|
||
// `uBubblyness`, `uDetach`, `uColorShiftSpeed`) are declared and uploaded but not
|
||
// yet consumed by the parity shader, so the compiler is free to dead-strip them;
|
||
// we exempt them from the warning to avoid a false alarm. Their values still reach
|
||
// the GPU when a location survives (verifiable in Wave 3).
|
||
const RESERVED_UNUSED = new Set(['timeSeconds', 'bubblyness', 'detach', 'colorShiftSpeed']);
|
||
const u = {
|
||
resolution: gl.getUniformLocation(program, 'uResolution'),
|
||
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
|
||
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
|
||
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
|
||
bubblyness: gl.getUniformLocation(program, 'uBubblyness'),
|
||
detach: gl.getUniformLocation(program, 'uDetach'),
|
||
colorShiftSpeed: gl.getUniformLocation(program, 'uColorShiftSpeed'),
|
||
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'),
|
||
};
|
||
for (const [name, loc] of Object.entries(u)) {
|
||
if (loc === null && !RESERVED_UNUSED.has(name)) {
|
||
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;
|
||
// Wave 2 control values, fed through the handle. Uploaded as uniforms in draw() but inert in the
|
||
// parity shader (Wave 3 consumes them). Seeded to the defaults that mirror MixVisualizerControlState.
|
||
let bubblyness = DEFAULT_BUBBLYNESS;
|
||
let detach = DEFAULT_DETACH;
|
||
let colorShiftSpeed = DEFAULT_COLOR_SHIFT_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 };
|
||
debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — moss=[${moss.map((c) => c.toFixed(2)).join(', ')}], navy=[${navy.map((c) => c.toFixed(2)).join(', ')}].`);
|
||
return resolved;
|
||
}
|
||
|
||
let theme: ResolvedTheme = readTheme();
|
||
|
||
let rafId: number | null = null;
|
||
let disposed = false;
|
||
const startTimeMs = 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);
|
||
// Wave 2 control uniforms. Uploaded every frame (cheap scalars); inert in the parity shader.
|
||
// gl.uniform1f with a null location (dead-stripped uniform) is a documented silent no-op, so
|
||
// these are safe to set unconditionally even before the Wave 3 shader references them.
|
||
gl.uniform1f(u.bubblyness, bubblyness);
|
||
gl.uniform1f(u.detach, detach);
|
||
gl.uniform1f(u.colorShiftSpeed, colorShiftSpeed);
|
||
gl.uniform3fv(u.colorAccent, theme.accent);
|
||
gl.uniform3fv(u.colorEdge, theme.edge);
|
||
|
||
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 is reserved for Wave 3's field/blob motion and is unused by this parity shader;
|
||
// it is NOT what drives the scroll here.)
|
||
|
||
/** 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();
|
||
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.`);
|
||
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 };
|
||
}
|
||
|
||
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();
|
||
},
|
||
|
||
// The three Wave 2 controls. Each clamps to [0,1], stores the value (uploaded as a uniform in
|
||
// draw()), and forces one still frame while idle — mirroring setZoom — so the new value reaches
|
||
// the GPU even when paused. INERT in Wave 2: the parity shader does not read these uniforms, so
|
||
// a change does not visibly alter the render; the value is verifiable in Wave 3.
|
||
setBubblyness(value: number): void {
|
||
bubblyness = Math.min(1, Math.max(0, value));
|
||
if (!playback.isPlaying) redrawOnce();
|
||
},
|
||
|
||
setDetach(value: number): void {
|
||
detach = Math.min(1, Math.max(0, value));
|
||
if (!playback.isPlaying) redrawOnce();
|
||
},
|
||
|
||
setColorShiftSpeed(value: number): void {
|
||
colorShiftSpeed = Math.min(1, Math.max(0, value));
|
||
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);
|
||
},
|
||
};
|
||
}
|