Merge branch 'polish/compile-async' into redesign/overnight
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