feat: build procedural lifecycle-safe drive scene
This commit is contained in:
557
src/scene/create-scene-controller.ts
Normal file
557
src/scene/create-scene-controller.ts
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type {
|
||||||
|
LiveQualityTier,
|
||||||
|
PageLifecycleOverlay,
|
||||||
|
SceneBuildContext,
|
||||||
|
SceneController,
|
||||||
|
SceneControllerOptions,
|
||||||
|
SceneControllerSnapshot,
|
||||||
|
SceneGeneration,
|
||||||
|
SceneLifecycleMachine,
|
||||||
|
SceneLifecycleState,
|
||||||
|
ScenePageEnvironment,
|
||||||
|
SceneScheduler,
|
||||||
|
} from '@/scene/types';
|
||||||
|
|
||||||
|
const RESTORE_CUTOFF_MS = 5_000;
|
||||||
|
|
||||||
|
const legalTransitions: 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', 'lost', 'static', 'disposed'],
|
||||||
|
lost: ['restoring', 'paused', 'static', 'disposed'],
|
||||||
|
restoring: ['running', 'paused', 'lost', 'static', 'disposed'],
|
||||||
|
static: ['loading', 'disposed'],
|
||||||
|
disposed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSceneLifecycleMachine(initial: SceneLifecycleState = { kind: 'idle' }): SceneLifecycleMachine {
|
||||||
|
let current = initial;
|
||||||
|
return {
|
||||||
|
transition(next) {
|
||||||
|
if (!legalTransitions[current.kind].includes(next.kind)) {
|
||||||
|
throw new Error(`Illegal transition: ${current.kind} -> ${next.kind}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.kind === 'paused'
|
||||||
|
&& next.kind === 'lost'
|
||||||
|
&& (current.generation === null || current.resumeNeedsLoad)
|
||||||
|
) {
|
||||||
|
throw new Error('Illegal transition: paused -> lost requires a retained context');
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
replace(next) {
|
||||||
|
if (next.kind !== current.kind) throw new Error('Lifecycle metadata replacement must preserve state kind');
|
||||||
|
current = next;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
snapshot: () => current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSceneController(options: SceneControllerOptions): SceneController {
|
||||||
|
const scheduler = options.scheduler ?? browserScheduler();
|
||||||
|
const page = options.page ?? browserPageEnvironment();
|
||||||
|
const machine = createSceneLifecycleMachine();
|
||||||
|
const rootResources = new SceneResourceBag();
|
||||||
|
let overlay: PageLifecycleOverlay = 'active';
|
||||||
|
let quality: LiveQualityTier = options.quality;
|
||||||
|
let generationCounter = 0;
|
||||||
|
let current: ActiveGeneration | null = null;
|
||||||
|
let loading: LoadingGeneration | null = null;
|
||||||
|
let activeOperation: Promise<void> | null = null;
|
||||||
|
let latestJourney = initialJourneySnapshot();
|
||||||
|
let pauseReason: 'visitor' | 'visibility' | 'freeze' | null = null;
|
||||||
|
let contextLossCount = 0;
|
||||||
|
let restorationSucceeded = false;
|
||||||
|
let lostAtForRestore: number | null = null;
|
||||||
|
|
||||||
|
const transition = (next: SceneLifecycleState): void => {
|
||||||
|
machine.transition(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRaf = (): void => rootResources.cancelAnimationFrame();
|
||||||
|
|
||||||
|
const disposeGeneration = (): void => {
|
||||||
|
stopRaf();
|
||||||
|
if (current) {
|
||||||
|
current.resources.dispose();
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidateLoading = (): void => {
|
||||||
|
if (!loading) return;
|
||||||
|
const invalidated = loading;
|
||||||
|
loading = null;
|
||||||
|
activeOperation = null;
|
||||||
|
invalidated.abort.abort();
|
||||||
|
invalidated.resources.dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidateAll = (): void => {
|
||||||
|
invalidateLoading();
|
||||||
|
disposeGeneration();
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterStatic = (reason: string): void => {
|
||||||
|
if (machine.snapshot().kind === 'disposed') return;
|
||||||
|
invalidateAll();
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
pauseReason = null;
|
||||||
|
lostAtForRestore = null;
|
||||||
|
transition({ kind: 'static', reason });
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFrame = (): void => {
|
||||||
|
if (
|
||||||
|
rootResources.snapshot().rafCount > 0
|
||||||
|
|| overlay !== 'active'
|
||||||
|
|| page.visibilityState !== 'visible'
|
||||||
|
|| machine.snapshot().kind !== 'running'
|
||||||
|
|| !current
|
||||||
|
) return;
|
||||||
|
let frameId = 0;
|
||||||
|
frameId = scheduler.requestAnimationFrame((now) => {
|
||||||
|
rootResources.releaseAnimationFrame(frameId);
|
||||||
|
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
|
||||||
|
try {
|
||||||
|
current.runtime.render(latestJourney, now);
|
||||||
|
} catch {
|
||||||
|
enterStatic('render-failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleFrame();
|
||||||
|
});
|
||||||
|
rootResources.animationFrame(frameId, scheduler.cancelAnimationFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginLoad = (mode: 'loading' | 'restoring', restoreLostAt?: number): Promise<void> => {
|
||||||
|
if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return Promise.resolve();
|
||||||
|
invalidateLoading();
|
||||||
|
disposeGeneration();
|
||||||
|
const generation = ++generationCounter;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
loading = restoreLostAt === undefined
|
||||||
|
? { generation, abort, resources, mode }
|
||||||
|
: { generation, abort, resources, mode, restoreLostAt };
|
||||||
|
transition(mode === 'restoring'
|
||||||
|
? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() }
|
||||||
|
: { kind: 'loading', generation });
|
||||||
|
|
||||||
|
const context: SceneBuildContext = {
|
||||||
|
canvas: options.canvas,
|
||||||
|
quality,
|
||||||
|
generation,
|
||||||
|
signal: abort.signal,
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
|
||||||
|
let buildResult: Promise<SceneGeneration>;
|
||||||
|
try {
|
||||||
|
buildResult = Promise.resolve(options.build(context));
|
||||||
|
} catch (error) {
|
||||||
|
buildResult = Promise.reject(error);
|
||||||
|
}
|
||||||
|
const operation = buildResult
|
||||||
|
.then((runtime) => finishLoad(generation, runtime))
|
||||||
|
.catch(() => failLoad(generation));
|
||||||
|
activeOperation = operation;
|
||||||
|
void operation.finally(() => {
|
||||||
|
if (activeOperation === operation) activeOperation = null;
|
||||||
|
});
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishLoad = (generation: number, runtime: SceneGeneration): void => {
|
||||||
|
const candidate = loading;
|
||||||
|
if (!candidate || candidate.generation !== generation) {
|
||||||
|
runtime.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candidate.resources.own('other', runtime);
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
const expectedKind = candidate.mode === 'restoring' ? 'restoring' : 'loading';
|
||||||
|
const pastCutoff = candidate.mode === 'restoring'
|
||||||
|
&& candidate.restoreLostAt !== undefined
|
||||||
|
&& scheduler.now() > candidate.restoreLostAt + RESTORE_CUTOFF_MS;
|
||||||
|
if (
|
||||||
|
candidate.abort.signal.aborted
|
||||||
|
|| overlay !== 'active'
|
||||||
|
|| lifecycle.kind !== expectedKind
|
||||||
|
|| lifecycle.generation !== generation
|
||||||
|
|| pastCutoff
|
||||||
|
) {
|
||||||
|
loading = null;
|
||||||
|
candidate.resources.dispose();
|
||||||
|
if (pastCutoff && machine.snapshot().kind === 'restoring') enterStatic('context-restore-timeout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = null;
|
||||||
|
current = { generation, resources: candidate.resources, runtime };
|
||||||
|
if (candidate.mode === 'restoring') {
|
||||||
|
restorationSucceeded = true;
|
||||||
|
lostAtForRestore = null;
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
}
|
||||||
|
if (page.visibilityState === 'visible') {
|
||||||
|
transition({ kind: 'running', generation });
|
||||||
|
pauseReason = null;
|
||||||
|
scheduleFrame();
|
||||||
|
} else {
|
||||||
|
transition({ kind: 'paused', generation, resumeNeedsLoad: false });
|
||||||
|
pauseReason = 'visibility';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const failLoad = (generation: number): void => {
|
||||||
|
if (!loading || loading.generation !== generation) return;
|
||||||
|
const failed = loading;
|
||||||
|
loading = null;
|
||||||
|
failed.resources.dispose();
|
||||||
|
if (failed.abort.signal.aborted || machine.snapshot().kind === 'disposed') return;
|
||||||
|
enterStatic(failed.mode === 'restoring' ? 'context-restore-failure' : 'scene-load-failure');
|
||||||
|
};
|
||||||
|
|
||||||
|
let restoreTimer: number | null = null;
|
||||||
|
const clearRestoreWatchdog = (): void => {
|
||||||
|
if (restoreTimer === null) return;
|
||||||
|
scheduler.clearTimeout(restoreTimer);
|
||||||
|
rootResources.releaseTimer(restoreTimer);
|
||||||
|
restoreTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const armRestoreWatchdog = (lostAt: number): void => {
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
const delay = Math.max(0, lostAt + RESTORE_CUTOFF_MS - scheduler.now());
|
||||||
|
const timer = scheduler.setTimeout(() => {
|
||||||
|
rootResources.releaseTimer(timer);
|
||||||
|
if (restoreTimer === timer) restoreTimer = null;
|
||||||
|
evaluatePendingRestore();
|
||||||
|
}, delay);
|
||||||
|
restoreTimer = rootResources.timer(timer, scheduler.clearTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluatePendingRestore = (): void => {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (lifecycle.kind !== 'lost' || !lifecycle.restorationPending) return;
|
||||||
|
if (overlay !== 'active' || page.visibilityState !== 'visible') return;
|
||||||
|
if (scheduler.now() > lifecycle.lostAt + RESTORE_CUTOFF_MS) {
|
||||||
|
enterStatic('context-restore-timeout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void beginLoad('restoring', lifecycle.lostAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextLost = (rawEvent: Event): void => {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
const legal = lifecycle.kind === 'loading'
|
||||||
|
|| lifecycle.kind === 'running'
|
||||||
|
|| (lifecycle.kind === 'paused' && lifecycle.generation !== null && !lifecycle.resumeNeedsLoad);
|
||||||
|
if (!legal || overlay === 'disposed') return;
|
||||||
|
rawEvent.preventDefault();
|
||||||
|
contextLossCount += 1;
|
||||||
|
if (restorationSucceeded || contextLossCount > 1) {
|
||||||
|
enterStatic('repeated-context-loss');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lostAt = scheduler.now();
|
||||||
|
invalidateAll();
|
||||||
|
pauseReason = null;
|
||||||
|
lostAtForRestore = lostAt;
|
||||||
|
transition({ kind: 'lost', lostAt, restorationPending: false });
|
||||||
|
if (page.visibilityState === 'visible' && overlay === 'active') armRestoreWatchdog(lostAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextRestored = (): void => {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (lifecycle.kind !== 'lost' || overlay === 'disposed') return;
|
||||||
|
machine.replace({ ...lifecycle, restorationPending: true });
|
||||||
|
if (overlay === 'active' && page.visibilityState === 'visible') evaluatePendingRestore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibilityChange = (): void => {
|
||||||
|
if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return;
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (page.visibilityState === 'hidden') {
|
||||||
|
stopRaf();
|
||||||
|
if (lifecycle.kind === 'running' && current) {
|
||||||
|
transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false });
|
||||||
|
pauseReason = 'visibility';
|
||||||
|
} else if (lifecycle.kind === 'restoring') {
|
||||||
|
const lostAt = lifecycle.lostAt;
|
||||||
|
invalidateLoading();
|
||||||
|
transition({ kind: 'lost', lostAt, restorationPending: true });
|
||||||
|
lostAtForRestore = lostAt;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lifecycle.kind === 'paused' && pauseReason === 'visibility') {
|
||||||
|
if (current && !lifecycle.resumeNeedsLoad) {
|
||||||
|
transition({ kind: 'running', generation: current.generation });
|
||||||
|
pauseReason = null;
|
||||||
|
scheduleFrame();
|
||||||
|
} else {
|
||||||
|
pauseReason = null;
|
||||||
|
void beginLoad('loading');
|
||||||
|
}
|
||||||
|
} else if (lifecycle.kind === 'lost') {
|
||||||
|
evaluatePendingRestore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageHide = (event: Event): void => {
|
||||||
|
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
|
||||||
|
if (!persisted) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overlay === 'disposed') return;
|
||||||
|
overlay = 'frozen';
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
switch (lifecycle.kind) {
|
||||||
|
case 'idle':
|
||||||
|
case 'static':
|
||||||
|
case 'disposed':
|
||||||
|
break;
|
||||||
|
case 'loading':
|
||||||
|
invalidateLoading();
|
||||||
|
transition({ kind: 'paused', generation: null, resumeNeedsLoad: true });
|
||||||
|
pauseReason = 'freeze';
|
||||||
|
break;
|
||||||
|
case 'running':
|
||||||
|
stopRaf();
|
||||||
|
transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false });
|
||||||
|
pauseReason = 'freeze';
|
||||||
|
break;
|
||||||
|
case 'paused':
|
||||||
|
pauseReason = 'freeze';
|
||||||
|
break;
|
||||||
|
case 'lost':
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
break;
|
||||||
|
case 'restoring': {
|
||||||
|
const lostAt = lifecycle.lostAt;
|
||||||
|
invalidateLoading();
|
||||||
|
transition({ kind: 'lost', lostAt, restorationPending: true });
|
||||||
|
lostAtForRestore = lostAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageShow = (event: Event): void => {
|
||||||
|
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
|
||||||
|
if (!persisted || overlay !== 'frozen') return;
|
||||||
|
overlay = 'active';
|
||||||
|
if (!(options.motionAllowed?.() ?? true)) {
|
||||||
|
enterStatic('motion-disallowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (page.visibilityState !== 'visible') return;
|
||||||
|
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
|
||||||
|
pauseReason = null;
|
||||||
|
if (current && !lifecycle.resumeNeedsLoad) {
|
||||||
|
transition({ kind: 'running', generation: current.generation });
|
||||||
|
scheduleFrame();
|
||||||
|
} else {
|
||||||
|
void beginLoad('loading');
|
||||||
|
}
|
||||||
|
} else if (lifecycle.kind === 'lost') {
|
||||||
|
evaluatePendingRestore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rootResources.listen(options.canvas, 'webglcontextlost', onContextLost);
|
||||||
|
rootResources.listen(options.canvas, 'webglcontextrestored', onContextRestored);
|
||||||
|
rootResources.listen(page.document, 'visibilitychange', onVisibilityChange);
|
||||||
|
rootResources.listen(page.window, 'pagehide', onPageHide);
|
||||||
|
rootResources.listen(page.window, 'pageshow', onPageShow);
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) dispose();
|
||||||
|
else rootResources.listen(options.signal, 'abort', dispose, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): Promise<void> {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (overlay !== 'active' || lifecycle.kind === 'disposed') return Promise.resolve();
|
||||||
|
if (lifecycle.kind === 'running' || lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') {
|
||||||
|
return activeOperation ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
if (lifecycle.kind !== 'idle') return Promise.resolve();
|
||||||
|
return beginLoad('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(): void {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (lifecycle.kind === 'disposed' || lifecycle.kind === 'static' || lifecycle.kind === 'paused') return;
|
||||||
|
pauseReason = 'visitor';
|
||||||
|
if (lifecycle.kind === 'running' && current) {
|
||||||
|
stopRaf();
|
||||||
|
transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invalidateAll();
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
lostAtForRestore = null;
|
||||||
|
transition({ kind: 'paused', generation: null, resumeNeedsLoad: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume(): Promise<void> {
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (overlay !== 'active' || lifecycle.kind !== 'paused') return Promise.resolve();
|
||||||
|
pauseReason = null;
|
||||||
|
if (current && !lifecycle.resumeNeedsLoad) {
|
||||||
|
transition({ kind: 'running', generation: current.generation });
|
||||||
|
scheduleFrame();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return beginLoad('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatic(reason: string): void {
|
||||||
|
if (machine.snapshot().kind === 'static' || machine.snapshot().kind === 'disposed') return;
|
||||||
|
enterStatic(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableMotion(): Promise<void> {
|
||||||
|
if (machine.snapshot().kind !== 'static' || !(options.motionAllowed?.() ?? true)) return Promise.resolve();
|
||||||
|
return beginLoad('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setQuality(tier: QualityTier): Promise<void> {
|
||||||
|
if (tier === 'static') {
|
||||||
|
setStatic('quality-static');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quality === tier) return;
|
||||||
|
quality = tier;
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
|
if (lifecycle.kind === 'running') {
|
||||||
|
stopRaf();
|
||||||
|
transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false });
|
||||||
|
disposeGeneration();
|
||||||
|
await beginLoad('loading');
|
||||||
|
} else if (lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') {
|
||||||
|
pause();
|
||||||
|
await resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(snapshot: JourneySnapshot): void {
|
||||||
|
latestJourney = copyJourneySnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(): SceneControllerSnapshot {
|
||||||
|
return {
|
||||||
|
state: machine.snapshot(),
|
||||||
|
overlay,
|
||||||
|
quality,
|
||||||
|
canvasCount: overlay === 'disposed' ? 0 : 1,
|
||||||
|
rafCount: rootResources.snapshot().rafCount,
|
||||||
|
currentGeneration: current?.generation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function whenSettled(): Promise<void> {
|
||||||
|
return activeOperation ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose(): void {
|
||||||
|
if (machine.snapshot().kind === 'disposed') return;
|
||||||
|
overlay = 'disposed';
|
||||||
|
invalidateAll();
|
||||||
|
clearRestoreWatchdog();
|
||||||
|
transition({ kind: 'disposed' });
|
||||||
|
rootResources.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
setStatic,
|
||||||
|
enableMotion,
|
||||||
|
setQuality,
|
||||||
|
update,
|
||||||
|
snapshot,
|
||||||
|
whenSettled,
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveGeneration {
|
||||||
|
generation: number;
|
||||||
|
resources: SceneResourceBag;
|
||||||
|
runtime: SceneGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingGeneration {
|
||||||
|
generation: number;
|
||||||
|
abort: AbortController;
|
||||||
|
resources: SceneResourceBag;
|
||||||
|
mode: 'loading' | 'restoring';
|
||||||
|
restoreLostAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
||||||
|
return {
|
||||||
|
scrollY: finite(snapshot.scrollY),
|
||||||
|
routeProgress: finite(snapshot.routeProgress),
|
||||||
|
direction: snapshot.direction,
|
||||||
|
acceleration: finite(snapshot.acceleration),
|
||||||
|
driveSpeed: finite(snapshot.driveSpeed),
|
||||||
|
activeSectionId: snapshot.activeSectionId,
|
||||||
|
chapter: snapshot.chapter,
|
||||||
|
sign: snapshot.sign ? {
|
||||||
|
targetId: snapshot.sign.targetId,
|
||||||
|
phase: snapshot.sign.phase,
|
||||||
|
progress: finite(snapshot.sign.progress),
|
||||||
|
} : null,
|
||||||
|
arrived: Boolean(snapshot.arrived),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finite(value: number): number {
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialJourneySnapshot(): JourneySnapshot {
|
||||||
|
return {
|
||||||
|
scrollY: 0,
|
||||||
|
routeProgress: 0,
|
||||||
|
direction: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
driveSpeed: 0.18,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
chapter: 'deep-night',
|
||||||
|
sign: null,
|
||||||
|
arrived: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserScheduler(): SceneScheduler {
|
||||||
|
return {
|
||||||
|
now: () => performance.now(),
|
||||||
|
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
|
||||||
|
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
|
||||||
|
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
|
||||||
|
clearTimeout: (id) => window.clearTimeout(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserPageEnvironment(): ScenePageEnvironment {
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
window,
|
||||||
|
get visibilityState() { return document.visibilityState; },
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/scene/render-quality.ts
Normal file
29
src/scene/render-quality.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { LiveQualityTier } from '@/scene/types';
|
||||||
|
|
||||||
|
export interface RenderQualitySettings {
|
||||||
|
pixelRatio: number;
|
||||||
|
terrainSegments: number;
|
||||||
|
particleCount: number;
|
||||||
|
postEffects: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pixelRatio'> & { dprCap: number }>> = {
|
||||||
|
high: { dprCap: 2, terrainSegments: 96, particleCount: 384, postEffects: true },
|
||||||
|
medium: { dprCap: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true },
|
||||||
|
low: { dprCap: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderQualityFor(
|
||||||
|
tier: LiveQualityTier,
|
||||||
|
devicePixelRatio: number,
|
||||||
|
mobileOrTablet: boolean,
|
||||||
|
): RenderQualitySettings {
|
||||||
|
const selected = settings[tier];
|
||||||
|
const finiteDpr = Number.isFinite(devicePixelRatio) ? Math.max(1, devicePixelRatio) : 1;
|
||||||
|
return {
|
||||||
|
pixelRatio: Math.min(finiteDpr, selected.dprCap, mobileOrTablet ? 1.5 : 2),
|
||||||
|
terrainSegments: selected.terrainSegments,
|
||||||
|
particleCount: selected.particleCount,
|
||||||
|
postEffects: selected.postEffects,
|
||||||
|
};
|
||||||
|
}
|
||||||
130
src/scene/resources.ts
Normal file
130
src/scene/resources.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
export type SceneResourceKind =
|
||||||
|
| 'geometry'
|
||||||
|
| 'material'
|
||||||
|
| 'texture'
|
||||||
|
| 'target'
|
||||||
|
| 'pass'
|
||||||
|
| 'renderer'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export interface DisposableResource {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisconnectableResource {
|
||||||
|
disconnect(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneResourceSnapshot {
|
||||||
|
disposed: boolean;
|
||||||
|
owned: Readonly<Record<SceneResourceKind, number>>;
|
||||||
|
listenerCount: number;
|
||||||
|
observerCount: number;
|
||||||
|
timerCount: number;
|
||||||
|
rafCount: 0 | 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceKinds: readonly SceneResourceKind[] = [
|
||||||
|
'geometry', 'material', 'texture', 'target', 'pass', 'renderer', 'other',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class SceneResourceBag {
|
||||||
|
private disposed = false;
|
||||||
|
private readonly resources = new Map<DisposableResource, SceneResourceKind>();
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private readonly observers = new Set<DisconnectableResource>();
|
||||||
|
private readonly timers = new Map<number, (id: number) => void>();
|
||||||
|
private raf: { id: number; cancel: (id: number) => void } | null = null;
|
||||||
|
|
||||||
|
own<T extends DisposableResource>(kind: SceneResourceKind, resource: T): T {
|
||||||
|
if (this.disposed) {
|
||||||
|
resource.dispose();
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
if (!this.resources.has(resource)) this.resources.set(resource, kind);
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen(
|
||||||
|
target: EventTarget,
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject,
|
||||||
|
options?: AddEventListenerOptions | boolean,
|
||||||
|
): () => void {
|
||||||
|
if (this.disposed) return () => undefined;
|
||||||
|
target.addEventListener(type, listener, options);
|
||||||
|
let active = true;
|
||||||
|
const remove = (): void => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
target.removeEventListener(type, listener, options);
|
||||||
|
this.listeners.delete(remove);
|
||||||
|
};
|
||||||
|
this.listeners.add(remove);
|
||||||
|
return remove;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe<T extends DisconnectableResource>(observer: T): T {
|
||||||
|
if (this.disposed) observer.disconnect();
|
||||||
|
else this.observers.add(observer);
|
||||||
|
return observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
timer(id: number, clear: (id: number) => void): number {
|
||||||
|
if (this.disposed) clear(id);
|
||||||
|
else this.timers.set(id, clear);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseTimer(id: number): void {
|
||||||
|
this.timers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrame(id: number, cancel: (id: number) => void): number {
|
||||||
|
if (this.disposed) {
|
||||||
|
cancel(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
if (this.raf) this.raf.cancel(this.raf.id);
|
||||||
|
this.raf = { id, cancel };
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseAnimationFrame(id: number): void {
|
||||||
|
if (this.raf?.id === id) this.raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimationFrame(): void {
|
||||||
|
if (!this.raf) return;
|
||||||
|
const current = this.raf;
|
||||||
|
this.raf = null;
|
||||||
|
current.cancel(current.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.cancelAnimationFrame();
|
||||||
|
for (const [id, clear] of this.timers) clear(id);
|
||||||
|
this.timers.clear();
|
||||||
|
for (const remove of [...this.listeners]) remove();
|
||||||
|
this.listeners.clear();
|
||||||
|
for (const observer of this.observers) observer.disconnect();
|
||||||
|
this.observers.clear();
|
||||||
|
for (const resource of this.resources.keys()) resource.dispose();
|
||||||
|
this.resources.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): SceneResourceSnapshot {
|
||||||
|
const owned = Object.fromEntries(resourceKinds.map((kind) => [kind, 0])) as Record<SceneResourceKind, number>;
|
||||||
|
for (const kind of this.resources.values()) owned[kind] += 1;
|
||||||
|
return {
|
||||||
|
disposed: this.disposed,
|
||||||
|
owned,
|
||||||
|
listenerCount: this.listeners.size,
|
||||||
|
observerCount: this.observers.size,
|
||||||
|
timerCount: this.timers.size,
|
||||||
|
rafCount: this.raf ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/scene/three.d.ts
vendored
Normal file
4
src/scene/three.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Three.js does not currently publish TypeScript declarations in its runtime package.
|
||||||
|
// The scene remains isolated behind typed application interfaces; this ambient declaration
|
||||||
|
// prevents the untyped renderer dependency from leaking into Vue or journey modules.
|
||||||
|
declare module 'three';
|
||||||
84
src/scene/types.ts
Normal file
84
src/scene/types.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export type LiveQualityTier = Exclude<QualityTier, 'static'>;
|
||||||
|
|
||||||
|
export type SceneLifecycleState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'loading'; generation: number }
|
||||||
|
| { kind: 'running'; generation: number }
|
||||||
|
| { kind: 'paused'; generation: number | null; resumeNeedsLoad: boolean }
|
||||||
|
| { kind: 'lost'; lostAt: number; restorationPending: boolean }
|
||||||
|
| { kind: 'restoring'; generation: number; lostAt: number }
|
||||||
|
| { kind: 'static'; reason: string }
|
||||||
|
| { kind: 'disposed' };
|
||||||
|
|
||||||
|
export type PageLifecycleOverlay = 'active' | 'frozen' | 'disposed';
|
||||||
|
|
||||||
|
export interface SceneGeneration {
|
||||||
|
render(snapshot: Readonly<JourneySnapshot>, now: number): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneBuildContext {
|
||||||
|
canvas: EventTarget;
|
||||||
|
quality: LiveQualityTier;
|
||||||
|
generation: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
resources: SceneResourceBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneBuilder = (context: SceneBuildContext) => Promise<SceneGeneration>;
|
||||||
|
|
||||||
|
export interface SceneScheduler {
|
||||||
|
now(): number;
|
||||||
|
requestAnimationFrame(callback: FrameRequestCallback): number;
|
||||||
|
cancelAnimationFrame(id: number): void;
|
||||||
|
setTimeout(callback: () => void, delay: number): number;
|
||||||
|
clearTimeout(id: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenePageEnvironment {
|
||||||
|
document: EventTarget;
|
||||||
|
window: EventTarget;
|
||||||
|
visibilityState: DocumentVisibilityState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneControllerOptions {
|
||||||
|
canvas: EventTarget;
|
||||||
|
quality: LiveQualityTier;
|
||||||
|
build: SceneBuilder;
|
||||||
|
scheduler?: SceneScheduler;
|
||||||
|
page?: ScenePageEnvironment;
|
||||||
|
motionAllowed?: () => boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneControllerSnapshot {
|
||||||
|
state: SceneLifecycleState;
|
||||||
|
overlay: PageLifecycleOverlay;
|
||||||
|
quality: LiveQualityTier;
|
||||||
|
canvasCount: 0 | 1;
|
||||||
|
rafCount: 0 | 1;
|
||||||
|
currentGeneration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneController {
|
||||||
|
start(): Promise<void>;
|
||||||
|
pause(): void;
|
||||||
|
resume(): Promise<void>;
|
||||||
|
setStatic(reason: string): void;
|
||||||
|
enableMotion(): Promise<void>;
|
||||||
|
setQuality(tier: QualityTier): Promise<void>;
|
||||||
|
update(snapshot: JourneySnapshot): void;
|
||||||
|
snapshot(): SceneControllerSnapshot;
|
||||||
|
whenSettled(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneLifecycleMachine {
|
||||||
|
transition(next: SceneLifecycleState): SceneLifecycleState;
|
||||||
|
replace(next: SceneLifecycleState): SceneLifecycleState;
|
||||||
|
snapshot(): SceneLifecycleState;
|
||||||
|
}
|
||||||
69
src/scene/world/car.ts
Normal file
69
src/scene/world/car.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export function createCar(resources: SceneResourceBag) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.position.set(0, 0.72, 4.4);
|
||||||
|
|
||||||
|
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x111722,
|
||||||
|
roughness: 0.28,
|
||||||
|
metalness: 0.78,
|
||||||
|
}));
|
||||||
|
const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x10273a,
|
||||||
|
roughness: 0.12,
|
||||||
|
metalness: 0.35,
|
||||||
|
}));
|
||||||
|
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
|
||||||
|
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x28d8ef,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lowerGeometry = resources.own('geometry', new THREE.BoxGeometry(4.6, 0.55, 6.6));
|
||||||
|
const lower = new THREE.Mesh(lowerGeometry, bodyMaterial);
|
||||||
|
lower.scale.set(1, 1, 0.92);
|
||||||
|
group.add(lower);
|
||||||
|
|
||||||
|
const cabinGeometry = resources.own('geometry', new THREE.CylinderGeometry(1.25, 1.72, 2.9, 4));
|
||||||
|
const cabin = new THREE.Mesh(cabinGeometry, glassMaterial);
|
||||||
|
cabin.rotation.set(Math.PI / 2, Math.PI / 4, 0);
|
||||||
|
cabin.scale.set(1, 1, 0.62);
|
||||||
|
cabin.position.set(0, 0.64, -0.35);
|
||||||
|
group.add(cabin);
|
||||||
|
|
||||||
|
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(3.9, 0.12, 0.08));
|
||||||
|
const rearLight = new THREE.Mesh(lightGeometry, tailMaterial);
|
||||||
|
rearLight.position.set(0, 0.04, 3.08);
|
||||||
|
group.add(rearLight);
|
||||||
|
|
||||||
|
const glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
|
||||||
|
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
|
||||||
|
underglow.rotation.x = -Math.PI / 2;
|
||||||
|
underglow.position.y = -0.31;
|
||||||
|
group.add(underglow);
|
||||||
|
|
||||||
|
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.62, 0.62, 0.42, 18));
|
||||||
|
const wheelMaterial = resources.own('material', new THREE.MeshStandardMaterial({ color: 0x030407, roughness: 0.92 }));
|
||||||
|
for (const x of [-2.05, 2.05]) {
|
||||||
|
for (const z of [-2.15, 2.15]) {
|
||||||
|
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
|
||||||
|
wheel.rotation.z = Math.PI / 2;
|
||||||
|
wheel.position.set(x, -0.18, z);
|
||||||
|
group.add(wheel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(speed: number, acceleration: number, elapsedSeconds: number) {
|
||||||
|
const response = Math.min(1, Math.max(0, acceleration));
|
||||||
|
group.position.y = 0.72 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
|
||||||
|
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
|
||||||
|
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
|
||||||
|
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
157
src/scene/world/environment.ts
Normal file
157
src/scene/world/environment.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { EnvironmentChapter, JourneySnapshot } from '@/journey/types';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||||
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
|
import { createRoad } from '@/scene/world/road';
|
||||||
|
import { createTerrain } from '@/scene/world/terrain';
|
||||||
|
import { createSun } from '@/scene/world/sun';
|
||||||
|
import { createCar } from '@/scene/world/car';
|
||||||
|
import { createSign } from '@/scene/world/sign';
|
||||||
|
import { createParticles } from '@/scene/world/particles';
|
||||||
|
|
||||||
|
const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
|
||||||
|
'deep-night': 0x040713,
|
||||||
|
systems: 0x060918,
|
||||||
|
infrastructure: 0x080b1d,
|
||||||
|
builds: 0x0d1025,
|
||||||
|
'pre-dawn': 0x18152c,
|
||||||
|
destination: 0x2b2035,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createProceduralSceneGeneration(
|
||||||
|
context: SceneBuildContext,
|
||||||
|
): Promise<SceneGeneration> {
|
||||||
|
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
|
const canvas = context.canvas as HTMLCanvasElement;
|
||||||
|
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
|
||||||
|
const mobileOrTablet = Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
|
||||||
|
const quality = renderQualityFor(context.quality, globalThis.devicePixelRatio ?? 1, mobileOrTablet);
|
||||||
|
const renderer = context.resources.own('renderer', new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: false,
|
||||||
|
antialias: context.quality !== 'low',
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
}));
|
||||||
|
renderer.setPixelRatio(quality.pixelRatio);
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.05;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const background = new THREE.Color(skyColors['deep-night']);
|
||||||
|
scene.background = background;
|
||||||
|
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
|
||||||
|
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 320);
|
||||||
|
camera.position.set(0, 4.8, 12.5);
|
||||||
|
camera.lookAt(0, 1.6, -22);
|
||||||
|
|
||||||
|
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||||
|
scene.add(ambient);
|
||||||
|
const rearLight = new THREE.PointLight(0xff365f, 5, 18);
|
||||||
|
rearLight.position.set(0, 1.2, 7);
|
||||||
|
scene.add(rearLight);
|
||||||
|
|
||||||
|
const road = createRoad(context.resources);
|
||||||
|
const terrain = createTerrain(context.resources, quality.terrainSegments);
|
||||||
|
const sun = createSun(context.resources);
|
||||||
|
const car = createCar(context.resources);
|
||||||
|
const sign = createSign(context.resources);
|
||||||
|
const particles = createParticles(context.resources, quality.particleCount);
|
||||||
|
scene.add(road.group, terrain.group, sun.mesh, car.group, sign.group, particles.points);
|
||||||
|
|
||||||
|
const postTarget = quality.postEffects
|
||||||
|
? context.resources.own('target', new THREE.WebGLRenderTarget(1, 1, {
|
||||||
|
depthBuffer: true,
|
||||||
|
minFilter: THREE.LinearFilter,
|
||||||
|
magFilter: THREE.LinearFilter,
|
||||||
|
}))
|
||||||
|
: null;
|
||||||
|
const postScene = postTarget ? new THREE.Scene() : null;
|
||||||
|
const postCamera = postTarget ? new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) : null;
|
||||||
|
if (postScene && postTarget) {
|
||||||
|
const postGeometry = context.resources.own('geometry', new THREE.PlaneGeometry(2, 2));
|
||||||
|
const postMaterial = context.resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: { source: { value: postTarget.texture } },
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D source;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(source, vUv);
|
||||||
|
float vignette = smoothstep(0.92, 0.24, distance(vUv, vec2(0.5)));
|
||||||
|
gl_FragColor = vec4(color.rgb * mix(0.82, 1.0, vignette), color.a);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
}));
|
||||||
|
postScene.add(new THREE.Mesh(postGeometry, postMaterial));
|
||||||
|
}
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let driveDistance = 0;
|
||||||
|
let lastNow: number | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const resize = (): void => {
|
||||||
|
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||||
|
const nextHeight = Math.max(1, canvas.clientHeight || canvas.height || 1);
|
||||||
|
if (nextWidth === width && nextHeight === height) return;
|
||||||
|
width = nextWidth;
|
||||||
|
height = nextHeight;
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
postTarget?.setSize(
|
||||||
|
Math.max(1, Math.floor(width * quality.pixelRatio)),
|
||||||
|
Math.max(1, Math.floor(height * quality.pixelRatio)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.signal.aborted) {
|
||||||
|
context.resources.dispose();
|
||||||
|
throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
||||||
|
if (disposed) return;
|
||||||
|
resize();
|
||||||
|
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||||
|
lastNow = now;
|
||||||
|
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
||||||
|
road.update(driveDistance, snapshot.driveSpeed);
|
||||||
|
terrain.update(driveDistance);
|
||||||
|
sun.update(snapshot.chapter, snapshot.routeProgress);
|
||||||
|
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
||||||
|
sign.update(snapshot.sign);
|
||||||
|
particles.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
||||||
|
background.setHex(skyColors[snapshot.chapter]);
|
||||||
|
const fov = 52 + Math.min(7, snapshot.acceleration * 4.5);
|
||||||
|
if (camera.fov !== fov) {
|
||||||
|
camera.fov = fov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
if (postTarget) {
|
||||||
|
renderer.setRenderTarget(postTarget);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
renderer.setRenderTarget(null);
|
||||||
|
renderer.render(postScene, postCamera);
|
||||||
|
} else {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
scene.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/scene/world/particles.ts
Normal file
40
src/scene/world/particles.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export function createParticles(resources: SceneResourceBag, count: number) {
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const offset = index * 3;
|
||||||
|
positions[offset] = seeded(index * 3) * 30 - 15;
|
||||||
|
positions[offset + 1] = seeded(index * 3 + 1) * 7 + 0.2;
|
||||||
|
positions[offset + 2] = -(seeded(index * 3 + 2) * 150);
|
||||||
|
}
|
||||||
|
const geometry = resources.own('geometry', new THREE.BufferGeometry());
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
const material = resources.own('material', new THREE.PointsMaterial({
|
||||||
|
color: 0xa8f5ff,
|
||||||
|
size: 0.07,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
depthWrite: false,
|
||||||
|
}));
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
update(distance: number, speed: number, acceleration: number) {
|
||||||
|
points.position.z = modulo(distance * (1 + speed * 2), 150);
|
||||||
|
material.opacity = Math.min(0.78, 0.25 + speed * 0.28 + acceleration * 0.18);
|
||||||
|
material.size = 0.055 + Math.min(0.1, speed * 0.04);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seeded(value: number): number {
|
||||||
|
const result = Math.sin(value * 91.713 + 17.17) * 43758.5453;
|
||||||
|
return result - Math.floor(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulo(value: number, divisor: number): number {
|
||||||
|
return ((value % divisor) + divisor) % divisor;
|
||||||
|
}
|
||||||
50
src/scene/world/road.ts
Normal file
50
src/scene/world/road.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export function createRoad(resources: SceneResourceBag) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const roadGeometry = resources.own('geometry', new THREE.PlaneGeometry(18, 240));
|
||||||
|
const roadMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x070b16,
|
||||||
|
roughness: 0.88,
|
||||||
|
metalness: 0.12,
|
||||||
|
}));
|
||||||
|
const road = new THREE.Mesh(roadGeometry, roadMaterial);
|
||||||
|
road.rotation.x = -Math.PI / 2;
|
||||||
|
road.position.z = -92;
|
||||||
|
group.add(road);
|
||||||
|
|
||||||
|
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 0.035, 240));
|
||||||
|
const edgeMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x38dff2 }));
|
||||||
|
for (const x of [-8.65, 8.65]) {
|
||||||
|
const edge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||||
|
edge.position.set(x, 0.035, -92);
|
||||||
|
group.add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerGeometry = resources.own('geometry', new THREE.BoxGeometry(0.14, 0.025, 3.8));
|
||||||
|
const markerMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff9e68 }));
|
||||||
|
const markers: Array<{ position: { z: number } }> = [];
|
||||||
|
for (let index = 0; index < 22; index += 1) {
|
||||||
|
const marker = new THREE.Mesh(markerGeometry, markerMaterial);
|
||||||
|
marker.position.set(0, 0.045, 8 - index * 10);
|
||||||
|
markers.push(marker);
|
||||||
|
group.add(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(distance: number, speed: number) {
|
||||||
|
const flow = distance * (5 + speed * 8);
|
||||||
|
for (let index = 0; index < markers.length; index += 1) {
|
||||||
|
const marker = markers[index];
|
||||||
|
if (!marker) continue;
|
||||||
|
marker.position.z = 8 - modulo(index * 10 - flow, 220);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulo(value: number, divisor: number): number {
|
||||||
|
return ((value % divisor) + divisor) % divisor;
|
||||||
|
}
|
||||||
79
src/scene/world/sign.ts
Normal file
79
src/scene/world/sign.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||||
|
start: '00 / START',
|
||||||
|
profile: '01 / PROFILE',
|
||||||
|
experience: '02 / EXPERIENCE',
|
||||||
|
infrastructure: '03 / INFRASTRUCTURE',
|
||||||
|
'selected-builds': '04 / SELECTED BUILDS',
|
||||||
|
'additional-work': '05 / ADDITIONAL WORK',
|
||||||
|
toolkit: '06 / TOOLKIT',
|
||||||
|
contact: '07 / DESTINATION',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function routeLabelFor(id: SectionId): string {
|
||||||
|
return routeLabels[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSign(resources: SceneResourceBag) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.visible = false;
|
||||||
|
const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 768;
|
||||||
|
canvas.height = 192;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.96,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
map: texture,
|
||||||
|
}));
|
||||||
|
const panel = new THREE.Mesh(geometry, material);
|
||||||
|
group.add(panel);
|
||||||
|
|
||||||
|
const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8));
|
||||||
|
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 }));
|
||||||
|
for (const x of [-3.5, 3.5]) {
|
||||||
|
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial);
|
||||||
|
thruster.rotation.x = Math.PI / 2;
|
||||||
|
thruster.position.set(x, -1.22, 0.08);
|
||||||
|
group.add(thruster);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: SectionId | null = null;
|
||||||
|
const updateTexture = (id: SectionId): void => {
|
||||||
|
if (target === id) return;
|
||||||
|
target = id;
|
||||||
|
if (!context) return;
|
||||||
|
context.fillStyle = '#07111b';
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.strokeStyle = '#35ddeb';
|
||||||
|
context.lineWidth = 7;
|
||||||
|
context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16);
|
||||||
|
context.fillStyle = '#e8fbff';
|
||||||
|
context.font = '600 46px ui-monospace, monospace';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(sign: SignSnapshot | null) {
|
||||||
|
group.visible = sign !== null;
|
||||||
|
if (!sign) return;
|
||||||
|
updateTexture(sign.targetId);
|
||||||
|
const phaseOffset = sign.phase === 'approach' ? -26 : sign.phase === 'stabilized' ? -5 : 8;
|
||||||
|
group.position.set(7.2, 4.1, phaseOffset + sign.progress * 8);
|
||||||
|
group.rotation.y = -0.12;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/scene/world/sun.ts
Normal file
33
src/scene/world/sun.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { EnvironmentChapter } from '@/journey/types';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
const chapterWarmth: Readonly<Record<EnvironmentChapter, number>> = {
|
||||||
|
'deep-night': 0,
|
||||||
|
systems: 0.12,
|
||||||
|
infrastructure: 0.24,
|
||||||
|
builds: 0.42,
|
||||||
|
'pre-dawn': 0.68,
|
||||||
|
destination: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSun(resources: SceneResourceBag) {
|
||||||
|
const geometry = resources.own('geometry', new THREE.CircleGeometry(18, 64));
|
||||||
|
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff6f83,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.82,
|
||||||
|
fog: false,
|
||||||
|
}));
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.position.set(0, 12, -128);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
update(chapter: EnvironmentChapter, routeProgress: number) {
|
||||||
|
const warmth = Math.max(chapterWarmth[chapter], routeProgress);
|
||||||
|
material.color.setRGB(1, 0.34 + warmth * 0.24, 0.42 + warmth * 0.22);
|
||||||
|
mesh.position.y = 12 + warmth * 5;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/scene/world/terrain.ts
Normal file
43
src/scene/world/terrain.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export function createTerrain(resources: SceneResourceBag, segments: number) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const geometry = resources.own(
|
||||||
|
'geometry',
|
||||||
|
new THREE.PlaneGeometry(72, 240, Math.max(4, Math.floor(segments / 4)), segments),
|
||||||
|
);
|
||||||
|
const cyan = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x168fae,
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.34,
|
||||||
|
}));
|
||||||
|
const magenta = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xbd317f,
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.22,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const planes: Array<{ position: { z: number } }> = [];
|
||||||
|
for (const [x, material] of [[-44, magenta], [44, cyan]] as const) {
|
||||||
|
const plane = new THREE.Mesh(geometry, material);
|
||||||
|
plane.rotation.x = -Math.PI / 2;
|
||||||
|
plane.position.set(x, -0.2, -92);
|
||||||
|
planes.push(plane);
|
||||||
|
group.add(plane);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(distance: number) {
|
||||||
|
const drift = modulo(distance * 1.4, 8);
|
||||||
|
for (const plane of planes) plane.position.z = -92 + drift;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulo(value: number, divisor: number): number {
|
||||||
|
return ((value % divisor) + divisor) % divisor;
|
||||||
|
}
|
||||||
435
tests/unit/scene-lifecycle.test.ts
Normal file
435
tests/unit/scene-lifecycle.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
tests/unit/scene-resources.test.ts
Normal file
112
tests/unit/scene-resources.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user