perf: compile scene shaders off-thread before the first frame

I warm the world's programs with renderer.compileAsync() at the end of the
async build, with the composer's scene target bound so tone mapping and
output colour space key the same programs the composer draws with, and with
hidden subtrees revealed so the light count matches the frame-0 warm render.
The wait is capped at 2.5 s; the warm render stays as the safety net for the
post passes, texture uploads and anything a slow or extension-less driver
left. Test builds report renderer.info.programs, and a browser spec pins zero
program growth after frame 0.

On the Intel ARL laptop the frame-0 task drops from ~1.74 s to ~0.34 s with a
pixel-identical first frame; measurements in
docs/overnight/compile-async-results.md.
This commit is contained in:
2026-10-12 18:34:12 +03:00
parent 71465fa481
commit 26e5ff01fd
8 changed files with 576 additions and 5 deletions

View File

@@ -0,0 +1,106 @@
// usage: node measure.mjs <label> <runs> [--shot] [--nokhr] [--noscroll]
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
const [label, runsArg] = process.argv.slice(2);
const SHOT = process.argv.includes('--shot');
const NOKHR = process.argv.includes('--nokhr');
const NOSCROLL = process.argv.includes('--noscroll');
const runs = Number(runsArg ?? 5);
const out = process.env.OUT ?? 'compile-async-out'; // runs from the repo root; serve dist on 127.0.0.1:5181 first
mkdirSync(out, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const init = `(() => {
const T = { links: [], lit: null, linksAtLit: null, longtasks: [], frames: [], shot: null };
window.__m = T;
for (const C of [WebGL2RenderingContext, WebGLRenderingContext]) {
const link = C.prototype.linkProgram;
C.prototype.linkProgram = function (p) { T.links.push(performance.now()); return link.call(this, p); };
if (${NOKHR}) {
const ge = C.prototype.getExtension;
C.prototype.getExtension = function (n) { return n === 'KHR_parallel_shader_compile' ? null : ge.call(this, n); };
const gs = C.prototype.getSupportedExtensions;
C.prototype.getSupportedExtensions = function () { return gs.call(this).filter((e) => e !== 'KHR_parallel_shader_compile'); };
}
}
new PerformanceObserver((l) => { for (const e of l.getEntries()) T.longtasks.push([e.startTime, e.duration]); })
.observe({ type: 'longtask', buffered: true });
const mo = new MutationObserver(() => {
if (T.lit !== null) return;
const c = document.querySelector('canvas.journey-canvas');
if (!c || c.dataset.lit === undefined) return;
T.lit = performance.now();
T.linksAtLit = T.links.length;
if (${SHOT}) T.shot = c.toDataURL('image/png');
});
mo.observe(document, { subtree: true, attributes: true, attributeFilter: ['data-lit'] });
let last = null;
const loop = (t) => { if (last !== null) T.frames.push([t, t - last]); last = t; requestAnimationFrame(loop); };
requestAnimationFrame(loop);
})();`;
const results = [];
for (let i = 0; i < runs; i += 1) {
const browser = await chromium.launch({
channel: 'chromium',
headless: process.env.HEADED !== '1',
args: ['--use-gl=angle', '--use-angle=gl', '--ignore-gpu-blocklist', '--enable-gpu-rasterization',
'--enable-webgl', '--disable-gpu-sandbox', '--disable-gpu-shader-disk-cache'],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.addInitScript(init);
await page.goto('http://127.0.0.1:5181/', { waitUntil: 'load' });
await page.waitForFunction(() => window.__m.lit !== null, null, { timeout: 20000 });
const env = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
khr: gl.getExtension('KHR_parallel_shader_compile') !== null,
};
});
await sleep(2500);
const scrollStart = await page.evaluate(() => performance.now());
if (!NOSCROLL) {
await page.mouse.move(720, 450);
const t0 = Date.now();
while (Date.now() - t0 < 10000) {
await page.mouse.wheel(0, 200);
await sleep(80);
}
} else {
await sleep(10000);
}
const scrollEnd = await page.evaluate(() => performance.now());
const m = await page.evaluate(() => window.__m);
const nav = await page.evaluate(() => performance.getEntriesByType('navigation')[0].toJSON());
const r = {
run: i,
renderer: env.renderer,
khr: env.khr,
litMs: Math.round(m.lit),
linksBeforeLit: m.linksAtLit,
linksAfterLit: m.links.length - m.linksAtLit,
linksAfterLitTimes: m.links.slice(m.linksAtLit).map((t) => Math.round(t - m.lit)),
frame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? Math.round(t[1]) : null; })(),
linksInFrame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? m.links.filter((x) => x >= t[0] && x <= m.lit).length : null; })(),
timeline: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); const f0 = t ? t[0] : m.lit; const pre = m.links.filter((x) => x < f0); return { firstLink: Math.round(m.links[0]), lastBuildLink: pre.length ? Math.round(pre[pre.length - 1]) : null, buildLinks: pre.length, frame0Start: Math.round(f0), lit: Math.round(m.lit) }; })(),
longestTask: Math.round(Math.max(0, ...m.longtasks.map((x) => x[1]))),
longTasks: m.longtasks.filter((x) => x[1] > 100).map((x) => [Math.round(x[0]), Math.round(x[1])]),
framesOver50Idle: m.frames.filter(([t, d]) => t > m.lit + 50 && t < scrollStart && d > 50).map(([t, d]) => [Math.round(t - m.lit), Math.round(d)]),
framesOver50Scroll: m.frames.filter(([t, d]) => t >= scrollStart && t <= scrollEnd && d > 50).map(([t, d]) => [Math.round(t - scrollStart), Math.round(d)]),
worstScrollFrame: Math.round(Math.max(0, ...m.frames.filter(([t]) => t >= scrollStart && t <= scrollEnd).map((x) => x[1]))),
scrollY: await page.evaluate(() => scrollY),
domContentLoaded: Math.round(nav.domContentLoadedEventEnd),
errors,
};
if (SHOT && m.shot) writeFileSync(`${out}/${label}-frame0-${Date.now()}.png`, Buffer.from(m.shot.split(',')[1], 'base64'));
console.log(JSON.stringify(r));
results.push(r);
await browser.close();
}
appendFileSync(`${out}/${label}.jsonl`, results.map((r) => JSON.stringify(r)).join('\n') + '\n');

View File

@@ -0,0 +1,152 @@
# Issue 2, option A: `compileAsync` shader warm-up — measurements
Branch `polish/compile-async`, against baseline `71465fa`.
## What changed
- `src/scene/world/shader-warmup.ts` (new): `warmUpShaders()` binds the
composer's scene target, reveals hidden subtrees (so `compile()` gathers the
same lights as the frame-0 warm render), calls `renderer.compileAsync()`,
restores target and visibility right after the synchronous `compile()` part,
then awaits the parallel link with a 2.5 s timeout (`ready` / `timeout` /
`failed`).
- `src/scene/post-processing.ts`: `PostProcessor.sceneTarget()` returns the
EffectComposer `readBuffer` (the target RenderPass draws into). In three r185,
`WebGLPrograms.getParameters` keys tone mapping and output colour space on the
bound target. Any non-XR off-screen target gives the same key, so the
precompiled programs are the ones the composer uses.
- `src/scene/world/environment.ts`: at the end of the async build, call
`resize()`, then `await warmUpShaders(...)`. The frame-0 warm render stays as
the safety net. Post-pass programs are not in the scene, so the warm render
still builds those. It also uploads textures and finishes whatever a
timed-out or extension-less warm-up left behind. Test builds dispatch
`portfolio-program-count` whenever `renderer.info.programs.length` changes.
- Regression checks:
- `tests/browser/shader-warmup.spec.ts`: after frame 0 no program is ever
created (idle ignition, then 12 wheel steps). It also checks that the
warm-up ran and that frame 0 adds at most 12 programs on top of it (only
the post passes; a mis-keyed warm-up would add about 60). Passes on desktop-chromium and
mobile-chromium. In SwiftShader, desktop compiles 59 programs in the
warm-up and frame 0 adds 9 post-pass programs, for 68 total. Mobile:
53 + 0 = 53.
- `tests/unit/shader-warmup.test.ts` (fakes): target and visibility during
compile, both restored afterwards, the timeout, the failure paths, and the
timer cleanup.
- `tests/unit/world-models.test.ts`: `sceneTarget()`.
## Method
- Hardware: `ANGLE (Intel, Mesa Intel(R) Graphics (ARL), OpenGL ES 3.2)`.
Playwright Chromium 151.0.7922.34, **headed** on the desktop session. In a
headless trial the frame-0 task took 3.1 s and every scroll frame took
>50 ms, so headed is the representative setup.
- Flags: `--use-gl=angle --use-angle=gl --ignore-gpu-blocklist
--enable-gpu-rasterization --enable-webgl --disable-gpu-sandbox
--disable-gpu-shader-disk-cache`. These are the hardware-GL flags from the
scratchpad `shots.mjs`; `r6.mjs` no longer existed. Every run launches a new
browser with a fresh temporary profile. Viewport 1440x900, DPR 1, production
preview build served by `python3 -m http.server` on 127.0.0.1:5181.
- Runs were interleaved A/B/A/B. The harness is
`docs/overnight/compile-async-measure.mjs`. An init script wraps
`linkProgram` (count and timestamps, so it works on production builds),
watches `canvas[data-lit]` with a MutationObserver (it fires in the microtask
right after the frame-0 render, still inside that task), records
`PerformanceObserver('longtask')` and rAF deltas. After lit it idles 2.5 s,
then wheels 200 px every 80 ms for 10 s.
- **Power and load:** `ADP0/online` read `1` before every run, and
`BAT0` reported "Not charging", 79 %. The coordinator reported battery power
earlier. Load average was **6-11** because four other agents shared the
machine. **Absolute times are inflated and noisy. Compare the A/B
differences, not the absolute numbers.**
## Results (median [min-max])
### KHR_parallel_shader_compile available (6 + 6 runs)
| metric | baseline 71465fa | option A |
|---|---|---|
| time to first scene frame (`data-lit`, ms after navigation start) | 2788 [2733-3431] | 2667 [2422-2960] |
| frame-0 task (long task that contains frame 0) | **1737 [1706-1771]** | **340 [323-405]** |
| longest main-thread task | 1737 [1706-1771] | 458 [422-521] (page boot JS, not the scene) |
| programs linked before frame 0 / inside frame 0 / after | 3 / 68 / 0 | 62 (async, during the build) / 9 (post passes) / 0 |
| frame after frame 0 (presentation hitch) | 177 [154-229] | 98 [0-167] |
| scroll frames >50 ms in 10 s (per run) | 13, 6, 5, 20, 72, 42 | 12, 6, 3, 29, 72, 28 |
| worst scroll frame | 146 [71-229] | 158 [71-250] |
| long tasks during scroll | none | none |
Timeline from three more interleaved runs per variant, ms after navigation:
| | first link | last link before frame 0 | frame 0 starts | first frame (lit) |
|---|---|---|---|---|
| baseline | 1497 / 834 / 842 | 3 links only | 1621 / 944 / 964 | 3144 / 2396 / 2453 |
| option A | 752 / 810 / 818 | 918 / 978 / 994 (62 links) | 1933 / 2109 / 2152 | 2248 / 2417 / 2454 |
Option A issues the 59 scene links in about 170 ms, inside the build's
existing ~420 ms boot task, so it adds no new long task. The build then
awaits the driver for about 1.15 s while the main thread is free (poster up,
page scrollable). Frame 0 follows at about 300 ms.
### KHR_parallel_shader_compile hidden (getExtension patched, 3 + 3 runs)
| metric | baseline | option A |
|---|---|---|
| first scene frame | 2611 [2604-2686] | 2473 [2433-2820] |
| frame-0 task | 1647 [1631-1679] | 1418 [1400-1559] |
| links inside frame 0 | 68 | 9 (the other 62 were queued during the build) |
| links after frame 0 | 0 | 0 |
Without the extension three marks every program ready immediately, so
`compileAsync` resolves after one 10 ms timeout. The links queued during the
build overlap the rest of the build, and the warm render then blocks on the
first status query. The result is still a little better than baseline,
never worse. Behaviour is the same as before, with no timeout path.
### Visual correctness
Frame 0 was captured with `canvas.toDataURL()` inside the frame-0 task, 3
interleaved runs per variant. All 15 pairwise comparisons (base-base,
new-new, base-new) have **max channel difference 0**: option A's first
frame is pixel-identical to the baseline's.
## Pros and cons (measured)
Pros
- The frame-0 main-thread block drops from about 1.74 s to about 0.34 s (-80 %).
The longest main-thread task on the page goes from the scene's 1.7 s to
page boot (~0.45 s).
- About 1.15 s of shader linking now runs on the driver's threads while the
poster shows and the page stays responsive.
- The hitch on the frame after frame 0 is also smaller (177 -> 98 ms median).
- Frame 0 is pixel-identical. The regression test pins zero program growth
after frame 0. On this machine no programs were linked after frame 0 in
10 s of scrolling, in either variant.
- No extension: slightly better than baseline. Slow driver: the 2.5 s
timeout hands the remainder to the warm render, which is the old behaviour.
Cons
- **Time to first scene frame does not improve much** (-120 ms median with
KHR, within noise). The total link work is about the same; it moved off
the main thread, it did not shrink. The win is responsiveness and long-task
size, not an earlier frame.
- The approach depends on three's key internals: the bound target decides
tone mapping and colour space, and `compile()` gathers lights with
`traverseVisible`. A three upgrade that changes either would quietly
double-compile. The browser spec guards this by allowing frame 0 to add at
most 12 programs on top of the warm-up (9 post passes today).
- Frame 0 still costs about 330 ms: 9 post-pass programs, texture uploads
and the first full draw. Options to cut it further: precompile the post
passes' materials on a fullscreen quad through the same warm-up, or
combine with option B (slicing).
- After a timeout, three's `compileAsync` keeps polling every 10 ms until
every program reports ready. This is harmless, but the poll would throw
once if the generation were disposed in the meantime.
- The build waits for the parallel compile, so the first-frame watchdog
(5 s, armed before the build) now also covers the compile. The 2.5 s cap
keeps that bounded.
## Verification
`npx vue-tsc --noEmit` clean. `npx vitest run`: 84 files, 978 tests passed.
`npm run build:preview`: exit 0, `"violations": []`.
`npx playwright test tests/browser/shader-warmup.spec.ts`: 2 passed
(desktop-chromium and mobile-chromium).