fix(raster-tileset): memoize tile bounding volumes across traversals#525
Conversation
Standalone cache for tile bounding volumes keyed by "z/x/y", with LRU recency and a swept soft cap. Not wired into the traversal yet. Refs #523. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getBoundingVolume consults an optional BoundingVolumeCache (passed via the RasterTileNode ctor / createRootTiles / getTileIndices opts) before doing the proj4 reprojections + OBB fit. Omitting the cache preserves the previous per-node-only behavior. getTileIndices sweeps the cache once at the top of each traversal. Refs #523. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each RasterTileset2D now owns a BoundingVolumeCache and passes it into getTileIndices, so animation-frame traversals reuse the proj4 reprojections + OBB fit instead of recomputing them every frame. New RasterTileset2DOptions.maxBoundingVolumeCacheSize tunes the cap. Fixes the per-frame getTileIndices regression in #523. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| /** | ||
| * A memoized tile bounding volume, tagged with the elevation range it was | ||
| * computed for (so a `zRange` change can invalidate it). | ||
| */ | ||
| export interface BoundingVolumeCacheEntry { | ||
| zRange: ZRange; | ||
| boundingVolume: OrientedBoundingBox; | ||
| commonSpaceBounds: Bounds; | ||
| } |
There was a problem hiding this comment.
Isn't ZRange a property of the data? Well, I guess looking at the TileRange.zRange docs, it is a property of the data but it's not necessarily known by the individual tiles?
| // Re-insert to move the key to the most-recently-used end of the Map. | ||
| this.entries.delete(key); | ||
| this.entries.set(key, entry); |
There was a problem hiding this comment.
Is deleting and re-inserting the easiest way to handle the LRU order?
| * the per-node `_boundingVolume` field, so bounding volumes survive across | ||
| * `getTileIndices` calls. Provided by `RasterTileset2D`. | ||
| */ | ||
| private boundingVolumeCache?: BoundingVolumeCache; |
There was a problem hiding this comment.
Why optional? What's the downside to making it always exist? We can default to a newly-created cache with default parameters if one isn't passed in.
| new RasterTileNode(x, y, childZ, { | ||
| descriptor, | ||
| boundingVolumeCache, | ||
| }), |
There was a problem hiding this comment.
Is this a little weird? Feels like the cache is a property of the node, rather than the nodes being a property of the cache?
| // one) is reused across getTileIndices calls / animation frames. Without a | ||
| // shared cache we fall back to the per-node `_boundingVolume` field (good | ||
| // for one traversal only), so standalone callers behave exactly as before. | ||
| if (boundingVolumeCache) { |
There was a problem hiding this comment.
If the bounding volume cache always exists, then we can simplify this if/else block
…aversal Per PR review: - The cache is a property of the traversal, not of a node — thread it through `update()`'s params (which already propagate down the recursion) instead of the `RasterTileNode` constructor / `children` getter / `createRootTiles`. - It always exists: `getTileIndices` uses a caller-supplied cache or a throwaway one. That lets `getBoundingVolume` drop its if/else and the now-redundant per-node `_boundingVolume` field (which never actually hit — `update` calls `getBoundingVolume` once per node). - Write `DEFAULT_MAX_ENTRIES` as `65_536`. Refs #523. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| getBoundingVolume( | ||
| zRange: ZRange, | ||
| project: ((xyz: number[]) => number[]) | null, | ||
| boundingVolumeCache: BoundingVolumeCache, | ||
| ): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds } { | ||
| const boundingVolumeCache = this.boundingVolumeCache; | ||
|
|
||
| // Cache lookup. A tile's bounding volume depends only on (z, x, y, zRange) | ||
| // for a given descriptor, so the shared cache (when the tileset provides | ||
| // one) is reused across getTileIndices calls / animation frames. Without a | ||
| // shared cache we fall back to the per-node `_boundingVolume` field (good | ||
| // for one traversal only), so standalone callers behave exactly as before. | ||
| if (boundingVolumeCache) { | ||
| const hit = boundingVolumeCache.get(this.z, this.x, this.y); | ||
| if (hit && hit.zRange[0] === zRange[0] && hit.zRange[1] === zRange[1]) { | ||
| return hit; | ||
| } | ||
| } else { | ||
| const cached = this._boundingVolume; | ||
| if ( | ||
| cached && | ||
| cached.zRange[0] === zRange[0] && | ||
| cached.zRange[1] === zRange[1] | ||
| ) { | ||
| return cached.result; | ||
| } | ||
| const hit = boundingVolumeCache.get(this.z, this.x, this.y); | ||
| if (hit && hit.zRange[0] === zRange[0] && hit.zRange[1] === zRange[1]) { | ||
| return hit; |
There was a problem hiding this comment.
Perhaps we should split getBoundingVolume and then a lower level computeBoundingVolume? getBoundingVolume checks the cache and returns if a hit was found. Otherwise it calls computeBoundingVolume and caches the result before returning it.
…lume Per PR review: getBoundingVolume is now just the cache wrapper (lookup -> hit? return : compute, store, return); the case dispatch (Globe assert, future mercator fast paths, generic Case 4) moves into a lower-level private computeBoundingVolume. Refs #523. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #523
This is a whole lot better than in #523. In particular we have almost no dropped frames in the usgs topo cutline example:
There's still more perf improvements we could do in the future, but this is still a big improvement.
What I am changing
Fixes the per-frame tile-traversal regression in #523. On a HiDPI display,
getTileIndices(the raster tile traversal) was taking ~12 ms/frame — visible as dropped frames in theusgs-topo-cutlineexample during the loadfitBoundstransition / panning.getTileIndicesruns once per animation frame and was recomputing every visited tile's bounding volume from scratch (≈9 proj4 reprojections + an oriented-bounding-box fit per tile), because nothing survived between calls. #513's switch to a device-pixel LOD criterion — correct, and kept — made this ~4× worse on 2× displays by descending one extra overview level over the viewport.How I did it
Memoize bounding volumes across
getTileIndicescalls. A tile's bounding volume depends only on(z, x, y, zRange)for a given tileset descriptor — frame-invariant — so it's safe to cache.BoundingVolumeCache(new, internal): an LRUMapkeyed"z/x/y", soft-capped (default 65 536 entries),sweep()ed once at the top of each traversal so a single frame is never starved of an entry it computed earlier that frame. It caches only visited tiles, populated lazily — for a single COG that's ~10³ entries total; the cap exists to bound huge single-level zarrs / long pan sessions.RasterTileset2Downs one cache for its lifetime (so it persists across animation frames) and threads it into the traversal. NewRasterTileset2DOptions.maxBoundingVolumeCacheSizetunes the cap; no new layer prop. Standalone callers ofgetTileIndicesthat don't pass a cache get a throwaway one (still dedups within a traversal).update()'s params (alongside the viewport / frustum / etc., which already propagate down the recursion), andgetBoundingVolume(zRange, project, cache)is now just a cache wrapper around a lower-levelcomputeBoundingVolumethat holds the case dispatch (Globe assert, future mercator fast-path slots, generic Case 4). The old per-node_boundingVolumefield is removed — it never actually hit (updatecallsgetBoundingVolumeonce per node).Steady-state per-frame traversal cost drops from
O(visited nodes) × (≈9 proj4 + OBB fit)toO(visited nodes) × (Map lookup + frustum cull + LOD compare)— well under a millisecond. No behavior change: the selected tile set for a given viewport /zRange/pixelRatiois identical.Design doc:
dev-docs/specs/2026-05-11-traversal-bounding-volume-cache-design.md.How you can test it
pnpm --filter @developmentseed/deck.gl-raster test— 93 tests pass, including newbounding-volume-cache.test.ts(LRU / sweep behavior),bounding-volume-cache-memoization.test.ts(aprojectTo3857-counting descriptor proves a secondgetTileIndicescall does zero reprojections), andraster-tileset-2d-cache.test.ts(cache reuse + correctness with a tiny / zero cap).usgs-topo-cutlineexample on a HiDPI display and profile in the Chrome DevTools performance panel — per-framegetTileIndicesis now sub-millisecond after the first frame, with no dropped frames during the loadfitBoundstransition / panning.Related Issues
Fixes #523.
Out of scope, tracked in #523 as follow-ups: an axis-aligned fast path in
computeBoundingVolumefor EPSG:4326 / mercator-family sources (cheaper cold compute), and removing the redundant double-wrap ofmakeClampedForwardTo3857.