From 6cdbfae919d73c0c24616b9b8850624fd56bcc6d Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sat, 30 May 2026 23:53:16 -0400 Subject: [PATCH 1/2] feat(webgl): handle WebGL context loss On low-RAM devices running Chromium 123+, the GPU context is dropped when the app is backgrounded, after which gl.create* calls return null and the engine crashes. Detect webglcontextlost/webglcontextrestored on the canvas, preventDefault to allow restoration, pause the render loop while the context is lost, and emit contextLost/contextRestored events so consumers can react (typically by reloading the app). In-engine GL resource recovery is intentionally out of scope; the draw loop is already crash-safe via the existing bindTextures guard. Co-Authored-By: Claude Opus 4.7 --- BROWSERS.md | 15 ++++ src/core/Stage.contextLoss.test.ts | 74 ++++++++++++++++++ src/core/Stage.ts | 36 +++++++++ .../web/WebPlatform.contextLoss.test.ts | 78 +++++++++++++++++++ src/core/platforms/web/WebPlatform.ts | 7 ++ src/core/renderers/webgl/WebGlRenderer.ts | 29 +++++++ src/main-api/Renderer.ts | 47 +++++++++++ 7 files changed, 286 insertions(+) create mode 100644 src/core/Stage.contextLoss.test.ts create mode 100644 src/core/platforms/web/WebPlatform.contextLoss.test.ts diff --git a/BROWSERS.md b/BROWSERS.md index dcb8755..13b90a0 100644 --- a/BROWSERS.md +++ b/BROWSERS.md @@ -10,6 +10,21 @@ LightningJS relies on **WebGL 1.x** (based on OpenGL ES 2.0) or newer for its re - **No CSS Dependency**: Unlike traditional DOM/CSS rendering, LightningJS avoids reliance on CSS features, extensions, or browser-specific CSS implementations. - **Consistency**: In our experience, WebGL support is consistent across browsers. Once LightningJS is confirmed to work in a browser, it will deliver uniform output. +## WebGL Context Loss + +On low-RAM devices (those with **less than ~1GB of physical RAM**) running **Chromium v123 or newer**, the browser may proactively free the GPU and **lose the WebGL context** when the app is backgrounded for more than ~5 seconds (see [Chromium change 5285836](https://chromium-review.googlesource.com/c/chromium/src/+/5285836)). This is common on Android TV and Fire TV. After a loss, the GPU resources (textures, programs, buffers) are gone and the underlying `gl.*` create calls return `null`. + +The renderer detects this via the canvas `webglcontextlost` / `webglcontextrestored` events. On loss it calls `event.preventDefault()` (required for the browser to ever restore the context), pauses the render loop, and emits a `contextLost` event. When the context comes back it resumes the loop and emits `contextRestored`. + +The renderer does **not** automatically rebuild GPU resources on restore. The recommended handling is to reload the app on context loss: + +```ts +renderer.on('contextLost', () => { + // GPU state is gone — the simplest reliable recovery is a reload. + window.location.reload(); +}); +``` + ## Supported Browsers and Quirks Below is a detailed breakdown of confirmed browsers that work with LightningJS, along with the quirks or features specific to their versions: diff --git a/src/core/Stage.contextLoss.test.ts b/src/core/Stage.contextLoss.test.ts new file mode 100644 index 0000000..83063d4 --- /dev/null +++ b/src/core/Stage.contextLoss.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for WebGL context-loss handling on the Stage. + * + * Covers the pragmatic core support added for low-RAM devices (Chromium 123+ + * drops the GPU context when backgrounded). On loss we pause the render loop + * and emit events so consumers can react; we do NOT auto-rebuild GL resources. + */ +import { describe, expect, it, vi } from 'vitest'; +import { Stage } from './Stage.js'; +import { EventEmitter } from '../common/EventEmitter.js'; + +// Build a minimal Stage-like object that exercises the context-loss methods +// without standing up the full GL-backed constructor. +function makeStage() { + const eventBus = new EventEmitter(); + const requestRender = vi.fn(); + const stage = Object.create(Stage.prototype) as Stage; + (stage as unknown as { eventBus: EventEmitter }).eventBus = eventBus; + (stage as unknown as { requestRender: () => void }).requestRender = + requestRender; + stage.isContextLost = false; + return { stage, eventBus, requestRender }; +} + +describe('Stage.setContextLost', () => { + it('sets the flag and emits contextLost', () => { + const { stage, eventBus } = makeStage(); + const onLost = vi.fn(); + eventBus.on('contextLost', onLost); + + stage.setContextLost(); + + expect(stage.isContextLost).toBe(true); + expect(onLost).toHaveBeenCalledTimes(1); + }); + + it('is idempotent — does not re-emit when already lost', () => { + const { stage, eventBus } = makeStage(); + const onLost = vi.fn(); + eventBus.on('contextLost', onLost); + + stage.setContextLost(); + stage.setContextLost(); + + expect(onLost).toHaveBeenCalledTimes(1); + }); +}); + +describe('Stage.setContextRestored', () => { + it('clears the flag, requests a render, and emits contextRestored', () => { + const { stage, eventBus, requestRender } = makeStage(); + const onRestored = vi.fn(); + eventBus.on('contextRestored', onRestored); + + stage.setContextLost(); + stage.setContextRestored(); + + expect(stage.isContextLost).toBe(false); + expect(requestRender).toHaveBeenCalledTimes(1); + expect(onRestored).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when the context was never lost', () => { + const { stage, eventBus, requestRender } = makeStage(); + const onRestored = vi.fn(); + eventBus.on('contextRestored', onRestored); + + stage.setContextRestored(); + + expect(stage.isContextLost).toBe(false); + expect(requestRender).not.toHaveBeenCalled(); + expect(onRestored).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index e3eb43e..f73a087 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -119,6 +119,17 @@ export class Stage { */ public readonly eventBus: EventEmitter; + /** + * Whether the underlying WebGL context is currently lost. + * + * @remarks + * Set by the renderer's `webglcontextlost` / `webglcontextrestored` listeners. + * While true, the render loop skips all GL work to avoid issuing calls + * against a dead context (which would return null/throw on low-RAM devices, + * e.g. Chromium 123+ backgrounding behaviour). + */ + public isContextLost = false; + /// State startTime = 0; deltaTime = 0; @@ -429,6 +440,31 @@ export class Stage { }); } + /** + * Mark the WebGL context as lost. Pauses GL work and notifies consumers. + */ + setContextLost() { + if (this.isContextLost === true) { + return; + } + this.isContextLost = true; + this.eventBus.emit('contextLost'); + } + + /** + * Mark the WebGL context as restored. Resumes the render loop and notifies + * consumers. Note: in-engine GL resources are NOT automatically rebuilt; + * consumers are expected to reload the app on `contextLost`. + */ + setContextRestored() { + if (this.isContextLost === false) { + return; + } + this.isContextLost = false; + this.requestRender(); + this.eventBus.emit('contextRestored'); + } + /** * Create default PixelTexture */ diff --git a/src/core/platforms/web/WebPlatform.contextLoss.test.ts b/src/core/platforms/web/WebPlatform.contextLoss.test.ts new file mode 100644 index 0000000..94122b3 --- /dev/null +++ b/src/core/platforms/web/WebPlatform.contextLoss.test.ts @@ -0,0 +1,78 @@ +/** + * Tests that the render loop pauses GL work while the WebGL context is lost. + * + * When `stage.isContextLost === true`, `runLoop` must issue no GL-touching + * calls and instead schedule a slow heartbeat so it resumes once the context + * is restored. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { WebPlatform } from './WebPlatform.js'; +import type { Stage } from '../../Stage.js'; + +function makeFakeStage(isContextLost: boolean) { + return { + isContextLost, + targetFrameTime: 0, + updateFrameTime: vi.fn(), + updateAnimations: vi.fn(() => false), + hasSceneUpdates: vi.fn(() => true), + drawFrame: vi.fn(), + flushFrameEvents: vi.fn(), + calculateFps: vi.fn(), + } as unknown as Stage; +} + +describe('WebPlatform render loop context-loss guard', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('skips all GL work and schedules a heartbeat while context is lost', () => { + let capturedLoop: ((t?: number) => void) | null = null; + const raf = vi.fn((cb: (t?: number) => void) => { + capturedLoop = cb; + return 1; + }); + const setTimeoutSpy = vi.fn( + (_cb: () => void, _ms?: number) => + 1 as unknown as ReturnType, + ); + vi.stubGlobal('requestAnimationFrame', raf); + vi.stubGlobal('setTimeout', setTimeoutSpy); + + const stage = makeFakeStage(true); + new WebPlatform().startLoop(stage); + + // startLoop kicks off the first frame via requestAnimationFrame + expect(capturedLoop).not.toBeNull(); + + // Run one iteration with the context lost + capturedLoop!(0); + + // No GL-touching frame work happened + expect(stage.updateFrameTime).not.toHaveBeenCalled(); + expect(stage.drawFrame).not.toHaveBeenCalled(); + expect(stage.hasSceneUpdates).not.toHaveBeenCalled(); + + // A heartbeat was scheduled so the loop can resume after restore + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy.mock.calls[0]![1]).toBe(1000); + }); + + it('runs frame work when the context is healthy', () => { + let capturedLoop: ((t?: number) => void) | null = null; + const raf = vi.fn((cb: (t?: number) => void) => { + capturedLoop = cb; + return 1; + }); + vi.stubGlobal('requestAnimationFrame', raf); + + const stage = makeFakeStage(false); + new WebPlatform().startLoop(stage); + + capturedLoop!(0); + + expect(stage.updateFrameTime).toHaveBeenCalledTimes(1); + expect(stage.drawFrame).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 5871b1f..9acf2a9 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -32,6 +32,13 @@ export class WebPlatform extends Platform { const buffer = 4; const runLoop = (currentTime: number = 0) => { + // While the GL context is lost, issue no GL calls. Keep a slow heartbeat + // so the loop resumes automatically once the context is restored. + if (stage.isContextLost === true) { + setTimeout(requestLoop, 1000); + return; + } + const targetFrameTime = stage.targetFrameTime; // Frame Limiting logic diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 7c1c35b..d67e054 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -174,6 +174,8 @@ export class WebGlRenderer extends CoreRenderer { const glw = (this.glw = new WebGlContextWrapper(gl)); glw.viewport(0, 0, options.canvas.width, options.canvas.height); + this.attachContextLossListeners(options.canvas); + this.updateClearColor(this.stage.clearColor); glw.setBlend(true); @@ -300,6 +302,33 @@ export class WebGlRenderer extends CoreRenderer { ]); } + /** + * Listen for WebGL context loss/restore on the canvas. + * + * @remarks + * On low-RAM devices (e.g. Chromium 123+ after backgrounding) the GPU + * context is dropped, after which `gl.createTexture()` and friends return + * null and the engine would crash. `preventDefault()` on `webglcontextlost` + * is required for the browser to ever fire `webglcontextrestored`. We pause + * the render loop via the Stage flag and surface `contextLost` / + * `contextRestored` events so consumers can react (typically by reloading). + */ + private attachContextLossListeners( + canvas: HTMLCanvasElement | OffscreenCanvas, + ): void { + if ('addEventListener' in canvas === false) { + return; + } + const target = canvas as HTMLCanvasElement; + target.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + this.stage.setContextLost(); + }); + target.addEventListener('webglcontextrestored', () => { + this.stage.setContextRestored(); + }); + } + reset() { const { glw } = this; if (DIRTY_QUAD_BUFFER) { diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index a90d9b3..59dcfb1 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -145,6 +145,49 @@ export interface RendererMainCriticalCleanupFailedEvent { criticalThreshold: number; } +/** + * WebGL Context Lost Event Data + * + * @remarks + * Fired when the underlying WebGL context is lost (e.g. on low-RAM devices + * running Chromium 123+ after the app has been backgrounded). The render loop + * is paused automatically; in-engine GL resources are NOT rebuilt, so the + * recommended handling is to reload the app. + * + * @category Events + * @example + * ```typescript + * renderer.on('contextLost', () => { + * window.location.reload(); + * }); + * ``` + */ +export interface RendererMainContextLostEvent { + /** This event has no payload - listen without parameters */ + readonly __eventHasNoPayload?: never; +} + +/** + * WebGL Context Restored Event Data + * + * @remarks + * Fired when a previously lost WebGL context is restored. The render loop + * resumes automatically. Note that in-engine GL resources (textures, programs, + * buffers) are NOT automatically re-uploaded. + * + * @category Events + * @example + * ```typescript + * renderer.on('contextRestored', () => { + * console.log('WebGL context restored'); + * }); + * ``` + */ +export interface RendererMainContextRestoredEvent { + /** This event has no payload - listen without parameters */ + readonly __eventHasNoPayload?: never; +} + /** * Settings for the Renderer that can be updated during runtime. */ @@ -506,6 +549,8 @@ export type RendererMainSettings = RendererRuntimeSettings & { * @see {@link RendererMainIdleEvent} * @see {@link RendererMainCriticalCleanupEvent} * @see {@link RendererMainCriticalCleanupFailedEvent} + * @see {@link RendererMainContextLostEvent} + * @see {@link RendererMainContextRestoredEvent} * * @fires RendererMain#fpsUpdate * @fires RendererMain#frameTick @@ -513,6 +558,8 @@ export type RendererMainSettings = RendererRuntimeSettings & { * @fires RendererMain#idle * @fires RendererMain#criticalCleanup * @fires RendererMain#criticalCleanupFailed + * @fires RendererMain#contextLost + * @fires RendererMain#contextRestored */ export class RendererMain extends EventEmitter { readonly root: INode; From 182282c096931c88828853f3118d9d619777bbd7 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 31 May 2026 00:33:56 -0400 Subject: [PATCH 2/2] refactor(webgl): drop unsafe context-restore path The previous restore handler resumed the render loop on webglcontextrestored without rebuilding the now-dead GL resources, which would render against an invalid context. Since the engine cannot rebuild GPU state in place, the supported recovery is an app reload. Stop opting into restoration: remove the preventDefault() call (which was the browser's request-to-restore signal), the webglcontextrestored listener, Stage.setContextRestored, and the contextRestored event type. isContextLost is now a one-way latch that halts the loop until reload. Co-Authored-By: Claude Opus 4.7 --- BROWSERS.md | 4 +- src/core/Stage.contextLoss.test.ts | 37 ++----------------- src/core/Stage.ts | 31 ++++++---------- .../web/WebPlatform.contextLoss.test.ts | 16 ++++---- src/core/platforms/web/WebPlatform.ts | 6 +-- src/core/renderers/webgl/WebGlRenderer.ts | 19 +++++----- src/main-api/Renderer.ts | 27 +------------- 7 files changed, 40 insertions(+), 100 deletions(-) diff --git a/BROWSERS.md b/BROWSERS.md index 13b90a0..6c2a3b7 100644 --- a/BROWSERS.md +++ b/BROWSERS.md @@ -14,9 +14,9 @@ LightningJS relies on **WebGL 1.x** (based on OpenGL ES 2.0) or newer for its re On low-RAM devices (those with **less than ~1GB of physical RAM**) running **Chromium v123 or newer**, the browser may proactively free the GPU and **lose the WebGL context** when the app is backgrounded for more than ~5 seconds (see [Chromium change 5285836](https://chromium-review.googlesource.com/c/chromium/src/+/5285836)). This is common on Android TV and Fire TV. After a loss, the GPU resources (textures, programs, buffers) are gone and the underlying `gl.*` create calls return `null`. -The renderer detects this via the canvas `webglcontextlost` / `webglcontextrestored` events. On loss it calls `event.preventDefault()` (required for the browser to ever restore the context), pauses the render loop, and emits a `contextLost` event. When the context comes back it resumes the loop and emits `contextRestored`. +The renderer detects this via the canvas `webglcontextlost` event. On loss it stops the render loop (so it issues no GL calls against the dead context) and emits a `contextLost` event. -The renderer does **not** automatically rebuild GPU resources on restore. The recommended handling is to reload the app on context loss: +The renderer does **not** rebuild GPU resources in place — it deliberately does not request context restoration. The supported handling is to reload the app on context loss: ```ts renderer.on('contextLost', () => { diff --git a/src/core/Stage.contextLoss.test.ts b/src/core/Stage.contextLoss.test.ts index 83063d4..3bc0f98 100644 --- a/src/core/Stage.contextLoss.test.ts +++ b/src/core/Stage.contextLoss.test.ts @@ -2,8 +2,9 @@ * Tests for WebGL context-loss handling on the Stage. * * Covers the pragmatic core support added for low-RAM devices (Chromium 123+ - * drops the GPU context when backgrounded). On loss we pause the render loop - * and emit events so consumers can react; we do NOT auto-rebuild GL resources. + * drops the GPU context when backgrounded). On loss we stop the render loop + * and emit a `contextLost` event so consumers can reload; the engine does not + * rebuild GL resources in place, so there is no restore path. */ import { describe, expect, it, vi } from 'vitest'; import { Stage } from './Stage.js'; @@ -13,13 +14,10 @@ import { EventEmitter } from '../common/EventEmitter.js'; // without standing up the full GL-backed constructor. function makeStage() { const eventBus = new EventEmitter(); - const requestRender = vi.fn(); const stage = Object.create(Stage.prototype) as Stage; (stage as unknown as { eventBus: EventEmitter }).eventBus = eventBus; - (stage as unknown as { requestRender: () => void }).requestRender = - requestRender; stage.isContextLost = false; - return { stage, eventBus, requestRender }; + return { stage, eventBus }; } describe('Stage.setContextLost', () => { @@ -45,30 +43,3 @@ describe('Stage.setContextLost', () => { expect(onLost).toHaveBeenCalledTimes(1); }); }); - -describe('Stage.setContextRestored', () => { - it('clears the flag, requests a render, and emits contextRestored', () => { - const { stage, eventBus, requestRender } = makeStage(); - const onRestored = vi.fn(); - eventBus.on('contextRestored', onRestored); - - stage.setContextLost(); - stage.setContextRestored(); - - expect(stage.isContextLost).toBe(false); - expect(requestRender).toHaveBeenCalledTimes(1); - expect(onRestored).toHaveBeenCalledTimes(1); - }); - - it('is a no-op when the context was never lost', () => { - const { stage, eventBus, requestRender } = makeStage(); - const onRestored = vi.fn(); - eventBus.on('contextRestored', onRestored); - - stage.setContextRestored(); - - expect(stage.isContextLost).toBe(false); - expect(requestRender).not.toHaveBeenCalled(); - expect(onRestored).not.toHaveBeenCalled(); - }); -}); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index f73a087..7f495e1 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -120,13 +120,14 @@ export class Stage { public readonly eventBus: EventEmitter; /** - * Whether the underlying WebGL context is currently lost. + * Whether the underlying WebGL context has been lost. * * @remarks - * Set by the renderer's `webglcontextlost` / `webglcontextrestored` listeners. - * While true, the render loop skips all GL work to avoid issuing calls - * against a dead context (which would return null/throw on low-RAM devices, - * e.g. Chromium 123+ backgrounding behaviour). + * Set by the renderer's `webglcontextlost` listener. Once true it stays true: + * the engine does not rebuild GPU resources in-place, so the render loop + * stops and the supported recovery is to reload the app (see the `contextLost` + * event). This avoids issuing GL calls against a dead context, which return + * null/throw on low-RAM devices (e.g. Chromium 123+ backgrounding behaviour). */ public isContextLost = false; @@ -441,7 +442,11 @@ export class Stage { } /** - * Mark the WebGL context as lost. Pauses GL work and notifies consumers. + * Mark the WebGL context as lost. Stops GL work and notifies consumers. + * + * @remarks + * The engine does not rebuild GPU resources in-place; consumers are expected + * to reload the app in response to the `contextLost` event. */ setContextLost() { if (this.isContextLost === true) { @@ -451,20 +456,6 @@ export class Stage { this.eventBus.emit('contextLost'); } - /** - * Mark the WebGL context as restored. Resumes the render loop and notifies - * consumers. Note: in-engine GL resources are NOT automatically rebuilt; - * consumers are expected to reload the app on `contextLost`. - */ - setContextRestored() { - if (this.isContextLost === false) { - return; - } - this.isContextLost = false; - this.requestRender(); - this.eventBus.emit('contextRestored'); - } - /** * Create default PixelTexture */ diff --git a/src/core/platforms/web/WebPlatform.contextLoss.test.ts b/src/core/platforms/web/WebPlatform.contextLoss.test.ts index 94122b3..f719d83 100644 --- a/src/core/platforms/web/WebPlatform.contextLoss.test.ts +++ b/src/core/platforms/web/WebPlatform.contextLoss.test.ts @@ -1,9 +1,10 @@ /** - * Tests that the render loop pauses GL work while the WebGL context is lost. + * Tests that the render loop stops doing GL work once the WebGL context is + * lost. * * When `stage.isContextLost === true`, `runLoop` must issue no GL-touching - * calls and instead schedule a slow heartbeat so it resumes once the context - * is restored. + * calls and must not reschedule itself — the engine does not rebuild GL + * resources, so recovery is via app reload. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebPlatform } from './WebPlatform.js'; @@ -27,7 +28,7 @@ describe('WebPlatform render loop context-loss guard', () => { vi.unstubAllGlobals(); }); - it('skips all GL work and schedules a heartbeat while context is lost', () => { + it('skips all GL work and does not reschedule while context is lost', () => { let capturedLoop: ((t?: number) => void) | null = null; const raf = vi.fn((cb: (t?: number) => void) => { capturedLoop = cb; @@ -45,6 +46,7 @@ describe('WebPlatform render loop context-loss guard', () => { // startLoop kicks off the first frame via requestAnimationFrame expect(capturedLoop).not.toBeNull(); + expect(raf).toHaveBeenCalledTimes(1); // Run one iteration with the context lost capturedLoop!(0); @@ -54,9 +56,9 @@ describe('WebPlatform render loop context-loss guard', () => { expect(stage.drawFrame).not.toHaveBeenCalled(); expect(stage.hasSceneUpdates).not.toHaveBeenCalled(); - // A heartbeat was scheduled so the loop can resume after restore - expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - expect(setTimeoutSpy.mock.calls[0]![1]).toBe(1000); + // The loop did not reschedule itself (neither RAF nor setTimeout) + expect(raf).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); }); it('runs frame work when the context is healthy', () => { diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 9acf2a9..8c2d527 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -32,10 +32,10 @@ export class WebPlatform extends Platform { const buffer = 4; const runLoop = (currentTime: number = 0) => { - // While the GL context is lost, issue no GL calls. Keep a slow heartbeat - // so the loop resumes automatically once the context is restored. + // The GL context is lost and the engine does not rebuild it in place. + // Stop the loop entirely (no reschedule) so we issue no GL calls against + // a dead context. Recovery is via app reload (see the `contextLost` event). if (stage.isContextLost === true) { - setTimeout(requestLoop, 1000); return; } diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index d67e054..b0d838e 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -303,15 +303,18 @@ export class WebGlRenderer extends CoreRenderer { } /** - * Listen for WebGL context loss/restore on the canvas. + * Listen for WebGL context loss on the canvas. * * @remarks * On low-RAM devices (e.g. Chromium 123+ after backgrounding) the GPU * context is dropped, after which `gl.createTexture()` and friends return - * null and the engine would crash. `preventDefault()` on `webglcontextlost` - * is required for the browser to ever fire `webglcontextrestored`. We pause - * the render loop via the Stage flag and surface `contextLost` / - * `contextRestored` events so consumers can react (typically by reloading). + * null and the engine would crash. We pause the render loop via the Stage + * flag and surface a `contextLost` event so consumers can react. + * + * We intentionally do NOT call `event.preventDefault()` (which would ask the + * browser to restore the context) and do NOT listen for + * `webglcontextrestored`: the engine cannot rebuild its GPU resources + * in-place, so the supported recovery is to reload the app. See BROWSERS.md. */ private attachContextLossListeners( canvas: HTMLCanvasElement | OffscreenCanvas, @@ -320,13 +323,9 @@ export class WebGlRenderer extends CoreRenderer { return; } const target = canvas as HTMLCanvasElement; - target.addEventListener('webglcontextlost', (event) => { - event.preventDefault(); + target.addEventListener('webglcontextlost', () => { this.stage.setContextLost(); }); - target.addEventListener('webglcontextrestored', () => { - this.stage.setContextRestored(); - }); } reset() { diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 59dcfb1..5c978de 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -151,8 +151,8 @@ export interface RendererMainCriticalCleanupFailedEvent { * @remarks * Fired when the underlying WebGL context is lost (e.g. on low-RAM devices * running Chromium 123+ after the app has been backgrounded). The render loop - * is paused automatically; in-engine GL resources are NOT rebuilt, so the - * recommended handling is to reload the app. + * stops; in-engine GL resources are NOT rebuilt, so the supported handling is + * to reload the app. * * @category Events * @example @@ -167,27 +167,6 @@ export interface RendererMainContextLostEvent { readonly __eventHasNoPayload?: never; } -/** - * WebGL Context Restored Event Data - * - * @remarks - * Fired when a previously lost WebGL context is restored. The render loop - * resumes automatically. Note that in-engine GL resources (textures, programs, - * buffers) are NOT automatically re-uploaded. - * - * @category Events - * @example - * ```typescript - * renderer.on('contextRestored', () => { - * console.log('WebGL context restored'); - * }); - * ``` - */ -export interface RendererMainContextRestoredEvent { - /** This event has no payload - listen without parameters */ - readonly __eventHasNoPayload?: never; -} - /** * Settings for the Renderer that can be updated during runtime. */ @@ -550,7 +529,6 @@ export type RendererMainSettings = RendererRuntimeSettings & { * @see {@link RendererMainCriticalCleanupEvent} * @see {@link RendererMainCriticalCleanupFailedEvent} * @see {@link RendererMainContextLostEvent} - * @see {@link RendererMainContextRestoredEvent} * * @fires RendererMain#fpsUpdate * @fires RendererMain#frameTick @@ -559,7 +537,6 @@ export type RendererMainSettings = RendererRuntimeSettings & { * @fires RendererMain#criticalCleanup * @fires RendererMain#criticalCleanupFailed * @fires RendererMain#contextLost - * @fires RendererMain#contextRestored */ export class RendererMain extends EventEmitter { readonly root: INode;