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; },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user