feat(visualizer): R2 lava tuning — flat fluid, melt, up+out throw, heat-driven turbulence, waveform-width knob
This commit is contained in:
@@ -54,30 +54,45 @@ export const DEFAULT_VISIBLE_SECONDS = 10;
|
||||
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are
|
||||
// normalized [0,1].
|
||||
//
|
||||
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper six-knob set):
|
||||
// the three existing control knobs are re-purposed to drive the new lava physics so
|
||||
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper seven-knob set):
|
||||
// the FOUR existing control knobs are re-purposed to drive the new lava physics so
|
||||
// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say
|
||||
// the old thing; the SETTERS below (setBubblyness/setDetach/setColorShiftSpeed) route
|
||||
// them to the new physics params. Mapping:
|
||||
// • "Detach" knob (Air icon) → lava HEAT
|
||||
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY
|
||||
// • "Color-shift" knob (Palette) → COLLISION STRENGTH
|
||||
// Blob DENSITY has no live knob this wave; it sits at DEFAULT_BLOB_DENSITY (R4 adds it).
|
||||
// The defaults below are chosen so the lava looks ALIVE on open (heat non-zero, mid
|
||||
// gravity, mid collision) — Daniel then tunes on screen.
|
||||
// the old thing; the SETTERS below route them to the new physics params. Mapping:
|
||||
// • "Detach" knob (Air icon) → lava HEAT (setDetach)
|
||||
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY (setBubblyness)
|
||||
// • "Color-shift" knob (Palette) → COLLISION STRENGTH (setColorShiftSpeed)
|
||||
// • "Resolution" knob (ZoomIn) → WAVEFORM WIDTH (setWaveformWidth) ← R2 NEW
|
||||
// The resolution/zoom knob is repurposed because scroll speed is not critical for
|
||||
// evaluating the lava: the controls row no longer mutates VisibleSeconds, so the window
|
||||
// holds at DEFAULT_VISIBLE_SECONDS (setZoom is still seeded once with that default).
|
||||
// Blob DENSITY has no live knob this wave; it sits at
|
||||
// DEFAULT_BLOB_DENSITY (R4 adds it). The defaults below are tuned to Daniel's sweet spot
|
||||
// (~20% gravity, ~100% heat) so the lava looks ALIVE and fluid on open — he then tunes
|
||||
// on screen. ALL of this temp wiring is removed in R4 for the real knob set.
|
||||
|
||||
/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness. Mid = a settled-but-mobile lamp. */
|
||||
export const DEFAULT_BUBBLYNESS = 0.5;
|
||||
/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness.
|
||||
* Tuned to Daniel's R2 sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
|
||||
export const DEFAULT_BUBBLYNESS = 0.2;
|
||||
|
||||
/** Default HEAT dial (was detach). Mirrors C# DefaultDetach. Non-zero so the lamp is alive on open. */
|
||||
export const DEFAULT_DETACH = 0.45;
|
||||
/** Default HEAT dial (was detach). Mirrors C# DefaultDetach.
|
||||
* Tuned to Daniel's R2 sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
|
||||
export const DEFAULT_DETACH = 1.0;
|
||||
|
||||
/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed. Mid soft↔hard. */
|
||||
/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed.
|
||||
* Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
|
||||
export const DEFAULT_COLOR_SHIFT_SPEED = 0.5;
|
||||
|
||||
/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */
|
||||
export const DEFAULT_BLOB_DENSITY = 0.4;
|
||||
|
||||
/**
|
||||
* Default WAVEFORM-WIDTH dial (R2 TEMP — mapped to the resolution/zoom knob for in-browser
|
||||
* test; R4 gives it its own knob). 1 = full ribbon width (the prior behaviour); lower values
|
||||
* narrow the waveform band so the lava fluid gets more room to move on loud songs. Mirrors C#
|
||||
* DefaultWaveformWidth. Opens at full width so the default look matches the prior ribbon.
|
||||
*/
|
||||
export const DEFAULT_WAVEFORM_WIDTH = 1.0;
|
||||
|
||||
/**
|
||||
* Where the "now" line sits within the window, as a fraction from the top.
|
||||
* 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
|
||||
@@ -166,12 +181,25 @@ const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "ho
|
||||
const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone"
|
||||
|
||||
/**
|
||||
* Viscous (linear) velocity damping per second — the lazy/high-viscosity regime that
|
||||
* makes it read as wax, not water (spec §4a). Applied as v *= exp(−DAMPING·dt) each
|
||||
* step, so it is frame-rate independent. High enough that motion is slow and gooey;
|
||||
* low enough that hot blobs still make the trip up.
|
||||
* Viscous (linear) velocity damping per second. Applied as v *= exp(−DAMPING·dt) each
|
||||
* step, so it is frame-rate independent.
|
||||
*
|
||||
* R2 tuning (Daniel #2 — "too viscous, needs to melt into a single fluid"): dropped from
|
||||
* 1.4 to 0.55 so the wax is markedly LESS stiff and flows together instead of holding as
|
||||
* distinct globs. Combined with the larger smin coalescence (BLOB_SMOOTHMIN_K below) and
|
||||
* the elastic throw, the surface now reads as a unified fluid body rather than separate
|
||||
* stiff blobs. Still > water so it stays a lazy lava, not a splash.
|
||||
*/
|
||||
const VISCOUS_DAMPING = 1.4;
|
||||
const VISCOUS_DAMPING = 0.55;
|
||||
|
||||
/**
|
||||
* Hard speed clamp in height-units/s, applied after every substep. R2 jitter fix (Daniel
|
||||
* #5): with the lower viscosity + higher elasticity, a deep overlap resolved by the elastic
|
||||
* impulse could occasionally fling a blob fast enough to tunnel and re-collide next step —
|
||||
* the buzz. Capping the per-axis speed keeps the integrator stable (no explosive feedback)
|
||||
* while being far above any speed real convection produces, so it never throttles the look.
|
||||
*/
|
||||
const MAX_BLOB_SPEED = 2.5;
|
||||
|
||||
/**
|
||||
* Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up
|
||||
@@ -188,9 +216,9 @@ const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor conta
|
||||
* endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the
|
||||
* hard end; the spring stiffness is the firmness of the soft end.
|
||||
*/
|
||||
const BLOB_COLLIDE_SPRING = 14.0; // soft penalty stiffness (height-units/s² per overlap)
|
||||
const BLOB_RESTITUTION_HARD = 0.9; // elastic restitution at strength = 1 (near-perfect bounce)
|
||||
const BLOB_RESTITUTION_SOFT = 0.15; // residual restitution at strength = 0 (mostly absorptive)
|
||||
const BLOB_COLLIDE_SPRING = 9.0; // soft penalty stiffness (height-units/s² per overlap)
|
||||
const BLOB_RESTITUTION_HARD = 1.15; // elastic restitution at strength = 1 — over-unity = the springy "throw" (Daniel #6)
|
||||
const BLOB_RESTITUTION_SOFT = 0.05; // residual restitution at strength = 0 (almost pure mush, Daniel #3)
|
||||
|
||||
/**
|
||||
* Blob↔waveform collision (always on, independent of heat — §5b). The waveform's
|
||||
@@ -200,9 +228,19 @@ const BLOB_RESTITUTION_SOFT = 0.15; // residual restitution at strength = 0 (mo
|
||||
* 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 = 20.0; // soft penalty stiffness pushing wax off the ribbon
|
||||
const WAVE_RESTITUTION_HARD = 0.85; // elastic reflection strength at full collision hardness
|
||||
const WAVE_RESTITUTION_SOFT = 0.1;
|
||||
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)
|
||||
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.
|
||||
*/
|
||||
const WAVE_THROW_UP = 26.0;
|
||||
|
||||
/**
|
||||
* Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a
|
||||
@@ -211,8 +249,31 @@ const WAVE_RESTITUTION_SOFT = 0.1;
|
||||
* slowly that frame, which is invisible. (We also sub-step within this cap below.)
|
||||
*/
|
||||
const PHYSICS_MAX_DT = 1 / 30;
|
||||
/** Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable. */
|
||||
const PHYSICS_SUBSTEPS = 2;
|
||||
/**
|
||||
* Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable.
|
||||
* R2 jitter fix (Daniel #5): raised 2 → 4. The lower viscosity + higher restitution make
|
||||
* the contact response stiffer relative to the step, so each frame's dt is now resolved in
|
||||
* four smaller passes — the collisions settle smoothly instead of buzzing. 4 × ≤32² pair
|
||||
* tests ≈ 4k/frame, still trivial, and the frame budget is untouched (FPS holds at 60).
|
||||
*/
|
||||
const PHYSICS_SUBSTEPS = 4;
|
||||
|
||||
/**
|
||||
* Energy-coupled dynamics (Daniel #7 — "at higher heat, bubbles are SMALLER and move with
|
||||
* MORE TURBULENCE"). Heat (the heatScale transfer output) drives two effects each step:
|
||||
*
|
||||
* • SIZE: a blob's effective radius shrinks toward HEAT_RADIUS_MIN_SCALE of its base radius
|
||||
* as it heats. High heat ⇒ a swarm of small lively bubbles; low heat ⇒ fewer, larger,
|
||||
* calmer wax. The shrink is applied to the SIMULATED radius (so collisions match what's
|
||||
* drawn) and tracks temperature continuously, so a blob grows back as it cools at the top.
|
||||
*
|
||||
* • TURBULENCE: a divergence-free-ish curl of value-noise injects a small random velocity
|
||||
* each step, scaled by heatScale × the blob's own temperature, so only HOT wax churns.
|
||||
* This is what makes high heat read as turbulent and low heat as a calm pool.
|
||||
*/
|
||||
const HEAT_RADIUS_MIN_SCALE = 0.45; // hottest blob shrinks to 45% of its base radius
|
||||
const TURBULENCE_ACCEL = 3.2; // peak turbulent accel (height-units/s²) at full heat × full temp
|
||||
const TURBULENCE_RATE = 1.9; // how fast the turbulence field evolves (rad/s scale)
|
||||
|
||||
/**
|
||||
* Playhead-correction smoothing time constant, in seconds. Governs how fast the
|
||||
@@ -408,6 +469,8 @@ export interface MixVisualizerHandle {
|
||||
setDetach(value: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */
|
||||
setColorShiftSpeed(value: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Resolution"/zoom knob to WAVEFORM WIDTH (R4 gives it its own knob). */
|
||||
setWaveformWidth(value: number): void;
|
||||
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
|
||||
refreshTheme(): void;
|
||||
dispose(): void;
|
||||
@@ -493,6 +556,7 @@ uniform vec2 uResolution; // canvas size in device pixels
|
||||
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)
|
||||
// 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;
|
||||
@@ -532,11 +596,14 @@ const float RIBBON_OPACITY_R2 = 0.62;
|
||||
// "necks" where two blobs merge are fatter → a gooier, more-connected wax that splits and
|
||||
// recombines (the organic non-circular look the spec wants, §4b). This + varied radii are
|
||||
// what kill the "giant disconnected circles" failure.
|
||||
const float BLOB_SMOOTHMIN_K = 0.045;
|
||||
// R2 (Daniel #2 — "melt into a single fluid"): raised 0.045 → 0.085 so neighbouring blobs
|
||||
// fuse into one continuous fluid body across wider gaps rather than reading as separate globs.
|
||||
const float BLOB_SMOOTHMIN_K = 0.085;
|
||||
|
||||
// smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax
|
||||
// reads as continuous with the ribbon surface rather than a disc sitting on a wall.
|
||||
const float WAVE_SMOOTHMIN_K = 0.03;
|
||||
// R2: raised 0.03 → 0.055 to match the fattier wax-union neck (continuous fluid surface).
|
||||
const float WAVE_SMOOTHMIN_K = 0.055;
|
||||
|
||||
// Low-frequency, blob-tied radius wobble: a slow per-blob breathing so each wax shape is
|
||||
// organic, not a perfect circle (§4b). This is FLUID-tied noise (keyed to blob identity +
|
||||
@@ -642,7 +709,9 @@ float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) {
|
||||
float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
|
||||
float amp = sampleAt(t); // loudness 0..1 at this row
|
||||
float centreX = aspect * 0.5; // canvas centre x in height-norm units
|
||||
float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC; // ribbon half-width here
|
||||
// Ribbon half-width here, scaled by the waveform-width dial (R2 #8): at width 1 it is the
|
||||
// full prior band; lower narrows it so the lava fluid gets more room on loud songs.
|
||||
float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * uWaveformWidth;
|
||||
// Distance to a centred vertical band of half-width halfW: |x − centre| − halfW.
|
||||
// Negative inside the band, positive outside. (A pure horizontal band; the vertical
|
||||
// extent is the whole column, which is what the scrolling ribbon is.)
|
||||
@@ -708,43 +777,39 @@ void main() {
|
||||
float nowYn = NOW_ANCHOR_FROM_TOP; // now-line, height-norm (y ∈ [0,1])
|
||||
float secondsPerHeight = uVisibleSeconds; // one full height spans uVisibleSeconds
|
||||
|
||||
// ── Evaluate the combined liquid SDF + its gradient (the surface normal). ──────────
|
||||
// Central differences in height-norm space; the step is one device pixel = 1/h.
|
||||
// ── Evaluate the combined liquid SDF only (no normal/gradient shading this wave). ──
|
||||
// R2 cone fix (Daniel #1): the previous build derived a surface NORMAL from the SDF
|
||||
// gradient and shaded the fill by it (mix(0.82, 1.12, lightUp)). On a metaball the
|
||||
// gradient points outward from each blob centre, so that shading lit a bright spot at
|
||||
// every centre and darkened the rims — the wax read as a CONE with a pointed bright tip,
|
||||
// not a flat fluid surface. We DROP the normal shading entirely: a metaball / fluid
|
||||
// surface is FLAT, distinguished only by a soft anti-aliased edge. (Form/colour is the
|
||||
// Wave R3 job; here we only flatten.) This also removes the 4 extra SDF evaluations the
|
||||
// central-difference gradient cost — a small frame-budget win.
|
||||
float hot;
|
||||
float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot);
|
||||
|
||||
float e = 1.0 / h; // one-pixel step in height-norm units
|
||||
float ig;
|
||||
float dRx = liquidSdf(p + vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig);
|
||||
float dLx = liquidSdf(p - vec2(e, 0.0), aspect, nowYn, secondsPerHeight, ig);
|
||||
float dDy = liquidSdf(p + vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig);
|
||||
float dUy = liquidSdf(p - vec2(0.0, e), aspect, nowYn, secondsPerHeight, ig);
|
||||
vec2 grad = vec2(dRx - dLx, dDy - dUy);
|
||||
vec2 normal = length(grad) > 1e-5 ? normalize(grad) : vec2(0.0, -1.0);
|
||||
|
||||
// Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an
|
||||
// anti-aliased edge instead of a hard chart line (no blur — spec §2/§3).
|
||||
// anti-aliased edge instead of a hard chart line (no blur — spec §2/§3). This soft edge
|
||||
// is the ONLY shading on the surface now — the body is flat.
|
||||
float pxFeather = 1.2 / h;
|
||||
float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d);
|
||||
if (inside <= 0.0) { fragColor = vec4(0.0); return; }
|
||||
|
||||
// ── Simple serviceable theme fill (R3 replaces with the OKLab three-colour gradient).
|
||||
// ── Simple serviceable FLAT theme fill (R3 replaces with the OKLab three-colour gradient).
|
||||
// Linear A→B from the centre line outward: NAVY (uColorEdge) at the root, MOSS
|
||||
// (uColorAccent) at the extended edge. Just enough colour to read the physics; NOT the
|
||||
// final colour model. No HSL, no vivify, no glass — those are gone (R3 owns colour).
|
||||
// (uColorAccent) at the extended edge. This horizontal ramp is a gentle field gradient
|
||||
// across the whole canvas, NOT a per-blob radial — so the fluid surface reads flat. Just
|
||||
// enough colour to read the physics; NOT the final colour model. No glass, no per-blob
|
||||
// shading (R3 owns colour).
|
||||
float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0);
|
||||
vec3 fill = mix(uColorEdge, uColorAccent, xnAbs);
|
||||
|
||||
// Warm tint on hot, rising wax so the eye reads convection (serviceable, R3-subordinate).
|
||||
// A flat per-blob temperature lean — no spatial falloff, so it does not reintroduce a cone.
|
||||
float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT;
|
||||
fill = mix(fill, HOT_TINT, hotLean);
|
||||
|
||||
// A soft top-light shade off the surface normal so the wax has form (a single lazy
|
||||
// gradient, not the old four-part glass). Keeps it from reading flat without competing
|
||||
// with the (future) colour model.
|
||||
float lightUp = clamp(dot(normal, vec2(0.0, -1.0)) * 0.5 + 0.5, 0.0, 1.0);
|
||||
fill *= mix(0.82, 1.12, lightUp);
|
||||
|
||||
float alpha = inside * RIBBON_OPACITY_R2;
|
||||
fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_ALPHA
|
||||
}
|
||||
@@ -793,6 +858,7 @@ function noopHandle(): MixVisualizerHandle {
|
||||
setBubblyness() {},
|
||||
setDetach() {},
|
||||
setColorShiftSpeed() {},
|
||||
setWaveformWidth() {},
|
||||
refreshTheme() {},
|
||||
dispose() {},
|
||||
};
|
||||
@@ -849,6 +915,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
|
||||
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
|
||||
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
|
||||
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
|
||||
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
|
||||
colorAccent: gl.getUniformLocation(program, 'uColorAccent'),
|
||||
colorEdge: gl.getUniformLocation(program, 'uColorEdge'),
|
||||
@@ -877,6 +944,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity
|
||||
let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness
|
||||
let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it)
|
||||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // "Resolution" knob → ribbon width (R2 TEMP, R4 own knob)
|
||||
|
||||
/**
|
||||
* The *authoritative* playhead for this instant: the last pushed position advanced
|
||||
@@ -978,8 +1046,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
interface Blob {
|
||||
x: number; y: number; // centre, height-norm
|
||||
vx: number; vy: number; // velocity, height-norm/s
|
||||
r: number; // radius, height-norm (fixed per blob, density-biased)
|
||||
r: number; // BASE radius, height-norm (fixed per blob, density-biased)
|
||||
er: number; // EFFECTIVE radius this step = r shrunk by heat (Daniel #7); used by
|
||||
// collisions AND uploaded to the shader so the two always agree
|
||||
temp: number; // temperature 0..1
|
||||
noiseSeed: number; // fixed per-blob phase offset so each blob's turbulence is decorrelated
|
||||
}
|
||||
|
||||
// The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS,
|
||||
@@ -1007,18 +1078,20 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const radiusBias = 1 - blobDensity * 0.6; // density 0 → big, density 1 → smaller
|
||||
const r = (BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN)) * radiusBias;
|
||||
b.r = r;
|
||||
b.er = r; // starts at full size (cool); shrinks as it heats
|
||||
b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width
|
||||
b.y = 1 - r - rng() * 0.1; // pooled near the floor
|
||||
b.vx = 0;
|
||||
b.vy = 0;
|
||||
b.temp = rng() * 0.3; // cool to start (heats at the floor)
|
||||
b.noiseSeed = rng() * 100; // decorrelate this blob's turbulence field
|
||||
}
|
||||
|
||||
/** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */
|
||||
function initBlobs(aspect: number): void {
|
||||
blobs.length = 0;
|
||||
for (let i = 0; i < MAX_BLOBS; i++) {
|
||||
const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, temp: 0 };
|
||||
const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 };
|
||||
seedBlob(b, aspect);
|
||||
blobs.push(b);
|
||||
}
|
||||
@@ -1066,10 +1139,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
return soft + (hard - soft) * d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap continuous 1-D-ish noise in [−1,1] for the turbulence field (Daniel #7). A pair of
|
||||
* out-of-phase sines is enough for an organic, smoothly-evolving churn — far cheaper than a
|
||||
* lattice value-noise and we only need it twice per hot blob per substep. Decorrelated per
|
||||
* blob via the seed so neighbouring bubbles don't churn in lock-step.
|
||||
*/
|
||||
function turbNoise(seed: number, t: number): number {
|
||||
return Math.sin(seed + t) * 0.6 + Math.sin(seed * 1.7 + t * 0.53) * 0.4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the physics by dt seconds. Sub-stepped for spring stability. The collision
|
||||
* model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon
|
||||
* surface normal, always on), blob↔blob (elastic, soft↔hard via the strength dial).
|
||||
* surface normal + an upward throw, always on), blob↔blob (elastic, soft↔hard via the
|
||||
* strength dial). Heat shrinks each blob's effective radius and injects turbulence so
|
||||
* high heat reads as a swarm of small lively bubbles (Daniel #7).
|
||||
*/
|
||||
function stepPhysics(dtTotal: number): void {
|
||||
if (canvas.height <= 0) return;
|
||||
@@ -1091,13 +1176,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const nowYn = NOW_ANCHOR_FROM_TOP;
|
||||
const secondsPerHeight = visibleSeconds;
|
||||
const centreX = aspect * 0.5;
|
||||
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC;
|
||||
// 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;
|
||||
const playhead = effectivePlayhead();
|
||||
|
||||
const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS;
|
||||
// Wall-clock seconds for the turbulence field (separate from the playhead/scroll).
|
||||
const turbTime = (performance.now() - startTimeMs) / 1000 * TURBULENCE_RATE;
|
||||
|
||||
for (let s = 0; s < PHYSICS_SUBSTEPS; s++) {
|
||||
// ── Per-blob: heat exchange, buoyancy, gravity, damping, floor contact. ──
|
||||
// ── Per-blob: heat exchange, size, buoyancy, gravity, turbulence, damping, floor. ──
|
||||
for (let i = 0; i < count; i++) {
|
||||
const b = blobs[i];
|
||||
|
||||
@@ -1115,21 +1204,45 @@ 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);
|
||||
|
||||
// Energy → SIZE (Daniel #7): the hotter the wax, the smaller it shrinks. Driven by
|
||||
// heatScale × temperature so it only shrinks when the lamp is actually hot AND this
|
||||
// blob is hot — at heat 0 every blob stays full size. Tracks temp continuously, so a
|
||||
// bubble grows back as it cools near the top. Effective radius feeds both collision
|
||||
// and the upload, so the sim and the render never disagree.
|
||||
const shrink = 1 - heatScale * b.temp * (1 - HEAT_RADIUS_MIN_SCALE);
|
||||
b.er = b.r * shrink;
|
||||
|
||||
// Forces: gravity down (+y), buoyancy from temperature (up = −y when hot).
|
||||
const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT);
|
||||
b.vy += (gravity - buoyancy) * dt;
|
||||
|
||||
// Energy → TURBULENCE (Daniel #7): a small churn injected into hot wax only, scaled
|
||||
// by heatScale × temperature. High heat ⇒ lots of small bubbles jittering with life;
|
||||
// low heat ⇒ a calm pool. Two decorrelated noise reads give an x/y churn vector.
|
||||
const turbAmp = TURBULENCE_ACCEL * heatScale * b.temp;
|
||||
if (turbAmp > 0) {
|
||||
b.vx += turbNoise(b.noiseSeed, turbTime) * turbAmp * dt;
|
||||
b.vy += turbNoise(b.noiseSeed + 50.0, turbTime) * turbAmp * dt;
|
||||
}
|
||||
|
||||
// Viscous damping (lazy wax): frame-rate-independent exponential decay.
|
||||
const damp = Math.exp(-VISCOUS_DAMPING * dt);
|
||||
b.vx *= damp;
|
||||
b.vy *= damp;
|
||||
|
||||
// Velocity clamp (Daniel #5 jitter fix): keep the integrator stable under the
|
||||
// lower viscosity + over-unity restitution so a deep overlap can't fling a blob
|
||||
// fast enough to tunnel and re-collide (the buzz). Far above real convection speed.
|
||||
if (b.vx > MAX_BLOB_SPEED) b.vx = MAX_BLOB_SPEED; else if (b.vx < -MAX_BLOB_SPEED) b.vx = -MAX_BLOB_SPEED;
|
||||
if (b.vy > MAX_BLOB_SPEED) b.vy = MAX_BLOB_SPEED; else if (b.vy < -MAX_BLOB_SPEED) b.vy = -MAX_BLOB_SPEED;
|
||||
|
||||
// Integrate position.
|
||||
b.x += b.vx * dt;
|
||||
b.y += b.vy * dt;
|
||||
|
||||
// Boundaries use the EFFECTIVE radius so shrunk hot bubbles sit correctly.
|
||||
// Floor: soft contact spring + extra damping so resting wax pools and flattens.
|
||||
const floorY = 1 - b.r;
|
||||
const floorY = 1 - b.er;
|
||||
if (b.y > floorY) {
|
||||
const pen = b.y - floorY;
|
||||
b.vy -= FLOOR_SPRING * pen * dt; // spring pushes up out of the floor
|
||||
@@ -1138,11 +1251,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
// Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools
|
||||
// at the top and falls back; just keep it inside the box.
|
||||
const ceilY = b.r;
|
||||
const ceilY = b.er;
|
||||
if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; }
|
||||
// Side walls: reflect softly so wax stays on screen.
|
||||
if (b.x < b.r) { b.x = b.r; if (b.vx < 0) b.vx = -b.vx * 0.3; }
|
||||
if (b.x > aspect - b.r) { b.x = aspect - b.r; if (b.vx > 0) b.vx = -b.vx * 0.3; }
|
||||
if (b.x < b.er) { b.x = b.er; if (b.vx < 0) b.vx = -b.vx * 0.3; }
|
||||
if (b.x > aspect - b.er) { b.x = aspect - b.er; if (b.vx > 0) b.vx = -b.vx * 0.3; }
|
||||
}
|
||||
|
||||
// ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ──
|
||||
@@ -1158,60 +1271,78 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const halfW = amp * maxHalf;
|
||||
const dx = b.x - centreX;
|
||||
const sideSign = dx >= 0 ? 1 : -1; // outward surface normal (in x)
|
||||
const penetration = halfW + b.r - Math.abs(dx);
|
||||
if (penetration > 0) {
|
||||
// Soft penalty (the soft end of the dial): a spring proportional to the
|
||||
// penetration depth pushes the wax out along the normal. Stronger as the
|
||||
// dial → soft so the soft regime still recovers, just gently.
|
||||
b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness * 0.5);
|
||||
const penetration = halfW + b.er - Math.abs(dx);
|
||||
if (penetration <= 0) continue;
|
||||
|
||||
// Hard elastic (the hard end): reflect the velocity component going INTO
|
||||
// the ribbon back out, scaled by restitution × hardness. inwardSpeed > 0
|
||||
// means the blob is moving toward the centre line (into the surface).
|
||||
const inwardSpeed = -sideSign * b.vx;
|
||||
if (inwardSpeed > 0) {
|
||||
// Remove the inward component and add back a restituted outward one.
|
||||
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
|
||||
}
|
||||
// Capture the inward velocity ONCE up front (Daniel #5 jitter fix). The prior
|
||||
// build read b.vx for the elastic term AFTER the spring had already mutated it,
|
||||
// so the spring and reflection fought each other within one pass — a buzz source.
|
||||
// Now each contribution reads the same pre-collision state and they sum cleanly.
|
||||
const inwardSpeed = -sideSign * b.vx; // >0 means moving toward the centre line
|
||||
|
||||
// Positional push-out: firm at the hard end (no penetration allowed),
|
||||
// partial at the soft end (wax squishes in then eases out via the spring).
|
||||
b.x += sideSign * penetration * (0.3 + 0.7 * collideHardness);
|
||||
// Soft penalty spring (soft end of the dial): a gentle outward shove proportional
|
||||
// to penetration. Softened for R2 (Daniel #3) so the low end genuinely mushes the
|
||||
// wax around. The (1 − hardness) factor hands the work to the elastic term as the
|
||||
// dial climbs, so we never double-drive at the hard end.
|
||||
b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness);
|
||||
|
||||
// Hard elastic reflection (hard end): bounce the inward velocity back out, scaled
|
||||
// by restitution × hardness (over-unity restitution at the top = the springy throw).
|
||||
if (inwardSpeed > 0) {
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ──
|
||||
// O(count²) ≤ ~1k pair tests — trivial. Mass ∝ r² so big blobs shove small ones.
|
||||
// O(count²) ≤ ~1k pair tests — trivial. Mass ∝ er² so big blobs shove small ones, and
|
||||
// hot shrunk bubbles are correspondingly lighter (Daniel #7). Geometry uses effective
|
||||
// radii so collisions match what's drawn.
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = blobs[i];
|
||||
for (let j = i + 1; j < count; j++) {
|
||||
const c = blobs[j];
|
||||
let dx = c.x - a.x;
|
||||
let dy = c.y - a.y;
|
||||
let dist = Math.hypot(dx, dy);
|
||||
const minDist = a.r + c.r;
|
||||
const dx = c.x - a.x;
|
||||
const dy = c.y - a.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const minDist = a.er + c.er;
|
||||
if (dist >= minDist || dist <= 1e-6) continue;
|
||||
|
||||
const nx = dx / dist, ny = dy / dist; // collision normal a→c
|
||||
const overlap = minDist - dist;
|
||||
const ma = a.r * a.r, mc = c.r * c.r; // mass ∝ area
|
||||
const ma = a.er * a.er, mc = c.er * c.er; // mass ∝ area
|
||||
const invSum = 1 / (ma + mc);
|
||||
|
||||
// Capture the approach velocity ONCE from pre-collision state (Daniel #5 jitter
|
||||
// fix): the prior build read the relative velocity for the elastic impulse AFTER
|
||||
// the penalty spring had already mutated it, so spring and impulse fought within
|
||||
// one pass — buzz. Now both read the same state and sum cleanly.
|
||||
const velAlongNormal = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny;
|
||||
|
||||
// Positional separation along the normal, mass-weighted (split the overlap).
|
||||
const sep = overlap * (0.3 + 0.7 * collideHardness);
|
||||
// Soft at low strength (Daniel #3: gentle, blobs squish and overlap), firm at high.
|
||||
const sep = overlap * (0.15 + 0.6 * collideHardness);
|
||||
a.x -= nx * sep * (mc * invSum);
|
||||
a.y -= ny * sep * (mc * invSum);
|
||||
c.x += nx * sep * (ma * invSum);
|
||||
c.y += ny * sep * (ma * invSum);
|
||||
|
||||
// Soft penalty spring along the normal (gentle shove, low strength).
|
||||
const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness * 0.6) * dt;
|
||||
// Soft penalty spring along the normal (gentle shove, dominant at the soft end).
|
||||
const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness) * dt;
|
||||
a.vx -= nx * springAcc; a.vy -= ny * springAcc;
|
||||
c.vx += nx * springAcc; c.vy += ny * springAcc;
|
||||
|
||||
// Elastic impulse along the normal (hard end), with restitution + mass.
|
||||
const rvx = c.vx - a.vx, rvy = c.vy - a.vy;
|
||||
const velAlongNormal = rvx * nx + rvy * ny;
|
||||
// Elastic impulse along the normal (hard end), with restitution + mass. Over-unity
|
||||
// restitution at full hardness gives the springy throw (Daniel #6).
|
||||
if (velAlongNormal < 0) { // approaching
|
||||
const e = collideRest * collideHardness;
|
||||
const impulse = -(1 + e) * velAlongNormal * invSum;
|
||||
@@ -1231,7 +1362,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const o = i * 4;
|
||||
blobUpload[o] = b.x;
|
||||
blobUpload[o + 1] = b.y;
|
||||
blobUpload[o + 2] = b.r;
|
||||
blobUpload[o + 2] = b.er; // effective (heat-shrunk) radius — matches the collision geometry
|
||||
blobUpload[o + 3] = b.temp;
|
||||
}
|
||||
return count;
|
||||
@@ -1335,6 +1466,7 @@ 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.uniform3fv(u.colorAccent, theme.accent);
|
||||
gl.uniform3fv(u.colorEdge, theme.edge);
|
||||
|
||||
@@ -1467,16 +1599,19 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let buoyant = 0;
|
||||
let pooled = 0;
|
||||
let avgTemp = 0;
|
||||
let avgShrink = 0; // mean effective/base radius ratio — shows the heat→size coupling
|
||||
for (let i = 0; i < live; i++) {
|
||||
const b = blobs[i];
|
||||
avgTemp += b.temp;
|
||||
avgShrink += b.r > 0 ? b.er / b.r : 1;
|
||||
if (b.temp > TEMP_AMBIENT) buoyant++;
|
||||
if (b.y > 1 - b.r - 0.04) pooled++;
|
||||
if (b.y > 1 - b.er - 0.04) pooled++;
|
||||
}
|
||||
debugLog(
|
||||
`lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` +
|
||||
`collision=${collisionStrength.toFixed(2)} density=${blobDensity.toFixed(2)} | ` +
|
||||
`blobs=${live} buoyant=${buoyant} pooled=${pooled} avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)}.`,
|
||||
`collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} density=${blobDensity.toFixed(2)} | ` +
|
||||
`blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
|
||||
`avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`,
|
||||
);
|
||||
fpsFrameCount = 0;
|
||||
fpsWindowStartMs = nowMs;
|
||||
@@ -1678,6 +1813,16 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
},
|
||||
|
||||
// R2 TEMP: the resolution/zoom knob is repurposed to the waveform-width param this wave
|
||||
// (scroll speed isn't critical for evaluating the lava). The bridge calls this with the
|
||||
// raw knob fraction [0,1]; 1 = full ribbon, lower narrows the band. R4 gives width its
|
||||
// own knob and restores the resolution knob to setZoom.
|
||||
setWaveformWidth(value: number): void {
|
||||
waveformWidth = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setWaveformWidth (via resolution knob) → ${waveformWidth.toFixed(3)}.`);
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
},
|
||||
|
||||
refreshTheme(): void {
|
||||
theme = readTheme();
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
|
||||
Reference in New Issue
Block a user