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.
This commit is contained in:
nvrl committed 2026-11-02 22:27:11 +01:00
1 parent f2c8915e5a
commit b290cd49e3
10 files changed
+419 -493

No files matched your search

+7 -7
View File
@@ -130,11 +130,11 @@
<label><input type="radio" name="visuals" value="liquid"> liquid</label> <label><input type="radio" name="visuals" value="liquid"> liquid</label>
<label><input type="radio" name="visuals" value="simple"> simple</label> <label><input type="radio" name="visuals" value="simple"> simple</label>
</fieldset> </fieldset>
<fieldset class="modes" id="flares-setting"> <fieldset class="modes" id="spikes-setting">
<legend>solar flares <small>on the liquid drop</small></legend> <legend>ferrofluid spikes <small>on the liquid drop</small></legend>
<label title="on for riddim, tearout and 140 tracks"><input type="radio" name="flares" value="auto"> auto</label> <label title="on for riddim, tearout and 140 tracks"><input type="radio" name="spikes" value="auto"> auto</label>
<label><input type="radio" name="flares" value="on"> on</label> <label><input type="radio" name="spikes" value="on"> on</label>
<label><input type="radio" name="flares" value="off"> off</label> <label><input type="radio" name="spikes" value="off"> off</label>
</fieldset> </fieldset>
<fieldset class="modes"> <fieldset class="modes">
<legend>crossfade</legend> <legend>crossfade</legend>
@@ -274,8 +274,8 @@
<dt>m · + −</dt><dd>mute · volume</dd> <dt>m · + −</dt><dd>mute · volume</dd>
<dt>?</dt><dd>this help</dd> <dt>?</dt><dd>this help</dd>
</dl> </dl>
<p>Drag the liquid artwork sideways to spin it. On riddim the solar flares round it follow the <p>Drag the liquid artwork sideways to spin it. On riddim it turns to ferrofluid: spikes
beat: a loop on each kick, the same crown every bar, a break on each snare. Lock-screen and stand up with the bass, and each kick raises the same crown every bar. Lock-screen and
headphone buttons work too.</p> headphone buttons work too.</p>
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or <p class="pointer-only fineprint">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: closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
+34 -28
View File
@@ -3,7 +3,8 @@
// page; cards around it show their artwork as a plain image. // page; cards around it show their artwork as a plain image.
import type { Features } from "../features"; import type { Features } from "../features";
import { Flares } from "./flares"; import { Caustics } from "./caustics";
import { Ferro } from "./ferro";
import { icosphere } from "./mesh"; import { icosphere } from "./mesh";
import { FRAGMENT, VERTEX } from "./shaders"; import { FRAGMENT, VERTEX } from "./shaders";
import { Spin } from "./spin"; 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 const FAST_FOR = 10; // s of frames on time before it tries one step up again
/// Quality steps: icosphere subdivisions and resolution scale. /// Quality steps: icosphere subdivisions and resolution scale.
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]];
/// Solar flare loops at most, per quality step. /// Quality steps that still draw the light behind the drop.
const FLARE_LOOPS = [8, 6, 5, 4]; 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). /// Ferrofluid spikes: "auto" shows them where the track's look asks for them (style.ts).
export type FlareSetting = "auto" | "on" | "off"; export type SpikeSetting = "auto" | "on" | "off";
const MAX_PIXELS = 2.6e6; // canvas pixels at full quality, about 1600 x 1600 const MAX_PIXELS = 2.6e6; // canvas pixels at full quality, about 1600 x 1600
const UNIFORMS = [ const UNIFORMS = [
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl", "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", "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", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const; ] as const;
@@ -82,9 +84,11 @@ export class LiquidBlob {
readonly spin = new Spin(); readonly spin = new Spin();
private gl: WebGL2RenderingContext; private gl: WebGL2RenderingContext;
private prog!: WebGLProgram; private prog!: WebGLProgram;
/// The loops round the drop; null if their shaders failed, which leaves the drop. private ferro = new Ferro();
private flares: Flares | null = null; spikeSetting: SpikeSetting = "auto";
flareSetting: FlareSetting = "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 proj = perspective(FOV, 1, 0.1, 20);
private scaleNow: [number, number, number] = [1, 1, 1]; private scaleNow: [number, number, number] = [1, 1, 1];
private liftNow = 0; private liftNow = 0;
@@ -191,10 +195,10 @@ export class LiquidBlob {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
try { try {
this.flares = new Flares(gl, FLARE_LOOPS[this.quality]); this.caustics = new Caustics(gl);
} catch (err) { } catch (err) {
this.flares = null; this.caustics = null;
console.warn("no solar flares:", (err as Error).message); console.warn("no caustics:", (err as Error).message);
} }
gl.useProgram(prog); gl.useProgram(prog);
} }
@@ -236,7 +240,8 @@ export class LiquidBlob {
this.morph = 0; this.morph = 0;
this.slow = this.fast = 0; this.slow = this.fast = 0;
this.spin.reset(); this.spin.reset();
this.flares?.reset(); this.ferro.reset();
this.light = 0;
this.iconFor = null; this.iconFor = null;
const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint)); const source = await artwork(art, initial, tint).catch(() => artwork(null, initial, tint));
if (gen !== this.gen || this.gl.isContextLost()) return false; 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.uniform4f(u.uPointer, ...this.spin.toObject(px, py, pz), pw);
gl.uniform3f(u.uFront, ...this.spin.toObject(0, 0, 1)); gl.uniform3f(u.uFront, ...this.spin.toObject(0, 0, 1));
gl.uniformMatrix3fv(u.uRot, false, this.spin.matrix); 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); 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.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); 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; const fading = Math.abs(lit - this.light) > 0.003;
// Not while the drop is still flat artwork, nor over the icon. return this.morph < 1 || moving || spinning || showing || spiking || fading || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
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;
} }
/// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for /// 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`, `vertex highp ${hp ? hp.precision : "?"} bits`,
`ready ${this.ready} · lost ${gl.isContextLost()} · error ${gl.getError()}`, `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}`, `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}`, `in page ${this.canvas.isConnected} · css ${this.canvas.clientWidth}x${this.canvas.clientHeight}`,
].join("\n"); ].join("\n");
} }
@@ -476,7 +483,6 @@ export class LiquidBlob {
const before = QUALITY[this.quality][0]; const before = QUALITY[this.quality][0];
this.quality = q; this.quality = q;
if (QUALITY[q][0] !== before) this.buildMesh(); if (QUALITY[q][0] !== before) this.buildMesh();
this.flares?.setMax(FLARE_LOOPS[q]);
} }
} }
+151
View File
@@ -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);
}
}
+140
View File
@@ -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;
}
}
}
-414
View File
@@ -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(),
});
}
}
+48 -7
View File
@@ -1,10 +1,12 @@
import { SPIKES } from "./ferro";
// GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with // 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 // 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. // 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: // 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence:
// https://github.com/ashima/webgl-noise // 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; } 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 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); } 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 mat3 uRot; // turned by hand, object to view space
uniform vec3 uFront; // towards the viewer, in object space uniform vec3 uFront; // towards the viewer, in object space
uniform float uCalm; // calms the surface while the icon or the loading ring shows 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 vPos; // view space
out vec3 vNormal; // view space out vec3 vNormal; // view space
out vec3 vObj; // the resting sphere; its front projection carries the artwork out vec3 vObj; // the resting sphere; its front projection carries the artwork
out float vSpike; // how far out on a spike, 0..1
${NOISE} ${NOISE}
@@ -162,10 +168,39 @@ float disp(vec3 p) {
float wave = exp(-ahead * ahead) * exp(-uKickAge * 2.8) * 0.09 * uShock; float wave = exp(-ahead * ahead) * exp(-uKickAge * 2.8) * 0.09 * uShock;
float near = distance(p, uPointer.xyz) * 2.2; float near = distance(p, uPointer.xyz) * 2.2;
float dent = -0.18 * uPointer.w * exp(-near * near); 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. // 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); 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() { void main() {
vec3 p = position; vec3 p = position;
// The surface is the sphere pushed out by h. Its normal tips against the slope of // 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 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); vec3 t2 = cross(p, t1);
const float e = 0.012; const float e = 0.012;
float h = disp(p) * uMorph; vec3 fe = ferro(p, t1, t2) * uMorph;
float g1 = (disp(p + t1 * e) * uMorph - h) / e; float h0 = disp(p) * uMorph;
float g2 = (disp(p + t2 * e) * uMorph - h) / e; 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; 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. // Flattened into the static artwork while it morphs in; the normal scales the other way.
float depth = mix(0.04, 1.0, uMorph); float depth = mix(0.04, 1.0, uMorph);
@@ -195,6 +233,7 @@ precision highp float;
in vec3 vPos; in vec3 vPos;
in vec3 vNormal; in vec3 vNormal;
in vec3 vObj; in vec3 vObj;
in float vSpike;
uniform sampler2D uArt; uniform sampler2D uArt;
uniform vec3 uAccent; uniform vec3 uAccent;
@@ -248,16 +287,18 @@ void main() {
// A thin, hot edge that flares on hits. // A thin, hot edge that flares on hits.
float edge = fres * fres; 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 += 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 += 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 // 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. // put while the surface turns under them, which is what makes a spin read.
vec3 r = reflect(-v, n); vec3 r = reflect(-v, n);
vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); 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)))); 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 // 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 // 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. // ring round it shows how much has played, or runs while the track loads.
+22 -20
View File
@@ -29,43 +29,45 @@ export interface Look {
heat: number; heat: number;
/// Rim hue offset from the key, radians. /// Rim hue offset from the key, radians.
hue: number; 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. /// where this is above 0.
flares: number; ferro: number;
/// Loop positions round the drop: one per beat for a riddim (4), so each bar /// Spots round the rim that kicks raise spikes at: one per beat for a riddim (4),
/// draws the same crown again. /// so each bar raises the same crown again.
crown: number; 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; scatter: number;
/// Loop height over the surface, in radii. /// Spike height, in radii.
arch: number; reach: number;
/// Beats a loop lives. /// Beats a kick's spikes stay up.
life: number; hold: number;
/// Snares weaker than this pinch no loop. /// Snares weaker than this jolt no spikes.
erupt: number; jolt: number;
/// Brightness of the light the drop throws behind it (caustics.ts).
caustics: number;
} }
const BASE: Look = { 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, 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<string, (l: Look) => void> = { const TAGS: Record<string, (l: Look) => 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, { 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, 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 }), 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, flares: 1, crown: 6, scatter: 0.6, erupt: 0.2 }), 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, { 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, 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 }), 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, 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 }), 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 l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 };
const tags = t.tags ?? []; const tags = t.tags ?? [];
// Untagged dubstep tempo counts too: most riddim carries no riddim tag. // 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); for (const tag of tags) TAGS[tag]?.(l);
l.hue = keyHue(t.key); l.hue = keyHue(t.key);
return l; return l;
+5 -5
View File
@@ -9,7 +9,7 @@ import { Pager } from "./pager";
import { Player } from "./player"; import { Player } from "./player";
import { Sheet } from "./sheet"; import { Sheet } from "./sheet";
import { Station } from "./station"; import { Station } from "./station";
import type { FlareSetting } from "./blob/blob"; import type { SpikeSetting } from "./blob/blob";
import { type VisualsSetting, Visualizer } from "./visual"; import { type VisualsSetting, Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end 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 { function setVisuals(v: VisualsSetting): void {
visual.visuals = v; visual.visuals = v;
showSetting("visuals", v); showSetting("visuals", v);
$("flares-setting").hidden = v !== "liquid"; $("spikes-setting").hidden = v !== "liquid";
} }
filtersForm.addEventListener("change", (e) => { filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
if (input.name === "visuals") setVisuals(input.value as VisualsSetting); 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 === "crossfade") player.crossfade = Number(input.value);
else if (input.name === "bass") player.bassBoost = input.checked; else if (input.name === "bass") player.bassBoost = input.checked;
else if (input.name === "sleep") setSleep(input.value); else if (input.name === "sleep") setSleep(input.value);
@@ -865,8 +865,8 @@ $("filters-clear").addEventListener("click", () => {
function showSettings(): void { function showSettings(): void {
showSetting("visuals", visual.visuals); showSetting("visuals", visual.visuals);
showSetting("flares", visual.flares); showSetting("spikes", visual.spikes);
$("flares-setting").hidden = visual.visuals !== "liquid"; $("spikes-setting").hidden = visual.visuals !== "liquid";
// A saved crossfade that is not one of the choices shows as the nearest one. // A saved crossfade that is not one of the choices shows as the nearest one.
const fades = [...filtersForm.querySelectorAll<HTMLInputElement>("input[name=crossfade]")].map((i) => Number(i.value)); const fades = [...filtersForm.querySelectorAll<HTMLInputElement>("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)); const fade = fades.reduce((a, b) => (Math.abs(b - player.crossfade) < Math.abs(a - player.crossfade) ? b : a));
+1 -1
View File
@@ -230,7 +230,7 @@ body.zen #bar { display: none; }
color: var(--muted); color: var(--muted);
} }
#sleep-left { text-transform: none; letter-spacing: 0; color: var(--accent); } #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-actions { display: flex; align-items: center; gap: 1rem; }
.sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.sheet-tools input[type=search] { .sheet-tools input[type=search] {
+11 -11
View File
@@ -6,7 +6,7 @@
// Web Audio, otherwise from the track's waveform at the current position. // Web Audio, otherwise from the track's waveform at the current position.
import { artistImg } from "./api"; 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 { lookFor } from "./blob/style";
import type { Card } from "./cards"; import type { Card } from "./cards";
import { accentFor, accentForName } from "./color"; import { accentFor, accentForName } from "./color";
@@ -15,7 +15,7 @@ import type { Player } from "./player";
export type VisualsSetting = "liquid" | "simple"; export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals"; 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 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 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 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 blob: LiquidBlob | null | undefined;
private reduced = matchMedia("(prefers-reduced-motion: reduce)"); private reduced = matchMedia("(prefers-reduced-motion: reduce)");
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid"; 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 debug: ((f: Features) => void) | null = null;
private dropDebug: ((text: string) => void) | null = null; private dropDebug: ((text: string) => void) | null = null;
/// A press on the drop; `on` once it moved enough to turn it. /// 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); this.attach(this.card, true);
} }
get flares(): FlareSetting { get spikes(): SpikeSetting {
return this.flareSetting; return this.spikeSetting;
} }
/// Solar flares round the drop: auto, on or off. /// Ferrofluid spikes on the drop: auto, on or off.
set flares(v: FlareSetting) { set spikes(v: SpikeSetting) {
this.flareSetting = v; this.spikeSetting = v;
localStorage.setItem(FLARES_KEY, v); localStorage.setItem(SPIKES_KEY, v);
if (this.blob) this.blob.flareSetting = v; if (this.blob) this.blob.spikeSetting = v;
} }
/// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works. /// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works.
@@ -93,7 +93,7 @@ export class Visualizer {
if (this.blob === undefined) { if (this.blob === undefined) {
this.blob = LiquidBlob.create(this.touch); this.blob = LiquidBlob.create(this.touch);
if (this.blob) { 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. // Plain art while the GPU is gone; the drop comes back with it.
this.blob.onLost = () => this.card?.el?.classList.remove("liquid"); this.blob.onLost = () => this.card?.el?.classList.remove("liquid");
this.blob.onRestored = () => this.attach(this.card, true); this.blob.onRestored = () => this.attach(this.card, true);