mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user