MapLibre and Mapbox WebGPU Interop

MapLibre GL JS and Mapbox GL JS own their canvas, their render loop and their camera, and they expose one seam through which an application can draw its own geometry: the custom layer interface. Historically that seam handed you a WebGL context; the interesting question now is how to put a WebGPU pass through it without fighting the host for control of the frame. The answer turns on three things — getting the camera matrix from the host rather than recomputing it, deciding whether the overlay shares the host’s depth buffer or draws strictly after it, and leaving every piece of render state exactly as it was found. This page covers all three, and the tile-event synchronisation that keeps GPU buffers in step with the basemap. It sits under framework integration and backend synchronization.

Prerequisites

  1. A map instance with a custom layer hook. MapLibre’s addLayer accepts an object with onAdd, render and onRemove; the shape is stable across versions even as the rendering internals change.
  2. A device acquired outside the layer. The layer must not own the device — several layers on one map need to share it, exactly as described under framework integration.
  3. Tile-local coordinates. The overlay’s geometry must use the same origin convention as the basemap, which means the coordinate precision work has already been done.
  4. A pipeline built against the host’s formats. Colour format, depth format and sample count all have to match what the host is rendering into.
Where a custom layer sits inside the host frame Two lanes show one frame. The host lane runs its own sequence: it updates the camera from user input, renders the basemap tiles, then invokes each custom layer render callback in layer order, and finally submits the frame once. The overlay lane shows what the custom layer does inside that callback: it reads the matrix the host passed, drains any pending buffer uploads, and records its draw into the host encoder — and it never submits, because the submission belongs to the host. ONE FRAME · HOST AND OVERLAY Host Overlay Update camera from user input Render basemap its own tiles Submit once the whole frame render(matrix) drain uploads, record draw Never submit the host owns it A submit from inside render splits the frame and loses the ordering between host and overlay.
The overlay is a guest in someone else’s frame. Everything on this page follows from that: use the host’s matrix, write into the host’s attachments, and let the host decide when the work is sent.

Getting the camera exactly right

The single most common failure in this integration is an overlay that is nearly aligned with the basemap and drifts as the user zooms. It has one cause: the camera matrix was recomputed rather than read.

MapLibre passes the custom layer a matrix in its render callback, and that matrix is the map’s own view-projection — including the pitch, the bearing, the exact Web Mercator scale factor at the current zoom, and whatever adjustments the version applies for high-DPI displays. Reconstructing it from map.getZoom(), map.getCenter() and a projection function gets close and is never exact, because the host’s internal constants and rounding are not part of its public interface.

typescript
interface CustomLayer {
  id: string;
  type: "custom";
  renderingMode?: "2d" | "3d";
  onAdd(map: maplibregl.Map, gl: WebGL2RenderingContext): void;
  render(gl: WebGL2RenderingContext, matrix: number[]): void;
  onRemove(map: maplibregl.Map, gl: WebGL2RenderingContext): void;
}

const overlay: CustomLayer = {
  id: "webgpu-overlay",
  type: "custom",
  renderingMode: "3d",          // opt into the depth buffer

  onAdd(map) {
    this.map = map;
    // Resources come from the shared device; the layer creates none of its own.
    this.renderer = createOverlayRenderer(sharedDevice, map.getCanvas());
  },

  render(_gl, matrix) {
    // `matrix` IS the host's view-projection. Use it; never rebuild it.
    this.renderer.setViewProjection(matrix);
    this.renderer.draw();
    // Ask for another frame only if the overlay is animating.
    if (this.renderer.isAnimating) this.map.triggerRepaint();
  },

  onRemove() {
    this.renderer.destroy();     // owns nothing shared — safe to tear down
  },
};

map.addLayer(overlay);

The matrix arrives in the host’s coordinate convention, which for MapLibre is Mercator coordinates normalised to the unit square rather than metres. Feeding it tile-local metre residuals therefore needs one extra transform, composed on the host in double precision exactly as the relative-to-eye encoding describes.

Sharing the depth buffer, or not

renderingMode: "3d" opts the layer into the host’s depth buffer, and the choice has consequences in both directions.

With a shared depth buffer, the overlay’s geometry sorts correctly against the basemap’s — a building extrusion can be occluded by terrain, a route line can pass behind a hill. The cost is that the overlay must use the host’s depth format and comparison function exactly; a mismatch produces z-fighting that looks like flickering along every shared surface.

Without it, in "2d" mode, the overlay draws strictly after everything the host drew, with no depth interaction at all. That is correct for anything genuinely two-dimensional — circles, labels, a heat surface — and much simpler, because the overlay’s pass has no depth attachment and nothing to match.

The rule worth applying is that the mode should follow the geometry rather than the visual intent. Anything with real elevation belongs in "3d"; anything that is a screen-space annotation belongs in "2d". Putting a 2D overlay in 3D mode costs a depth attachment for nothing and invites exactly the format-mismatch bugs above.

Choosing between 2D and 3D rendering mode A decision diagram. The question asks whether the overlay geometry has real elevation that should be occluded by terrain and buildings. On the yes branch the layer uses 3D rendering mode, shares the host depth buffer, and must match the host depth format and comparison function exactly or z-fighting appears along every shared surface. On the no branch the layer uses 2D rendering mode, draws strictly after everything the host drew with no depth attachment at all, and has nothing to match. RENDERING MODE · FOLLOW THE GEOMETRY Does the geometry have real elevation? should terrain occlude it? renderingMode "3d" share the depth buffer yes Match format exactly or z-fighting appears renderingMode "2d" drawn strictly last no No depth attachment nothing to match A 2D overlay in 3D mode is the configuration that produces the most confusing z-fighting.
Choosing the mode from the geometry rather than from the visual intent avoids the common mistake of putting a screen-space annotation in 3D mode, which costs a depth attachment and invites format mismatches for no benefit.

The state contract

A custom layer runs inside the host’s frame, between the host’s own draw calls, and everything it changes it must change back. That is straightforward with WebGPU’s explicit state model and there is one hard rule underneath it: the layer must not submit.

The host records its frame into its own encoder and submits once. A layer that calls queue.submit() inside render splits the frame into two submissions, which loses the ordering guarantee between the host’s work and the overlay’s, and on some paths causes the overlay to be drawn into a texture the host has already presented. The overlay’s work belongs in the host’s encoder where the host exposes one, and in a separate pass against the same texture with loadOp: "load" where it does not.

State Contract
Command submission never from inside render
Colour attachment the host’s texture, loadOp: "load"
Depth attachment the host’s, in "3d" mode; none in "2d"
Blend state premultiplied alpha, matching the host
Viewport and scissor leave as found
Device shared, never created by the layer

Keeping buffers in step with tiles

The other half of the integration is data rather than drawing. The overlay usually renders something derived from the same tiles the basemap is showing, and the host emits events as those tiles load, become idle and are removed.

The events worth listening to are sourcedata for tile arrival, idle for the moment the map has finished loading everything it wants, and move/moveend for camera changes. The pattern that works is to treat them as invalidation signals rather than as work triggers: an event marks a set of tiles dirty, and the next render callback drains that set and issues the uploads. That keeps every GPU operation inside the frame the host controls, which is the same discipline the deck.gl integration follows for the same reason.

The state contract a custom layer has to honour A table of six pieces of state and the contract for each. Command submission must never happen from inside the render callback, because the host submits the whole frame once. The colour attachment must be the host texture with load rather than clear. The depth attachment is the host in 3D mode and absent in 2D. Blend state must be premultiplied alpha to match the host compositing. Viewport and scissor must be left as found. The device must be shared rather than created by the layer. CUSTOM LAYER · STATE CONTRACT Contract Command submission never from render Colour attachment host texture, loadOp "load" Depth attachment host in 3d, none in 2d Blend state premultiplied alpha Viewport and scissor leave as found Device shared, never created here Read the host’s formats at run time — hard-coding them survives exactly one library upgrade.
Five of the six are about not disturbing the host. The first is the one that actually breaks things, and it is also the most tempting, because a standalone renderer ends every frame with a submit.

Reading the host’s coordinate convention

MapLibre does not hand the custom layer a Mercator-metres matrix. It hands one that maps normalised Mercator coordinates — the world as a unit square, with the origin at the north-west corner and y increasing downwards — into clip space. Getting from tile-local metre residuals to that convention takes one composed transform, and building it wrong is the source of most “the overlay is in the right place but the wrong size” reports.

The chain is: tile-local metres, add the tile origin to get world metres, divide by the world extent to normalise, flip y, then apply the host matrix. Every step except the last happens on the host in double precision, and the result is a single mat4x4 that the shader multiplies its residual by — which keeps the shader’s arithmetic in the small-numbers regime the precision work established.

The flip is the step people miss. Mercator y increases northwards; the normalised tile scheme’s y increases southwards. A pipeline that gets everything else right and omits the flip produces an overlay mirrored about the equator, which on a regional map looks like a plausible offset rather than an obvious mirror.

Mapbox GL JS uses the same convention, which is unsurprising given the shared history, but the two libraries have diverged elsewhere and it is worth verifying rather than assuming for any given version.

Testing an overlay’s alignment

Alignment bugs are the defining failure of this integration, and they are cheap to test for if the test is designed around what actually goes wrong.

The useful fixture is a graticule: draw the overlay as a grid of lines at known latitudes and longitudes, and compare against the basemap’s own graticule or against a reference screenshot. A scale error shows as lines that diverge towards the edges; an offset shows as uniform displacement; a flip shows as the pattern being upside down. All three are obvious in the fixture and nearly invisible in real data, which is exactly what makes the fixture worth having.

The second useful test is a zoom sweep. Capture the fixture at half a dozen zoom levels from 2 to 18 and diff each against a stored reference. A precision problem appears only at high zoom, a scale-factor problem appears at every zoom equally, and a projection-constant problem grows with latitude — so the sweep separates three causes that look identical in a single screenshot.

Both belong in continuous integration, because the thing that breaks them is a library upgrade rather than a code change, and an upgrade is exactly the moment nobody is looking at the map closely.

Memory and performance implications

An overlay inside a host frame shares that frame’s budget, which means the overlay’s compute work competes directly with the basemap’s rasterization. On a busy vector basemap the host may already be using ten of the sixteen milliseconds available, leaving the overlay a much smaller allowance than a standalone renderer would have.

Two consequences follow. The overlay’s per-frame work should be bounded rather than proportional to its data — cull first, and let the resident-but-invisible geometry cost nothing. And the overlay should avoid triggerRepaint unless it is genuinely animating: a layer that requests a repaint every frame turns a static map into a continuously rendering one, which on a laptop is the difference between an idle GPU and a warm one.

Failure modes and diagnostics

  • The overlay drifts from the basemap as the user zooms. The camera matrix was recomputed instead of using the one passed to render.
  • The overlay flickers against terrain. Depth format or comparison mismatch in "3d" mode.
  • The basemap disappears when the overlay draws. loadOp: "clear" instead of "load" on the colour attachment.
  • The overlay renders one frame and then never updates. No triggerRepaint, and the map is otherwise idle. The host only redraws when it thinks something changed.
  • The overlay is mirrored about the equator. The normalised tile scheme’s y axis increases southwards and Mercator’s increases northwards; the flip in the coordinate chain was omitted.
  • Everything breaks after a library upgrade. The custom layer surface exposes rendering internals, and it is the part most likely to change between major versions. Pin the version and keep the integration behind a thin adapter.

What the host does not expose

A custom layer is a narrow seam, and it is worth being clear about what is on the other side of it that the layer cannot reach.

The host’s tile geometry is not available. MapLibre parses vector tiles into its own internal buffers, and there is no supported way to read them; an overlay that wants the same features has to fetch and parse them itself. That sounds wasteful and usually is not — the browser’s HTTP cache serves the second request, and the overlay generally wants a different representation anyway.

The host’s label placement is not available either, and this one causes real friction. An overlay that draws its own labels cannot participate in the host’s collision detection, so overlay labels and basemap labels overlap. There are three responses: draw overlay labels in a reserved area the basemap does not use, turn off the basemap’s labels entirely and draw all of them yourself, or accept the overlap. The middle option is what most serious integrations end up doing.

The host’s render order within a frame is only partly controllable. A custom layer can be inserted before a named layer, which controls its position in the draw order, but the host reserves the right to reorder internally between versions. Anything that depends on drawing exactly between two specific basemap layers is fragile in a way that will surface at the next upgrade.

Version drift, and how to survive it

The custom layer interface exposes rendering internals, which makes it the part of the host most likely to change. Three habits keep the cost of that low.

Keep the integration behind an adapter of your own — one file that implements the host’s interface and delegates to a renderer that knows nothing about the host. When the interface changes, one file changes. The renderer, which is where the actual work lives, does not.

Pin the host version and upgrade deliberately. The failure mode of an unpinned upgrade here is not a build error; it is a renamed lifecycle method that silently stops being called, and an overlay that draws once and never again.

Assert the things the host promises. The matrix should be sixteen numbers, the canvas should have the dimensions the layer expects, and the formats should match what the pipeline was built for. Checking those at onAdd and throwing a clear error beats debugging a misaligned overlay three versions later.

Several overlays on one map

A production map rarely has one custom layer, and the constraints multiply in ways worth planning for.

Every layer’s render is called separately, in layer order, within the same host frame. That means each one records into the host’s encoder independently and none of them can see the others — so shared setup work, such as writing a per-frame uniform buffer that all the overlays read, has to happen somewhere that runs once. The natural place is a prerender-style hook if the host offers one, or the first layer in the order acting as the owner, which is fragile enough that a small coordinator object shared between the layers is usually better.

Resources should be shared aggressively across overlays. One device, one per-frame uniform buffer, one bind group layout for group 0, and one sampler set covers most of what several layers need in common. What must not be shared is anything a layer destroys in onRemove: removing one overlay should never invalidate another, which means the shared objects belong to the coordinator and the per-layer objects belong to the layer.

The ordering interaction with the basemap is the last consideration. Layers inserted before a named basemap layer draw beneath it; layers appended draw above everything. A map with both — an elevation-aware overlay under the labels and an annotation overlay above them — is common and works, provided each one’s rendering mode matches its position: an underneath layer that ignores depth will be painted over by terrain it should have been occluded by, which is the correct outcome but rarely the intended one.

Continue in this section