feat: WebGL context loss recovery for iPad PWA (#14)

Applies Replit PR #21 feature on top of current main:
- buildWorld()/teardown() lifecycle for world rebuild on context restore
- disposeWorld(), disposeAgents(), disposeEffects(), disposeInteraction()
- getAgentStates()/applyAgentStates() for state preservation across rebuilds
- webgl-recovery-overlay in index.html
- Canvas reuse on reinit (existingCanvas param in initWorld)
- Preserves: visibility-change pause, visitor init, debounced resize
This commit is contained in:
2026-03-19 02:01:23 +00:00
parent 174c8425ec
commit f0231733a2
6 changed files with 185 additions and 25 deletions

View File

@@ -163,6 +163,11 @@
</head>
<body>
<div id="loading-screen"><span>INITIALIZING...</span></div>
<!-- WebGL context loss overlay (iPad PWA, GPU resets) -->
<div id="webgl-recovery-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.9);color:#00ff41;font-family:monospace;align-items:center;justify-content:center;flex-direction:column">
<p style="font-size:1.4rem">RECOVERING WebGL CONTEXT…</p>
<p style="font-size:.85rem;opacity:.6">GPU was reset. Rebuilding world.</p>
</div>
<div id="ui-overlay">
<div id="hud">
<h1>TIMMY TOWER WORLD</h1>
@@ -174,6 +179,7 @@
<div id="agent-list"></div>
</div>
<div id="chat-panel"></div>
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
<div id="bark-container"></div>
<div id="connection-status">OFFLINE</div>
</div>

View File

@@ -260,3 +260,38 @@ export function removeAgent(agentId) {
console.info('[Agents] Removed agent:', agentId);
return true;
}
/**
* Snapshot current agent states for preservation across WebGL context loss.
* @returns {Object.<string,string>} agentId → state string
*/
export function getAgentStates() {
const snapshot = {};
for (const [id, agent] of agents) {
snapshot[id] = agent.state || 'idle';
}
return snapshot;
}
/**
* Reapply a state snapshot after world rebuild.
* @param {Object.<string,string>} snapshot
*/
export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [id, state] of Object.entries(snapshot)) {
const agent = agents.get(id);
if (agent) agent.state = state;
}
}
/**
* Dispose all agent resources (used on world teardown).
*/
export function disposeAgents() {
for (const [id, agent] of agents) {
scene.remove(agent.group);
agent.dispose();
}
agents.clear();
}

15
js/effects.js vendored
View File

@@ -100,3 +100,18 @@ export function updateEffects(_time) {
rainParticles.geometry.attributes.position.needsUpdate = true;
}
/**
* Dispose all effect resources (used on world teardown).
*/
export function disposeEffects() {
if (rainParticles) {
rainParticles.geometry.dispose();
rainParticles.material.dispose();
rainParticles = null;
}
rainPositions = null;
rainVelocities = null;
rainCount = 0;
frameCounter = 0;
}

View File

@@ -19,3 +19,13 @@ export function initInteraction(camera, renderer) {
export function updateControls() {
if (controls) controls.update();
}
/**
* Dispose orbit controls (used on world teardown).
*/
export function disposeInteraction() {
if (controls) {
controls.dispose();
controls = null;
}
}

View File

@@ -1,46 +1,73 @@
import { initWorld, onWindowResize } from './world.js';
import { initAgents, updateAgents, getAgentCount } from './agents.js';
import { initEffects, updateEffects } from './effects.js';
import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, updateControls } from './interaction.js';
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
let frameCount = 0;
let lastFpsTime = performance.now();
let currentFps = 0;
let running = false;
let canvas = null;
function main() {
const { scene, camera, renderer } = initWorld();
/**
* Build (or rebuild) the Three.js world.
*
* @param {boolean} firstInit
* true — first page load: also starts UI, WebSocket, and visitor
* false — context-restore reinit: skips UI/WS (they survive context loss)
* @param {Object.<string,string>|null} stateSnapshot
* Agent state map captured just before teardown; reapplied after initAgents.
*/
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
initEffects(scene);
initAgents(scene);
initInteraction(camera, renderer);
initUI();
initWebSocket(scene);
initVisitor();
// Debounce resize to 1 call per frame (avoids dozens of framebuffer re-allocations during drag)
if (stateSnapshot) {
applyAgentStates(stateSnapshot);
}
initInteraction(camera, renderer);
if (firstInit) {
initUI();
initWebSocket(scene);
initVisitor();
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) loadingScreen.classList.add('hidden');
}
// Debounce resize to 1 call per frame
const ac = new AbortController();
let resizeFrame = null;
window.addEventListener('resize', () => {
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer));
});
}, { signal: ac.signal });
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) loadingScreen.classList.add('hidden');
let frameCount = 0;
let lastFpsTime = performance.now();
let currentFps = 0;
let rafId = null;
let rafId = null;
running = true;
function animate() {
if (!running) return;
rafId = requestAnimationFrame(animate);
const now = performance.now();
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
lastFpsTime = now;
}
@@ -63,13 +90,48 @@ function main() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
running = false;
}
} else {
if (!rafId) animate();
if (!running) {
running = true;
animate();
}
}
});
animate();
return { scene, renderer, ac };
}
function teardown({ scene, renderer, ac }) {
running = false;
ac.abort();
disposeInteraction();
disposeEffects();
disposeAgents();
disposeWorld(renderer, scene);
}
function main() {
const $overlay = document.getElementById('webgl-recovery-overlay');
let handle = buildWorld(true, null);
// WebGL context loss recovery (iPad PWA, GPU driver reset, etc.)
canvas.addEventListener('webglcontextlost', event => {
event.preventDefault();
running = false;
if ($overlay) $overlay.style.display = 'flex';
});
canvas.addEventListener('webglcontextrestored', () => {
const snapshot = getAgentStates();
teardown(handle);
handle = buildWorld(false, snapshot);
if ($overlay) $overlay.style.display = 'none';
});
}
main();

View File

@@ -2,8 +2,13 @@ import * as THREE from 'three';
import { getMaxPixelRatio, getQualityTier } from './quality.js';
let scene, camera, renderer;
const _worldObjects = [];
export function initWorld() {
/**
* @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on
* re-init so Three.js reuses the same DOM element instead of creating a new one
*/
export function initWorld(existingCanvas) {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.FogExp2(0x000000, 0.035);
@@ -13,11 +18,17 @@ export function initWorld() {
camera.lookAt(0, 0, 0);
const tier = getQualityTier();
renderer = new THREE.WebGLRenderer({ antialias: tier !== 'low' });
renderer = new THREE.WebGLRenderer({
antialias: tier !== 'low',
canvas: existingCanvas || undefined,
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio()));
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.prepend(renderer.domElement);
if (!existingCanvas) {
document.body.prepend(renderer.domElement);
}
addLights(scene);
addGrid(scene, tier);
@@ -43,6 +54,7 @@ function addGrid(scene, tier) {
const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);
_worldObjects.push(grid);
const planeGeo = new THREE.PlaneGeometry(100, 100);
const planeMat = new THREE.MeshBasicMaterial({
@@ -54,6 +66,26 @@ function addGrid(scene, tier) {
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.02;
scene.add(plane);
_worldObjects.push(plane);
}
/**
* Dispose only world-owned geometries, materials, and the renderer.
* Agent and effect objects are disposed by their own modules before this runs.
*/
export function disposeWorld(disposeRenderer, _scene) {
for (const obj of _worldObjects) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
if (m.map) m.map.dispose();
m.dispose();
});
}
}
_worldObjects.length = 0;
disposeRenderer.dispose();
}
export function onWindowResize(camera, renderer) {