WMS Float32 DEM Protocol
Bridging a plain WMS elevation service into MapLibre's raster-dem pipeline
Most of this app's terrain sources are tile-native (XYZ Terrarium/Terrain-RGB, or a COG read directly in-browser — see Bring Your Own Data). Some elevation providers only expose a traditional WMS GetMap service, with no tiled/COG access at all — IGN France's LidarHD is the motivating example. float32dem:// (lib/float32dem-protocol.ts) is a custom MapLibre protocol that bridges that gap: it issues a WMS request per tile, decodes a real float32 elevation grid out of the response, and re-packs it as standard Terrarium raster-dem, so every other part of the app (hillshade, terrain-analysis derivatives, 3D terrain) can treat it exactly like any other elevation source.
A standalone demo of this protocol, decoupled from the rest of the app, is deployed at terrain-viewer.iconem.com/maplibre-raster-dem-wms-float32-generic.html (source: public/maplibre-raster-dem-wms-float32-generic.html) — useful for testing a new WMS endpoint in isolation before wiring it into the main app.
1. The WMS request
A GetMap request asking for image/geotiff — a real georeferenced float32 band, not an 8-bit styled/quantized image:
https://data.geopf.fr/wms-r?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap
&LAYERS=IGNF_LIDAR-HD_MNS_ELEVATION.ELEVATIONGRIDCOVERAGE.LAMB93
&STYLES=&FORMAT=image%2Fgeotiff&CRS=EPSG:3857
&BBOX={bbox-epsg-3857}&WIDTH=514&HEIGHT=514{bbox-epsg-3857} is MapLibre's own tile-source placeholder — the same mechanism an ordinary type: "raster" WMS source already uses — substituted per-tile with that tile's bbox in EPSG:3857.
2. Decode → re-encode
The response is a real GeoTIFF, parsed client-side with geotiff.js. Band 0 is read as raw float32 meters, then re-packed into standard Terrarium RGB (the same encoding described in Terrain Analysis Rendering Pipeline) and rasterized to a PNG — so MapLibre's native raster-dem consumer needs no awareness this data ever passed through WMS or GeoTIFF at all:
const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer)
const image = await tiff.getImage()
const elevationData = (await image.readRasters())[0]
for (let i = 0; i < elevationData.length; i++) {
const v = elevationData[i] + 32768
const intPart = Math.floor(v)
rgbaData[i*4 + 0] = Math.floor(intPart / 256) & 0xFF
rgbaData[i*4 + 1] = intPart & 0xFF
rgbaData[i*4 + 2] = Math.floor((v - intPart) * 256) & 0xFF
rgbaData[i*4 + 3] = 255
}The RGBA buffer goes through the shared toTileImage tail (lib/tile-image.ts) and is returned to MapLibre as an ImageBitmap, decoded rather than PNG-encoded — worth roughly 99 ms a tile; see Tile Caches.
3. Protocol registration
Registered as maplibregl.addProtocol('float32dem', withTileResultCache(float32demProtocol)) in components/TerrainViewer.tsx. Tile URLs are the scheme plus the WMS URL with its https:// stripped (re-prepended by the handler before fetching): float32dem://data.geopf.fr/wms-r?...&BBOX={bbox-epsg-3857}....
4. Relation to the app's COG terrain path
float32dem:// exists specifically for the "no COG, no server-side GDAL WMS bridge" case in the pure-browser rendering path. In lib/source-builder.ts, a "wms-raw" source kind branches on rendering mode:
- Client-side (
@geomatico/maplibre-cog-protocol, which needs an actual COG file) always falls back tofloat32dem://, since there's no COG for it to read. - TiTiler-backed mode instead passes
WMS:<url>through GDAL's own WMS minidriver, letting the server treat the live WMS endpoint as an addressable raster dataset and handle reprojection/tiling itself — nofloat32dem://involved.
float32dem:// is also reused as a building block by two other parts of the app: LRM (its ancestor-tile fetch rewrites the WMS request size for a WMS-raw upstream, since such sources have no real overview pyramid to exploit) and lib/cog-contour-protocol.ts/ContoursLayer.tsx (contour generation).
5. Exposure in the app UI
components/TerrainControlPanel/wms-picker-panel.tsx is a generic "bring your own WMS" picker — fetches GetCapabilities, lists available layers, builds a {bbox-epsg-3857}-templated GetMap URL — shared between the basemap and terrain "Add Source" flows. A format prop selects image/png for ordinary basemap layers or image/geotiff for elevation/terrain sources; the resulting URL is routed through source-builder.ts's "wms-raw" case described above. It sits alongside COG uploads as one more entry in Bring Your Own Data, not a separate standalone feature.
6. Slow servers: tile size, a queue of our own, and retries
IGN's LiDAR HD service is the hardest source the app streams: the finest data it has for France, served by a WMS that renders every GetMap on demand from the LiDAR tiles, and that sometimes answers in under a second and sometimes not at all. Three measures, all in lib/float32dem-protocol.ts, took it from "tiles keep stalling" to loading like a tile service. Measured on 2026-09-28 at the Rade de Brest and in the Beauce.
Fewer, larger GetMaps. A wms-raw source can set tileSize (default 512, the GetMap WIDTH/HEIGHT two pixels more). The IGN LiDAR HD sources use 1024, asking for 1026 px. One GetMap over an area came back two to five times faster than the four smaller ones covering it:
| Area | 4 × 514 px in parallel | 1 × 1026 px |
|---|---|---|
| Rade de Brest, z16 | 3.9 s | 0.8 s |
| Rade de Brest, z15 | 4.8 s | 1.7 s |
| Beauce, z16 | 4.6 s | 1.6 s |
With four requests in flight, the slowest one sets the pace, and one of four is often slow. A difference source (nDSM) reads a larger-tiled operand from its parent tile at the same pixel density, so its own 256 px tiles do not multiply the requests (the 256~2~2 depth segment in demdiff:// URLs).
A queue of our own, six per host. data.geopf.fr speaks HTTP/1.1, and a browser runs at most six HTTP/1.1 requests per host, queueing the rest where no script can see them. With the terrain, the hillshade and both operands of an nDSM all asking the same host, most requests spent their time in that invisible queue. The protocol now keeps the queue itself (acquireSlot): six GetMaps per host at a time, the rest waiting in order, and an aborted tile leaves the queue without ever being sent.
A timeout that only counts the request, and retries. Some GetMaps hang for minutes while the same tile, asked again, comes back at once: the server finished and cached it meanwhile. Panning a little made missing tiles appear, which is what gave this away. Each attempt now gets 25 s from the moment it holds a slot (time spent waiting in the queue does not count, or a queued request would time out and retry for nothing), then is sent again, up to three attempts, also on a 429 or a 5xx. The caller's abort still cancels everything.
The result on the IGN nDSM (DSM minus DTM, two GetMaps per tile): the nine tiles around the Rade de Brest load in 12 s where they took 43 s, and the stalled-tiles toast no longer shows in normal use.
Two related gotchas.
- Nodata travels as URL markers (
__nodatafloor,__nodatafill) and has to be appended on both paths to this protocol: the map's own tiles (lib/source-builder.ts) and the client reads that derived modes, differences, contours and exports use (useClientDemUpstreaminMapSources.tsx). Missing on the second path, IGN's reprojected -9999 sentinel passed as ground and an nDSM stood up in spikes of up to 1000 m along coastlines. Holes are returned as a mask, encoded with alpha 254 at the fill height, so 3D terrain stays level while every other reader skips them. - Derived modes ask for the tile plus its margin in one GetMap. Slope, aspect, curvature, the relief modes and the mound detector need a halo of pixels around a tile for their kernel. From a tile service that means the tile plus its eight neighbours, nine fetches for a one-pixel border, and that is what the WMS path did too: measured on IGN, turning on aspect over a 2 × 2 view cost 16 GetMaps (4 of them the terrain's own tiles, served from the browser's HTTP cache since IGN's answers are cacheable for 21 days and the URL is the same string). A WMS answers any bbox at any size, so
fetchPaddedElevationGrid(lib/normal-derived-protocol.ts) now asks one GetMap for the tile's bbox grown by the halo, attileSize + 2 × halopixels, straight from the GeoTIFF floats with the hole mask as validity: the same view costs 4 GetMaps. (Those no longer match the terrain's URLs, so the HTTP cache no longer helps there; four fresh requests still beat sixteen with four cached.) Only the plain fetch takes this path, away from the world's edge, and up to 2048 px a side; LRM's ancestor reads keep their own request rewrite.