mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Make chrome cheap to draw and restyle it as dark metal
This commit is contained in:
1 parent
cfccfcaac6
commit
041ce73a3e
9 files changed
+666
-373
No files matched your search
Generated
+4
-4
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "pl8inf-web",
|
||||
"dependencies": {
|
||||
"@fontsource/bruno-ace-sc": "^5.3.0",
|
||||
"@fontsource-variable/unbounded": "^5.3.0",
|
||||
"hls.js": "1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -14,10 +14,10 @@
|
||||
"vite": "8.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bruno-ace-sc": {
|
||||
"node_modules/@fontsource-variable/unbounded": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bruno-ace-sc/-/bruno-ace-sc-5.3.0.tgz",
|
||||
"integrity": "sha512-jQLpVbpmped/MMXyukOLGyroF/SBqY36slCy0GgDyYKcSbTCLcoz32rW1E+i8Mvvm61NfWIyPfeM9Bem3/1QQg==",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.3.0.tgz",
|
||||
"integrity": "sha512-ea565Ncn4r7gPdBEHcmaF31V11hDfkL7b/JCklLobQbMfbu2ZZQrVWNgdeEFWFLqNh36yhieZWOgxdAnMTzrLg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bruno-ace-sc": "^5.3.0",
|
||||
"@fontsource-variable/unbounded": "^5.3.0",
|
||||
"hls.js": "1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -391,6 +391,11 @@ export class LiquidBlob {
|
||||
);
|
||||
}
|
||||
|
||||
/// Stepped down to the lowest quality: the GPU had trouble keeping up.
|
||||
get struggling(): boolean {
|
||||
return this.quality === QUALITY.length - 1;
|
||||
}
|
||||
|
||||
/// Step the quality down when frames keep coming in slow, and back up once they
|
||||
/// have been on time for a while.
|
||||
private watch(dt: number): void {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// The CPU half of chrome (chrome.ts): a shape's mask baked into the texture its
|
||||
// shader lights. Runs in a worker (chrome-worker.ts), or inline where there is none.
|
||||
|
||||
/// A run of the mask that forms one line: its face runs sky to floor over `height`.
|
||||
export interface Line {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/// A mask to bake, in device px.
|
||||
export interface BakeJob {
|
||||
alpha: Uint8ClampedArray;
|
||||
w: number;
|
||||
h: number;
|
||||
bevel: number;
|
||||
depth: number;
|
||||
lines: Line[];
|
||||
}
|
||||
|
||||
const INF = 1e20;
|
||||
|
||||
/// The mask's alpha to the texture the shader lights: RG the surface normal, B
|
||||
/// the coverage of the metal (face and side wall), A the shadow under it.
|
||||
export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array<ArrayBuffer> {
|
||||
const n = w * h;
|
||||
const cover = new Float32Array(n);
|
||||
// Squared distance from inside the shape to its edge, with sub-pixel edges.
|
||||
const grid = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = alpha[i * 4 + 3] / 255;
|
||||
cover[i] = a;
|
||||
grid[i] = a === 1 ? INF : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2;
|
||||
}
|
||||
edt(grid, w, h);
|
||||
const height = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = Math.min(1, Math.sqrt(grid[i]) / bevel);
|
||||
// Mostly a flat chamfer, rounded a little at the top.
|
||||
height[i] = 0.6 * t + 0.4 * (1 - (1 - t) * (1 - t));
|
||||
}
|
||||
// How far down its line each pixel is; each line is a plate curved top to
|
||||
// bottom, so its face runs from the sky through the horizon to the floor.
|
||||
const run = new Float32Array(n).fill(0.5);
|
||||
for (const l of lines) {
|
||||
const x0 = Math.max(0, Math.floor(l.left)), x1 = Math.min(w, Math.ceil(l.left + l.width));
|
||||
const y0 = Math.max(0, Math.floor(l.top)), y1 = Math.min(h, Math.ceil(l.top + l.height + depth));
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const v = Math.min(1, (y - l.top) / Math.max(1, l.height));
|
||||
for (let x = x0; x < x1; x++) run[y * w + x] = v;
|
||||
}
|
||||
}
|
||||
// Slope per px times the bevel width: a bevel at about 55 degrees.
|
||||
const k = 0.5 * bevel * 1.4;
|
||||
const shadow = depth ? shadowOf(cover, w, h, depth) : null;
|
||||
const out = new Uint8Array(n * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
const hx = (x + 1 < w ? height[i + 1] : 0) - (x > 0 ? height[i - 1] : 0);
|
||||
const hy = (y + 1 < h ? height[i + w] : 0) - (y > 0 ? height[i - w] : 0);
|
||||
let nx = -hx * k, ny = hy * k + 0.38 - run[i] * 0.8, nz = 1;
|
||||
// The side wall: the nearest face above, up to `depth` px; the further
|
||||
// down from it, the more it faces the floor.
|
||||
let m = cover[i];
|
||||
let up = 0;
|
||||
for (let d = 1; m < 1 && d <= depth && y - d >= 0; d++) {
|
||||
if (cover[i - d * w] > 0.5) {
|
||||
up = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (up) {
|
||||
const wall = 1 - m;
|
||||
const t = up / depth;
|
||||
nx *= m;
|
||||
ny = ny * m + wall * -(0.4 + 1.2 * t);
|
||||
m = 1;
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
out[i * 4] = Math.round((nx / len) * 127.5 + 127.5);
|
||||
out[i * 4 + 1] = Math.round((ny / len) * 127.5 + 127.5);
|
||||
out[i * 4 + 2] = Math.round(m * 255);
|
||||
out[i * 4 + 3] = shadow ? Math.round(shadow[i] * 255) : 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The coverage moved down past the side wall and blurred: the shadow it casts.
|
||||
function shadowOf(cover: Float32Array, w: number, h: number, depth: number): Float32Array {
|
||||
const drop = depth + Math.max(1, Math.round(depth * 0.5));
|
||||
const r = Math.max(1, Math.round(depth * 0.8));
|
||||
let a: Float32Array<ArrayBuffer> = new Float32Array(cover.length);
|
||||
for (let y = drop; y < h; y++) a.set(cover.subarray((y - drop) * w, (y - drop + 1) * w), y * w);
|
||||
// Two box blurs each way are close enough to a gaussian.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
a = boxBlur(a, w, h, r, 1, w);
|
||||
a = boxBlur(a, h, w, r, w, 1);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/// Blur runs of `len` values `step` apart, `count` of them `stride` apart.
|
||||
function boxBlur(src: Float32Array, len: number, count: number, r: number, step: number, stride: number): Float32Array<ArrayBuffer> {
|
||||
const dst = new Float32Array(src.length);
|
||||
const norm = 1 / (2 * r + 1);
|
||||
for (let j = 0; j < count; j++) {
|
||||
const o = j * stride;
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += src[o + Math.min(len - 1, Math.max(0, i)) * step];
|
||||
for (let i = 0; i < len; i++) {
|
||||
dst[o + i * step] = sum * norm;
|
||||
sum += src[o + Math.min(len - 1, i + r + 1) * step] - src[o + Math.max(0, i - r) * step];
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/// Felzenszwalb and Huttenlocher's distance transform, in place: columns, then rows.
|
||||
function edt(grid: Float64Array, w: number, h: number): void {
|
||||
const size = Math.max(w, h);
|
||||
const f = new Float64Array(size), z = new Float64Array(size + 1), v = new Uint16Array(size);
|
||||
for (let x = 0; x < w; x++) edt1d(grid, x, w, h, f, v, z);
|
||||
for (let y = 0; y < h; y++) edt1d(grid, y * w, 1, w, f, v, z);
|
||||
}
|
||||
|
||||
function edt1d(grid: Float64Array, offset: number, stride: number, length: number, f: Float64Array, v: Uint16Array, z: Float64Array): void {
|
||||
v[0] = 0;
|
||||
z[0] = -INF;
|
||||
z[1] = INF;
|
||||
f[0] = grid[offset];
|
||||
for (let q = 1, k = 0, s = 0; q < length; q++) {
|
||||
f[q] = grid[offset + q * stride];
|
||||
const q2 = q * q;
|
||||
do {
|
||||
const r = v[k];
|
||||
s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2;
|
||||
} while (s <= z[k] && --k > -1);
|
||||
k++;
|
||||
v[k] = q;
|
||||
z[k] = s;
|
||||
z[k + 1] = INF;
|
||||
}
|
||||
for (let q = 0, k = 0; q < length; q++) {
|
||||
while (z[k + 1] < q) k++;
|
||||
const r = v[k], qr = q - r;
|
||||
grid[offset + q * stride] = f[r] + qr * qr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Bakes chrome shapes off the main thread; see chrome-bake.ts.
|
||||
|
||||
import { bake, type BakeJob } from "./chrome-bake";
|
||||
|
||||
self.onmessage = (e: MessageEvent<BakeJob & { id: number }>) => {
|
||||
const pixels = bake(e.data);
|
||||
(self as unknown as Worker).postMessage({ id: e.data.id, pixels }, [pixels.buffer]);
|
||||
};
|
||||
+266
-249
@@ -1,33 +1,41 @@
|
||||
// Chrome: the playing card's title, its waveform and the logo as bevelled,
|
||||
// polished metal, after chrome lettering. Drawn by WebGL2, not by CSS.
|
||||
// Chrome: the playing card's title, its waveform and the logo as heavy, polished
|
||||
// metal, after chrome lettering on a dark flyer. Drawn by WebGL2, not by CSS.
|
||||
//
|
||||
// A shape (glyphs or waveform bars) is drawn as a mask on a 2D canvas. A distance
|
||||
// transform turns the mask into a height field: every edge is a bevel of a fixed
|
||||
// width, so thin strokes become sharp ridges, like blades, and thick ones get a
|
||||
// flat face. A fragment shader then lights it as a mirror: normals from the
|
||||
// slope, the reflection of a studio around it (softboxes in a pale sky, a dark
|
||||
// horizon, the card's colour and art as the floor), split per colour channel at
|
||||
// the steep edges, a highlight that follows the pointer and flares on kicks.
|
||||
// A shape (glyphs or waveform bars) is drawn as a mask on a 2D canvas and baked
|
||||
// once, on the CPU and off the main thread (chrome-bake.ts), into a texture: a distance transform gives every edge a bevel
|
||||
// of a fixed width, the bevel's slope gives the surface normal, a side wall is
|
||||
// pushed out below the shape and a soft shadow falls under it. Lighting it is
|
||||
// then one texture fetch and a few lookups into a small painted studio: mostly
|
||||
// black, a sharp horizon through every letter's face, one white strip above it
|
||||
// and the card's colour as the floor. Colours split at the steep edges, more on
|
||||
// kicks, and a highlight follows the pointer.
|
||||
//
|
||||
// One WebGL2 context, offscreen, serves every plate: each render is copied into
|
||||
// the plate's own 2D canvas, so a page full of chrome costs one context.
|
||||
// One WebGL2 context draws straight into a canvas laid over the playing card, so
|
||||
// nothing is copied per frame. Only the logo, which never moves, is read back
|
||||
// once into its own canvas.
|
||||
|
||||
import { bake, type BakeJob, type Line } from "./chrome-bake";
|
||||
|
||||
const MAX_DPR = 2;
|
||||
const ART_SIZE = 64; // the art is only ever seen blurred, in reflections
|
||||
const ENV_W = 64;
|
||||
const ENV_H = 256; // tall, so the horizon stays sharp
|
||||
const ART_SIZE = 8; // the art is only ever seen blurred, in the floor
|
||||
|
||||
/// What a plate shows.
|
||||
export type Kind = "text" | "wave";
|
||||
|
||||
/// A surface to draw chrome on: a 2D canvas on the page and its shape on the GPU.
|
||||
/// A shape on the GPU, drawn at a place on the layer.
|
||||
export interface Plate {
|
||||
canvas: HTMLCanvasElement;
|
||||
kind: Kind;
|
||||
/// Size in device px, and the bevel width.
|
||||
/// Place on the layer and size, in device px.
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
bevel: number;
|
||||
/// The shape as uploaded (R height, G coverage, B height within the line).
|
||||
/// The baked shape as uploaded (RG normal, B coverage, A shadow).
|
||||
pixels: Uint8Array | null;
|
||||
/// Counts shapes asked for, so a late bake of an old one is dropped.
|
||||
job: number;
|
||||
tex: WebGLTexture | null;
|
||||
/// Waveform: the y of the line the bars stand on, 0..1 from the top.
|
||||
floor: number;
|
||||
@@ -35,11 +43,10 @@ export interface Plate {
|
||||
|
||||
/// Per-render light and state.
|
||||
export interface Light {
|
||||
/// Card colour as linear-ish rgb 0..1.
|
||||
/// Card colour as rgb 0..1.
|
||||
tint: [number, number, number];
|
||||
/// Where the light comes from, -1..1 each way; follows the pointer.
|
||||
tilt: [number, number];
|
||||
time: number;
|
||||
/// Onset envelope, 0..1.
|
||||
kick: number;
|
||||
/// A highlight sweeping across, 0..1 along the plate; outside that, none.
|
||||
@@ -49,8 +56,6 @@ export interface Light {
|
||||
export interface WaveState {
|
||||
progress: number;
|
||||
buffered: number;
|
||||
/// The top of the bar being played, 0..1 from the top; for the glint.
|
||||
top: number;
|
||||
}
|
||||
|
||||
const VERTEX = `#version 300 es
|
||||
@@ -65,152 +70,99 @@ void main() {
|
||||
const FRAGMENT = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D uShape;
|
||||
uniform sampler2D uArt;
|
||||
uniform sampler2D uEnv;
|
||||
uniform vec2 uSize;
|
||||
uniform float uBevel;
|
||||
uniform int uKind;
|
||||
uniform vec3 uTint;
|
||||
uniform vec2 uTilt;
|
||||
uniform float uTime;
|
||||
uniform float uKick;
|
||||
uniform float uSweep;
|
||||
uniform float uFloor;
|
||||
uniform vec3 uWave; // progress, buffered, top of the playing bar
|
||||
uniform float uDepth; // px the shapes stand out of the page
|
||||
uniform vec2 uWave; // progress, buffered
|
||||
in vec2 vUv;
|
||||
out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265;
|
||||
|
||||
vec4 shape(vec2 uv) { return textureLod(uShape, uv, 0.0); }
|
||||
|
||||
// The studio the metal reflects; r is the reflected ray, y up, z to the viewer.
|
||||
vec3 studio(vec3 r) {
|
||||
float y = r.y - uTilt.y * 0.22;
|
||||
float x = r.x + uTilt.x * 0.35;
|
||||
vec3 sky = mix(vec3(0.56, 0.58, 0.64), vec3(1.0), smoothstep(0.04, 0.75, y));
|
||||
// Softboxes: a wide bar high up, a narrow strip that drifts.
|
||||
sky += 1.3 * smoothstep(0.07, 0.0, abs(y - 0.5));
|
||||
sky += 0.9 * smoothstep(0.06, 0.0, abs(x - 0.45 + 0.2 * sin(uTime * 0.21)));
|
||||
sky += 0.6 * smoothstep(0.05, 0.0, abs(x + 0.55 - 0.15 * sin(uTime * 0.17 + 1.0)));
|
||||
// The floor: the card's colour, with its art in it.
|
||||
vec3 art = textureLod(uArt, vec2(fract(x * 0.4 + 0.5), clamp(-y * 1.4, 0.0, 1.0)), 2.5).rgb;
|
||||
vec3 tint = mix(vec3(0.62, 0.62, 0.68), uTint, 0.7);
|
||||
vec3 floorC = mix(tint, art * 1.4, 0.3) * mix(1.3, 0.2, smoothstep(0.0, 0.7, -y));
|
||||
vec3 c = y > 0.0 ? sky : floorC;
|
||||
// The dark horizon: sharp above, soft below.
|
||||
float lit = y > 0.0 ? smoothstep(0.0, 0.045, y) : smoothstep(0.0, 0.2, -y);
|
||||
return c * mix(0.04, 1.0, lit);
|
||||
}
|
||||
|
||||
vec3 shade(vec2 uv, vec2 px, out float cover) {
|
||||
vec4 s = shape(uv);
|
||||
cover = s.g;
|
||||
float hx = shape(uv + vec2(px.x, 0.0)).r - shape(uv - vec2(px.x, 0.0)).r;
|
||||
float hy = shape(uv + vec2(0.0, px.y)).r - shape(uv - vec2(0.0, px.y)).r;
|
||||
// Slope per px times the bevel width: a bevel at about 55 degrees.
|
||||
float k = 0.5 * uBevel * 1.4;
|
||||
vec3 n = normalize(vec3(-hx * k, hy * k, 1.0));
|
||||
// Each line of text (each bar) is a plate curved top to bottom, so its face
|
||||
// runs from sky through the horizon to the floor.
|
||||
float along = s.b;
|
||||
vec3 face = normalize(vec3(n.xy + vec2(0.0, 0.38 - along * 0.8), n.z));
|
||||
float steep = 1.0 - face.z;
|
||||
// Split the colours at the steep edges, like a cheap lens does.
|
||||
vec3 view = vec3(0.0, 0.0, -1.0);
|
||||
float split = 0.08 * steep + 0.01;
|
||||
vec3 c;
|
||||
c.r = studio(reflect(view, normalize(vec3(face.xy * (1.0 + split), face.z)))).r;
|
||||
c.g = studio(reflect(view, face)).g;
|
||||
c.b = studio(reflect(view, normalize(vec3(face.xy * (1.0 - split), face.z)))).b;
|
||||
// A key light that follows the pointer; kicks make it flare.
|
||||
vec3 l = normalize(vec3(uTilt.x * 0.9 - 0.25, -uTilt.y * 0.7 + 0.55, 1.0));
|
||||
float spec = pow(max(dot(reflect(-l, face), vec3(0.0, 0.0, 1.0)), 0.0), 48.0);
|
||||
c += spec * (0.9 + 2.2 * uKick) * mix(vec3(1.0), uTint, 0.25);
|
||||
// The sweep: a bright band slanting across.
|
||||
float band = (uv.x - uSweep) * 7.0 + (uv.y - 0.5) * 1.5;
|
||||
c += 1.6 * exp(-band * band) * step(-0.5, uSweep) * step(uSweep, 1.5);
|
||||
// The crease where a bevel meets the ground darkens.
|
||||
c *= mix(0.45, 1.0, smoothstep(0.0, 0.25, s.r));
|
||||
return c;
|
||||
// The studio seen along the reflection of a ray from the viewer off normal n.
|
||||
vec3 env(vec3 n) {
|
||||
vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);
|
||||
vec2 at = vec2(0.5 + 0.5 * clamp(r.x * 0.8 + uTilt.x * 0.3, -1.0, 1.0), 0.5 - 0.5 * clamp(r.y + uTilt.y * 0.2, -1.0, 1.0));
|
||||
return texture(uEnv, at).rgb;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vec2(vUv.x, 1.0 - vUv.y);
|
||||
vec2 px = 1.0 / uSize;
|
||||
float fade = 1.0;
|
||||
vec2 at = uv;
|
||||
if (uKind == 1 && uv.y > uFloor) {
|
||||
float fade = 1.0;
|
||||
bool wave = uKind == 1;
|
||||
if (wave && uv.y > uFloor) {
|
||||
// Under the line the bars stand on: their reflection, fading out.
|
||||
at.y = 2.0 * uFloor - uv.y;
|
||||
fade = 0.28 * (1.0 - smoothstep(0.0, 1.0 - uFloor, uv.y - uFloor));
|
||||
fade = 0.18 * (1.0 - smoothstep(0.0, 1.0 - uFloor, uv.y - uFloor));
|
||||
}
|
||||
float cover;
|
||||
vec3 c = shade(at, px, cover);
|
||||
float a = cover * fade;
|
||||
// The side wall: the shape has depth, seen a little from above and to the side
|
||||
// of where the light is, so it shifts as the pointer moves.
|
||||
vec2 dir = normalize(vec2(0.3 - uTilt.x * 0.5, 1.0 - uTilt.y * 0.25));
|
||||
vec3 wall = vec3(0.0);
|
||||
float wallA = 0.0;
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
float k = float(i) / 8.0;
|
||||
float g = shape(at - dir * k * uDepth * px).g;
|
||||
if (g > 0.5) {
|
||||
// Bright lip under the face, then darker metal going back.
|
||||
vec3 metal = mix(vec3(0.85, 0.85, 0.9), uTint, 0.45);
|
||||
float glance = 0.5 + 0.5 * sin(at.x * 40.0 + uTilt.x * 3.0 + uTime * 0.3);
|
||||
wall = mix(metal * (0.7 + 0.5 * glance), uTint * 0.12, smoothstep(0.0, 1.0, k));
|
||||
wallA = g * fade;
|
||||
break;
|
||||
}
|
||||
vec4 s = texture(uShape, at);
|
||||
float m = s.b * fade;
|
||||
float shadow = s.a * fade;
|
||||
// Waveform: a thin, hot playhead from the floor up, and a hairline floor.
|
||||
float head = 0.0;
|
||||
float floorLine = 0.0;
|
||||
if (wave) {
|
||||
if (uWave.x > 0.0 && uWave.x < 1.0) head = smoothstep(1.5 * px.x, 0.0, abs(uv.x - uWave.x)) * step(uv.y, uFloor) * smoothstep(0.0, 0.6, uv.y);
|
||||
floorLine = smoothstep(1.2 * px.y, 0.0, abs(uv.y - uFloor - px.y)) * 0.35;
|
||||
}
|
||||
c = mix(wall, c, cover);
|
||||
a = max(a, wallA);
|
||||
if (uKind == 1) {
|
||||
if (m < 0.002 && shadow < 0.002 && head + floorLine < 0.002) discard;
|
||||
|
||||
vec3 n = vec3(s.rg * 2.0 - 1.0, 0.0);
|
||||
n.z = sqrt(max(0.0, 1.0 - dot(n.xy, n.xy)));
|
||||
// Split the colours at the steep edges, like a cheap lens; a kick pushes it.
|
||||
float split = 0.015 + (1.0 - n.z) * (0.05 + 0.3 * uKick);
|
||||
vec3 c;
|
||||
c.r = env(normalize(vec3(n.xy * (1.0 + split), n.z))).r;
|
||||
c.g = env(n).g;
|
||||
c.b = env(normalize(vec3(n.xy * (1.0 - split), n.z))).b;
|
||||
// A key light that follows the pointer; kicks make it flare.
|
||||
vec3 l = normalize(vec3(uTilt.x * 0.9 - 0.25, -uTilt.y * 0.7 + 0.55, 1.0));
|
||||
float spec = pow(max(dot(reflect(-l, n), vec3(0.0, 0.0, 1.0)), 0.0), 64.0);
|
||||
c += spec * (0.7 + 1.8 * uKick) * mix(vec3(1.0), uTint, 0.3);
|
||||
// The sweep: a narrow bright band slanting across.
|
||||
float band = (uv.x - uSweep) * 12.0 + (uv.y - 0.5) * 2.0;
|
||||
c += 1.4 * exp(-band * band) * step(-0.5, uSweep) * step(uSweep, 1.5);
|
||||
if (wave) {
|
||||
// Played bars are chrome; the rest is dark gunmetal, lighter where loaded.
|
||||
float played = step(uv.x, uWave.x);
|
||||
float loaded = step(uv.x, uWave.y);
|
||||
float grey = dot(c, vec3(0.3, 0.59, 0.11));
|
||||
vec3 gun = vec3(grey) * mix(0.5, 0.68, loaded) * vec3(0.92, 0.93, 1.0);
|
||||
vec3 gun = vec3(grey) * mix(0.45, 0.65, loaded) * vec3(0.92, 0.93, 1.0);
|
||||
c = mix(gun, c, played);
|
||||
a *= mix(mix(0.7, 0.85, loaded), 1.0, played);
|
||||
// The floor itself: a hairline of metal, brightest at the playhead.
|
||||
float line = smoothstep(1.5 * px.y, 0.0, abs(uv.y - uFloor - px.y));
|
||||
float near = exp(-abs(uv.x - uWave.x) * 4.0);
|
||||
c = mix(c, vec3(1.0), line * (0.25 + 0.75 * near) * (1.0 - a));
|
||||
a = max(a, line * (0.2 + 0.7 * near));
|
||||
m *= mix(mix(0.75, 0.9, loaded), 1.0, played);
|
||||
}
|
||||
// Under the metal: a soft shadow below it and a glow of the card's colour.
|
||||
float under = textureLod(uShape, at + vec2(0.0, -2.5 * px.y), 2.0).g * fade;
|
||||
float halo = textureLod(uShape, at, 3.5).g * fade;
|
||||
vec3 col = c * a;
|
||||
float alpha = a;
|
||||
float glow = halo * (0.35 + 0.5 * uKick) * (uKind == 1 ? 0.3 : 1.0);
|
||||
col += uTint * glow * (1.0 - alpha);
|
||||
alpha += glow * 0.6 * (1.0 - alpha);
|
||||
alpha += under * 0.55 * (1.0 - alpha);
|
||||
if (uKind == 1 && uWave.x > 0.0 && uWave.x < 1.0) {
|
||||
// A four-point glint on the top of the bar being played.
|
||||
vec2 d = (uv - vec2(uWave.x, uWave.z)) * uSize;
|
||||
float r = 7.0 + 3.0 * sin(uTime * 2.6) + 6.0 * uKick;
|
||||
float rays = exp(-abs(d.x) * 0.9) * exp(-d.y * d.y / (r * r)) + exp(-abs(d.y) * 0.9) * exp(-d.x * d.x / (r * r));
|
||||
float core = exp(-dot(d, d) / 6.0);
|
||||
float g = clamp(rays + core, 0.0, 1.0);
|
||||
col += mix(uTint, vec3(1.0), g) * g;
|
||||
alpha = max(alpha, g);
|
||||
}
|
||||
outColor = vec4(min(col, vec3(alpha)), alpha);
|
||||
vec3 col = c * m;
|
||||
float a = m;
|
||||
vec3 hot = mix(uTint, vec3(1.0), 0.25) * 1.4;
|
||||
col = mix(col, hot, head);
|
||||
a = max(a, head);
|
||||
col += vec3(0.8) * floorLine * (1.0 - a);
|
||||
a += floorLine * (1.0 - a);
|
||||
a += shadow * 0.65 * (1.0 - a);
|
||||
outColor = vec4(min(col, vec3(a)), a);
|
||||
}`;
|
||||
|
||||
const UNIFORMS = ["uShape", "uArt", "uSize", "uBevel", "uKind", "uTint", "uTilt", "uTime", "uKick", "uSweep", "uFloor", "uWave", "uDepth"] as const;
|
||||
const UNIFORMS = ["uShape", "uEnv", "uSize", "uKind", "uTint", "uTilt", "uKick", "uSweep", "uFloor", "uWave"] as const;
|
||||
|
||||
export class Chrome {
|
||||
private canvas: HTMLCanvasElement;
|
||||
/// The layer laid over the playing card.
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
/// Called once the GPU gave the context back; everything should be drawn again.
|
||||
onRestored: (() => void) | null = null;
|
||||
readonly dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
|
||||
private gl: WebGL2RenderingContext;
|
||||
private u: Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null> = {} as never;
|
||||
private art: WebGLTexture | null = null;
|
||||
/// The studio as the playing card colours it, and as the logo sees it.
|
||||
private env: WebGLTexture | null = null;
|
||||
private baseEnv: WebGLTexture | null = null;
|
||||
private art: { img: HTMLImageElement | null; color: string } = { img: null, color: "#ff4d6d" };
|
||||
private plates = new Set<Plate>();
|
||||
private baker = new Baker();
|
||||
|
||||
private constructor(gl: WebGL2RenderingContext) {
|
||||
this.gl = gl;
|
||||
@@ -222,6 +174,7 @@ export class Chrome {
|
||||
p.tex = null;
|
||||
if (p.pixels) this.upload(p);
|
||||
}
|
||||
this.onRestored?.();
|
||||
});
|
||||
this.init();
|
||||
}
|
||||
@@ -229,7 +182,9 @@ export class Chrome {
|
||||
static create(): Chrome | null {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, antialias: false, alpha: true });
|
||||
canvas.className = "chrome-layer";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, antialias: false, alpha: true, depth: false, stencil: false });
|
||||
return gl ? new Chrome(gl) : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -255,35 +210,80 @@ export class Chrome {
|
||||
gl.useProgram(prog);
|
||||
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
|
||||
gl.uniform1i(this.u.uShape, 0);
|
||||
gl.uniform1i(this.u.uArt, 1);
|
||||
gl.uniform1i(this.u.uEnv, 1);
|
||||
gl.bindVertexArray(gl.createVertexArray());
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
this.art = null;
|
||||
this.setArt(null, "#ff4d6d");
|
||||
this.baseEnv = this.paintEnv(gl.createTexture(), null, "#ff4d6d");
|
||||
this.env = this.paintEnv(gl.createTexture(), this.art.img, this.art.color);
|
||||
}
|
||||
|
||||
/// What the floor of the studio shows: the card's art, or its colour.
|
||||
setArt(img: HTMLImageElement | null, color: string): void {
|
||||
const gl = this.gl;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = c.height = ART_SIZE;
|
||||
const ctx = c.getContext("2d")!;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, ART_SIZE, ART_SIZE);
|
||||
if (img) ctx.drawImage(img, 0, 0, ART_SIZE, ART_SIZE);
|
||||
this.art ??= gl.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.art);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
this.art = { img, color };
|
||||
this.env = this.paintEnv(this.env, img, color);
|
||||
}
|
||||
|
||||
plate(canvas: HTMLCanvasElement, kind: Kind): Plate {
|
||||
const p: Plate = { canvas, kind, w: 0, h: 0, bevel: 1, pixels: null, tex: null, floor: 1 };
|
||||
/// Paint the studio into `tex`. Rows run from straight up to straight down.
|
||||
private paintEnv(tex: WebGLTexture | null, img: HTMLImageElement | null, color: string): WebGLTexture | null {
|
||||
const gl = this.gl;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = ENV_W;
|
||||
c.height = ENV_H;
|
||||
const ctx = c.getContext("2d")!;
|
||||
const half = ENV_H / 2;
|
||||
// The sky: black overhead, rising to white at the horizon.
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, half);
|
||||
sky.addColorStop(0, "#000000");
|
||||
sky.addColorStop(0.3, "#16181d");
|
||||
sky.addColorStop(0.62, "#626876");
|
||||
sky.addColorStop(0.9, "#eef1f6");
|
||||
sky.addColorStop(1, "#ffffff");
|
||||
ctx.fillStyle = sky;
|
||||
ctx.fillRect(0, 0, ENV_W, half);
|
||||
// One softbox strip high up, and a faint narrow one to the side.
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, half * 0.38, ENV_W, 3);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.35)";
|
||||
ctx.fillRect(ENV_W * 0.74, 0, 2, half * 0.9);
|
||||
// The floor: black at the horizon, the card's colour and art further down.
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, half, ENV_W, half);
|
||||
if (img) {
|
||||
// Drawn tiny and stretched up: a blur for free.
|
||||
const small = document.createElement("canvas");
|
||||
small.width = small.height = ART_SIZE;
|
||||
small.getContext("2d")!.drawImage(img, 0, 0, ART_SIZE, ART_SIZE);
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.drawImage(small, 0, half, ENV_W, half);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
const dark = ctx.createLinearGradient(0, half, 0, ENV_H);
|
||||
dark.addColorStop(0, "rgba(0,0,0,1)");
|
||||
dark.addColorStop(0.1, "rgba(0,0,0,0.7)");
|
||||
dark.addColorStop(1, "rgba(0,0,0,0.1)");
|
||||
ctx.fillStyle = dark;
|
||||
ctx.fillRect(0, half, ENV_W, half);
|
||||
tex ??= gl.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// Size the layer to `w` x `h` CSS px. Clears it.
|
||||
layout(w: number, h: number): void {
|
||||
const W = Math.max(1, Math.round(w * this.dpr)), H = Math.max(1, Math.round(h * this.dpr));
|
||||
if (this.canvas.width !== W) this.canvas.width = W;
|
||||
if (this.canvas.height !== H) this.canvas.height = H;
|
||||
}
|
||||
|
||||
plate(kind: Kind): Plate {
|
||||
const p: Plate = { kind, x: 0, y: 0, w: 0, h: 0, pixels: null, job: 0, tex: null, floor: 1 };
|
||||
this.plates.add(p);
|
||||
return p;
|
||||
}
|
||||
@@ -292,14 +292,23 @@ export class Chrome {
|
||||
if (p.tex) this.gl.deleteTexture(p.tex);
|
||||
p.tex = null;
|
||||
p.pixels = null;
|
||||
p.job++;
|
||||
this.plates.delete(p);
|
||||
}
|
||||
|
||||
/// Put `p` at `x`, `y` CSS px from the layer's top left.
|
||||
place(p: Plate, x: number, y: number): void {
|
||||
p.x = Math.round(x * this.dpr);
|
||||
p.y = Math.round(y * this.dpr);
|
||||
}
|
||||
|
||||
/// Give `p` its shape: `draw` paints the mask in CSS px onto a canvas of
|
||||
/// `w` x `h` CSS px and returns, per painted region, the line it belongs to
|
||||
/// (for the face's sky-to-floor run). `bevel` is in CSS px.
|
||||
shape(p: Plate, w: number, h: number, bevel: number, draw: (ctx: CanvasRenderingContext2D) => Line[]): void {
|
||||
const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
|
||||
/// (for the face's sky-to-floor run). `bevel` and `depth` (of the side wall
|
||||
/// and the shadow; 0 for none) are in CSS px. Resolves once the shape is on
|
||||
/// the GPU, or false if it was dropped or asked for again in the meantime.
|
||||
async shape(p: Plate, w: number, h: number, bevel: number, depth: number, draw: (ctx: CanvasRenderingContext2D) => Line[]): Promise<boolean> {
|
||||
const dpr = this.dpr;
|
||||
const W = Math.max(1, Math.round(w * dpr)), H = Math.max(1, Math.round(h * dpr));
|
||||
const mask = document.createElement("canvas");
|
||||
mask.width = W;
|
||||
@@ -309,13 +318,15 @@ export class Chrome {
|
||||
ctx.fillStyle = "#fff";
|
||||
const lines = draw(ctx);
|
||||
const alpha = ctx.getImageData(0, 0, W, H).data;
|
||||
const scaled = lines.map((l) => ({ top: l.top * dpr, height: l.height * dpr, left: l.left * dpr, width: l.width * dpr }));
|
||||
const job = ++p.job;
|
||||
const pixels = await this.baker.bake({ alpha, w: W, h: H, bevel: bevel * dpr, depth: Math.round(depth * dpr), lines: scaled });
|
||||
if (job !== p.job) return false;
|
||||
p.w = W;
|
||||
p.h = H;
|
||||
p.bevel = bevel * dpr;
|
||||
p.canvas.width = W;
|
||||
p.canvas.height = H;
|
||||
p.pixels = heights(alpha, W, H, p.bevel, lines.map((l) => ({ top: l.top * dpr, height: l.height * dpr, left: l.left * dpr, width: l.width * dpr })));
|
||||
p.pixels = pixels;
|
||||
this.upload(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
private upload(p: Plate): void {
|
||||
@@ -325,115 +336,118 @@ export class Chrome {
|
||||
gl.bindTexture(gl.TEXTURE_2D, p.tex);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, p.w, p.h, 0, gl.RGBA, gl.UNSIGNED_BYTE, p.pixels);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
/// Light `p` and copy the result onto its canvas.
|
||||
/// Empty the layer, before its plates are drawn again.
|
||||
clear(): void {
|
||||
if (this.lost) return;
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
/// Light `p` at its place on the layer.
|
||||
render(p: Plate, light: Light, wave?: WaveState): void {
|
||||
this.draw(p, p.x, this.canvas.height - p.y - p.h, light, this.env, wave);
|
||||
}
|
||||
|
||||
/// Light `p` once, off screen, and keep the picture in `target`: for a plate
|
||||
/// that never moves.
|
||||
still(p: Plate, target: HTMLCanvasElement, light: Light): void {
|
||||
const gl = this.gl;
|
||||
if (!p.tex || gl.isContextLost()) return;
|
||||
const c = this.canvas;
|
||||
if (c.width < p.w || c.height < p.h) {
|
||||
c.width = Math.max(c.width, p.w);
|
||||
c.height = Math.max(c.height, p.h);
|
||||
}
|
||||
gl.viewport(0, 0, p.w, p.h);
|
||||
const tex = gl.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, p.w, p.h);
|
||||
const fb = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
this.draw(p, 0, 0, light, this.baseEnv);
|
||||
const px = new Uint8Array(p.w * p.h * 4);
|
||||
gl.readPixels(0, 0, p.w, p.h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.deleteFramebuffer(fb);
|
||||
gl.deleteTexture(tex);
|
||||
// GL's rows run bottom up and its colour is premultiplied; ImageData's are not.
|
||||
const img = new ImageData(p.w, p.h);
|
||||
const row = p.w * 4;
|
||||
for (let y = 0; y < p.h; y++) {
|
||||
for (let i = 0, from = (p.h - 1 - y) * row, to = y * row; i < row; i += 4) {
|
||||
const a = px[from + i + 3];
|
||||
const k = a ? 255 / a : 0;
|
||||
img.data[to + i] = px[from + i] * k;
|
||||
img.data[to + i + 1] = px[from + i + 1] * k;
|
||||
img.data[to + i + 2] = px[from + i + 2] * k;
|
||||
img.data[to + i + 3] = a;
|
||||
}
|
||||
}
|
||||
target.width = p.w;
|
||||
target.height = p.h;
|
||||
target.getContext("2d")!.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
private draw(p: Plate, x: number, y: number, light: Light, env: WebGLTexture | null, wave?: WaveState): boolean {
|
||||
const gl = this.gl;
|
||||
if (!p.tex || gl.isContextLost()) return false;
|
||||
gl.viewport(x, y, p.w, p.h);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, env);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, p.tex);
|
||||
gl.uniform2f(this.u.uSize, p.w, p.h);
|
||||
gl.uniform1f(this.u.uBevel, p.bevel);
|
||||
gl.uniform1f(this.u.uDepth, p.bevel * (p.kind === "wave" ? 1.2 : 1.6));
|
||||
gl.uniform1i(this.u.uKind, p.kind === "wave" ? 1 : 0);
|
||||
gl.uniform3fv(this.u.uTint, light.tint);
|
||||
gl.uniform2fv(this.u.uTilt, light.tilt);
|
||||
gl.uniform1f(this.u.uTime, light.time);
|
||||
gl.uniform1f(this.u.uKick, light.kick);
|
||||
gl.uniform1f(this.u.uSweep, light.sweep);
|
||||
gl.uniform1f(this.u.uFloor, p.floor);
|
||||
gl.uniform3f(this.u.uWave, wave?.progress ?? 0, wave?.buffered ?? 0, wave?.top ?? 0);
|
||||
gl.uniform2f(this.u.uWave, wave?.progress ?? 0, wave?.buffered ?? 0);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
// The viewport sits at the bottom left of the GL canvas.
|
||||
const ctx = p.canvas.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, p.w, p.h);
|
||||
ctx.drawImage(c, 0, c.height - p.h, p.w, p.h, 0, 0, p.w, p.h);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of the mask that forms one line: its face runs sky to floor over `height`.
|
||||
export interface Line {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
/// Bakes shapes in a worker; inline where the worker cannot start.
|
||||
class Baker {
|
||||
private worker: Worker | null = null;
|
||||
private next = 1;
|
||||
private waiting = new Map<number, { job: BakeJob; done: (pixels: Uint8Array) => void }>();
|
||||
|
||||
const INF = 1e20;
|
||||
|
||||
/// The mask's alpha to RGBA: R the bevelled height, G the coverage, B how far
|
||||
/// down its line a pixel is.
|
||||
function heights(alpha: Uint8ClampedArray, w: number, h: number, bevel: number, lines: Line[]): Uint8Array {
|
||||
const n = w * h;
|
||||
// Squared distance from inside the shape to its edge, with sub-pixel edges.
|
||||
const grid = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = alpha[i * 4 + 3] / 255;
|
||||
grid[i] = a === 1 ? INF : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2;
|
||||
}
|
||||
edt(grid, w, h);
|
||||
const out = new Uint8Array(n * 4);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = Math.min(1, Math.sqrt(grid[i]) / bevel);
|
||||
// Mostly a flat chamfer, rounded a little at the top.
|
||||
const height = 0.6 * t + 0.4 * (1 - (1 - t) * (1 - t));
|
||||
out[i * 4] = Math.round(height * 255);
|
||||
out[i * 4 + 1] = alpha[i * 4 + 3];
|
||||
out[i * 4 + 3] = 255;
|
||||
}
|
||||
for (const l of lines) {
|
||||
const x0 = Math.max(0, Math.floor(l.left)), x1 = Math.min(w, Math.ceil(l.left + l.width));
|
||||
const y0 = Math.max(0, Math.floor(l.top)), y1 = Math.min(h, Math.ceil(l.top + l.height));
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const v = Math.round(((y - l.top) / Math.max(1, l.height)) * 255);
|
||||
for (let x = x0; x < x1; x++) out[(y * w + x) * 4 + 2] = v;
|
||||
constructor() {
|
||||
try {
|
||||
this.worker = new Worker(new URL("./chrome-worker.ts", import.meta.url), { type: "module" });
|
||||
this.worker.onmessage = (e: MessageEvent<{ id: number; pixels: Uint8Array }>) => {
|
||||
this.waiting.get(e.data.id)?.done(e.data.pixels);
|
||||
this.waiting.delete(e.data.id);
|
||||
};
|
||||
this.worker.onerror = () => this.inline();
|
||||
} catch {
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Felzenszwalb and Huttenlocher's distance transform, in place: columns, then rows.
|
||||
function edt(grid: Float64Array, w: number, h: number): void {
|
||||
const size = Math.max(w, h);
|
||||
const f = new Float64Array(size), z = new Float64Array(size + 1), v = new Uint16Array(size);
|
||||
for (let x = 0; x < w; x++) edt1d(grid, x, w, h, f, v, z);
|
||||
for (let y = 0; y < h; y++) edt1d(grid, y * w, 1, w, f, v, z);
|
||||
}
|
||||
|
||||
function edt1d(grid: Float64Array, offset: number, stride: number, length: number, f: Float64Array, v: Uint16Array, z: Float64Array): void {
|
||||
v[0] = 0;
|
||||
z[0] = -INF;
|
||||
z[1] = INF;
|
||||
f[0] = grid[offset];
|
||||
for (let q = 1, k = 0, s = 0; q < length; q++) {
|
||||
f[q] = grid[offset + q * stride];
|
||||
const q2 = q * q;
|
||||
do {
|
||||
const r = v[k];
|
||||
s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2;
|
||||
} while (s <= z[k] && --k > -1);
|
||||
k++;
|
||||
v[k] = q;
|
||||
z[k] = s;
|
||||
z[k + 1] = INF;
|
||||
bake(job: BakeJob): Promise<Uint8Array> {
|
||||
if (!this.worker) return Promise.resolve(bake(job));
|
||||
const id = this.next++;
|
||||
return new Promise((done) => {
|
||||
this.waiting.set(id, { job, done });
|
||||
this.worker!.postMessage({ ...job, id });
|
||||
});
|
||||
}
|
||||
for (let q = 0, k = 0; q < length; q++) {
|
||||
while (z[k + 1] < q) k++;
|
||||
const r = v[k], qr = q - r;
|
||||
grid[offset + q * stride] = f[r] + qr * qr;
|
||||
|
||||
/// The worker failed: bake what it was given here, and everything after.
|
||||
private inline(): void {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
for (const { job, done } of this.waiting.values()) done(bake(job));
|
||||
this.waiting.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,11 +489,14 @@ export function textMask(el: HTMLElement, pad: number, ctx: CanvasRenderingConte
|
||||
return [...lines.values()];
|
||||
}
|
||||
|
||||
let probe: CanvasRenderingContext2D | null = null;
|
||||
|
||||
/// "#rrggbb", "rgb(...)" or "hsl(...)" to rgb 0..1, through the page's own parser.
|
||||
export function rgb(color: string): [number, number, number] {
|
||||
const c = document.createElement("canvas").getContext("2d")!;
|
||||
c.fillStyle = color;
|
||||
c.fillRect(0, 0, 1, 1);
|
||||
const d = c.getImageData(0, 0, 1, 1).data;
|
||||
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true })!;
|
||||
probe.clearRect(0, 0, 1, 1);
|
||||
probe.fillStyle = color;
|
||||
probe.fillRect(0, 0, 1, 1);
|
||||
const d = probe.getImageData(0, 0, 1, 1).data;
|
||||
return [d[0] / 255, d[1] / 255, d[2] / 255];
|
||||
}
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
import "./style.css";
|
||||
import "@fontsource/bruno-ace-sc/400.css";
|
||||
import "@fontsource-variable/unbounded";
|
||||
|
||||
import * as api from "./api";
|
||||
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
|
||||
@@ -57,6 +57,8 @@ const sheets = {
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => console.warn("no service worker:", err));
|
||||
}
|
||||
paintGrain();
|
||||
|
||||
const cards: Card[] = [];
|
||||
let active = -1;
|
||||
let loading = false;
|
||||
@@ -1172,6 +1174,24 @@ function signedIn(user: User, pl8list?: Pl8Status): void {
|
||||
if (!cards.length) openLinkOrFeed();
|
||||
}
|
||||
|
||||
/// The tile of film grain the stylesheet lays over everything: faint specks, each
|
||||
/// a little lighter or darker than what is under it.
|
||||
function paintGrain(): void {
|
||||
const size = 128;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = c.height = size;
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const img = ctx.createImageData(size, size);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const v = Math.random() < 0.5 ? 0 : 255;
|
||||
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
|
||||
img.data[i + 3] = Math.random() * 26;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
document.documentElement.style.setProperty("--grain", `url(${c.toDataURL()})`);
|
||||
}
|
||||
|
||||
/// `/?t=handle/slug` opens on that track; the feed carries on after it.
|
||||
async function openLinkOrFeed(): Promise<void> {
|
||||
const link = new URLSearchParams(location.search).get("t")?.split("/");
|
||||
|
||||
+27
-15
@@ -18,8 +18,8 @@
|
||||
--radius-l: 18px;
|
||||
--shadow: 0 16px 40px #000a;
|
||||
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
/* One weight only: a synthetic bold would smear it, so display text stays 400. */
|
||||
--font-display: "Bruno Ace SC", var(--font);
|
||||
/* Heavy and wide, so chrome has broad faces to show its horizon on. */
|
||||
--font-display: "Unbounded Variable", var(--font);
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--fast: 0.15s;
|
||||
}
|
||||
@@ -35,6 +35,16 @@ html, body {
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Film grain over everything, from a tile main.ts paints: a printed flyer. Static
|
||||
and without a blend mode, so it costs nothing per frame. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
background: var(--grain, none);
|
||||
}
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
@@ -74,8 +84,8 @@ button {
|
||||
}
|
||||
body.zen #bar { display: none; }
|
||||
|
||||
.logo { position: relative; font: 400 0.95rem var(--font-display); letter-spacing: 0.03em; display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
|
||||
.logo b { position: relative; color: var(--accent); font-weight: 400; }
|
||||
.logo { position: relative; font: 800 0.95rem var(--font-display); display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
|
||||
.logo b { position: relative; color: var(--accent); }
|
||||
.logo svg { width: 1.3em; height: 1.3em; color: var(--accent); flex: none; }
|
||||
.bar-end { justify-self: end; display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
@@ -188,7 +198,7 @@ body.zen #bar { display: none; }
|
||||
.sheet:focus-visible { outline: none; }
|
||||
@keyframes drawer { from { transform: translateX(100%); } }
|
||||
.sheet-handle { display: none; }
|
||||
.sheet h2 { margin: 0; font: 400 1.2rem/1.2 var(--font-display); letter-spacing: 0.03em; }
|
||||
.sheet h2 { margin: 0; font: 700 1.15rem/1.2 var(--font-display); }
|
||||
.sheet fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.sheet legend, .sheet h3 {
|
||||
padding: 0;
|
||||
@@ -461,7 +471,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(20% + var(--energy) * 40%), transparent), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card.liquid .content > :not(.art) { position: relative; z-index: 1; }
|
||||
.card.liquid .content > :not(.art, .chrome-layer) { position: relative; z-index: 1; }
|
||||
.card.liquid .title { text-shadow: 0 2px 16px #000b; }
|
||||
/* The drop shows loading as a ring of its own. */
|
||||
.card.liquid .initial, .card.liquid .paused-badge, .card.liquid .spinner { display: none; }
|
||||
@@ -521,15 +531,17 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
.title {
|
||||
position: relative;
|
||||
z-index: 1; /* over the drop's spill */
|
||||
font: 400 clamp(1.25rem, 3.4vw, 1.65rem)/1.2 var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
font: 800 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display);
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* Chrome (chrome.ts): a canvas over the text draws it as metal; the text stays
|
||||
for selection and screen readers, but transparent. The canvas reaches past the
|
||||
text for the glow and the shadow. */
|
||||
/* Chrome (chrome.ts): one canvas over the playing card's title and waveform
|
||||
draws them as metal, placed and sized by visual.ts; the text stays for
|
||||
selection and screen readers, but transparent. The logo, which never moves,
|
||||
gets a canvas of its own that reaches past the text for its shadow. */
|
||||
.content > .chrome-layer { position: absolute; z-index: 2; pointer-events: none; }
|
||||
.chrome-plate {
|
||||
position: absolute;
|
||||
inset: calc(-1 * var(--pad, 16px));
|
||||
@@ -539,7 +551,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s;
|
||||
}
|
||||
.chromed { color: transparent !important; }
|
||||
.chromed { color: transparent !important; text-shadow: none !important; }
|
||||
.chromed > .chrome-plate { opacity: 1; }
|
||||
|
||||
.why {
|
||||
@@ -809,7 +821,7 @@ body.zen #up-next { display: none; }
|
||||
}
|
||||
.start-play svg { width: 2.6rem; height: 2.6rem; margin-left: 0.3rem; }
|
||||
@keyframes beckon { to { box-shadow: 0 0 0 2rem transparent; } }
|
||||
.start-track { margin: 0.4rem 0 0; font: 400 1.1rem/1.25 var(--font-display); letter-spacing: 0.02em; text-align: center; padding: 0 1rem; }
|
||||
.start-track { margin: 0.4rem 0 0; font: 700 1.05rem/1.25 var(--font-display); text-align: center; padding: 0 1rem; }
|
||||
.start-hint { margin: 0; color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
|
||||
@@ -843,7 +855,7 @@ body.zen #up-next { display: none; }
|
||||
.icon-btn { width: 2.2rem; height: 2.2rem; padding: 0; display: grid; place-items: center; }
|
||||
.icon-btn svg { width: 1rem; height: 1rem; }
|
||||
.station-head { display: flex; gap: 1rem; align-items: center; }
|
||||
.station-head h2 { margin: 0; font: 400 1.3rem/1.15 var(--font-display); overflow-wrap: anywhere; }
|
||||
.station-head h2 { margin: 0; font: 700 1.25rem/1.15 var(--font-display); overflow-wrap: anywhere; }
|
||||
.station-kicker { margin: 0 0 0.2rem; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
|
||||
#station-sub { margin: 0.3rem 0 0.4rem; color: var(--muted); font-size: 0.85rem; }
|
||||
#station-tags { justify-content: flex-start; }
|
||||
@@ -956,7 +968,7 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.login-box h1 { margin: 0; font: 400 2.2rem/1.1 var(--font-display); letter-spacing: 0.02em; display: flex; align-items: center; gap: 0.3em; }
|
||||
.login-box h1 { margin: 0; font: 800 2.1rem/1.1 var(--font-display); letter-spacing: -0.01em; display: flex; align-items: center; gap: 0.3em; }
|
||||
.login-box h1 b { color: var(--accent); }
|
||||
/* A wheel up: the plate is pulled back once, then rests. */
|
||||
.login-mark { width: 1.2em; height: 1.2em; color: var(--accent); flex: none; animation: wheel-up 1.1s 0.3s cubic-bezier(0.3, 0, 0.2, 1) both; }
|
||||
|
||||
+183
-103
@@ -20,28 +20,42 @@ const VISUALS_KEY = "pl8inf.visuals";
|
||||
const SLOP = 10; // px before a press on the drop turns it
|
||||
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
|
||||
const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
|
||||
const CHROME_FPS = 30; // chrome is redrawn at most this often, 24 on phones
|
||||
const PAD = 16; // CSS px the chrome canvas reaches past the text, for glow and shadow
|
||||
const SWEEP = 1.4; // s for the highlight to sweep across a new title
|
||||
// Chrome is drawn only when something on it changes: the light moving, a kick, a
|
||||
// bar of the waveform played, the sweep over a new title.
|
||||
const CHROME_FPS = 30; // at most, 20 on phones
|
||||
const TOUCH_CHROME_FPS = 20;
|
||||
const PAD = 16; // CSS px the chrome reaches past the text, for its side wall and shadow
|
||||
const BEVEL = 0.05; // of the font size
|
||||
const DEPTH = 0.09; // of the font size: how far the side wall reaches down
|
||||
const SWEEP = 0.9; // s for the highlight to sweep across a new title
|
||||
const AIM = 4; // 1/s: how fast the light follows the pointer
|
||||
const KICK_STEP = 0.02; // a change in the kick envelope worth a frame
|
||||
const CHROME_BAR = 5; // CSS px per chrome waveform bar, gap included: wide enough to read as metal
|
||||
const TILT_DEG = 7; // how far the chrome title turns towards the pointer
|
||||
const LOGO_PAD = 10;
|
||||
const LOGO_TINT: [number, number, number] = [1, 0.3, 0.43];
|
||||
const REST_TILT: [number, number] = [0, -0.3];
|
||||
|
||||
/// The card in chrome: its title and waveform plates, and what they show.
|
||||
interface Dressed {
|
||||
card: Card;
|
||||
titleEl: HTMLElement;
|
||||
waveCanvas: HTMLCanvasElement;
|
||||
title: Plate;
|
||||
wave: Plate;
|
||||
layoutKey: string;
|
||||
titleKey: string;
|
||||
waveKey: string;
|
||||
/// Per bar, how far down its top is, 0..1.
|
||||
tops: number[];
|
||||
/// The waveform's width in CSS px and its number of bars.
|
||||
waveW: number;
|
||||
bars: number;
|
||||
/// Bars played and loaded, as drawn.
|
||||
played: number;
|
||||
loaded: number;
|
||||
tint: [number, number, number];
|
||||
tintAt: number;
|
||||
sweepFrom: number;
|
||||
progress: number;
|
||||
buffered: number;
|
||||
observer: ResizeObserver;
|
||||
}
|
||||
|
||||
export class Visualizer {
|
||||
@@ -63,12 +77,13 @@ export class Visualizer {
|
||||
/// Undefined until first needed; null where WebGL2 failed.
|
||||
private chrome: Chrome | null | undefined;
|
||||
private dressed: Dressed | null = null;
|
||||
private logo: { el: HTMLElement; plate: Plate; key: string } | null = null;
|
||||
private logo: { el: HTMLElement; canvas: HTMLCanvasElement; plate: Plate; key: string } | null = null;
|
||||
/// Where the light comes from, and where the pointer wants it.
|
||||
private tilt: [number, number] = [0, -0.3];
|
||||
private aim: [number, number] = [0, -0.3];
|
||||
private tilt: [number, number] = [...REST_TILT];
|
||||
private aim: [number, number] = [...REST_TILT];
|
||||
private chromeAt = 0;
|
||||
private chromeDirty = true;
|
||||
private kickDrawn = 0;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
@@ -131,21 +146,21 @@ export class Visualizer {
|
||||
/// Chrome is part of the liquid look, and needs WebGL2 too.
|
||||
private chromeOn(): Chrome | null {
|
||||
if (this.setting !== "liquid" || this.chrome === null) return null;
|
||||
this.chrome ??= Chrome.create();
|
||||
if (this.chrome === undefined) {
|
||||
this.chrome = Chrome.create();
|
||||
if (this.chrome) this.chrome.onRestored = () => {
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
};
|
||||
}
|
||||
if (this.chrome && !this.logo) this.dressLogo(this.chrome);
|
||||
return this.chrome;
|
||||
}
|
||||
|
||||
/// Put `card`'s title and waveform in chrome. The card before keeps its last
|
||||
/// picture; only the playing card's chrome moves.
|
||||
/// Put `card`'s title and waveform in chrome: the layer moves into the card.
|
||||
private dress(card: Card | null): void {
|
||||
const old = this.dressed;
|
||||
if (old && (old.card !== card || !old.card.el)) {
|
||||
old.card.onWave = null;
|
||||
this.chrome?.drop(old.title);
|
||||
this.chrome?.drop(old.wave);
|
||||
this.dressed = null;
|
||||
}
|
||||
if (old && (old.card !== card || !old.card.el)) this.strip(old);
|
||||
const chrome = this.chromeOn();
|
||||
if (!chrome) {
|
||||
this.undress();
|
||||
@@ -154,54 +169,90 @@ export class Visualizer {
|
||||
const titleEl = card?.el?.querySelector<HTMLElement>(".title");
|
||||
const waveCanvas = card?.waveCanvas;
|
||||
if (!card || !titleEl || !waveCanvas || this.dressed) return;
|
||||
const canvas = titleEl.querySelector<HTMLCanvasElement>(".chrome-plate") ?? plateCanvas(titleEl);
|
||||
titleEl.parentElement!.append(chrome.canvas);
|
||||
chrome.clear();
|
||||
// The waveform's own canvas stays, for seeking, but empty.
|
||||
waveCanvas.getContext("2d")?.clearRect(0, 0, waveCanvas.width, waveCanvas.height);
|
||||
const color = getComputedStyle(card.el!).getPropertyValue("--card-accent").trim() || "#ff4d6d";
|
||||
const d: Dressed = {
|
||||
card,
|
||||
titleEl,
|
||||
title: chrome.plate(canvas, "text"),
|
||||
wave: chrome.plate(waveCanvas, "wave"),
|
||||
waveCanvas,
|
||||
title: chrome.plate("text"),
|
||||
wave: chrome.plate("wave"),
|
||||
layoutKey: "",
|
||||
titleKey: "",
|
||||
waveKey: "",
|
||||
tops: [],
|
||||
tint: rgb(getComputedStyle(card.el!).getPropertyValue("--card-accent") || "#ff4d6d"),
|
||||
tintAt: 0,
|
||||
waveW: 0,
|
||||
bars: 0,
|
||||
played: -1,
|
||||
loaded: -1,
|
||||
tint: rgb(color),
|
||||
sweepFrom: performance.now(),
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
// Text loading around the title moves it; the layer follows.
|
||||
observer: new ResizeObserver(() => {
|
||||
d.layoutKey = "";
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
}),
|
||||
};
|
||||
for (const el of [titleEl.parentElement!, titleEl, waveCanvas]) d.observer.observe(el);
|
||||
this.dressed = d;
|
||||
card.onWave = (progress, buffered) => {
|
||||
if (progress === d.progress && buffered === d.buffered) return;
|
||||
d.progress = progress;
|
||||
d.buffered = buffered;
|
||||
const played = Math.floor(progress * d.bars), loaded = Math.floor(buffered * d.bars);
|
||||
if (played === d.played && loaded === d.loaded) return;
|
||||
d.played = played;
|
||||
d.loaded = loaded;
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
};
|
||||
// The studio's floor shows the art.
|
||||
// The studio's floor shows the art, in the colour taken from it.
|
||||
chrome.setArt(null, color);
|
||||
const img = artistImg(card.track);
|
||||
const own = accentForName(card.track.artist_name || "?");
|
||||
chrome.setArt(null, own);
|
||||
if (img) {
|
||||
const pic = new Image();
|
||||
pic.src = img;
|
||||
pic.decode().then(() => { if (this.dressed === d) chrome.setArt(pic, own); }, () => {});
|
||||
Promise.all([pic.decode(), accentFor(img)]).then(([, c]) => {
|
||||
if (this.dressed !== d) return;
|
||||
chrome.setArt(pic, c ?? color);
|
||||
if (c) d.tint = rgb(c);
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
}, () => {});
|
||||
}
|
||||
this.chromeDirty = true;
|
||||
}
|
||||
|
||||
/// Take the chrome off `d`'s card: plain text and the drawn waveform again.
|
||||
private strip(d: Dressed): void {
|
||||
d.card.onWave = null;
|
||||
d.observer.disconnect();
|
||||
d.titleEl.classList.remove("chromed");
|
||||
this.chrome?.drop(d.title);
|
||||
this.chrome?.drop(d.wave);
|
||||
this.chrome?.canvas.remove();
|
||||
d.card.drawWave(d.progress, true, d.buffered);
|
||||
this.dressed = null;
|
||||
}
|
||||
|
||||
/// Back to plain text and the drawn waveform everywhere.
|
||||
private undress(): void {
|
||||
if (this.dressed) this.strip(this.dressed);
|
||||
for (const el of document.querySelectorAll(".chromed")) el.classList.remove("chromed");
|
||||
for (const c of document.querySelectorAll(".chrome-plate")) c.remove();
|
||||
if (this.dressed) this.dressed.card.onWave = null;
|
||||
this.dressed = null;
|
||||
if (this.logo) this.chrome?.drop(this.logo.plate);
|
||||
if (this.logo) {
|
||||
this.logo.canvas.remove();
|
||||
this.chrome?.drop(this.logo.plate);
|
||||
}
|
||||
this.logo = null;
|
||||
}
|
||||
|
||||
/// Fonts arrived or the layout changed: shapes are drawn again on the next frame.
|
||||
/// Fonts arrived: shapes are drawn again on the next frame.
|
||||
private redress(): void {
|
||||
if (this.dressed) this.dressed.titleKey = this.dressed.waveKey = "";
|
||||
if (this.dressed) this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = "";
|
||||
if (this.logo) this.logo.key = "";
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
@@ -210,87 +261,116 @@ export class Visualizer {
|
||||
private dressLogo(chrome: Chrome): void {
|
||||
const el = document.querySelector<HTMLElement>(".logo b");
|
||||
if (!el) return;
|
||||
el.style.setProperty("--pad", "10px");
|
||||
this.logo = { el, plate: chrome.plate(plateCanvas(el), "text"), key: "" };
|
||||
el.style.setProperty("--pad", `${LOGO_PAD}px`);
|
||||
this.logo = { el, canvas: plateCanvas(el), plate: chrome.plate("text"), key: "" };
|
||||
}
|
||||
|
||||
/// Draw the shapes again where their size or text changed.
|
||||
private reshape(chrome: Chrome): void {
|
||||
const d = this.dressed;
|
||||
if (d) {
|
||||
const el = d.titleEl;
|
||||
const key = `${el.clientWidth}x${el.clientHeight}:${el.textContent}`;
|
||||
if (key !== d.titleKey && el.clientWidth) {
|
||||
d.titleKey = key;
|
||||
const bevel = parseFloat(getComputedStyle(el).fontSize) * 0.075;
|
||||
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, bevel, (ctx) => textMask(el, PAD, ctx));
|
||||
el.classList.add("chromed");
|
||||
}
|
||||
const c = d.wave.canvas;
|
||||
const wkey = `${c.clientWidth}x${c.clientHeight}`;
|
||||
if (wkey !== d.waveKey && c.clientWidth) {
|
||||
d.waveKey = wkey;
|
||||
const [w, h] = [c.clientWidth, c.clientHeight];
|
||||
chrome.shape(d.wave, w, h, CHROME_BAR * 0.36, (ctx) => {
|
||||
const { base, tops, width } = d.card.waveMask(ctx, w, h, CHROME_BAR);
|
||||
d.tops = tops.map((t) => t / h);
|
||||
d.wave.floor = base / h;
|
||||
// Each bar is its own little pillar of metal, sky at its top.
|
||||
return tops.map((top, i) => ({ left: i * CHROME_BAR, top, width, height: base - top }));
|
||||
});
|
||||
}
|
||||
/// Size and place the layer over the title and the waveform, and draw their
|
||||
/// shapes again where their size or text changed.
|
||||
private reshape(chrome: Chrome, d: Dressed): boolean {
|
||||
const el = d.titleEl, c = d.waveCanvas, wrap = c.parentElement!;
|
||||
if (!el.clientWidth || !c.clientWidth) return false;
|
||||
// Layout offsets, not client rects: the card scales as it leaves.
|
||||
const top = el.offsetTop - PAD;
|
||||
const waveTop = wrap.offsetTop + c.offsetTop;
|
||||
const width = el.parentElement!.clientWidth + 2 * PAD;
|
||||
const height = waveTop + c.clientHeight + PAD - top;
|
||||
const key = `${width}x${height}@${top}`;
|
||||
if (key !== d.layoutKey) {
|
||||
d.layoutKey = key;
|
||||
const s = chrome.canvas.style;
|
||||
s.left = `${-PAD}px`;
|
||||
s.top = `${top}px`;
|
||||
s.width = `${width}px`;
|
||||
s.height = `${height}px`;
|
||||
chrome.layout(width, height);
|
||||
}
|
||||
const tkey = `${el.clientWidth}x${el.clientHeight}:${el.textContent}`;
|
||||
if (tkey !== d.titleKey) {
|
||||
d.titleKey = tkey;
|
||||
const size = parseFloat(getComputedStyle(el).fontSize);
|
||||
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, size * DEPTH, (ctx) => textMask(el, PAD, ctx)).then((ok) => {
|
||||
if (!ok) return;
|
||||
// The sweep runs over the title once it is metal.
|
||||
if (!el.classList.contains("chromed")) d.sweepFrom = performance.now();
|
||||
el.classList.add("chromed");
|
||||
this.redraw();
|
||||
});
|
||||
}
|
||||
const wkey = `${c.clientWidth}x${c.clientHeight}`;
|
||||
if (wkey !== d.waveKey) {
|
||||
d.waveKey = wkey;
|
||||
const [w, h] = [c.clientWidth, c.clientHeight];
|
||||
chrome.shape(d.wave, w, h, CHROME_BAR * 0.25, 0, (ctx) => {
|
||||
const { base, tops } = d.card.waveMask(ctx, w, h, CHROME_BAR);
|
||||
d.waveW = w;
|
||||
d.bars = tops.length;
|
||||
d.played = Math.floor(d.progress * d.bars);
|
||||
d.loaded = Math.floor(d.buffered * d.bars);
|
||||
d.wave.floor = base / h;
|
||||
// One horizon runs through all the bars, as through one sheet of metal.
|
||||
return [{ left: 0, top: Math.min(...tops), width: tops.length * CHROME_BAR, height: base - Math.min(...tops) }];
|
||||
}).then((ok) => ok && this.redraw());
|
||||
}
|
||||
// The title's plate starts PAD up and left of it, as the layer does.
|
||||
chrome.place(d.title, el.offsetLeft, 0);
|
||||
chrome.place(d.wave, wrap.offsetLeft + c.offsetLeft + PAD, waveTop - top);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A shape arrived from the baker: draw again.
|
||||
private redraw(): void {
|
||||
this.chromeDirty = true;
|
||||
this.start();
|
||||
}
|
||||
|
||||
/// Light the logo once, whenever its size changed.
|
||||
private shineLogo(chrome: Chrome): void {
|
||||
const logo = this.logo;
|
||||
if (logo) {
|
||||
const el = logo.el;
|
||||
const key = `${el.clientWidth}x${el.clientHeight}`;
|
||||
if (key !== logo.key && el.clientWidth) {
|
||||
logo.key = key;
|
||||
chrome.shape(logo.plate, el.clientWidth + 20, el.clientHeight + 20, 1, (ctx) => textMask(el, 10, ctx));
|
||||
el.classList.add("chromed");
|
||||
}
|
||||
}
|
||||
if (!logo) return;
|
||||
const el = logo.el;
|
||||
const key = `${el.clientWidth}x${el.clientHeight}`;
|
||||
if (key === logo.key || !el.clientWidth) return;
|
||||
logo.key = key;
|
||||
chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 1, (ctx) => textMask(el, LOGO_PAD, ctx)).then((ok) => {
|
||||
if (!ok || this.logo !== logo) return;
|
||||
chrome.still(logo.plate, logo.canvas, { tint: LOGO_TINT, tilt: REST_TILT, kick: 0, sweep: -1 });
|
||||
el.classList.add("chromed");
|
||||
});
|
||||
}
|
||||
|
||||
/// Light the chrome. Returns whether it wants more frames.
|
||||
private shine(now: number, dt: number, kick: number, playing: boolean): boolean {
|
||||
/// Light the chrome where something on it changed. Returns whether it wants
|
||||
/// more frames.
|
||||
private shine(now: number, dt: number, kick: number): boolean {
|
||||
const chrome = this.chrome;
|
||||
if (!chrome || chrome.lost) return false;
|
||||
const d = this.dressed;
|
||||
// A mouse moves the light; on phones it drifts on its own.
|
||||
if (this.touch && !this.reduced.matches) this.aim = [Math.sin(now / 2500) * 0.6, Math.cos(now / 3300) * 0.35 - 0.2];
|
||||
// Where the drop had to step down to its lowest quality, the GPU is slow:
|
||||
// chrome then holds still and is drawn only when what it shows changes.
|
||||
const frozen = !!this.blob?.struggling;
|
||||
const calm = frozen || this.reduced.matches;
|
||||
const k = 1 - Math.exp(-dt * AIM);
|
||||
this.tilt[0] += (this.aim[0] - this.tilt[0]) * k;
|
||||
this.tilt[1] += (this.aim[1] - this.tilt[1]) * k;
|
||||
const moving = Math.abs(this.aim[0] - this.tilt[0]) + Math.abs(this.aim[1] - this.tilt[1]) > 0.003;
|
||||
const t = d && !this.reduced.matches ? (now - d.sweepFrom) / 1000 / SWEEP : 2;
|
||||
const moving = !frozen && Math.abs(this.aim[0] - this.tilt[0]) + Math.abs(this.aim[1] - this.tilt[1]) > 0.003;
|
||||
if (calm) kick = 0;
|
||||
const kicking = Math.abs(kick - this.kickDrawn) > KICK_STEP;
|
||||
const t = d && !calm ? (now - d.sweepFrom) / 1000 / SWEEP : 2;
|
||||
const sweeping = t <= 1;
|
||||
const want = this.chromeDirty || moving || sweeping || playing;
|
||||
if (!want || now - this.chromeAt < 1000 / (this.touch ? 24 : CHROME_FPS)) return want;
|
||||
const want = this.chromeDirty || moving || kicking || sweeping;
|
||||
if (!want || now - this.chromeAt < 1000 / (this.touch ? TOUCH_CHROME_FPS : CHROME_FPS)) return want;
|
||||
this.chromeAt = now;
|
||||
this.chromeDirty = false;
|
||||
this.reshape(chrome);
|
||||
const light: Light = {
|
||||
tint: [1, 0.3, 0.43],
|
||||
tilt: this.tilt,
|
||||
time: this.reduced.matches ? 0 : now / 1000,
|
||||
kick: this.reduced.matches ? 0 : kick,
|
||||
sweep: sweeping ? -0.3 + 1.6 * t : -1,
|
||||
};
|
||||
if (d) {
|
||||
// The card's colour can arrive late, from its art.
|
||||
if (now - d.tintAt > 500 && d.card.el) {
|
||||
d.tintAt = now;
|
||||
d.tint = rgb(getComputedStyle(d.card.el).getPropertyValue("--card-accent") || "#ff4d6d");
|
||||
}
|
||||
const lit = { ...light, tint: d.tint };
|
||||
chrome.render(d.title, lit);
|
||||
// The title turns a little towards the light, as a solid thing would.
|
||||
d.title.canvas.style.transform = `perspective(700px) rotateY(${(this.tilt[0] * TILT_DEG).toFixed(2)}deg) rotateX(${(-this.tilt[1] * TILT_DEG * 0.6).toFixed(2)}deg)`;
|
||||
const bar = Math.min(d.tops.length - 1, Math.floor(d.progress * d.tops.length));
|
||||
chrome.render(d.wave, lit, { progress: d.progress, buffered: d.buffered, top: d.tops[Math.max(0, bar)] ?? 1 });
|
||||
}
|
||||
if (this.logo) chrome.render(this.logo.plate, { ...light, sweep: -1, kick: 0 });
|
||||
this.kickDrawn = kick;
|
||||
this.shineLogo(chrome);
|
||||
if (!d || !this.reshape(chrome, d)) return want;
|
||||
const light: Light = { tint: d.tint, tilt: this.tilt, kick, sweep: sweeping ? -0.3 + 1.6 * t : -1 };
|
||||
chrome.clear();
|
||||
chrome.render(d.title, light);
|
||||
// The playhead sits in the gap after the last bar played.
|
||||
const gap = (CHROME_BAR * 0.3) / 2;
|
||||
const at = (bars: number) => (bars > 0 ? (bars * CHROME_BAR - gap) / d.waveW : 0);
|
||||
chrome.render(d.wave, { ...light, sweep: -1 }, { progress: at(d.played), buffered: at(d.loaded) });
|
||||
return want;
|
||||
}
|
||||
|
||||
@@ -449,7 +529,7 @@ export class Visualizer {
|
||||
? this.features.fromAnalyser(an, now / 1000, dt)
|
||||
: this.features.fromLoudness(card.loudnessAt(progress), now / 1000, dt);
|
||||
this.debug?.(f);
|
||||
let busy = this.shine(now, dt, f.kick, playing) || playing;
|
||||
let busy = this.shine(now, dt, f.kick) || playing;
|
||||
const liquid = !!this.blob && !!card.el?.classList.contains("liquid");
|
||||
if (!this.reduced.matches) {
|
||||
// The kick drives the pulse; without an analyser the kicks come from the waveform.
|
||||
|
||||
Reference in new issue
Block a user