Generating Quantized Mesh Terrain Tiles
This page turns a DEM mosaic into a streaming terrain pyramid in the quantized-mesh format — the layer CesiumJS renders under a 3D Tiles city — and covers the three details that decide whether the result is watertight: the TMS tile addressing, the per-edge vertex lists that let neighbouring tiles agree, and the skirt heights that hide what is left. A terrain pyramid that renders with visible cracks at tile boundaries is nearly always failing one of those three.
Why you hit this
Terrain and buildings arrive in a viewer from two different pipelines, and the terrain one is the older and less forgiving. Quantized mesh predates 3D Tiles, addresses tiles in TMS rather than XYZ, quantises every vertex to 16 bits within the tile’s own bounding box, and requires each tile to declare which of its vertices lie on each edge so the runtime can weld neighbours. None of that is difficult; all of it is easy to get subtly wrong in a way that renders as a hairline crack the length of every tile boundary.
The mosaic this consumes comes out of merging and mosaicking DEM tiles with GDAL.
Prerequisites
ctb-quantized-mesh(a fork of Cesium Terrain Builder) orcesium-terrain-builder-docker, plus GDAL 3.6+.- A single-band float32 DEM in EPSG:4326 with an explicit nodata value. The format is defined on the geographic grid, so a projected mosaic has to be warped first.
- Python 3.10+ with
numpy>=1.24andrequestsfor the verification steps. - Roughly 3–5× the DEM’s size in free disk for the pyramid.
Step-by-Step
1. Warp the mosaic to EPSG:4326 on the tiling grid
Quantized mesh tiles the geographic grid, so the source has to be there too, aligned to whole tile boundaries.
gdalwarp \
-t_srs EPSG:4326 \
-r bilinear \
-dstnodata -9999 \
-co TILED=YES -co COMPRESS=DEFLATE -co PREDICTOR=3 -co BIGTIFF=IF_SAFER \
-multi -wo NUM_THREADS=ALL_CPUS \
dem_utm33n_1m.tif dem_wgs84.tif
gdalinfo dem_wgs84.tif | grep -E "Size is|Pixel Size|NoData"
Resampling a projected DEM to geographic stretches cells non-uniformly with latitude, which is unavoidable and worth knowing: at 60° N a cell that was square in UTM becomes twice as wide as it is tall in degrees. That is the format’s constraint, not a mistake.
2. Build the pyramid
ctb-tile walks the zoom levels, meshing each tile to a vertex budget.
mkdir -p terrain
ctb-tile --output-dir terrain --output-format Mesh \
--start-zoom 14 --end-zoom 0 \
--thread-count $(nproc) \
dem_wgs84.tif
# The layer descriptor the client reads first.
ctb-tile --output-dir terrain --output-format Mesh --layer dem_wgs84.tif
cat terrain/layer.json
Building from the deepest zoom upward is deliberate: each coarser level is meshed from the level below rather than from the DEM, so the pyramid is internally consistent and a coarse tile is a genuine simplification of its children rather than an independent sampling of the raster.
3. Understand what a tile actually contains
Every vertex is quantised to 16 bits within the tile’s own bounding box, and the edges are declared explicitly.
import struct
import gzip
import numpy as np
def read_quantized_mesh(path):
raw = gzip.open(path, "rb").read() if path.endswith(".gz") else open(path, "rb").read()
off = 0
cx, cy, cz, min_h, max_h, bx, by, bz, br, hx, hy, hz = struct.unpack_from("<3d2f4d3d", raw, off)
off += 88
(count,) = struct.unpack_from("<I", raw, off); off += 4
def zigzag(a):
return (a >> 1) ^ (-(a & 1))
def decode(n):
nonlocal off
arr = np.frombuffer(raw, dtype="<u2", count=n, offset=off).astype(np.int32)
off += n * 2
return np.cumsum(zigzag(arr))
u, v, h = decode(count), decode(count), decode(count)
return {"vertices": count, "min_h": min_h, "max_h": max_h,
"u": u, "v": v, "h": h, "centre": (cx, cy, cz)}
t = read_quantized_mesh("terrain/14/8801/9702.terrain")
print(f"{t['vertices']} vertices, height range {t['min_h']:.1f}–{t['max_h']:.1f} m")
print("u range:", int(t["u"].min()), int(t["u"].max()))
The u, v and h arrays run from 0 to 32767 across the tile, so the vertical precision is the tile’s height range divided by 32767 — under a centimetre for a typical tile and metres for one spanning a mountain range. That is the format’s main limitation and the reason a tile covering enormous relief benefits from being split.
4. Check that neighbouring tiles agree on their shared edge
Each tile lists the indices of the vertices on its west, south, east and north edges. Two neighbours are watertight only if their shared edge vertices have identical positions.
import numpy as np
def edge_heights(tile, side):
idx = tile["edges"][side]
return tile["h"][idx], tile["u"][idx], tile["v"][idx]
def seam_residual(left, right):
"""left's east edge against right's west edge."""
lh, _, lv = edge_heights(left, "east")
rh, _, rv = edge_heights(right, "west")
if len(lh) != len(rh):
return None # different vertex counts: cannot weld
order_l, order_r = np.argsort(lv), np.argsort(rv)
return np.abs(lh[order_l] - rh[order_r]).max()
r = seam_residual(tile_a, tile_b)
print("max edge height difference:", "incompatible" if r is None else f"{r} quantised units")
A differing vertex count on the shared edge is the crack that cannot be closed by any skirt: the runtime has no correspondence to weld. It happens when two tiles were meshed from different source resolutions, which is why the pyramid must be built in one pass from one mosaic.
5. Serve the pyramid with the right headers
The client fetches layer.json first and then tiles by TMS address, and both need specific content types.
location ~* \.terrain$ {
add_header Content-Type application/vnd.quantized-mesh;
add_header Content-Encoding gzip;
add_header Access-Control-Allow-Origin *;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /terrain/layer.json {
add_header Content-Type application/json;
add_header Cache-Control "no-cache";
}
The Content-Encoding: gzip header is required because ctb-tile writes the tiles already gzipped and the server must not compress them again. Omitting it makes the client receive compressed bytes it does not know to inflate, and every tile fails to parse.
Expected Output & Verification
A correct pyramid over a city extent:
Size is 41216, 28904
Pixel Size = (0.0000089831529,-0.0000089831529)
NoData Value=-9999
14/8801/9702.terrain — 4218 vertices, height range 3.2–214.7 m
u range: 0 32767
max edge height difference: 0 quantised units
Three things to confirm. The u and v ranges must reach 0 and 32767, or the tile does not span its own bounding box and a gap will appear at the edge. Edge residuals must be exactly zero in quantised units — not merely small, because the runtime welds by exact match. And layer.json must list every zoom level you built, since a missing level makes the client fall back to a coarser one without reporting anything.
Common Errors
Hairline cracks along every tile boundary. Either the edge vertex lists are absent, or neighbours disagree on their shared edge. Check the residual as in step 4; if the counts differ, the pyramid was built from more than one source.
Terrain is mirrored north to south. The server is addressing tiles in XYZ while the format uses TMS. Convert with y_tms = 2**zoom - 1 - y_xyz.
Every tile fails to parse in the client. The server re-compressed already-gzipped tiles, or omitted Content-Encoding: gzip so the client never inflated them. Fetch one tile with curl -I and check the headers.
Terrain floats above or below the buildings. The DEM was orthometric and the client expects ellipsoidal heights, so the two layers differ by the geoid separation. Convert the mosaic through a compound CRS before tiling.
Frequently Asked Questions
How deep should the pyramid go?
To the zoom whose tile size matches the DEM resolution — beyond that the mesher is interpolating rather than describing. For a 1 m DEM at mid-latitudes that is around zoom 15 or 16.
Should I use quantized mesh or 3D Tiles for terrain?
Quantized mesh where CesiumJS is the client and terrain is a separate layer, because it is what the terrain provider API expects. 3D Tiles where terrain and city ship as one tileset, which simplifies the pipeline at the cost of losing the dedicated terrain rendering path.
What vertex budget per tile?
The builder chooses adaptively, and typical tiles land between 1,000 and 8,000 vertices. Forcing a higher budget rarely improves the visible surface and multiplies the pyramid’s size, because most of the extra vertices land on ground that is already flat.
One consequence of that quantisation is worth planning for. Because precision is a function of the tile’s height range rather than of its ground size, the coarse levels of a pyramid over varied terrain are the least precise part of it — exactly the levels a viewer sees first. Where that matters, the remedy is not a finer format but a shallower start zoom, so the client never renders the levels whose vertical resolution has degraded past the tolerance.
The second planning note concerns rebuilds. A quantized-mesh pyramid is not incrementally updatable in any meaningful sense: changing one DEM tile changes the meshing of every level above it, because each coarse tile is derived from its children. Budget for full rebuilds, keep the source mosaic, and version the pyramid by build so a rollback is an alias swap rather than a regeneration.
Related Guides
- Digital Elevation Model Workflows — producing the DEM this consumes
- Merging and Mosaicking DEM Tiles with GDAL — the single mosaic the pyramid must come from
- Handling Vertical Datums and Geoid Separation — why terrain floats above buildings
Back to Digital Elevation Model Workflows.