Merge pull request 'scene: host handles a lost GL context (ground on loss, rebuild on restore)' (#44) from nibu/context-lost into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled

This commit is contained in:
Void 2026-11-09 22:13:23 +00:00
commit a129866236
5 changed files with 197 additions and 21 deletions

View file

@ -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),
});

View file

@ -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.

View file

@ -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();

View file

@ -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();
});
});
});

View file

@ -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;
};