The capture harness uses the graphics card that was there all along
`launch()` asks for `--use-angle=vulkan` and gets the Radeon RX 6700 XT. On the real film loop at 1440x900 that is 7.56 s a frame to 0.47, and about twelve cores pegged down to less than one. A 180-frame film goes from twenty-two minutes to ninety seconds; the three shipped reels go from over an hour to four minutes. The SwiftShader flag was inherited from the share-card script and its comment said "a box with no display and no GPU". Half of that was never true. The card has been in this machine the whole time, 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 group `render` already grants. So the portability the flag was protecting is intact: this still runs over ssh and under cron. Naming the backend is the whole trick, and most of the plausible spellings are traps. `--use-angle=gl`, `--use-gl=egl`, `--use-gl=desktop`, `--enable-features=Vulkan` and passing no GL flags at all were each measured on this box, and all five land on SwiftShader while reporting success. Only `--use-angle=vulkan` and `--use-angle=gl-egl` reach the card. So `launch()` verifies instead of assuming. `--use-angle=vulkan` is a demand, not a preference: with the driver hidden it yields no WebGL context at all rather than falling back, the boot curtain never lifts, and the caller sits in `waitForFunction` until a three-minute timeout before failing with something that looks nothing like "there is no GPU here". Reading `UNMASKED_RENDERER_WEBGL` once per run costs about half a second and turns that into a printed line and a slow, correct render. Both branches are tested; the sabotaged one was verified with `VK_LOADER_DRIVERS_DISABLE`. The pictures are the same pictures: mean absolute difference of half a level out of 255, confined to MSAA edges and to cloud shadow that already drifts between any two runs of the same renderer. Also corrects every comment across the four scripts that asserted three frames a second or twenty-two minutes a reel. Those were true when written and are not now, and the `reducedMotion` rationale in two of them was resting on the number: the real reason a chapter flight cannot be waited out is that it is ~40 frames however fast they are drawn, which was twenty seconds under software GL and is a shorter race on the GPU — still a race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user