mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
pagescene: free scene loads and per-object registries along with GPU memory
Loading a scene over the current one removed every child without freeing anything. It now goes through Scene.disposeContents, the same path New Scene uses. Deleting or clearing objects also left them in Scene's side registries: deleted Water stayed in shader_objects and was ticked every frame, video planes kept their <video> element playing and buffered in video_planes, and MMD meshes stayed registered with the animation helper. Scene.disposeObject now prunes those entries and stops the video (pause alone keeps the decoder alive, so the source is dropped too) before freeing the tree. Object delete routes through it.
This commit is contained in:
@@ -18,34 +18,25 @@ import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
|
||||
// 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 SparkRenderer) disposeSparkRenderer(child);
|
||||
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);
|
||||
}
|
||||
(child as Partial<THREE.Mesh>).geometry?.dispose();
|
||||
});
|
||||
|
||||
const textures = new Set<THREE.Texture>();
|
||||
for (const material of materials) {
|
||||
collectTextures(Object.values(material), textures);
|
||||
if (material instanceof THREE.ShaderMaterial) {
|
||||
collectTextures(
|
||||
Object.values(material.uniforms).map((uniform) => uniform?.value),
|
||||
textures,
|
||||
);
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
const materials = collectMaterials(root);
|
||||
const textures = collectMaterialTextures(materials);
|
||||
for (const material of materials) material.dispose();
|
||||
for (const texture of textures) texture.dispose();
|
||||
}
|
||||
|
||||
// Every texture the tree's materials reference, as material properties or
|
||||
// ShaderMaterial uniforms, deduplicated.
|
||||
export function collectObjectTextures(root: THREE.Object3D): Set<THREE.Texture> {
|
||||
return collectMaterialTextures(collectMaterials(root));
|
||||
}
|
||||
|
||||
// A SparkRenderer is added to the scene automatically the first time a
|
||||
// splat renders. Its viewpoint render targets and splat accumulators are
|
||||
// not reachable from its material, so free them explicitly. The viewpoint
|
||||
@@ -59,7 +50,35 @@ function disposeSparkRenderer(spark: SparkRenderer): void {
|
||||
for (const accumulator of freeAccumulators) accumulator.splats.dispose();
|
||||
}
|
||||
|
||||
function collectTextures(values: unknown[], into: Set<THREE.Texture>): void {
|
||||
function collectMaterials(root: THREE.Object3D): Set<THREE.Material> {
|
||||
const materials = new Set<THREE.Material>();
|
||||
root.traverse((child) => {
|
||||
const { material } = child as Partial<THREE.Mesh>;
|
||||
const childMaterials = Array.isArray(material) ? material : [material];
|
||||
for (const childMaterial of childMaterials) {
|
||||
if (childMaterial) materials.add(childMaterial);
|
||||
}
|
||||
});
|
||||
return materials;
|
||||
}
|
||||
|
||||
function collectMaterialTextures(
|
||||
materials: Iterable<THREE.Material>,
|
||||
): Set<THREE.Texture> {
|
||||
const textures = new Set<THREE.Texture>();
|
||||
for (const material of materials) {
|
||||
addTextures(Object.values(material), textures);
|
||||
if (material instanceof THREE.ShaderMaterial) {
|
||||
addTextures(
|
||||
Object.values(material.uniforms).map((uniform) => uniform?.value),
|
||||
textures,
|
||||
);
|
||||
}
|
||||
}
|
||||
return textures;
|
||||
}
|
||||
|
||||
function addTextures(values: unknown[], into: Set<THREE.Texture>): void {
|
||||
for (const value of values) {
|
||||
if (value instanceof THREE.Texture) {
|
||||
into.add(value);
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -153,7 +152,7 @@ function removeObject3D(object3D) {
|
||||
// Undo re-loads deleted objects from a snapshot (DeleteAction), so
|
||||
// nothing reuses this instance.
|
||||
this.scene.scene.remove(obj);
|
||||
disposeObject3D(obj);
|
||||
this.scene.disposeObject(obj);
|
||||
|
||||
this.deps.bus.emit(new ObjectRemovedEvent(uuid));
|
||||
this.deps.clearSelected();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as THREE from "three";
|
||||
import Scene from "./scene";
|
||||
|
||||
// Scene imports Spark for splat loading; none of these tests load one.
|
||||
vi.mock("@sparkjsdev/spark", async () => {
|
||||
const { Object3D } = await import("three");
|
||||
return {
|
||||
SplatMesh: class extends Object3D {},
|
||||
SparkRenderer: class extends Object3D {},
|
||||
};
|
||||
});
|
||||
|
||||
// The Addons barrel pulls in a lottie canvas module jsdom cannot run; Scene
|
||||
// only needs these two names from it.
|
||||
vi.mock("three/examples/jsm/Addons.js", async () => {
|
||||
const { Mesh } = await import("three");
|
||||
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
|
||||
});
|
||||
|
||||
describe("Scene disposal", () => {
|
||||
describe("disposeObject", () => {
|
||||
it("frees the object's GPU resources", () => {
|
||||
const scene = makeScene();
|
||||
const geometry = new THREE.BoxGeometry();
|
||||
const onDispose = vi.fn();
|
||||
geometry.addEventListener("dispose", onDispose);
|
||||
|
||||
scene.disposeObject(new THREE.Mesh(geometry));
|
||||
|
||||
expect(onDispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops shader ticks for disposed water but keeps the rest", () => {
|
||||
const scene = makeScene();
|
||||
const water = waterLikeMesh();
|
||||
const otherWater = waterLikeMesh();
|
||||
scene.shader_objects = [water, otherWater] as never;
|
||||
const group = new THREE.Group();
|
||||
group.add(water);
|
||||
|
||||
scene.disposeObject(group);
|
||||
|
||||
expect(scene.shader_objects).toEqual([otherWater]);
|
||||
});
|
||||
|
||||
it("stops and forgets the video behind a video plane", () => {
|
||||
const scene = makeScene();
|
||||
const video = stubbedVideo();
|
||||
const otherVideo = stubbedVideo();
|
||||
scene.video_planes = [video, otherVideo];
|
||||
const plane = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(),
|
||||
new THREE.MeshBasicMaterial({ map: new THREE.VideoTexture(video) }),
|
||||
);
|
||||
|
||||
scene.disposeObject(plane);
|
||||
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(video.load).toHaveBeenCalled();
|
||||
expect(video.hasAttribute("src")).toBe(false);
|
||||
expect(scene.video_planes).toEqual([otherVideo]);
|
||||
expect(otherVideo.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes MMD meshes from the animation helper", () => {
|
||||
const scene = makeScene();
|
||||
const mmd = new THREE.SkinnedMesh();
|
||||
const other = new THREE.SkinnedMesh();
|
||||
const remove = vi.fn();
|
||||
scene.helper = { meshes: [mmd, other], remove } as never;
|
||||
|
||||
scene.disposeObject(mmd);
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
expect(remove).toHaveBeenCalledWith(mmd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeContents", () => {
|
||||
it("empties the scene and frees every child", () => {
|
||||
const scene = makeScene();
|
||||
const geometries = [new THREE.BoxGeometry(), new THREE.SphereGeometry()];
|
||||
const disposed = new Set<THREE.BufferGeometry>();
|
||||
for (const geometry of geometries) {
|
||||
geometry.addEventListener("dispose", () => disposed.add(geometry));
|
||||
scene.scene.add(new THREE.Mesh(geometry));
|
||||
}
|
||||
const child = scene.scene.children[0];
|
||||
scene.hot_items = [child];
|
||||
|
||||
scene.disposeContents();
|
||||
|
||||
expect(scene.scene.children).toHaveLength(0);
|
||||
expect(child.parent).toBeNull();
|
||||
expect(scene.hot_items).toEqual([]);
|
||||
expect(disposed).toEqual(new Set(geometries));
|
||||
});
|
||||
|
||||
it("leaves the background for the skybox to replace", () => {
|
||||
const scene = makeScene();
|
||||
const background = new THREE.CubeTexture();
|
||||
const onDispose = vi.fn();
|
||||
background.addEventListener("dispose", onDispose);
|
||||
scene.scene.background = background;
|
||||
|
||||
scene.disposeContents();
|
||||
|
||||
expect(scene.scene.background).toBe(background);
|
||||
expect(onDispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("disposeBackground frees a texture background", () => {
|
||||
const scene = makeScene();
|
||||
const background = new THREE.CubeTexture();
|
||||
const onDispose = vi.fn();
|
||||
background.addEventListener("dispose", onDispose);
|
||||
scene.scene.background = background;
|
||||
|
||||
scene.disposeBackground();
|
||||
|
||||
expect(onDispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function makeScene(): Scene {
|
||||
return new Scene("test", "::CAM::", () => undefined, 1, {
|
||||
getCameras: () => [],
|
||||
getSelectedCameraId: () => "main",
|
||||
fetchAsset: () => Promise.reject(new Error("no network in tests")),
|
||||
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
|
||||
});
|
||||
}
|
||||
|
||||
// Scene only tracks Water instances by identity; a plain mesh stands in.
|
||||
function waterLikeMesh(): THREE.Mesh {
|
||||
return new THREE.Mesh(new THREE.PlaneGeometry(), new THREE.MeshBasicMaterial());
|
||||
}
|
||||
|
||||
// jsdom does not implement media playback, so stub the calls stopVideo makes.
|
||||
function stubbedVideo(): HTMLVideoElement {
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.invalid/clip.mp4";
|
||||
video.pause = vi.fn();
|
||||
video.load = vi.fn();
|
||||
return video;
|
||||
}
|
||||
@@ -13,7 +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";
|
||||
import { collectObjectTextures, disposeObject3D } from "./disposeObject3D";
|
||||
|
||||
// Capabilities Scene needs from outside its own state. Editor wires
|
||||
// these in inline at construction (Phase 2 idiom — same shape as
|
||||
@@ -135,20 +135,55 @@ class Scene {
|
||||
}
|
||||
|
||||
clear() {
|
||||
// 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 = [];
|
||||
this.disposeContents();
|
||||
this._createGrid();
|
||||
this._create_base_lighting();
|
||||
this._create_skybox();
|
||||
this._create_camera_obj();
|
||||
}
|
||||
|
||||
// Remove everything from the scene and free its GPU resources. The
|
||||
// background is left in place: _create_skybox frees it when it is
|
||||
// replaced, and teardown frees it through disposeBackground.
|
||||
disposeContents() {
|
||||
this.disposeObject(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 = [];
|
||||
}
|
||||
|
||||
// Free an object tree that has left the scene for good: its GPU
|
||||
// resources, plus its entries in the per-object registries (Water
|
||||
// shaders ticked every frame, video elements, MMD animation).
|
||||
disposeObject(root: THREE.Object3D) {
|
||||
const inTree = new Set<THREE.Object3D>();
|
||||
root.traverse((child) => inTree.add(child));
|
||||
this.shader_objects = this.shader_objects.filter(
|
||||
(water) => !inTree.has(water),
|
||||
);
|
||||
for (const mesh of [...(this.helper?.meshes ?? [])]) {
|
||||
if (inTree.has(mesh)) this.helper.remove(mesh);
|
||||
}
|
||||
|
||||
const videos = new Set<HTMLVideoElement>();
|
||||
for (const texture of collectObjectTextures(root)) {
|
||||
if (texture instanceof THREE.VideoTexture) videos.add(texture.image);
|
||||
}
|
||||
for (const video of videos) stopVideo(video);
|
||||
this.video_planes = this.video_planes.filter(
|
||||
(video) => !videos.has(video),
|
||||
);
|
||||
|
||||
disposeObject3D(root);
|
||||
}
|
||||
|
||||
disposeBackground() {
|
||||
if (this.scene.background instanceof THREE.Texture) {
|
||||
this.scene.background.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async instantiate(
|
||||
name: string,
|
||||
pos: THREE.Vector3 = new THREE.Vector3(0, 0, 0),
|
||||
@@ -1125,9 +1160,7 @@ class Scene {
|
||||
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();
|
||||
}
|
||||
this.disposeBackground();
|
||||
|
||||
// Theme-aware: in light mode, use a near-white background for better contrast with UI
|
||||
try {
|
||||
@@ -1282,4 +1315,12 @@ class Scene {
|
||||
|
||||
}
|
||||
|
||||
// Pausing alone keeps the decoder and buffered media alive; dropping the
|
||||
// source and reloading is what releases them.
|
||||
function stopVideo(video: HTMLVideoElement) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
|
||||
export default Scene;
|
||||
|
||||
@@ -128,9 +128,9 @@ export class StoryTellerProxyScene {
|
||||
) {
|
||||
if (scene_json == null || this.scene == null) return;
|
||||
|
||||
while (this.scene.scene.children.length > 0) {
|
||||
this.scene.scene.remove(this.scene.scene.children[0]);
|
||||
}
|
||||
// Free the outgoing scene before loading over it; removing the
|
||||
// children alone left all of their GPU resources allocated.
|
||||
this.scene.disposeContents();
|
||||
|
||||
// Warm Scene's URL cache up front so per-asset loadObject() calls
|
||||
// never hit the network for token→URL resolution. One batch call
|
||||
|
||||
Reference in New Issue
Block a user