From dc09c368229037e32d5f65fa6f754864e842c99c Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 6 Oct 2026 15:30:46 -0700 Subject: [PATCH] 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. --- .../src/lib/engine/disposeObject3D.spec.ts | 64 ++++++++++++++++++- .../src/lib/engine/disposeObject3D.ts | 44 ++++++++++++- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts index 5aa443d67d..0a1066c30e 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.spec.ts @@ -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()); diff --git a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts index af8b3d0321..269ee90a27 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/disposeObject3D.ts @@ -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(); 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; @@ -25,10 +32,41 @@ export function disposeObject3D(root: THREE.Object3D): void { } }); + const textures = new Set(); 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): 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); + } + } + } }