@@ -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 th is 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 (al most 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 her e
// Ribbon half-width here, scaled by the waveform-width dial (R2 #8): at width 1 it is th e
// 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 h ard 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 surfa ce) .
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 inw ard 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 sour ce.
// 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 penetra tion allowed),
// partial at the s oft 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 propor tional
// to penetration. S oftened 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 ∝ e r² 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 <= 1 e - 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 ( ) ;