Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions dev-docs/specs/2026-06-05-destroy-tile-textures-design.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions examples/aef-mosaic/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/dynamical-zarr-ecmwf/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions examples/land-cover/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions examples/naip-mosaic/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ export default function App() {
colormapIndex: colormapChoice.colormapIndex,
colormapReversed: colormapChoice.reversed,
}),
onTileUnload: (tile) => tile.content?.texture.destroy(),
signal,
});
},
Expand Down
1 change: 1 addition & 0 deletions examples/nldas-icechunk/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions examples/usgs-topo-cutline/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions examples/vermont-cog-comparison/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function makeCOGLayer(args: CogLayerArgs): COGLayer<TileTextureData> | 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
Expand Down
25 changes: 22 additions & 3 deletions packages/deck.gl-geotiff/src/cog-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,14 @@ 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";
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;
Expand Down Expand Up @@ -356,4 +354,25 @@ export class COGLayer<

return userFn as NonNullable<RasterTileLayerProps<DataT>["renderTile"]>;
}

protected override _onTileUnloadCallback(): RasterTileLayerProps<DataT>["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();
}
};
}
}
22 changes: 21 additions & 1 deletion packages/deck.gl-geotiff/src/multi-cog-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
MultiRasterTilesetDescriptor,
ProjectionFunction,
RasterModule,
RasterTileLayerProps,
RasterTilesetDescriptor,
RasterTilesetLevel,
RenderTileResult,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -621,6 +623,24 @@ export class MultiCOGLayer extends RasterTileLayer<
this._buildRenderResult(data);
}

protected override _onTileUnloadCallback(): RasterTileLayerProps<MultiTileResult>["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<MultiTileResult>,
data: MultiTileResult | null,
Expand Down
79 changes: 79 additions & 0 deletions packages/deck.gl-geotiff/tests/cog-layer.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
} {
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);
});
});
Loading
Loading