From cccdf1d11ca5fd5b2785b542c0c9c1f328ba62cf Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 14:42:39 +0100 Subject: [PATCH] Make the liquid drop wobble like jelly and jump on prominent hits The terraces, snare spikes and fine ripples creased the surface into jagged corners. The drop now always moves in broad, smooth lobes, and the strongest kick and bass hits (weighed by prominence against the last seconds) throw it up on a spring: it stretches as it rises, squashes as it lands and jiggles back. Snares pump its size. It also swells in from the flat artwork more slowly. --- web/src/blob/blob.ts | 55 ++++++++++++++++++++++++++++++++++------- web/src/blob/shaders.ts | 30 +++++++++++----------- web/src/blob/style.ts | 24 +++++++++--------- web/src/features.ts | 18 ++++++++++++-- web/src/style.css | 6 ++--- 5 files changed, 92 insertions(+), 41 deletions(-) diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index 5a45886..c47d07e 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -9,9 +9,18 @@ import type { Look } from "./style"; const FOV = (30 * Math.PI) / 180; /// The canvas is this much larger than the art slot, so the liquid can spill out. -export const SPILL = 1.5; +export const SPILL = 1.8; const DIST = SPILL / Math.tan(FOV / 2); -const MORPH_IN = 0.7; // s from flat artwork to liquid +const MORPH_IN = 1.4; // s from flat artwork to liquid +// The jump: a spring that each prominent kick or bass hit kicks upwards. Hits are +// weighed by prominence squared, so only the strongest really make it jump. +const JUMP = 1.9; // upward speed of a full-strength hit, in radii per second +const JUMP_DAMPING = 7; // 1/s; low enough that it jiggles a few times as it lands +const MAX_LIFT = 0.16; // radii +const STRETCH = 0.08; // stretch per unit of speed: long while flying, squashed on landing +const PUMP = 0.7; // size kick of a snare hit +const PUMP_STIFF = 160; +const PUMP_DAMPING = 12; const TEXTURE_SIZE = 512; const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR … const SLOW_FOR = 2; // … seconds step the quality down @@ -19,8 +28,8 @@ const SLOW_FOR = 2; // … seconds step the quality down const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]]; const UNIFORMS = [ - "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", - "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uPointer", + "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uDrop", + "uGain", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", ] as const; @@ -44,6 +53,9 @@ export class LiquidBlob { private pointer = [0, 0, 1, 0]; private pointerGoal = [0, 0, 1, 0]; private slow = 0; + /// Springs, position and speed: the jump (upwards) and the pump (size). + private jump = [0, 0]; + private pump = [0, 0]; /// Null where WebGL2 is not available. static create(touch: boolean): LiquidBlob | null { @@ -162,11 +174,12 @@ export class LiquidBlob { gl.viewport(0, 0, w, h); this.morph = Math.min(1, this.morph + dt / MORPH_IN); - const morph = 1 - (1 - this.morph) ** 3; + const morph = this.morph * this.morph * (3 - 2 * this.morph); // The liquid flows with the tempo, faster when the music moves more. this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25); const k = 1 - Math.exp(-dt / 0.15); for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k; + const moving = this.bounce(f, dt, look); const u = this.u; gl.uniform1f(u.uTime, this.time); @@ -175,11 +188,8 @@ export class LiquidBlob { gl.uniform1f(u.uWobble, f.wobble); gl.uniform1f(u.uKick, f.kick); gl.uniform1f(u.uKickAge, f.kickAge); - gl.uniform1f(u.uSnare, f.snare); gl.uniform1f(u.uDrop, f.drop); gl.uniform1f(u.uGain, look.gain); - gl.uniform1f(u.uSpike, look.spike); - gl.uniform1f(u.uStep, look.step); gl.uniform1f(u.uTwist, look.twist); gl.uniform1f(u.uRipple, look.ripple); gl.uniform1f(u.uShock, look.shock); @@ -193,7 +203,34 @@ export class LiquidBlob { gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.bindVertexArray(this.vao); gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); - return this.morph < 1 || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; + return this.morph < 1 || moving || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; + } + + /// Step the springs and set the jump uniforms. True while they still move. + private bounce(f: Features, dt: number, look: Look): boolean { + const [jump, pump] = [this.jump, this.pump]; + if (f.kickHit) jump[1] += JUMP * look.bounce * f.kickHit ** 2; + if (f.snareHit) pump[1] += PUMP * look.bounce * f.snareHit ** 2; + // The size also follows the bass and the drop, through the same spring. + const pumpRest = 0.04 * f.low + 0.05 * f.drop; + // Small fixed steps keep the springs stable on slow frames. + for (let left = Math.min(dt, 0.1); left > 0; left -= 1 / 240) { + const h = Math.min(left, 1 / 240); + jump[1] += (-look.stiff * jump[0] - JUMP_DAMPING * jump[1]) * h; + jump[0] += jump[1] * h; + pump[1] += (-PUMP_STIFF * (pump[0] - pumpRest) - PUMP_DAMPING * pump[1]) * h; + pump[0] += pump[1] * h; + } + jump[0] = Math.min(MAX_LIFT, Math.max(-MAX_LIFT / 2, jump[0])); + // Long while it flies up, squashed as it comes down and lands. + const stretch = 1 + Math.min(0.18, Math.max(-0.15, jump[1] * STRETCH + Math.min(0, jump[0]) * 1.2)); + const size = 1 + pump[0]; + const side = size / Math.sqrt(stretch); + const u = this.u; + this.gl.uniform3f(u.uScale, side, size * stretch, side); + this.gl.uniform1f(u.uLift, Math.max(0, jump[0])); + this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6)); + return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002; } /// Step the quality down when frames keep coming in slow. diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index bf89b8f..dc6e84f 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -64,9 +64,13 @@ uniform float uDist; // camera distance uniform float uTime; // flow time, runs with the tempo uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid // Music, see features.ts. -uniform float uLevel, uWobble, uKick, uKickAge, uSnare, uDrop; +uniform float uLevel, uWobble, uKickAge, uDrop; // Character of the track, see style.ts. -uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock; +uniform float uGain, uTwist, uRipple, uShock; +// The jump on prominent hits, from the springs in blob.ts. +uniform vec3 uScale; // squash and stretch +uniform float uLift; // upwards +uniform float uJelly; // extra wobble while a jump rings out, 0..1 uniform vec4 uPointer; // direction on the sphere, strength out vec3 vPos; // view space @@ -75,24 +79,20 @@ out vec2 vFlat; // front projection of the resting sphere, for the artwork ${NOISE} +// Only broad, smooth shapes: the drop always wobbles like jelly and never creases. float disp(vec3 p) { vec3 q = p; float a = uTwist * (p.y + 0.3 * sin(uTime)) * (0.5 + uWobble); q.xz = mat2(cos(a), -sin(a), sin(a), cos(a)) * q.xz; float t = uTime; - float lobes = snoise(q * 1.1 + vec3(0.0, t * 0.35, t * 0.2)) * (0.05 + 0.12 * uLevel + 0.12 * uDrop); - // Terraces with soft edges: stiff, but no torn shards. - float steps = lobes * 10.0; - lobes = mix(lobes, (floor(steps) + smoothstep(0.3, 0.7, fract(steps))) / 10.0, uStep); - float ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.07 * uWobble) * uRipple; - float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9)); - float spikes = pow(max(n, 0.0), 3.0) * (0.06 + 0.35 * uSnare + 0.15 * uDrop) * uSpike; - // After each kick a ring runs from the front of the drop round to the back. + float body = snoise(q * 0.9 + vec3(0.0, t * 0.35, t * 0.2)) * (0.11 + 0.05 * uLevel + 0.07 * uDrop + 0.1 * uJelly); + float sway = snoise(q * 1.7 - vec3(t * 0.5, 0.0, t * 0.3)) * (0.035 + 0.03 * uWobble + 0.05 * uJelly); + float ripple = snoise(q * 2.6 + vec3(t * 1.1, -t * 0.8, 0.0)) * 0.02 * uWobble * uRipple; + // After each kick a broad wave runs from the front of the drop round to the back. float angle = acos(clamp(p.z, -1.0, 1.0)); - float ring = exp(-pow((angle - uKickAge * 6.0) * 2.5, 2.0)) * exp(-uKickAge * 3.0) * 0.14 * uShock; - float swell = uKick * 0.05; + float wave = exp(-pow((angle - uKickAge * 4.5) * 1.3, 2.0)) * exp(-uKickAge * 2.5) * 0.06 * uShock; float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); - return clamp((lobes + ripples + spikes + ring + swell) * uGain + dent, -0.3, 0.42); + return clamp((body + sway + ripple + wave) * uGain + dent, -0.3, 0.32); } vec3 shape(vec3 p) { @@ -110,8 +110,8 @@ void main() { vec3 s = shape(p); vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s)); if (dot(n, p) < 0.0) n = -n; - vPos = s - vec3(0.0, 0.0, uDist); - vNormal = n; + vPos = s * uScale + vec3(0.0, uLift, -uDist); + vNormal = normalize(n / uScale); vFlat = p.xy; gl_Position = uProj * vec4(vPos, 1.0); } diff --git a/web/src/blob/style.ts b/web/src/blob/style.ts index b49563c..19065fb 100644 --- a/web/src/blob/style.ts +++ b/web/src/blob/style.ts @@ -8,12 +8,12 @@ export interface Look { flow: number; /// Overall size of the movement. gain: number; - /// Spikes on the snare. - spike: number; - /// 0 smooth, 1 stepped (stiff, quantised) lobes. - step: number; + /// How high prominent hits make the drop jump. + bounce: number; + /// Spring stiffness of the jump: higher snaps back faster, lower floats. + stiff: number; twist: number; - /// Fast ripples from the wobble. + /// Fine ripples from the wobble. ripple: number; /// Ring that runs over the surface on each kick. shock: number; @@ -25,17 +25,17 @@ export interface Look { hue: number; } -const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 }; +const BASE: Look = { flow: 1, gain: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 }; const TAGS: Record void> = { - riddim: (l) => Object.assign(l, { step: 0.8, metal: 0.9, shock: 1.4, spike: 0.6, ripple: 0.7 }), - tearout: (l) => Object.assign(l, { spike: 1.4, gain: l.gain * 1.25, heat: 0.7 }), - deathstep: (l) => Object.assign(l, { spike: 1.5, gain: l.gain * 1.25, heat: 0.9, metal: 0.7 }), - melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }), - colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, metal: 0.15 }), + riddim: (l) => Object.assign(l, { bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 0.7 }), + tearout: (l) => Object.assign(l, { bounce: 1.4, gain: l.gain * 1.2, heat: 0.7 }), + deathstep: (l) => Object.assign(l, { bounce: 1.4, gain: l.gain * 1.2, heat: 0.9, metal: 0.7 }), + melodic: (l) => Object.assign(l, { iri: 0.8, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }), + colourbass: (l) => Object.assign(l, { iri: 1, bounce: 0.8, stiff: 70, metal: 0.15 }), dnb: (l) => Object.assign(l, { ripple: 1.6 }), "170": (l) => Object.assign(l, { ripple: 1.5 }), - halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15 }), + halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70 }), wonk: (l) => Object.assign(l, { twist: 0.9 }), }; diff --git a/web/src/features.ts b/web/src/features.ts index 3df2ec9..33401e7 100644 --- a/web/src/features.ts +++ b/web/src/features.ts @@ -19,6 +19,10 @@ export interface Features { snare: number; /// Seconds since the last kick; large when there was none. kickAge: number; + /// Set for the one frame a hit lands: how prominent it is against recent hits, + /// 0..1 (the loudest hits of the last seconds are 1). 0 on every other frame. + kickHit: number; + snareHit: number; /// How much the mids move (wobble bass, growls), 0..1. wobble: number; /// 0..1, high while the low end sits well above its recent average. @@ -39,6 +43,7 @@ const ONSET_MIN_FLUX = 1.2; // dB of mean rise per bin; quieter changes are nois const ONSET_REFRACTORY = 0.11; // s const KICK_DECAY = 0.16; // s const SNARE_DECAY = 0.12; // s +const PROMINENCE_MEMORY = 6; // s; a hit is weighed against the strongest ones this recent const WOBBLE_SMOOTH = 0.12; // s const DROP_SHORT = 1; // s of low-end loudness … @@ -70,8 +75,10 @@ class Onset { private mean = 0; private variance = 1; private last = -1; + private peak = ONSET_MIN_FLUX; env = 0; age = 99; + hit = 0; constructor(private decay: number) {} @@ -83,7 +90,11 @@ class Onset { this.mean += a * d; this.variance += a * (d * d - this.variance); this.age += dt; + this.peak = Math.max(ONSET_MIN_FLUX, this.peak * Math.exp(-dt / PROMINENCE_MEMORY)); + this.hit = 0; if (hit) { + this.peak = Math.max(this.peak, flux); + this.hit = clamp01(flux / this.peak); this.last = now; this.env = 1; this.age = 0; @@ -94,7 +105,7 @@ class Onset { } export class FeatureTracker { - readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, wobble: 0, drop: 0 }; + readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, kickHit: 0, snareHit: 0, wobble: 0, drop: 0 }; private bins: Float32Array | null = null; private prev: Float32Array | null = null; private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() }; @@ -143,7 +154,9 @@ export class FeatureTracker { this.snare.update((flux.mid + flux.high) / 2, now, dt); f.kick = this.kick.env; f.kickAge = this.kick.age; + f.kickHit = this.kick.hit; f.snare = this.snare.env; + f.snareHit = this.snare.hit; this.track(f.mid, Math.max(db.sub, db.low), dt); return f; } @@ -152,7 +165,7 @@ export class FeatureTracker { fromLoudness(level: number, dt: number): Features { const f = this.f; f.sub = f.low = f.mid = f.high = level; - f.kick = f.snare = 0; + f.kick = f.snare = f.kickHit = f.snareHit = 0; f.kickAge = 99; this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt); return f; @@ -165,6 +178,7 @@ export class FeatureTracker { f.sub *= k; f.low *= k; f.mid *= k; f.high *= k; f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k; f.kickAge += dt; + f.kickHit = f.snareHit = 0; return f; } diff --git a/web/src/style.css b/web/src/style.css index 2f30dc4..f47a37d 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -257,9 +257,9 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; /* The liquid drop spills past the art slot; taps go through it to the card. */ .liquid-canvas { position: absolute; - inset: -25%; - width: 150%; - height: 150%; + inset: -40%; + width: 180%; + height: 180%; pointer-events: none; } .card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }