Rendering a WebGPU Overlay Aligned to a MapLibre Camera
The overlay is in roughly the right place, and it drifts as the user zooms. That single symptom accounts for most of the time spent on this integration, and it has one cause: the transform between the application’s coordinates and the matrix MapLibre passes to render was reconstructed rather than composed. This page is the exact chain — tile-local metres to world metres to normalised Mercator to clip space — the double-precision half that belongs on the host, the y flip that mirrors an otherwise perfect overlay, and the graticule fixture that catches all three failure shapes in one screenshot. It is one stage of MapLibre and Mapbox WebGPU interop.
Runnable reference implementation
The host matrix maps normalised Mercator — the world as a unit square, origin north-west, y increasing southwards — into clip space. Everything before that is the application’s responsibility and all of it happens in double precision.
const WORLD_METRES = 20037508.342789244 * 2; // full Mercator extent
/**
* Compose tile-local residual metres -> the host's clip space.
* Every multiply here is f64; only the finished matrix is narrowed.
*/
function localToClip(hostMatrix: number[], originX: number, originY: number): Float32Array {
const s = 1 / WORLD_METRES; // metres -> normalised units
// Normalised x = (worldX + halfWorld) / worldExtent
// Normalised y = (halfWorld - worldY) / worldExtent <- the flip
const tx = (originX + WORLD_METRES / 2) * s;
const ty = (WORLD_METRES / 2 - originY) * s;
// Column-major, as WebGPU and MapLibre both expect.
const localToWorld = new Float64Array([
s, 0, 0, 0,
0, -s, 0, 0, // negative: y increases southwards downstream
0, 0, 1, 0,
tx, ty, 0, 1,
]);
return new Float32Array(multiply4x4(hostMatrix, localToWorld));
}
The shader then does nothing clever, which is the point: the residual is small, the matrix carries the magnitude, and no two large numbers are ever added.
struct Frame { local_to_clip : mat4x4<f32> };
@group(0) @binding(0) var<uniform> frame : Frame;
@vertex
fn vs(@location(0) local_xy : vec2<f32>) -> @builtin(position) vec4<f32> {
// local_xy is metres from this tile's origin — a few hundred at most.
return frame.local_to_clip * vec4<f32>(local_xy, 0.0, 1.0);
}
One matrix is composed per tile per frame. Sixty-four bytes multiplied by the tiles on screen is a few kilobytes, which is nothing, and building it per draw call rather than per tile is the mistake that makes it look expensive.
Parameter reference
| Value | Setting here | Guidance |
|---|---|---|
WORLD_METRES |
40 075 016.6856 | 2 × R × π with R = 6378137. Using a rounded value produces a scale error that grows towards the map edges. |
| Matrix source | the render argument |
Never map.getZoom() and friends — the host’s matrix includes adjustments its public API does not expose. |
| Precision of the compose | f64 |
JavaScript numbers already are; the narrowing happens once, on the finished matrix. |
y sign |
negative | Mercator y grows north, the normalised scheme grows south. |
| Matrix order | column-major | Both MapLibre and WebGPU expect it; a row-major upload transposes the transform. |
The three failure shapes, and telling them apart
An overlay that is not aligned fails in one of three ways, and each has a distinct signature once you know to look for it.
A scale error shows as agreement at the centre of the screen and divergence towards the edges, growing with distance from the view centre. It comes from a wrong world extent, a wrong Earth radius, or a matrix multiplied in the wrong order so the scale and translate are composed backwards.
An offset error shows as uniform displacement — every feature wrong by the same screen distance, in the same direction, at every zoom. It comes from a tile origin that does not match the one the geometry was encoded against, which usually means the origin travelled separately from the vertices and the two got out of step.
A flip shows as the overlay mirrored about the equator, which on a regional map reads as a large offset rather than as an obvious mirror. It comes from the missing negative in the y scale.
The reason it is worth naming them is that they look similar in real data and completely different in a graticule. Drawing the overlay as a grid of lines at whole degrees, over a basemap with its own graticule, separates the three in one glance: divergent lines are a scale error, parallel displacement is an offset, and an upside-down pattern is a flip.
Pitch, bearing and the third dimension
Everything above assumes a flat overlay, and a pitched map complicates it in one specific way worth stating.
The host matrix already contains the pitch and bearing, so an overlay drawn with it is correctly perspective-projected without the application knowing either value. That is the good news, and it means a 2D overlay needs no changes at all to work on a tilted map.
What does need attention is the z component. The chain above sets z to zero, which puts the overlay on the ground plane. For geometry with real elevation the z has to be scaled into the same normalised units as x and y — the host treats one normalised unit as the full world extent in all three axes — so a height in metres becomes height / WORLD_METRES before the matrix is applied. Forgetting the division produces an overlay that shoots off the top of the screen at the first non-zero elevation, which is at least unambiguous.
There is also a renderingMode interaction. In "2d" mode the host may not supply a depth buffer at all, so an overlay with elevation will draw over terrain it should be behind. That is the case the mode exists to distinguish, and the fix is to declare "3d" rather than to compensate in the shader.
Failure modes
- The overlay drifts as the user zooms. The matrix was recomputed rather than taken from the
renderargument. - The overlay is mirrored north-south. The
yscale is positive; it must be negative. - The overlay is correct at zoom 4 and visibly wrong at zoom 18. A precision problem rather than a transform one — the residual encoding is missing and full-magnitude coordinates are reaching the shader.
- Everything is rotated ninety degrees. The matrix was uploaded row-major.
- The overlay shoots off screen the moment elevation is non-zero. The
zcomponent was left in metres instead of being divided by the world extent likexandy. - The overlay renders once and then freezes. The map is idle and nothing called
triggerRepaint; the host only redraws when it believes something changed.
Backend / Python interop note
The tile origin has to reach the client, and putting it in the tile’s own metadata rather than deriving it client-side removes an entire class of offset bug.
A tile’s origin is a function of its (z, x, y) key and nothing else, so both ends can compute it — which is exactly why they should not. Two implementations of the same formula drift; one value transported once does not. Emitting the origin as two doubles in the record batch metadata, alongside the geometry, means the client uses the same number the geometry was encoded against by construction.
WORLD = 20037508.342789244
def tile_origin(z: int, x: int, y: int) -> tuple[float, float]:
"""North-west corner of a tile in Web Mercator metres."""
span = (WORLD * 2) / (2 ** z)
return (-WORLD + x * span, WORLD - y * span)
It is worth noting what the origin is not. It is not the tile’s centre, and it is not the bounding box of the features the tile happens to contain. Both are tempting because they make the residuals slightly smaller, and both break the moment two tiles are drawn together: a residual is only meaningful relative to a stated origin, and an origin derived from content changes whenever the content does. The tile key is the only source that is stable, shared and derivable by both ends.
The other server-side note is about consistency across sources. A map showing an overlay derived from one service over a basemap from another only aligns if both use the same Mercator constants — and while EPSG:3857 fixes them, a service that quietly uses a different Earth radius produces an overlay that is a few hundred metres out at high latitudes. Checking a known landmark at 60° north is a faster diagnosis than reading either service’s source.
Related
- MapLibre and Mapbox WebGPU interop — the topic this page belongs to.
- Synchronizing MapLibre tile events with GPU buffer uploads — keeping the data in step once the geometry is aligned.
- Relative-to-eye encoding for f32 coordinate precision — why the residual reaches the shader rather than the coordinate.
- Web Mercator projection in a WGSL vertex shader — the constants this chain depends on.
- deck.gl layer integration with WebGPU — the same alignment problem with a different host.