From fd8347fbdc9d9c8e0bad500d42b4f097a323d5d4 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 04:53:55 +0300 Subject: [PATCH] feat: build procedural lifecycle-safe drive scene --- src/scene/create-scene-controller.ts | 557 +++++++++++++++++++++++++++ src/scene/render-quality.ts | 29 ++ src/scene/resources.ts | 130 +++++++ src/scene/three.d.ts | 4 + src/scene/types.ts | 84 ++++ src/scene/world/car.ts | 69 ++++ src/scene/world/environment.ts | 157 ++++++++ src/scene/world/particles.ts | 40 ++ src/scene/world/road.ts | 50 +++ src/scene/world/sign.ts | 79 ++++ src/scene/world/sun.ts | 33 ++ src/scene/world/terrain.ts | 43 +++ tests/unit/scene-lifecycle.test.ts | 435 +++++++++++++++++++++ tests/unit/scene-resources.test.ts | 112 ++++++ 14 files changed, 1822 insertions(+) create mode 100644 src/scene/create-scene-controller.ts create mode 100644 src/scene/render-quality.ts create mode 100644 src/scene/resources.ts create mode 100644 src/scene/three.d.ts create mode 100644 src/scene/types.ts create mode 100644 src/scene/world/car.ts create mode 100644 src/scene/world/environment.ts create mode 100644 src/scene/world/particles.ts create mode 100644 src/scene/world/road.ts create mode 100644 src/scene/world/sign.ts create mode 100644 src/scene/world/sun.ts create mode 100644 src/scene/world/terrain.ts create mode 100644 tests/unit/scene-lifecycle.test.ts create mode 100644 tests/unit/scene-resources.test.ts diff --git a/src/scene/create-scene-controller.ts b/src/scene/create-scene-controller.ts new file mode 100644 index 0000000..d362a89 --- /dev/null +++ b/src/scene/create-scene-controller.ts @@ -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> = { + 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 | 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 => { + 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; + 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 { + 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 { + 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 { + if (machine.snapshot().kind !== 'static' || !(options.motionAllowed?.() ?? true)) return Promise.resolve(); + return beginLoad('loading'); + } + + async function setQuality(tier: QualityTier): Promise { + 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 { + 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; }, + }; +} diff --git a/src/scene/render-quality.ts b/src/scene/render-quality.ts new file mode 100644 index 0000000..f2a4d70 --- /dev/null +++ b/src/scene/render-quality.ts @@ -0,0 +1,29 @@ +import type { LiveQualityTier } from '@/scene/types'; + +export interface RenderQualitySettings { + pixelRatio: number; + terrainSegments: number; + particleCount: number; + postEffects: boolean; +} + +const settings: Readonly & { 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, + }; +} diff --git a/src/scene/resources.ts b/src/scene/resources.ts new file mode 100644 index 0000000..878fd62 --- /dev/null +++ b/src/scene/resources.ts @@ -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>; + 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(); + private readonly listeners = new Set<() => void>(); + private readonly observers = new Set(); + private readonly timers = new Map void>(); + private raf: { id: number; cancel: (id: number) => void } | null = null; + + own(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(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; + 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, + }; + } +} diff --git a/src/scene/three.d.ts b/src/scene/three.d.ts new file mode 100644 index 0000000..8300eb6 --- /dev/null +++ b/src/scene/three.d.ts @@ -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'; diff --git a/src/scene/types.ts b/src/scene/types.ts new file mode 100644 index 0000000..c614d81 --- /dev/null +++ b/src/scene/types.ts @@ -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; + +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, now: number): void; + dispose(): void; +} + +export interface SceneBuildContext { + canvas: EventTarget; + quality: LiveQualityTier; + generation: number; + signal: AbortSignal; + resources: SceneResourceBag; +} + +export type SceneBuilder = (context: SceneBuildContext) => Promise; + +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; + pause(): void; + resume(): Promise; + setStatic(reason: string): void; + enableMotion(): Promise; + setQuality(tier: QualityTier): Promise; + update(snapshot: JourneySnapshot): void; + snapshot(): SceneControllerSnapshot; + whenSettled(): Promise; + dispose(): void; +} + +export interface SceneLifecycleMachine { + transition(next: SceneLifecycleState): SceneLifecycleState; + replace(next: SceneLifecycleState): SceneLifecycleState; + snapshot(): SceneLifecycleState; +} diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts new file mode 100644 index 0000000..75d608c --- /dev/null +++ b/src/scene/world/car.ts @@ -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); + }, + }; +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts new file mode 100644 index 0000000..cd6ea68 --- /dev/null +++ b/src/scene/world/environment.ts @@ -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> = { + 'deep-night': 0x040713, + systems: 0x060918, + infrastructure: 0x080b1d, + builds: 0x0d1025, + 'pre-dawn': 0x18152c, + destination: 0x2b2035, +}; + +export async function createProceduralSceneGeneration( + context: SceneBuildContext, +): Promise { + 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, 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(); + }, + }; +} diff --git a/src/scene/world/particles.ts b/src/scene/world/particles.ts new file mode 100644 index 0000000..574c943 --- /dev/null +++ b/src/scene/world/particles.ts @@ -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; +} diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts new file mode 100644 index 0000000..245d8a4 --- /dev/null +++ b/src/scene/world/road.ts @@ -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; +} diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts new file mode 100644 index 0000000..6d1ab34 --- /dev/null +++ b/src/scene/world/sign.ts @@ -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> = { + 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; + }, + }; +} diff --git a/src/scene/world/sun.ts b/src/scene/world/sun.ts new file mode 100644 index 0000000..fc8c5a7 --- /dev/null +++ b/src/scene/world/sun.ts @@ -0,0 +1,33 @@ +import * as THREE from 'three'; +import type { EnvironmentChapter } from '@/journey/types'; +import type { SceneResourceBag } from '@/scene/resources'; + +const chapterWarmth: Readonly> = { + '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; + }, + }; +} diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts new file mode 100644 index 0000000..2213db8 --- /dev/null +++ b/src/scene/world/terrain.ts @@ -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; +} diff --git a/tests/unit/scene-lifecycle.test.ts b/tests/unit/scene-lifecycle.test.ts new file mode 100644 index 0000000..d464ef9 --- /dev/null +++ b/tests/unit/scene-lifecycle.test.ts @@ -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> = { + 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 | 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; dispose: ReturnType }> = []; + const buildContexts: SceneBuildContext[] = []; + const pending: Array<((generation: SceneGeneration) => void) | undefined> = []; + + const makeGeneration = (): SceneGeneration & { render: ReturnType; dispose: ReturnType } => { + const generation = { render: vi.fn(), dispose: vi.fn() }; + generations.push(generation); + return generation; + }; + const deferredBuilder = () => (context: SceneBuildContext): Promise => { + buildContexts.push(context); + return new Promise((resolve) => pending.push(resolve)); + }; + const immediateBuilder = async (context: SceneBuildContext): Promise => { + 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(); + private timers = new Map 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 { + return { + scrollY: 0, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + activeSectionId: 'start', + chapter: 'deep-night', + sign: null, + arrived: false, + ...overrides, + }; +} diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts new file mode 100644 index 0000000..7b52116 --- /dev/null +++ b/tests/unit/scene-resources.test.ts @@ -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'); + }); +});