Free GPU resources on New Scene, object delete and skybox change
Three paths in the 3D stage dropped objects without releasing what they held on the GPU: - Scene.clear() (New Scene) assigned `scene.children = []`, which disposed nothing and left each child parented to the scene. Every New Scene leaked all geometries, materials, textures and splat buffers of the previous one. - SceneUtils.deleteObject disposed geometries and materials but not the textures they reference (Material.dispose() does not free its maps), so deleting a textured GLB leaked every map. - _create_skybox replaced scene.background without disposing the previous cube texture. Add disposeObject3D(), which frees geometries, materials and their textures (deduplicated), skeleton bone textures and Spark splat buffers, and use it in clear() and deleteObject. clear() now uses Object3D.clear() so children are actually unparented. The old deleteObject also checked a custom `.texture` field that nothing in the codebase sets; that branch is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3210977fb1
commit
2abba5bd20
|
|
@ -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<THREE.Texture | THREE.Material | THREE.BufferGeometry>,
|
||||
): Set<unknown> {
|
||||
const disposed = new Set<unknown>();
|
||||
for (const resource of resources) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(resource as any).addEventListener("dispose", () => disposed.add(resource));
|
||||
}
|
||||
return disposed;
|
||||
}
|
||||
|
|
@ -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<THREE.Material>();
|
||||
root.traverse((child) => {
|
||||
if (child instanceof SplatMesh) child.dispose();
|
||||
if (child instanceof THREE.SkinnedMesh) child.skeleton?.dispose();
|
||||
|
||||
const { geometry, material } = child as Partial<THREE.Mesh>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue