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:
55
tests/browser/shader-warmup.spec.ts
Normal file
55
tests/browser/shader-warmup.spec.ts
Normal 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([]);
|
||||
}
|
||||
});
|
||||
115
tests/unit/shader-warmup.test.ts
Normal file
115
tests/unit/shader-warmup.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,8 @@ describe("procedural world models", () => {
|
||||
expect(bloomPass.strength).toBe(restingBloom.strength);
|
||||
expect(bloomPass.threshold).toBe(restingBloom.threshold);
|
||||
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({
|
||||
tier: "high",
|
||||
defaultFramebuffer: {
|
||||
|
||||
Reference in New Issue
Block a user