Merge p10-w3-shader-effects into dev (Phase 10 Wave 3: four in-shader effects — gradient field, bubblyness, lava-lamp detach, glass)

This commit is contained in:
daniel-c-harvey
2026-06-15 23:56:07 -04:00
@@ -1,5 +1,5 @@
/**
* MixVisualizer — the scrolling Mix waveform background (Phase 10, Wave 1).
* MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 13).
*
* 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,
@@ -7,13 +7,20 @@
* 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 is a wholesale renderer swap from
* the Canvas 2D predecessor (8.K). The reasons are forward-looking (the planned
* bulge / detach / morphing-field / glass effects 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 1 introduces NO new
* effects: it reproduces the predecessor's scrolling navy/moss-ish ribbon on the
* GPU at parity, holding 60 FPS, with the Blazor bridge contract unchanged.
* 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
@@ -134,29 +141,43 @@ function debugLog(...args: unknown[]): void {
if (DEBUG) console.log(TAG, ...args);
}
// ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─
// ── 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 ("Charleston
// in the Day" / "Lowcountry Summer Nights") swap those vars when dark mode toggles,
// so re-reading + re-uploading them re-themes the ribbon with no reload. The
// component just calls `refreshTheme()` after a dark-mode change.
// 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.
//
// Parity binding (matches the Canvas predecessor): accent = --mud-palette-primary
// (brightest, at the now line), edge = --mud-palette-surface (dim, at the window
// edges so the ribbon fades into the page). Wave 3 will replace this two-stop
// gradient with the full navy↔moss morphing field; for parity we keep the
// predecessor's exact stops.
// 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 {
/** RGB [0,1] at the "now" line (brightest). */
/** Moss-green pole RGB [0,1] — uploaded to uColorAccent. */
accent: [number, number, number];
/** RGB [0,1] at the window edges (dimmer). */
/** 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
@@ -335,14 +356,14 @@ 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) — reserved for Wave 3 motion
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) — 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 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; // brightest stop, at the now line (per theme)
uniform vec3 uColorEdge; // dim stop, at the window edges (per theme)
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)
@@ -354,6 +375,34 @@ 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
@@ -393,6 +442,167 @@ float sampleAt(float timeSeconds) {
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;
@@ -403,40 +613,122 @@ void main() {
float nowY = h * NOW_ANCHOR_FROM_TOP;
float pixelsPerSecond = h / uVisibleSeconds;
// time at this fragment's row, then loudness there.
float t = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
float amp = sampleAt(t);
// Ribbon silhouette: symmetric about the horizontal centre, half-width scales
// with loudness. This is the signed-distance form of the Canvas mirrored path.
float centreX = w * 0.5;
float maxHalfWidth = (w * 0.5) * RIBBON_HALF_WIDTH_FRAC;
float halfWidth = amp * maxHalfWidth;
float distFromCentre = abs(screenX - centreX);
// Soft edge: a ~1.5px feather at the silhouette boundary so the ribbon reads as
// a glowy lit band rather than a hard-edged chart — the parity stand-in for the
// predecessor's shadowBlur halo, done with a cheap smoothstep instead of a
// per-frame CPU blur. (No CSS backdrop-filter, no shadowBlur — spec §5.2.)
float feather = 1.5;
float inside = 1.0 - smoothstep(halfWidth - feather, halfWidth + feather, distFromCentre);
// With no datum, amp is 0 everywhere and halfWidth collapses to 0 — but the
// feathered smoothstep still lights the ~feather-wide column at the exact centre,
// drawing a thin vertical line down the middle of an otherwise-empty backdrop.
// That is the at-rest "vertical bar" artifact. The predecessor drew nothing at
// rest; match it by forcing the silhouette empty whenever there is no datum.
if (uHasDatum < 0.5) inside = 0.0;
// Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note).
if (uHasDatum < 0.5) {
fragColor = vec4(0.0);
return;
}
// Vertical gradient: brightest at the now line, dimming toward both edges. Same
// luminosity cue the predecessor drew (edge -> accent -> edge by screen Y).
float distFromNow = abs(screenYTop - nowY) / max(nowY, h - nowY);
vec3 ribbonColor = mix(uColorAccent, uColorEdge, clamp(distFromNow, 0.0, 1.0));
// 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);
float alpha = inside * RIBBON_OPACITY;
// Pre-multiplied output (blend func is ONE / ONE_MINUS_SRC_ALPHA) so the
// translucent ribbon composites cleanly over the transparent canvas.
fragColor = vec4(ribbonColor * alpha, alpha);
// ── 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);
}
`;
@@ -623,15 +915,29 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
return effectivePlayhead() + correctionOffset;
}
/** Resolve the gradient stops from the live palette vars on the canvas. */
/**
* 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 resolved: ResolvedTheme = {
// Brightest stop at the "now" line — the bespoke themes' primary accent.
accent: parseColor(readVar(canvas, '--mud-palette-primary', '#b08d57')),
// Dim stop at the edges — the surface colour so the ribbon fades into the page.
edge: parseColor(readVar(canvas, '--mud-palette-surface', '#1a1a1a')),
};
debugLog(`theme resolved — accent=[${resolved.accent.map((c) => c.toFixed(2)).join(', ')}], edge=[${resolved.edge.map((c) => c.toFixed(2)).join(', ')}].`);
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;
}