Builder docs

3D Terrain Pipeline

Turn drone footage into Terrain-RGB PMTiles and Gaussian-splat OGC 3D Tiles rendered by MapLibre.

By · Updated

Overview

geolith compiles raw drone capture into web-streamable 3D terrain through two subcommands:

TrackCommandInputOutput
Terrain-RGBgeolith terrainGeoTIFF DEMraster-dem PMTiles for MapLibre setTerrain + hillshade
Gaussian splatsgeolith splat-tilesTrained 3DGS PLYOGC 3D Tiles 1.1 (KHR_gaussian_splatting + KHR_gaussian_splatting_compression_spz_2)

Capture Recipe (upstream of geolith)

drone video (5–10 min 4K, orbit or lawnmower grid)
  → frame extraction @ 2–5 fps (~200–500 frames)
  → OpenDroneMap: --dsm --dtm --orthophoto
  → GeoTIFF DEM (+ orthophoto)

For splats, continue from the same frames:

frames → COLMAP (`colmap mapper --global`)
  → gsplat / Nerfstudio `splatfacto` (Apache-2.0)
  → PLY splat export

Track A — GeoTIFF DEM → Terrain-RGB PMTiles

# Drone survey (UTM GeoTIFF from OpenDroneMap / Pix4D)
geolith terrain \
  --dem ./data/belvedere_dsm.tif \
  --output ./data/belvedere-terrain.pmtiles \
  --min-zoom 8 --max-zoom 16 \
  --encoding mapbox \
  --name belvedere-glacier

# Regional demo from a public DEM (Copernicus GLO-30, EPSG:4326) —
# e.g. the Garhwal Himalaya (Kedarnath / Badrinath / Nanda Devi):
curl -LO https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N30_00_E079_00_DEM/Copernicus_DSM_COG_10_N30_00_E079_00_DEM.tif
geolith terrain \
  --dem Copernicus_DSM_COG_10_N30_00_E079_00_DEM.tif \
  --output ./data/garhwal-himalaya.pmtiles \
  --min-zoom 6 --max-zoom 12 \
  --name garhwal-himalaya

A live archive built exactly this way serves at https://samples.geolith.app/terrain/garhwal-himalaya.pmtiles (257 tiles, z6-z12, the 79-80°E / 30-31°N Copernicus tile).

FlagDefaultPurpose
--demrequiredInput GeoTIFF DEM (Float32/Int16, tiled or stripped, LZW/Deflate)
--outputrequiredOutput PMTiles path
--encodingmapboxmapbox (Terrain-RGB) or terrarium
--min-zoom / --max-zoom0 / 14Zoom range
--tile-size512Tile edge in pixels
--dem-crsautoEPSG override when the GeoTIFF lacks geokeys
--nameterrainLayer name in archive metadata

The CRS is read from the GeoTIFF GeoKeyDirectory. WGS84 (4326), Web Mercator (3857), and UTM zones on WGS84/ETRS89/RDN2008/NAD83 datums resolve automatically; anything else needs --dem-crs.

Tiles with no DEM coverage are skipped, nodata pixels encode as sea level, and elevations bilinearly resample from the source raster.

Rendering

map.addSource('terrain', {
  type: 'raster-dem',
  url: 'pmtiles://https://samples.geolith.app/terrain/garhwal-himalaya.pmtiles',
  encoding: 'mapbox',
  tileSize: 512,
});
map.setTerrain({ source: 'terrain', exaggeration: 1.2 });
map.addLayer({ id: 'hills', type: 'hillshade', source: 'terrain' });

Serving works through tileserver-rs or any PMTiles host — the archive declares tile_type PNG with no tile compression, per the PMTiles v3 spec.

Track B — 3DGS PLY → OGC 3D Tiles

geolith splat-tiles \
  --ply ./data/scene.ply \
  --output ./data/tileset \
  --longitude 7.9797 --latitude 45.9756 --altitude 2250 \
  --max-splats-per-tile 65536
FlagDefaultPurpose
--plyrequiredTrained 3DGS PLY (INRIA / gsplat / splatfacto layout)
--outputrequiredOutput directory (tileset.json + tiles/*.glb + scene.spz)
--longitude / --latituderequiredGeographic anchor of the scene
--altitude0Anchor height above the ellipsoid in metres
--max-splats-per-tile100000Octree leaf capacity
--max-depth6Octree depth limit
--root-geometric-error64Root tile geometric error
--antialiasedfalseMark splats trained with antialiasing
--emit-scene-spztrueAlso write a standalone scene.spz
--crop-percentile0Trim this % from both ends of every axis, removing the hallucinated far-field shell of inward-facing captures
--scale1Uniform scene scale factor (positions and splat footprints together)

Pick assets that suit a map: outward-facing captures of a subject at true metric scale render crisply from map viewpoints, while indoor inward-facing scans (the MipNeRF-360 datasets) show their hallucinated shell from outside. For a bad world scale, --scale normalises it; for a noisy far field, --crop-percentile trims it.

The output shape matches the reference 3DGS-PLY-3DTiles-Converter: an octree of GLB tiles with refine: REPLACE, each carrying a gzip-compressed SPZ stream under KHR_gaussian_splatting_compression_spz_2, georeferenced by an east-north-up ECEF transform on the root tile. Parents hold decimated previews of their subtree so coarse zooms stay light.

Rendering

tileset.json + GLB tiles are static files — serve from tileserver-rs, R2, or any CDN. Render in MapLibre through @geoql/v-maplibre:

<!-- @geoql/v-maplibre/splat — single-file splat -->
<VLayerSplat url="https://samples.geolith.app/splat/cactus/scene.spz"
  :longitude="7.9797" :latitude="45.9756" :altitude="2250" :lod="true" />

<!-- @geoql/v-maplibre/3d-tiles — streamed tileset with LOD -->
<VLayer3DTiles url="https://samples.geolith.app/splat/cactus/tileset.json" :error-target="6" />

Sample Data

The Belvedere Glacier open dataset provides real multi-year UAV DSMs and orthophotos for Track A; any INRIA-format splat export exercises Track B.