Quantized Mesh Protocol
quantized-mesh:// — rasterising Cesium's terrain TINs into Terrarium tiles, the geographic-to-Mercator tile mapping, and why the meshing libraries do not help
quantized-mesh:// (lib/quantized-mesh-protocol.ts) consumes Cesium terrain — the quantized-mesh-1.0 format — as an ordinary MapLibre raster-dem source, so 3D terrain and every visualization mode treat it like any other DEM.
It is the most involved of the protocols here, because unlike LERC or float32dem the source is not a grid at all.
The direction the meshing libraries do not go
Worth stating first, because the obvious tools are the wrong ones.
pymartini and pydelatin — and their JavaScript ports @mapbox/martini and delatin — are terrain mesh generation tools. They take a raster heightmap and produce a TIN. That is what a titiler-style service uses to serve quantized mesh from a DEM, and it is why titiler's own algorithm list (hillshade, slope, contours, terrarium, terrainrgb, …) contains no quantized-mesh entry: the meshing lives in a separate extension, and it points the other way.
Consuming a TIN needs the inverse — mesh → raster — which is plain triangle rasterisation. No package ships it because it is a loop, and that loop is rasterise() in this file.
@loaders.gl/terrain's QuantizedMeshLoader does the decoding, which is the part that genuinely benefits from a library.
Two URL shapes
quantized-mesh://ion/<assetId>/{z}/{x}/{y} a Cesium ion asset
quantized-mesh://<host+path>/{z}/{x}/{y} any other service{z}/{x}/{y} are MapLibre's own Web Mercator coordinates. Quantized mesh is tiled geographically, so the mapping happens inside the protocol rather than in the template — see below.
The ion account token is not in the URL. A protocol URL is also the tile cache key, and it surfaces in devtools, logs and error messages. The token is held in the module and written by setCesiumIonToken from a cesiumIonKeyAtom effect in TerrainViewer; changing it clears the resolved per-asset endpoints.
An ion asset needs two calls: api.cesium.com/v1/assets/<id>/endpoint returns a tile base URL plus a short-lived access token. That pair is cached per asset and re-resolved once on a 401, so an expired session token recovers without a reload.
The tile mapping
Quantized mesh uses the geographic tiling scheme: EPSG:4326, TMS row order (row 0 at the south), and level L holds 2^(L+1) × 2^L tiles over the whole −180…180 / −90…90 world. Two consequences:
- Level. A Mercator tile at zoom z spans 360/2^z degrees of longitude; a geographic tile at level L spans 360/2^(L+1). They match when L = z − 1.
- Count. A geographic tile is square in degrees, while a Mercator tile is shorter in latitude than in longitude. So one output tile usually falls inside a single source tile, and sometimes straddles two. Both are fetched and rasterised into the same output buffer.
Rasterising
The trick that keeps this cheap is QuantizedMeshLoader's bounds option. The mesh's vertices are normalised within their tile; passing the tile's own geographic rectangle maps them straight to (lon, lat, height):
QuantizedMeshLoader.parseSync(buf, {
"quantized-mesh": { bounds: [b.west, b.south, b.east, b.north], skirtHeight: null },
})The Mercator projection then costs one operation per vertex rather than a resampling pass over every pixel:
px[v] = ((lon - out.west) / lonSpan) * TILE_SIZE
py[v] = ((out.northY - lat2merc(lat)) / ySpan) * TILE_SIZEEach triangle is then scan-converted over its pixel bounding box with barycentric weights, interpolating height. Forward rasterisation is deliberate: the alternative — asking, per output pixel, which triangle contains it — is a point-in-TIN query over thousands of triangles, and would also have to do the projection per pixel instead of per vertex.
The rasteriser is not the expensive part, which is worth knowing before anyone reaches for a GPU implementation. A tile at this size carries a few thousand triangles and scan-converts in single-digit milliseconds; what used to make this protocol take about a second per tile was the PNG encode every protocol ended with. See Tile Caches. With that gone the protocol is network-bound.
The scan-conversion, step by step
The loop is short but every line of it is load-bearing, so here it is in full.
1. Project once, per vertex. Before touching a triangle, all vertices are
converted to the output tile's pixel space and cached in two Float32Arrays.
Doing this per triangle instead would repeat the work for every triangle a
vertex belongs to — six on average in a TIN.
2. Signed area as the guard and the divisor. For each triangle:
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay)
if (area === 0 || !Number.isFinite(area)) continue
const inv = 1 / areaTwice the signed area. Zero means the three points are collinear — a
degenerate triangle covers no pixels, and dividing by it would produce NaN
weights that then fail the containment test in unpredictable ways. Its
reciprocal is taken once and reused, because a division per pixel is
meaningfully slower than a multiply.
Note that the sign is not checked: back-facing triangles are rasterised too. A TIN has no consistent winding after projection, and discarding by winding would punch holes.
3. Bounding box, clipped to the tile. Only pixels inside the triangle's own box are visited, clamped to the tile so a triangle straddling the edge costs nothing extra:
const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx)))
const x1 = Math.min(TILE_SIZE - 1, Math.ceil(Math.max(ax, bx, cx)))This is what keeps the cost proportional to area covered rather than triangles × pixels. A tile of a few thousand triangles scan-converts in single -digit milliseconds.
4. Barycentric weights at the pixel centre. For each candidate pixel, the
same cross-product form as the area, evaluated against (col + 0.5, row + 0.5):
const w0 = ((bx - cxPix) * (cy - cyPix) - (cx - cxPix) * (by - cyPix)) * inv
const w1 = ((cx - cxPix) * (ay - cyPix) - (ax - cxPix) * (cy - cyPix)) * inv
const w2 = 1 - w0 - w1
if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue
heights[i] = w0 * az + w1 * bz + w2 * czThe pixel centre, not its corner, is what makes a pixel belong to exactly
one triangle in the common case. w2 is derived rather than computed, since
the three weights sum to 1 by definition — one fewer cross product per pixel.
All three non-negative means the point is inside. The weights are then the interpolation coefficients for free: a plane through three heights, evaluated at that pixel. That is what makes this linear interpolation across the triangle rather than nearest-vertex.
5. The -1e-6 tolerance. Two triangles sharing an edge produce weights
that are exactly zero on that edge in exact arithmetic. In floating point
one of them lands at -1e-9 and neither claims the pixel, leaving a
one-pixel unwritten seam along every shared edge — which a hillshade turns
into a visible wireframe. The tolerance is large enough to absorb that error
and small enough that a pixel genuinely outside a triangle is still rejected.
The cost is that edge pixels may be written twice, by both neighbours; they
agree to within rounding, so the second write is harmless.
6. filled is a separate array. Height 0 is a perfectly valid elevation,
so "was this pixel written" cannot be inferred from the height buffer. The
parallel Uint8Array is what the hole pass reads afterwards to decide between
a real 0 m and an untouched pixel.
Pixels no triangle covered are holes: 0 m with alpha 254, the app-wide convention. A tile with no coverage at all throws a 404, which MapLibre treats as an ordinary absent tile.
Cesium World Terrain: measured
| asset | ion 1, layer.json reports quantized-mesh-1.0, scheme: tms, projection: EPSG:4326, max level 15 |
| a z13 output tile over Innsbruck | 0% holes, 614.3 – 2076.5 m, 3–97 ms warm |
| datum | ELLIPSOIDAL |
The datum is the finding worth carrying. Six points across one tile read +48.1 m mean against an orthometric reference (spread 22.8 m, which is sampling noise between two models in steep ground). That is the alpine geoid separation almost exactly — so Cesium World Terrain behaves like ArcticDEM and REMA, and unlike the Esri LERC sources, which measured −0.6 m on the same points and are orthometric.
In practice: summits read roughly 50 m high in the Alps, and the offset varies by tens of metres around the world. Fine for shape and shading, wrong for absolute heights unless you correct it.
Compared with MapLibre's own example
MapLibre merged an official quantized-mesh terrain example in
#7958 (August 2026, against the still-open
#4493). It uses the
maplibre-gl-3dtiles-terrain plugin, and it is
worth knowing how it differs — it is the closest thing to a reference implementation, and it is better than
this one in two specific places.
maplibre-gl-3dtiles-terrain | this app | |
|---|---|---|
| Mesh → raster | inverse: per output pixel, mercatorPixelToLngLat then a point-in-TIN query against a bucketed index (buildTriangleIndex, multiMeshElevationAt) | forward: scan-convert each triangle over its own pixel box |
| Reprojection | per pixel | per vertex, via QuantizedMeshLoader's bounds |
| Which levels exist | reads layer.json's available ranges (collectAvailableTiles) | discovers by 404 and steps back a level |
| Decoding | in a worker, transferable ImageBitmaps | main thread |
| Caching | decoded mesh + index LRU | finished-tile LRU only (tile caches) |
| Output | Terrarium (packTerrarium) | Terrarium |
Where theirs is better, and worth taking:
- The availability walk. Reading
availablemeans never firing a request for a level that does not exist, instead of learning by 404. (It is not a universal fix: swisstopo'slayer.jsoncarries noavailablearray at all, so the blind fallback is still needed as a backstop.) - A decoded-mesh cache. At detail offset +1 four adjacent output tiles share source tiles, and this app re-decodes each one. That is a real, measurable win left on the table.
Where this one is better:
- Forward rasterisation needs no index. Theirs builds a spatial index per tile and then queries it once per pixel; ours visits only the pixels a triangle actually covers, with no structure to build.
- Projection per vertex, not per pixel. A tile has a few thousand vertices and 65 536 pixels.
- Integration. ion endpoint resolution and token handling, the alpha-254 hole convention shared with
every other elevation path, the shared tile cache, and returning an
ImageBitmaprather than encoded bytes.
Their worker matters less than it sounds now that the PNG encode is gone — the scan-conversion itself is single-digit milliseconds, and the protocol is network-bound.
Is it worth using?
Honest answer: usually not, over lerc://. Cesium World Terrain tops out at level 15 — comparable to the z16 the keyless Esri services already reach — from a broadly similar blend of public sources, and it needs a token and a geoid correction.
Where it earns its place is ion-hosted private assets: terrain you or your organisation uploaded to ion, which nothing else in this app can reach. The protocol takes any asset id, so that is a matter of pointing a custom source at ion/<your asset>.
ArcGIS LERC Elevation Protocol
lerc:// — decoding Esri's float raster codec into Terrarium tiles, and why the tile pyramid is the only anonymous way into an ArcGIS elevation service
Derived Terrain Protocol
demdiff:// — subtracting one elevation source from another, tile by tile, and the alpha-254 convention that keeps holes flat