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).

View File

@@ -15,6 +15,11 @@ export interface PostProcessor extends GpuRenderTargetReporter {
setDynamics(acceleration: number, dawn?: number): void; setDynamics(acceleration: number, dawn?: number): void;
setLens(state: LensState): void; setLens(state: LensState): void;
render(scene: unknown, camera: unknown): void; render(scene: unknown, camera: unknown): void;
/**
* The off-screen target the scene pass draws into. Bind it while compiling
* scene programs: three keys tone mapping and output colour space on it.
*/
sceneTarget(): unknown;
dispose(): void; dispose(): void;
} }
@@ -28,6 +33,8 @@ interface RenderTargetLike {
interface ComposerLike { interface ComposerLike {
renderTarget1?: RenderTargetLike; renderTarget1?: RenderTargetLike;
renderTarget2?: RenderTargetLike; renderTarget2?: RenderTargetLike;
/** RenderPass (needsSwap false) draws the scene into this target. */
readBuffer?: unknown;
addPass(pass: unknown): void; addPass(pass: unknown): void;
setPixelRatio(pixelRatio: number): void; setPixelRatio(pixelRatio: number): void;
setSize(width: number, height: number): void; setSize(width: number, height: number): void;
@@ -146,6 +153,9 @@ export function createPostProcessor(
renderPass.camera = camera; renderPass.camera = camera;
composer.render(); composer.render();
}, },
sceneTarget() {
return composer.readBuffer ?? composer.renderTarget2 ?? null;
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;

View File

@@ -54,6 +54,7 @@ import { readIgnition } from '@/journey/ignition-feed';
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass'; import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass'; import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes'; import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
import { warmUpShaders, type ShaderWarmupResult } from '@/scene/world/shader-warmup';
declare const __PORTFOLIO_TEST_BUILD__: boolean; declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = const TEST_BUILD =
@@ -71,6 +72,8 @@ export function createWorldFog(color: number) {
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END); return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
} }
let warmupGenerations = 0;
export async function createProceduralSceneGeneration( export async function createProceduralSceneGeneration(
context: SceneBuildContext, context: SceneBuildContext,
): Promise<SceneGeneration> { ): Promise<SceneGeneration> {
@@ -353,6 +356,23 @@ export async function createProceduralSceneGeneration(
} }
}; };
// SHADER WARM-UP: link the world's programs on the driver's compile threads
// while the poster is still up, so frame 0 finds them ready instead of
// compiling ~60 programs inside one task (src/scene/world/shader-warmup.ts).
// Size the canvas and the composer targets first: the camera and the
// targets are then exactly what frame 0 draws with.
resize();
const warmup: ShaderWarmupResult = await warmUpShaders(
renderer,
scene,
camera,
postProcessor?.sceneTarget() ?? null,
);
const warmupPrograms = (renderer.info.programs as unknown[] | null)?.length ?? 0;
// Test builds: renderer.info.programs must not grow after frame 0 (issue 2).
const warmupGeneration = (warmupGenerations += 1);
let programCount = -1;
if (context.signal.aborted) { if (context.signal.aborted) {
context.resources.dispose(); context.resources.dispose();
throw new DOMException('Scene build aborted', 'AbortError'); throw new DOMException('Scene build aborted', 'AbortError');
@@ -939,11 +959,12 @@ export async function createProceduralSceneGeneration(
// its program as a 70-280 ms hitch inside the opening shot (three slow // its program as a 70-280 ms hitch inside the opening shot (three slow
// frames end the crane; one over 250 ms is a quality downgrade that // frames end the crane; one over 250 ms is a quality downgrade that
// rebuilds the scene). Draw everything once now, behind the poster, // rebuilds the scene). Draw everything once now, behind the poster,
// through the real pipeline: renderer.compile() keys its programs for // through the real pipeline. The build's shader warm-up has already
// the screen (sRGB output, tone mapping) and never matches the post // linked the scene programs off-thread, so this draw is the safety
// processor's linear targets, so every program compiled twice. The // net: it builds the post passes' programs (not in the scene), uploads
// drawing buffer is presented once per task: the cold frame below // textures, and finishes whatever a timed-out or extension-less
// is what the canvas shows. // warm-up left. The drawing buffer is presented once per task: the
// cold frame below is what the canvas shows.
// Culling is off too: the car sits outside the crane's frustum. // Culling is off too: the car sits outside the crane's frustum.
const hidden: { visible: boolean }[] = []; const hidden: { visible: boolean }[] = [];
const culled: { frustumCulled: boolean }[] = []; const culled: { frustumCulled: boolean }[] = [];
@@ -991,6 +1012,20 @@ export async function createProceduralSceneGeneration(
} }
} }
renderedFrames += 1; renderedFrames += 1;
if (TEST_BUILD) {
const programs = (renderer.info.programs as unknown[] | null)?.length ?? 0;
if (programs !== programCount) {
programCount = programs;
window.dispatchEvent(new CustomEvent('portfolio-program-count', {
detail: {
generation: warmupGeneration,
frame: renderedFrames,
programs,
warmup: { ...warmup, programs: warmupPrograms },
},
}));
}
}
}, },
dispose() { dispose() {
if (disposed) return; if (disposed) return;

View File

@@ -0,0 +1,96 @@
// SHADER WARM-UP: compile the world's programs off the main thread before the
// first frame (issue 2, option A).
//
// renderer.compileAsync() asks the driver to link every material's program and
// then polls KHR_parallel_shader_compile's COMPLETION_STATUS instead of blocking
// on LINK_STATUS, so the scene build awaits the compile while the poster stays
// up and the main thread stays free. Two details make the precompiled programs
// the ones the real frames use:
// - three r185 keys a program's tone mapping and output colour space on the
// BOUND render target (WebGLPrograms.getParameters): null means "the screen"
// (sRGB, tone mapped), any off-screen target means "linear, untouched". The
// scene is drawn into the composer's target, so that target must be bound.
// - the light count is part of the key, and compile() gathers lights with
// traverseVisible(). The frame-0 warm render draws with every object visible,
// so the compile reveals the same hidden subtrees.
// Without the extension three flags every program ready at once and resolves
// after one 10 ms timeout: the links are queued, and the warm render waits on
// them as before. A slow driver gets TIMEOUT_MS; the warm render then finishes
// whatever is left synchronously, as it always did.
export const SHADER_WARMUP_TIMEOUT_MS = 2_500;
export type ShaderWarmupOutcome = 'ready' | 'timeout' | 'failed';
export interface ShaderWarmupResult {
outcome: ShaderWarmupOutcome;
/** Wall time the build waited, ms. */
waited: number;
/** KHR_parallel_shader_compile was available (true off-thread compile). */
parallel: boolean;
}
interface WarmupObject {
visible: boolean;
traverse(callback: (object: WarmupObject) => void): void;
}
export interface WarmupRenderer {
getRenderTarget(): unknown;
setRenderTarget(target: unknown): void;
compileAsync(scene: unknown, camera: unknown): Promise<unknown>;
extensions: { has(name: string): boolean };
}
export interface WarmupScheduler {
now(): number;
setTimeout(callback: () => void, delay: number): unknown;
clearTimeout(handle: unknown): void;
}
const defaultScheduler: WarmupScheduler = {
now: () => performance.now(),
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
};
export async function warmUpShaders(
renderer: WarmupRenderer,
scene: WarmupObject,
camera: unknown,
/** The target the scene pass draws into; null when there is no composer. */
sceneTarget: unknown,
timeoutMs = SHADER_WARMUP_TIMEOUT_MS,
scheduler: WarmupScheduler = defaultScheduler,
): Promise<ShaderWarmupResult> {
const started = scheduler.now();
const parallel = renderer.extensions.has('KHR_parallel_shader_compile');
let pending: Promise<unknown>;
// compile() itself is synchronous: target and visibility only need to hold
// for this call, not for the wait.
const previousTarget = renderer.getRenderTarget();
const hidden: WarmupObject[] = [];
try {
scene.traverse((object) => {
if (object.visible) return;
object.visible = true;
hidden.push(object);
});
renderer.setRenderTarget(sceneTarget);
pending = renderer.compileAsync(scene, camera);
} catch {
return { outcome: 'failed', waited: scheduler.now() - started, parallel };
} finally {
for (const object of hidden) object.visible = false;
renderer.setRenderTarget(previousTarget);
}
let timer: unknown = null;
const outcome = await Promise.race<ShaderWarmupOutcome>([
pending.then(() => 'ready' as const, () => 'failed' as const),
new Promise<ShaderWarmupOutcome>((resolve) => {
timer = scheduler.setTimeout(() => resolve('timeout'), timeoutMs);
}),
]);
scheduler.clearTimeout(timer);
return { outcome, waited: scheduler.now() - started, parallel };
}

View File

@@ -0,0 +1,55 @@
import { expect, test } from '../helpers/serve-dist';
// Issue 2 (cold-GPU first frame): the build links the world's programs with
// renderer.compileAsync() against the composer's target, and frame 0 draws
// everything once as a safety net. After frame 0 no program may be created:
// each later one is a 70-280 ms hitch inside the opening shot. Test builds
// dispatch portfolio-program-count whenever renderer.info.programs changes.
interface ProgramCountEvent {
generation: number;
frame: number;
programs: number;
warmup: { outcome: 'ready' | 'timeout' | 'failed'; waited: number; parallel: boolean; programs: number };
}
test('compiles every scene program before the first frame and none after it', async ({ page, distUrl }) => {
await page.addInitScript(() => {
const events: unknown[] = [];
(window as unknown as { __programCounts: unknown[] }).__programCounts = events;
window.addEventListener('portfolio-program-count', (event) => {
events.push(structuredClone((event as CustomEvent).detail));
});
});
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('canvas.journey-canvas')).toHaveAttribute('data-lit', '', { timeout: 20_000 });
// The ignition hold, then the pull-away and the first chapters.
await page.waitForTimeout(3_500);
await page.mouse.move(200, 300);
for (let step = 0; step < 12; step += 1) {
await page.mouse.wheel(0, 400);
await page.waitForTimeout(120);
}
await page.waitForTimeout(500);
const events = await page.evaluate(
() => (window as unknown as { __programCounts: ProgramCountEvent[] }).__programCounts,
);
expect(events.length).toBeGreaterThan(0);
for (const generation of new Set(events.map((event) => event.generation))) {
const own = events.filter((event) => event.generation === generation);
const first = own[0]!;
// The first report comes right after frame 0.
expect(first.frame).toBe(1);
// The warm-up compiled the scene before frame 0 (or gave up on a slow driver).
expect(['ready', 'timeout']).toContain(first.warmup.outcome);
expect(first.warmup.programs).toBeGreaterThan(0);
expect(first.programs).toBeGreaterThanOrEqual(first.warmup.programs);
// Frame 0 adds only the post passes (9 on desktop). A warm-up keyed for
// the wrong target or light count would add the whole scene again (~60).
expect(first.programs - first.warmup.programs).toBeLessThanOrEqual(12);
// No growth afterwards.
expect(own.slice(1)).toEqual([]);
}
});

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { warmUpShaders, type WarmupRenderer, type WarmupScheduler } from '@/scene/world/shader-warmup';
interface Node {
name: string;
visible: boolean;
children: Node[];
traverse(callback: (object: Node) => void): void;
}
const node = (name: string, visible: boolean, children: Node[] = []): Node => ({
name,
visible,
children,
traverse(callback) {
callback(this);
for (const child of this.children) child.traverse(callback);
},
});
const manualScheduler = () => {
let clock = 0;
const timers = new Map<number, () => void>();
let next = 1;
const scheduler: WarmupScheduler = {
now: () => clock,
setTimeout(callback, delay) {
const id = next++;
timers.set(id, () => {
clock += delay;
callback();
});
return id;
},
clearTimeout(handle) {
timers.delete(handle as number);
},
};
return { scheduler, fire: () => [...timers.values()].forEach((run) => run()), pending: () => timers.size };
};
const fakeRenderer = (compile: () => Promise<unknown>, parallel = true) => {
const seen: { target: unknown; visible: string[] }[] = [];
const state = { target: 'screen-previous' as unknown };
let scene: Node | null = null;
const renderer: WarmupRenderer = {
getRenderTarget: () => state.target,
setRenderTarget: (target) => {
state.target = target;
},
compileAsync: (root) => {
scene = root as Node;
const visible: string[] = [];
scene.traverse((object) => {
if (object.visible) visible.push(object.name);
});
seen.push({ target: state.target, visible });
return compile();
},
extensions: { has: (name) => parallel && name === 'KHR_parallel_shader_compile' },
};
return { renderer, seen, state };
};
describe('shader warm-up', () => {
it('compiles against the scene target with every subtree visible, then restores both', async () => {
const lamp = node('hidden-light', false);
const scene = node('root', true, [node('car', true), node('cone', false, [lamp])]);
const { renderer, seen, state } = fakeRenderer(() => Promise.resolve());
const { scheduler } = manualScheduler();
const result = await warmUpShaders(renderer, scene, {}, 'composer-target', 2_500, scheduler);
expect(result).toMatchObject({ outcome: 'ready', parallel: true });
expect(seen).toEqual([
{ target: 'composer-target', visible: ['root', 'car', 'cone', 'hidden-light'] },
]);
expect(state.target).toBe('screen-previous');
expect(scene.children[1]!.visible).toBe(false);
expect(lamp.visible).toBe(false);
});
it('gives up after the timeout so a slow driver never holds the build', async () => {
const { renderer } = fakeRenderer(() => new Promise(() => undefined));
const timers = manualScheduler();
const pending = warmUpShaders(renderer, node('root', true), {}, null, 2_500, timers.scheduler);
timers.fire();
await expect(pending).resolves.toMatchObject({ outcome: 'timeout', waited: 2_500 });
});
it('clears its timer once the compile resolves', async () => {
const { renderer } = fakeRenderer(() => Promise.resolve(), false);
const timers = manualScheduler();
const result = await warmUpShaders(renderer, node('root', true), {}, null, 2_500, timers.scheduler);
expect(result).toMatchObject({ outcome: 'ready', parallel: false });
expect(timers.pending()).toBe(0);
});
it('reports a throwing compile as failed and still restores the renderer and scene', async () => {
const hidden = node('hidden', false);
const { renderer, state } = fakeRenderer(() => {
throw new Error('link failed');
});
const result = await warmUpShaders(renderer, node('root', true, [hidden]), {}, 'target', 2_500, manualScheduler().scheduler);
expect(result.outcome).toBe('failed');
expect(state.target).toBe('screen-previous');
expect(hidden.visible).toBe(false);
});
it('reports a rejected compile as failed', async () => {
const { renderer } = fakeRenderer(() => Promise.reject(new Error('lost context')));
const result = await warmUpShaders(renderer, node('root', true), {}, null, 2_500, manualScheduler().scheduler);
expect(result.outcome).toBe('failed');
});
});

View File

@@ -404,6 +404,8 @@ describe("procedural world models", () => {
expect(bloomPass.strength).toBe(restingBloom.strength); expect(bloomPass.strength).toBe(restingBloom.strength);
expect(bloomPass.threshold).toBe(restingBloom.threshold); expect(bloomPass.threshold).toBe(restingBloom.threshold);
processor.render({}, {}); processor.render({}, {});
// The shader warm-up binds the scene pass's target (EffectComposer's readBuffer).
expect(processor.sceneTarget()).toBe(composer.renderTarget2);
const allocations = resources.gpuBudgetSnapshot({ const allocations = resources.gpuBudgetSnapshot({
tier: "high", tier: "high",
defaultFramebuffer: { defaultFramebuffer: {