From a9d644588133abd5b1145bddcc2bc37c593d52d3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:42:44 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(visualizer):=20four=20in-shader=20Mix?= =?UTF-8?q?=20effects=20=E2=80=94=20morphing=20navy-moss=20field,=20bubbly?= =?UTF-8?q?ness,=20lava-lamp=20detach,=20glass=20(P10=20W3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interop/visualizer/MixVisualizer.ts | 427 +++++++++++++++--- 1 file changed, 363 insertions(+), 64 deletions(-) diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index 24f0671..a83709f 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -1,5 +1,5 @@ /** - * MixVisualizer — the scrolling Mix waveform background (Phase 10, Wave 1). + * MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 1–3). * * What this renders: a *windowed* slice of a mix's loudness profile, scrolling * bottom-to-top, coupled to playback position. New audio enters at the bottom, @@ -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,165 @@ 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, 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); + float up = sdRoundBox(vec2(xn, reach), vec2(max(ampUp, 0.001), reach), cornerR); + float dn = sdRoundBox(vec2(xn, -reach), vec2(max(ampDn, 0.001), reach), cornerR); + 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. Sampled once at the playhead (not per-pixel-varying). + float feed = sampleAt(uPlayheadSeconds); + 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 +611,117 @@ 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)); + // ── 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, amp); - 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); + float e = 1.0; // 1px central-difference step + float ignore; + float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, 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 +908,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; } From 5011fb43f09f1cc4d02abf31110b5abb6f92d8ee Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:55:16 -0400 Subject: [PATCH 2/2] perf(shader): hoist playhead texture tap; clamp neighbour sdRoundBox corner radius --- .../Interop/visualizer/MixVisualizer.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index a83709f..f2c0d0f 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -502,7 +502,7 @@ float smin(float a, float b, float k) { // 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, out float ampOut) { +float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, float playheadFeed, out float ampOut) { float screenYTop = px.y; float screenX = px.x; @@ -546,8 +546,10 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth float dtRow = reach * maxHalfWidth / pixelsPerSecond; // xn-reach back to seconds float ampUp = sampleAt(t - dtRow); float ampDn = sampleAt(t + dtRow); - float up = sdRoundBox(vec2(xn, reach), vec2(max(ampUp, 0.001), reach), cornerR); - float dn = sdRoundBox(vec2(xn, -reach), vec2(max(ampDn, 0.001), reach), cornerR); + 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); } @@ -569,8 +571,8 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth // 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. Sampled once at the playhead (not per-pixel-varying). - float feed = sampleAt(uPlayheadSeconds); + // 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. @@ -619,20 +621,25 @@ void main() { return; } + // Hoist the playhead-feed tap: sampleAt(uPlayheadSeconds) is fragment-invariant + // (depends only on a uniform) and would otherwise run inside the 7-iteration detach + // blob loop × 5 ribbonField calls = up to 35× per pixel. Compute it once here. + float playheadFeed = sampleAt(uPlayheadSeconds); + // ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ── // The gradient of the SDF is the outward surface normal — we need it for the glass // (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the // step is one device-pixel mapped into the field's xn/yTop frame. vec2 px = vec2(screenX, screenYTop); float amp; - float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, 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, ignore); - float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); - float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); - float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, 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);