Tile Caches
The two caches every custom protocol sits on, and the ArrayBuffer detachment rule that governs both
Every custom protocol in this app sits on two caches, and one hard rule about buffer ownership. Getting the rule wrong produces a DataCloneError that only appears on the second view of a tile, which is a miserable thing to debug from first principles.
The two caches
sharedTileCache | tileResultCache | |
|---|---|---|
| Holds | decoded upstream DEM tiles (float arrays + validity mask) | finished protocol output (an ImageBitmap, or MVT bytes) |
| Keyed on | upstream tile URL + encoding | the full protocol URL |
| Lives in | lib/normal-derived-protocol.ts | lib/tile-result-cache.ts |
| Saves | the network fetch and the decode | the per-pixel computation |
| Budget | per-entry, by decoded size | 96 MB LRU |
They are complementary. sharedTileCache is why turning on Slope after Hillshade does not re-download anything. tileResultCache is why turning Slope back off and on again is instant.
Why the result cache exists
MapLibre releases a source's tiles when the layer using it goes visibility: none — which is what a sub-mode checkbox does — and when the source unmounts entirely, which is what the master viz-mode toggle does. Re-showing a mode therefore re-runs the whole per-pixel computation for every visible tile, even though the decoded upstream DEM is still sitting in sharedTileCache.
No network, but measured ~1.2 s of recompute for LRM over a z13 viewport. Caching the finished tile makes re-toggling near-instant.
It is gated by Cache computed viz-mode tiles in Settings (cacheVizTilesAtom, synced through setTileResultCacheEnabled). Turning it off also frees everything already held — the point of turning it off is reclaiming memory, not merely stopping new inserts.
Handing tiles to maplibre
Protocols return an ImageBitmap, not encoded bytes. maplibre supports this directly — its image request checks for one before falling back to decoding a buffer:
if (response.data instanceof HTMLImageElement || isImageBitmap(response.data)) {
// User using addProtocol can directly return HTMLImageElement/ImageBitmap type
onSuccess(response);
}Every protocol used to finish with OffscreenCanvas.convertToBlob({type: "image/png"}) instead, which was by far the most expensive thing any of them did — and entirely wasted, since maplibre decoded the PNG straight back to a bitmap. Measured over 15 runs on one 256×256 tile:
| median | |
|---|---|
convertToBlob → PNG | 99 ms (about half the runs ~1000 ms) |
createImageBitmap | 0.1 ms |
End to end that took quantized-mesh:// from ~1000 ms to 3–97 ms per tile and lerc:// from ~1100 ms to 66–233 ms, both now bounded by the network rather than by us. lib/tile-image.ts is the shared tail; it keeps the PNG path as a fallback for environments without createImageBitmap.
The rule: always clone before you store, always clone before you hand out
MapLibre transfers a protocol response's ArrayBuffer to its worker, which detaches it on this side. So:
- Handing out the cache's own retained buffer detaches the cache's copy too. The next hit on that key tries to transfer an already-detached buffer and throws
DataCloneError: ArrayBuffer ... already detached. - Storing the same object you are about to return has the same problem from the other direction: the buffer you kept is dead before the first hit can use it.
A bitmap is the same story from the other end: maplibre may close() what it is given, so the cache keeps its own clone and hands out a further clone on every hit. Bitmaps are also sized as width × height × 4 and close()d on eviction — their memory is invisible to the GC, so without that the 96 MB budget would be fiction.
withTileResultCache therefore clones on both sides:
const hit = lru.get(params.url)
if (hit) {
lru.delete(params.url); lru.set(params.url, hit) // refresh LRU recency
return { data: isBitmap(hit) ? await createImageBitmap(hit) : hit.slice() }
}
const result = await inner(params, abortController)
if (result?.data instanceof Uint8Array) put(params.url, result.data.slice())
else if (result?.data && isBitmap(result.data)) put(params.url, await createImageBitmap(result.data))
return resultThis applies to any cache you add that holds bytes destined for MapLibre, not just this one. If you are storing an ArrayBuffer or a Uint8Array that a protocol handler will return, clone it.
Wrapping a protocol
Registration in TerrainViewer.tsx is where the composition happens:
maplibregl.addProtocol('slope', withTileResultCache(slopeProtocol))
maplibregl.addProtocol('svf', withTileResultCache(withSlowTileStats('svf', svfProtocol)))Note the nesting on the second line: withSlowTileStats composes inside withTileResultCache, so it measures the real ray-marching cost rather than a cache hit. If it wrapped the outside, the statistics would flatter the expensive modes exactly as they got faster.
Two protocols are deliberately not wrapped:
cog-contour— its output is vector tiles produced in a dedicated worker, and the worker round trip already amortises; see PMTiles and COG Contours.pmtiles— it is a third-party handler doing range reads against an archive, with its own caching.
Correctness comes from the key, not from invalidation
There is no invalidation logic in either cache, and there does not need to be. buildProtocolUrl encodes the upstream template, the encoding, the tile size, every mode parameter and the tile coordinate into the protocol URL. Change the sun azimuth, the LRM kernel radius, the difference offset — it is a different key, so a stale tile is not served; the old entry simply ages out of the LRU.
This is worth preserving when you add a parameter to a mode: if it affects the output and it is not in the URL, you have introduced a stale-tile bug that no amount of cache-clearing will fix.
Inspecting it
In dev builds, window.__tileResultCacheStats() reports { enabled, entries, totalBytes, hits, misses }. It is exposed as a global deliberately — importing the module from the console gets a different HMR-versioned instance with its own empty cache.
What reuses what
A few places look like they should re-fetch and do not:
- The derived protocols (slope, aspect, curvature, TPI, LRM, SVF, openness, matcap, phong in raster mode) share one result cache, keyed by the upstream template and the parameters. Switching a mode off and on, or a second view asking for the same tile, is a cache hit.
- The horizon search behind SVF and openness reuses the decoded DEM neighbourhood rather than decoding each tile again per direction.
- Hypsometric "set from viewport" reads the min and max MapLibre already decoded for the raster-dem tiles on screen (
tile.demon the source's tile manager: the terrain source under 3D terrain, the hillshade source in 2D, where nothing draws from the terrain source). No network, no second decode. - The elevation picker asks
queryTerrainElevationin 3D, which samples the terrain already in memory. In 2D there is no terrain object, so it fetches the one tile under the point through the source's own protocol, which is again a cache hit when that tile is on screen. - The export, the 2D picker and the contours layer read the same client upstream as the viz modes (
useClientDemUpstream) and dispatch its template through the protocol registry, so their tiles land in the same result cache as the map's own. - One handler run per URL in flight. MapLibre asks each source for its own tiles and dedupes requests per source only, so a template carried by two sources (
terrainSourceandhillshadeSourcehold the same one) or two derived modes over one upstream can ask a protocol for the same URL at the same moment.withTileResultCachecoalesces those: the second caller waits for the first run and receives its own clone (sharedinwindow.__tileResultCacheStats()). Measured on the Mexico VRT in 3D it saves little between terrain and hillshade, because the terrain picks its tiles at its own zoom and the two seldom overlap; the saving is real for the derived modes and the export, which do request the exact tiles on screen. For plainhttpstemplates the browser's HTTP cache already coalesces identical in-flight GETs.
Why SVF and openness feel slower than the other derivatives
Slope, curvature and the rest need a one-pixel apron around a tile, so the padded grid of step 3 in the terrain analysis pipeline costs one extra ring of neighbours, usually already cached. Sky-view factor and openness search the horizon over a radius of tens of pixels, so every output tile needs the DEM well beyond its own footprint, and beyond the viewport for the tiles along its edge. Three things follow, none of them a device-pixel-ratio bug:
- Tiles are requested outside the view. They are the neighbours the horizon search reads, at the same zoom.
- A tile cannot paint until its whole neighbourhood has decoded, so the tiles of a view tend to appear together rather than one by one, and a pan that brings back tiles the cache still holds paints those first while the new edge waits for its neighbours.
- Zooming out repeats the burst at the coarser level, since the radius is in pixels and the neighbourhood is a fresh set of tiles.