A fast OpenStreetMap renderer and real-time 3D map server written in Rust.
Load an .osm.pbf extract and it renders every tile on demand: rotated,
tilted and in 3D. It can also write print-quality posters or a static tile
pyramid offline.
| Financial District, z17 | Civic Center: roof shapes, mapped colours, parts |
|---|---|
![]() |
![]() |
| Brooklyn Bridge: decks on pillars over their shade | New York City, 184 MB extract |
|---|---|
![]() |
![]() |
WebGL client, like Google Maps
maps servemaps the map from a snapshot (or builds it from an extract) and builds geometry tiles on request: styled, triangulated ground, road ribbons, buildings and model instances (src/vtile.rs).- The browser draws every frame itself with WebGL2 and a perspective camera, so pan, zoom, rotate and tilt are smooth at 60 fps.
- Only tiles inside the view frustum load, near ones at full detail and far ones coarser, with haze toward the horizon and parent tiles standing in while children load.
- Tiles are compact: 16-bit positions in columns, normals derived in the shader, and flat-roofed buildings sent as outlines that the client extrudes. A Lower Manhattan z15 tile is 76 KB gzipped (it was over 500 KB with 32-bit float meshes).
- Buildings never vanish with distance: tiles below z15 carry simplified flat-roofed versions, fewer and larger as tiles get coarser, down to the skyline.
- Material 3 controls, light and dark themes.
- The server-rendered raster viewer is still available at
/raster.
True 3D, painted in depth order
- Buildings and
building:parts rise from their mappedmin_heightto theirheight, so skyscraper setbacks and floating parts appear where they are mapped. Untagged buildings get typical heights for their type. - Roofs follow
roof:shape: flat, gabled, hipped, pyramidal or cone, skillion, dome or onion. Each roof face is shaded under one sun, androof:colourandbuilding:colourare used when mapped. - Facades show floors as window bands. At night some windows are lit.
- Objects mapped on roofs (
location=roof, such as New York's wooden water tanks) stand on their building. - Bridges and viaducts are raised decks on pillars. Walls, hedges, fences and dams are vertical faces. Power lines and aerial tramways hang between pylons. Tree rows become rows of trees. Water lies below the land, so shorelines show banks.
- Everything with height is painted back to front, so a tall building in front hides the street, overpass or tower behind it. Tunnels are hidden in tilted views.
Bespoke models for street objects
- About 40 kinds of point objects have their own 3D model at real
dimensions:
- trees (with conifer variants)
- lamps, traffic signals with three lamps, stop signs
- power poles with crossarms, braced lattice pylons
- flagpoles with flags, chimneys, towers
- wooden water towers, cranes
- hydrants, benches, picnic tables, post boxes, bike racks
- bus shelters, phone booths, subway entrances with globe lamps
- swing sets, monuments, buffer stops, and more
Full cartography
- About 140 feature kinds, chosen from a census of every tag in the NYC
extract (
examples/census.rs). Only physical things are drawn; names, addresses and routes are not. - Golf courses, sport pitches by sport, swimming pools, parking stalls, road-surface areas, piers, platforms and aprons are all covered.
- Coastlines become land polygons, multipolygons keep their holes, and road casings merge cleanly at junctions.
- Two themes: Daylight and Midnight.
cargo build --release
# Real-time server and viewer: open http://localhost:8080
./target/release/maps serve nyc.osm.pbf --addr 0.0.0.0:8080 --cache-mb 1024
# A poster: 45° tilt by default, any bearing, any region
./target/release/maps render nyc.osm.pbf -o fidi.png \
--bbox=-74.017,40.702,-74.006,40.710 --zoom 17 --pitch 50 --bearing 29 --scale 2
# Straight down, the whole extract, dark theme
./target/release/maps render nyc.osm.pbf -o nyc.png --pitch 0 --theme dark
# A static tile pyramid (oblique 3D, Web Mercator aligned) with a Leaflet viewer
./target/release/maps tiles nyc.osm.pbf -o tiles --min-zoom 10 --max-zoom 17 --scale 2
# Feature counts and bounds
./target/release/maps info nyc.osm.pbf
# Build once into a snapshot; every command maps it from disk at once
./target/release/maps build us-northeast.osm.pbf -o northeast.snap
./target/release/maps serve northeast.snap
# Convert an older MAPSNAP1 snapshot without rebuilding
./target/release/maps convert old.snap northeast.snapIn the viewer, drag to pan and scroll to zoom. Right-drag or Ctrl+drag
rotates and tilts; Shift+drag tilts. On touch screens, pinch to zoom, twist to rotate and
swipe two fingers vertically to tilt. The URL hash
(#zoom/lat/lon/bearing/pitch) is shareable.
Extracts for any region are available from BBBike or Geofabrik.
maps serve also exposes a JSON API under /v1, with interactive docs at
/v1/docs and the OpenAPI 3.1 spec at /v1/openapi.json.
| Path | Purpose |
|---|---|
/v1/info |
Bounds, center, data version, counts |
/v1/kinds |
Feature kinds in the extract with counts |
/v1/features?bbox=w,s,e,n&kind=building&page=1 |
GeoJSON features in a box (paged) |
/v1/features/{id} |
One feature |
/v1/lookup?lat=&lon=&radius= |
What is at a point, tallest first |
/v1/tallest?bbox= |
Tallest structures in a box |
/v1/static.png?lat=&lon=&zoom=&bearing=&pitch=&v= |
A 3D render as PNG; with v (the data version) it is cached forever (/v1/static also works) |
Features carry real attributes: height_m, min_height_m, roof_shape,
levels, colours, heading_deg for street furniture, and a third
coordinate (meters) on raised roads and rails. Errors are
{"detail": {"code", "message"}}.
curl 'localhost:8080/v1/tallest?bbox=-74.016,40.704,-74.008,40.712&limit=3'
curl -o fidi.png 'localhost:8080/v1/static.png?lat=40.7075&lon=-74.0115&zoom=17&bearing=29&pitch=50'The binary is self-contained, pure Rust, and has no system dependencies beyond libc. A multi-stage Dockerfile builds a 53 MB distroless image that runs as non-root:
docker build -t maps .
docker run -p 8080:8080 -v $PWD/nyc.osm.pbf:/data/map.osm.pbf:ro maps
# or: MAP=./nyc.osm.pbf docker compose up --buildEndpoints:
| Path | Purpose |
|---|---|
/ |
WebGL viewer |
/raster |
Raster viewer (server-rendered tiles) |
/meta.json |
Bounds, styles, data version |
/geo/{version}/{theme}/{z}/{x}/{y}.bin |
Geometry tiles (gzip) |
/models/{version}/{theme}.bin |
Model templates for instances |
/tiles/{version}/{style}/{bearing}/{pitch}/{z}/{x}/{y}[@2x].png |
Tiles. Immutable and cacheable forever, because version changes with the data. |
/healthz |
Liveness probe (ok); maps check probes it from inside the image |
/metrics |
Prometheus counters: requests, cache hits, renders, render time, geometry tiles and bytes sent, and the size of the raster and geometry tile caches |
The server shuts down gracefully on SIGTERM. Pushes to main publish
ghcr.io/kensac/maps (latest and sha-<short>).
Building the map from an extract needs about twice the memory the map
itself takes. maps build does that once and writes a snapshot, which every
command accepts in place of the .osm.pbf. A snapshot is memory-mapped and
used in place, spatial index included, so a server is up at once and holds
only the pages tiles touch, which the kernel can drop again:
| US Northeast (1.8 GB PBF, 25 M features) | From PBF | From snapshot |
|---|---|---|
| Ready to serve | 72 s | 0.04 s |
| Peak memory | 10 GB | 3 MB, plus 200 MB of file pages read by the checks |
| Anonymous memory after 180 tiles in five cities (Linux) | 135 MB, plus 1.5 GB of file pages |
The snapshot is 4.3 GB, of which the index is 0.5 GB (21 bytes a feature). A tile that touches pages not yet read waits for the disk: in that test first renders took about twice as long as repeats.
Format. MAPSNAP2 (src/snapshot.rs): a fixed header (bounds, origin,
background, tallest structure, and the offset and length of each section),
then 64-byte-aligned sections: points, ring starts, features (56-byte
records), roof and facade details (24-byte records), elevations, building
groups, per-kind feature counts (so /v1/kinds needs no scan) and one packed
Hilbert R-tree per zoom bucket (src/index.rs; 16 children a node, f32
boxes, u32 ids). Opening checks the header, every section's bounds and
alignment, that ring starts are in order and end at the last point, and
that the index has exactly the shape the builder writes, with every id in
range. That reads the ring starts and index ids, about 200 MB for US
Northeast (40 ms from the page cache). The 1.4 GB of feature records are not
scanned: their kind and roof bytes go through checked accessors, and their
ring, detail, elevation and group indices are bounds-checked where they are
used, so a damaged feature draws nothing. A render that panics anyway fails
its own request, not the server.
Older snapshots. MAPSNAP1 files still load, copied onto the heap with
the index rebuilt (5.3 GB peak, 4.7 GB after loading, for US Northeast).
maps convert old.snap new.snap converts one in seconds without the
extract, copying the arrays and building only the index (1.3 GB peak for US
Northeast).
Replacing a snapshot. A running server reads the file in place, so
changing it under the server crashes it (SIGBUS) or serves garbage. Never
cp over a snapshot or write into it. Write the new file next to it as
<name>.partial, then mv it over the old one on the same filesystem (a
rename leaves the old file intact for anyone still mapping it), then
restart. maps build and maps convert already write <output>.partial
and rename it, syncing both the file and its directory.
Measured on an Apple M1 Pro (10 cores), release build.
| Extract | PBF | Features | Load |
|---|---|---|---|
| JFK airport | 2.3 MB | 59 k | 0.12 s |
| Manhattan + Brooklyn | 16 MB | 361 k | 0.34 s |
| New York metro | 184 MB | 3.9 M | 4.5 s, then 1.7 GB resident; from a snapshot (590 MB) at once, with a 70 MB footprint after 300 tile requests |
Serving the NYC extract (Midtown, 512 px retina tiles at bearing 29° and pitch 45°, 16 concurrent clients):
| Average latency | Max | |
|---|---|---|
| Uncached tile (rendered on demand, about 16 ms CPU) | 18 ms | 99 ms |
| Cached tile | 0.5 ms | 3 ms |
A tilted 8000 px poster of the whole metro area renders in about 3 s after loading. The previous version of this project took 17 s for a single unstyled 4096 px image.
.osm.pbf ─► ingest ─► map ─────────────────► render ─► server / tiles / poster
3 passes rings, coastlines, ground layers, then a
in parallel parts, roofs placed, depth-sorted 3D scene
R-tree per zoom
Ingest (src/ingest.rs) reads the PBF in three parallel passes over its
compressed blobs:
- Relations.
- Ways, skipping node blobs undecompressed.
- Only the referenced nodes, plus tagged objects, skipping way blobs.
It classifies tags into physical kinds with real or typical dimensions
(src/classify.rs).
Map building (src/map.rs, src/assemble.rs):
- Assembles multipolygon rings and orients them for non-zero filling.
- Turns coastlines into land.
- Drops building outlines that are drawn through their parts, and lifts rooftop objects onto their buildings.
- Sorts features into draw order and indexes them in one packed Hilbert
R-tree per zoom bucket (
src/index.rs), stored in the snapshot.
Rendering (src/render/) uses an orthographic camera with bearing and
pitch. The ground is foreshortened by cos(pitch) and heights rise by
sin(pitch); because that map is affine, any rotated, tilted view is still a
regular tile grid.
- Ground layers are projected, simplified, clipped and rasterized with tiny-skia.
- Everything with height goes into a scene (
scene.rs) and is painted back to front. That includes solids with real roof geometry (solids.rs), bespoke object models (models.rs, on a small 3D mesh rasterizer inmesh.rs) and raised line chunks (lines.rs). - The paint order is a pure function of geometry and camera, so tiles agree at their edges.
Geometry tiles (src/vtile.rs) reuse the same styling and 3D models
but emit vertex buffers instead of pixels: earcut fills, mitered line
ribbons with per-vertex height, building meshes with roofs, and instances
of shared model templates. Flat-roofed solids with one outline go out as
prisms (outline, heights, colors, roof triangles) for the client to
extrude. Detail drops with zoom: full solids, walls and pillars from z15,
simplified buildings below it, street objects from z16. The format (GTL3) is documented at the
top of src/vtile.rs; FORMAT is part of the data version, so a format
change never reuses cached tiles. Every cacheable URL ends in .bin or
.png, which CDNs such as Cloudflare cache by default.
Serving (src/server.rs):
- Tokio and axum handle HTTP; rendering runs on the rayon CPU pool.
- A size-bounded moka cache coalesces concurrent requests for the same tile.
- Renders whose clients have gone are skipped, and a semaphore bounds in-flight work.
- Render buffers are reused per thread.
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt
cargo run --release --example census -- nyc.osm.pbf # tag census of an extractMap data © OpenStreetMap contributors, available under the Open Database License.




