Skip to content

fix(deck.gl-raster): Select each tile once in raster tile traversal - #689

Open
kylebarron wants to merge 2 commits into
mainfrom
kyle/select-each-tile-once
Open

kylebarron wants to merge 2 commits into
mainfrom
kyle/select-each-tile-once

Conversation

@kylebarron

Copy link
Copy Markdown
Member

Note

This PR was written by Claude (Claude Code), not by @kylebarron.

RasterTileset2D selects the same tile many times on aligned grids such as WebMercatorQuad.

Problem

Both crsBoundsToTileRange implementations (TileMatrixAdaptor and AffineTilesetLevel) floor the box's max edge, so a tile that only touches the box's right or bottom edge counts as overlapping. The traversal finds a tile's children by passing the tile's bounds to the next level's crsBoundsToTileRange. In an aligned pyramid (WebMercatorQuad, or COG overviews that exactly halve), each parent's right and bottom edges lie on child tile edges, so most parents also claim their neighbors' children:

const { levels } = new TileMatrixSetAdaptor(webMercatorQuad, projections);
const { topLeft, bottomRight } = levels[1]!.projectedTileCorners(0, 0);
levels[2]!.crsBoundsToTileRange(topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]);
// main:    { minCol: 0, maxCol: 2, minRow: 0, maxRow: 2 }
// this PR: { minCol: 0, maxCol: 1, minRow: 0, maxRow: 1 }

RasterTileNode.children builds a new node for every tile in the range, and each node is selected on its own, so getTileIndices returns a tile once per parent that claimed it. The extra children get extra children of their own, so it compounds down the pyramid. For a 512×512 WebMercatorViewport at lng/lat 0, where every level's tile edges meet:

TileMatrixSet zoom 3 zoom 10
titiler's WebMercatorQuad 48 indices for 9 tiles 286 for 9
OGC example WebMercatorQuad (rounded cellSizes) 64 for 9 3844 for 9

That view is the worst case for flat maps. With titiler's TileMatrixSet, ordinary flat views get about 2-10× as many indices as tiles (e.g. 110 for 20 over San Francisco at zoom 12). The OGC example's gets more (1215 for 20 there), and pitched views get far more. lonboard's RasterLayer and #469's titiler example go through this path.

What the repeats cost with deck.gl 9.4.0's Tileset2D:

  • No duplicate requests or draws. getTileData runs once per tile, and renderLayers iterates the unique tileset.tiles.

  • Cache size. selectedTiles keeps every repeat, and the default cache cap is 5 * selectedTiles.length: 1430 instead of 45 for the zoom 10 view above. onViewportLoad also receives the repeats.

  • Traversal time per getTileIndices call (warm cache, 1024×768 unless noted). Pitch 60 is lonboard's default maximum for map views.

    View main this PR
    lng/lat 0, zoom 10, 512×512, titiler's TMS 1.93 ms 0.08 ms
    same, OGC example TMS 15.2 ms 0.08 ms
    Berlin, zoom 16, flat 0.14 ms 0.09 ms
    Berlin, zoom 18, pitch 60 54.5 ms (26952 indices for 76 tiles) 0.19 ms
    lat 50, zoom 8, pitch 75, bearing 45 874 ms 3.3 ms
  • Overlapping tiles. Where the selected zoom level changes, a parent can recurse while its right or bottom neighbor is selected at the coarser level. The parent's extra child lies inside that neighbor and is selected too, so the same area is fetched and drawn at two resolutions. For example, at lat 40, zoom 2, pitch 45 that's 38 extra tiles.

The other two callers of crsBoundsToTileRange see the same rule. createRootTiles (the culled path for large root matrices) keeps roots that only touch the viewport. resolveSecondaryTiles (MultiCOGLayer) fetches up to 2×2 secondary tiles for a primary tile whose right or bottom edge lies on a secondary tile edge: 256 secondary fetches for 121 primary tiles on a Sentinel-2-like 10 m / 20 m pair, where 121 suffice.

Separately, when levels don't line up (COG overviews of odd sizes, e.g. 1373 → 2745 px), a child tile can straddle two parents and really is a child of both. The traversal selects it once through each, so even with exact ranges a 10980 px COG returns 16 indices for 9 tiles.

Changes

  • crsBoundsToTileRange leaves out tiles that only touch the box. A new internal helper, overlappingTileSpan, returns [floor(start + ε), ceil(end - ε) - 1] along one axis with ε = 1e-6 of a tile. Both implementations use it and keep their existing clamping. The RasterTilesetLevel docs now state the rule, and the two test doubles that compute ranges follow it.
  • getTileIndices returns each z/x/y once, keeping the first node, for levels that don't line up.

Across about 75 views and tilesets checked during the investigation (flat, pitched up to 75°, rotated, world copies, globe; both WebMercatorQuads, UTM31 through proj4; power-of-two, odd-size, 1:1 and rotated COG pyramids), the new selection had no repeats and was always a subset of the old one. No sampled screen point lost coverage: every tile it no longer selects lies under a coarser tile that is still selected. The timings above come from that investigation, on code with the same source logic as this PR.

Tests

  • New tile-children.test.ts:
    • Child ranges for both WebMercatorQuads (tile 1/0/0, and a sample of tiles at every zoom), a power-of-two COG pyramid, a 1:1 level pair (tile size doubles as cellSize halves), and a 10980 px COG whose coarser overviews don't line up. The last one checks that every child that really overlaps its parent is kept.
    • getTileIndices with both WebMercatorQuads in six views (flat at zoom 3 and 10, pitched and rotated, pitched across zoom levels, world copies, globe): no tile is selected twice or together with one of its ancestors.
    • getTileIndices on the 10980 px COG selects each tile once.
  • create-root-tiles.test.ts: roots that only touch the viewport are left out (6 roots instead of 12 on a 1° grid).
  • secondary-tile-resolver.test.ts: one 20 m tile for a 10 m tile whose edges lie on its edges, with real AffineTilesetLevels. The mock level used to count a tile that touches the box's left or top edge as overlapping, so the existing "spanning two secondary tiles" case only touched the first tile. The mock now follows the rule, and that case uses a secondary grid that the primary tile really straddles.

Every new test fails on main. With the four changed source files reverted (keeping the new helper), the three files give Tests 22 failed | 7 passed (29), and the 7 that pass are existing tests. For example:

  • tile 1/0/0: expected { minCol: +0, maxCol: 2, …(2) } to deeply equal { minCol: +0, maxCol: 1, …(2) }
  • titiler, zoom 10: expected [ '11/1024/1023', …(276) ] to deeply equal []

The non-aligned COG test fails against the first commit alone (expected 16 to be 9) and passes with the second.

  • pnpm --filter @developmentseed/deck.gl-raster test: 152/152 pass. typecheck passes (TypeScript 6.0.3; CI covers 7).
  • After rebuilding deck.gl-raster, pnpm --filter @developmentseed/deck.gl-geotiff test (33/33) and pnpm --filter @developmentseed/deck.gl-zarr test (16/16) pass.
  • pnpm exec biome ci . passes.

Notes

  • Behavior changes. MultiCOGLayer now fetches one secondary tile, not up to 2×2, for a primary tile whose right or bottom edge lies on a secondary tile edge. With linear filtering those edges now clamp to the texture edge, as the left and top edges always did. I haven't checked this visually. Root culling is also tighter.
  • Tolerance. Float error at aligned edges measured at most 3.3e-9 of a tile, and 1e-6 of a 4096 px tile is about 0.004 px. It doesn't absorb TileMatrixSets that round their cellSizes to a few digits. In the OGC CDB1GlobalGrid example (2^-14 listed as 6.10351562e-05), 2269 of 2879 parents per row at that level still claim a neighbor's column. The second commit keeps the output unique there, but the extra traversal remains. Deriving children with integer arithmetic when levels line up would fix that; I'd leave it for a follow-up.
  • Repeats are dropped from the output rather than avoided. Keeping one RasterTileNode per z/x/y within a traversal would also stop shared children in non-aligned pyramids from being traversed twice. I went with the smaller change.
  • Rotated or skewed affine grids don't benefit from the range change, because child ranges come from the bounding box of the parent's bounding box. The second commit keeps their output unique, but they still traverse extra nodes.
  • overlappingTileSpan is not exported. RasterTilesetLevel is public, so other implementations have to follow the documented rule on their own. Should the helper be exported?
  • Fixture. tile-children.test.ts reads the OGC WebMercatorQuad example from the morecantile spec submodule, as fix(deck.gl-raster): Infer missing TileMatrixSet bounds and honor extent #684's tests do. CI checks out submodules, but a clone without them fails these tests. If it should be inlined, it's probably worth doing for both PRs together.
  • fix(deck.gl-raster): Infer missing TileMatrixSet bounds and honor extent #684. Its raster-tileset-2d-extent.test.ts dedups getTileIndices output because "the traversal can return the same tile more than once". Whichever of the two PRs lands second can drop that and assert there are no repeats.
  • Not checked: rendering in a browser (memory use, blending of overlapping tiles below opacity 1), and whether repeated secondary fetches were deduplicated at the HTTP level.

Ref #523 (per-frame traversal cost, which these extra nodes inflate), Ref #684

🤖 Written by Claude Code

kylebarron and others added 2 commits October 1, 2026 20:03
…Range

Both implementations floored the max edge, so in an aligned pyramid
(WebMercatorQuad, or COG overviews that exactly halve) most parents also
claimed their right and bottom neighbors' children, and the traversal
selected those once per parent: getTileIndices returned 286 indices for 9
tiles at zoom 10 with titiler's WebMercatorQuad, and selected some
children inside coarser tiles it had also selected. Tiles that only touch
the box (within 1e-6 of a tile) are now left out, which also tightens root
culling and MultiCOGLayer's secondary tile lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When pyramid levels are not aligned (e.g. COG overviews of odd sizes), a
child tile can straddle two parents, and the traversal selects it once
through each: a 10980 px COG still returned 16 indices for 9 tiles. Keep
the first node for each z/x/y.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the fix label Oct 2, 2026

This branch has not been deployed

No deployments
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.

1 participant