mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
// 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(),
|
|
});
|
|
}
|
|
}
|