From b290cd49e309bc8c81f52bbf7a1fe62f00fd1b56 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 2 Nov 2026 22:27:11 +0100 Subject: [PATCH] Ferrofluid spikes and caustics replace the solar flares The drop grows spikes on a fixed lattice, raised by the bass line and by kicks at the beat's spot on the rim, and throws a moving net of light behind it. --- web/index.html | 14 +- web/src/blob/blob.ts | 62 +++--- web/src/blob/caustics.ts | 151 ++++++++++++++ web/src/blob/ferro.ts | 140 +++++++++++++ web/src/blob/flares.ts | 414 --------------------------------------- web/src/blob/shaders.ts | 55 +++++- web/src/blob/style.ts | 42 ++-- web/src/main.ts | 10 +- web/src/style.css | 2 +- web/src/visual.ts | 22 +-- 10 files changed, 419 insertions(+), 493 deletions(-) create mode 100644 web/src/blob/caustics.ts create mode 100644 web/src/blob/ferro.ts delete mode 100644 web/src/blob/flares.ts diff --git a/web/index.html b/web/index.html index 6fea923..1d8535a 100644 --- a/web/index.html +++ b/web/index.html @@ -130,11 +130,11 @@ -
- solar flares on the liquid drop - - - +
+ ferrofluid spikes on the liquid drop + + +
crossfade @@ -274,8 +274,8 @@
m · + −
mute · volume
?
this help
-

Drag the liquid artwork sideways to spin it. On riddim the solar flares round it follow the - beat: a loop on each kick, the same crown every bar, a break on each snare. Lock-screen and +

Drag the liquid artwork sideways to spin it. On riddim it turns to ferrofluid: spikes + stand up with the bass, and each kick raises the same crown every bar. Lock-screen and headphone buttons work too.

Keys like r or x doing something else, such as reloading or closing the tab? A vim-keys browser extension (Vimium and the like) takes them first: diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index dbb79c0..1733054 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -3,7 +3,8 @@ // page; cards around it show their artwork as a plain image. import type { Features } from "../features"; -import { Flares } from "./flares"; +import { Caustics } from "./caustics"; +import { Ferro } from "./ferro"; import { icosphere } from "./mesh"; import { FRAGMENT, VERTEX } from "./shaders"; import { Spin } from "./spin"; @@ -44,17 +45,18 @@ const SLOW_FOR = 2; // … and this many seconds of them step the quality down const FAST_FOR = 10; // s of frames on time before it tries one step up again /// Quality steps: icosphere subdivisions and resolution scale. const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]]; -/// Solar flare loops at most, per quality step. -const FLARE_LOOPS = [8, 6, 5, 4]; +/// Quality steps that still draw the light behind the drop. +const CAUSTICS_UP_TO = 1; +const CAUSTICS_IN = 0.6; // s for the light to come up as playing starts, and go -/// Solar flares: "auto" shows them where the track's look asks for them (style.ts). -export type FlareSetting = "auto" | "on" | "off"; +/// Ferrofluid spikes: "auto" shows them where the track's look asks for them (style.ts). +export type SpikeSetting = "auto" | "on" | "off"; const MAX_PIXELS = 2.6e6; // canvas pixels at full quality, about 1600 x 1600 const UNIFORMS = [ "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl", "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", - "uIcon", "uIconA", "uCalm", "uBusy", "uProgress", "uClock", + "uIcon", "uIconA", "uCalm", "uBusy", "uProgress", "uClock", "uSpikes", "uSpikeOn", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", ] as const; @@ -82,9 +84,11 @@ export class LiquidBlob { readonly spin = new Spin(); private gl: WebGL2RenderingContext; private prog!: WebGLProgram; - /// The loops round the drop; null if their shaders failed, which leaves the drop. - private flares: Flares | null = null; - flareSetting: FlareSetting = "auto"; + private ferro = new Ferro(); + spikeSetting: SpikeSetting = "auto"; + /// The light behind the drop; null if its shaders failed, which leaves the drop. + private caustics: Caustics | null = null; + private light = 0; private proj = perspective(FOV, 1, 0.1, 20); private scaleNow: [number, number, number] = [1, 1, 1]; private liftNow = 0; @@ -191,10 +195,10 @@ export class LiquidBlob { gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.clearColor(0, 0, 0, 0); try { - this.flares = new Flares(gl, FLARE_LOOPS[this.quality]); + this.caustics = new Caustics(gl); } catch (err) { - this.flares = null; - console.warn("no solar flares:", (err as Error).message); + this.caustics = null; + console.warn("no caustics:", (err as Error).message); } gl.useProgram(prog); } @@ -236,7 +240,8 @@ export class LiquidBlob { this.morph = 0; this.slow = this.fast = 0; this.spin.reset(); - this.flares?.reset(); + this.ferro.reset(); + this.light = 0; this.iconFor = null; const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint)); if (gen !== this.gen || this.gl.isContextLost()) return false; @@ -326,23 +331,25 @@ export class LiquidBlob { gl.uniform4f(u.uPointer, ...this.spin.toObject(px, py, pz), pw); gl.uniform3f(u.uFront, ...this.spin.toObject(0, 0, 1)); gl.uniformMatrix3fv(u.uRot, false, this.spin.matrix); + const strength = this.spikeSetting === "off" ? 0 : this.spikeSetting === "on" ? Math.max(1, look.ferro) : look.ferro; + // Not while the drop is still flat artwork, nor under the icon. + const spiking = this.ferro.frame(f, dt, look, playing && morph > 0.9, strength * (1 - this.calm), (x, y, z) => this.spin.toObject(x, y, z)); + gl.uniform4fv(u.uSpikes, this.ferro.data); + gl.uniform1f(u.uSpikeOn, this.ferro.tallest); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + // The light behind it first, while it plays. + const lit = playing && this.quality <= CAUSTICS_UP_TO ? 1 : 0; + this.light += (lit - this.light) * ease(dt, CAUSTICS_IN); + if (this.caustics && this.light > 0.003) { + this.caustics.draw(f, look, { spill: SPILL, scale: this.scaleNow, lift: this.liftNow, time: this.time, accent: this.accent, amount: this.light * morph }); + gl.useProgram(this.prog); + } + gl.enable(gl.DEPTH_TEST); gl.bindVertexArray(this.vao); gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); - const strength = this.flareSetting === "off" ? 0 : this.flareSetting === "on" ? Math.max(1, look.flares) : look.flares; - // Not while the drop is still flat artwork, nor over the icon. - const flaring = !!this.flares && this.flares.frame(f, dt, look, playing && morph > 0.9, strength * (1 - this.calm), f.period, { - proj: this.proj, - dist: DIST, - rot: this.spin.matrix, - scale: this.scaleNow, - lift: this.liftNow, - accent: this.accent, - time: this.time, - toObject: (x, y, z) => this.spin.toObject(x, y, z), - }); - return this.morph < 1 || moving || spinning || showing || flaring || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; + const fading = Math.abs(lit - this.light) > 0.003; + return this.morph < 1 || moving || spinning || showing || spiking || fading || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; } /// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for @@ -446,7 +453,7 @@ export class LiquidBlob { `vertex highp ${hp ? hp.precision : "?"} bits`, `ready ${this.ready} · lost ${gl.isContextLost()} · error ${gl.getError()}`, `morph ${this.morph.toFixed(2)} · quality ${this.quality} · canvas ${this.canvas.width}x${this.canvas.height}`, - `flares ${this.flares ? this.flareSetting : "failed"} · look ${this.look?.flares ?? "-"}`, + `spikes ${this.spikeSetting} · look ${this.look?.ferro ?? "-"} · caustics ${this.caustics ? this.light.toFixed(2) : "failed"}`, `in page ${this.canvas.isConnected} · css ${this.canvas.clientWidth}x${this.canvas.clientHeight}`, ].join("\n"); } @@ -476,7 +483,6 @@ export class LiquidBlob { const before = QUALITY[this.quality][0]; this.quality = q; if (QUALITY[q][0] !== before) this.buildMesh(); - this.flares?.setMax(FLARE_LOOPS[q]); } } diff --git a/web/src/blob/caustics.ts b/web/src/blob/caustics.ts new file mode 100644 index 0000000..2ceca9b --- /dev/null +++ b/web/src/blob/caustics.ts @@ -0,0 +1,151 @@ +// Caustics: the light that falls through the drop and lands on the wall behind it, +// focused by the liquid into a moving net of bright lines in the artwork's colours +// and the accent. Drawn before the drop, over the whole canvas, so the drop sits in +// its own light. It fades out well inside the canvas, so no edge of it shows. +// +// - The net flows with the drop's own noise time, and the wobble's filter warps it. +// - Each womb of the bass line thickens the lines; the drop brightens them. +// - After each kick a ring of light runs outwards, as the wave runs over the drop. +// - In a gap the light nearly goes out. + +import type { Features } from "../features"; +import { NOISE } from "./shaders"; +import type { Look } from "./style"; + +const VERTEX = /* glsl */ `#version 300 es +out vec2 vNdc; +void main() { + // One triangle over the whole canvas. + vec2 p = vec2(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0); + vNdc = p; + gl_Position = vec4(p, 0.0, 1.0); +} +`; + +const FRAGMENT = /* glsl */ `#version 300 es +precision highp float; + +in vec2 vNdc; + +uniform sampler2D uArt; +uniform vec3 uAccent; +uniform vec3 uScale; +uniform float uSpill; // the canvas's half width, in radii of the drop +uniform float uLift; +uniform float uTime, uKick, uKickAge, uBass, uDrop, uGap, uGrowl; +uniform float uHue, uIri, uHeat; +uniform float uAmount; // 0 none, 1 full + +out vec4 outColor; + +${NOISE} + +vec3 hueRotate(vec3 c, float a) { + const vec3 k = vec3(0.57735); + float ca = cos(a); + return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca); +} + +void main() { + vec2 world = vNdc * uSpill; + // The light comes from the upper left, so its pool lies a little below and right, + // and stretches and squashes with the drop. + vec2 s = (world - vec2(0.15, uLift - 0.2)) / uScale.xy; + float r = length(s); + float pool = (1.0 - smoothstep(0.8, 1.75, r)) * (1.0 - smoothstep(uSpill * 0.72, uSpill * 0.95, length(world))); + // Every pixel that gets no light, or lies behind the body of the drop, skips the noise. + float behind = length((world - vec2(0.0, uLift)) / uScale.xy); + if (pool * uAmount < 0.003 || behind < 0.8) { + outColor = vec4(0.0); + return; + } + float t = uTime; + vec3 q = vec3(s * 2.3, t * 0.3); + // A cheap swirl rather than more noise: this runs for most of the canvas. + vec2 warp = vec2(sin(q.y * 1.3 + t * 0.7), cos(q.x * 1.1 - t * 0.5)); + q.xy += warp * (0.35 + 0.4 * uGrowl); + // Thin lines that thicken on each womb: the crests of the noise, sharpened. + float sharp = mix(10.0, 5.0, clamp(uBass + 0.5 * uDrop, 0.0, 1.0)); + float net = pow(1.0 - abs(snoise(q)), sharp); + net += 0.6 * pow(1.0 - abs(snoise(q * 1.9 + vec3(4.0, 2.0, t * 0.2))), sharp + 3.0); + float ahead = (r - 0.95 - uKickAge * 1.4) * 5.0; + float ring = exp(-ahead * ahead) * exp(-uKickAge * 2.5); + + vec3 art = textureLod(uArt, 0.5 + vec2(s.x, -s.y) * 0.3, 6.0).rgb; + vec3 rim = hueRotate(uAccent, uHue + uIri * (net + 0.5 * sin(t * 0.3))); + rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6); + vec3 light = min(mix(rim, art * 1.5 + 0.08, 0.45), vec3(1.0)); + float k = net * (0.3 + 0.4 * uBass + 0.7 * uKick + 0.5 * uDrop + 1.6 * ring) + 0.05 * (uBass + uDrop); + float a = clamp(k * pool * uAmount * (1.0 - 0.8 * uGap), 0.0, 0.6); + // Premultiplied, and never brighter than its own alpha allows. + outColor = vec4(light * a, a * max(light.r, max(light.g, light.b))); +} +`; + +const UNIFORMS = [ + "uArt", "uAccent", "uScale", "uSpill", "uLift", "uTime", "uKick", "uKickAge", "uBass", "uDrop", "uGap", "uGrowl", + "uHue", "uIri", "uHeat", "uAmount", +] as const; + +/// What the drop shares with its light each frame. +export interface CausticFrame { + spill: number; + scale: [number, number, number]; + lift: number; + time: number; + accent: [number, number, number]; + /// 0 none, 1 full. + amount: number; +} + +export class Caustics { + private prog!: WebGLProgram; + private vao!: WebGLVertexArrayObject; + private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>; + + constructor(private gl: WebGL2RenderingContext) { + const prog = gl.createProgram(); + for (const [type, src] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "caustics shader failed"); + gl.attachShader(prog, s); + } + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "caustics link failed"); + this.prog = prog; + for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name); + gl.useProgram(prog); + gl.uniform1i(this.u.uArt, 0); + this.vao = gl.createVertexArray(); + } + + /// Draw the light behind the drop. Leaves depth testing off; the caller turns it on. + draw(f: Features, look: Look, c: CausticFrame): void { + if (c.amount < 0.003) return; + const gl = this.gl; + const u = this.u; + gl.useProgram(this.prog); + gl.uniform3fv(u.uAccent, c.accent); + gl.uniform3f(u.uScale, ...c.scale); + gl.uniform1f(u.uSpill, c.spill); + gl.uniform1f(u.uLift, c.lift); + gl.uniform1f(u.uTime, c.time); + gl.uniform1f(u.uKick, f.kick); + gl.uniform1f(u.uKickAge, f.kickAge); + gl.uniform1f(u.uBass, f.bass * look.throb); + gl.uniform1f(u.uDrop, f.drop); + gl.uniform1f(u.uGap, f.gap); + gl.uniform1f(u.uGrowl, f.growl); + gl.uniform1f(u.uHue, look.hue); + gl.uniform1f(u.uIri, look.iri); + gl.uniform1f(u.uHeat, look.heat); + gl.uniform1f(u.uAmount, c.amount * look.caustics); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.depthMask(true); + } +} diff --git a/web/src/blob/ferro.ts b/web/src/blob/ferro.ts new file mode 100644 index 0000000..511d3cc --- /dev/null +++ b/web/src/blob/ferro.ts @@ -0,0 +1,140 @@ +// Ferrofluid spikes: the drop is a magnetic liquid, and the bass is the field. A +// fixed lattice of points on the drop each grows a spike, the way ferrofluid does +// over a magnet; this file steps how tall each one stands, and the vertex shader +// (shaders.ts) pushes the surface out into them. +// +// - The bass line's envelope is the field: every "womb" raises the whole lattice, +// and on a riddim the drop goes from smooth to hedgehog and back on each one. +// - Each kick charges the spikes round one spot on the rim. Where follows the beat, +// so a riddim, which puts the same kick on the same beat bar after bar, raises the +// same crown every bar (`Look.crown` positions, `Look.scatter` off them). +// - A snare jolts them, the charged ones most. +// - In a gap (the stop before a drop) they melt flat; the drop then charges all of +// them at once. +// +// Each height is a spring, stiff on the way up so a spike snaps out and overshoots, +// softer on the way down so it melts back. + +import type { Features } from "../features"; +import type { Look } from "./style"; + +/// Spikes on the lattice; the shader has an array of this size. +export const SPIKES = 60; +const KICK_MIN = 0.2; // weaker kicks charge nothing +const PATCH = 0.55; // radius of the patch a kick charges, as a chord of the unit sphere +const DROP_BURST = 0.55; // the drop rising through this charges every spike +const RISE = [170, 13]; // stiffness, damping on the way up +const FALL = [55, 12]; // and on the way down +const MAX = 0.45; // radii + +export class Ferro { + /// Per spike: direction in the drop's own space, then height. Goes to `uSpikes`. + readonly data = new Float32Array(SPIKES * 4); + private height = new Float32Array(SPIKES); + private speed = new Float32Array(SPIKES); + private charge = new Float32Array(SPIKES); + private seed = new Float32Array(SPIKES); + private clock = 0; + private lastDrop = 0; + /// Where on the crown the next kick lands, for tracks with scatter. + private turn = Math.random(); + /// The tallest spike, so the shader can skip them all when there are none. + tallest = 0; + + constructor() { + // A Fibonacci lattice: near enough even spacing, close to the hexagons that real + // ferrofluid settles into. + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < SPIKES; i++) { + const y = 1 - ((i + 0.5) / SPIKES) * 2; + const r = Math.sqrt(1 - y * y); + this.data.set([Math.cos(golden * i) * r, y, Math.sin(golden * i) * r, 0], i * 4); + this.seed[i] = (i * 0.618034) % 1; + } + } + + /// A new track: nothing carried over. + reset(): void { + this.height.fill(0); + this.speed.fill(0); + this.charge.fill(0); + this.lastDrop = 0; + this.tallest = 0; + for (let i = 0; i < SPIKES; i++) this.data[i * 4 + 3] = 0; + } + + /// Step the spikes by `dt`. `strength` 0 lets them melt back. True while any moves. + frame(f: Features, dt: number, look: Look, playing: boolean, strength: number, toObject: (x: number, y: number, z: number) => [number, number, number]): boolean { + const on = playing && strength > 0; + const reach = look.reach * Math.min(1, strength); + const n = SPIKES; + this.clock += dt; + if (on && f.kickHit >= KICK_MIN && f.gap < 0.5) this.kick(f, look, reach, toObject); + if (on && f.drop > DROP_BURST && this.lastDrop <= DROP_BURST) { + for (let i = 0; i < n; i++) { + this.charge[i] = 1; + this.speed[i] += 2.5 * reach; + } + } + this.lastDrop = f.drop; + if (on && f.snareHit >= look.jolt) { + for (let i = 0; i < n; i++) this.speed[i] += f.snareHit * reach * (0.5 + 3 * this.charge[i]); + } + + // A kick's charge holds for `Look.hold` beats. + const fade = Math.exp(-dt / Math.max(0.05, look.hold * f.period * 0.5)); + const field = (0.08 + 0.6 * f.bass + 0.2 * f.wobble * f.growl + 0.35 * f.drop) * (1 - 0.9 * f.gap); + // Stepped tracks keep the lattice in step; the rest let it breathe unevenly. + const even = Math.min(1, look.step * 2); + let tallest = 0; + let moving = false; + for (let i = 0; i < n; i++) { + this.charge[i] *= fade; + const breathe = 0.55 + 0.45 * Math.sin(this.seed[i] * 6.2832 + this.clock * 0.9); + const goal = on ? reach * (field * (even + (1 - even) * breathe) + 0.9 * this.charge[i]) : 0; + let h = this.height[i], v = this.speed[i]; + const [k, d] = goal > h ? RISE : FALL; + // Small fixed steps keep the springs stable on slow frames. + for (let left = Math.min(dt, 0.1); left > 0; left -= 1 / 240) { + const s = Math.min(left, 1 / 240); + v += (-k * (h - goal) - d * v) * s; + h += v * s; + } + h = Math.min(MAX, Math.max(-0.03, h)); + this.height[i] = h; + this.speed[i] = v; + const shown = Math.max(0, h); + this.data[i * 4 + 3] = shown; + tallest = Math.max(tallest, shown); + if (Math.abs(h - goal) > 0.002 || Math.abs(v) > 0.01) moving = true; + } + this.tallest = tallest; + return moving || tallest > 0.002; + } + + /// Charge the spikes round the rim where the beat says, as seen from the front, so + /// they stand out against the page. + private kick(f: Features, look: Look, reach: number, toObject: (x: number, y: number, z: number) => [number, number, number]): void { + const crown = Math.max(1, Math.round(look.crown)); + let slot: number; + if (look.scatter === 0) slot = Math.round(f.bar * crown) % crown; + else { + this.turn = (this.turn + 1 + (Math.random() - 0.5) * look.scatter) % crown; + slot = this.turn; + } + // Upright crowns read best: the first slot sits at the top. + const angle = Math.PI / 2 - (slot / crown) * Math.PI * 2; + const z = 0.15 + Math.random() * 0.2; + const r = Math.sqrt(1 - z * z); + const [cx, cy, cz] = toObject(Math.cos(angle) * r, Math.sin(angle) * r, z); + for (let i = 0; i < SPIKES; i++) { + const d = this.data; + const chord2 = (d[i * 4] - cx) ** 2 + (d[i * 4 + 1] - cy) ** 2 + (d[i * 4 + 2] - cz) ** 2; + const w = f.kickHit * Math.exp(-chord2 / (PATCH * PATCH)); + if (w < 0.02) continue; + this.charge[i] = Math.max(this.charge[i], w); + // Snapped out, so it overshoots its charge once. + this.speed[i] += 9 * w * reach; + } + } +} diff --git a/web/src/blob/flares.ts b/web/src/blob/flares.ts deleted file mode 100644 index 45c5b02..0000000 --- a/web/src/blob/flares.ts +++ /dev/null @@ -1,414 +0,0 @@ -// Solar flares round the drop: coronal loops, the arcs that leave a star at one -// point and land at another, played by the music. They are pulled out of the drop -// itself, so they are made of its liquid: the same artwork colours, the same rim in -// the card's accent, the same gloss, and they spring like it does. -// -// - Each kick pulls a loop out. Where it rises follows the beat, so a riddim, which -// puts the same kick on the same beat bar after bar, draws the same crown again -// every bar (`Look.crown` positions, `Look.scatter` off them). -// - The bass line's envelope lifts every loop: each "womb" swells them. -// - Light runs along each loop once per womb. -// - The wobble's filter sways the loops: a dark "oo" barely, a bright "wah" more. -// - A snare pinches the top off a loop and throws it out as a drop of its own. -// - In a gap (the stop before a drop) the loops sink back in; the drop then pulls -// out a burst of them at once. -// -// The loops are drawn after the drop, in the same WebGL2 context, with its depth, so -// their feet sink into it and the drop hides what is behind it. - -import type { Features } from "../features"; -import type { Look } from "./style"; - -const SEGMENTS = 32; // along each loop -const SIDES = 10; // round it -const FLOATS = 16; // per loop in the instance buffer -const KICK_MIN = 0.2; // weaker kicks pull out no loop -const SPAN = [0.2, 0.36]; // half the distance between a loop's feet, rad -const WIDTH = 0.024; // the loop's radius at its top, in radii of the drop -const ERUPT = 0.6; // s from the pinch until the thrown top is gone -const DROP_BURST = 0.55; // the drop rising through this pulls out a burst of loops -// Each loop's height is a spring, a little looser than the drop's jump, so it -// overshoots once as it comes out and sinks back softly. -const SPRING = 150; -const DAMPING = 13; - -interface Loop { - /// Feet, unit vectors in the drop's own space, so they turn with it. - a: [number, number, number]; - b: [number, number, number]; - age: number; - /// Seconds before it sinks back, from the tempo. - life: number; - /// Seconds since its top was pinched off, or -1. - erupt: number; - seed: number; - /// Light travelled along it, in loop lengths. - phase: number; - height: number; - speed: number; - /// How high it wants to stand. - goal: number; -} - -export const FLARE_VERTEX = /* glsl */ `#version 300 es -precision highp float; - -layout(location = 0) in vec2 aTube; // along 0..1, round 0..1 -layout(location = 1) in vec3 aA; -layout(location = 2) in vec3 aB; -layout(location = 3) in vec4 aP; // height, alpha, grown 0..1, seed -layout(location = 4) in vec4 aQ; // erupt 0..1, sway, width, phase - -uniform mat4 uProj; -uniform mat3 uRot; -uniform vec3 uScale; -uniform float uLift, uDist, uTime; - -out vec3 vPos; // view space -out vec3 vNormal; // view space -out vec3 vObj; // the drop's surface under this point, for the artwork -out float vT; -out float vAlpha; -out float vPhase; - -const float PI = 3.14159265; - -// The loop's centre line at t, in the drop's own space. -vec3 along(float t) { - t = clamp(t, 0.0, 1.0); - float om = acos(clamp(dot(aA, aB), -1.0, 1.0)); - vec3 base = om < 0.001 ? aA : (sin((1.0 - t) * om) * aA + sin(t * om) * aB) / sin(om); - float arch = sin(PI * t); - float e = aQ.x; - // The piece that is pinched off: thrown out while the legs sink back. - float top = 1.0 - smoothstep(0.12, 0.2, abs(t - 0.5)); - float h = aP.x * arch * (1.0 - e * (1.0 - top)) + e * (0.15 + 0.45 * e) * top; - // The feet start a little inside, so they come out of the surface, not off it. - vec3 p = base * (0.93 + 0.07 * arch + h); - // A slow sway, flowing with the drop's own noise time. - vec3 side = normalize(cross(base, aB - aA + vec3(1e-4))); - p += side * aQ.y * 0.05 * sin(t * 6.283 + uTime * 2.0 + aP.w * 6.283) * arch; - return p; -} - -void main() { - float t = aTube.x; - vec3 c = along(t); - vec3 ahead = normalize(along(t + 0.01) - along(t - 0.01) + vec3(1e-5)); - vec3 n1 = normalize(cross(ahead, normalize(c)) + vec3(1e-5)); - vec3 n2 = cross(n1, ahead); - float a = aTube.y * 2.0 * PI; - vec3 d = cos(a) * n1 + sin(a) * n2; - // Thick feet that melt into the drop, thinner at the top; thin while it grows. - float arch = sin(PI * t); - float foot = 1.0 - arch; - float r = aQ.z * (1.0 + 2.2 * foot * foot * foot) * aP.z; - float e = aQ.x; - if (e > 0.0) { - // A neck on each side of the top that narrows until it parts. - float neck = abs(abs(t - 0.5) - 0.16); - r *= smoothstep(0.0, 0.01 + 0.09 * e, neck); - r *= 1.0 - 0.6 * e * (1.0 - smoothstep(0.12, 0.2, abs(t - 0.5))); - } - vec3 p = c + d * r; - vPos = uRot * p * uScale + vec3(0.0, uLift, -uDist); - vNormal = normalize(uRot * d / uScale); - vObj = normalize(c); - vT = t; - vAlpha = aP.y; - vPhase = aQ.w; - gl_Position = uProj * vec4(vPos, 1.0); -} -`; - -export const FLARE_FRAGMENT = /* glsl */ `#version 300 es -precision highp float; - -in vec3 vPos; -in vec3 vNormal; -in vec3 vObj; -in float vT; -in float vAlpha; -in float vPhase; - -uniform sampler2D uArt; -uniform vec3 uAccent; -uniform float uTime, uHue, uIri, uMetal, uHeat; -uniform float uGlow; // the drop's rim brightness from the music -uniform float uRun; // light running along: 0 none, 1 from the feet, -1 to them - -out vec4 outColor; - -vec3 hueRotate(vec3 c, float a) { - const vec3 k = vec3(0.57735); - float ca = cos(a); - return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca); -} - -// The drop's own shading (shaders.ts), without the refraction: the loops are thin. -void main() { - vec3 n = normalize(vNormal); - vec3 v = normalize(-vPos); - float nv = 1.0 - max(dot(n, v), 0.0); - float fres = nv * nv * nv; - // The artwork where the loop comes out, blurred into its colours. - vec3 art = textureLod(uArt, 0.5 + vec2(vObj.x, -vObj.y) * 0.48, 4.5).rgb; - vec3 rim = hueRotate(uAccent, uHue + uIri * (fres + 0.5 * sin(uTime * 0.3))); - rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6); - vec3 slick = 0.5 + 0.5 * cos(6.2831853 * (fres * 1.6 + vec3(0.0, 0.33, 0.67) + uTime * 0.05)); - rim = mix(rim, rim * 0.4 + slick * length(rim) * 0.75, 0.25 + 0.5 * uIri); - - vec3 col = art * mix(0.8, 0.4, fres); - col += rim * fres * (0.65 + uGlow); - col += mix(rim, vec3(1.0), 0.5) * fres * fres * (0.3 + 0.8 * uGlow); - vec3 light = normalize(vec3(-0.5, 0.7, 0.6)); - col += pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal); - vec3 r = reflect(-v, n); - vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); - env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5)))); - col += env * (0.1 + 0.5 * fres); - // Light in the accent runs up both legs, or back down them in a gap. - float s = 1.0 - abs(vT - 0.5) * 2.0; - float k = fract(s - vPhase * sign(uRun)); - float run = smoothstep(0.0, 0.08, k) * (1.0 - smoothstep(0.08, 0.4, k)) * abs(uRun); - col += rim * run * (0.25 + 0.6 * fres); - // Glassy: the middle lets the page through, the rim holds the colour. - float a = mix(0.8, 1.0, smoothstep(0.0, 0.5, nv)) * (1.0 - 0.9 * smoothstep(0.8, 1.0, fres)) * vAlpha; - outColor = vec4(col * a, a); -} -`; - -const FLARE_UNIFORMS = [ - "uProj", "uRot", "uScale", "uLift", "uDist", "uTime", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", "uGlow", "uRun", -] as const; - -/// What the drop shares with its loops each frame. -export interface FlareFrame { - proj: Float32Array; - dist: number; - rot: Float32Array; - scale: [number, number, number]; - lift: number; - accent: [number, number, number]; - /// The drop's flow time, so the loops sway with its surface. - time: number; - /// View direction to the drop's own space (spin.ts). - toObject: (x: number, y: number, z: number) => [number, number, number]; -} - -export class Flares { - private prog!: WebGLProgram; - private vao!: WebGLVertexArrayObject; - private inst!: WebGLBuffer; - private count = 0; - private u = {} as Record<(typeof FLARE_UNIFORMS)[number], WebGLUniformLocation | null>; - private data: Float32Array; - private loops: Loop[] = []; - private lastDrop = 0; - /// Where on the crown the next loop goes, for tracks with scatter. - private turn = Math.random(); - - constructor( - private gl: WebGL2RenderingContext, - private max: number, - ) { - this.data = new Float32Array(max * FLOATS); - this.init(); - } - - /// Everything on the GPU; again after a lost context comes back. - init(): void { - const gl = this.gl; - const prog = gl.createProgram(); - for (const [type, src] of [[gl.VERTEX_SHADER, FLARE_VERTEX], [gl.FRAGMENT_SHADER, FLARE_FRAGMENT]] as const) { - const s = gl.createShader(type)!; - gl.shaderSource(s, src); - gl.compileShader(s); - if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "flare shader failed"); - gl.attachShader(prog, s); - } - gl.linkProgram(prog); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "flare link failed"); - this.prog = prog; - for (const name of FLARE_UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name); - gl.useProgram(prog); - gl.uniform1i(this.u.uArt, 0); - - // A tube: rings of SIDES + 1 points (the seam twice) at SEGMENTS + 1 steps. - const ring = SIDES + 1; - const tube = new Float32Array((SEGMENTS + 1) * ring * 2); - for (let i = 0; i <= SEGMENTS; i++) for (let j = 0; j < ring; j++) tube.set([i / SEGMENTS, j / SIDES], (i * ring + j) * 2); - const idx = new Uint16Array(SEGMENTS * SIDES * 6); - let o = 0; - for (let i = 0; i < SEGMENTS; i++) { - for (let j = 0; j < SIDES; j++) { - const p = i * ring + j, q = p + ring; - idx.set([p, q, p + 1, p + 1, q, q + 1], o); - o += 6; - } - } - this.count = idx.length; - - this.vao = gl.createVertexArray(); - gl.bindVertexArray(this.vao); - const pos = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, pos); - gl.bufferData(gl.ARRAY_BUFFER, tube, gl.STATIC_DRAW); - gl.enableVertexAttribArray(0); - gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); - const ib = gl.createBuffer(); - gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); - gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW); - this.inst = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, this.inst); - gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW); - const stride = FLOATS * 4; - for (const [loc, size, offset] of [[1, 3, 0], [2, 3, 3], [3, 4, 6], [4, 4, 10]]) { - gl.enableVertexAttribArray(loc); - gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset * 4); - gl.vertexAttribDivisor(loc, 1); - } - gl.bindVertexArray(null); - } - - /// A new track: no loops carried over. - reset(): void { - this.loops = []; - this.lastDrop = 0; - } - - /// Fewer loops on slow devices. - setMax(max: number): void { - this.max = max; - if (this.loops.length > max) this.loops.length = max; - if (this.data.length < max * FLOATS) { - this.data = new Float32Array(max * FLOATS); - const gl = this.gl; - gl.bindBuffer(gl.ARRAY_BUFFER, this.inst); - gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW); - } - } - - get active(): boolean { - return this.loops.length > 0; - } - - /// Step the loops by `dt` and draw them. `strength` 0 lets them sink back. - /// True while there are loops left. - frame(f: Features, dt: number, look: Look, playing: boolean, strength: number, period: number, view: FlareFrame): boolean { - const on = playing && strength > 0; - if (on && f.kickHit >= KICK_MIN && f.gap < 0.5) this.spawn(f, look, period, view); - // The drop: a burst all round. - if (on && f.drop > DROP_BURST && this.lastDrop <= DROP_BURST) { - for (let i = 0; i < Math.ceil(this.max / 2); i++) this.spawn(f, look, period, view, true); - } - this.lastDrop = f.drop; - if (on && f.snareHit >= look.erupt) { - const victim = this.loops.filter((l) => l.erupt < 0 && l.height > 0.5 * l.goal * look.arch).sort((x, y) => y.age - x.age)[0]; - if (victim) victim.erupt = 0; - } - - // Light runs once along a loop per womb, or with the beat while there is no wobble. - const rate = f.wobbleRate > 0.3 ? f.wobbleRate : 0.5 / period; - const lift = look.arch * (0.35 + 0.5 * f.bass + 0.25 * f.drop) * (1 - 0.85 * f.gap); - this.loops = this.loops.filter((l) => { - l.age += dt; - l.phase += dt * rate; - if (l.erupt >= 0) l.erupt += dt; - // Without music, or past its life, it sinks back in. - const sinking = !on || l.age > l.life; - const goal = sinking ? 0 : lift * l.goal; - for (let left = Math.min(dt, 0.1); left > 0; left -= 1 / 240) { - const h = Math.min(left, 1 / 240); - l.speed += (-SPRING * (l.height - goal) - DAMPING * l.speed) * h; - l.height += l.speed * h; - } - if (l.erupt >= ERUPT) return false; - return !(sinking && l.height < 0.01 && l.speed <= 0); - }); - if (!this.loops.length) return false; - - const d = this.data; - const n = Math.min(this.loops.length, this.max); - // As thick as it is out: a loop grows out of the surface and sinks back thin. - const full = Math.max(0.05, 0.6 * look.arch); - const sway = 0.3 + 1.2 * f.growl * f.bass + 0.4 * f.wobble; - for (let i = 0; i < n; i++) { - const l = this.loops[i]; - const grown = Math.min(1, Math.max(0, l.height) / full); - const e = l.erupt < 0 ? 0 : l.erupt / ERUPT; - d.set([ - ...l.a, ...l.b, - Math.max(0, l.height), Math.min(1, strength + (on ? 0 : 1)), Math.sqrt(grown), l.seed, - e, sway, WIDTH * (1 + 0.3 * f.drop + 0.2 * f.bass), l.phase, - 0, 0, - ], i * FLOATS); - } - - const gl = this.gl; - const u = this.u; - gl.useProgram(this.prog); - gl.uniformMatrix4fv(u.uProj, false, view.proj); - gl.uniformMatrix3fv(u.uRot, false, view.rot); - gl.uniform3f(u.uScale, ...view.scale); - gl.uniform1f(u.uLift, view.lift); - gl.uniform1f(u.uDist, view.dist); - gl.uniform1f(u.uTime, view.time); - gl.uniform3fv(u.uAccent, view.accent); - gl.uniform1f(u.uHue, look.hue); - gl.uniform1f(u.uIri, look.iri); - gl.uniform1f(u.uMetal, look.metal); - gl.uniform1f(u.uHeat, look.heat); - gl.uniform1f(u.uGlow, 0.9 * f.kick + 0.5 * f.drop + 0.5 * f.bass * look.throb); - gl.uniform1f(u.uRun, f.gap > 0.5 ? -1 : Math.min(1, 0.3 + f.bass + f.wobble)); - gl.bindVertexArray(this.vao); - gl.bindBuffer(gl.ARRAY_BUFFER, this.inst); - gl.bufferSubData(gl.ARRAY_BUFFER, 0, d, 0, n * FLOATS); - gl.drawElementsInstanced(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0, n); - gl.bindVertexArray(null); - return true; - } - - /// A new loop where the beat says: round the rim of the drop as seen, straddling - /// it, so it arcs out past the edge. - private spawn(f: Features, look: Look, period: number, view: FlareFrame, anywhere = false): void { - if (this.loops.length >= this.max) { - // The oldest that is not breaking sinks back to make room. - const oldest = this.loops.reduce((o, l) => (l.erupt < 0 && l.age > o.age ? l : o), this.loops[0]); - oldest.life = Math.min(oldest.life, oldest.age); - if (this.loops.length >= this.max + 2) return; - } - const crown = Math.max(1, Math.round(look.crown)); - let slot: number; - if (anywhere) slot = Math.random() * crown; - else if (look.scatter === 0) slot = Math.round(f.bar * crown) % crown; - else { - this.turn = (this.turn + 1 + (Math.random() - 0.5) * look.scatter) % crown; - slot = this.turn; - } - // Upright crowns read best: the first slot sits at the top. - const angle = Math.PI / 2 - (slot / crown) * Math.PI * 2; - const z = -0.1 + Math.random() * 0.35; - const r = Math.sqrt(1 - z * z); - const c: [number, number, number] = [Math.cos(angle) * r, Math.sin(angle) * r, z]; - const tx = -Math.sin(angle), ty = Math.cos(angle); - const span = SPAN[0] + Math.random() * (SPAN[1] - SPAN[0]); - const foot = (sgn: number) => { - const x = c[0] + tx * span * sgn, y = c[1] + ty * span * sgn, zz = c[2]; - const len = Math.hypot(x, y, zz); - return view.toObject(x / len, y / len, zz / len); - }; - this.loops.push({ - a: foot(1), - b: foot(-1), - age: 0, - life: look.life * period * (0.9 + 0.2 * Math.random()), - erupt: -1, - seed: Math.random(), - phase: Math.random(), - height: 0, - speed: 0, - goal: 0.85 + 0.3 * Math.random(), - }); - } -} diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index 065dc0a..4186a3c 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -1,10 +1,12 @@ +import { SPIKES } from "./ferro"; + // GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with // noise driven by the music and turns it as spin.ts says; the fragment shader shows // the artwork through the surface, glossy with the card's accent colour. // 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence: // https://github.com/ashima/webgl-noise -const NOISE = /* glsl */ ` +export const NOISE = /* glsl */ ` vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); } @@ -125,10 +127,14 @@ uniform vec4 uPointer; // direction on the sphere, strength uniform mat3 uRot; // turned by hand, object to view space uniform vec3 uFront; // towards the viewer, in object space uniform float uCalm; // calms the surface while the icon or the loading ring shows +// Ferrofluid spikes (ferro.ts): direction in the drop's own space, height in radii. +uniform vec4 uSpikes[${SPIKES}]; +uniform float uSpikeOn; // the tallest spike; 0 skips them all out vec3 vPos; // view space out vec3 vNormal; // view space out vec3 vObj; // the resting sphere; its front projection carries the artwork +out float vSpike; // how far out on a spike, 0..1 ${NOISE} @@ -162,10 +168,39 @@ float disp(vec3 p) { float wave = exp(-ahead * ahead) * exp(-uKickAge * 2.8) * 0.09 * uShock; float near = distance(p, uPointer.xyz) * 2.2; float dent = -0.18 * uPointer.w * exp(-near * near); + // Where ferrofluid spikes stand, they take over from the noise's own points, so + // the cones read clean. + float ferroOn = smoothstep(0.0, 0.15, uSpikeOn); + spikes *= 1.0 - 0.8 * ferroOn; + thorns *= 1.0 - 0.8 * ferroOn; // Calmer while the icon shows, so it stays readable. return clamp((lobes + sway + ripples + spikes + thorns + wave) * uGain + dent, -0.3, 0.5) * (1.0 - 0.6 * uCalm); } +// The spikes: cones with hollow flanks and a sharp tip, as ferrofluid stands over a +// magnet. Wider at the base the taller they grow. Stepped tracks get sharper ones. +// Returns the height and, in yz, its slope along t1 and t2: worked out here rather +// than by sampling the neighbours, which would run the loop three times. +// The test is on uniforms only, so every vertex takes the same branch. +vec3 ferro(vec3 p, vec3 t1, vec3 t2) { + if (uSpikeOn < 0.002) return vec3(0.0); + float sharp = 2.0 + 1.5 * uStep; + vec3 h = vec3(0.0); + for (int i = 0; i < ${SPIKES}; i++) { + vec4 s = uSpikes[i]; + float w = 0.1 + 0.55 * s.w; + vec3 d = p - s.xyz; + float len = length(d); + float c = len / w; + if (c < 1.0) { + float k = pow(1.0 - c, sharp - 1.0); + vec3 grad = -sharp * s.w * k * d / (max(len, 1e-4) * w); + h += vec3(s.w * k * (1.0 - c), dot(grad, t1), dot(grad, t2)); + } + } + return h * (1.0 - 0.8 * uCalm); +} + void main() { vec3 p = position; // The surface is the sphere pushed out by h. Its normal tips against the slope of @@ -173,9 +208,12 @@ void main() { vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0))); vec3 t2 = cross(p, t1); const float e = 0.012; - float h = disp(p) * uMorph; - float g1 = (disp(p + t1 * e) * uMorph - h) / e; - float g2 = (disp(p + t2 * e) * uMorph - h) / e; + vec3 fe = ferro(p, t1, t2) * uMorph; + float h0 = disp(p) * uMorph; + float g1 = (disp(p + t1 * e) * uMorph - h0) / e + fe.y; + float g2 = (disp(p + t2 * e) * uMorph - h0) / e + fe.z; + float h = h0 + fe.x; + vSpike = clamp(fe.x / 0.2, 0.0, 1.0); vec3 n = p * (1.0 + h) - g1 * t1 - g2 * t2; // Flattened into the static artwork while it morphs in; the normal scales the other way. float depth = mix(0.04, 1.0, uMorph); @@ -195,6 +233,7 @@ precision highp float; in vec3 vPos; in vec3 vNormal; in vec3 vObj; +in float vSpike; uniform sampler2D uArt; uniform vec3 uAccent; @@ -248,16 +287,18 @@ void main() { // A thin, hot edge that flares on hits. float edge = fres * fres; - vec3 col = art * mix(1.0, 0.45, fres * uMorph); + // Out on a spike the liquid turns to ink, like ferrofluid: the artwork darkens and + // the gloss takes over. + vec3 col = art * mix(1.0, 0.45, fres * uMorph) * (1.0 - 0.6 * vSpike); col += rim * fresC * (0.65 + 0.9 * uKick + 0.5 * uDrop + 0.5 * uBass) * uMorph; col += mix(rim, vec3(1.0), 0.5) * edge * (0.3 + 1.2 * uKick + 0.8 * uDrop) * uMorph; - col += spec * uMorph; + col += spec * (1.0 + 0.8 * vSpike) * uMorph; // Gloss: a dark room lit by the accent from above, and one soft window. They stay // put while the surface turns under them, which is what makes a spin read. vec3 r = reflect(-v, n); vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5)))); - col += env * (0.1 + 0.5 * fres) * uMorph; + col += env * (0.1 + 0.5 * fres + 0.5 * vSpike) * uMorph; // The icon sits on a disc of frosted glass, painted onto the surface so it turns // with the drop: the artwork behind it blurred and dimmed, lit along its rim. A // ring round it shows how much has played, or runs while the track loads. diff --git a/web/src/blob/style.ts b/web/src/blob/style.ts index e664727..02c5d21 100644 --- a/web/src/blob/style.ts +++ b/web/src/blob/style.ts @@ -29,43 +29,45 @@ export interface Look { heat: number; /// Rim hue offset from the key, radians. hue: number; - /// Solar flares (flares.ts): 0 off, 1 full. "auto" in the settings shows them + /// Ferrofluid spikes (ferro.ts): 0 off, 1 full. "auto" in the settings shows them /// where this is above 0. - flares: number; - /// Loop positions round the drop: one per beat for a riddim (4), so each bar - /// draws the same crown again. + ferro: number; + /// Spots round the rim that kicks raise spikes at: one per beat for a riddim (4), + /// so each bar raises the same crown again. crown: number; - /// How far off those positions a loop may land, 0..1 of the gap between them. + /// How far off those spots a kick may land, 0..1 of the gap between them. scatter: number; - /// Loop height over the surface, in radii. - arch: number; - /// Beats a loop lives. - life: number; - /// Snares weaker than this pinch no loop. - erupt: number; + /// Spike height, in radii. + reach: number; + /// Beats a kick's spikes stay up. + hold: number; + /// Snares weaker than this jolt no spikes. + jolt: number; + /// Brightness of the light the drop throws behind it (caustics.ts). + caustics: number; } const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, throb: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0, - flares: 0, crown: 6, scatter: 0.4, arch: 0.32, life: 4, erupt: 0.45, + ferro: 0, crown: 6, scatter: 0.4, reach: 0.22, hold: 2, jolt: 0.45, caustics: 1, }; const TAGS: Record void> = { - // Riddim: the loops snap to the beat and the crown repeats every bar. + // Riddim: the spikes stand in step and the crown repeats every bar. riddim: (l) => Object.assign(l, { step: 0.5, spike: 0.6, throb: 1.4, bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 1.2, - flares: 1, crown: 4, scatter: 0, arch: 0.36, life: 4, erupt: 0.4, + ferro: 1, crown: 4, scatter: 0, reach: 0.26, hold: 2, jolt: 0.4, }), - tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7, flares: 1, crown: 6, scatter: 0.5, erupt: 0.25 }), - deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7, flares: 1, crown: 6, scatter: 0.6, erupt: 0.2 }), + tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7, ferro: 1, crown: 6, scatter: 0.5, reach: 0.3, jolt: 0.25 }), + deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7, ferro: 1, crown: 6, scatter: 0.6, reach: 0.32, jolt: 0.2 }), melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, throb: 0.7, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1, - flares: 0, crown: 5, scatter: 0.3, arch: 0.45, life: 8, erupt: 0.8, + ferro: 0, crown: 5, scatter: 0.3, reach: 0.18, hold: 4, jolt: 0.8, caustics: 1.3, }), - colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15 }), + colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15, caustics: 1.4 }), 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, stiff: 70, crown: 2, arch: 0.45 }), + halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70, crown: 2, reach: 0.3 }), wonk: (l) => Object.assign(l, { twist: 0.9 }), }; @@ -74,7 +76,7 @@ export function lookFor(t: Track): Look { const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 }; const tags = t.tags ?? []; // Untagged dubstep tempo counts too: most riddim carries no riddim tag. - if (tags.includes("140")) l.flares = 1; + if (tags.includes("140")) l.ferro = 1; for (const tag of tags) TAGS[tag]?.(l); l.hue = keyHue(t.key); return l; diff --git a/web/src/main.ts b/web/src/main.ts index b5e6271..eac472e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -9,7 +9,7 @@ import { Pager } from "./pager"; import { Player } from "./player"; import { Sheet } from "./sheet"; import { Station } from "./station"; -import type { FlareSetting } from "./blob/blob"; +import type { SpikeSetting } from "./blob/blob"; import { type VisualsSetting, Visualizer } from "./visual"; const PREFETCH_AHEAD = 4; // fetch more when this close to the end @@ -845,13 +845,13 @@ function showSetting(name: string, value: string): void { function setVisuals(v: VisualsSetting): void { visual.visuals = v; showSetting("visuals", v); - $("flares-setting").hidden = v !== "liquid"; + $("spikes-setting").hidden = v !== "liquid"; } filtersForm.addEventListener("change", (e) => { const input = e.target as HTMLInputElement; if (input.name === "visuals") setVisuals(input.value as VisualsSetting); - else if (input.name === "flares") visual.flares = input.value as FlareSetting; + else if (input.name === "spikes") visual.spikes = input.value as SpikeSetting; else if (input.name === "crossfade") player.crossfade = Number(input.value); else if (input.name === "bass") player.bassBoost = input.checked; else if (input.name === "sleep") setSleep(input.value); @@ -865,8 +865,8 @@ $("filters-clear").addEventListener("click", () => { function showSettings(): void { showSetting("visuals", visual.visuals); - showSetting("flares", visual.flares); - $("flares-setting").hidden = visual.visuals !== "liquid"; + showSetting("spikes", visual.spikes); + $("spikes-setting").hidden = visual.visuals !== "liquid"; // A saved crossfade that is not one of the choices shows as the nearest one. const fades = [...filtersForm.querySelectorAll("input[name=crossfade]")].map((i) => Number(i.value)); const fade = fades.reduce((a, b) => (Math.abs(b - player.crossfade) < Math.abs(a - player.crossfade) ? b : a)); diff --git a/web/src/style.css b/web/src/style.css index 57fa017..09a5963 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -230,7 +230,7 @@ body.zen #bar { display: none; } color: var(--muted); } #sleep-left { text-transform: none; letter-spacing: 0; color: var(--accent); } -#flares-setting legend small { text-transform: none; letter-spacing: 0; color: var(--muted); font-weight: 400; } +#spikes-setting legend small { text-transform: none; letter-spacing: 0; color: var(--muted); font-weight: 400; } .sheet-actions { display: flex; align-items: center; gap: 1rem; } .sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .sheet-tools input[type=search] { diff --git a/web/src/visual.ts b/web/src/visual.ts index d07de17..408c1a8 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -6,7 +6,7 @@ // Web Audio, otherwise from the track's waveform at the current position. import { artistImg } from "./api"; -import { type FlareSetting, LiquidBlob, SPILL } from "./blob/blob"; +import { LiquidBlob, SPILL, type SpikeSetting } from "./blob/blob"; import { lookFor } from "./blob/style"; import type { Card } from "./cards"; import { accentFor, accentForName } from "./color"; @@ -15,7 +15,7 @@ import type { Player } from "./player"; export type VisualsSetting = "liquid" | "simple"; const VISUALS_KEY = "pl8inf.visuals"; -const FLARES_KEY = "pl8inf.flares"; +const SPIKES_KEY = "pl8inf.spikes"; const SLOP = 10; // px before a press on the drop turns it const REACH = 1.2; // how far past the art's radius the drop can be grabbed const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate @@ -30,7 +30,7 @@ export class Visualizer { private blob: LiquidBlob | null | undefined; private reduced = matchMedia("(prefers-reduced-motion: reduce)"); private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid"; - private flareSetting: FlareSetting = (["on", "off"] as const).find((v) => v === localStorage.getItem(FLARES_KEY)) ?? "auto"; + private spikeSetting: SpikeSetting = (["on", "off"] as const).find((v) => v === localStorage.getItem(SPIKES_KEY)) ?? "auto"; private debug: ((f: Features) => void) | null = null; private dropDebug: ((text: string) => void) | null = null; /// A press on the drop; `on` once it moved enough to turn it. @@ -76,15 +76,15 @@ export class Visualizer { this.attach(this.card, true); } - get flares(): FlareSetting { - return this.flareSetting; + get spikes(): SpikeSetting { + return this.spikeSetting; } - /// Solar flares round the drop: auto, on or off. - set flares(v: FlareSetting) { - this.flareSetting = v; - localStorage.setItem(FLARES_KEY, v); - if (this.blob) this.blob.flareSetting = v; + /// Ferrofluid spikes on the drop: auto, on or off. + set spikes(v: SpikeSetting) { + this.spikeSetting = v; + localStorage.setItem(SPIKES_KEY, v); + if (this.blob) this.blob.spikeSetting = v; } /// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works. @@ -93,7 +93,7 @@ export class Visualizer { if (this.blob === undefined) { this.blob = LiquidBlob.create(this.touch); if (this.blob) { - this.blob.flareSetting = this.flareSetting; + this.blob.spikeSetting = this.spikeSetting; // Plain art while the GPU is gone; the drop comes back with it. this.blob.onLost = () => this.card?.el?.classList.remove("liquid"); this.blob.onRestored = () => this.attach(this.card, true);