Walk or fly across the real surface of the Earth, in a browser, with no install and no account. Elevation is streamed from open data as you move.
No dependencies. No build step. No package manager. Roughly 1,000 lines of plain ES modules and one WebGL2 shader pair.
python serve.py
Then open http://localhost:8080/.
Use serve.py, not python -m http.server. On Windows, Python's built-in
server reads MIME types from the registry, where .js is frequently registered
as text/plain. Browsers refuse to execute ES modules served with a
non-JavaScript MIME type, so the page loads, the spinner sits there, and not one
line of the code ever runs. serve.py sets the types explicitly. It also
disables caching so reloads pick up edits.
Any correctly configured static server works too (npx serve, nginx, GitHub
Pages). It does have to be HTTP — file:// blocks both ES modules and workers.
Open http://localhost:8080/diag.html. It tests each layer separately — MIME types, module execution, module workers, WebGL2 and shader compilation, tile fetch and CORS, OffscreenCanvas — and names the cause. It deliberately uses no modules itself, so it still runs when module loading is the broken part.
To publish, push the repo and turn on GitHub Pages from the root. That is the whole deployment.
| Input | Action |
|---|---|
| click | capture the mouse, esc releases |
W A S D |
move |
shift |
run (walking) |
G or space space |
toggle flight |
space / shift |
up / down (flying) |
ctrl |
boost, 8x |
| wheel | flight speed |
R |
return to the spawn point |
V X B C |
toggle water, roads, built-up, land cover |
F |
fog on/off (off by default) |
H |
hide the help panel |
The view slider sets render distance, from about 27 km to about 600 km.
The URL carries the camera, so a location is shareable by copying the address bar:
index.html#lat=27.9881&lon=86.9250&alt=9000&mode=fly
A few to start with:
| Place | Hash |
|---|---|
| Lake Ontario (default spawn) | #lat=43.87172&lon=-77.68043 |
| Lauterbrunnen, Switzerland | #lat=46.5590&lon=7.9310 |
| Everest, from the south | #lat=27.9500&lon=86.9250&alt=6000&mode=fly |
| Grand Canyon | #lat=36.0600&lon=-112.1100&alt=2200&mode=fly |
| Milford Sound, New Zealand | #lat=-44.6700&lon=167.9200 |
| Atacama | #lat=-23.1000&lon=-67.7500&alt=5000&mode=fly |
| Faroe Islands | #lat=62.1000&lon=-7.0000&alt=900&mode=fly |
Data. One endpoint, no API key:
s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png. Elevation is
packed into RGB with a 32,768 m offset. Underneath it is NASADEM and SRTM at
about 30 m worldwide, which is why the finest level is zoom 12: past that the
data is only interpolation.
Clipmap. Six levels, zoom 12 down to zoom 7. Each level is a 4x4 block of tiles whose origin is snapped to an even tile coordinate. That one constraint makes the nesting exact: a 4x4 block at zoom z+1 covers precisely 2x2 whole tiles at zoom z, aligned to the coarse grid, so the coarse level drops exactly those and there is no gap and no overlap anywhere. About 76 tiles and 690k triangles on screen at any time, regardless of where you are or how fast you are going.
Camera-relative rendering. Vertex positions are stored in tile-local mercator metres and never change. The camera offset is folded into a per-tile uniform computed in float64 on the CPU. Float32 vertex data therefore stays accurate everywhere on Earth, and there is no origin-rebasing machinery.
Meshing off the main thread. Fetch, PNG decode and mesh generation all happen in workers. Buffers come back as transferables. Doing this on the main thread produces a visible hitch on every single tile load.
Skirts. Each tile drops a 150 m vertical wall from its border, which hides the cracks between levels. Back-face culling is off, so the winding does not matter.
Two depth passes. A single 0.5 m to 600 km depth range has nowhere near enough precision and distant ridges z-fight into mush. Far levels are drawn first, the depth buffer is cleared, then the near levels are drawn over the top. The split planes are derived from the actual block extents; a fixed constant opens a visible gap ring on the horizon.
No normals, no textures, no colour attributes. The only vertex attribute is
a vec4. Flat shading comes from screen-space derivatives and the colour ramp
is computed from elevation in the fragment shader, then quantised to 5 bits per
channel.
Elevation cannot tell you what water is. Lake Superior sits at 183 m, Erie at 174 m, Ontario at 74 m, so a colour ramp renders one lake system as three different greens, while the Caspian comes out correctly blue purely because it happens to be 28 m below sea level. Water is a category, not a height.
The same is true of forests, farmland, ice and cities. So they all arrive as vectors from one OpenStreetMap tile and are draped as textures.
Two images per tile, channel-packed so a layer can be switched off with a single uniform — nothing is refetched and nothing is re-rasterised:
| Image | Channels |
|---|---|
mask RGBA |
R water, G roads, B built-up, A land-cover coverage |
cover RGB |
land-cover colour |
Land cover is baked as colour, not as a class index. The textures are sampled with LINEAR filtering, and interpolating between two index values would invent a third class that is not there. Interpolating between two colours is exactly what is wanted.
The pipeline:
- Fetch the OpenStreetMap vector tile alongside the elevation tile. The tile URL comes from OpenFreeMap's TileJSON at runtime, never hardcoded.
- Decode it with a hand-written MVT reader in
src/mvt.js, about 130 lines and no dependencies. Water polygons, waterway lines, and theclasstag. - Rasterise to a 256x256 single-channel mask in the worker with Canvas 2D path fills. Nonzero winding gives island holes for free.
- Upload per tile as an
R8texture and sample it in the fragment shader.
Vectors travel over the wire; pixels are materialised at load time and never stored or transmitted. All 76 tiles cost about 5 MB of GPU memory. UVs fall out of the tile-local vertex positions, so there is no extra attribute, and skirt vertices inherit their edge's UV so shorelines do not tear at LOD seams.
Not flattened. Rivers are not level — the St. Clair drops about a metre over 40 km — and OSM stores wide rivers as polygons, so a blanket flatten would level them. NASADEM already flattened large lakes during processing anyway.
This is the general overlay mechanism. Roads, built-up areas, borders and chart symbology are all the same path: rasterise vectors, drape on terrain. What remains is drawing code, not architecture.
About 690k triangles across 72 draw calls of static buffers, which any GPU from the last decade handles without noticing. Bandwidth is roughly 107 KB per kilometre travelled: about 0.6 KB/s walking, about 56 KB/s at full boost. The initial load is around 6 MB, requested coarsest-first so the whole scene appears immediately and then sharpens.
Requests are capped at six in flight. This endpoint is a free public good and does not deserve to be hammered. Please do not point automated flythroughs at it.
See TODO.md. Bugs, untested areas, and decisions waiting on a human are all tracked there rather than in anyone memory.
node test/behaviour.mjs # movement, ground clamping, flight, height sampling
node test/coverage.mjs # depth pass coverage across latitudes and settings
node test/rings.mjs # exact tiling, including mid-load substitution
node test/smoke.mjs # whole app against a mocked WebGL2 context
No test framework, no dependencies. There is also a proof that the clipmap tiles exactly, checked against 150,000 sampled points.
Stated plainly, because you will notice all of these within a minute:
- The ground under your feet is invented. Source data is sampled every 30 m and your eye is 1.7 m up. Large landforms are real and recognisable. Anything within walking distance is smooth interpolation.
- Forest canopy is baked into the terrain. This is a surface model, not a bare-earth model, so forest edges appear as cliffs.
- You cannot fall. Walking clamps you to the ground surface, so walking at a cliff means riding up it like an escalator. Gravity is a character controller, which is its own project.
- Lakes are not flat and rivers do not always run downhill. Nothing in a raw elevation model enforces hydrology.
- The ocean is a flat plane at 0 m. No bathymetry.
- Heights are ellipsoidal, not orthometric. Expect a vertical offset from published map elevations, up to about 100 m in some regions.
- High latitudes distort. Mercator tiles are square in projection, not on the ground. Past about 75 degrees it gets silly, and the poles have no data.
- Quadtree LOD instead of fixed levels, so detail follows terrain roughness
- A service worker, so a region can be cached and flown offline
- FABDEM or Copernicus GLO-30 as an alternate source, to get rid of the canopy
- Deterministic, position-seeded fractal detail inside 300 m, so the near field stops being smooth putty
- ESA WorldCover land classes driving the palette instead of elevation bands
- Extruded OpenStreetMap building footprints
- A native port to Rust and wgpu. Every line of tile, clipmap and shader logic ports across unchanged.
Everything this renders is streamed from two free public services. Neither charges anything, neither asks for an API key, and both deserve to be treated carefully rather than merely legally.
Elevation — Mapzen / AWS Open Data Terrain Tiles, derived from NASADEM, SRTM and USGS 3DEP. A funded AWS Open Data dataset serving tens of millions of requests a day. No SLA, no rate limit.
Water, land cover, roads and built-up areas — OpenStreetMap via OpenFreeMap, in the OpenMapTiles schema. OpenFreeMap is one person's project, funded by donations, offering unlimited free tile hosting with no registration. "No limits" is a generous policy, not an invitation to test it. If this project is useful to you, sponsor OpenFreeMap.
Attribution for both is displayed in the corner of the view and is required by the licences. Please keep it there.
- At most 10 requests in flight, and requests are only made when the visible tile set actually changes. Standing still costs nothing.
- Roughly 107 KB per kilometre travelled, across both hosts. Comparable to one person browsing a map site.
- Detail levels that cannot finish loading before they are superseded are not requested at all, so nothing is fetched and then discarded.
- Browser HTTP caching does the rest; flying back over ground you have already seen costs no requests.
- Automated flythroughs. A script, or leaving this running unattended at 100 km/s, turns one person browsing into a crawler. Don't.
- Bulk downloading through the tile endpoints. Both projects publish full planet dumps for exactly this purpose: OpenFreeMap ships weekly planet downloads, and the terrain tiles are a public S3 bucket you can sync.
- Pointing significant traffic at them from a popular deployment. If this ever got real traffic, the right move is to self-host tiles rather than let someone else's donation-funded server absorb it.
This started as a thought while driving: the topography of the Earth is essentially mapped, most of it is freely available, and so is the knowledge of how to build 3D worlds. How hard would it be to join the two and go for a walk anywhere?
It turns out: not very. That is the interesting result. A walkable, flyable planet built from open data is about a thousand lines and no dependencies.
I am genuinely pleased with how it turned out — considerably better than I dared hope when I started. But I have no big plans for it. It was an experiment in whether the idea worked, and it does. It is unmaintained, not intended to be depended on, and the outstanding work is longer than the finished work. Fork it freely.
This was written collaboratively with Claude (Anthropic) in a single working session. I set the direction, made the design calls, and did all the testing; the AI wrote the code, did the maths, and researched the data sources.
That division mattered more than it might sound, because every significant bug was found by a human looking at the screen. The AI could not see the output. Several were invisible to a passing test suite:
- A depth-precision bug that made distant terrain flicker survived six versions because the tests checked that geometry fell inside the clip range and never asked whether the depth buffer could resolve anything out there. It was found by noticing that the view-distance slider changed the effect.
- A loader deadlock that pinned the whole view to coarse tiles was found by reading a tile count in the HUD that did not match the expected number.
- Flickering tiles were narrowed down by toggling layers off one at a time, which separated three independent causes that had been assumed to be one.
- A Windows-specific MIME type quirk stopped every line of code from running, and was invisible to a test suite that imports modules directly.
The TODO opens with the patterns behind the worst of them, because they are more useful than the fixes.
Repository: https://github.com/jimvanm/TerrainWalker
Code is MIT. See LICENSE. Map data licences belong to the sources above:
OpenStreetMap data is ODbL, and the terrain tiles carry the licences of their
underlying public-domain sources.