pagescene: free the scene when the editor unmounts

unmountEngine disposed the renderers but none of the scene. Every mount
builds a new Editor, and on a scene change it builds it on the same
canvases, which hands it the same WebGL context, so the old scene's
buffers and textures stayed allocated in a live context. In the webapp
this happened on every scene-to-scene navigation.

Unmount now finishes running entrances, disposes the gizmo (which also
removes its pointer listeners from the reused canvas) and frees the
scene's contents and background. This is safe because EngineProvider
serializes the scene before calling unmountEngine and nothing reuses an
Editor after it. The old pause/resume use of unmount/remount, which kept
the scene alive across tab switches, went away in #654.

forceContextLoss is deliberately not called: the next Editor may be
created on these canvases and would start on a lost context.
This commit is contained in:
Tanish Manku
2026-10-06 17:46:29 -07:00
parent 2eddf76795
commit 4402f4f668
3 changed files with 36 additions and 0 deletions
@@ -1128,6 +1128,16 @@ class Editor {
this.bus.emit(new SceneLoadedEvent(false));
this.stopRenderLoop();
// Free the scene's GPU resources. EngineProvider serialized the scene
// before calling us, and the next mount builds a fresh Editor, so
// nothing reads this scene again. The gizmo's dispose also removes its
// pointer listeners from the canvas. No forceContextLoss: a scene
// change mounts the next Editor on these same canvases, which hands it
// this same WebGL context.
this.entranceAnimator.clear();
this.gizmo.control?.dispose();
this.activeScene.dispose();
// Fix: dispose 3D contexts
this.renderer?.dispose();
this.postProcessing.dispose();
@@ -110,6 +110,23 @@ describe("Scene disposal", () => {
});
});
it("dispose frees the contents and the background", () => {
const scene = makeScene();
const geometry = new THREE.BoxGeometry();
const background = new THREE.CubeTexture();
const disposed = new Set<unknown>();
geometry.addEventListener("dispose", () => disposed.add(geometry));
background.addEventListener("dispose", () => disposed.add(background));
scene.scene.add(new THREE.Mesh(geometry));
scene.scene.background = background;
scene.dispose();
expect(disposed).toEqual(new Set([geometry, background]));
expect(scene.scene.children).toHaveLength(0);
expect(scene.scene.background).toBeNull();
});
it("disposeBackground frees a texture background", () => {
const scene = makeScene();
const background = new THREE.CubeTexture();
@@ -178,6 +178,15 @@ class Scene {
disposeObject3D(root);
}
// Final teardown when the owning Editor unmounts: free the contents and
// the background. The Scene is not used again — every mount builds a new
// Editor, and EngineProvider serializes the scene before unmounting.
dispose() {
this.disposeContents();
this.disposeBackground();
this.scene.background = null;
}
disposeBackground() {
if (this.scene.background instanceof THREE.Texture) {
this.scene.background.dispose();