fix(dashboard): switching zones in the world view lets the old one go

The old zone's scenery, flairs, terrain and markers stayed until the new
zone's data came back, and a manifest still loading could land after a newer
one. Switching now clears them at once; scenery requests are aborted when
cleared, late manifests and textures of a cleared load are dropped (and
disposed), and the budget no longer counts them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 21:40:30 -05:00
parent c0054d8fa3
commit a979ae1658
2 changed files with 41 additions and 7 deletions

View File

@@ -51,6 +51,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
let used = 0; // bytes of geometry and textures on the GPU let used = 0; // bytes of geometry and textures on the GPU
let active = 0; let active = 0;
let generation = 0; // bumped on clear, so late responses are dropped let generation = 0; // bumped on clear, so late responses are dropped
let requests = new AbortController(); // aborted on clear: requests for what's no longer wanted stop
let lastUpdate = 0; let lastUpdate = 0;
const focusPoint = new THREE.Vector3(); const focusPoint = new THREE.Vector3();
@@ -71,7 +72,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
} }
async function fetchBuffer(url) { async function fetchBuffer(url) {
const response = await fetch(url, { credentials: 'same-origin' }); const response = await fetch(url, { credentials: 'same-origin', signal: requests.signal });
if (!response.ok) throw new Error(response.status + ' ' + url); if (!response.ok) throw new Error(response.status + ' ' + url);
return response.arrayBuffer(); return response.arrayBuffer();
} }
@@ -110,10 +111,11 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
// Textures stored inside a .nif are named by their block ("#12"), so only unique within their model // Textures stored inside a .nif are named by their block ("#12"), so only unique within their model
const key = lod + '/' + (path.startsWith('#') ? asset + path : path); const key = lod + '/' + (path.startsWith('#') ? asset + path : path);
if (!textures.has(key)) { if (!textures.has(key)) {
const size = detail.texture; const size = detail.texture, mine = generation;
textures.set(key, fetchBuffer(versioned(urls.texture(manifest.zone, asset, slot, lod))).then((buffer) => { textures.set(key, fetchBuffer(versioned(urls.texture(manifest.zone, asset, slot, lod))).then((buffer) => {
const texture = makeTexture(buffer, size); const texture = makeTexture(buffer, size);
if (texture) used += texture.userData.bytes; // Cleared meanwhile: the texture was already let go of (clear disposes what it finds)
if (texture && mine === generation) used += texture.userData.bytes;
return texture; return texture;
}).catch(() => null)); }).catch(() => null));
} }
@@ -281,7 +283,10 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
active++; active++;
try { try {
const { parts, radius, center } = await buildParts(asset, detail.lod, false); const { parts, radius, center } = await buildParts(asset, detail.lod, false);
if (mine !== generation) { parts.forEach((p) => { p.geometry.dispose(); p.material.dispose(); }); return; } if (mine !== generation) {
parts.forEach((p) => { p.geometry.dispose(); if (p.material.map) p.material.map.dispose(); p.material.dispose(); });
return;
}
const instances = byAsset.get(asset) || []; const instances = byAsset.get(asset) || [];
const matrix = new THREE.Matrix4(), position = new THREE.Vector3(), rotation = new THREE.Quaternion(), scale = new THREE.Vector3(), tint = new THREE.Color(); const matrix = new THREE.Matrix4(), position = new THREE.Vector3(), rotation = new THREE.Quaternion(), scale = new THREE.Vector3(), tint = new THREE.Color();
// Objects the game doesn't draw (volumes, triggers) get their own cells in a see-through colour, shown on request // Objects the game doesn't draw (volumes, triggers) get their own cells in a see-through colour, shown on request
@@ -394,6 +399,8 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
function clear() { function clear() {
generation++; generation++;
requests.abort();
requests = new AbortController();
for (const entry of assets.values()) { for (const entry of assets.values()) {
for (const cell of entry.cells) { for (const cell of entry.cells) {
root.remove(cell.group); root.remove(cell.group);
@@ -418,11 +425,21 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
/** Load a manifest (or reload it at another detail level: 0 high, 1 medium, 2 low). */ /** Load a manifest (or reload it at another detail level: 0 high, 1 medium, 2 low). */
async load(url = urls.manifest, level = 2) { async load(url = urls.manifest, level = 2) {
clear(); clear();
const mine = generation;
detail = DETAIL[Math.max(0, Math.min(DETAIL.length - 1, level))]; detail = DETAIL[Math.max(0, Math.min(DETAIL.length - 1, level))];
if (!manifest || manifest.url !== url) { if (!manifest || manifest.url !== url) {
const response = await fetch(url, { credentials: 'same-origin' }); manifest = null;
if (!response.ok) { manifest = null; return false; } let loaded = null;
manifest = await response.json(); try {
const response = await fetch(url, { credentials: 'same-origin', signal: requests.signal });
if (response.ok) loaded = await response.json();
} catch (error) {
// Aborted by a newer load or clear, or the network failed
}
// Another load or a clear came first: this one's result isn't wanted any more
if (mine !== generation) return false;
if (!loaded) return false;
manifest = loaded;
manifest.url = url; manifest.url = url;
setGameLights(manifest.lighting); setGameLights(manifest.lighting);
byAsset = groupObjects(manifest.objects); byAsset = groupObjects(manifest.objects);
@@ -475,6 +492,12 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
for (const entry of assets.values()) for (const cell of entry.cells) { cells++; if (cell.group.visible) drawn++; } for (const entry of assets.values()) for (const cell of entry.cells) { cells++; if (cell.group.visible) drawn++; }
return { assets: assets.size, loaded: [...assets.values()].filter((e) => e.state === 'done').length, cells, drawn, megabytes: Math.round(used / 1048576) }; return { assets: assets.size, loaded: [...assets.values()].filter((e) => e.state === 'done').length, cells, drawn, megabytes: Math.round(used / 1048576) };
}, },
/** Let go of everything loaded and stop what's loading (switching zones); load() starts again. */
clear() {
clear();
manifest = null;
byAsset = new Map();
},
dispose() { dispose() {
clear(); clear();
scene.remove(root); scene.remove(root);

View File

@@ -429,6 +429,17 @@ async function loadZone(zone) {
state.follow = null; state.follow = null;
stopReplay(); stopReplay();
clearHeat(); clearHeat();
// The old zone goes at once: its models, flairs, terrain and markers, and whatever of them is still loading
scenery.clear();
flairs.clear();
sceneryShown = flairsShown = null;
terrainData = null;
state.scene = null;
showTerrain();
buildObjects();
disposeGroup(layers.paths);
state.pathLines = new Map();
buildSpawns();
if (canHeat) loadHeatProperties(zone); if (canHeat) loadHeatProperties(zone);
setStatus('Loading ' + zoneName(zone) + '…'); setStatus('Loading ' + zoneName(zone) + '…');
const [sceneData, terrain] = await Promise.all([ const [sceneData, terrain] = await Promise.all([