diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index f2c0d0f..e6d2a08 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -73,8 +73,14 @@ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3; */ const NOW_ANCHOR_FROM_TOP = 0.5; -/** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */ -const RIBBON_OPACITY = 0.22; +/** + * Background opacity of the whole ribbon. Raised from the parity 0.22 → 0.55 for the + * Wave-3-rework "vivid/glassy" pass: at 0.22 the page (off-white / navy) showed through + * ~78% of every pixel and washed the field toward grey. 0.55 lets the saturated navy/moss + * read as real colour while still keeping it a translucent glass backdrop, not an opaque + * chart. (The rim/Fresnel lift on top pushes edges higher.) + */ +const RIBBON_OPACITY = 0.55; /** * Half-width of the ribbon at full loudness, as a fraction of half the canvas @@ -134,7 +140,10 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005; // received/uploaded, first-draw dimensions, GL error after first draw) are gated // here so they can be silenced once the renderer is confirmed healthy. Leave it on // while the runtime fix is being verified through the browser. -const DEBUG = false; +// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser; +// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the +// look is approved. +const DEBUG = true; const TAG = '[MixVisualizer]'; function debugLog(...args: unknown[]): void { @@ -378,30 +387,55 @@ 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 +// it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3 +// rework): the old 60 s slow end made even the default look frozen — Daniel reported the +// slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s +// (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential +// map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3 +// (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated +// poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey. +const float COLORSHIFT_PERIOD_SLOW = 24.0; // s at slider 0 — slow but perceptible drift +const float COLORSHIFT_PERIOD_FAST = 2.0; // s at slider 1 — unmistakably brisk morph + +// Vividness (W3 rework). The raw theme tokens are muted UI colours (navy text / moss +// secondary, both dark + low-saturation); a naive RGB lerp between them passes through a +// muddy grey midpoint, which is exactly the "mostly grey" Daniel rejected. We mix the +// field in HSL instead (hue/sat/lum interpolate independently, so the path between two +// saturated colours stays saturated — no grey midpoint), and lift saturation + luminance +// of the result so the field reads as rich glassy navy-blue ↔ vivid moss-green. These are +// the punch dials. +const float VIVID_SATURATION_FLOOR = 0.62; // min saturation of any field pixel [0,1] +const float VIVID_SATURATION_BOOST = 0.30; // extra saturation pushed in on top of the lerp +const float VIVID_LUMINANCE_LIFT = 0.14; // lifts the dark poles off black so colour reads // 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; +// Bubbling motion (W3 rework). Bubblyness used to only thicken the ribbon statically. +// Now it also drives a time-varying swell of the ribbon surface (a lava-lamp roil): a +// low-frequency noise displaces the bar half-width up and down over time, with amplitude +// and churn rate growing with uBubblyness. At 0 the displacement is zero (flat parity +// bars); rising = an increasingly active, undulating surface. +const float BUBBLE_SWELL_AMPLITUDE = 0.35; // max half-width swell (xn units) at bubblyness 1 +const float BUBBLE_SWELL_RATE = 0.55; // churn speed (rad/s scale) of the swell noise +const float BUBBLE_SWELL_FREQ = 2.2; // spatial frequency of the swell along the ribbon + // 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 +// as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT +// the waveform surface (where loudness is) and pinch off from it, rather than spawning in +// empty space — see ribbonField's detach block. +const int DETACH_BLOB_COUNT = 6; +const float DETACH_RISE_SPAN = 1.15; // window-heights a blob climbs across its life +const float DETACH_BLOB_DRIFT = 0.05; // horizontal lava-lamp wobble amplitude (xn units) // 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 +// (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3 +// rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy"). +const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot +const float GLASS_FRESNEL_POWER = 2.2; // lower = broader, more visible rim glow +const float GLASS_REFRACT_WARP = 0.10; // 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 @@ -469,6 +503,66 @@ float valueNoise(vec2 p) { return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } +// ── HSL conversion (for the VIVID field — see VIVID_* consts). ────────────────────── +// Mixing two saturated colours in linear RGB drags the midpoint through grey; mixing in +// HSL keeps hue/sat/lum independent so the path between navy and moss stays colourful. +// Standard branchless RGB↔HSL. h,s,l ∈ [0,1]. +vec3 rgb2hsl(vec3 c) { + float mx = max(max(c.r, c.g), c.b); + float mn = min(min(c.r, c.g), c.b); + float l = (mx + mn) * 0.5; + float d = mx - mn; + float s = 0.0; + float h = 0.0; + if (d > 1e-5) { + s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn); + if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + else if (mx == c.g) h = (c.b - c.r) / d + 2.0; + else h = (c.r - c.g) / d + 4.0; + h /= 6.0; + } + return vec3(h, s, l); +} +float hue2rgb(float p, float q, float t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0 / 2.0) return q; + if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + return p; +} +vec3 hsl2rgb(vec3 hsl) { + float h = hsl.x, s = hsl.y, l = hsl.z; + if (s < 1e-5) return vec3(l); + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + return vec3(hue2rgb(p, q, h + 1.0 / 3.0), hue2rgb(p, q, h), hue2rgb(p, q, h - 1.0 / 3.0)); +} +// Interpolate two RGB colours through HSL, taking the SHORT way around the hue circle so +// navy↔moss travels the rich teal/blue arc rather than wrapping through red. Returns the +// result back in RGB with no extra vividness applied (the caller adds the punch). +vec3 mixHsl(vec3 a, vec3 b, float t) { + vec3 ha = rgb2hsl(a); + vec3 hb = rgb2hsl(b); + float dh = hb.x - ha.x; + if (dh > 0.5) dh -= 1.0; // go the short way round the hue wheel + if (dh < -0.5) dh += 1.0; + float h = fract(ha.x + dh * t); + float s = mix(ha.y, hb.y, t); + float l = mix(ha.z, hb.z, t); + return hsl2rgb(vec3(h, s, l)); +} +// Push a colour toward vivid: raise saturation (with a floor) and lift luminance off +// black so the dark theme poles actually read as colour rather than near-grey. amp ∈ [0,1] +// (loudness) lifts a loud bar a little further for the "own living thing" read. +vec3 vivify(vec3 rgb, float amp) { + vec3 hsl = rgb2hsl(rgb); + hsl.y = max(hsl.y, VIVID_SATURATION_FLOOR); + hsl.y = clamp(hsl.y + VIVID_SATURATION_BOOST + amp * 0.10, 0.0, 1.0); + hsl.z = clamp(hsl.z + VIVID_LUMINANCE_LIFT + amp * 0.06, 0.0, 0.92); + return hsl2rgb(hsl); +} + // ── 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) { @@ -502,7 +596,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, float playheadFeed, out float ampOut) { +float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, out float ampOut) { float screenYTop = px.y; float screenX = px.x; @@ -513,7 +607,20 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth // 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 + + // --- BUBBLING MOTION (§4d rework) ------------------------------------------------- + // Bubblyness now drives a real over-time roil, not just a thicker static ribbon. A + // low-frequency noise sampled over (this row's mix-time, the wall clock) swells the + // bar's half-width up and down continuously — the surface churns like a lava lamp's. + // Amplitude AND churn rate both scale with uBubblyness, so at 0 the term vanishes + // (flat parity bars) and rising = an increasingly active, undulating surface. We key + // the noise to mix-time (not screen-Y) so the swell travels WITH the audio as it + // scrolls, rather than sitting still in screen space. Only applied where there is + // loudness (amp gates it) so silence stays flat. + float swellNoise = valueNoise(vec2(t * BUBBLE_SWELL_FREQ, + uTimeSeconds * BUBBLE_SWELL_RATE)) - 0.5; // ±0.5 + float swell = swellNoise * BUBBLE_SWELL_AMPLITUDE * uBubblyness * amp * 2.0; + float halfWidthN = max(amp + swell, 0.0); // box half-extent in xn units, now animated // --- ATTACHED SHAPE --------------------------------------------------------------- // At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular @@ -554,48 +661,74 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth 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. + // --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------ + // Reworked from the old "fixed-column blobs floating in empty space that vibrate" to + // bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge + // (where the loudness is) near the now-line, pinches off, and rises smoothly. Two + // fixes for the rejected version: + // 1. ORIGIN AT THE WAVEFORM. A bubble's birth column sits at ±(loudness) — the bar + // EDGE at its birth time — not a hash-picked column in empty space. We sample the + // datum at the birth time so a bubble only exists where there was actually sound, + // and it starts attached to the surface there. + // 2. NO VIBRATION. The vertical scale now matches the horizontal (xn) scale via the + // screen aspect (yAspect below), so blobs are round, not squashed — the old code + // normalised a vertical distance by maxHalfWidth (a HORIZONTAL scale), which + // stretched blobs and made the SDF-gradient normal unstable → shimmer. Motion is + // a single smooth fract(uTimeSeconds·rate); the only hash use is per-index + // identity (time-invariant), so there is no per-frame jitter. 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. + // Map a vertical screen-pixel distance into the same xn units the SDF circle uses, + // so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth + // (≈ half the canvas width); to match, vertical must divide by the same, hence the + // 1.0 here keeps both axes in maxHalfWidth units (screenY already in px like screenX). + float yToXn = 1.0 / maxHalfWidth; 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); + // Per-blob identity from a hash — stable over time (no per-frame term), so the + // blob set is a calm repeating drift, never a random storm. + float seed = hash21(vec2(fi, 7.0)); + float seed2 = hash21(vec2(fi, 19.0)); + float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off + + // Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase + // offset so they don't pulse in unison; rise rate scales gently with detach. + float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach); + float life = fract(uTimeSeconds * rate + seed); + + // Birth time: the mix-time at the now-line, nudged per blob so they're born at + // staggered moments. The bubble emanates from the surface AS IT WAS at birth. + float birthT = uPlayheadSeconds - seed * 0.15; + float birthAmp = sampleAt(birthT); + // No surface there (silence) → no bubble. This is what ties bubbles to the + // waveform: they only appear where there was loudness to shed them. + if (birthAmp < 0.02) continue; + + // Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts + // ON the surface. As it rises it drifts slightly inward/outward (lava wobble). + float birthX = side * birthAmp; + float driftX = birthX + side * DETACH_BLOB_DRIFT * sin(uTimeSeconds * 0.6 + seed * 6.28); + + // Rise: starts at the now-line (the surface) and climbs upward (screen-up = + // decreasing screenYTop), travelling DETACH_RISE_SPAN window-heights over life. + float riseN = life * DETACH_RISE_SPAN; // window-heights climbed + float blobYTop = nowY - riseN * uResolution.y; // screen Y of the blob centre + + // Radius: bigger from a louder birth surface; grows then shrinks across life so + // the bubble swells out of the surface and fades near the top — no hard pop. + float envelope = smoothstep(0.0, 0.15, life) * (1.0 - smoothstep(0.80, 1.0, life)); + float radius = (0.04 + 0.07 * seed) * (0.5 + 0.5 * birthAmp) * uDetach * envelope; + if (radius < 1e-4) continue; // fully faded — skip (also avoids a 0-radius SDF) + + // Blob centre in the (xn, xn) eval frame. Both axes now in maxHalfWidth units + // (driftX already in xn; vertical px scaled by yToXn) → the circle is round. + vec2 pBlob = vec2(xn - driftX, (screenYTop - blobYTop) * yToXn); 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); + + // Pinch-off neck: while young (low life) and at low detach the bubble stays + // linked to the parent surface via a fat smooth-min neck; as it rises (life→1) + // or detach→1 the neck thins toward a hard union, so it reads as separated. + float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7); field = smin(field, blob, max(neckK, 0.004)); } } @@ -621,25 +754,23 @@ 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. + // (The old playhead-feed hoist was removed in the W3 rework: detach now samples a + // per-blob birth-time loudness inside the loop, so there is no single shared tap to + // lift out. The taps remain uniform-only expressions, the same order of cost as before.) vec2 px = vec2(screenX, screenYTop); float amp; - float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, amp); + float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, 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); + 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); @@ -668,23 +799,33 @@ void main() { 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); + // (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit + // sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps + // the whole field navy↔moss once per cycle, so dragging the slider visibly changes + // how fast the field morphs (the old version relied on noise drifting through a + // near-grey lerp, so the morph was invisible — Daniel's "slider does nothing"). + // The noise rides on top for organic, non-repeating variation across the window. + float sweep = 0.5 + 0.5 * sin(phase); // 0→1, one cycle per period + float drift = valueNoise(vec2(tHere * 0.15 + phase * 0.5, phase)); + drift += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); + drift = clamp(drift / 1.5, 0.0, 1.0); + float base = clamp(sweep * 0.6 + drift * 0.4, 0.0, 1.0); // time-sweep dominant // (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. + // noise keyed to this bar's time so neighbours differ. 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); + float fieldMix = clamp(base * 0.55 + xnAbs * 0.30 + perBar * 0.20 + amp * 0.15, 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); + // VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB, + // so the path between them stays saturated instead of passing through the muddy grey + // midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance + // off the dark UI tokens so it reads as rich glassy navy ↔ vivid moss. accent = MOSS + // (peak/lively), edge = NAVY (zero-line/calm). + vec3 baseColor = vivify(mixHsl(uColorEdge, uColorAccent, fieldMix), amp); + // Pre-vivified accent for the glass rim/sheen below, so those highlights are vivid moss + // rather than the dull raw token (the rim is the strongest glass cue — keep it punchy). + vec3 vividAccent = vivify(uColorAccent, 1.0); // ── 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. @@ -700,7 +841,9 @@ void main() { 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); + // Warped read uses the same VIVID HSL mix as the straight read, so refraction bends a + // saturated colour through the lens rather than revealing the dull raw lerp. + vec3 glassColor = vivify(mixHsl(uColorEdge, uColorAccent, warpMix), amp); glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read // (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the @@ -717,11 +860,12 @@ void main() { 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. + // a Fresnel rim toward the VIVID moss accent, plus a white-hot specular dot. Using the + // vivified accent (not the dull raw token) keeps the glass cues punchy and glassy. 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 + lit += sheen * vividAccent; + lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss + 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. @@ -937,7 +1081,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { : 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(', ')}].`); + // Report BOTH poles the shader will actually use, as 0-255 RGB + relative luminance. + // This is the line Daniel watches to confirm the "grey" cause: if the poles are dull + // here (low luminance / low spread) the fix is the in-shader vivify(); if they look + // saturated here the muddying was the old linear-RGB midpoint lerp (now HSL). + 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)}.`); return resolved; }