On-demand 3D terrain tiles as GLB, at 1:1 world scale, addressed like a slippy map:
zoom/x/y. Tiles are built from public heightmap and imagery providers on first request,
cached, and served with immutable HTTP caching — ready to drop into three.js, Bevy, or any
glTF-capable engine.
- One GLB per tile: mesh, JPEG texture, per-vertex normals, PBR material — no extensions, no skirts.
- Any zoom 1–22: what the providers don't serve is derived from the closest lower zoom.
- Per-tile metadata: size, height range, and LOD geometric error as a JSON sidecar.
- Stateless scaling: cache on S3 and run any number of replicas.
The tile format — frame, mesh, heights, GLB layout, metadata, HTTP contract — is specified in
specs.md.
cargo run --release -- serve
open http://127.0.0.1:8080/example/The bundled viewer loads a block of tiles around the Grand Canyon; parameters live in the URL.
open-tiles serve --bind 0.0.0.0:8080 --cache-dir .cache -v| endpoint | description |
|---|---|
GET /{z}/{x}/{y}.glb |
the tile — built on first request, then served from the cache |
GET /{z}/{x}/{y}.json |
the tile's metadata document — computed on demand, then cached |
GET /metadata |
generate missing tile metadata, streaming progress as SSE (see below) |
GET / |
service description: version, fingerprint, URL template, conventions |
GET /healthz |
liveness — answers ok |
GET /example/ |
the bundled three.js viewer |
curl -I http://127.0.0.1:8080/12/772/1607.glb
# HTTP/1.1 200 OK
# content-type: model/gltf-binary
# cache-control: public, max-age=31536000, immutable
# etag: "<fingerprint>-548760"
# access-control-allow-origin: *- Tiles are cached at
glb/{fingerprint}/z/x/y.glb. The fingerprint hashes everything that changes the bytes (version, resolution table, provider URLs and zoom hints, JPEG quality), so a configuration change never serves stale geometry; old fingerprint directories can simply be deleted. - Concurrent requests for one tile share a single build;
--max-builds(default: CPU count) bounds parallel builds. One process, in-memory dedup — no broker. - Conditional requests:
If-None-Match→304;HEADis supported. - Errors are JSON bodies:
400invalid tile ·404nothing upstream at any zoom (max-age=3600) ·502upstream failure ·500decode/I-O. - CORS
*by default (disable with--no-cors). - Provider flags (
--texture-url,--heightmap-url,--normals-url, the--*-max-zoomhints,--no-normals,--timeout) work as forbuild.
Every built tile gets a JSON document with the same name and a .json extension
(glb/{fingerprint}/z/x/y.json):
{
"zoom": 12,
"x": 772,
"y": 1607,
"tile_size_m": 7908.657,
"min_height_m": 731.9,
"max_height_m": 2385.4,
"geometric_error_m": 14.2
}| field | meaning |
|---|---|
tile_size_m |
tile edge in metres at the tile's own centre latitude |
min_height_m / max_height_m |
height range of the tile's heightmap, metres above sea level |
geometric_error_m |
maximum distance between this tile's surface and its 4 children's at zoom + 1; 0 when no finer data exists |
The geometric error is measured, not estimated: the children's heightmaps are compared against
the tile's own surface (evaluated bilinearly at the children's texel centres) and the maximum
absolute difference is recorded. A 0 tells an LOD consumer there is nothing to refine into —
the tile is at (or beyond) the height provider's deepest coverage. A typical screen-space-error
refinement test is:
split while geometric_error_m · screenHeightPx / (2 · distance · tan(fovY / 2)) > budgetPx
Metadata is produced three ways, all writing the identical document:
-
During every build — the server writes the document right after caching a freshly built tile;
open-tiles buildwrites it next to the output file (tile.glb→tile.json). A metadata failure never fails the build; it is logged and left for a later pass. -
open-tiles metadata— scans the cache and backfills every built tile that lacks a document (existing ones are skipped, never rewritten):open-tiles metadata --cache-dir .cache # metadata: 118 written, 3024 skipped, 0 failed -
GET /metadata— the same scan, triggered over HTTP. Progress streams as server-sent events, one JSON event per tile and a final summary:curl -N http://127.0.0.1:8080/metadata # data: {"tile":"12/772/1607","key":"glb/…/12/772/1607.json","status":"written"} # data: {"tile":"12/772/1608","key":"glb/…/12/772/1608.json","status":"skipped"} # data: {"done":true,"written":1,"skipped":1,"failed":0}
Closing the connection stops the scan; only one scan runs at a time (a concurrent request answers
409).
Computing the error needs the 4 child heightmaps, fetched exactly like build inputs: from the
cache when present, from the provider (with write-through and .404 markers) when not.
cargo build --release
# which tile covers a coordinate?
open-tiles lookup 36.1 -112.1 12
# tile: 12 772 1607
# size_m: 7908.657
# …
# build it (downloads into .cache/, reuses on the next run)
open-tiles build 12 772 1607 -v
# 12-772-1607.glb (548760 bytes)
# 12-772-1607.json
# any zoom works; missing provider zooms derive from the closest lower one
open-tiles build 20 790547 411413 -v
# INFO heightmap 20/…: derived from zoom 15 (15/24704/12856)
# INFO imagery 20/…: derived from zoom 19 (19/395273/205706)
# 20-790547-411413.glb (7036 bytes)| command | description |
|---|---|
build z x y |
build one tile; writes the GLB and its metadata JSON |
serve |
the HTTP server |
lookup lat lon z |
which tile covers a coordinate, with bounds and size |
metadata |
backfill missing metadata for every built tile in the cache |
refresh-404 |
forget remembered provider 404s (--zoom N, --kind …) |
Options for build:
| flag | default | meaning |
|---|---|---|
-o, --output <path> |
./{zoom}-{x}-{y}.glb |
where to write |
--cache-dir <dir|s3://…> |
.cache ($CACHE_DIR) |
input cache: a directory (layout-compatible with raytiles/bevytiles) or an S3 bucket |
--resolution <n> |
per-zoom table (below) | vertices per edge for this zoom (2..=257) |
--texture-url, --heightmap-url, --normals-url |
Esri / AWS Terrarium / AWS normals | provider templates with :zoom: :x: :y: tokens |
--texture-max-zoom, --heightmap-max-zoom, --normals-max-zoom |
19 / 15 / 15 |
deepest zoom to ask the provider for; deeper tiles derive from there |
--no-normals |
off | skip the normals fetch and the NORMAL attribute |
--timeout <s> |
10 |
HTTP read timeout |
-v / -vv |
log fetches, fallbacks and timings |
The default provider URLs are examples — any XYZ tile source can take their place; see Data providers.
Exit codes: 0 ok · 2 usage / invalid tile / bad resolution · 3 nothing upstream at any
zoom · 4 network, decode or I/O failure.
Terrarium heightmaps stop at z15; Esri imagery at z19 nearly everywhere. A request at any zoom
starts at min(zoom, hint) and, on 404, walks down to the closest lower zoom that exists:
- heights — the tile becomes a window into its ancestor's padded height field: one bilinear interpolation from source texels to vertices, no intermediate image, no cracks at ancestor boundaries.
- imagery — the ancestor's image is cropped to the tile's sub-square and upscaled to 256².
- normals — sampled from the ancestor's normal map through the same window; when no zoom has one, normals are synthesized from the height field instead of failing the tile.
- A provider 404 leaves a zero-byte
{y}.png.404marker in the cache so the walk never repeats a known-missing request;refresh-404deletes the markers. extras.terrain_source_zoom/extras.imagery_source_zoomrecord what was used.
Watertightness holds between neighbours whose heightmaps resolve to the same source zoom. At a provider's coverage boundary (one tile at z, its neighbour only at z−1) a crack of up to one z−1 texel's gradient is possible — that is the dataset's edge.
Vertex spacing tracks the data: every tile carries 256 source texels while its metre size halves
per zoom. Defaults (vertices per edge), overridable with --resolution:
| zoom | 1–7 | 8–15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
|---|---|---|---|---|---|---|---|---|---|
| vertices/edge | 257 | 129 | 129 | 65 | 33 | 17 | 9 | 5 | 3 |
| raw mesh | 3.7 MB | 0.7 MB | 0.7 MB | 0.18 MB | 46 KB | 12 KB | 3.4 KB | <1.5 KB | <1 KB |
Whatever zoom the heights really came from, the value is capped at (256 >> dz) + 1 — beyond
that a grid only interpolates. extras.resolution records the value used
(resolution_requested when capped).
- One mesh, one JPEG texture, one plain PBR material, per-vertex normals (no skirts, no
extensions). Normals come from the providers' normal tiles (tilezen
normal) or, where those end, are derived from the heights;--no-normalsdrops them (~35 % smaller tiles). - Frame: right-handed, Y-up, metres.
+Xeast,+Zsouth. Origin at the tile's north-west corner; the tile spans[0, size_m]in X and Z. - Y is metres above sea level. Every tile shares the same
Y = 0, so laying tiles out is an X/Z translation only:(x · size_m, 0, y · size_m). size_mis the tile's edge at its own centre latitude (40 075 016.686 · cos(lat) / 2^zoom). Adjacent rows therefore differ slightly (≈1 % at z9, negligible from z14); rescale byextras.tile_size_mfor a strictly uniform grid.- Same-zoom neighbours are watertight: boundary vertices are sampled over a height field padded with the neighbours' edge texels, so both tiles compute the identical value.
- Root
extras:zoom, x, y, tile_size_m, bounds{north,south,west,east}, resolution, resolution_requested?, terrain_source_zoom, imagery_source_zoom, sources{imagery, elevation}.
--cache-dir (or $CACHE_DIR) holds both tiers — provider inputs
({texture,heightmap,normal}/z/x/y.png) and built tiles plus metadata
(glb/{fingerprint}/z/x/y.{glb,json}). It is a local directory or s3://bucket[/prefix]:
AWS_REGION=eu-central-1 open-tiles serve --cache-dir s3://my-tiles/open-tiles --bind 0.0.0.0:8080With S3 the server is stateless: any number of replicas — or ephemeral containers on Cloud Run / Fargate — share one cache, and nothing is rebuilt after a restart.
- Credentials and region come from the standard AWS environment (
AWS_REGION,AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,AWS_PROFILE, or an IAM role — instance profile, ECS task role, EKS IRSA). No open-tiles-specific settings. - IAM:
s3:GetObject,s3:PutObject,s3:DeleteObjectonbucket/prefix/*ands3:ListBucketon the bucket. WithoutListBucket, S3 answers403instead of404for missing keys; open-tiles treats such a403as a miss (warning once) so it still works, but grantingListBucketkeeps real permission problems visible and letsrefresh-404and the metadata scan list keys. - TLS: the S3 client trusts Mozilla's root CAs (bundled) plus the platform's — it works
in a slim container without
ca-certificatesand behind a corporate proxy whose CA is installed or named bySSL_CERT_FILE. - S3-compatible stores (MinIO, LocalStack, Ceph, R2, …): set
AWS_ENDPOINT_URL(orAWS_ENDPOINT_URL_S3); path-style addressing switches on automatically. build,metadataandrefresh-404accept--cache-dir s3://…the same way.- Each cache operation is one S3 request (a build touches ~10 inputs, in parallel). Concurrent writers of one key are harmless: puts are atomic and the bytes identical.
- The S3 backend is the
s3cargo feature (on by default);cargo build --no-default-featuresdrops the AWS SDK.
docker run --rm -p 8080:8080 -e AWS_REGION=eu-north-1 \
-e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… ghcr.io/opentiles/opentiles-server:latestThe container caches on S3 by default ($CACHE_DIR, credentials from the AWS environment or
the platform's IAM role) so replicas stay stateless; $PORT is honoured for Cloud Run-style
platforms, and container arguments are appended to open-tiles serve. For a local cache
instead, mount a volume:
docker build -t open-tiles .
docker run --rm -p 8080:8080 -e CACHE_DIR=/data -v open-tiles-cache:/data open-tilesuse open_tiles::{build_tile, Config, TileId};
let cfg = Config::default();
let tile = TileId::new(12, 772, 1607)?;
let glb = build_tile(&cfg, tile)?;
let meta = open_tiles::metadata::compute(&cfg, tile)?; // TileMetadataConfig mirrors the engines' NetworkConfig (cache, provider URL templates + zoom hints,
timeouts) plus the per-zoom resolution table. Config::with_cache_dir(path) caches on disk;
Config { cache: open_tiles::store::open("s3://bucket/prefix")?, ..Default::default() } caches
in S3 — or implement the Store trait for anything else. load_inputs exposes the windowed
height field, imagery and source tiles without building the GLB;
metadata::generate_missing[_with] is the scan behind the CLI and the SSE endpoint.
cargo test # fully offline: synthetic Terrarium/imagery fixtures + a local HTTP servertests/s3_tests.rs additionally runs against a real bucket when
OPEN_TILES_TEST_S3=s3://bucket/prefix is set (any S3-compatible endpoint; the file's header
shows a one-line MinIO setup) and is skipped otherwise.
The built-in provider URLs are defaults, not a dependency: imagery © Esri World Imagery,
elevation and normal maps from the Mapzen Terrain Tiles on AWS Open Data (Terrarium /
normal encodings). Any slippy-map (XYZ) tile provider works in their place — point
--texture-url, --heightmap-url and --normals-url at a template with :zoom:, :x:,
:y: tokens (heightmaps must be Terrarium-encoded PNGs, normals tilezen-encoded), and set the
matching --*-max-zoom hints to the provider's deepest coverage.
Whichever providers you use, their licenses and terms of use are yours to comply with —
the tiles you build and serve embed that data (the imagery is baked into every GLB). The
default Esri and Mapzen sources have their own terms. Each tile records attribution strings in
extras.sources; they default to the Esri/Mapzen credits, so when you swap providers via the
library, set Provider::imagery_attribution / elevation_attribution to match.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.