mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
f2c8915e5a
commit
b290cd49e3
10 files changed
+419
-493
No files matched your search
+7
-7
@@ -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
@@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user