From 9efca3ef556e683346f97d38e2b9c31b99e8ec77 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 23 Oct 2026 18:43:34 -0400 Subject: [PATCH] fix(deck.gl-raster): stop leaking a WebGL vertex array per tile luma.gl's `Model.destroy()` (as of `@luma.gl/engine` 9.4.2) releases a model's pipeline, shaders, uniform store and geometry, but never destroys its vertex array. `SimpleMeshLayer` destroys its model when the layer is finalized and when it rebuilds the model (on a new mesh, or on a render pipeline change, which `MeshTextureLayer` signals as `extensionsChanged`), so every tile ever drawn leaked one WebGL vertex array object for the life of the context. `MeshTextureLayer` now destroys the vertex array of each model it releases: in `finalizeState`, and in `updateState` when `SimpleMeshLayer` replaced the model. It skips a vertex array that is already destroyed, so it stays correct once luma.gl destroys it itself and the workaround can be removed. Co-Authored-By: Claude Opus 5.5 --- .../src/mesh-layer/mesh-layer.ts | 34 ++++- .../tests/mesh-layer/mesh-layer.test.ts | 116 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/deck.gl-raster/tests/mesh-layer/mesh-layer.test.ts diff --git a/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts b/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts index 0a9f1452..be94325c 100644 --- a/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts +++ b/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts @@ -1,11 +1,12 @@ import type { DefaultProps, + LayerContext, TextureSource, UpdateParameters, } from "@deck.gl/core"; import type { SimpleMeshLayerProps } from "@deck.gl/mesh-layers"; import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; -import type { Texture } from "@luma.gl/core"; +import type { Texture, VertexArray } from "@luma.gl/core"; import type { ShaderModule } from "@luma.gl/shadertools"; import { CreateTexture } from "../gpu-modules/create-texture.js"; import type { RasterModule } from "../gpu-modules/types.js"; @@ -60,6 +61,19 @@ const defaultProps: DefaultProps< getPosition: { type: "accessor", value: [0, 0, 0] }, }; +/** + * Destroys the vertex array of a model that has been destroyed. + * + * luma.gl's `Model.destroy()` doesn't destroy the model's `vertexArray` (as of + * `@luma.gl/engine` 9.4.2), which leaks one WebGL vertex array object per + * model. Remove this once luma.gl destroys it itself. + */ +function destroyVertexArray(model: { vertexArray: VertexArray }): void { + if (!model.vertexArray.destroyed) { + model.vertexArray.destroy(); + } +} + /** * A specialized raster-rendering layer, spiritually based on deck.gl's * `SimpleMeshLayer` but with a narrower purpose: it draws **one** texture-mapped @@ -115,6 +129,10 @@ export class MeshTextureLayer extends SimpleMeshLayer< return [...imageModule, ...(renderPipeline ?? [])]; } + /** + * Rebuilds the model when the render pipeline changes, and destroys the + * vertex array of any model that {@link SimpleMeshLayer} replaces. + */ override updateState(params: UpdateParameters): void { // Ensure the SimpleMeshLayer rebuilds the model when the renderPipeline has // changed. @@ -124,7 +142,21 @@ export class MeshTextureLayer extends SimpleMeshLayer< params.changeFlags.extensionsChanged = true; } + const oldModel = this.state.model; super.updateState(params); + // SimpleMeshLayer destroys the model it replaces + if (oldModel && oldModel !== this.state.model) { + destroyVertexArray(oldModel); + } + } + + /** Destroys this layer's models along with their vertex arrays. */ + override finalizeState(context: LayerContext): void { + const models = this.getModels(); + super.finalizeState(context); + for (const model of models) { + destroyVertexArray(model); + } } /** Returns true if the render pipeline has changed between the old and new props. */ diff --git a/packages/deck.gl-raster/tests/mesh-layer/mesh-layer.test.ts b/packages/deck.gl-raster/tests/mesh-layer/mesh-layer.test.ts new file mode 100644 index 00000000..9dbf6434 --- /dev/null +++ b/packages/deck.gl-raster/tests/mesh-layer/mesh-layer.test.ts @@ -0,0 +1,116 @@ +import { LayerManager } from "@deck.gl/core"; +import type { Device } from "@luma.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; +import { describe, expect, it, vi } from "vitest"; +import type { MeshTextureLayerProps } from "../../src/mesh-layer/mesh-layer.js"; +import { MeshTextureLayer } from "../../src/mesh-layer/mesh-layer.js"; + +/** A luma.gl `VertexArray` stand-in that records being destroyed. */ +function makeVertexArray() { + const vertexArray = { + destroyed: false, + destroy: vi.fn(() => { + vertexArray.destroyed = true; + }), + }; + return vertexArray; +} + +/** + * A luma.gl `Model` stand-in. Like luma.gl 9.4.2's, its `destroy()` leaves its + * vertex array alive. + */ +function makeModel() { + return { + vertexArray: makeVertexArray(), + destroy: () => {}, + // Called by deck.gl as it updates the layer + setTopology: () => {}, + setAttributes: () => {}, + setConstantAttributes: () => {}, + shaderInputs: { setProps: () => {} }, + }; +} + +type FakeModel = ReturnType; + +/** {@link MeshTextureLayer} with stand-in models, which need no GPU. */ +class FakeModelMeshTextureLayer extends MeshTextureLayer { + static override layerName = "FakeModelMeshTextureLayer"; + + protected override getModel(): any { + return makeModel(); + } +} + +const MESH = { + attributes: { positions: { value: new Float32Array(9), size: 3 } }, +}; + +/** + * Render {@link MeshTextureLayer} through deck.gl's {@link LayerManager}. Each + * call of `render` renders a new layer instance with the given props, as apps + * do on every render, and returns the layer's model. `remove` renders no + * layers. + */ +function makeRenderer() { + // The GPU resources deck.gl creates for the layer besides its model + const device = { + createTexture: () => ({ delete: () => {} }), + createBuffer: () => ({ write: () => {}, delete: () => {} }), + } as unknown as Device; + const layerManager = new LayerManager(device, {}); + // Fail the test on layer lifecycle errors, which deck.gl catches + layerManager.setProps({ + onError: (error: Error) => { + throw error; + }, + }); + + const render = ( + props: Partial> = {}, + ): FakeModel => { + const layer = new FakeModelMeshTextureLayer({ + id: "mesh", + mesh: MESH, + renderPipeline: [], + ...props, + }); + layerManager.setLayers([layer]); + return layer.getModels()[0] as unknown as FakeModel; + }; + const remove = () => layerManager.setLayers([]); + return { render, remove }; +} + +describe("MeshTextureLayer vertex arrays", () => { + it("destroys its model's vertex array when the layer is removed", () => { + const { render, remove } = makeRenderer(); + const model = render(); + remove(); + expect(model.vertexArray.destroyed).toBe(true); + }); + + it.each([ + ["the mesh changes", { mesh: { ...MESH } }], + [ + "the render pipeline changes", + { renderPipeline: [{ module: { name: "filter" } as ShaderModule }] }, + ], + ])("destroys the old model's vertex array when %s", (_, newProps) => { + const { render } = makeRenderer(); + const oldModel = render(); + const newModel = render(newProps); + expect(oldModel.vertexArray.destroyed).toBe(true); + expect(newModel.vertexArray.destroyed).toBe(false); + }); + + it("destroys a vertex array only once when Model.destroy() also destroys it", () => { + const { render, remove } = makeRenderer(); + const model = render(); + // As luma.gl's Model.destroy() will once it destroys its vertex array + model.destroy = () => model.vertexArray.destroy(); + remove(); + expect(model.vertexArray.destroy).toHaveBeenCalledTimes(1); + }); +});