feat(mix-visualizer): Phase 10 tuning — smooth waveform, bouncy collision, 8 knobs
Smooth the loudness contour (~50 ms envelope at preprocessing + decode-time, plus smootherstep render reconstruction); retune wax↔waveform collision to bouncy/sub-unity (no explosion/stuck/jitter); split the bubbles knob into fluid-amount + fluid-viscosity (cohesion via uniform-only smin/wobble); retune scroll/gravity/heat/width ranges; make the colour rotation visible and boost OKLab chroma; the controls bar now holds its layout and hides only its knobs via a Visible parameter.
This commit is contained in:
@@ -32,9 +32,10 @@
|
||||
* The Blazor component owns the canvas element and the inputs (datum, playback,
|
||||
* scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop,
|
||||
* the physics step, and all the GL math. The component drives it through the handle
|
||||
* returned by `create`. As of Wave R4 the handle exposes SEVEN dedicated control setters
|
||||
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity /
|
||||
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. As of Wave R3 the
|
||||
* returned by `create`. As of Phase 10 the handle exposes EIGHT dedicated control setters
|
||||
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount /
|
||||
* setFluidViscosity / setCollisionStrength / setWaveformWidth) — the single density knob is split into
|
||||
* fluid-amount + fluid-viscosity. As of Wave R3 the
|
||||
* gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation.
|
||||
*
|
||||
* PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and
|
||||
@@ -64,14 +65,15 @@ export const DEFAULT_VISIBLE_SECONDS = 10;
|
||||
// normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it
|
||||
// reaches setScrollSpeed; it arrives here already in seconds).
|
||||
//
|
||||
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the
|
||||
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping:
|
||||
// Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The
|
||||
// single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping:
|
||||
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
|
||||
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE
|
||||
// as of Wave R3; drives the OKLab gradient's anchor rotation
|
||||
// • Lava gravity → gravity dial (setLavaGravity)
|
||||
// • Lava heat → heat dial (setLavaHeat)
|
||||
// • Blob density/size → density dial (setBlobDensity)
|
||||
// • Fluid amount → blob count + per-blob volume (setFluidAmount)
|
||||
// • Fluid viscosity/cohesion → sphere-restoration: smin blend + wobble (setFluidViscosity)
|
||||
// • Collision strength → collision hardness dial (setCollisionStrength)
|
||||
// • Waveform width → ribbon half-width uniform (setWaveformWidth)
|
||||
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he
|
||||
@@ -89,15 +91,23 @@ export const DEFAULT_LAVA_HEAT = 1.0;
|
||||
* Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
|
||||
export const DEFAULT_COLLISION_STRENGTH = 0.5;
|
||||
|
||||
/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */
|
||||
export const DEFAULT_BLOB_DENSITY = 0.4;
|
||||
/** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10
|
||||
* split): how much wax is in the container — blob count + per-blob volume. 0 = few small blobs,
|
||||
* 1 = many larger blobs (more fluid). */
|
||||
export const DEFAULT_FLUID_AMOUNT = 0.4;
|
||||
|
||||
/** Default FLUID VISCOSITY / COHESION. Mirrors C# DefaultFluidViscosity. The "bubbles" knob's second
|
||||
* half (Phase 10 split): how strongly the wax holds a spherical shape. 1 = high cohesion (crisp
|
||||
* spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged under inertia).
|
||||
* Default leans cohesive so the at-rest look is rounded wax. */
|
||||
export const DEFAULT_FLUID_VISCOSITY = 0.6;
|
||||
|
||||
/**
|
||||
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
|
||||
* [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at
|
||||
* which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z).
|
||||
*/
|
||||
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
|
||||
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.45;
|
||||
|
||||
/**
|
||||
* Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor
|
||||
@@ -105,14 +115,18 @@ export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
|
||||
* ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end.
|
||||
* Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead.
|
||||
*/
|
||||
const GRADIENT_ROTATION_RATE_MAX = 0.18;
|
||||
const GRADIENT_ROTATION_RATE_MIN = 0.01;
|
||||
// Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full
|
||||
// three-colour cycle took ~17 s at full dial and ~49 s at the 0.3 default — below the threshold of
|
||||
// "this is moving". Raised so the dial has obvious effect: 0.6 → a full cycle in ~5 s at full speed,
|
||||
// and the default (now 0.45) cycles in ~7 s — clearly rotating, still meditative not strobing.
|
||||
const GRADIENT_ROTATION_RATE_MAX = 0.6;
|
||||
const GRADIENT_ROTATION_RATE_MIN = 0.03;
|
||||
|
||||
/**
|
||||
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower
|
||||
* values narrow the waveform band so the lava fluid gets more room to move on loud songs.
|
||||
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful
|
||||
* 10%–95% ribbon-extent band (Phase 10 §3.7 — see effectiveWaveformWidth); 0.5 opens mid-band.
|
||||
*/
|
||||
export const DEFAULT_WAVEFORM_WIDTH = 0.6;
|
||||
export const DEFAULT_WAVEFORM_WIDTH = 0.5;
|
||||
|
||||
/**
|
||||
* Where the "now" line sits within the window, as a fraction from the top.
|
||||
@@ -249,19 +263,29 @@ const BLOB_RESTITUTION_SOFT = 0.05; // residual restitution at strength = 0 (al
|
||||
* the soft end → elastic reflection of the inward velocity at the hard end. The waveform
|
||||
* is read-only authority: it pushes the fluid, the fluid never moves it.
|
||||
*/
|
||||
const WAVE_COLLIDE_SPRING = 12.0; // soft penalty stiffness pushing wax off the ribbon (softened, Daniel #3)
|
||||
const WAVE_RESTITUTION_HARD = 1.1; // elastic reflection at full hardness — over-unity for the "throw" (Daniel #4/#6)
|
||||
// Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax). The
|
||||
// smoothed waveform (item 1) gives a gently-moving boundary, so the response can be springier without
|
||||
// buzzing. Restitution is now SUB-unity: a real bounce conserves-or-loses energy, never adds it —
|
||||
// over-unity (the old 1.1) injected energy each contact and read as "explosive". 0.85 at the hard end
|
||||
// is lively/springy; the soft end stays near-zero (mush).
|
||||
const WAVE_COLLIDE_SPRING = 10.0; // soft penalty stiffness pushing wax off the ribbon (slightly softer)
|
||||
const WAVE_RESTITUTION_HARD = 0.85; // springy but energy-bounded reflection at full hardness (no explosion)
|
||||
const WAVE_RESTITUTION_SOFT = 0.05; // near-pure mush at the soft end (Daniel #3)
|
||||
|
||||
/**
|
||||
* Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax
|
||||
* penetrates the ribbon, in addition to the outward (horizontal) surface-normal push we add
|
||||
* an UPWARD (−y) impulse proportional to the penetration depth and the collision-strength
|
||||
* dial. At low strength this is ~0 (the ribbon just mushes the wax around horizontally); at
|
||||
* high strength a loud transient launches bubbles up and out — the lively "thrown" look. The
|
||||
* coefficient is in height-units/s² per unit penetration, scaled by the strength dial.
|
||||
* Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates
|
||||
* the ribbon we add a small UPWARD (−y) nudge so loud transients lift bubbles toward the surface
|
||||
* rather than only shoving them sideways.
|
||||
*
|
||||
* Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration ×
|
||||
* hardness × dt, accumulated on a sustained loud passage and launched bubbles off-screen — the
|
||||
* "explosive" feel. Cut to a gentle lift and CAPPED per contact (see the clamp in stepPhysics) so a
|
||||
* deep/sustained overlap can't pump unbounded upward speed. Reads as a bouncy bob, not a rocket.
|
||||
*/
|
||||
const WAVE_THROW_UP = 26.0;
|
||||
const WAVE_THROW_UP = 9.0;
|
||||
/** Hard cap on the per-contact upward throw velocity (height-units/s) so a sustained loud transient
|
||||
* can never accumulate into an off-screen launch. Well above a natural bob, far below escape speed. */
|
||||
const WAVE_THROW_UP_MAX = 0.6;
|
||||
|
||||
/**
|
||||
* Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a
|
||||
@@ -497,8 +521,11 @@ export interface MixVisualizerHandle {
|
||||
setLavaGravity(value: number): void;
|
||||
/** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
|
||||
setLavaHeat(value: number): void;
|
||||
/** [0,1]. Amount of wax — blob count/size. */
|
||||
setBlobDensity(value: number): void;
|
||||
/** [0,1]. Amount of wax — blob count + per-blob volume. */
|
||||
setFluidAmount(value: number): void;
|
||||
/** [0,1]. Fluid viscosity / cohesion — how strongly wax restores to a sphere (1) vs stays
|
||||
* deformed/gooey (0). Drives the metaball smin blend + wobble; no per-fragment cost change. */
|
||||
setFluidViscosity(value: number): void;
|
||||
/** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
|
||||
setCollisionStrength(value: number): void;
|
||||
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
|
||||
@@ -523,6 +550,43 @@ function decodeSamples(base64: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope-follower smoothing time constant, seconds — mirrors C#
|
||||
* RmsLoudnessAlgorithm.SmoothingTimeConstantSeconds. The ~50 ms target rounds the spikey
|
||||
* per-sample loudness into a smooth ribbon contour (Phase 10 tuning).
|
||||
*/
|
||||
const SMOOTHING_TIME_CONSTANT_SECONDS = 0.05;
|
||||
|
||||
/**
|
||||
* Smooth the [0,255] loudness datum in place with a symmetric (zero-phase) one-pole envelope
|
||||
* follower targeting SMOOTHING_TIME_CONSTANT_SECONDS. This runs at DECODE time so EXISTING stored
|
||||
* mixes — whose vault profiles predate the C#-side preprocessing smoothing — read as a smooth
|
||||
* curve with no data regeneration. New mixes are already smoothed at preprocessing; a second light
|
||||
* pass over an already-smooth curve is near-idempotent, so applying it unconditionally here is safe.
|
||||
*
|
||||
* The coefficient a = exp(−secondsPerSample / τ): forward then backward pass cancels the single-pole
|
||||
* lag (no time shift). Bytes stay [0,255]; we smooth in float and round back. A degenerate sample
|
||||
* rate (≤0 or non-finite) leaves the data untouched.
|
||||
*/
|
||||
function smoothDatum(samples: Uint8Array, sampleCount: number, durationSeconds: number): void {
|
||||
if (sampleCount < 2 || durationSeconds <= 0 || !Number.isFinite(durationSeconds)) return;
|
||||
const secondsPerSample = durationSeconds / sampleCount;
|
||||
const a = Math.exp(-secondsPerSample / SMOOTHING_TIME_CONSTANT_SECONDS);
|
||||
|
||||
// Float working buffer over the real samples (tail padding, if any, is untouched).
|
||||
const env = new Float32Array(sampleCount);
|
||||
let acc = samples[0];
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
acc = a * acc + (1 - a) * samples[i];
|
||||
env[i] = acc;
|
||||
}
|
||||
acc = env[sampleCount - 1];
|
||||
for (let i = sampleCount - 1; i >= 0; i--) {
|
||||
acc = a * acc + (1 - a) * env[i];
|
||||
samples[i] = Math.round(Math.min(255, Math.max(0, acc)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shaders. ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Vertex: trivial pass-through. We draw a single triangle that more than covers the
|
||||
@@ -589,6 +653,8 @@ uniform float uPlayheadSeconds; // current playback position (per-frame)
|
||||
uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph
|
||||
uniform float uVisibleSeconds; // zoom: window time-span (per change)
|
||||
uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
|
||||
uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
|
||||
// low = gooey/deformed (drives the smin blend width + wobble below)
|
||||
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
|
||||
// in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old
|
||||
// uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason;
|
||||
@@ -713,7 +779,12 @@ float sampleAt(float timeSeconds) {
|
||||
int i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1);
|
||||
int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1);
|
||||
float f = clamp(p - floor(p), 0.0, 1.0);
|
||||
return mix(fetchSample(i0), fetchSample(i1), f);
|
||||
// Smootherstep (C1-continuous Hermite) blend between the two bracketing samples instead of a
|
||||
// straight linear lerp. Linear reconstruction connects samples with straight segments, so the
|
||||
// ribbon edge reads as faceted polygons; the Hermite ease gives a smooth sinusoid-shaped contour
|
||||
// between samples with zero slope at each sample point (Phase 10 tuning — smooth, not polygonal).
|
||||
float fs = f * f * (3.0 - 2.0 * f);
|
||||
return mix(fetchSample(i0), fetchSample(i1), fs);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -807,10 +878,20 @@ vec3 oklabToLinear(vec3 lab) {
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
||||
);
|
||||
}
|
||||
// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → lerp → back to gamma sRGB.
|
||||
// Chroma (vibrancy) boost in OKLab (Phase 10 — Daniel: "colours too muted, more punch"). OKLab's L
|
||||
// is lightness; (a,b) is the chroma vector. Scaling (a,b) about the neutral axis raises saturation
|
||||
// while preserving hue (the a:b ratio) and lightness (L untouched), so the palette-sourced navy/moss/
|
||||
// off-white stay themselves — just more vivid. No hardcoded hexes: the anchors remain the live palette
|
||||
// vars (spec §6a), this only amplifies their existing chroma. >1 = more punch.
|
||||
const float CHROMA_BOOST = 1.45;
|
||||
vec3 vivifyOklab(vec3 lab) {
|
||||
return vec3(lab.x, lab.y * CHROMA_BOOST, lab.z * CHROMA_BOOST);
|
||||
}
|
||||
// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → boost chroma → lerp → back to gamma
|
||||
// sRGB. The chroma boost gives the gradient punch (Phase 10) while OKLab keeps the blend faithful.
|
||||
vec3 mixOklab(vec3 a, vec3 b, float t) {
|
||||
vec3 la = linearToOklab(srgbToLinear3(a));
|
||||
vec3 lb = linearToOklab(srgbToLinear3(b));
|
||||
vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a)));
|
||||
vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b)));
|
||||
vec3 m = mix(la, lb, t);
|
||||
return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0);
|
||||
}
|
||||
@@ -863,6 +944,14 @@ float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight,
|
||||
float hotAccum = 0.0;
|
||||
float hotWeight = 0.0;
|
||||
|
||||
// Phase 10 cohesion (viscosity knob): low cohesion → a wider smin neck (blobs fuse and stay
|
||||
// gooey/deformed) and more wobble (less sphere-like); high cohesion → a tight neck and minimal
|
||||
// wobble (crisp spheres that read as "snapped back to round"). Pure uniform scaling of the two
|
||||
// existing constants — no extra per-fragment loop iterations, so weaker hardware is unaffected.
|
||||
// Range chosen so cohesion 1 still keeps a small organic neck/wobble (never a hard-edged circle).
|
||||
float blobK = BLOB_SMOOTHMIN_K * (1.0 + (1.0 - uCohesion) * 1.4); // ×1.0 (crisp) → ×2.4 (gooey)
|
||||
float wobbleAmt = BLOB_WOBBLE_AMOUNT * (0.35 + (1.0 - uCohesion) * 1.4); // less wobble when cohesive
|
||||
|
||||
// Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set.
|
||||
for (int i = 0; i < MAX_BLOBS; i++) {
|
||||
if (i >= uBlobCount) break;
|
||||
@@ -873,12 +962,13 @@ float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight,
|
||||
|
||||
// Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so
|
||||
// the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok).
|
||||
// Amount scaled by cohesion (low cohesion deforms more — Phase 10 viscosity split).
|
||||
float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5)
|
||||
* 2.0 * BLOB_WOBBLE_AMOUNT;
|
||||
* 2.0 * wobbleAmt;
|
||||
float rr = r * (1.0 + wob);
|
||||
|
||||
float blob = sdCircle(p - c, rr);
|
||||
field = smin(field, blob, BLOB_SMOOTHMIN_K);
|
||||
field = smin(field, blob, blobK);
|
||||
|
||||
// Weight this blob's temperature by proximity so the tint follows the nearest wax.
|
||||
float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0);
|
||||
@@ -1018,7 +1108,8 @@ function noopHandle(): MixVisualizerHandle {
|
||||
setGradientRotationSpeed() {},
|
||||
setLavaGravity() {},
|
||||
setLavaHeat() {},
|
||||
setBlobDensity() {},
|
||||
setFluidAmount() {},
|
||||
setFluidViscosity() {},
|
||||
setCollisionStrength() {},
|
||||
setWaveformWidth() {},
|
||||
refreshTheme() {},
|
||||
@@ -1078,6 +1169,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
|
||||
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
|
||||
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
|
||||
cohesion: gl.getUniformLocation(program, 'uCohesion'),
|
||||
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
|
||||
colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
|
||||
colorMoss: gl.getUniformLocation(program, 'uColorMoss'),
|
||||
@@ -1108,11 +1200,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let lavaHeat = DEFAULT_LAVA_HEAT;
|
||||
let lavaGravity = DEFAULT_LAVA_GRAVITY;
|
||||
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
|
||||
let blobDensity = DEFAULT_BLOB_DENSITY;
|
||||
// Phase 10 — the split "bubbles" knob: fluidAmount drives count + per-blob volume; fluidViscosity
|
||||
// (cohesion) drives the shader's sphere-restoration (smin blend + wobble) via uCohesion.
|
||||
let fluidAmount = DEFAULT_FLUID_AMOUNT;
|
||||
let fluidViscosity = DEFAULT_FLUID_VISCOSITY;
|
||||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
|
||||
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
|
||||
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
|
||||
|
||||
/** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
|
||||
* travel maps onto the useful 10%–95% band (full-width 100% read too wide; sub-10% vanished).
|
||||
* Both the shader uniform and the CPU collision boundary read this so they stay aligned. */
|
||||
function effectiveWaveformWidth(): number {
|
||||
return 0.10 + waveformWidth * 0.85;
|
||||
}
|
||||
|
||||
// ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the
|
||||
// shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by
|
||||
// rate·dt, where dt is the delta of (performance.now()−startTimeMs)/1000 (== uTimeSeconds).
|
||||
@@ -1257,19 +1359,19 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
const rng = makeRng(0x1a2b3c4d);
|
||||
|
||||
/** The density dial's effect on blob SIZE (Daniel #1): density 0 → big lazy wax, density 1 →
|
||||
* smaller wax. Applied LIVE each frame to the blob's unbiased base radius (r0 → r), so turning
|
||||
* the dial resizes already-live blobs, not just how many spawn. One source so seed + per-frame
|
||||
* agree. */
|
||||
function densitySizeBias(): number {
|
||||
return 1 - blobDensity * 0.6; // density 0 → ×1.0 (big), density 1 → ×0.4 (smaller)
|
||||
/** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE
|
||||
* each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live
|
||||
* blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean),
|
||||
* amount 1 → ×1.15 (fat, lots of wax). */
|
||||
function fluidSizeBias(): number {
|
||||
return 0.6 + fluidAmount * 0.55;
|
||||
}
|
||||
|
||||
/** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
|
||||
function seedBlob(b: Blob, aspect: number): void {
|
||||
// Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame.
|
||||
const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN);
|
||||
const r = r0 * densitySizeBias();
|
||||
const r = r0 * fluidSizeBias();
|
||||
b.r0 = r0;
|
||||
b.r = r;
|
||||
b.er = r; // starts at full size (cool); shrinks as it heats
|
||||
@@ -1292,9 +1394,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
let blobsInitialized = false;
|
||||
|
||||
/** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
|
||||
/** Live blob count for the current fluid-amount dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
|
||||
function liveBlobCount(): number {
|
||||
return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT));
|
||||
return Math.round(MIN_BLOB_COUNT + fluidAmount * (MAX_BLOBS - MIN_BLOB_COUNT));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1312,7 +1414,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const f = Math.min(Math.max(p - Math.floor(p), 0), 1);
|
||||
const s0 = d.samples[i0] / 255;
|
||||
const s1 = d.samples[i1] / 255;
|
||||
return s0 + (s1 - s0) * f;
|
||||
// Smootherstep (Hermite) blend — mirrors the shader's sampleAt so the CPU collision boundary
|
||||
// follows the same smooth sinusoid contour the ribbon is drawn with (no faceted mismatch).
|
||||
const fs = f * f * (3 - 2 * f);
|
||||
return s0 + (s1 - s0) * fs;
|
||||
}
|
||||
|
||||
/** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in.
|
||||
@@ -1321,7 +1426,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
* toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */
|
||||
function heatScaleFromDial(dial: number): number {
|
||||
const d = Math.min(Math.max(dial, 0), 1);
|
||||
return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1
|
||||
// Smoothstep toe (gentle at 0) scaled to reach 1.2 at dial 1 — Phase 10 §3.4: ~20% stronger
|
||||
// at the high end so full heat roils harder. The low/mid feel is unchanged (the toe dominates
|
||||
// there); only the top end gains the extra 20% drive into the floor-heating + buoyancy + turbulence.
|
||||
return d * d * (3 - 2 * d) * 1.2;
|
||||
}
|
||||
|
||||
/** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive),
|
||||
@@ -1360,9 +1468,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
|
||||
const count = liveBlobCount();
|
||||
const sizeBias = densitySizeBias(); // density dial → live size scale (Daniel #1, recomputed each step)
|
||||
const sizeBias = fluidSizeBias(); // fluid-amount dial → live size scale (Phase 10, recomputed each step)
|
||||
const heatScale = heatScaleFromDial(lavaHeat);
|
||||
const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN);
|
||||
// Gravity range remap (Phase 10 §3.3): the knob's full [0,1] travel now covers only the useful
|
||||
// 0%–75% of the old gravity span — the top quarter was too heavy (wax slammed down). So the dial
|
||||
// is scaled to 0.75 before mapping onto [MIN, MAX], keeping the low/mid feel and dropping the slam.
|
||||
const gravityDial = lavaGravity * 0.75;
|
||||
const gravity = GRAVITY_ACCEL_MIN + gravityDial * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN);
|
||||
const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD);
|
||||
const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD);
|
||||
const collideHardness = Math.min(Math.max(collisionStrength, 0), 1);
|
||||
@@ -1372,8 +1484,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const secondsPerHeight = visibleSeconds;
|
||||
const centreX = aspect * 0.5;
|
||||
// Match the shader's width-dialled ribbon so the collision boundary lines up with what
|
||||
// is drawn (R2 #8): a narrower waveform must also collide narrower.
|
||||
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * waveformWidth;
|
||||
// is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped
|
||||
// effective width as the uniform (Phase 10 §3.7) so the boundary never drifts from the ribbon.
|
||||
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * effectiveWaveformWidth();
|
||||
const playhead = effectivePlayhead();
|
||||
|
||||
const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS;
|
||||
@@ -1399,9 +1512,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt;
|
||||
b.temp = Math.min(Math.max(b.temp, 0), 1);
|
||||
|
||||
// Density → SIZE (Daniel #1): scale the blob's identity radius by the live density
|
||||
// bias EACH STEP, so turning the density dial visibly resizes already-live wax (the
|
||||
// "size" half is no longer baked at seed). r feeds the heat-shrink below and the
|
||||
// Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid-
|
||||
// amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the
|
||||
// "size" half is not baked at seed). r feeds the heat-shrink below and the
|
||||
// collisions/upload via er, so the dial moves the actual drawn + simulated size.
|
||||
b.r = b.r0 * sizeBias;
|
||||
|
||||
@@ -1493,15 +1606,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
|
||||
}
|
||||
|
||||
// UPWARD throw (Daniel #4): on top of the outward push, launch the bubble UP. The
|
||||
// ribbon only ever drives wax up+out (−y), never down, so loud transients toss
|
||||
// bubbles toward the surface. Scaled by penetration × hardness, so at low collision
|
||||
// strength it's ~0 (just mushed around) and at high strength it "throws" them up.
|
||||
b.vy -= WAVE_THROW_UP * penetration * dt * collideHardness;
|
||||
// UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob
|
||||
// bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the
|
||||
// accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a
|
||||
// sustained/deep overlap lifts firmly but never launches the bubble off-screen.
|
||||
const throwUp = Math.min(WAVE_THROW_UP * penetration * dt * collideHardness, WAVE_THROW_UP_MAX);
|
||||
b.vy -= throwUp;
|
||||
|
||||
// Positional push-out: partial at the soft end (wax squishes in then eases out via
|
||||
// the spring — Daniel #3 mushy), firm at the hard end (no deep penetration allowed).
|
||||
b.x += sideSign * penetration * (0.15 + 0.6 * collideHardness);
|
||||
// Positional push-out: always eject the wax fully out of the ribbon along the normal so
|
||||
// it can never lodge inside (Daniel "gets stuck"). The soft end eases it out gently
|
||||
// (mushy), the hard end snaps it clean — but both clear the boundary, so no stuck wax.
|
||||
b.x += sideSign * penetration * (0.5 + 0.5 * collideHardness);
|
||||
}
|
||||
|
||||
// ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ──
|
||||
@@ -1679,7 +1794,8 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
|
||||
// separate dirty-tracking needed for scalars/vec3s).
|
||||
gl.uniform1f(u.visibleSeconds, visibleSeconds);
|
||||
gl.uniform1f(u.waveformWidth, waveformWidth);
|
||||
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
|
||||
gl.uniform1f(u.cohesion, fluidViscosity);
|
||||
gl.uniform1f(u.gradientPhase, gradientPhase);
|
||||
gl.uniform3fv(u.colorNavy, theme.navy);
|
||||
gl.uniform3fv(u.colorMoss, theme.moss);
|
||||
@@ -1828,7 +1944,8 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
debugLog(
|
||||
`lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` +
|
||||
`collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} density=${blobDensity.toFixed(2)} | ` +
|
||||
`collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} ` +
|
||||
`fluidAmount=${fluidAmount.toFixed(2)} viscosity=${fluidViscosity.toFixed(2)} | ` +
|
||||
`blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
|
||||
`avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`,
|
||||
);
|
||||
@@ -1908,6 +2025,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Smooth the loudness contour at decode time so EXISTING mixes (stored before the C#-side
|
||||
// preprocessing smoothing) read as a smooth curve with no regeneration. Mutates `samples` in
|
||||
// place — both the GPU texture (below) and the CPU collision mirror (datum.samples) read it.
|
||||
smoothDatum(samples, sampleCount, durationSeconds);
|
||||
|
||||
// Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
|
||||
// under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
|
||||
// still clamp it to the actual max in case a driver reports something smaller.
|
||||
@@ -2048,12 +2170,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
// Blob density/size: drives BOTH halves live — count (liveBlobCount) AND size (densitySizeBias
|
||||
// applied to every blob's radius each physics step, Daniel #1). Turning it visibly resizes the
|
||||
// already-live wax, not just how many blobs there are.
|
||||
setBlobDensity(value: number): void {
|
||||
blobDensity = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setBlobDensity → ${blobDensity.toFixed(3)}.`);
|
||||
// Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount)
|
||||
// AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it
|
||||
// visibly adds/removes wax and resizes the already-live blobs.
|
||||
setFluidAmount(value: number): void {
|
||||
fluidAmount = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setFluidAmount → ${fluidAmount.toFixed(3)}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
// Fluid viscosity / cohesion (Phase 10 — second half of the split knob): drives the shader's
|
||||
// uCohesion, which scales the metaball smin blend + wobble. High = crisp spheres that snap back;
|
||||
// low = gooey/deformed wax. Uniform-only — no per-fragment cost change, weaker hardware unaffected.
|
||||
setFluidViscosity(value: number): void {
|
||||
fluidViscosity = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setFluidViscosity → ${fluidViscosity.toFixed(3)}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user