Merge p10-reframe-w3-color into dev (Phase 10 Reframe W3: OKLab three-color gradient + live density-size)

This commit is contained in:
daniel-c-harvey
2026-06-16 18:03:46 -04:00
@@ -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 navymoss 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)}.`);