Turning a real city into a 3D scene: a pipeline from OpenStreetMap through Blender to Three.js
When I built Midnight Run I could have drawn a track. A few corners, a few straights, something with a good rhythm — two hours of work and there is something to play.
But I wanted to run in Taipei.
Not a scene that feels like Taipei. The actual road: starting south-east of Taipei 101, along Songzhi Road and Songshou Road, around Shifu Road and back via Section 5 of Xinyi Road. If you have ever run there, you would recognise it.
This is how that pipeline works, from a piece of map data to a 3D city that runs in a browser.
Can you download a city?
You can. OpenStreetMap is an open map database, and anyone can export the raw data for an area. It is not an image — it is structured nodes and ways: the outline of every building, the path of every road, and a great many tags.
I took one block around Taipei 101, from 121.5602, 25.0312 to 121.5718, 25.0418. The snapshot looks like this:
| Item | Value |
|---|---|
| Compressed file | 782 KB |
| Uncompressed XML | 11.4 MB |
| Nodes | 25,735 |
| Ways | 3,139 |
| Licence | ODbL |
Among those 3,139 ways are building outlines, roads, street trees and parking bays. The job that follows is picking out the parts that can become a scene.
One practical decision: I committed the snapshot to version control. Map data changes — somebody is editing OpenStreetMap right now — so re-downloading on every build would let the same code produce a different city. Committing it gives the scene a fixed origin.
The road comes first, not the buildings
The instinct is to start with the buildings, but the route determines everything else: the camera moves along it, the polygon budget is allocated by distance from it, and even how large a city you need is decided by it.
A road in OpenStreetMap is not one object. It is cut into many ways, each with its own id. So the first step is picking the five that make up this loop by hand:
ROUTE_WAY_IDS = [
1335559366, # Songzhi Road, starting south-east of Taipei 101
1335559368,
1335559369, # Songshou Road
231534776, # Shifu Road
334841514, # Xinyi Road Section 5
]Nothing here is automated. I clicked through them on the OpenStreetMap site and copied the ids down. Finding a route programmatically is possible, but a fixed track only needs picking once, and writing the algorithm would have taken longer.
Ways do not join up in order
Stitching those five together brings the first real-world problem: the direction of a way is whichever direction the mapper happened to draw it, which need not match the direction you want to travel. The second way might start where the first ends — or it might end there.
The fix is to compare distances as you append: if the end of this way is closer to the current tail of the route than its start is, reverse the whole way.
if route and distance(route[-1], coordinates[-1]) < distance(route[-1], coordinates[0]):
coordinates.reverse()
if route and distance(route[-1], coordinates[0]) < 0.8:
coordinates = coordinates[1:]The second condition handles the seam. Adjacent ways usually share the junction node, so joining them directly leaves a duplicate point. Anything within 0.8 metres is treated as the same point and dropped.
Real roads have sharp corners
Running on the stitched route directly feels bad. OpenStreetMap roads are polylines, and a corner is a few vertices meeting at an angle. Move a camera along it and the view snaps from segment to segment.
I run Chaikin's algorithm three times. It is simple: replace every vertex with two points, one a quarter along each adjoining segment and one three quarters along. Each pass rounds the corners a little more; three passes are close enough to a curve.
def chaikin_closed(points, iterations=3):
result = points
for _ in range(iterations):
smoothed = []
for index, point in enumerate(result):
following = result[(index + 1) % len(result)]
smoothed.append((point[0] * 0.75 + following[0] * 0.25, point[1] * 0.75 + following[1] * 0.25))
smoothed.append((point[0] * 0.25 + following[0] * 0.75, point[1] * 0.25 + following[1] * 0.75))
result = smoothed
return resultSmoothing leaves one more thing to fix: even spacing. OpenStreetMap vertices are distributed unevenly — a straight stretch might go two hundred metres between points while a junction has a dozen. Driving a camera along those directly makes speed lurch.
So I resample along the arc length, placing a point every 1.4 metres. The result is a closed loop of 724 points and 1,011.8 metres. After that, speed, progress and street-light placement can all work from an index.
Latitude and longitude are not a coordinate system
This is the easiest part of the pipeline to get wrong, and the least written about.
Latitude and longitude are angles, not lengths. Worse, a degree of longitude shrinks as you move away from the equator — about 111 km there, only about 100 km in Taipei. Feed them into a 3D engine as x and y and the city comes out stretched sideways.
Over a few hundred metres an equirectangular projection is plenty:
def to_local(lat, lon, origin_lat, origin_lon):
east = (lon - origin_lon) * 111_320 * math.cos(math.radians(origin_lat))
north = (lat - origin_lat) * 110_540
return east, northThat cosine is the correction for longitude shrinking. A proper projection library would be more rigorous and, at this scale, indistinguishable.
And then the minus sign
Once you have metres, they have to land in Three.js coordinates — and here is the trap everyone falls into once.
Blender is Z-up. The glTF specification is Y-up. Turn on the Y-up conversion on export and Blender rotates the whole scene, which means north ends up as the negative Z direction in Three.js.
So the route data stores it already converted:
# Blender's Y-up GLB conversion maps north to negative Three.js Z.
"points": [
{"x": round(east, 3), "y": 0, "z": round(-north, 3)}
for east, north in route
],Miss that minus sign and the city and the route are mirrored along the north-south axis. It still looks like a city, everything still runs — you just find yourself running through the inside of buildings. That kind of bug is particularly unpleasant because nothing throws an error.
OpenStreetMap often has no height
With the route settled, the city follows. This part runs as Python inside Blender, because it builds meshes, assigns materials and exports directly.
Height is the awkward part. Building outlines are nearly always present; heights frequently are not. So you fall back through the options:
height = parse_number(tags.get("height"))
if height is None:
levels = parse_number(tags.get("building:levels"))
height = levels * 3.25 if levels else 12 + (way_id % 7) * 5.5Use the height tag if it exists. Failing that, use the storey count at 3.25 metres each, including the floor slab. Failing both, derive a height from the id of the way.
That last line is my favourite in the script. It is not random — way_id % 7 always gives the same value for the same building. Which buys two things: the skyline is not a row of identical blocks, and every rebuild gives that building the same height. Actual randomness would lose the second property.
As for parse_number, it exists because height tags are free text. You will receive 30, 30 m, 30 meters, and sometimes 30;35. That is ordinary life in a collaboratively edited database.
Taipei 101 needs its own code
Treat every building as an extruded outline and the block looks reasonable — except for Taipei 101, which becomes a 508-metre square column. It is very funny and completely wrong.
So it is a special case. Any building over 120 metres within 95 metres of the known tower position gets dedicated geometry instead: stacked frustums for the eight tiers, glowing bands between them, and a spire on top.
Most cities have a landmark or two that no general rule handles well. Rather than growing the rule until it covers them, it is cleaner to admit they are exceptions.
Spend the polygon budget where the player is
A phone GPU has a limited budget, and this route is fixed — the player always travels the same line. That fact is worth a lot.
So detail is allocated by distance from the track:
| Detail | Condition |
|---|---|
| Podium | Within 105 m of the track, taller than 9 m |
| Rooftop units | Within 145 m, 18–180 m tall, footprint 120–16,000 m² |
| Plain massing | Everything else |
The podium is the street-level portion — the part closest to the eye as you run past, and it reads as a city immediately. Rooftop units add silhouette detail in the distance so the skyline is not too clean. A building two hundred metres off the track is only ever a silhouette, so a silhouette is all it gets.
This is an old trick from games, but it pays off unusually well here because the route is fixed: no dynamic level of detail is needed, since who deserves detail can be computed offline.
Export
Finally, export to GLB. A few settings are worth explaining:
bpy.ops.export_scene.gltf(
filepath=str(GLB_PATH),
export_format="GLB",
use_selection=True,
export_apply=True, # bake modifiers rather than leave them to the browser
export_yup=True, # Blender Z-up to glTF Y-up
export_animations=False,
export_cameras=False, # camera and lights are driven from Three.js
export_lights=False,
export_extras=True, # carry provenance and licence along
)The export_extras line is deliberate. I attach source, license and snapshot date to the root object, so that even if the GLB is taken on its own, the OpenStreetMap attribution travels with it.
Does a rebuild produce the same thing?
While writing this I re-ran the whole pipeline and compared file hashes:
| Output | Byte-identical |
|---|---|
| Route JSON | Yes |
| City GLB | Yes |
| Blender .blend source | No |
The two files that actually ship are deterministic, because the input is a committed snapshot and every piece of "randomness" derives from a way id. Anyone who clones the repository rebuilds exactly the same city.
The .blend differs because it embeds session data such as timestamps, none of which is geometry. Knowing that, the git diff that always appears stops being a mystery.
The numbers
| Stage | Count |
|---|---|
| OpenStreetMap ways (input) | 3,139 |
| Building features | 401 |
| Building volumes, including podiums and rooftop units | 532 |
| Roads | 73 |
| Route sample points | 724 |
| Route length | 1,011.8 m |
| City GLB | 1,329 KB |
| Facade textures (4) | 74–92 KB |
1.3 MB for a whole block of Taipei seems like a fair trade.
Run it in the browser: Midnight RunRun it yourself
I packaged a stripped-down version of this pipeline as a starter. Give it a bounding box and it downloads real map data from the Overpass API, then generates a GLB with Blender.
Two commands:
# west south east north — this box is around Taipei 101
python3 fetch_osm.py 121.5602 25.0312 121.5718 25.0418
blender --background --python build_city.pyI ran it against the same Xinyi snapshot: 1,122 buildings, 1,000 roads, 44,113 triangles, a 2.1 MB GLB. More than the shipped version, because the starter does no distance filtering — it builds everything inside the box.
What the starter deliberately leaves out is everything that needs judgement: roof shapes, textures, landmark special cases, levels of detail. Those are where your own scene gets its character.
Download the OSM City GLB StarterAttribution is not a formality
OpenStreetMap data is licensed under the ODbL. Anything built from it has to credit the source, and a derived database has to stay under the same licence.
This is not a legal box to tick. Those building outlines were drawn by people, one at a time — the level of detail around Taipei 101 is the result of many hours of somebody mapping it. A line of attribution costs approximately nothing.
Finally
No single stage of this pipeline is hard. What makes it work is that there are many stages, and each one hides a detail you only learn by doing it: way direction is arbitrary, seams duplicate points, height tags are free text, north becomes negative Z.
None of that appears in tutorials, because tutorials use clean data. Real map data is the collaborative work of millions of people, and it reflects reality rather than a specification.
Which is also why running it feels the way it does. It is not a track I drew. It is the road I have run on.