diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index a6f743f..68cc326 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -23,16 +23,19 @@ * 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). + * Colour (Wave R3) is the OKLab THREE-COLOUR gradient: A→B linear from the centre line + * outward, A and B rotating among three theme anchors (navy/moss/off-white) at the + * gradient-rotation dial's rate, with a per-segment mix-time sinusoid (colour "waves" + * baked per segment) and a per-bar curve shift (A-dominant low → B-dominant high). OKLab + * keeps the blend faithful — no HSL cyan excursion. No glass, no screen-space noise (R1). * * The Blazor component owns the canvas element and the inputs (datum, playback, * scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop, * the physics step, and all the GL math. The component drives it through the handle * returned by `create`. As of Wave R4 the handle exposes SEVEN dedicated control setters * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity / - * setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. Gradient rotation is - * stored but inert until Wave R3 builds the OKLab gradient. + * setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. As of Wave R3 the + * gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation. * * PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and * the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio @@ -64,8 +67,8 @@ export const DEFAULT_VISIBLE_SECONDS = 10; // Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the // R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping: // • Scroll speed → visible time-span / scroll rate (setScrollSpeed) -// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — INERT -// until Wave R3 builds the OKLab gradient that consumes it +// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE +// as of Wave R3; drives the OKLab gradient's anchor rotation // • Lava gravity → gravity dial (setLavaGravity) // • Lava heat → heat dial (setLavaHeat) // • Blob density/size → density dial (setBlobDensity) @@ -91,12 +94,20 @@ export const DEFAULT_BLOB_DENSITY = 0.4; /** * Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized - * [0,1] → slow→fast anchor rotation. INERT until Wave R3 builds the OKLab three-colour gradient - * that consumes it — stored and round-tripped through the handle so the knob persists, but it - * drives nothing this wave (the R2 flat placeholder fill ignores it). + * [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at + * which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z). */ export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3; +/** + * Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor + * step X→Y, so 3 ring-units is a full X→Y→Z→X cycle). 0.18 → a full three-colour cycle in + * ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end. + * Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead. + */ +const GRADIENT_ROTATION_RATE_MAX = 0.18; +const GRADIENT_ROTATION_RATE_MIN = 0.01; + /** * Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower * values narrow the waveform band so the lava fluid gets more room to move on loud songs. @@ -340,7 +351,7 @@ function debugLog(...args: unknown[]): void { if (DEBUG) console.log(TAG, ...args); } -// ── Theme: the navy↔moss field poles, read live from the active MudBlazor palette. ─ +// ── Theme: the THREE colour anchors (X, Y, Z), read live from the active palette. ── // // The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain // floats. So we read the computed `--mud-palette-*` custom properties straight off @@ -349,27 +360,32 @@ function debugLog(...args: unknown[]): void { // 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.) +// Wave R3 binding — the THREE anchors of the OKLab gradient (spec §6a/§6b motion 1). +// The gradient's two stops (A = centre/root, B = outer/edge) ROTATE among these three +// over time. The palette's signature triad is navy / moss / off-white — the identity +// `DeepDrftPalettes` is built on (see the class doc comment there). All three are read +// from the live palette vars (single source of truth — spec §6a): no hardcoded hexes. +// - X = NAVY (the dark ground / navy-mid) +// - Y = MOSS (the interactive green) +// - Z = OFF-WHITE (the warm paper ground) — the chosen third anchor (surfaced in handoff) // -// 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. +// The cross-mode problem (spec §6a, explicit): navy / moss / off-white are NOT a single +// stable set 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); off-white is the +// `--mud-palette-background` ground in LIGHT but `--mud-palette-secondary` in DARK. No one +// var holds a given identity 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 three anchors per mode. 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]; + /** Navy anchor (X) RGB [0,1] — uploaded to uColorNavy. */ + navy: [number, number, number]; + /** Moss-green anchor (Y) RGB [0,1] — uploaded to uColorMoss. */ + moss: [number, number, number]; + /** Off-white anchor (Z) RGB [0,1] — uploaded to uColorPaper. */ + paper: [number, number, number]; } /** sRGB relative luminance (cheap Rec.709 weights) of a normalized RGB triple. */ @@ -475,7 +491,7 @@ export interface MixVisualizerHandle { setPlayback(positionSeconds: number, isPlaying: boolean): void; /** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */ setScrollSpeed(visibleSeconds: number): void; - /** [0,1]. Colour anchor-rotation rate. INERT until Wave R3 (stored + round-tripped only). */ + /** [0,1]. Colour anchor-rotation rate — drives the OKLab gradient's Motion 1 (live, R3). */ setGradientRotationSpeed(value: number): void; /** [0,1]. Downward force on the wax. */ setLavaGravity(value: number): void; @@ -579,8 +595,14 @@ uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narr // 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) +// R3 — the three OKLab gradient anchors (X/Y/Z), read live from the palette (per theme). +uniform vec3 uColorNavy; // X — navy anchor +uniform vec3 uColorMoss; // Y — moss anchor +uniform vec3 uColorPaper; // Z — off-white anchor +// R3 — gradient anchor-rotation PHASE (radians), integrated CPU-side from the same +// uTimeSeconds clock at the rotation-speed dial's rate (so a speed change never snaps the +// phase). Drives Motion 1: which two of the three anchors A and B are right now (spec §6b). +uniform float uGradientPhase; 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) @@ -629,9 +651,31 @@ 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. +// colour. Subordinate to the R3 gradient (spec §4f) — 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) +const float HOT_TINT_AMOUNT = 0.25; // max lean at temperature 1 (above ambient) + +// ── R3 colour-gradient tuning (the three motions, spec §6b). Daniel tunes by editing here. ── + +// Motion 1 — the phase OFFSET between anchor A (centre/root) and anchor B (outer/edge) as +// they rotate among the three anchors. A non-zero offset means A and B sit at different +// points on the X→Y→Z ring, so the gradient always spans two distinct colours rather than +// collapsing to one. 1.0 = a full one-anchor lead (e.g. A on navy while B is on moss). +const float GRADIENT_AB_PHASE_OFFSET = 1.0; + +// Motion 2 — per-bar sinusoidal variation, KEYED TO MIX-TIME (spec §6b motion 2, decided +// realization). Because mix-time is fixed for a given segment, the sinusoid is a pure +// function of mix-time and therefore baked-per-segment by construction: it travels with the +// segment as it scrolls, no ring buffer. AMOUNT is the ± phase wobble it adds to the anchor +// rotation (in ring units); FREQ is how many colour "waves" pack into one second of mix. +const float SEG_WAVE_AMOUNT = 0.35; // ± ring-phase wobble per segment +const float SEG_WAVE_FREQ = 1.7; // colour waves per second of mix-time + +// Motion 3 — per-bar gradient CURVE shift with scroll height (spec §6b motion 3). A bar is +// mostly A at the bottom and mostly B by the top: we bias the centre→outer A→B mix toward A +// low on screen and toward B high on screen, so colour appears to climb outward as the bar +// scrolls up. This is the max ± shift applied to the A→B mix fraction across the screen. +const float CURVE_SHIFT_AMOUNT = 0.45; // 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 @@ -712,6 +756,77 @@ float smin(float a, float b, float k) { return mix(b, a, h) - k * h * (1.0 - h); } +// ── OKLab colour interpolation (spec §6c — replaces the rejected HSL mixHsl/vivify). ─ +// +// OKLab is a perceptually-uniform space (Björn Ottosson). A straight line between two +// colours in OKLab stays perceptually faithful — no hue drift, no saturation pumping, no +// rainbow excursion. That is the structural fix for the navy→moss cyan bug: HSL hue-lerp +// between blue and green passes through cyan; OKLab does not. We convert each anchor +// linear-sRGB → OKLab, mix() in OKLab, convert back — all per-fragment (cheap: a cube +// root and two 3×3 matmuls each way). +// +// The uColor* uniforms arrive as GAMMA sRGB [0,1] (parsed straight from the CSS hex), so +// we linearise on the way in and re-encode gamma on the way out. +float srgbToLinear(float c) { + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); +} +float linearToSrgb(float c) { + return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1.0 / 2.4) - 0.055; +} +vec3 srgbToLinear3(vec3 c) { + return vec3(srgbToLinear(c.r), srgbToLinear(c.g), srgbToLinear(c.b)); +} +vec3 linearToSrgb3(vec3 c) { + return vec3(linearToSrgb(c.r), linearToSrgb(c.g), linearToSrgb(c.b)); +} +// linear-sRGB → OKLab (Ottosson's standard matrices). +vec3 linearToOklab(vec3 c) { + float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b; + float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b; + float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b; + float l_ = pow(l, 1.0 / 3.0); + float m_ = pow(m, 1.0 / 3.0); + float s_ = pow(s, 1.0 / 3.0); + return vec3( + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + ); +} +// OKLab → linear-sRGB (the inverse). +vec3 oklabToLinear(vec3 lab) { + float l_ = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z; + float m_ = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z; + float s_ = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z; + float l = l_ * l_ * l_; + float m = m_ * m_ * m_; + float s = s_ * s_ * s_; + return vec3( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + ); +} +// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → lerp → back to gamma sRGB. +vec3 mixOklab(vec3 a, vec3 b, float t) { + vec3 la = linearToOklab(srgbToLinear3(a)); + vec3 lb = linearToOklab(srgbToLinear3(b)); + vec3 m = mix(la, lb, t); + return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0); +} +// One of the three anchors as a continuous function of a phase in [0,3): a triangular +// blend around the ring X→Y→Z→X so the picked anchor travels smoothly through all three +// (OKLab-interpolated, so the transitions stay faithful). phase need not be wrapped — +// we fract it to the ring here. +vec3 anchorAtPhase(float phase) { + float p = fract(phase / 3.0) * 3.0; // [0,3) + float seg = floor(p); // 0,1,2 + float f = p - seg; // [0,1) within the segment + if (seg < 0.5) return mixOklab(uColorNavy, uColorMoss, f); + else if (seg < 1.5) return mixOklab(uColorMoss, uColorPaper, f); + else return mixOklab(uColorPaper, uColorNavy, f); +} + // ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ────────── // // The waveform is the same symmetric ±loudness ribbon about the centre line as before, @@ -812,17 +927,46 @@ void main() { float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d); if (inside <= 0.0) { fragColor = vec4(0.0); return; } - // ── Simple serviceable FLAT theme fill (R3 replaces with the OKLab three-colour gradient). - // Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS - // (uColorAccent) at the extended edge. This horizontal ramp is a gentle field gradient - // across the whole canvas, NOT a per-blob radial — so the fluid surface reads flat. Just - // enough colour to read the physics; NOT the final colour model. No glass, no per-blob - // shading (R3 owns colour). - float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0); - vec3 fill = mix(uColorEdge, uColorAccent, xnAbs); + // ── R3 — the three-colour OKLab gradient (the three combined motions, spec §6b). ── + // + // The static structure is a LINEAR A→B from the centre line outward (A at the root, B at + // the extended edge), with A and B drawn from the rotating three-anchor ring. On top of + // that, three motions combine — all OKLab-interpolated, so no rainbow/cyan excursion. - // Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate). - // A flat per-blob temperature lean — no spatial falloff, so it does not reintroduce a cone. + // Centre-outward fraction [0,1] for this fragment: 0 at the centre line (root), 1 at the + // canvas edge. This is the axis the A→B linear runs along (spec §6b: "from the 0 centre + // line outward along the waveform"). + float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0); + + // Motion 2 — per-bar sinusoid keyed to MIX-TIME (spec §6b motion 2). The mix-time at this + // fragment's row is identical to the waveform's row-time, so the sinusoid is fixed for a + // given segment and travels up with it as it scrolls — baked-per-segment by construction, + // no ring buffer. It nudges the anchor-ring phase, so neighbouring segments sit on slightly + // different colours: "waves" of colour across the waveform rather than one uniform gradient. + float segTime = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; + float segWave = sin(segTime * SEG_WAVE_FREQ * 6.2831853) * SEG_WAVE_AMOUNT; + + // Motion 1 — anchor rotation among X/Y/Z (spec §6b motion 1). uGradientPhase advances at + // the gradient-rotation-speed dial's rate (CPU-integrated from uTimeSeconds). A leads B by + // a fixed ring offset so the gradient always spans two distinct anchors. The per-segment + // wave (Motion 2) is folded into the phase so each segment is offset on the ring. + float phaseA = uGradientPhase + segWave; + float phaseB = uGradientPhase + GRADIENT_AB_PHASE_OFFSET + segWave; + vec3 colorA = anchorAtPhase(phaseA); // centre/root colour + vec3 colorB = anchorAtPhase(phaseB); // outer/edge colour + + // Motion 3 — per-bar curve shift with scroll height (spec §6b motion 3). p.y is 0 at the + // top and 1 at the floor; (0.5 - p.y) is +0.5 at the top, −0.5 at the floor. We shift the + // A→B mix fraction toward A (negative) low on screen and toward B (positive) high on + // screen, so a bar is mostly A at the bottom and mostly B by the top — colour climbs + // outward as the bar scrolls up. + float curveShift = (0.5 - p.y) * 2.0 * CURVE_SHIFT_AMOUNT; + float mixFrac = clamp(xnAbs + curveShift, 0.0, 1.0); + + vec3 fill = mixOklab(colorA, colorB, mixFrac); + + // Warm tint on hot, rising wax so the eye reads convection (subordinate to the gradient, + // spec §4f). A flat per-blob temperature lean — no spatial falloff, so no cone is reintroduced. float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT; fill = mix(fill, HOT_TINT, hotLean); @@ -935,8 +1079,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), - colorAccent: gl.getUniformLocation(program, 'uColorAccent'), - colorEdge: gl.getUniformLocation(program, 'uColorEdge'), + colorNavy: gl.getUniformLocation(program, 'uColorNavy'), + colorMoss: gl.getUniformLocation(program, 'uColorMoss'), + colorPaper: gl.getUniformLocation(program, 'uColorPaper'), + gradientPhase: gl.getUniformLocation(program, 'uGradientPhase'), hasDatum: gl.getUniformLocation(program, 'uHasDatum'), datum: gl.getUniformLocation(program, 'uDatum'), datumWidth: gl.getUniformLocation(program, 'uDatumWidth'), @@ -958,15 +1104,23 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default // consts at the top of this file). These are the dials the seven knobs feed, routed here by the // handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader - // uniform; gradientRotationSpeed is stored but INERT until Wave R3 builds the colour gradient. + // uniform; gradientRotationSpeed drives the OKLab gradient's anchor rotation (live as of R3). let lavaHeat = DEFAULT_LAVA_HEAT; let lavaGravity = DEFAULT_LAVA_GRAVITY; let collisionStrength = DEFAULT_COLLISION_STRENGTH; let blobDensity = DEFAULT_BLOB_DENSITY; let waveformWidth = DEFAULT_WAVEFORM_WIDTH; - // INERT until Wave R3 — held so the knob round-trips and persists; nothing reads it this wave. + // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1). let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED; + // ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the + // shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by + // rate·dt, where dt is the delta of (performance.now()−startTimeMs)/1000 (== uTimeSeconds). + // Integrating rate·dt (rather than computing phase = t·rate in the shader) keeps the phase + // CONTINUOUS when the dial changes — a rate change alters the slope, never snaps the value. + let gradientPhase = 0; + let lastGradientClockSeconds = 0; + /** * 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 @@ -1022,33 +1176,39 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } /** - * Resolve the navy↔moss field poles from the live palette vars on the canvas. + * Resolve the three colour anchors (navy / moss / off-white) from the live palette + * vars on the canvas — the single source of truth (spec §6a). * - * Detects light vs dark by the page background luminance, then binds each pole to + * Detects light vs dark by the page background luminance, then binds each anchor 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. + * LIGHT: navy = --mud-palette-primary (#17283f), moss = --mud-palette-secondary (#3D7A68), + * off-white = --mud-palette-background (#FAFAF8) + * DARK: navy = --mud-palette-background (#0D1B2A), moss = --mud-palette-primary (#3D7A68), + * off-white = --mud-palette-secondary (#FAFAF8) + * This yields the navy / moss / off-white triad the gradient rotates among in either theme. */ function readTheme(): ResolvedTheme { const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8')); const isDark = luminance(background) < 0.5; + const navy = isDark + ? background // the dark ground (#0D1B2A) IS the navy anchor on dark + : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); 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 paper = isDark + ? parseColor(readVar(canvas, '--mud-palette-secondary', '#FAFAF8')) + : background; // the light ground (#FAFAF8) IS the off-white anchor on light - 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 resolved: ResolvedTheme = { navy, moss, paper }; + // Report all THREE anchors the OKLab gradient rotates among, as 0-255 RGB + relative + // luminance — confirms the navy / moss / off-white triad resolved off the canvas vars + // in the active mode (no hardcoded hexes; a dark-mode toggle re-themes live). 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)}.`); + debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — NAVY(X)=${fmt(navy)} MOSS(Y)=${fmt(moss)} PAPER(Z)=${fmt(paper)}.`); return resolved; } @@ -1067,7 +1227,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { interface Blob { x: number; y: number; // centre, height-norm vx: number; vy: number; // velocity, height-norm/s - r: number; // BASE radius, height-norm (fixed per blob, density-biased) + r0: number; // UNBIASED base radius, height-norm (fixed per blob — the blob's + // identity size; the density dial scales it LIVE into r each frame) + r: number; // DENSITY-biased base radius this step = r0 × density bias (Daniel + // #1: density's "size" half is live — recomputed each frame, not + // baked at seed, so turning the dial visibly resizes live wax) er: number; // EFFECTIVE radius this step = r shrunk by heat (Daniel #7); used by // collisions AND uploaded to the shader so the two always agree temp: number; // temperature 0..1 @@ -1093,11 +1257,20 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } const rng = makeRng(0x1a2b3c4d); + /** The density dial's effect on blob SIZE (Daniel #1): density 0 → big lazy wax, density 1 → + * smaller wax. Applied LIVE each frame to the blob's unbiased base radius (r0 → r), so turning + * the dial resizes already-live blobs, not just how many spawn. One source so seed + per-frame + * agree. */ + function densitySizeBias(): number { + return 1 - blobDensity * 0.6; // density 0 → ×1.0 (big), density 1 → ×0.4 (smaller) + } + /** 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; + // Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame. + const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN); + const r = r0 * densitySizeBias(); + b.r0 = r0; b.r = r; b.er = r; // starts at full size (cool); shrinks as it heats b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width @@ -1112,7 +1285,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { function initBlobs(aspect: number): void { blobs.length = 0; for (let i = 0; i < MAX_BLOBS; i++) { - const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 }; + const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r0: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 }; seedBlob(b, aspect); blobs.push(b); } @@ -1187,6 +1360,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } const count = liveBlobCount(); + const sizeBias = densitySizeBias(); // density dial → live size scale (Daniel #1, recomputed each step) 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); @@ -1225,6 +1399,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt; b.temp = Math.min(Math.max(b.temp, 0), 1); + // Density → SIZE (Daniel #1): scale the blob's identity radius by the live density + // bias EACH STEP, so turning the density dial visibly resizes already-live wax (the + // "size" half is no longer baked at seed). r feeds the heat-shrink below and the + // collisions/upload via er, so the dial moves the actual drawn + simulated size. + b.r = b.r0 * sizeBias; + // Energy → SIZE (Daniel #7): the hotter the wax, the smaller it shrinks. Driven by // heatScale × temperature so it only shrinks when the lamp is actually hot AND this // blob is hot — at heat 0 every blob stays full size. Tracks temp continuously, so a @@ -1481,15 +1661,29 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // 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. + const clockSeconds = (performance.now() - startTimeMs) / 1000; gl.uniform2f(u.resolution, canvas.width, canvas.height); gl.uniform1f(u.playheadSeconds, renderedPlayhead()); - gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000); + gl.uniform1f(u.timeSeconds, clockSeconds); + + // Advance the gradient-rotation phase (Motion 1) off the SAME clock as uTimeSeconds — the + // delta since the last drawn frame, scaled by the dial's rate. Integrating rate·dt keeps + // the phase continuous across a dial change (no snap). Idle one-shot redraws advance it by + // their real dt too, so the field keeps morphing while paused (the loop runs continuously). + const gradientDt = Math.max(0, clockSeconds - lastGradientClockSeconds); + lastGradientClockSeconds = clockSeconds; + const rotationRate = GRADIENT_ROTATION_RATE_MIN + + gradientRotationSpeed * (GRADIENT_ROTATION_RATE_MAX - GRADIENT_ROTATION_RATE_MIN); + gradientPhase += gradientDt * rotationRate; + // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no // separate dirty-tracking needed for scalars/vec3s). gl.uniform1f(u.visibleSeconds, visibleSeconds); gl.uniform1f(u.waveformWidth, waveformWidth); - gl.uniform3fv(u.colorAccent, theme.accent); - gl.uniform3fv(u.colorEdge, theme.edge); + gl.uniform1f(u.gradientPhase, gradientPhase); + gl.uniform3fv(u.colorNavy, theme.navy); + gl.uniform3fv(u.colorMoss, theme.moss); + gl.uniform3fv(u.colorPaper, theme.paper); // 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 @@ -1638,6 +1832,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { `blobs=${live} buoyant=${buoyant} pooled=${pooled} ` + `avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`, ); + // Colour diagnostic (R3): the rotation dial + the live gradient phase. Daniel watches + // phase advance (faster at a higher dial, near-static at the low end) to confirm Motion 1 + // is live, and that the dial visibly changes the rate. phase mod 3 = the ring position. + debugLog( + `colour — rotationSpeed=${gradientRotationSpeed.toFixed(2)} ` + + `gradientPhase=${gradientPhase.toFixed(2)} (ring ${(gradientPhase % 3).toFixed(2)}/3).`, + ); fpsFrameCount = 0; fpsWindowStartMs = nowMs; } @@ -1825,11 +2026,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (rafId === null) redrawOnce(); }, - // Gradient rotation speed: INERT until Wave R3. Stored so the knob round-trips/persists; the - // R2 flat placeholder fill ignores it, so there is nothing to redraw. + // Gradient rotation speed: LIVE as of Wave R3 — sets the anchor-rotation rate (Motion 1). + // The phase integrator (draw()) reads this; changing it alters the slope, never snaps the + // phase, so the gradient speeds up/slows down smoothly. redrawOnce guards the fully-stopped + // (tab-hidden) case so a tweak still lands a still frame when it resumes-and-draws. setGradientRotationSpeed(value: number): void { gradientRotationSpeed = Math.min(1, Math.max(0, value)); - debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)} (inert until R3).`); + debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)}.`); + if (rafId === null) redrawOnce(); }, setLavaGravity(value: number): void { @@ -1844,6 +2048,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (rafId === null) redrawOnce(); }, + // Blob density/size: drives BOTH halves live — count (liveBlobCount) AND size (densitySizeBias + // applied to every blob's radius each physics step, Daniel #1). Turning it visibly resizes the + // already-live wax, not just how many blobs there are. setBlobDensity(value: number): void { blobDensity = Math.min(1, Math.max(0, value)); debugLog(`setBlobDensity → ${blobDensity.toFixed(3)}.`);