From a64a5598aed88a417723018d3a3213381026e5bb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 12:48:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(visualizer):=20R2=20lava=20tuning=20?= =?UTF-8?q?=E2=80=94=20flat=20fluid,=20melt,=20up+out=20throw,=20heat-driv?= =?UTF-8?q?en=20turbulence,=20waveform-width=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/MixVisualizerControls.razor | 21 +- .../Controls/MixWaveformVisualizer.razor.cs | 11 +- .../Services/MixVisualizerControlState.cs | 37 +- .../Interop/visualizer/MixVisualizer.ts | 333 +++++++++++++----- 4 files changed, 283 insertions(+), 119 deletions(-) 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();