Merge branch 'polish/compile-async' into redesign/overnight
This commit is contained in:
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