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; }