Merge p10-w3-effects-rework into dev (P10 W3 rework: vivid HSL field, time-driven bubbling, surface-born bubbles, working color-shift)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user