diff --git a/web/package-lock.json b/web/package-lock.json index 70f98ba..68b284c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index d5bd760..f2b4cf4 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index fdf2855..83d0aa9 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -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 { diff --git a/web/src/chrome-bake.ts b/web/src/chrome-bake.ts new file mode 100644 index 0000000..3cd9d24 --- /dev/null +++ b/web/src/chrome-bake.ts @@ -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 { + 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 = 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 { + 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; + } +} diff --git a/web/src/chrome-worker.ts b/web/src/chrome-worker.ts new file mode 100644 index 0000000..993490b --- /dev/null +++ b/web/src/chrome-worker.ts @@ -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) => { + const pixels = bake(e.data); + (self as unknown as Worker).postMessage({ id: e.data.id, pixels }, [pixels.buffer]); +}; diff --git a/web/src/chrome.ts b/web/src/chrome.ts index 618a3bc..c274dd7 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -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(); + 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 { + 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 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 { + 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]; } diff --git a/web/src/main.ts b/web/src/main.ts index c6e676d..c8df47d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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 { const link = new URLSearchParams(location.search).get("t")?.split("/"); diff --git a/web/src/style.css b/web/src/style.css index 24db0fa..687d22b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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; } diff --git a/web/src/visual.ts b/web/src/visual.ts index 6d99d7a..520ee4e 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -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(".title"); const waveCanvas = card?.waveCanvas; if (!card || !titleEl || !waveCanvas || this.dressed) return; - const canvas = titleEl.querySelector(".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(".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.