diff --git a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts new file mode 100644 index 0000000000..5aa443d67d --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts @@ -0,0 +1,102 @@ +import * as THREE from "three"; +import { SplatMesh } from "@sparkjsdev/spark"; +import { disposeObject3D } from "./disposeObject3D"; + +// A real SplatMesh needs Spark's WASM decoder; only `instanceof` and +// dispose() matter here. +vi.mock("@sparkjsdev/spark", async () => { + const { Object3D } = await import("three"); + return { + SplatMesh: class extends Object3D { + dispose = vi.fn(); + }, + }; +}); + +describe("disposeObject3D", () => { + it("disposes nested geometries, materials and their textures", () => { + const map = new THREE.Texture(); + const normalMap = new THREE.Texture(); + const material = new THREE.MeshStandardMaterial({ map, normalMap }); + const geometry = new THREE.BoxGeometry(); + const root = new THREE.Group(); + const child = new THREE.Group(); + child.add(new THREE.Mesh(geometry, material)); + root.add(child); + + const disposed = trackDisposals([map, normalMap, material, geometry]); + disposeObject3D(root); + + expect(disposed).toEqual(new Set([map, normalMap, material, geometry])); + }); + + it("disposes every material of a multi-material mesh", () => { + const first = new THREE.MeshBasicMaterial({ map: new THREE.Texture() }); + const second = new THREE.MeshBasicMaterial(); + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), [first, second]); + + const disposed = trackDisposals([first, second, first.map!]); + disposeObject3D(mesh); + + expect(disposed).toEqual(new Set([first, second, first.map])); + }); + + it("disposes a material shared by several meshes once", () => { + const material = new THREE.MeshBasicMaterial(); + const root = new THREE.Group(); + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)); + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)); + + const onDispose = vi.fn(); + material.addEventListener("dispose", onDispose); + disposeObject3D(root); + + expect(onDispose).toHaveBeenCalledTimes(1); + }); + + it("disposes skinned mesh skeletons", () => { + const bone = new THREE.Bone(); + const skeleton = new THREE.Skeleton([bone]); + const mesh = new THREE.SkinnedMesh( + new THREE.BoxGeometry(), + new THREE.MeshBasicMaterial(), + ); + mesh.add(bone); + mesh.bind(skeleton); + const dispose = vi.spyOn(skeleton, "dispose"); + + disposeObject3D(mesh); + + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it("frees splat buffers", () => { + const splat = new SplatMesh(); + const root = new THREE.Group(); + root.add(splat); + + disposeObject3D(root); + + expect(splat.dispose).toHaveBeenCalledTimes(1); + }); + + it("skips objects without geometry or material", () => { + const root = new THREE.Group(); + root.add(new THREE.DirectionalLight()); + root.add(new THREE.PerspectiveCamera()); + + expect(() => disposeObject3D(root)).not.toThrow(); + }); +}); + +// Records which resources dispatched their "dispose" event. +function trackDisposals( + resources: Array, +): Set { + const disposed = new Set(); + for (const resource of resources) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (resource as any).addEventListener("dispose", () => disposed.add(resource)); + } + return disposed; +} diff --git a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts new file mode 100644 index 0000000000..af8b3d0321 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts @@ -0,0 +1,34 @@ +import * as THREE from "three"; +import { SplatMesh } from "@sparkjsdev/spark"; + +// Free the GPU resources held by an object tree that is leaving the scene +// for good: geometries, materials, the textures those materials reference +// (Material.dispose() does not free its maps), skeleton bone textures and +// Spark splat buffers. Call it after the tree has been removed. +// +// Geometry, material and texture disposal is safe even if one of them is +// shared with an object that stays — three.js re-uploads it the next time +// it renders. Splat buffers are not re-creatable, but every SplatMesh owns +// its own (scene.ts builds one per load), and undo re-loads deleted +// objects from their snapshot rather than reusing the instance. +export function disposeObject3D(root: THREE.Object3D): void { + const materials = new Set(); + root.traverse((child) => { + if (child instanceof SplatMesh) child.dispose(); + if (child instanceof THREE.SkinnedMesh) child.skeleton?.dispose(); + + const { geometry, material } = child as Partial; + geometry?.dispose(); + const childMaterials = Array.isArray(material) ? material : [material]; + for (const childMaterial of childMaterials) { + if (childMaterial) materials.add(childMaterial); + } + }); + + for (const material of materials) { + for (const value of Object.values(material)) { + if (value instanceof THREE.Texture) value.dispose(); + } + material.dispose(); + } +} diff --git a/frontend/libs/components/pagescene/src/lib/engine/helper.ts b/frontend/libs/components/pagescene/src/lib/engine/helper.ts index b90ef1d826..d3de71e2bf 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/helper.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/helper.ts @@ -3,6 +3,7 @@ import type { SelectionOutlinePass } from "./SelectionOutlinePass"; import type { TransformControls } from "./TransformControls.js"; import Scene from "./scene"; +import { disposeObject3D } from "./disposeObject3D"; import type { EngineEventBus } from "./events/EngineEventBus"; import { InspectorPanelChangedEvent, @@ -148,45 +149,11 @@ function removeObject3D(object3D) { return; } - // Finally remove the object from the scene + // Finally remove the object from the scene and free its GPU resources. + // Undo re-loads deleted objects from a snapshot (DeleteAction), so + // nothing reuses this instance. this.scene.scene.remove(obj); - - obj.traverse(child => { - (child as THREE.Mesh)?.geometry?.dispose() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const childTexture = (child as any).texture; - if (Array.isArray(childTexture)) { - childTexture.forEach(mat => mat.dispose()); - } else if (childTexture) { - childTexture.dispose(); - } - - const childMaterial = (child as THREE.Mesh).material; - if (Array.isArray(childMaterial)) { - childMaterial.forEach(mat => mat.dispose()); - } else if (childMaterial) { - childMaterial.dispose(); - } - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const objTexture = (obj as any).texture; - if (Array.isArray(objTexture)) { - objTexture.forEach(mat => mat.dispose()); - } else if (objTexture) { - objTexture.dispose(); - } - - const objMaterial = (obj as THREE.Mesh).material; - if (Array.isArray(objMaterial)) { - objMaterial.forEach(mat => mat.dispose()); - } else if (objMaterial) { - objMaterial.dispose(); - } - - if ((obj as THREE.Mesh).geometry) { - (obj as THREE.Mesh).geometry.dispose() - } + disposeObject3D(obj); this.deps.bus.emit(new ObjectRemovedEvent(uuid)); this.deps.clearSelected(); diff --git a/frontend/libs/components/pagescene/src/lib/engine/scene.ts b/frontend/libs/components/pagescene/src/lib/engine/scene.ts index cf5972e565..11e953086b 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/scene.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/scene.ts @@ -13,6 +13,7 @@ import type { Camera } from "@storyteller/common"; import toast from "react-hot-toast"; import { SplatMesh } from "@sparkjsdev/spark"; import { ensureInternalBbox } from "./internalBbox"; +import { disposeObject3D } from "./disposeObject3D"; // Capabilities Scene needs from outside its own state. Editor wires // these in inline at construction (Phase 2 idiom — same shape as @@ -134,7 +135,11 @@ class Scene { } clear() { - this.scene.children = []; + // Free the outgoing scene's GPU resources before dropping it. Assigning + // `children = []` released nothing and left every child pointing at + // this scene as its parent. + disposeObject3D(this.scene); + this.scene.clear(); // hot_items held detached keyframe-point refs across reloads; reset it // so it never carries stale objects from a previous scene. this.hot_items = []; @@ -1118,6 +1123,11 @@ class Scene { // default skybox. _create_skybox() { const loader = new THREE.CubeTextureLoader(); + // Every branch below replaces the background, so free the cube texture + // being replaced (skybox switches and New Scene used to leak it). + if (this.scene.background instanceof THREE.Texture) { + this.scene.background.dispose(); + } // Theme-aware: in light mode, use a near-white background for better contrast with UI try {