From 305534cc6579903c0bb98fb49f55f830ab2f5f3e Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 17 Jun 2026 09:05:12 -0700 Subject: [PATCH] =?UTF-8?q?Dispose=20WebGL=20renderer=20when=20a=20game=20?= =?UTF-8?q?stops=20=F0=9F=A7=B9=20(#4295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `ClientGameRunner.stop()` tore down the worker, network, and sound, but left the `MapRenderer` (and its WebGL context), the WebGL canvas, the input overlay, and the self-driving RAF loop in place. When you exit a game via the **Exit button** or browser **back**, the page navigates to `/`, so the browser reclaims everything — that path is fine. But you can start a new game **without** a reload: matchmaking and joining another lobby go through `handleJoinLobby`, which calls `lobbyHandle.stop(true)` then `joinLobby()` on the same document. The old WebGL context stayed alive (the never-cancelled RAF kept it referenced, so it wasn't even GC'd), and each new game stacked another context. After a few games, mobile browsers hit their WebGL context limit — matching the repro in #4267. ## Fix `stop()` now disposes the renderer: - cancels the self-driving RAF loop and disconnects the frame-loop resize observer - disposes the `MapRenderer` (frees all GPU resources) - removes the WebGL canvas and the input overlay from the DOM `GPURenderer.dispose()` additionally calls `WEBGL_lose_context.loseContext()` so the context is released promptly instead of waiting on unreliable GC. The territory-patterns settings listener is wired to the existing graphics `AbortController` so it no longer outlives the disposed view. The cleanup runs unconditionally in `stop()` (a superseded join can stop before the game becomes active) and is idempotent against repeated `stop()` calls. Fixes #4267 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- src/client/ClientGameRunner.ts | 44 +++++++++++++++++++++++++++----- src/client/render/gl/Renderer.ts | 4 +++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2f3331654..d6c3f7a32 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -329,7 +329,7 @@ function mountWebGLFrameLoop( transformHandler: import("./TransformHandler").TransformHandler, gameView: GameView, eventBus: EventBus, -): { builder: WebGLFrameBuilder } { +): { builder: WebGLFrameBuilder; stopFrameLoop: () => void } { const gameMap = terrainMap.gameMap; const mapWidth = gameMap.width(); const mapHeight = gameMap.height(); @@ -388,11 +388,24 @@ function mountWebGLFrameLoop( // TransformHandler, pushes it to WebGL, and synchronously invokes the // renderer's captured frame callback (which draws). One RAF = one // synchronized camera-update + WebGL render. + let rafId: number | null = null; const driveFrame = (): void => { syncCamera(); - requestAnimationFrame(driveFrame); + rafId = requestAnimationFrame(driveFrame); + }; + rafId = requestAnimationFrame(driveFrame); + + // Tear down the per-frame loop so a stopped game stops driving WebGL and + // releases the view for disposal. Left running, the RAF keeps the WebGL + // context referenced (and alive) forever — each new game would then stack + // another context until the browser's limit is hit. + const stopFrameLoop = (): void => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + resizeObs.disconnect(); }; - requestAnimationFrame(driveFrame); const builder = new WebGLFrameBuilder(view); @@ -423,7 +436,7 @@ function mountWebGLFrameLoop( builder.update(gameView); }; - return { builder }; + return { builder, stopFrameLoop }; } async function createClientGame( @@ -502,13 +515,15 @@ async function createClientGame( resolveRenderSettings(), ); + const graphicsListenerAbort = new AbortController(); + view.setShowPatterns(userSettings.territoryPatterns()); globalThis.addEventListener( `${USER_SETTINGS_CHANGED_EVENT}:settings.territoryPatterns`, (e) => view.setShowPatterns((e as CustomEvent).detail === "true"), + { signal: graphicsListenerAbort.signal }, ); - const graphicsListenerAbort = new AbortController(); // Re-resolve settings and copy them onto the renderer's live object in // place (passes hold a reference to it, so they pick the change up). const regenerateRenderSettings = (): void => { @@ -574,7 +589,7 @@ async function createClientGame( view, ); - const { builder: webglBuilder } = mountWebGLFrameLoop( + const { builder: webglBuilder, stopFrameLoop } = mountWebGLFrameLoop( gameMap, view, glCanvas, @@ -584,6 +599,20 @@ async function createClientGame( eventBus, ); + // Releases all WebGL/DOM resources this game created. Without it, stopping + // a game (e.g. joining another without a page reload) leaks the WebGL + // context, canvas and input overlay — a few games and mobile browsers hit + // their WebGL context limit. Idempotent: stop() may be called more than once. + let rendererDisposed = false; + const disposeRenderer = (): void => { + if (rendererDisposed) return; + rendererDisposed = true; + stopFrameLoop(); + view.dispose(); + glCanvas.remove(); + inputOverlay.remove(); + }; + console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, ); @@ -601,6 +630,7 @@ async function createClientGame( userSettings, webglBuilder, graphicsListenerAbort, + disposeRenderer, ); } catch (err) { soundManager.dispose(); @@ -635,6 +665,7 @@ export class ClientGameRunner { private userSettings: UserSettings, private webglBuilder: WebGLFrameBuilder | null = null, private graphicsListenerAbort: AbortController | null = null, + private disposeRenderer: (() => void) | null = null, ) { this.lastMessageTime = Date.now(); } @@ -892,6 +923,7 @@ export class ClientGameRunner { public stop() { this.soundManager.dispose(); this.graphicsListenerAbort?.abort(); + this.disposeRenderer?.(); if (!this.isActive) return; this.isActive = false; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 22a0b3c00..87a504998 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -1226,5 +1226,9 @@ export class GPURenderer { this.gl.deleteTexture(this.sceneTarget.tex); this.lastUnits = new Map(); this.lastStructures = new Map(); + // Deleting GL resources isn't enough — the context itself counts against + // the browser's WebGL context limit until it's GC'd, which is unreliable + // on mobile. Explicitly drop it so repeated game starts don't overflow. + this.gl.getExtension("WEBGL_lose_context")?.loseContext(); } }