Skip to content

fix(raster-tileset): memoize tile bounding volumes across traversals#525

Merged
kylebarron merged 6 commits into
mainfrom
kyle/tile-traversal-regression
May 11, 2026
Merged

fix(raster-tileset): memoize tile bounding volumes across traversals#525
kylebarron merged 6 commits into
mainfrom
kyle/tile-traversal-regression

Conversation

@kylebarron
Copy link
Copy Markdown
Member

@kylebarron kylebarron commented May 11, 2026

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:

image

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 the usgs-topo-cutline example during the load fitBounds transition / panning. getTileIndices runs 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 getTileIndices calls. 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 LRU Map keyed "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.
  • RasterTileset2D owns one cache for its lifetime (so it persists across animation frames) and threads it into the traversal. New RasterTileset2DOptions.maxBoundingVolumeCacheSize tunes the cap; no new layer prop. Standalone callers of getTileIndices that don't pass a cache get a throwaway one (still dedups within a traversal).
  • The cache flows through update()'s params (alongside the viewport / frustum / etc., which already propagate down the recursion), and getBoundingVolume(zRange, project, cache) is now just a cache wrapper around a lower-level computeBoundingVolume that holds the case dispatch (Globe assert, future mercator fast-path slots, generic Case 4). The old per-node _boundingVolume field is removed — it never actually hit (update calls getBoundingVolume once per node).

Steady-state per-frame traversal cost drops from O(visited nodes) × (≈9 proj4 + OBB fit) to O(visited nodes) × (Map lookup + frustum cull + LOD compare) — well under a millisecond. No behavior change: the selected tile set for a given viewport / zRange / pixelRatio is 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 new bounding-volume-cache.test.ts (LRU / sweep behavior), bounding-volume-cache-memoization.test.ts (a projectTo3857-counting descriptor proves a second getTileIndices call does zero reprojections), and raster-tileset-2d-cache.test.ts (cache reuse + correctness with a tiny / zero cap).
  • Manually: open the usgs-topo-cutline example on a HiDPI display and profile in the Chrome DevTools performance panel — per-frame getTileIndices is now sub-millisecond after the first frame, with no dropped frames during the load fitBounds transition / panning.

Related Issues

Fixes #523.

Out of scope, tracked in #523 as follow-ups: an axis-aligned fast path in computeBoundingVolume for EPSG:4326 / mercator-family sources (cheaper cold compute), and removing the redundant double-wrap of makeClampedForwardTo3857.

kylebarron and others added 3 commits May 11, 2026 15:27
Memoizes per-tile bounding volumes (proj4 reprojection + OBB construction)
across getTileIndices calls on the RasterTileset2D, eliminating the
per-animation-frame recomputation that #513's device-pixel LOD change
exposed (#523).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Comment thread packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts Outdated
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>
Comment on lines +5 to +13
/**
* 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;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +73 to +75
// Re-insert to move the key to the most-recently-used end of the Map.
this.entries.delete(key);
this.entries.set(key, entry);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +223 to +226
new RasterTileNode(x, y, childZ, {
descriptor,
boundingVolumeCache,
}),
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment on lines 387 to +394
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;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@kylebarron kylebarron changed the title Kyle/tile traversal regression fix(raster-tileset): memoize tile bounding volumes across traversals May 11, 2026
@github-actions github-actions Bot added the fix label May 11, 2026
@kylebarron kylebarron marked this pull request as ready for review May 11, 2026 20:14
@kylebarron kylebarron merged commit 6f2a009 into main May 11, 2026
5 of 7 checks passed
@kylebarron kylebarron deleted the kyle/tile-traversal-regression branch May 11, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tile traversal regression: getTileIndices ~12 ms/frame on HiDPI (no cross-frame memoization)

1 participant