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,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');
});
});