From f2c8915e5a2ccf8edfb67b8b728dcae904754ed3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 2 Nov 2026 22:11:56 +0100 Subject: [PATCH] Flares as loops of the drop's own liquid --- web/src/blob/blob.ts | 3 +- web/src/blob/flares.ts | 293 ++++++++++++++++++++++++----------------- web/src/blob/style.ts | 14 +- 3 files changed, 183 insertions(+), 127 deletions(-) diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index d66ff25..dbb79c0 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -45,7 +45,7 @@ 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 = [10, 8, 6, 4]; +const FLARE_LOOPS = [8, 6, 5, 4]; /// Solar flares: "auto" shows them where the track's look asks for them (style.ts). export type FlareSetting = "auto" | "on" | "off"; @@ -339,6 +339,7 @@ export class LiquidBlob { 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; diff --git a/web/src/blob/flares.ts b/web/src/blob/flares.ts index 67aa987..45c5b02 100644 --- a/web/src/blob/flares.ts +++ b/web/src/blob/flares.ts @@ -1,151 +1,186 @@ -// Solar flares round the drop: coronal loops, the magnetic arcs that leave a star at -// one point and land at another, played by the music. +// 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 raises a loop. Where it rises follows the beat, so a riddim, which +// - 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 and drops every loop: each "womb" swells them. -// - Bright knots of plasma run along each loop once per womb. -// - The wobble's filter sets the heat: a dark "oo" glows deep red, a bright "wah" -// burns yellow to white, and twists the loops. -// - A snare breaks a loop at its top and throws the top out, like an eruption. -// - In a gap (the stop before a drop) the loops sink down and plasma rains back -// to their feet; the drop then sets everything off at once. +// - 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, added onto it. -// They test against its depth, so the drop hides the parts behind it. +// 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 = 40; // along each loop +const SEGMENTS = 32; // along each loop +const SIDES = 10; // round it const FLOATS = 16; // per loop in the instance buffer -const RISE = 0.18; // s for a new loop to stand up -const FADE = 0.35; // s for an old one to go -const ERUPT = 0.7; // s from the break until the loop is gone -const KICK_MIN = 0.2; // weaker kicks raise no loop -const SPAN = [0.22, 0.42]; // half the distance between a loop's feet, rad -const WIDTH = 0.03; // across a loop, in radii -const HEAT_SMOOTH = 0.12; // s -const DROP_BURST = 0.55; // the drop rising through this sets off a burst of loops +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 it lives, from the tempo. + /// Seconds before it sinks back, from the tempo. life: number; - /// Seconds since it broke, or -1. + /// Seconds since its top was pinched off, or -1. erupt: number; seed: number; - /// Knots travelled, in loop lengths. + /// 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 aStrip; // along 0..1, across -1..1 +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, heat, seed -layout(location = 4) in vec4 aQ; // erupt 0..1, twist, width, phase +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 vSide; out float vAlpha; -out float vHeat; out float vPhase; -out float vErupt; -// The loop's centre line at t, in view space. +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(3.14159265 * t); + float arch = sin(PI * t); float e = aQ.x; - // Broken, the legs sink and the top is thrown out. - float top = smoothstep(0.7, 0.98, arch); - float h = aP.x * arch * (1.0 - 0.6 * e) + e * (0.25 + 0.5 * e) * top; - vec3 p = base * (1.0 + h); - // A twist: the loop winds round its own line, more at the top. + // 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.06 * sin(t * 12.566 + uTime * 3.0 + aP.w * 6.283) * arch; - return uRot * p * uScale + vec3(0.0, uLift, -uDist); + p += side * aQ.y * 0.05 * sin(t * 6.283 + uTime * 2.0 + aP.w * 6.283) * arch; + return p; } void main() { - float t = aStrip.x; - vec3 v = along(t); - vec3 ahead = along(min(1.0, t + 0.01)) - along(max(0.0, t - 0.01)); - vec3 across = normalize(cross(ahead, v) + vec3(1e-5)); - // Thicker legs, thin at the top, like the real ones look. - float arch = sin(3.14159265 * t); - v += across * aStrip.y * aQ.z * (1.0 - 0.35 * arch); + 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; - vSide = aStrip.y; vAlpha = aP.y; - vHeat = aP.z; vPhase = aQ.w; - vErupt = aQ.x; - gl_Position = uProj * vec4(v, 1.0); + 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 vSide; in float vAlpha; -in float vHeat; in float vPhase; -in float vErupt; +uniform sampler2D uArt; uniform vec3 uAccent; -uniform float uTint; -uniform float uRain; // 0..1: plasma runs down to the feet instead of along -uniform float uFlash; // the feet light up on a drop +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; -// Glowing plasma from cool to hot: deep red, orange, yellow, white. -vec3 heat(float h) { - vec3 c = mix(vec3(0.45, 0.02, 0.0), vec3(1.0, 0.3, 0.02), smoothstep(0.0, 0.35, h)); - c = mix(c, vec3(1.0, 0.78, 0.25), smoothstep(0.35, 0.7, h)); - return mix(c, vec3(1.0, 0.97, 0.9), smoothstep(0.7, 1.0, h)); +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() { - float across = 1.0 - abs(vSide); - float core = pow(across, 6.0); - float glow = pow(across, 1.5); - // Knots: along the loop, or from its top down both legs while it rains. - float s = mix(vT, 1.0 - abs(vT - 0.5) * 2.0, uRain); - float k = fract(s * 2.0 - vPhase); - float knot = smoothstep(0.0, 0.06, k) * (1.0 - smoothstep(0.06, 0.32, k)); - float feet = 1.0 - smoothstep(0.0, 0.14, min(vT, 1.0 - vT)); - vec3 hot = heat(vHeat); - vec3 col = hot * (0.35 * glow + 0.8 * core); - col += heat(min(1.0, vHeat + 0.35)) * knot * (0.3 * glow + 1.1 * core); - col += heat(min(1.0, vHeat + 0.5)) * feet * glow * (0.25 + 1.2 * uFlash); - // Leaning towards the card's colours, keeping the brightness. - col = mix(col, uAccent * dot(col, vec3(0.6)), uTint); - // A broken loop fades from the break outwards. - col *= vAlpha * (1.0 - vErupt * smoothstep(0.0, 0.5, 1.0 - abs(vT - 0.5) * 2.0) * 0.5); - // Added onto what is there; premultiplied, so alpha is the brightest channel. - outColor = vec4(col, max(col.r, max(col.g, col.b))); + 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", "uAccent", "uTint", "uRain", "uFlash"] as const; +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 { @@ -155,6 +190,8 @@ export interface FlareFrame { 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]; } @@ -162,13 +199,11 @@ export interface FlareFrame { export class Flares { private prog!: WebGLProgram; private vao!: WebGLVertexArrayObject; - private strip!: WebGLBuffer; private inst!: WebGLBuffer; + private count = 0; private u = {} as Record<(typeof FLARE_UNIFORMS)[number], WebGLUniformLocation | null>; private data: Float32Array; private loops: Loop[] = []; - private heat = 0.3; - private time = 0; private lastDrop = 0; /// Where on the crown the next loop goes, for tracks with scatter. private turn = Math.random(); @@ -196,16 +231,34 @@ export class Flares { 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 strip = new Float32Array((SEGMENTS + 1) * 4); - for (let i = 0; i <= SEGMENTS; i++) strip.set([i / SEGMENTS, -1, i / SEGMENTS, 1], i * 4); - this.strip = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, this.strip); - gl.bufferData(gl.ARRAY_BUFFER, strip, gl.STATIC_DRAW); + 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); @@ -240,12 +293,10 @@ export class Flares { return this.loops.length > 0; } - /// Step the loops by `dt` and draw them. `strength` 0 lets them die out. + /// 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; - this.time += dt; - this.heat += (Math.min(1, 0.3 + 0.55 * f.growl * Math.max(0.4, f.bass) + 0.35 * f.drop) - this.heat) * (1 - Math.exp(-dt / HEAT_SMOOTH)); 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) { @@ -253,37 +304,43 @@ export class Flares { } this.lastDrop = f.drop; if (on && f.snareHit >= look.erupt) { - const victim = this.loops.filter((l) => l.erupt < 0 && l.age > RISE).sort((x, y) => y.age - x.age)[0]; + 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; } - // Knots run once along a loop per womb, or with the beat while there is no wobble. + // 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 want = look.arch * (0.3 + 0.55 * f.bass + 0.3 * f.drop) * (1 - 0.8 * f.gap); - const follow = 1 - Math.exp(-dt / 0.06); + 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 * (1 - 0.5 * f.gap); - l.height += (want * (0.85 + 0.3 * l.seed) - l.height) * follow; + l.phase += dt * rate; if (l.erupt >= 0) l.erupt += dt; - // Without music they go, quicker than they would. - if (!on) l.life = Math.min(l.life, l.age + FADE); - return l.erupt < ERUPT && l.age < l.life; + // 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 rise = Math.min(1, l.age / RISE); - const fade = Math.min(1, (l.life - l.age) / FADE); - const alpha = rise * rise * (3 - 2 * rise) * Math.max(0, fade) * Math.max(strength, on ? 0 : 1); + 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, - l.height * rise, alpha * (1 - e * e), this.heat, l.seed, - e, 0.2 + 1.2 * f.growl * f.bass + 0.3 * f.wobble, WIDTH * (1 + 0.6 * f.drop), l.phase, + 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); } @@ -296,20 +353,18 @@ export class Flares { gl.uniform3f(u.uScale, ...view.scale); gl.uniform1f(u.uLift, view.lift); gl.uniform1f(u.uDist, view.dist); - gl.uniform1f(u.uTime, this.time); + gl.uniform1f(u.uTime, view.time); gl.uniform3fv(u.uAccent, view.accent); - gl.uniform1f(u.uTint, look.tint); - gl.uniform1f(u.uRain, f.gap); - gl.uniform1f(u.uFlash, f.drop); + 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); - // Added on, tested against the drop but not hiding each other. - gl.depthMask(false); - gl.blendFunc(gl.ONE, gl.ONE); - gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, (SEGMENTS + 1) * 2, n); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.depthMask(true); + gl.drawElementsInstanced(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0, n); gl.bindVertexArray(null); return true; } @@ -318,9 +373,9 @@ export class Flares { /// 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 makes room. + // 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 + FADE); + 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)); @@ -333,7 +388,7 @@ export class Flares { } // 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.4; + 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); @@ -352,6 +407,8 @@ export class Flares { seed: Math.random(), phase: Math.random(), height: 0, + speed: 0, + goal: 0.85 + 0.3 * Math.random(), }); } } diff --git a/web/src/blob/style.ts b/web/src/blob/style.ts index 082bada..e664727 100644 --- a/web/src/blob/style.ts +++ b/web/src/blob/style.ts @@ -41,33 +41,31 @@ export interface Look { arch: number; /// Beats a loop lives. life: number; - /// Snares weaker than this break no loop. + /// Snares weaker than this pinch no loop. erupt: number; - /// How far the loops' colour leans from the sun's towards the card's accent. - tint: 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.5, life: 4, erupt: 0.45, tint: 0.15, + flares: 0, crown: 6, scatter: 0.4, arch: 0.32, life: 4, erupt: 0.45, }; const TAGS: Record void> = { // Riddim: the loops snap to the beat 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.55, life: 4, erupt: 0.4, + flares: 1, crown: 4, scatter: 0, arch: 0.36, life: 4, erupt: 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 }), 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.8, life: 8, erupt: 0.8, tint: 0.5, + flares: 0, crown: 5, scatter: 0.3, arch: 0.45, life: 8, erupt: 0.8, }), - colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15, tint: 0.6 }), + colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, 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, stiff: 70, crown: 2, arch: 0.7 }), + halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70, crown: 2, arch: 0.45 }), wonk: (l) => Object.assign(l, { twist: 0.9 }), };