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.
This commit is contained in:
nvrl committed 2026-11-01 14:42:39 +01:00
1 parent 2125000644
commit cccdf1d11c
5 files changed
+92 -41

No files matched your search

+46 -9
View File
@@ -9,9 +9,18 @@ import type { Look } from "./style";
const FOV = (30 * Math.PI) / 180; const FOV = (30 * Math.PI) / 180;
/// The canvas is this much larger than the art slot, so the liquid can spill out. /// 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 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 TEXTURE_SIZE = 512;
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR … const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
const SLOW_FOR = 2; // … seconds step the quality down 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 QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
const UNIFORMS = [ const UNIFORMS = [
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uDrop",
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uPointer", "uGain", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer",
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const; ] as const;
@@ -44,6 +53,9 @@ export class LiquidBlob {
private pointer = [0, 0, 1, 0]; private pointer = [0, 0, 1, 0];
private pointerGoal = [0, 0, 1, 0]; private pointerGoal = [0, 0, 1, 0];
private slow = 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. /// Null where WebGL2 is not available.
static create(touch: boolean): LiquidBlob | null { static create(touch: boolean): LiquidBlob | null {
@@ -162,11 +174,12 @@ export class LiquidBlob {
gl.viewport(0, 0, w, h); gl.viewport(0, 0, w, h);
this.morph = Math.min(1, this.morph + dt / MORPH_IN); 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. // 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); 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); 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; 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; const u = this.u;
gl.uniform1f(u.uTime, this.time); gl.uniform1f(u.uTime, this.time);
@@ -175,11 +188,8 @@ export class LiquidBlob {
gl.uniform1f(u.uWobble, f.wobble); gl.uniform1f(u.uWobble, f.wobble);
gl.uniform1f(u.uKick, f.kick); gl.uniform1f(u.uKick, f.kick);
gl.uniform1f(u.uKickAge, f.kickAge); gl.uniform1f(u.uKickAge, f.kickAge);
gl.uniform1f(u.uSnare, f.snare);
gl.uniform1f(u.uDrop, f.drop); gl.uniform1f(u.uDrop, f.drop);
gl.uniform1f(u.uGain, look.gain); 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.uTwist, look.twist);
gl.uniform1f(u.uRipple, look.ripple); gl.uniform1f(u.uRipple, look.ripple);
gl.uniform1f(u.uShock, look.shock); gl.uniform1f(u.uShock, look.shock);
@@ -193,7 +203,34 @@ export class LiquidBlob {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(this.vao); gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); 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. /// Step the quality down when frames keep coming in slow.
+15 -15
View File
@@ -64,9 +64,13 @@ uniform float uDist; // camera distance
uniform float uTime; // flow time, runs with the tempo uniform float uTime; // flow time, runs with the tempo
uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid
// Music, see features.ts. // 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. // 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 uniform vec4 uPointer; // direction on the sphere, strength
out vec3 vPos; // view space out vec3 vPos; // view space
@@ -75,24 +79,20 @@ out vec2 vFlat; // front projection of the resting sphere, for the artwork
${NOISE} ${NOISE}
// Only broad, smooth shapes: the drop always wobbles like jelly and never creases.
float disp(vec3 p) { float disp(vec3 p) {
vec3 q = p; vec3 q = p;
float a = uTwist * (p.y + 0.3 * sin(uTime)) * (0.5 + uWobble); 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; q.xz = mat2(cos(a), -sin(a), sin(a), cos(a)) * q.xz;
float t = uTime; 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); 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);
// Terraces with soft edges: stiff, but no torn shards. float sway = snoise(q * 1.7 - vec3(t * 0.5, 0.0, t * 0.3)) * (0.035 + 0.03 * uWobble + 0.05 * uJelly);
float steps = lobes * 10.0; float ripple = snoise(q * 2.6 + vec3(t * 1.1, -t * 0.8, 0.0)) * 0.02 * uWobble * uRipple;
lobes = mix(lobes, (floor(steps) + smoothstep(0.3, 0.7, fract(steps))) / 10.0, uStep); // After each kick a broad wave runs from the front of the drop round to the back.
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 angle = acos(clamp(p.z, -1.0, 1.0)); 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 wave = exp(-pow((angle - uKickAge * 4.5) * 1.3, 2.0)) * exp(-uKickAge * 2.5) * 0.06 * uShock;
float swell = uKick * 0.05;
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); 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) { vec3 shape(vec3 p) {
@@ -110,8 +110,8 @@ void main() {
vec3 s = shape(p); vec3 s = shape(p);
vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s)); vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s));
if (dot(n, p) < 0.0) n = -n; if (dot(n, p) < 0.0) n = -n;
vPos = s - vec3(0.0, 0.0, uDist); vPos = s * uScale + vec3(0.0, uLift, -uDist);
vNormal = n; vNormal = normalize(n / uScale);
vFlat = p.xy; vFlat = p.xy;
gl_Position = uProj * vec4(vPos, 1.0); gl_Position = uProj * vec4(vPos, 1.0);
} }
+12 -12
View File
@@ -8,12 +8,12 @@ export interface Look {
flow: number; flow: number;
/// Overall size of the movement. /// Overall size of the movement.
gain: number; gain: number;
/// Spikes on the snare. /// How high prominent hits make the drop jump.
spike: number; bounce: number;
/// 0 smooth, 1 stepped (stiff, quantised) lobes. /// Spring stiffness of the jump: higher snaps back faster, lower floats.
step: number; stiff: number;
twist: number; twist: number;
/// Fast ripples from the wobble. /// Fine ripples from the wobble.
ripple: number; ripple: number;
/// Ring that runs over the surface on each kick. /// Ring that runs over the surface on each kick.
shock: number; shock: number;
@@ -25,17 +25,17 @@ export interface Look {
hue: number; 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<string, (l: Look) => void> = { const TAGS: Record<string, (l: Look) => void> = {
riddim: (l) => Object.assign(l, { step: 0.8, metal: 0.9, shock: 1.4, spike: 0.6, ripple: 0.7 }), riddim: (l) => Object.assign(l, { bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 0.7 }),
tearout: (l) => Object.assign(l, { spike: 1.4, gain: l.gain * 1.25, heat: 0.7 }), tearout: (l) => Object.assign(l, { bounce: 1.4, gain: l.gain * 1.2, heat: 0.7 }),
deathstep: (l) => Object.assign(l, { spike: 1.5, gain: l.gain * 1.25, heat: 0.9, metal: 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, spike: 0.15, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }), 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, spike: 0.25, metal: 0.15 }), colourbass: (l) => Object.assign(l, { iri: 1, bounce: 0.8, stiff: 70, metal: 0.15 }),
dnb: (l) => Object.assign(l, { ripple: 1.6 }), dnb: (l) => Object.assign(l, { ripple: 1.6 }),
"170": (l) => Object.assign(l, { ripple: 1.5 }), "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 }), wonk: (l) => Object.assign(l, { twist: 0.9 }),
}; };
+16 -2
View File
@@ -19,6 +19,10 @@ export interface Features {
snare: number; snare: number;
/// Seconds since the last kick; large when there was none. /// Seconds since the last kick; large when there was none.
kickAge: number; 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. /// How much the mids move (wobble bass, growls), 0..1.
wobble: number; wobble: number;
/// 0..1, high while the low end sits well above its recent average. /// 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 ONSET_REFRACTORY = 0.11; // s
const KICK_DECAY = 0.16; // s const KICK_DECAY = 0.16; // s
const SNARE_DECAY = 0.12; // 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 WOBBLE_SMOOTH = 0.12; // s
const DROP_SHORT = 1; // s of low-end loudness … const DROP_SHORT = 1; // s of low-end loudness …
@@ -70,8 +75,10 @@ class Onset {
private mean = 0; private mean = 0;
private variance = 1; private variance = 1;
private last = -1; private last = -1;
private peak = ONSET_MIN_FLUX;
env = 0; env = 0;
age = 99; age = 99;
hit = 0;
constructor(private decay: number) {} constructor(private decay: number) {}
@@ -83,7 +90,11 @@ class Onset {
this.mean += a * d; this.mean += a * d;
this.variance += a * (d * d - this.variance); this.variance += a * (d * d - this.variance);
this.age += dt; this.age += dt;
this.peak = Math.max(ONSET_MIN_FLUX, this.peak * Math.exp(-dt / PROMINENCE_MEMORY));
this.hit = 0;
if (hit) { if (hit) {
this.peak = Math.max(this.peak, flux);
this.hit = clamp01(flux / this.peak);
this.last = now; this.last = now;
this.env = 1; this.env = 1;
this.age = 0; this.age = 0;
@@ -94,7 +105,7 @@ class Onset {
} }
export class FeatureTracker { 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<ArrayBuffer> | null = null; private bins: Float32Array<ArrayBuffer> | null = null;
private prev: Float32Array<ArrayBuffer> | null = null; private prev: Float32Array<ArrayBuffer> | null = null;
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() }; 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); this.snare.update((flux.mid + flux.high) / 2, now, dt);
f.kick = this.kick.env; f.kick = this.kick.env;
f.kickAge = this.kick.age; f.kickAge = this.kick.age;
f.kickHit = this.kick.hit;
f.snare = this.snare.env; f.snare = this.snare.env;
f.snareHit = this.snare.hit;
this.track(f.mid, Math.max(db.sub, db.low), dt); this.track(f.mid, Math.max(db.sub, db.low), dt);
return f; return f;
} }
@@ -152,7 +165,7 @@ export class FeatureTracker {
fromLoudness(level: number, dt: number): Features { fromLoudness(level: number, dt: number): Features {
const f = this.f; const f = this.f;
f.sub = f.low = f.mid = f.high = level; 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; f.kickAge = 99;
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt); this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
return f; return f;
@@ -165,6 +178,7 @@ export class FeatureTracker {
f.sub *= k; f.low *= k; f.mid *= k; f.high *= k; 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.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k;
f.kickAge += dt; f.kickAge += dt;
f.kickHit = f.snareHit = 0;
return f; return f;
} }
+3 -3
View File
@@ -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. */ /* The liquid drop spills past the art slot; taps go through it to the card. */
.liquid-canvas { .liquid-canvas {
position: absolute; position: absolute;
inset: -25%; inset: -40%;
width: 150%; width: 180%;
height: 150%; height: 180%;
pointer-events: none; pointer-events: none;
} }
.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; } .card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }