Dispose WebGL renderer when a game stops 🧹 (#4295)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-17 09:05:12 -07:00
committed by GitHub
parent 64409cae4d
commit 305534cc65
2 changed files with 42 additions and 6 deletions
+38 -6
View File
@@ -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<string>).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;
+4
View File
@@ -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();
}
}