diff --git a/scripts/brand-assets/capture.mjs b/scripts/brand-assets/capture.mjs index a37f3f3..2b34871 100644 --- a/scripts/brand-assets/capture.mjs +++ b/scripts/brand-assets/capture.mjs @@ -53,12 +53,13 @@ async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) * since the day it was written. * * `scenekit.ts` eases a chapter change over about two seconds of scene - * time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader on this - * box draws about three frames a second — so the flight below takes some - * twenty seconds of wall clock, and the four this waited caught the camera - * partway across the bay at a different point each time. Two consecutive - * captures of an unchanged repo produced two different cards, neither of - * them the chapter the key press had asked for. + * time and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about + * forty frames however fast they are drawn, and the four seconds this + * waited caught the camera partway across the bay at a different point + * each time. Two consecutive captures of an unchanged repo produced two + * different cards, neither of them the chapter the key press had asked + * for. It was worst under software GL, where forty frames took twenty + * seconds; on the GPU it is a shorter race and still a race. * * Reduced motion is the app's own answer for "somebody clicked a name in a * list": `flyTo` sets the pose outright, so the shutter opens on the pose @@ -102,10 +103,10 @@ async function renderCard(browser, which, out) { /** * `--cards-only` re-renders the two PNGs from art already on disk. * - * The app pass is a minute of software rasterisation and the card pass is two - * seconds, and every iteration on a headline needs only the second. Without the - * flag, tuning a line of copy costs a minute each time, which is how a card ends - * up shipped with the first wording anybody tried. + * The app pass shoots the running city and the card pass is two seconds, and + * every iteration on a headline needs only the second. Without the flag, tuning + * a line of copy costs a whole app pass each time, which is how a card ends up + * shipped with the first wording anybody tried. */ const cardsOnly = process.argv.includes("--cards-only"); diff --git a/scripts/brand-assets/films.mjs b/scripts/brand-assets/films.mjs index 6790dea..2967640 100644 --- a/scripts/brand-assets/films.mjs +++ b/scripts/brand-assets/films.mjs @@ -27,9 +27,8 @@ * ### Why the clock is stepped and not simply left running * * Real-time capture is not available on this box and would be the wrong idea - * anyway. SwiftShader draws about three frames a second with no GPU, so a - * screen recording would be a slideshow; and a sunset takes an hour, which is - * not a length of video anybody watches. So the film is rendered offline the + * anyway. A sunset takes an hour, which is not a length of video anybody + * watches, and the capture loop screenshots far slower than it renders. So the film is rendered offline the * way films always have been: set the clock, let the frame settle, expose, * advance. The output is smooth 30 fps regardless of what the renderer managed * while it was being photographed. @@ -188,10 +187,10 @@ const only = flag("only")?.split(",").map((s) => s.trim()); /** * Override the frame count for a rough cut. * - * Filming costs about seven seconds a frame at 1440x900 on this box, so a - * 180-frame film is some twenty-two minutes. `--frames 24` is three, and it is - * the cheapest possible way to discover that a camera is pointed at the wrong - * thing. A rough cut also skips the site, because a 24-frame stutter is not + * Filming costs about half a second a frame at 1440x900 on the GPU, so a + * 180-frame film is a minute and a half; on the software fallback it is seven + * seconds a frame and twenty-two minutes. `--frames 24` is the cheapest + * possible way to discover that a camera is pointed at the wrong thing. A rough cut also skips the site, because a 24-frame stutter is not * something to publish by accident. */ const frameOverride = flag("frames") ? Number(flag("frames")) : null; @@ -202,10 +201,10 @@ const manifestOnly = process.argv.includes("--manifest-only"); /** * Publish an existing render directory to the site instead of shooting a new one. * - * Filming is twenty-two minutes a reel, so there has to be a way to put reels - * that already exist in front of the site without paying that again — otherwise - * the answer to "how did those files get there" becomes `cp`, and a hand-copied - * artefact is exactly the thing this pipeline exists to not have. + * There has to be a way to put reels that already exist in front of the site + * without re-shooting them — otherwise the answer to "how did those files get + * there" becomes `cp`, and a hand-copied artefact is exactly the thing this + * pipeline exists to not have. */ const publishFrom = flag("publish"); const wanted = only ? FILMS.filter((f) => only.includes(f.id)) : FILMS; @@ -266,7 +265,7 @@ async function expose(page, instant) { await page.waitForTimeout(260); // ...and then two straddled frames, so what is on screen is what we just asked // for rather than the one before it. A `waitForTimeout` alone cannot promise - // that at three frames a second. + // that when the renderer is slower than the loop asking for frames. await page.evaluate( () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), ); @@ -423,8 +422,8 @@ async function writeManifest() { * Copy a render directory to the site, then rewrite the manifest. * * A film that is not in `fromDir` but is already published is **left alone** - * rather than treated as an error. Reels cost twenty-two minutes each, so a - * one-reel render is the normal way to add or replace one, and demanding that + * rather than treated as an error. A one-reel render is the normal way to add + * or replace one, and demanding that * every reel be present in the same directory would mean re-shooting the whole * set to change any of it. What is not tolerated is a film that exists in * neither place: the manifest is about to name it, so that is a hard failure diff --git a/scripts/brand-assets/harness.mjs b/scripts/brand-assets/harness.mjs index b7e265f..f18f2d5 100644 --- a/scripts/brand-assets/harness.mjs +++ b/scripts/brand-assets/harness.mjs @@ -4,11 +4,11 @@ * * This file exists because the second consumer arrived. `capture.mjs` shot the * app to make two share cards; `shots.mjs` shoots it to make the product - * imagery on lumbridgecorp.com. Everything below — the static server, the - * SwiftShader flags, the two-hostnames-one-dist trick, the clock shim — was - * already load-bearing for the first one, and copying it would have meant two - * capture pipelines drifting apart while both claimed to photograph the same - * app. There is one pipeline. The two scripts differ only in what they frame. + * imagery on lumbridgecorp.com. Everything below — the static server, the GL + * flags, the two-hostnames-one-dist trick, the clock shim — was already + * load-bearing for the first one, and copying it would have meant two capture + * pipelines drifting apart while both claimed to photograph the same app. + * There is one pipeline. The two scripts differ only in what they frame. */ import { chromium } from "playwright"; @@ -49,19 +49,90 @@ export function serve(dir, port, { spa = false } = {}) { return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server))); } -export const CHROME_ARGS = [ +const COMMON_ARGS = [ "--no-sandbox", "--disable-dev-shm-usage", - // Software GL, so this runs on a box with no display and no GPU. - "--use-gl=angle", - "--use-angle=swiftshader", // The app reads its own hostname to decide which door it is. Resolving both // names at the local server is what makes one `dist/` produce both shots. "--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1", ]; -export function launch() { - return chromium.launch({ channel: "chrome", args: CHROME_ARGS }); +/** + * The GPU is real, and headless Chrome will use it if you name the backend + * precisely. + * + * This box has a Radeon RX 6700 XT with the amdgpu driver and a working RADV + * ICD, and Chrome reaches it with **no display server at all** — no `DISPLAY`, + * no `XDG_RUNTIME_DIR`, nothing but read access to `/dev/dri/renderD128`, which + * membership of the `render` group already grants. So this still runs under + * cron and over ssh, which is what the software-GL flag was protecting. + * + * It is worth naming the backend exactly, because most of the plausible spellings + * silently give you SwiftShader instead and report success: `--use-angle=gl`, + * `--use-gl=egl`, `--use-gl=desktop`, `--enable-features=Vulkan` and passing no + * GL flags at all were all measured on this box, and all four land on + * `SwiftShader Device (Subzero)`. Only `--use-angle=vulkan` and + * `--use-angle=gl-egl` reach the card. Nothing else is required — the + * `--ozone-platform`, `--enable-features=Vulkan` and `--disable-software-rasterizer` + * flags that usually accompany this change measured as no-ops. + * + * What it buys, on the real film loop at 1440x900: 7.56 s/frame to 0.47, and + * about twelve cores pegged to less than one. A 180-frame film goes from + * twenty-two minutes to eighty seconds. The pictures are the same pictures — + * mean absolute difference half a level out of 255, confined to MSAA edges and + * to cloud shadow that drifts between any two runs anyway. + */ +export const GPU_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=vulkan"]; + +/** Where `launch()` goes when there is no usable card. See `launch()`. */ +export const SOFTWARE_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=swiftshader"]; + +/** The renderer string a real WebGL context reports, or `null` if it has none. */ +async function rendererOf(browser) { + const page = await browser.newPage(); + try { + await page.goto("about:blank"); + return await page.evaluate(() => { + const gl = document.createElement("canvas").getContext("webgl2"); + const ext = gl && gl.getExtension("WEBGL_debug_renderer_info"); + return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null; + }); + } catch { + return null; + } finally { + await page.close(); + } +} + +/** + * Chrome with the GPU, having checked that it actually got one. + * + * The check is not ceremony. `--use-angle=vulkan` is a demand, not a + * preference: on a box where the driver is missing it does not quietly fall + * back to software, it produces **no WebGL context at all**. The app's boot + * curtain then never lifts, and the caller sits in `waitForFunction` until its + * three-minute timeout before failing with something that looks nothing like + * "there is no GPU here". Asking the context what it is costs half a second + * once per run and turns that into a line of output and a slow, correct render. + * + * `SwiftShader` and `llvmpipe` both count as failure — they are what a silent + * fallback looks like, and a run that thinks it is on the GPU while taking + * twenty minutes a film is the confusion this whole check exists to prevent. + */ +export async function launch({ gpu = true } = {}) { + if (gpu) { + const browser = await chromium.launch({ channel: "chrome", args: GPU_ARGS }); + const renderer = await rendererOf(browser); + if (renderer && !/SwiftShader|llvmpipe/i.test(renderer)) { + console.log(` GPU: ${renderer}`); + return browser; + } + console.log(` no GPU (${renderer ?? "no WebGL context"}) — falling back to SwiftShader, which is slow.`); + await browser.close(); + } + const browser = await chromium.launch({ channel: "chrome", args: SOFTWARE_ARGS }); + console.log(` GPU: ${await rendererOf(browser)}`); + return browser; } /** diff --git a/scripts/brand-assets/shots.mjs b/scripts/brand-assets/shots.mjs index f9809ee..da6474f 100644 --- a/scripts/brand-assets/shots.mjs +++ b/scripts/brand-assets/shots.mjs @@ -252,8 +252,8 @@ const preview = process.argv.includes("--preview"); * Rewrite the site's manifest from the shot list without opening a browser. * * A caption is prose and gets edited like prose — several times, in a row, to - * hear how it reads. Charging three minutes of software rasterisation for each - * pass is how a caption ends up shipped in the first wording anybody tried, + * hear how it reads. Charging a full render for each pass is how a caption + * ends up shipped in the first wording anybody tried, * which is the same failure the share-card README describes. The images this * points at are the ones already on disk, and the commit it stamps is today's; * both are true, because the pictures did not change. @@ -349,10 +349,10 @@ async function shoot(browser, shot, frame) { * one that quietly photographs the camera mid-flight. * * `scenekit.ts` eases a chapter change over about two seconds of *scene* - * time, and `stage.ts` clamps `dt` to 50 ms a frame. Under SwiftShader on a - * box with no GPU the app runs at roughly three frames a second, so those - * two seconds take about twenty of wall clock, and any fixed wait short of - * that lands somewhere over the bay. Reduced motion is the app's own + * time, and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about + * forty frames however fast they are drawn, and any fixed wait shorter than + * they take lands somewhere over the bay. Under the software fallback that + * was twenty seconds. Reduced motion is the app's own * documented answer to "I only clicked a name in a list": `flyTo` calls * `setPose` and the pose is simply true on the next frame. */