feat: build procedural lifecycle-safe drive scene

This commit is contained in:
2026-09-02 04:53:55 +03:00
parent cc0887fee0
commit fd8347fbdc
14 changed files with 1822 additions and 0 deletions

View File

@@ -0,0 +1,435 @@
import { describe, expect, it, vi } from 'vitest';
import {
createSceneController,
createSceneLifecycleMachine,
} from '@/scene/create-scene-controller';
import type {
SceneBuildContext,
SceneGeneration,
SceneLifecycleState,
SceneScheduler,
} from '@/scene/types';
import type { JourneySnapshot } from '@/journey/types';
const state = (kind: SceneLifecycleState['kind']): SceneLifecycleState => {
switch (kind) {
case 'idle': return { kind };
case 'loading': return { kind, generation: 1 };
case 'running': return { kind, generation: 1 };
case 'paused': return { kind, generation: null, resumeNeedsLoad: true };
case 'lost': return { kind, lostAt: 100, restorationPending: false };
case 'restoring': return { kind, generation: 2, lostAt: 100 };
case 'static': return { kind, reason: 'test' };
case 'disposed': return { kind };
}
};
describe('scene lifecycle machine', () => {
const legal: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
idle: ['loading', 'paused', 'static', 'disposed'],
loading: ['running', 'paused', 'lost', 'static', 'disposed'],
running: ['paused', 'lost', 'static', 'disposed'],
paused: ['running', 'loading', 'static', 'disposed'],
lost: ['restoring', 'paused', 'static', 'disposed'],
restoring: ['running', 'paused', 'lost', 'static', 'disposed'],
static: ['loading', 'disposed'],
disposed: [],
};
for (const from of Object.keys(legal) as SceneLifecycleState['kind'][]) {
for (const to of Object.keys(legal) as SceneLifecycleState['kind'][]) {
if (from === to) continue;
const expected = legal[from].includes(to) || (from === 'paused' && to === 'lost');
it(`${expected ? 'allows' : 'rejects'} ${from} -> ${to}`, () => {
const initial = from === 'paused' && to === 'lost'
? { kind: 'paused' as const, generation: 1, resumeNeedsLoad: false }
: state(from);
const machine = createSceneLifecycleMachine(initial);
if (expected) {
expect(() => machine.transition(state(to))).not.toThrow();
expect(machine.snapshot().kind).toBe(to);
} else {
expect(() => machine.transition(state(to))).toThrow(/illegal transition/i);
}
});
}
}
it('rejects paused -> lost when no complete retained context exists', () => {
const machine = createSceneLifecycleMachine({
kind: 'paused', generation: null, resumeNeedsLoad: true,
});
expect(() => machine.transition(state('lost'))).toThrow(/retained context/i);
});
it('keeps disposed terminal', () => {
const machine = createSceneLifecycleMachine({ kind: 'disposed' });
expect(() => machine.transition({ kind: 'running', generation: 9 })).toThrow(/illegal transition/i);
});
});
describe('scene controller lifecycle', () => {
it('attaches one generation and schedules at most one RAF', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'running', generation: 1 },
canvasCount: 1,
rafCount: 1,
currentGeneration: 1,
});
controller.resume();
controller.resume();
expect(controller.snapshot().rafCount).toBe(1);
harness.scheduler.flushFrame(16);
expect(controller.snapshot().rafCount).toBe(1);
expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
});
it('copies serializable journey snapshots instead of retaining caller objects', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
const input = journey({ routeProgress: 0.25, sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2 } });
controller.update(input);
input.routeProgress = 0.9;
if (input.sign) input.sign.progress = 0.8;
harness.scheduler.flushFrame(16);
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
expect.objectContaining({ routeProgress: 0.25, sign: expect.objectContaining({ progress: 0.2 }) }),
16,
);
});
it('invalidates and disposes an aborted loading generation before a late completion can attach', async () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
const start = controller.start();
expect(controller.snapshot().state.kind).toBe('loading');
controller.pause();
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
harness.resolveBuild(0);
await start;
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'paused', generation: null, resumeNeedsLoad: true },
currentGeneration: null,
rafCount: 0,
});
});
it('disposes stale async results and lets only the current generation attach', async () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
const first = controller.start();
controller.pause();
const second = controller.resume();
harness.resolveBuild(0);
harness.resolveBuild(1);
await Promise.all([first, second]);
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
expect(harness.generations[1]?.dispose).not.toHaveBeenCalled();
expect(controller.snapshot()).toMatchObject({ currentGeneration: 2, canvasCount: 1, rafCount: 1 });
});
it.each(['loading', 'running', 'paused'] as const)('handles first context loss from retained %s state', async (source) => {
const harness = createHarness({ deferredBuild: source === 'loading' });
const controller = createSceneController(harness.options);
const start = controller.start();
if (source !== 'loading') await start;
if (source === 'paused') controller.pause();
const event = new Event('webglcontextlost', { cancelable: true });
harness.canvas.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(controller.snapshot()).toMatchObject({
state: { kind: 'lost', lostAt: 0, restorationPending: false },
rafCount: 0,
currentGeneration: null,
});
if (source === 'loading') {
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
harness.resolveBuild(0);
await start;
}
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
});
it('rejects context loss from a pause without a retained context', () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
void controller.start();
controller.pause();
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
expect(controller.snapshot().state).toEqual({ kind: 'paused', generation: null, resumeNeedsLoad: true });
});
it('defers a hidden restoration and restores on the first visible lifecycle task before cutoff', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.page.visibilityState = 'hidden';
harness.page.document.dispatchEvent(new Event('visibilitychange'));
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
harness.clock.now = 2000;
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
expect(controller.snapshot().state).toEqual({ kind: 'lost', lostAt: 0, restorationPending: true });
expect(harness.buildContexts).toHaveLength(1);
harness.page.visibilityState = 'visible';
harness.page.document.dispatchEvent(new Event('visibilitychange'));
await controller.whenSettled();
expect(controller.snapshot().state.kind).toBe('running');
expect(harness.buildContexts).toHaveLength(2);
});
it('enters static when hidden restoration is first evaluated after the five-second cutoff', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.page.visibilityState = 'hidden';
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
harness.clock.now = 5000.01;
harness.page.visibilityState = 'visible';
harness.page.document.dispatchEvent(new Event('visibilitychange'));
await controller.whenSettled();
expect(controller.snapshot()).toMatchObject({ state: { kind: 'static', reason: 'context-restore-timeout' }, rafCount: 0 });
});
it('disposes a restoration that finishes after the attach-time cutoff', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
harness.options.build = harness.deferredBuilder();
harness.clock.now = 1000;
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
expect(controller.snapshot().state.kind).toBe('restoring');
harness.clock.now = 5001;
harness.resolveBuild(0);
await controller.whenSettled();
expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce();
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'context-restore-timeout' });
});
it('makes a later context loss terminal static after one successful restoration', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
await controller.whenSettled();
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'repeated-context-loss' });
});
it.each([
['idle', 'idle'],
['loading', 'paused'],
['running', 'paused'],
['paused', 'paused'],
['static', 'static'],
] as const)('freezes %s without creating work and restores according to the BFCache table', async (source, frozenKind) => {
const harness = createHarness({ deferredBuild: source === 'loading' });
const controller = createSceneController(harness.options);
let start: Promise<void> | undefined;
if (source !== 'idle') start = controller.start();
if (source !== 'idle' && source !== 'loading') await start;
if (source === 'paused') controller.pause();
if (source === 'static') controller.setStatic('test');
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', state: { kind: frozenKind }, rafCount: 0 });
const beforeShow = harness.buildContexts.length;
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
if (source === 'loading') harness.resolveBuild(1);
await controller.whenSettled();
expect(controller.snapshot().overlay).toBe('active');
if (source === 'running' || source === 'paused') expect(controller.snapshot().state.kind).toBe('running');
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
if (start && source === 'loading') {
harness.resolveBuild(0);
await start;
}
});
it('freezes restoration back to lost and applies the original cutoff on pageshow', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
harness.options.build = harness.deferredBuilder();
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', state: { kind: 'lost', lostAt: 0 } });
harness.clock.now = 5001;
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
await controller.whenSettled();
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'context-restore-timeout' });
});
it('does not attach a completion while frozen', async () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
const start = controller.start();
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
harness.resolveBuild(0);
await start;
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', currentGeneration: null, rafCount: 0 });
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
});
it('makes non-persisted pagehide and disposal terminal and idempotent', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.page.window.dispatchEvent(pageEvent('pagehide', false));
controller.dispose();
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
expect(controller.snapshot()).toMatchObject({ overlay: 'disposed', state: { kind: 'disposed' }, canvasCount: 0, rafCount: 0 });
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
await expect(controller.start()).resolves.toBeUndefined();
expect(controller.snapshot().state.kind).toBe('disposed');
});
it('restarts with a new generation when quality changes and applies tier settings', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
await controller.setQuality('low');
expect(harness.buildContexts.map((context) => context.quality)).toEqual(['high', 'low']);
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
expect(controller.snapshot()).toMatchObject({ state: { kind: 'running', generation: 2 }, quality: 'low' });
});
});
function createHarness(options: { deferredBuild?: boolean } = {}) {
const clock = { now: 0 };
const scheduler = new FakeScheduler(clock);
const canvas = new EventTarget();
const page = {
document: new EventTarget(),
window: new EventTarget(),
visibilityState: 'visible' as DocumentVisibilityState,
};
const generations: Array<SceneGeneration & { render: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> }> = [];
const buildContexts: SceneBuildContext[] = [];
const pending: Array<((generation: SceneGeneration) => void) | undefined> = [];
const makeGeneration = (): SceneGeneration & { render: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> } => {
const generation = { render: vi.fn(), dispose: vi.fn() };
generations.push(generation);
return generation;
};
const deferredBuilder = () => (context: SceneBuildContext): Promise<SceneGeneration> => {
buildContexts.push(context);
return new Promise((resolve) => pending.push(resolve));
};
const immediateBuilder = async (context: SceneBuildContext): Promise<SceneGeneration> => {
buildContexts.push(context);
return makeGeneration();
};
const controllerOptions = {
canvas,
quality: 'high' as const,
scheduler,
page,
motionAllowed: () => true,
build: options.deferredBuild ? deferredBuilder() : immediateBuilder,
};
return {
clock,
scheduler,
canvas,
page,
generations,
buildContexts,
options: controllerOptions,
deferredBuilder,
resolveBuild(index: number) {
const resolve = pending[index];
if (!resolve) throw new Error(`No pending build ${index}`);
resolve(makeGeneration());
},
};
}
class FakeScheduler implements SceneScheduler {
private nextId = 1;
private frames = new Map<number, FrameRequestCallback>();
private timers = new Map<number, () => void>();
constructor(private readonly clock: { now: number }) {}
now = () => this.clock.now;
requestAnimationFrame = (callback: FrameRequestCallback): number => {
const id = this.nextId++;
this.frames.set(id, callback);
return id;
};
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
setTimeout = (callback: () => void): number => {
const id = this.nextId++;
this.timers.set(id, callback);
return id;
};
clearTimeout = (id: number): void => { this.timers.delete(id); };
flushFrame(now: number): void {
this.clock.now = now;
const entries = [...this.frames.entries()];
this.frames.clear();
for (const [, callback] of entries) callback(now);
}
}
function pageEvent(type: string, persisted: boolean): Event {
const event = new Event(type);
Object.defineProperty(event, 'persisted', { value: persisted });
return event;
}
function journey(overrides: Partial<JourneySnapshot> = {}): JourneySnapshot {
return {
scrollY: 0,
routeProgress: 0,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
activeSectionId: 'start',
chapter: 'deep-night',
sign: null,
arrived: false,
...overrides,
};
}

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { routeLabelFor } from '@/scene/world/sign';
describe('SceneResourceBag', () => {
it.each([
'geometry',
'material',
'texture',
'target',
'pass',
'renderer',
] as const)('disposes an owned %s exactly once', (kind) => {
const dispose = vi.fn();
const bag = new SceneResourceBag();
bag.own(kind, { dispose });
bag.dispose();
bag.dispose();
expect(dispose).toHaveBeenCalledTimes(1);
expect(bag.snapshot().disposed).toBe(true);
expect(bag.snapshot().owned[kind]).toBe(0);
});
it('deduplicates the same disposable across ownership categories', () => {
const dispose = vi.fn();
const resource = { dispose };
const bag = new SceneResourceBag();
bag.own('geometry', resource);
bag.own('material', resource);
bag.dispose();
expect(dispose).toHaveBeenCalledTimes(1);
});
it('immediately disposes a resource registered after terminal disposal', () => {
const dispose = vi.fn();
const bag = new SceneResourceBag();
bag.dispose();
bag.own('texture', { dispose });
expect(dispose).toHaveBeenCalledTimes(1);
});
it('removes listeners, disconnects observers, and clears timers and RAF exactly once', () => {
const target = new EventTarget();
const listener = vi.fn();
const observer = { disconnect: vi.fn() };
const clearTimer = vi.fn();
const cancelFrame = vi.fn();
const bag = new SceneResourceBag();
bag.listen(target, 'ping', listener);
bag.observe(observer);
bag.timer(7, clearTimer);
bag.animationFrame(9, cancelFrame);
target.dispatchEvent(new Event('ping'));
bag.dispose();
bag.dispose();
target.dispatchEvent(new Event('ping'));
expect(listener).toHaveBeenCalledTimes(1);
expect(observer.disconnect).toHaveBeenCalledTimes(1);
expect(clearTimer).toHaveBeenCalledTimes(1);
expect(clearTimer).toHaveBeenCalledWith(7);
expect(cancelFrame).toHaveBeenCalledTimes(1);
expect(cancelFrame).toHaveBeenCalledWith(9);
});
it('replaces a tracked RAF without allowing more than one outstanding frame', () => {
const cancelFrame = vi.fn();
const bag = new SceneResourceBag();
bag.animationFrame(1, cancelFrame);
bag.animationFrame(2, cancelFrame);
expect(cancelFrame).toHaveBeenCalledOnce();
expect(cancelFrame).toHaveBeenCalledWith(1);
expect(bag.snapshot().rafCount).toBe(1);
bag.releaseAnimationFrame(2);
expect(bag.snapshot().rafCount).toBe(0);
bag.dispose();
expect(cancelFrame).toHaveBeenCalledOnce();
});
});
describe('scene render quality', () => {
it('reduces GPU work monotonically across live tiers', () => {
const high = renderQualityFor('high', 3, false);
const medium = renderQualityFor('medium', 3, false);
const low = renderQualityFor('low', 3, false);
expect(high).toEqual({ pixelRatio: 2, terrainSegments: 96, particleCount: 384, postEffects: true });
expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true });
expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false });
});
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
});
it('keeps route sign labels canonical and compact', () => {
expect(routeLabelFor('start')).toBe('00 / START');
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
});
});