feat(visualizer): four in-shader Mix effects — morphing navy-moss field, bubblyness, lava-lamp detach, glass (P10 W3)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user