Files
Portfolio/tests/browser/shader-warmup.spec.ts
LucaP 26e5ff01fd 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.
2026-10-12 18:34:12 +03:00

56 lines
2.5 KiB
TypeScript

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([]);
}
});