diff --git a/web/src/chrome-bake.ts b/web/src/chrome-bake.ts index 3cd9d24..e87b7ef 100644 --- a/web/src/chrome-bake.ts +++ b/web/src/chrome-bake.ts @@ -15,7 +15,10 @@ export interface BakeJob { w: number; h: number; bevel: number; + /// How far the side wall reaches down; 0 for none. depth: number; + /// How far the shadow falls and spreads; 0 for none. + shadow: number; lines: Line[]; } @@ -23,7 +26,7 @@ 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 { +export function bake({ alpha, w, h, bevel, depth, shadow: fall, 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. @@ -41,7 +44,8 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array< 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. + // bottom, so its face runs from the bright sky through the horizon to the upper + // floor, never out to the dark ends of the studio. 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)); @@ -53,14 +57,14 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array< } // 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 shadow = fall ? shadowOf(cover, w, h, depth, fall) : 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; + let nx = -hx * k, ny = hy * k + 0.25 - run[i] * 0.5, 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]; @@ -89,9 +93,9 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array< } /// 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)); +function shadowOf(cover: Float32Array, w: number, h: number, depth: number, fall: number): Float32Array { + const drop = depth + Math.max(1, Math.round(fall * 0.5)); + const r = Math.max(1, Math.round(fall * 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. diff --git a/web/src/chrome.ts b/web/src/chrome.ts index c274dd7..dcd8160 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -115,7 +115,8 @@ void main() { 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); + // Text gets a third of it, so small glyphs keep clean outlines. + float split = (0.015 + (1.0 - n.z) * (0.05 + 0.3 * uKick)) * (wave ? 1.0 : 0.35); vec3 c; c.r = env(normalize(vec3(n.xy * (1.0 + split), n.z))).r; c.g = env(n).g; @@ -231,12 +232,13 @@ export class Chrome { c.height = ENV_H; const ctx = c.getContext("2d")!; const half = ENV_H / 2; - // The sky: black overhead, rising to white at the horizon. + // The sky: mid grey overhead, rising to white at the horizon, so no part of a + // face sinks into the dark page. 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(0, "#5c626e"); + sky.addColorStop(0.35, "#7c8290"); + sky.addColorStop(0.75, "#d6dae2"); + sky.addColorStop(0.95, "#ffffff"); sky.addColorStop(1, "#ffffff"); ctx.fillStyle = sky; ctx.fillRect(0, 0, ENV_W, half); @@ -245,7 +247,7 @@ export class Chrome { 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. + // The floor: a thin dark horizon, then the card's colour and art. ctx.fillStyle = color; ctx.fillRect(0, half, ENV_W, half); if (img) { @@ -258,9 +260,10 @@ export class Chrome { 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)"); + dark.addColorStop(0, "rgba(0,0,0,0.9)"); + dark.addColorStop(0.06, "rgba(0,0,0,0.6)"); + dark.addColorStop(0.25, "rgba(0,0,0,0.1)"); + dark.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = dark; ctx.fillRect(0, half, ENV_W, half); tex ??= gl.createTexture(); @@ -304,10 +307,11 @@ export class Chrome { /// 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` 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 { + /// (for the face's sky-to-floor run). `bevel`, `depth` (of the side wall) and + /// `shadow` (how far it falls; 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, shadow: 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"); @@ -320,7 +324,7 @@ export class Chrome { 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 }); + const pixels = await this.baker.bake({ alpha, w: W, h: H, bevel: bevel * dpr, depth: Math.round(depth * dpr), shadow: Math.round(shadow * dpr), lines: scaled }); if (job !== p.job) return false; p.w = W; p.h = H; diff --git a/web/src/style.css b/web/src/style.css index 69e00bc..81af233 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -75,7 +75,7 @@ button { } body.zen #bar { display: none; } -.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 { position: relative; font: 800 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); } .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; } @@ -533,8 +533,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; .title { position: relative; z-index: 1; /* over the drop's spill */ - font: 800 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display); - letter-spacing: -0.01em; + font: 700 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display); + letter-spacing: 0.02em; margin: 0; overflow-wrap: anywhere; text-wrap: balance; diff --git a/web/src/visual.ts b/web/src/visual.ts index 1b210e3..ce6c542 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -26,7 +26,9 @@ 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 DEPTH = 0.05; // of the font size: how far the side wall reaches down … +const WALL_MIN = 20; // … on text from this many CSS px; smaller text would clog up +const SHADOW = 0.09; // of the font size: how far the shadow falls 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 @@ -290,7 +292,8 @@ export class Visualizer { 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) => { + const wall = size < WALL_MIN ? 0 : size * DEPTH; + chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, wall, size * SHADOW, (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(); @@ -302,7 +305,7 @@ export class Visualizer { 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) => { + chrome.shape(d.wave, w, h, CHROME_BAR * 0.25, 0, 0, (ctx) => { const { base, tops } = d.card.waveMask(ctx, w, h, CHROME_BAR); d.waveW = w; d.bars = tops.length; @@ -333,7 +336,7 @@ export class Visualizer { 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) => { + chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 0, 1.5, (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");