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.

The four spaces a vertex passes through Four stages left to right. A vertex begins as metres from its tile origin, a few hundred at most. Adding the tile origin gives world Mercator metres, up to twenty million. Dividing by the world extent and flipping the y axis gives normalised Mercator, the unit square the host works in. The host matrix then maps that into clip space. Every step except the last is composed into one matrix on the host in double precision, so the shader only ever multiplies a small residual. TILE-LOCAL → CLIP SPACE Tile-local m a few hundred what the buffer holds World Mercator m up to 20 037 508 never reaches the shader Normalised unit square, y southwards the host convention Clip space the host matrix passed to render() The flip between the second and third boxes is the single most-missed step.
The second box is the one that must never exist as a value in a shader. Composing the whole chain into one matrix on the host is what keeps it a coefficient rather than a coordinate.

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.

typescript
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.

wgsl
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.
Telling three alignment failures apart A table of three alignment failures with their visual signature, how the error behaves across zoom levels, and the usual cause. A scale error agrees at the view centre and diverges towards the edges, gets proportionally worse with distance from centre, and comes from a wrong world extent or a transposed compose. An offset error displaces everything uniformly, stays constant across zoom, and comes from a tile origin that does not match the geometry. A flip mirrors the overlay about the equator, is symmetric about it, and comes from a missing negative on the y scale. ALIGNMENT FAILURE · SIGNATURE Looks like Across zoom Cause Scale diverges at edges worse further out wrong extent Offset uniform shift constant origin mismatch Flip mirrored N–S symmetric y sign Draw whole-degree lines over the basemap graticule and the diagnosis takes one glance.
In real data all three look like "the overlay is a bit off". In a graticule fixture they look completely different, which is the entire argument for having one.

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 render argument.
  • The overlay is mirrored north-south. The y scale 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 z component was left in metres instead of being divided by the world extent like x and y.
  • The overlay renders once and then freezes. The map is idle and nothing called triggerRepaint; the host only redraws when it believes something changed.
A zoom sweep that separates precision from transform Three checks in order. Capture the graticule fixture at a low zoom, around level four, where a transform error is obvious and a precision error is invisible. Capture it again at a middle zoom around eleven, where a scale error has grown and precision still has headroom. Capture it at a high zoom around eighteen, where any remaining error is precision rather than transform, because at that scale the residual encoding is the only thing still carrying detail. ZOOM SWEEP · THREE CAPTURES 1 Zoom 4 transform errors visible precision is irrelevant here 2 Zoom 11 scale errors have grown both causes still possible 3 Zoom 18 only precision remains a transform error would already have shown Store the three as references and re-run them on every host library upgrade.
A single screenshot cannot distinguish a transform bug from a precision bug. Three, at spread zooms, can — and the sweep is a few seconds in a headless browser.

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.

python
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.