feat(visualizer): R2 lava tuning — flat fluid, melt, up+out throw, heat-driven turbulence, waveform-width knob

This commit is contained in:
daniel-c-harvey
2026-06-16 12:48:17 -04:00
parent 09309630cb
commit a64a5598ae
4 changed files with 283 additions and 119 deletions
@@ -18,10 +18,13 @@
<div class="mix-visualizer-controls">
@* RadialKnob exposes no aria-label/attribute-capture and is out of scope to modify, so the
accessible name rides on the wrapping group div instead (a plain element accepts it). *@
<div class="mix-visualizer-control" role="group" aria-label="Resolution (visible time-span)">
<RadialKnob Value="@ResolutionFraction"
ValueChanged="@OnResolutionChanged"
accessible name rides on the wrapping group div instead (a plain element accepts it).
R2 TEMP: this knob is repurposed from resolution/zoom to WAVEFORM WIDTH for in-browser lava
testing (scroll speed isn't critical for evaluating the lava). The on-screen icon still reads
ZoomIn; R4 redraws the controls and restores the resolution mapping. *@
<div class="mix-visualizer-control" role="group" aria-label="Waveform width (R2 temp: on the resolution knob)">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0"
Max="1"
Step="0.001"
@@ -66,13 +69,11 @@
</div>
@code {
// Resolution rides the log mapping (knob fraction [0,1] ↔ visible seconds); the other three are
// already normalized [0,1] and bind to their state properties directly.
private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds);
private void OnResolutionChanged(double fraction)
// R2 TEMP: the resolution knob is repurposed to WAVEFORM WIDTH (already normalized [0,1], binds
// directly). R4 restores the log zoom mapping (MixZoomMapping) and gives width its own knob.
private void OnWaveformWidthChanged(double value)
{
ControlState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction);
ControlState.WaveformWidth = value;
ControlState.NotifyChanged();
}
@@ -202,10 +202,12 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push all four control values to the module from the shared state. Used to seed on first render
/// and to re-push when the controls row signals a change. Resolution drives the scroll/zoom; the
/// other three are routed to the lava physics (gravity/heat/collision) by the JS handle in
/// Wave R2 (see MixVisualizer.ts). The bridge contract is unchanged.
/// Push the control values to the module from the shared state. Used to seed on first render and
/// to re-push when the controls row signals a change. In the Phase 10 reframe Wave R2 the four
/// live controls are routed to the lava physics by the JS handle (see MixVisualizer.ts):
/// Bubblyness→gravity, Detach→heat, ColorShiftSpeed→collision, and the repurposed resolution knob
/// (WaveformWidth)→waveform width. VisibleSeconds is still seeded once via setZoom so the window
/// holds at its default; the controls row no longer mutates it this wave. Bridge contract unchanged.
/// </summary>
private async Task PushControlsAsync()
{
@@ -214,6 +216,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness);
await _handle.InvokeVoidAsync("setDetach", ControlState.Detach);
await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
}
/// <summary>
@@ -27,33 +27,42 @@ public sealed class MixVisualizerControlState
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
// R2 TEMP (Phase 10 reframe Wave R2): the three controls below are re-routed to the new
// R2 TEMP (Phase 10 reframe Wave R2): the FOUR controls below are re-routed to the new
// lava physics for Daniel's in-browser test — the JS handle setters map them as:
// Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH.
// The defaults are bumped so the lava looks ALIVE on open (heat non-zero). Wave R4
// replaces this with the proper six-knob set + its own typed properties. Keep these
// Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH,
// Resolution (VisibleSeconds knob) → WAVEFORM WIDTH (see MixVisualizerControls.razor).
// The defaults are tuned to Daniel's sweet spot (~20% gravity, ~100% heat). Wave R4
// replaces this with the proper seven-knob set + its own typed properties. Keep these
// mirrored to the DEFAULT_* anchors in MixVisualizer.ts, as the existing sync discipline.
/// <summary>
/// Default GRAVITY dial (R2 temp; was bulge). Mirrors <c>DEFAULT_BUBBLYNESS</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast.
/// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's
/// ~20% sweet spot so the wax is buoyant-dominated and flows.
/// </summary>
public const double DefaultBubblyness = 0.5;
public const double DefaultBubblyness = 0.2;
/// <summary>
/// Default HEAT dial (R2 temp; was detach). Mirrors <c>DEFAULT_DETACH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = many bubbles rising.
/// Non-zero default so the lamp is alive on open.
/// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = lots of small turbulent
/// bubbles. Tuned to Daniel's ~100% sweet spot.
/// </summary>
public const double DefaultDetach = 0.45;
public const double DefaultDetach = 1.0;
/// <summary>
/// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors
/// <c>DEFAULT_COLOR_SHIFT_SPEED</c> in MixVisualizer.ts. Normalized [0,1]; 0 = soft shove,
/// 1 = hard elastic wall.
/// <c>DEFAULT_COLOR_SHIFT_SPEED</c> in MixVisualizer.ts. Normalized [0,1]; 0 = soft mush,
/// 1 = hard elastic throw.
/// </summary>
public const double DefaultColorShiftSpeed = 0.5;
/// <summary>
/// Default WAVEFORM-WIDTH dial (R2 temp; routed to the resolution/zoom knob this wave). Mirrors
/// <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts. Normalized [0,1]; 1 = full ribbon width
/// (prior look), lower narrows the band so the lava gets more room. Opens at full width.
/// </summary>
public const double DefaultWaveformWidth = 1.0;
/// <summary>Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
@@ -66,6 +75,12 @@ public sealed class MixVisualizerControlState
/// <summary>Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
/// <summary>
/// Waveform width, normalized [0,1]. R2 TEMP: routed to the resolution/zoom knob for in-browser
/// testing (Wave R4 gives it its own knob and restores the resolution knob to VisibleSeconds).
/// </summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected uniform(s). Mutators set the property then raise this; subscribers re-read the values.
@@ -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();