mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
pagescene: free shader-uniform textures and SparkRenderer buffers on dispose
disposeObject3D only looked at material properties, so textures held in ShaderMaterial uniforms leaked: Water's normal map and mirror texture, and Spark's splat textures. It now collects textures from both places, deduplicated so a texture referenced twice (ChromaKeyMaterial keeps its VideoTexture as a property and a uniform) is disposed once. The SparkRenderer that Spark adds to the scene on the first splat render owns a viewpoint with render targets and a pool of splat accumulators, none of which its material references. Free them when it is disposed. Water keeps its mirror render target in a closure, so its framebuffer and depth buffer remain out of reach; only the color texture is freed.
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@sparkjsdev/spark";
|
||||
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
|
||||
import { disposeObject3D } from "./disposeObject3D";
|
||||
|
||||
// A real SplatMesh needs Spark's WASM decoder; only `instanceof` and
|
||||
// dispose() matter here.
|
||||
// A real SplatMesh / SparkRenderer needs Spark's WASM decoder and a WebGL
|
||||
// renderer; only `instanceof` and the dispose() calls matter here.
|
||||
vi.mock("@sparkjsdev/spark", async () => {
|
||||
const { Object3D } = await import("three");
|
||||
const accumulator = () => ({ splats: { dispose: vi.fn() } });
|
||||
return {
|
||||
SplatMesh: class extends Object3D {
|
||||
dispose = vi.fn();
|
||||
},
|
||||
SparkRenderer: class extends Object3D {
|
||||
defaultView = { dispose: vi.fn() };
|
||||
active = accumulator();
|
||||
freeAccumulators = [accumulator(), accumulator()];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -80,6 +86,58 @@ describe("disposeObject3D", () => {
|
||||
expect(splat.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("frees a SparkRenderer's viewpoint and accumulators", () => {
|
||||
const spark = new SparkRenderer({} as never);
|
||||
const root = new THREE.Group();
|
||||
root.add(spark);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { freeAccumulators } = spark as any;
|
||||
|
||||
disposeObject3D(root);
|
||||
|
||||
expect(spark.defaultView.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(spark.active.splats.dispose).toHaveBeenCalledTimes(1);
|
||||
for (const accumulator of freeAccumulators) {
|
||||
expect(accumulator.splats.dispose).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("disposes textures held in ShaderMaterial uniforms", () => {
|
||||
const normals = new THREE.Texture();
|
||||
const mirror = new THREE.WebGLRenderTarget(4, 4).texture;
|
||||
const layers = [new THREE.Texture(), new THREE.Texture()];
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
normalSampler: { value: normals },
|
||||
mirrorSampler: { value: mirror },
|
||||
layerSamplers: { value: layers },
|
||||
size: { value: 1.0 },
|
||||
},
|
||||
});
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
|
||||
|
||||
const disposed = trackDisposals([normals, mirror, ...layers]);
|
||||
disposeObject3D(mesh);
|
||||
|
||||
expect(disposed).toEqual(new Set([normals, mirror, ...layers]));
|
||||
});
|
||||
|
||||
it("disposes a texture shared by a property and a uniform once", () => {
|
||||
const video = new THREE.Texture();
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: { map: { value: video } },
|
||||
});
|
||||
// ChromaKeyMaterial keeps its VideoTexture both here and in a uniform.
|
||||
(material as unknown as { texture: THREE.Texture }).texture = video;
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
|
||||
|
||||
const onDispose = vi.fn();
|
||||
video.addEventListener("dispose", onDispose);
|
||||
disposeObject3D(mesh);
|
||||
|
||||
expect(onDispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips objects without geometry or material", () => {
|
||||
const root = new THREE.Group();
|
||||
root.add(new THREE.DirectionalLight());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@sparkjsdev/spark";
|
||||
import { SparkRenderer, 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.
|
||||
//
|
||||
// Textures are found both as material properties (map, normalMap, ...) and
|
||||
// as ShaderMaterial uniforms (Water's normal and mirror samplers, Spark's
|
||||
// splat textures). Water keeps its mirror render target in a closure, so
|
||||
// only that target's color texture can be reached; its framebuffer and depth
|
||||
// buffer stay allocated.
|
||||
//
|
||||
// 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
|
||||
@@ -15,6 +21,7 @@ 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>;
|
||||
@@ -25,10 +32,41 @@ export function disposeObject3D(root: THREE.Object3D): void {
|
||||
}
|
||||
});
|
||||
|
||||
const textures = new Set<THREE.Texture>();
|
||||
for (const material of materials) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value instanceof THREE.Texture) value.dispose();
|
||||
collectTextures(Object.values(material), textures);
|
||||
if (material instanceof THREE.ShaderMaterial) {
|
||||
collectTextures(
|
||||
Object.values(material.uniforms).map((uniform) => uniform?.value),
|
||||
textures,
|
||||
);
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
for (const texture of textures) texture.dispose();
|
||||
}
|
||||
|
||||
// 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
|
||||
// goes first: disposing it returns its accumulators to the free list.
|
||||
function disposeSparkRenderer(spark: SparkRenderer): void {
|
||||
spark.defaultView.dispose();
|
||||
spark.active.splats.dispose();
|
||||
const { freeAccumulators } = spark as unknown as {
|
||||
freeAccumulators: SparkRenderer["active"][];
|
||||
};
|
||||
for (const accumulator of freeAccumulators) accumulator.splats.dispose();
|
||||
}
|
||||
|
||||
function collectTextures(values: unknown[], into: Set<THREE.Texture>): void {
|
||||
for (const value of values) {
|
||||
if (value instanceof THREE.Texture) {
|
||||
into.add(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item instanceof THREE.Texture) into.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user