diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 643fa06d..10785a5f 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -27,6 +27,7 @@ export function BackgroundScene() { const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, { loggedIn: !!getFallbackSession(), onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''), + onInactive: () => root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE), // eslint-disable-next-line no-console onFail: (e) => console.warn('Background scene unavailable:', e), }); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 35075536..fa7565b0 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -82,8 +82,8 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context - stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to - show. It caps at 30 fps, pauses when + stops the scene for good and calls `onContextLost`; the scene never rebuilds itself. `host.ts` disposes it, drops + `data-scene` so the CSS ground shows, and starts a fresh scene on `webglcontextrestored`. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index de80583c..c2f0423a 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -599,8 +599,9 @@ export function createScene(opts: SceneOptions): Scene | null { } listen(window, 'resize', () => wake()); // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault: otherwise the browser never - // restores the context, which is what we want, and a restore would find a dead scene. + // scene ends and the host decides what to show. preventDefault is what lets the browser restore + // the context later (without it a lost context stays lost); the host then builds a new scene on + // `webglcontextrestored`, and this one stays dead. listen(canvas, 'webglcontextlost', (e) => { e.preventDefault(); stop(); diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 920e98e4..272f502f 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Scene } from './index'; import { mountBackgroundScene } from './host'; const { startBackgroundScene } = vi.hoisted(() => ({ startBackgroundScene: vi.fn() })); vi.mock('./index', () => ({ startBackgroundScene })); -const canvas = {} as HTMLCanvasElement; +let canvas: HTMLCanvasElement; const config = { tier: 'low' as const }; const caps = { hardwareConcurrency: 8 }; @@ -20,6 +20,10 @@ const flush = () => setTimeout(r, 0); }); +beforeEach(() => { + canvas = new EventTarget() as HTMLCanvasElement; +}); + afterEach(() => { startBackgroundScene.mockReset(); }); @@ -103,4 +107,144 @@ describe('mountBackgroundScene', () => { await flush(); expect(scene.login).toHaveBeenCalledOnce(); }); + + describe('a lost WebGL context', () => { + const lose = () => + startBackgroundScene.mock.calls[ + startBackgroundScene.mock.calls.length - 1 + ][2].onContextLost(); + const restore = () => canvas.dispatchEvent(new Event('webglcontextrestored')); + + it('disposes the dead scene and puts the ground back, without rebuilding yet', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + expect(scene.dispose).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('starts a fresh scene on restore, activates it and logs in when a session exists', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledTimes(2); + expect(onActive).toHaveBeenCalledTimes(2); + expect(second.login).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('rebuilds on every loss and restore, disposing each dead scene once', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => startBackgroundScene.mockResolvedValueOnce(s)); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + restore(); + await flush(); + lose(); + restore(); + await flush(); + expect(scenes.map((s) => s.dispose.mock.calls.length)).toEqual([1, 1, 0]); + }); + + it('ignores a restore while a scene is running', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('leaves the ground when the rebuild yields no scene', async () => { + startBackgroundScene.mockResolvedValueOnce(fakeScene()).mockResolvedValueOnce(null); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + }); + + it('does not rebuild after dispose, and removes its restore listener', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + dispose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('removes the exact restore listener it added when it is disposed', async () => { + const add = vi.spyOn(canvas, 'addEventListener'); + const remove = vi.spyOn(canvas, 'removeEventListener'); + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + const added = add.mock.calls.find(([type]) => type === 'webglcontextrestored'); + expect(added).toBeDefined(); + expect(remove).not.toHaveBeenCalled(); + dispose(); + expect(remove).toHaveBeenCalledWith('webglcontextrestored', added?.[1]); + }); + + it('treats a second loss from the same scene as nothing', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + lose(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(scene.dispose).toHaveBeenCalledOnce(); + }); + + it('ignores the old scene loss callback once a new scene has started', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + const oldLost = startBackgroundScene.mock.calls[0][2].onContextLost; + lose(); + restore(); + await flush(); + onInactive.mockClear(); + oldLost(); + expect(second.dispose).not.toHaveBeenCalled(); + expect(onInactive).not.toHaveBeenCalled(); + }); + + it('drops a scene that resolves after a loss that arrived while it started', async () => { + const late = fakeScene(); + let resolve!: (s: Scene) => void; + startBackgroundScene.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }) + ); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + lose(); + resolve(late); + await flush(); + expect(late.dispose).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index bd28f45c..e771f696 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -7,6 +7,8 @@ export type HostOptions = { loggedIn: boolean; /** The scene is running: the host may now let it show through. Not called when there is none. */ onActive?: () => void; + /** The scene stopped because its GL context was lost: put the CSS ground back until it restores. */ + onInactive?: () => void; /** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */ onFail?: (e: Error) => void; }; @@ -15,34 +17,62 @@ export type HostOptions = { * Start the background scene on `canvas` and return the function that ends it. The start is async, * so a dispose that arrives first wins: the scene is disposed the moment it resolves. A null scene * (tier off, no 2D context, load failure) leaves the canvas untouched and the ground showing. + * + * A lost WebGL context ends the scene it killed: the host disposes it, calls `onInactive` so the + * CSS ground shows, and starts a fresh scene when the canvas's context is restored. The 2D sky + * cannot take over on this canvas (a canvas that has held a WebGL context gives no 2D one), so the + * ground is the fallback while the context is gone; a restore that cannot rebuild leaves it there. */ export function mountBackgroundScene( config: BackgroundSceneConfig | undefined, capabilities: Capabilities, canvas: HTMLCanvasElement, - { loggedIn, onActive, onFail }: HostOptions + { loggedIn, onActive, onInactive, onFail }: HostOptions ): () => void { let cancelled = false; let scene: Scene | null = null; + let generation = 0; - startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail }).then( - (started) => { - if (cancelled) { - started?.dispose(); - return; + const start = () => { + generation += 1; + const mine = generation; + const stale = () => cancelled || mine !== generation; + startBackgroundScene(config, capabilities, { + canvas, + onCompileFail: onFail, + onContextLost: () => { + if (stale()) return; + generation += 1; + scene?.dispose(); + scene = null; + onInactive?.(); + }, + }).then( + (started) => { + if (stale()) { + started?.dispose(); + return; + } + scene = started; + if (!scene) return; + onActive?.(); + if (loggedIn) scene.login(); + }, + (e) => { + if (!stale()) onFail?.(e instanceof Error ? e : new Error(String(e))); } - scene = started; - if (!scene) return; - onActive?.(); - if (loggedIn) scene.login(); - }, - (e) => { - if (!cancelled) onFail?.(e instanceof Error ? e : new Error(String(e))); - } - ); + ); + }; + + const onRestored = () => { + if (!cancelled && !scene) start(); + }; + canvas.addEventListener('webglcontextrestored', onRestored); + start(); return () => { cancelled = true; + canvas.removeEventListener('webglcontextrestored', onRestored); scene?.dispose(); scene = null; };