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
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
This commit is contained in:
commit
a129866236
5 changed files with 197 additions and 21 deletions
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue