diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
index 90e8a99..3716fd8 100644
--- a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
+++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
@@ -18,10 +18,13 @@
@* 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). *@
-
-
+
@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();
}
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs
index 483f498..2d6121e 100644
--- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs
+++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs
@@ -202,10 +202,12 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
///
- /// 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.
///
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);
}
///
diff --git a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
index 3f1731d..954367b 100644
--- a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
+++ b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
@@ -27,33 +27,42 @@ public sealed class MixVisualizerControlState
///
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.
///
/// Default GRAVITY dial (R2 temp; was bulge). Mirrors DEFAULT_BUBBLYNESS 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.
///
- public const double DefaultBubblyness = 0.5;
+ public const double DefaultBubblyness = 0.2;
///
/// Default HEAT dial (R2 temp; was detach). Mirrors DEFAULT_DETACH 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.
///
- public const double DefaultDetach = 0.45;
+ public const double DefaultDetach = 1.0;
///
/// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors
- /// DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts. Normalized [0,1]; 0 = soft shove,
- /// 1 = hard elastic wall.
+ /// DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts. Normalized [0,1]; 0 = soft mush,
+ /// 1 = hard elastic throw.
///
public const double DefaultColorShiftSpeed = 0.5;
+ ///
+ /// Default WAVEFORM-WIDTH dial (R2 temp; routed to the resolution/zoom knob this wave). Mirrors
+ /// DEFAULT_WAVEFORM_WIDTH 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.
+ ///
+ public const double DefaultWaveformWidth = 1.0;
+
/// Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
@@ -66,6 +75,12 @@ public sealed class MixVisualizerControlState
/// Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.
public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
+ ///
+ /// 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).
+ ///
+ public double WaveformWidth { get; set; } = DefaultWaveformWidth;
+
///
/// 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.
diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
index 171920a..ab1fb46 100644
--- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
+++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
@@ -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();