Real CONTRABAND development case

WebGL context lifecycle: dispose is not free

A Martinez AI Studios education product

Shipyard, lock hologram, 3D map, hangar, and inventory each opened a GPU context. The flight canvas paid the bill.

Chromium keeps a small budget of WebGL contexts per process (on the order of 8–16). CONTRABAND preview UIs constructed a WebGLRenderer on open and dispose() on close. dispose() does not immediately release the GPU context. After a few reopen cycles the runtime logged too many active contexts, the main flight canvas received CONTEXT_LOST_WEBGL, HUD DOM stayed up, and the 3D view went white.

Category
Rendering / lifecycle
Project
CONTRABAND
Recorded
2026-08-19
Status
Verified · published

1. Symptom

Flight view went white or blank after using shipyard / lock preview / map / hangar. Console: “Too many active WebGL contexts” then THREE.WebGLRenderer: Context Lost on the primary canvas. HUD chips could still paint.

2. Player impact

The session looked like a crash of space itself: cockpit UI alive, stars gone. Reloading recovered; repeating shipyard Equip/tier actions reproduced it without a “gameplay” error.

3. Reproduction

Open and close 3D previews (shipyard inline hangar, ship viewer, galaxy map, lock hologram) several times, or refresh shipyard on each Equip. Watch the context-lost warning on the flight canvas. The pool module documents this sequence in its header comment.

4. System ownership

Primary world renderer: play/three/scene.js (webglcontextlost listener). Previews: shipyard.js, hud lock preview, galaxy-map-3d, hangar-ship, ui-ship-preview. Shared pool: webgl-preview-pool.js (PREVIEW_GL ids). gpu-warning.js surfaces GPU/renderer info.

5. Incorrect assumption

Calling renderer.dispose() returns the context to the budget before the next open. Creating a fresh WebGLRenderer per panel open is “clean architecture.” The main canvas is isolated from preview canvases.

6. Root cause

Context count is process-wide. dispose() is asynchronous from Chromium’s budget. Reopen cycles stacked live contexts until the oldest — often the flight renderer — was killed. Shipyard renderShipyard() historically tore down and rebuilt the inline hangar preview on Equip/tier/buy, which was a high-churn path.

7. Minimal fix

Acquire once per preview id, pause RAF on close, destroy only if the context is already lost. Do not innerHTML-replace a live hangar column. The pool’s acquirePreviewRenderer / pausePreviewRenderer / destroyPreviewRenderer is that contract.

8. Regression test

tests/webgl-preview-lifecycle.test.mjs covers reuse vs create-count and lost-context destroy. Earlier shipyard churn was also guarded so Equip does not open extra contexts.

9. Release validation

Development log 19 Aug 2026, v0.4.158 (preview pool). Earlier log entries record white-flash on dock (F opening a second station WebGL) and hangar teardown. Primary canvas still listens for webglcontextlost.

10. Generalized lesson

GPU contexts are a shared budget, not a private field on a component. Lifecycle is pause vs destroy. An AI that “cleans up” with dispose() on every React-like unmount can take down the game view. Count live renderers; test reopen loops, not a single open.

Simplified excerpt

Simplified / pseudocode for teaching. Not a dump of production files.

The pool rule, simplified from the module comment.

// Simplified teaching excerpt — not CONTRABAND production source.
// Chromium ~8–16 WebGL contexts per process.
function acquirePreviewRenderer(id) {
  const existing = slots.get(id);
  if (existing && !existing.contextLost) return existing; // reuse
  return createOnce(id);
}
function onPreviewClose(id) {
  pauseRaf(id);               // do not dispose every close
  // destroy only if getContext().isContextLost()
}

Public evidence

  • webgl-preview-pool.js header comment and acquire/pause/destroy API.
  • CONTRABAND development log, 19 Aug 2026, v0.4.158; earlier white-canvas / shipyard churn notes.
  • webgl-preview-lifecycle.test.mjs

Related flagship modules

Related notes