X-GIS
Blog

metadata

The cull we didn't dare add was already safe

Two engines rendered the same style differently because one honoured a metadata field the other deliberately skipped. The comment defending the skip described a danger an existing clamp had already neutralized — and the "working" half of the cull turned out to be silently keyed past its own lookup.

By the X-GIS team 4 min read

Zoom into Australia on the MapLibre demotiles style and, at z5, MapLibre drops the Tropic of Capricorn — the dashed reference line and its label vanish. Our engine kept drawing both forever. Nobody’s projection math differed; the divergence was one TileJSON field. The style’s geolines source-layer declares vector_layers maxzoom 4 while the source maxzoom is 6, so the native z5/z6 tiles simply omit that layer. MapLibre renders the empty native slice — lines gone. We ignored per-layer maxzoom and over-zoomed the z4 data indefinitely — lines forever.

Parity bugs between two consumers of the same data are usually not two algorithms disagreeing; they are one metadata field honoured asymmetrically.

The wrong first move

The skip wasn’t an oversight. The code defended it, at length:

// `maxzoom` is intentionally NOT used as a cull bound — it's
// a SOFT bound on raw archive data, but sub-tile generation
// continues to upscale the maxzoom data for over-zoom views.
// Culling on maxzoom would freeze rendering past z=15 on
// protomaps v4 (every layer reports maxzoom=15), defeating the
// whole over-zoom pipeline.

The fear was legitimate: on a basemap where every layer reports maxzoom 15, a naive currentZ > layer.maxzoom cull would blank the entire map past z15. So the field stayed half-honoured — minzoom culled, maxzoom “intentionally NOT” — and the comment sat there as the reason no one touched it.

But the danger had an unstated precondition, and the precondition no longer held. currentZ — the resolved native tile level — is already clamped to the source maxLevel before this code runs. On protomaps, currentZ can never exceed 15, so currentZ > 15 can never fire; over-zoom is untouched. The comparison fires only when a layer’s data-max is strictly below the source max — demotiles’ 4 < 6 — which is precisely the case where the native tile exists and omits the layer, i.e. precisely what MapLibre renders. The comment’s scenario was unreachable, and had been since the clamp existed.

What actually happened

The cull became a symmetric band check, with the safety argument stated as a reachability condition rather than a warning:

export function sliceOutsideDataZoomRange(
range: { minzoom: number; maxzoom: number } | null | undefined,
nativeZ: number,
): boolean {
if (!range) return false
return nativeZ < range.minzoom || nativeZ > range.maxzoom
}

Wiring it exposed a second, quieter bug in the half that everyone believed worked. Layers with a style filter get a slice key of the form sourceLayer::<hash> — but the metadata lookup keys on the bare source-layer name. The demotiles geolines layers all carry a filter, so the existing minzoom cull had been a silent no-op on them the whole time: a lookup that returns undefined and a guard that treats undefined as “always present.” The suffix is now stripped before the lookup, which means the minzoom cull is enforced on filtered layers for the first time — a latent behaviour change we flagged for the visual pass rather than assumed harmless.

How we know it holds

Three regression points, one per stakeholder in that old comment. The demotiles case: at z5 the geolines slice must produce no selection — reverting only the cull fails with a nine-tile Selection where null was expected, the exact pre-fix behaviour. The boundary: z4 (at the layer’s declared max) still renders — the band is inclusive, matching MapLibre. The feared case: protomaps roads at z18 are not culled, pinning that the clamp makes the over-zoom scenario unreachable. The converter side is pinned too — the style’s own maxzoom (24) passes through untouched, so the cull decision stays owned by the tile metadata, not the style. On-screen confirmation against MapLibre remains on the checklist for a GPU machine; the decision function and both call sites are what these tests prove.

What generalizes

“Intentionally NOT” comments have an expiry condition that nobody re-checks, because the comment reads as a conclusion rather than a claim. This one was protecting against a case an existing clamp had made unreachable — the reason not to fix had expired while the comment kept guarding it. When you find one, extract its precondition and test it; if the precondition holds structurally, encode it in the code path (or a pinned regression) and retire the prose. And distrust the half of a guard that “already works”: the working minzoom cull here had been no-oping on every filtered layer, failing in the only direction nobody screenshots — rendering more than it should.

Read next

verification

5 min read

Verified only where the bug showed: a hot-path fix that shipped a zoom-in regression

A WebGL2 flicker fix (#1139) was render-parity-verified on exactly one scenario — the zoom-OUT hold-tiles case that reproduced the original flicker, retained-content 0.19 → 0.79 matching WebGPU — and never on zoom-in or per-frame cost. It shipped an unmemoized per-frame classifyTile run twice per tile plus synchronous uploads, and stuttered with progressively-black fills on a Seoul zoom-in. Reverted 36 minutes later (#1140). A hot-path fix must be verified in its worst regime, not the one that showed the original bug.

rendering

7 min read

Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race

A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.

rendering

7 min read

The hemisphere that was never selected: a fast-path bbox that collapsed to a point

Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.