rendering
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.
By the X-GIS team 7 min read
A user pans the globe to the antimeridian — hash
#2.70/29.61151/179.66439/0.0/21.4, sub-camera longitude 179.66° — imports
the OpenFreeMap planet, and half the world is missing. Japan, the
Philippines, Papua New Guinea render on the left; the right half is flat
cream background, and the boundary is a hard vertical seam straight down the
middle of the disc.
If that reads like a rerun, it should. Two days earlier the same engine
produced a planet cut in
half, and that post’s
diagnostic tell applies verbatim here: a projection or culling bug curves;
a data-availability bug follows tile boundaries. The seam sits exactly on a
tile-column edge — the antimeridian is the x=0 boundary at every zoom — so
this is availability, not geometry. The question is which availability bug,
because the last one was a trap that cost a full session.
Two discriminators rule out the previous bug
The #1076 hemisphere bug
was a draw failure: the missing half’s fallback ancestors were computed,
CPU-cached, and force-uploaded to the GPU, then suppressed at the last hop by
a drape flag (_drapeGlobeFills) scoped to the renderer instead of to one
dispatch. The tiles were there; the draw call never fired.
Two things say this is not that:
- The seam is on the other meridian. #1076 cut at the 0-meridian while the west column streamed. This cuts at the antimeridian, 180° away.
- It persists. #1076 was transient — it healed the moment the delayed tiles arrived. This one is pumped through forty frames over six seconds of convergence and never heals. Nothing is en route to fill the gap.
Persistence points upstream of the draw, upstream of the upload, all the way
to selection. So instrument the one function whose job is to answer “which
tiles?” — globeVisibleTiles — and read its raw return before any world-copy
fan-out:
before: z2 x∈{2,3} (lon 0..180 — the east/front column only)after: z2 x∈{0,3} (both sides of the dateline)Loaded tiles: 31 → 50. The selector was never returning the far half. This is a selection bug, a different function in a different package from #1076’s renderer flag. Same symptom, opposite layer.
A fast-path that fires in the wrong regime
globeVisibleTiles normally descends the mercator tile pyramid in continuous
sphere space, which wraps the dateline by construction. But it has an
overzoom fast-path, added so that when the camera zooms past the data’s max
level — where a single tile projects larger than the whole viewport and the
5-sample descent cull collapses to ~1 tile — it can bypass the descent:
unproject the nine viewport probes (corners, edges, centre) onto the sphere,
take the lon/lat bounding box, and emit every tile covering it.
It enters on zoom > maxZ. That test is the bug. On the globe maxZ is the
current integer render level, floor(cameraZoom), so zoom > maxZ is true
for any fractional zoom — including low zoom, where the globe is a small
disc floating in background, nothing like the “one tile fills the viewport”
regime the fast-path was written for.
And on a small disc the probe math quietly degenerates. Seven of the nine probes are corners and edges; on a disc smaller than the viewport they miss the sphere entirely and return null. Only the two centre-ish probes hit — both at the sub-camera longitude, 179.66°. The bounding box collapses to a point:
ozHits: 2 ozLonMin: 179.66 ozLonMax: 179.66A collapsed box has zero longitude span, so it sails through the guard meant
to catch the opposite failure — lonMax - lonMin <= 170, there to reject a
box so wide it straddles the limb. The path then maps that single point to one
tile column (x=3, plus a ±1 pad → x∈{2,3}) and emits it. Everything else
on the visible hemisphere is dropped.
At any other longitude you would not notice: the front hemisphere is roughly
contiguous in tile-x, so a padded column near the centre covers most of what
matters and the far edge that gets dropped is back-facing anyway. The
antimeridian is the one place where the visible region wraps the x=0
seam — front tiles live at both x≈0 and x≈2^z−1, which are maximally far
apart in x. A contiguous [x0..x1] range physically cannot contain both ends.
The collapsed fast-path emits the near column and the wrap-around half has no
representation at all.
The fix is a precondition, not a special case
The fast-path’s bounding box is only meaningful when the viewport is entirely on the sphere — the deep zoom-in regime it was built for, where all nine probes hit. So gate on exactly that:
if (hits === probes.length && lonMax - lonMin <= 170 && latMax - latMin <= 170) {When the disc is smaller than the viewport, some probes miss, the guard fails, and selection falls through to the descent — which reasons in continuous sphere space and keeps both sides of the dateline by construction. It is the correct selector whenever the disc does not fill the frame, which at low zoom is always. Nothing else moves: a deep-zoom view sitting exactly on the antimeridian still has all nine probes hit but a ~360° span, so the existing span guard routes it to the descent too; a grazing-limb high-pitch view likewise. The fast-path keeps firing only where it was ever valid.
The regression test pins the regime the old tests missed. The existing
dateline test ran at zoom < maxZ — the descent path, which was always
correct — so the overzoom path had no coverage at all. The new one runs the
user’s camera (zoom > maxZ, antimeridian, small disc) and asserts tiles on
both sides; it fails on the pre-fix hits > 0 guard and passes on
hits === probes.length.
Verifying without re-learning the readback lesson
The fix is a change in a pure function, so it is proven at that layer. But
“half the globe” is a claim about pixels, and the sibling bug left a landmine
in exactly this verification: on a WebGPU canvas under Chromium, reading the
frame back through a Canvas2D getImageData yields transparent pixels — all
zeros — and would report the fix broken. The compositor screenshot is ground
truth; the in-page readback is not. So the check used page.screenshot() on a
headless SwiftShader run, never a readback.
Before versus after, 860×720, forty pumped frames: the directional diff changed 30.1% of the frame, entirely in the previously-blank half (186,644 changed pixels on the right, 23 on the left). Read at full resolution, the before frame is the reported bug — a hard seam at screen centre, right half solid cream — and the after frame renders the North Pacific, Bering Sea, and Gulf of Alaska continuously across the antimeridian with no seam. The half that wasn’t there is there.
What generalizes
“Half the globe is background, cut on a tile line” is a symptom with (at least) two root causes at two layers. One is a draw suppressed by state scoped wider than its decision; the other is a selection that never named the tiles. The seam’s meridian and whether it heals tell them apart before you open a single file — a cheap triage the corpus now supports.
A fast-path is a promise about a regime, and it must guard the regime, not a
proxy for it. zoom > maxZ was a proxy for “one tile is bigger than the
viewport,” and it aliased a second, opposite situation — a small disc at
fractional zoom — into the same branch. The honest precondition (the viewport
is entirely on the sphere: every probe hits) names the regime directly, so the
branch cannot fire outside it.
A degenerate primitive slips through a guard built for the other extreme. The span guard was written to reject boxes that are too wide. A box collapsed to a point is as far from that failure as possible, so it passes — and is just as wrong, in the other direction. When a guard bounds one end of a range, ask what happens at zero.
References
- “The hemisphere that wasn’t there: computed, cached, uploaded, drawn nowhere” — the sibling bug: identical symptom, a suppressed draw rather than an empty selection. Read together they are a triage table.
- “The map that downloaded the world” — the same selection machinery seen from the cost side; what “always have a tile” spends to be true.
- “Two renderers, one truth” — four globe defects, none in shared code; on trusting the backend that renders correctly and pinning the one that drifts.
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
6 min read
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.
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.