feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2)

This commit is contained in:
daniel-c-harvey
2026-06-15 23:15:44 -04:00
parent e0f371cda6
commit bf00b7f22f
12 changed files with 332 additions and 94 deletions
@@ -44,6 +44,21 @@ 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.
@@ -231,6 +246,12 @@ 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;
@@ -316,6 +337,9 @@ uniform vec2 uResolution; // canvas size in device pixels
uniform float uPlayheadSeconds; // current playback position (per-frame)
uniform float uTimeSeconds; // monotonic clock (per-frame) — reserved for Wave 3 motion
uniform float uVisibleSeconds; // zoom: window time-span (per change)
uniform float uBubblyness; // bulge amount [0,1] (per change) — reserved for Wave 3, inert now
uniform float uDetach; // lava-lamp detach [0,1] (per change) — reserved for Wave 3, inert now
uniform float uColorShiftSpeed; // gradient-morph rate [0,1] (per change) — reserved for Wave 3, inert now
uniform float uDurationSeconds; // mix length (per datum)
uniform vec3 uColorAccent; // brightest stop, at the now line (per theme)
uniform vec3 uColorEdge; // dim stop, at the window edges (per theme)
@@ -456,6 +480,9 @@ function noopHandle(): MixVisualizerHandle {
setDatum() {},
setPlayback() {},
setZoom() {},
setBubblyness() {},
setDetach() {},
setColorShiftSpeed() {},
refreshTheme() {},
dispose() {},
};
@@ -504,14 +531,20 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// 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. `uTimeSeconds` is reserved for Wave 3 and currently
// unused by the fragment shader, so the compiler is free to strip it; we exempt
// it from the warning to avoid a false alarm.
// 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'),
@@ -521,7 +554,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'),
};
for (const [name, loc] of Object.entries(u)) {
if (loc === null && name !== 'timeSeconds') {
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).`);
}
}
@@ -530,6 +563,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
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
@@ -695,6 +733,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// 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);
@@ -977,6 +1021,25 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
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();