Draping custom layers
What MapLibre drapes over its terrain and what it does not, why Matcap and Phong have a raster path and a live GL path, and the tile-seam fix
MapLibre 6.13: custom layers can drape
MapLibre 6.13 (#8588, experimental) adds the hook this page used to say was missing. The app runs 6.13 since 2026-10-07; nothing uses the hook yet.
renderToTerrainTile(gl, { tileID, width, height }), called instead ofrenderwhile terrain is on: the layer draws into one terrain tile's framebuffer, clip space(-1,-1)to(1,1)being the tile's south-west and north-east corners, and MapLibre drapes it with the fill, line and raster layers around it in the style.terrainTileRevision: a number the layer changes (thentriggerRepaint) when what it draws changes, so MapLibre redraws the terrain tiles.renderTerrainHeightMap(target), inprerender's arguments: the terrain's elevation as MapLibre draws it, into a float texture, for placing many objects on the ground on the GPU.
What it means here, in the order worth doing:
- Allmaps overlays on 3D terrain, at any pitch. Allmaps' renderer only knows flat viewports, which is why it breaks when tilted. Per terrain tile it would be handed a flat viewport (the tile's own square), so a wrapper around
WarpedMapLayerimplementingrenderToTerrainTilewould drape the in-browser warp on the terrain at any pitch, sharper than theallmaps.xyztiles we switch to today. Two things to handle in the wrapper: the renderer forcesgl.viewportto the canvas size (pin it to the tile's size during the call), and it fetches IIIF tiles for "the" viewport (keep that driven by the main view, inprerender, so per-tile calls do not thrash it). 2D views (no terrain) keep the tile-server fallback. - Live Phong and Matcap (
PhongLiveGlLayer,MatcapLiveGlLayer) draw their own tile meshes with skirts over the terrain today, with the seam fix below. Drawn per terrain tile instead, they would be draped like hillshade: no own mesh, no z-fighting, no skirts. The view-dependent terms (specular, the camera-relative matcap) stay correct by bumpingterrainTileRevisionon camera moves: a GPU redraw of the drape, not a tile reload as on the raster path. - Building shadows (
BuildingShadowLayer): same, draped instead of drawn above the ground. renderTerrainHeightMap: markers, labels or measure points snapped to the drawn terrain on the GPU.
Draping before 6.13
MapLibre 6 before 6.13 did not drape custom layers. A custom layer still draws itself in 3D with the projection data it is handed (getProjectionData is now part of the render arguments), while the render-to-texture path that drapes style layers over terrain has no public hook for a custom layer; 6 only refined that path (mipmap sampling, drape pooling, one stale drape re-rendered per frame). What does ride it is the raster mode of matcap and phong: the protocol emits tiles and MapLibre drapes them like any raster layer, with no seams. The live GL layers exist only for instant light and rotation changes, and they draw their own tile meshes. On 6 that showed as white dashes along every tile seam on the globe: MapLibre's terrain skirts, coloured by the draped layers, peeking through wherever a live quad stopped at the tile edge. The fix is the same technique MapLibre uses: the mesh is built with a border ring (generateBorders) and the vertex shader folds those vertices back onto the edge and drops them by Terrain.getSkirtLength(zoom), a vertical wall under each edge, so tiles neither gap nor overlap. Both follow MapLibre's default (terrainSkirtLength: "auto"), which is wanted over the plain background layer.
What can be draped, and why there are two paths
A question that comes back often, with MapLibre's own advice in issue #3001: can a Phong or Matcap render made from terrain tiles simply be draped on MapLibre's 3D terrain, without a mesh of our own? Yes, and the raster path already does exactly that. With terrain on, MapLibre renders every non-symbol style layer into per-tile textures and drapes them on its terrain mesh (render-to-texture). A custom protocol only has to return an image: matcap-protocol.ts and phong-protocol.ts fetch the DEM tile, compute normals with a one-tile border (so there are no seams, the caveat usually raised about this route), shade on the GPU (a fragment shader draw and readPixels, see gpu-matcap-compute.ts / gpu-phong-compute.ts) and return the tile. MapLibre drapes it like any raster. Normals come from the full-resolution DEM while the mesh is coarser, which is what you want: fine shading on the draped surface.
What decides between the two paths is whether a term depends on the view, not how free the camera is:
- View-independent terms can be draped at any pitch or bearing: diffuse (Lambert) from a fixed light, slope and aspect colouring, sky-view factor, openness, hard shadows. They depend only on the normal and a world-space light, so the result lives in tile space, the way hillshade does.
- View-dependent terms cannot: specular highlights, a camera-relative matcap (a lookup on the view-space normal), fresnel. Baked into a draped tile they are frozen at one view, the nadir, and wrong as soon as the map pitches or rotates; re-baking them on every camera move would mean reloading every tile.
So the raster path bakes: its matcap samples the tile-space normal (pinned to compass directions) and its Phong specular assumes a top-down view. The live path, a CustomLayerInterface, exists for the view-dependent version and for instant changes (a slider is a uniform, not a new tile). MapLibre does not drape custom layers, so it draws its own tile meshes over the terrain, with borders and skirts built the way MapLibre builds its own (above). Two other routes exist and were not taken: re-rendering MapLibre's own terrain tiles with our shader by reading map.terrain internals (private, and they moved between 5.x minors, sourceCache to tileManager), or a fork adding a shading mode to MapLibre's terrain fragment shader.
Issue #3001 itself is about something else: while a paint transition runs on a draped layer (an animated opacity, say), the render-to-texture cache kept the texture of the transition's first frame. It does not affect these modes: a new light direction or a new parameter goes into the tile URL, which reloads the tiles and invalidates the drape, and nothing here animates a paint property.