diff --git a/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md b/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md new file mode 100644 index 00000000..9e3caf47 --- /dev/null +++ b/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md @@ -0,0 +1,131 @@ +# Destroy tile textures on eviction — Design + +- **Date:** 2026-06-05 +- **Issues:** [#591](https://github.com/developmentseed/deck.gl-raster/issues/591) +- **Status:** Proposed + +## Problem + +When a tile is evicted from deck.gl's `TileLayer` cache, the GPU texture(s) we +uploaded for that tile are never freed. A luma.gl `Texture` is a thin JS wrapper +around a WebGL texture handle that holds GPU memory; JS garbage collection will +eventually reclaim the *wrapper object* but does **not** deterministically free +the underlying GL resource — luma.gl requires an explicit `.destroy()`. deck.gl's +own [`onTileUnload` docs](https://deck.gl/docs/api-reference/geo-layers/tile-layer#ontileunload) +state that the caller owns anything returned from `getTileData`, and passing a +texture as a uniform/binding to a `RasterLayer` sublayer does **not** transfer +ownership — luma only auto-destroys textures it created itself. + +The result is a GPU-memory leak that accumulates as the user pans and zooms. + +`onTileUnload` is the correct lifecycle hook: it fires on **cache eviction**, not +when a tile merely scrolls off-screen. A tile that goes off-screen but stays in +cache is re-shown without re-fetching, so destroying on off-screen would be wrong. +Eviction is the precise moment a tile's textures are provably no longer needed. + +The leak exists in two places: + +1. **The library's own default pipelines.** `COGLayer`'s default render pipeline + creates a `texture` (+ optional `mask`) per tile + (`packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts`), and `MultiCOGLayer` + creates one texture per band (`packages/deck.gl-geotiff/src/multi-cog-layer.ts`). + Neither is ever destroyed. +2. **Examples with custom `getTileData`.** Several examples upload their own + textures via `device.createTexture()` and never free them. + +## Ownership model + +The fix follows a single principle: **whoever creates a texture frees it.** + +- The library creates textures only inside its own default pipelines. It should + destroy them — but *only* when its default pipeline actually ran. +- A user who supplies a custom `getTileData` (or `getn`) owns the tile data and + its textures, and is responsible for freeing them in their own `onTileUnload`. + The library must not reach into a user-shaped `tile.data` and assume a texture + lives at `.texture`. + +This keeps the library's behavior predictable: passing `getTileData` means "I own +this tile's resources." + +## Layer-by-layer + +| Layer | Creates textures? | Library cleanup? | +| --- | --- | --- | +| `RasterTileLayer` (base) | No default pipeline | None — returns user `onTileUnload` unchanged | +| `COGLayer` | Only when `!props.getTileData` | Destroy `tile.data.texture` + `tile.data.mask`, **only** when default pipeline ran | +| `MultiCOGLayer` | Always (no user-`getTileData` path) | Always destroy every `tile.data.bands[*].texture` | +| `ZarrLayer` | Never (requires user `getTileData`) | None | + +## Wiring + +Add a protected `_onTileUnloadCallback()` hook on `RasterTileLayer`, mirroring the +existing `_getTileDataCallback()` / `_renderTileCallback()` / `_tilesetDescriptor()` +override pattern. The base `renderLayers` passes its result to the inner +`TileLayer` in place of `this.props.onTileUnload`. + +- **Base `RasterTileLayer`**: returns `this.props.onTileUnload` unchanged. +- **`COGLayer`** overrides it: when `!this.props.getTileData`, compose a destroyer + of `tile.data.texture` + `tile.data.mask` with the user's `onTileUnload`; + otherwise return the user's `onTileUnload` untouched. +- **`MultiCOGLayer`** overrides it: always compose a destroyer that walks + `tile.data.bands.values()` and destroys each `BandTileData.texture`. +- **`ZarrLayer`**: no override; inherits the base behavior. + +Composition rules: + +- Library cleanup runs **after** the user's `onTileUnload`, so user code still + observes live textures if it inspects them. +- Destroyers guard each field with `instanceof Texture` before calling + `.destroy()`, so a same-named non-Texture field is never touched. +- luma.gl's `.destroy()` is idempotent, so composition is safe even if a user + callback also frees the same texture. + +The "library created the texture" condition for `COGLayer` is precisely +`!this.props.getTileData`: `_getTileDataCallback()` returns +`props.getTileData ?? state.defaultGetTileData`, so the default pipeline's textures +exist exactly when `props.getTileData` is absent. (A user who passes `getTileData` +but not `renderTile` still supplies the textures, so no cleanup — correct.) + +## Helper + +Library cleanup uses an **internal-only** helper (e.g. `destroyTileTextures`); it +is not added to any barrel export. Examples destroy their own textures with an +inline one-liner. This keeps the public API surface minimal — there is no external +helper to maintain or document. + +## Examples + +Covered by library cleanup with **zero edits**: + +- `cog-basic`, `titiler-cog`, `cog-globe`, `globe-view` — stock `COGLayer`. +- `sentinel-2` — `MultiCOGLayer`. + +Need their own `onTileUnload` (custom `getTileData`/`getn`, user-owned textures): + +- COG: `usgs-topo-cutline`, `vermont-cog-comparison`, `naip-mosaic` (inner + `COGLayer`), `land-cover` — destroy `tile.data.texture`. Shared colormap/filter + textures are module-level and intentionally left alone. +- Zarr: `dynamical-zarr-ecmwf`, `aef-mosaic`, `nldas-icechunk` — destroy + `tile.data.texture`. + +To audit during implementation: + +- `zarr-sentinel2-tci` returns `{ image }` (a CPU `ImageData`, GC-safe) rather than + a `Texture`. Where its render texture is created and whether *that* leaks is a + separate question; confirm before deciding whether it needs cleanup. + +## Testing + +- Unit-test each destroyer: destroys Texture-valued `texture`/`mask`; ignores + absent or non-Texture fields; walks the bands map and destroys every band + texture. +- Test `COGLayer` installs cleanup only when `getTileData` is absent, and composes + the destroyer with a user-supplied `onTileUnload` (both run). +- Test `MultiCOGLayer` always installs cleanup. + +## Out of scope + +- No opt-out prop (e.g. `_destroyTileTextures: false`). Nothing currently relies on + texture retention, and this is a `0.8` beta. An escape hatch can be added later + if a concrete need for retaining a `tile.data.texture` appears. +- No public texture-cleanup helper export (see Helper). diff --git a/examples/aef-mosaic/src/App.tsx b/examples/aef-mosaic/src/App.tsx index 264a6070..15a691ef 100644 --- a/examples/aef-mosaic/src/App.tsx +++ b/examples/aef-mosaic/src/App.tsx @@ -96,6 +96,7 @@ export default function App() { selection, getTileData, renderTile, + onTileUnload: (tile) => tile.content?.texture.destroy(), minZoom: MIN_ZOOM, // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain diff --git a/examples/dynamical-zarr-ecmwf/src/App.tsx b/examples/dynamical-zarr-ecmwf/src/App.tsx index 56541e3d..cef06e7a 100644 --- a/examples/dynamical-zarr-ecmwf/src/App.tsx +++ b/examples/dynamical-zarr-ecmwf/src/App.tsx @@ -215,6 +215,7 @@ export default function App() { selection, getTileData, renderTile, + onTileUnload: (tile) => tile.content?.texture.destroy(), // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain maxRequests: 20, diff --git a/examples/land-cover/src/App.tsx b/examples/land-cover/src/App.tsx index 50cfe46d..4bd94768 100644 --- a/examples/land-cover/src/App.tsx +++ b/examples/land-cover/src/App.tsx @@ -98,6 +98,7 @@ export default function App() { epsgResolver, getTileData, renderTile, + onTileUnload: (tile) => tile.content?.texture.destroy(), onGeoTIFFLoad: (tiff, options) => { setGeotiff(tiff); const { west, south, east, north } = options.geographicBounds; diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 6f0dc050..eee9a797 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -433,6 +433,7 @@ export default function App() { colormapIndex: colormapChoice.colormapIndex, colormapReversed: colormapChoice.reversed, }), + onTileUnload: (tile) => tile.content?.texture.destroy(), signal, }); }, diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index b6994f0f..5f87ec7a 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -132,6 +132,7 @@ export default function App() { rescaleMin, rescaleMax, }), + onTileUnload: (tile) => tile.content?.texture.destroy(), // Re-run renderTile on cached tiles when the colormap or rescale // range changes. updateTriggers: { diff --git a/examples/usgs-topo-cutline/src/App.tsx b/examples/usgs-topo-cutline/src/App.tsx index fe09d5fa..f8e38e52 100644 --- a/examples/usgs-topo-cutline/src/App.tsx +++ b/examples/usgs-topo-cutline/src/App.tsx @@ -182,6 +182,7 @@ export default function App() { geotiff: selected.url, getTileData, renderTile: (data) => renderTile(data, cutlineEnabled, selected.bbox), + onTileUnload: (tile) => tile.content?.texture.destroy(), onGeoTIFFLoad: (_tiff, options) => { const { west, south, east, north } = options.geographicBounds; mapRef.current?.fitBounds( diff --git a/examples/vermont-cog-comparison/src/App.tsx b/examples/vermont-cog-comparison/src/App.tsx index e68c340c..74af2f8b 100644 --- a/examples/vermont-cog-comparison/src/App.tsx +++ b/examples/vermont-cog-comparison/src/App.tsx @@ -148,6 +148,7 @@ function makeCOGLayer(args: CogLayerArgs): COGLayer | null { epsgResolver, getTileData: useGrayLoader ? getTileDataGray : getTileDataRGBA, renderTile, + onTileUnload: (tile) => tile.content?.texture.destroy(), extensions: [new ClipExtension()], // @ts-expect-error clipBounds + clipByInstance + beforeId are injected // by ClipExtension and @deck.gl/mapbox; LayerProps doesn't know about diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index bf68f090..92c268d1 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -21,7 +21,7 @@ import { metersPerUnit, parseWkt, } from "@developmentseed/proj"; -import type { Texture } from "@luma.gl/core"; +import { Texture } from "@luma.gl/core"; import proj4 from "proj4"; import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; @@ -29,8 +29,6 @@ import type { TextureDataT } from "./geotiff/render-pipeline.js"; import { inferRenderPipeline } from "./geotiff/render-pipeline.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; -export type { MinimalTileData } from "@developmentseed/deck.gl-raster"; - type DefaultDataT = MinimalTileData & { texture: Texture; byteLength: number; @@ -356,4 +354,25 @@ export class COGLayer< return userFn as NonNullable["renderTile"]>; } + + protected override _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + const onTileUnload = this.props.onTileUnload; + // A user-supplied `getTileData` owns its own textures and is responsible for + // freeing them. Only destroy textures the default pipeline created. + if (this.props.getTileData) { + return onTileUnload; + } + return (tile) => { + onTileUnload?.(tile); + // `DataT` is generic here (no `texture`/`mask` on `MinimalTileData`), but + // the default pipeline always returns a `TextureDataT`. + const data = tile.content as TextureDataT | null; + if (data?.texture instanceof Texture) { + data?.texture.destroy(); + } + if (data?.mask instanceof Texture) { + data?.mask.destroy(); + } + }; + } } diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 8472054e..7b50a574 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -16,6 +16,7 @@ import type { MultiRasterTilesetDescriptor, ProjectionFunction, RasterModule, + RasterTileLayerProps, RasterTilesetDescriptor, RasterTilesetLevel, RenderTileResult, @@ -47,7 +48,8 @@ import { metersPerUnit, parseWkt, } from "@developmentseed/proj"; -import type { Device, Texture, TextureFormat } from "@luma.gl/core"; +import type { Device, TextureFormat } from "@luma.gl/core"; +import { Texture } from "@luma.gl/core"; import proj4 from "proj4"; import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; @@ -621,6 +623,24 @@ export class MultiCOGLayer extends RasterTileLayer< this._buildRenderResult(data); } + protected override _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + const onTileUnload = this.props.onTileUnload; + // MultiCOGLayer always creates the band textures itself (there is no + // user-supplied `getTileData`), so it always frees them. + return (tile) => { + onTileUnload?.(tile); + const data = tile.content; + if (!data) { + return; + } + for (const band of data.bands.values()) { + if (band.texture instanceof Texture) { + band.texture.destroy(); + } + } + }; + } + protected override _renderDebug( tile: Tile2DHeader, data: MultiTileResult | null, diff --git a/packages/deck.gl-geotiff/tests/cog-layer.test.ts b/packages/deck.gl-geotiff/tests/cog-layer.test.ts new file mode 100644 index 00000000..2b951866 --- /dev/null +++ b/packages/deck.gl-geotiff/tests/cog-layer.test.ts @@ -0,0 +1,79 @@ +import { Texture } from "@luma.gl/core"; +import { describe, expect, it, vi } from "vitest"; +import { COGLayer } from "../src/cog-layer.js"; + +function fakeTexture(): { + texture: Texture; + destroy: ReturnType; +} { + const destroy = vi.fn(); + const texture = Object.create(Texture.prototype) as Texture; + Object.defineProperty(texture, "destroy", { value: destroy }); + return { texture, destroy }; +} + +/** Expose the protected `_onTileUnloadCallback` for testing. */ +function unloadCallback(layer: COGLayer) { + return ( + layer as unknown as { + _onTileUnloadCallback: () => + | ((tile: { content: unknown }) => void) + | undefined; + } + )._onTileUnloadCallback(); +} + +describe("COGLayer._onTileUnloadCallback", () => { + it("destroys texture + mask and calls the user callback when no getTileData", () => { + const userCalls: unknown[] = []; + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + onTileUnload: (tile: unknown) => userCalls.push(tile), + } as never); + + const cb = unloadCallback(layer); + expect(cb).toBeTypeOf("function"); + + const texture = fakeTexture(); + const mask = fakeTexture(); + const tile = { content: { texture: texture.texture, mask: mask.texture } }; + cb?.(tile); + + expect(texture.destroy).toHaveBeenCalledOnce(); + expect(mask.destroy).toHaveBeenCalledOnce(); + expect(userCalls).toEqual([tile]); + }); + + it("tolerates a tile with no data and a missing mask", () => { + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + } as never); + const cb = unloadCallback(layer); + + expect(() => cb?.({ content: null })).not.toThrow(); + + const texture = fakeTexture(); + cb?.({ content: { texture: texture.texture } }); + expect(texture.destroy).toHaveBeenCalledOnce(); + }); + + it("returns the user onTileUnload unchanged when getTileData is supplied", () => { + const onTileUnload = () => {}; + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + getTileData: async () => ({ + texture: fakeTexture().texture, + width: 1, + height: 1, + byteLength: 4, + }), + renderTile: () => ({ renderPipeline: [] }), + onTileUnload, + } as never); + + expect(unloadCallback(layer)).toBe(onTileUnload); + }); +}); diff --git a/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts new file mode 100644 index 00000000..bc7317ee --- /dev/null +++ b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts @@ -0,0 +1,56 @@ +import { Texture } from "@luma.gl/core"; +import { describe, expect, it, vi } from "vitest"; +import { MultiCOGLayer } from "../src/multi-cog-layer.js"; + +function fakeTexture(): { + texture: Texture; + destroy: ReturnType; +} { + const destroy = vi.fn(); + const texture = Object.create(Texture.prototype) as Texture; + Object.defineProperty(texture, "destroy", { value: destroy }); + return { texture, destroy }; +} + +function unloadCallback(layer: MultiCOGLayer) { + return ( + layer as unknown as { + _onTileUnloadCallback: () => + | ((tile: { content: unknown }) => void) + | undefined; + } + )._onTileUnloadCallback(); +} + +describe("MultiCOGLayer._onTileUnloadCallback", () => { + it("destroys every band texture and calls the user callback", () => { + const userCalls: unknown[] = []; + const layer = new MultiCOGLayer({ + id: "multi", + sources: {}, + onTileUnload: (tile: unknown) => userCalls.push(tile), + } as never); + + const cb = unloadCallback(layer); + expect(cb).toBeTypeOf("function"); + + const texA = fakeTexture(); + const texB = fakeTexture(); + const bands = new Map([ + ["a", { texture: texA.texture }], + ["b", { texture: texB.texture }], + ]); + const tile = { content: { bands } }; + cb?.(tile); + + expect(texA.destroy).toHaveBeenCalledOnce(); + expect(texB.destroy).toHaveBeenCalledOnce(); + expect(userCalls).toEqual([tile]); + }); + + it("tolerates a tile with no data", () => { + const layer = new MultiCOGLayer({ id: "multi", sources: {} } as never); + const cb = unloadCallback(layer); + expect(() => cb?.({ content: null })).not.toThrow(); + }); +}); diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 8981f843..1f487fae 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -62,7 +62,7 @@ export type RasterTileLayerProps< DataT extends MinimalTileData = MinimalTileData, > = CompositeLayerProps & Pick< - TileLayerProps, + TileLayerProps, | "debounceTime" | "extent" | "maxCacheByteSize" @@ -225,6 +225,17 @@ export class RasterTileLayer< return (this.props as unknown as RasterTileLayerProps).renderTile; } + /** + * The currently effective tile-unload callback. + * + * Subclasses override this to destroy GPU textures their default pipeline + * created before delegating to the user's `onTileUnload`. The base layer owns + * no textures, so it returns the user's callback unchanged. + */ + protected _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + return this.props.onTileUnload; + } + /** * Hook for rendering per-tile debug overlay sub-layers. * @@ -314,7 +325,6 @@ export class RasterTileLayer< updateTriggers, onTileError, onTileLoad, - onTileUnload, onViewportLoad, } = this.props; @@ -348,7 +358,7 @@ export class RasterTileLayer< refinementStrategy, onTileError, onTileLoad, - onTileUnload, + onTileUnload: this._onTileUnloadCallback(), onViewportLoad, }); } diff --git a/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts b/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts index 0b03cb08..925b7516 100644 --- a/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts +++ b/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts @@ -31,4 +31,19 @@ describe("RasterTileLayer", () => { const layer = new ProbeLayer({ id: "probe" }); expect(layer.callDebug({ id: "x" }, null)).toEqual([]); }); + + it("base _onTileUnloadCallback returns the user onTileUnload unchanged", () => { + const onTileUnload = () => {}; + class ProbeLayer extends RasterTileLayer { + callUnload() { + return ( + this as unknown as { + _onTileUnloadCallback: () => unknown; + } + )._onTileUnloadCallback(); + } + } + const layer = new ProbeLayer({ id: "probe", onTileUnload }); + expect(layer.callUnload()).toBe(onTileUnload); + }); });