Summary
SparkRenderer.dispose() does not dispose the geometry of its own THREE.Mesh, so 2 WebGL buffers leak per SparkRenderer instance. Applications that mount and unmount splat scenes repeatedly (route changes, toggling a render mode, opening/closing an editor) accumulate GPU buffers for the lifetime of the WebGL context.
Confirmed by a one-line change on the caller side: adding spark.geometry.dispose() after spark.dispose() takes the growth to exactly zero.
Environment
@sparkjsdev/spark 2.1.0
- three.js r180
- Chrome (stable, real GPU via
--use-gl=angle), macOS
Reproduction
Mount a SparkRenderer + SplatMesh, render one frame, tear both down with the public API, repeat. Live WebGL objects are counted by wrapping createBuffer/deleteBuffer on WebGL2RenderingContext.prototype before anything else runs, so an object created and never deleted stays counted.
for (let i = 0; i < CYCLES; i++) {
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const mesh = new SplatMesh({ fileBytes: bytes.slice(0), fileType });
await mesh.initialized;
scene.add(mesh);
renderer.render(scene, camera); // make sure everything is allocated + uploaded
await new Promise(requestAnimationFrame);
scene.remove(mesh);
mesh.dispose();
scene.remove(spark);
spark.dispose(); // <-- everything the public API offers
// spark.geometry.dispose(); // <-- uncomment and the leak disappears
}
Result
Live WebGL buffers, sampled after a forced GC each cycle (8 cycles, 500k-Gaussian .ply):
| teardown |
live buffers per cycle |
per cycle |
spark.dispose() |
4 → 6 → 8 → 10 → 12 → 14 → 16 → 18 |
+2 |
spark.dispose() + spark.geometry.dispose() |
2 → 2 → 2 → 2 → 2 → 2 → 2 → 2 |
0 |
Live textures are flat at 7 in both, so this is specific to the geometry's buffers.
Where they are allocated
Capturing a stack at each createBuffer and reporting the ones still live shows both leaked buffers come from three.js uploading a BufferGeometry's attributes, never freed because the geometry's dispose event never fires:
buffer (x10) createBuffer (three.module.js:71)
← WebGLAttributes.update (three.module.js:268)
← WebGLObjects.update (three.module.js:3886)
buffer (x8) createBuffer (three.module.js:71)
← WebGLAttributes.update (three.module.js:268)
← WebGLBindingStates.setup (three.module.js:1618)
BufferGeometry.dispose() is what makes three delete the attribute buffers and the VAO; nothing in SparkRenderer.dispose() calls it.
Suggested fix
Dispose the mesh's own geometry (and, if it is not shared, its material) in SparkRenderer.dispose():
dispose() {
// ... existing teardown
this.geometry.dispose();
}
Note on 0.1.x, for anyone landing here from an older version
On 0.1.10 this specific leak does not occur, because there is no SparkRenderer.dispose() and callers have to dispose spark.geometry by hand. That version instead leaks one WebGL texture per instance: SparkRenderer allocates a SplatAccumulator in its constructor whose PackedSplats owns a WebGLArrayRenderTarget, and SplatAccumulator has no dispose() at all — releaseAccumulator only decrements a refcount onto a free list. Reaching through spark.active.splats.dispose() takes it to flat. Both of those are fixed in 2.x by the new dispose() methods, so this is just context, not a request to patch 0.1.x.
Separate observation, not part of this report
With the geometry fix applied and live GL objects flat, performance.measureUserAgentSpecificMemory() still grows roughly linearly per mount/teardown cycle (single-digit MB per cycle at 500k Gaussians, scaling with splat count), entirely outside the V8 heap. I could not attribute that one confidently and it may already be covered by #237, #286 or #401 — mentioning it only so it is not assumed fixed by the change above.
Summary
SparkRenderer.dispose()does not dispose the geometry of its ownTHREE.Mesh, so 2 WebGL buffers leak perSparkRendererinstance. Applications that mount and unmount splat scenes repeatedly (route changes, toggling a render mode, opening/closing an editor) accumulate GPU buffers for the lifetime of the WebGL context.Confirmed by a one-line change on the caller side: adding
spark.geometry.dispose()afterspark.dispose()takes the growth to exactly zero.Environment
@sparkjsdev/spark2.1.0--use-gl=angle), macOSReproduction
Mount a
SparkRenderer+SplatMesh, render one frame, tear both down with the public API, repeat. Live WebGL objects are counted by wrappingcreateBuffer/deleteBufferonWebGL2RenderingContext.prototypebefore anything else runs, so an object created and never deleted stays counted.Result
Live WebGL buffers, sampled after a forced GC each cycle (8 cycles, 500k-Gaussian
.ply):spark.dispose()spark.dispose()+spark.geometry.dispose()Live textures are flat at 7 in both, so this is specific to the geometry's buffers.
Where they are allocated
Capturing a stack at each
createBufferand reporting the ones still live shows both leaked buffers come from three.js uploading aBufferGeometry's attributes, never freed because the geometry'sdisposeevent never fires:BufferGeometry.dispose()is what makes three delete the attribute buffers and the VAO; nothing inSparkRenderer.dispose()calls it.Suggested fix
Dispose the mesh's own geometry (and, if it is not shared, its material) in
SparkRenderer.dispose():Note on 0.1.x, for anyone landing here from an older version
On
0.1.10this specific leak does not occur, because there is noSparkRenderer.dispose()and callers have to disposespark.geometryby hand. That version instead leaks one WebGL texture per instance:SparkRendererallocates aSplatAccumulatorin its constructor whosePackedSplatsowns aWebGLArrayRenderTarget, andSplatAccumulatorhas nodispose()at all —releaseAccumulatoronly decrements a refcount onto a free list. Reaching throughspark.active.splats.dispose()takes it to flat. Both of those are fixed in 2.x by the newdispose()methods, so this is just context, not a request to patch 0.1.x.Separate observation, not part of this report
With the geometry fix applied and live GL objects flat,
performance.measureUserAgentSpecificMemory()still grows roughly linearly per mount/teardown cycle (single-digit MB per cycle at 500k Gaussians, scaling with splat count), entirely outside the V8 heap. I could not attribute that one confidently and it may already be covered by #237, #286 or #401 — mentioning it only so it is not assumed fixed by the change above.