rendering
The hemisphere that wasn't there: computed, cached, uploaded, drawn nowhere
Half the globe rendered as pure background, cut exactly at the 0-meridian z1 tile boundary. The fallback ancestors for the missing half were computed, CPU-cached, and force-uploaded to the GPU — then drawn nowhere, because a drape suppression flag was scoped to the renderer instance instead of one dispatch. Verified by making the network slow on purpose, after the first pixel readback lied.
By the X-GIS team 6 min read
The probe capture for #1076 shows a planet cut in half: the east hemisphere fully rendered, the west pure background colour, and the boundary not anywhere interesting — exactly the 0-meridian, which at z1 is a tile column edge. That edge is the tell. A projection or culling bug curves; a data-availability bug follows tile boundaries. The west half’s tiles were still streaming, and the engine was drawing nothing in their place.
Which should have been impossible, because standing in for missing tiles is
a machinery we’ve invested in deliberately: a pinned low-zoom skeleton
guarantees classifyFallback’s ancestor walk always finds a cached tile
(before it, fast-pan cleared the canvas to white); the walk computes
fallbackKeys per frame; the coarse parents were CPU-cached and
force-uploaded to the GPU. Every stage upstream of the draw call did its
job. The draw call never happened.
The narrowing observation
The raster basemap path never shows this bug. Its fallback is embarrassingly self-contained — inside its own draw loop, per missing tile, it just walks up:
// Parent fallback: walk up until we find a cached tileand renders whatever ancestor it finds (raster-renderer.ts:578-608). No
other subsystem can interfere, because nothing else participates.
The vector globe path broke only under a specific configuration: WebGPU, non-extruded layers, constant fill, no pattern — which sounds narrow until you notice it is the default land/water case, the configuration every plain basemap runs. That configuration is exactly when the #599 drape is active: instead of drawing each tile’s fill as direct ECEF chord geometry (a triangle spanning a big arc projects as a chord and cuts through the sphere), the drape bakes resident tiles’ fills into textures and drapes them onto the curved sphere grid. And with the drape off, fallback worked fine — coarse chords are the accepted globe behaviour there.
So: fallback fine without the feature, gone with it. The bug lives in the interaction.
Two flags with the wrong lifetime
When the drape owns a frame’s tiles, the renderer suppresses the direct chord draw so tiles don’t render twice:
const drawFills = phase !== 'strokes' && !this._drapeGlobeFills_drapeGlobeFills and _drapeStrokes are instance fields, set once per
render() when the drape takes over. But renderTileKeys — the dispatch
that reads them — is shared by two callers: the primary draw of the
needed tiles, and the fallback draw of the ancestors. The drape’s
renderGlobeFills only ever receives neededKeys; nobody ever hands it
fallbackKeys. So the flags said “the drape has this covered” to a dispatch
whose tiles the drape has never seen. The primary suppression was correct;
the fallback inherited it and silently dropped every ancestor draw.
Computed, classified, cached, uploaded — and suppressed at the very last hop, by a flag scoped to the renderer instead of to the one dispatch whose double-draw it prevents.
The fix is a scope, not a feature
const _fbSavedDrapeGlobeFills = this._drapeGlobeFillsconst _fbSavedDrapeStrokes = this._drapeStrokesthis._drapeGlobeFills = falsethis._drapeStrokes = falsetry { // … fallback dispatch: bundle-record arm + direct arm …} finally { this._drapeGlobeFills = _fbSavedDrapeGlobeFills this._drapeStrokes = _fbSavedDrapeStrokes}One subtlety earns the try its width: the fallback path can render through
a cached render bundle, and the bundle is encoded synchronously inside the
try — so the recorded bundle bakes the un-suppressed draws in. Had the
encode been deferred past the finally, the bundle would have recorded
suppressed (empty) draws once and replayed the blank hemisphere from cache
indefinitely. And no double-draw is possible: fallback chords are clipped by
clip_bounds to the missing-child areas, which are disjoint from the tiles
the drape baked.
Primary suppression is untouched. The regression test makes that literal: it
is a source gate pinning the dispatch region’s text — the clear precedes the
first renderTileKeys, the restore lives in a finally after the last one,
and there is exactly one this._drapeGlobeFills = false in the file, so
the primary path can never quietly gain a second. (Why a source gate: the
flag is a private field read deep inside render(), behind source, bind
groups, tile decisions, drape, and bundle cache; faking that whole pipeline
to test one boolean is a bigger lie than reading the source. Fail-before
verified against the stashed pre-fix file: 3 of 4 assertions fail on it.)
Verifying a bug that needs a slow network
Here the interesting part of the session started: on a fast connection the bug is invisible, because the west tiles land before you can screenshot the gap. To verify the fix we had to manufacture the failure — a Playwright route handler holding every z1–z3 west-column tile request for 20 seconds while the east column streamed normally. Before the fix: the probe’s blank west hemisphere, on demand, for 20 s. After: coarse z1 parents render immediately as chords, and refine as the delayed tiles arrive.
The verification nearly reported the fix broken anyway. The first readback
drew the canvas into a 2D context and read pixels — zeros. All zeros. On a
WebGPU canvas under Chromium, drawImage/getImageData readback yields
transparent pixels; our own e2e helpers carry the warning
(“page.screenshot() captures the rendered canvas correctly … reading back
via Canvas2D drawImage on a WebGPU canvas yields transparent pixels”). The
compositor screenshot is the ground truth; the in-page readback is not.
Had we trusted the readback, we’d have concluded the hemisphere was still
missing and un-fixed a correct fix.
What generalizes
A fallback that routes through another subsystem’s dispatch inherits every flag that subsystem will ever grow. The raster path has never had this bug and never will — its fallback never leaves home. The vector path’s fallback was born correct and lost correctness when an unrelated feature (the drape) added a suppression flag whose lifetime — the whole render — was wider than the decision it encoded, which was about the primary tiles only. State scoped wider than its decision is a bomb a later feature arms.
And availability code has a special verification burden: it only executes when something is missing, so a green run on a healthy network verifies nothing. You have to break the network on purpose — and then make sure the instrument watching the screen isn’t the thing that’s actually broken.
Proper drape-native fallback (baking the parent into the drape with UV windowing, so ancestors get the curved treatment too) remains open as a #599 follow-up; today’s coarse chords beat a blank hemisphere.
References
- “The unused binding that dropped the draw” — another draw that silently never happened, for a very different reason.
- “The pixel test that passed on the wrong GPU” — the companion readback lesson: pin which system produced the pixels you’re judging.
- “The map that downloaded the world” — the same skeleton machinery, seen from the cost side: what “always have an ancestor cached” was spending to be true.
Read next
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.
labels
7 min read
Three rounds to keep a label on the planet
A row of country labels floated in the sky above the globe's horizon. Round 1 — an angular margin, provably safe at every zoom — died because floaters and healthy labels interleave in angular depth (Nigeria 0.332 floats, Kenya 0.329 doesn't). Round 2's screen-space limb fixed Chad and missed two-line Burkina Faso by exactly one line of text. The gate only held once it moved to where the quad height actually exists.