Emulating f64 in shaders · Part 9 of 10
The precision fix that opened a seam
Making the line outline's projection precise, but not its polygon fill, turned a shared invisible jitter into a ~3px fill≠outline seam at deep zoom. The fix isn't more f64 — it's the same camera-relative reframe, which the linear term needs no emulation for.
By the X-GIS team 6 min read
A commit that made the map more correct made it visibly worse. It sharpened the longitude projection of the line outline — deleting an f32 cancellation that shook road edges by tens of pixels at z22 — and left the polygon fill’s identical projection untouched. Before, fill and outline were wrong together: they shared the same sub-metre jitter, so the fill edge and the stroke drawn on top of it moved as one and nobody saw a gap. After, the outline snapped to the truth and the fill stayed behind. At a Seoul-longitude tile (127°, Mercator X ≈ 1.41 × 10⁷ m) the two now part company by ~0.4 m — about 3 px at z20 — a fill-that-shows-through-its-own-outline seam that did not exist the day before the fix.
This is the recurring shape of a bug in a renderer with two sibling paths for one edge: the danger is not that a path is imprecise, it’s that two paths that must coincide use different arithmetic. A precision fix is a change to arithmetic. Land it on one sibling only and you have manufactured a divergence out of a fix.
The wrong first move
The work order framed it backwards, and the framing was seductive. It said, in effect: the outline jitters at high zoom on non-Mercator projections — make it precise like the fill path. Copy the good sibling onto the bad one. That is the normal direction of a parity fix, and it reads as obviously safe.
It was inverted. Reading the two emitted shaders showed the fill was not the
precise one on this branch. The earlier commit (e251b66) had already rewritten the
line’s finalize_corner to project a camera-relative delta; the outline was now the
more precise sibling, and the fill still fed its projection the lossy absolute
longitude. “Make the outline like the fill” would have re-derived the bug we’d just
fixed. The move was the opposite: make the fill like the outline.
You cannot tell which sibling is correct from the bug report. You can only tell by reading what each one actually emits.
What actually happened
The fill’s vertex shader is generated, so the evidence is in the emitted WGSL, not
the TypeScript. Slicing the quantized fill entry out of emitPolygonWgsl and reading
the projection call is the whole diagnosis:
// pre-fix fill (vs_main_ecef): the absolute degree, reconstructed by ADDING// the ~1.4e7 m tile origin back on before dividing to degrees.let _cse4 = (abs_lon + u.tile_origin_merc.x);let _cse2 = flat_rel((_cse4 / _cse5), true_lat, u.proj_params, ((u.tile_origin_merc.x + (0.5 * u.tile_extent_m)) / _cse5));abs_lon + u.tile_origin_merc.x is the smoking gun. It rebuilds the vertex’s
absolute Mercator X — a number near 1.4 × 10⁷ — in f32, whose spacing there is
about a metre, and only then divides down to a degree the projection can consume. The
projection itself then computes radians(abs_lon) − radians(clon): a subtraction of
two large, nearly-equal angles, i.e. catastrophic cancellation. The ~0.4 m residual
is what survives. The line, meanwhile, had stopped reconstructing the absolute value
at all.
The fix
The reframe is the interesting part, and it is why this belongs in a series about
emulating f64: here we don’t emulate anything. The projection depends on longitude
only through lon − clon. So subtract the camera longitude first, in the
tile-local frame where the 1.4 × 10⁷ m tile origin cancels before it ever reaches
f32, and recentre the projection onto clon = 0:
if (localMerc) { const clon = projParamsV.y const relMercX = localMerc.x.sub(U.field.cam_h.x).sub(U.field.cam_l.x) const dLon = relMercX.div(deg2rad.mul(earthR)) const projParamsRel = vec4(projParamsV.x, f32(0), projParamsV.z, projParamsV.w) const tileRefLonRel = U.field.tile_origin_merc.x .add(f32(0.5).mul(U.field.tile_extent_m)) .div(deg2rad.mul(earthR)) .sub(clon) const relG = flat_rel(dLon, discLat ?? absLat, projParamsRel, tileRefLonRel) clip.assign(transformMat4(mvp, vec4(relG.x, relG.y, zG, 1))) return}localMerc.x is the vertex’s tile-local Mercator X — the vertex minus the tile
origin, carried in the fill’s f32 tail in the same frame as cam_h + cam_l. The
large magnitude is gone before the division. This is exactly the arithmetic the
outline’s finalize_corner already emits, so the two are byte-identical by
construction. The subtraction lon − clon is exact in real arithmetic; recentring
just moves the cancellation to where it costs nothing. No emulated double, no Taylor
series — the linear term never needed one.
One guard: the disc arm is shared by three vertex entries, and only the quantized
fill carries localMerc. The extruded and non-quantized entries fall back to the old
abs_lon path — where fill and outline share it identically, so there is no seam to
open.
How we know it holds
Latitude is deliberately left alone, and that is the tell that this fix is honest.
Latitude reaches the projection through inv_merc_lat’s atan(exp(...)) — nonlinear,
no lat − clat cancellation to exploit — and both fill and outline round-trip it
through the same f32 degree. It is a real ~0.4 m residual, but it is shared, so it
causes no divergence; making it precise means threading emulated f64 through the
projection trig, which is a different, GPU-wide change. The linear half has a
closed-form reframe; the nonlinear half does not. Knowing which half you’re holding
is the entire triage.
The gate is two tests, no GPU. An analytic one emulates, in f64 on the CPU, the exact
longitude each sibling feeds the projection and measures the seam between them: the
pre-fix fill lands > 0.2 m from the outline across projTypes 1–5 at z16/18/20,
the post-fix fill < 0.01 m. A structural one asserts the emitted fill shader now
contains the camera-relative, clon-recentred flat_rel(...) call and no longer the
flat_rel(abs_lon, ...) disc arm — it fails on the unmodified shader against the
literal pre-fix string. What a unit test cannot vouch for is the final absence of the
3 px seam on a real over-zoomed non-Mercator frame; that is recorded as pending a
GPU pixel-diff, not claimed.
What generalizes
The instinct on finding a jittering path is to make it precise. The instinct is incomplete: precision is a property of arithmetic, and a rendered edge is usually two paths of arithmetic that have to agree. Sharpen one and you can move a shared, invisible error into a visible seam — the fix and the regression are the same commit. Before you land a precision change, find every sibling that has to coincide with the value you’re changing, and change them together or not at all.
And the quieter lesson, the one this series keeps circling: reach for emulated f64 last, not first. A large coordinate minus a nearby camera is a subtraction that is exact the moment you do it in the right frame. The expensive machinery is for the term that has no such frame — here, the latitude — and spending it on the term that does is how you end up maintaining a Taylor series where a change of origin would have done.
References
- Earlier in this series: Part 5 — the low word a load throws away, Part 7 — the multiply you cannot guard, and Part 8 — a GPU extended-precision field guide.
- The migration regressed one renderer at a time — the same fill-vs-sibling divergence archetype, that time from a coordinate-frame migration rather than a precision fix.
Read next
precision
6 min read
Extended precision on the GPU: a field guide
The GPU has no double. There are four ways to get one anyway — double-float error-free transforms, integer emulation, reference-point rendering, and perturbation — and they are not interchangeable. A decision guide, from a survey of Thall, QD/CAMPARY, Cesium, deck.gl, and fractal deep-zoom engines.
shader-dsl
9 min read
An opaque guard defeats the compiler, not the hardware
After a texel-fetched guard fixed the fast-math collapse, isolated df64 ops passed on the reporter's phone but composed ones still broke. There are two failure classes, not one — and the second lives in the ALU, where no value you thread into the shader can reach it. df64 is per-vendor, and our CI's SwiftShader is structurally blind to half of it.
precision
5 min read
20.7 km and 0.7 m: a difference forgives the bias its terms share
I spec'd a drift guard for the globe's camera-relative ECEF offset: force the ellipsoid constant to a sphere, assert the result shifts >10 km. The implementer measured it: 0.7 m. Turning the WHOLE computation spherical barely moves a relative offset, because both endpoints carry the same 21 km bias and a subtraction cancels it. The regression that actually bites is the mixed frame — ellipsoid tile, spherical camera — and only a guard that breaks the symmetry the same way sees it.