把真實城市變成 3D 場景:OpenStreetMap、Blender 與 Three.js 的一條管線
做 Midnight Run 的時候,我本來可以直接畫一條賽道。用幾個彎、幾條直線,兜出一個好跑的形狀,兩個小時就有東西可以玩。
但我想跑的是台北。
不是「有台北感覺」的場景,是真的那條路:從 101 東南側起跑,沿松智路、松壽路,繞過市府路,再從信義路五段接回來。你如果在那一帶跑過步,應該認得出來。
這篇是那條管線的拆解——從一份地圖資料,到瀏覽器裡跑得動的 3D 城市。
一座城市可以下載嗎?
可以。OpenStreetMap 是一個開放的地圖資料庫,任何人都能匯出一塊範圍的原始資料。它不是圖片,是結構化的節點與路徑:每一棟建築的輪廓、每一條路的走向、還有一大堆標籤。
我拉的範圍是 101 周邊一個街廓,經緯度從 121.5602, 25.0312 到 121.5718, 25.0418。這份快照長這樣:
| 項目 | 數值 |
|---|---|
| 壓縮後檔案 | 782 KB |
| 解壓後 XML | 11.4 MB |
| 節點(node) | 25,735 |
| 路徑(way) | 3,139 |
| 授權 | ODbL |
3,139 條路徑裡,有建築輪廓、有道路、有行道樹、有停車格。接下來的工作就是從這堆東西裡,挑出能變成場景的部分。
一個實際的決定:我把這份快照存進版控。地圖資料是會變的,OSM 隨時有人在編輯,如果每次重建都重新下載,同一份程式碼會產生不同的城市。存下來的那一刻起,這個場景就有了確定的來源。
第一件事是路,不是建築
直覺上會想先蓋房子,但路線才是決定一切的東西——玩家的相機沿著它移動,多邊形預算依它分配,連城市要蓋多大都由它決定。
OSM 裡的一條路不是一整條,是被切成很多段的 way,每段有自己的 id。所以第一步是手動挑出構成這個迴圈的五段:
ROUTE_WAY_IDS = [
1335559366, # 松智路,從 101 東南側起跑
1335559368,
1335559369, # 松壽路
231534776, # 市府路
334841514, # 信義路五段
]這裡沒有自動化。我在 OSM 網站上一段一段點出來、抄下 id。自動找路徑不是做不到,但一條固定賽道只需要挑一次,寫演算法反而比較慢。
way 不會乖乖照順序接好
把五段接起來時會遇到第一個真實世界的問題:每一段 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:]第二個 if 是在處理接縫。相鄰的兩段路通常共用同一個路口節點,直接串起來會有一個重複的點。距離小於 0.8 公尺就視為同一點,丟掉。
真實道路的轉角是有稜有角的
接好之後直接拿來跑,體感會很糟。OSM 的道路是折線,轉彎處是幾個頂點硬接出來的角。人在上面跑,相機會一下一下地折。
我用 Chaikin 演算法跑三輪。它的作法很單純:每個線段取四分之一和四分之三兩個點,用它們取代原本的頂點。跑一輪角就鈍一點,跑三輪就接近曲線了。
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 result平滑之後還有一件事要做:等距重取樣。OSM 的頂點分布很不平均,直路上可能兩百公尺才一個點,路口卻擠了十幾個。如果直接用這些點推進相機,速度會忽快忽慢。
所以我沿著整條線的弧長,每 1.4 公尺放一個取樣點。最後得到 724 個點、全長 1011.8 公尺的封閉迴圈。之後不管要算速度、算進度、還是放路燈,都可以直接用索引。
經緯度不是座標系
這是整條管線裡最容易出錯、也最少被講的一段。
經緯度是角度,不是長度。而且一度經度的實際距離會隨緯度縮小——在赤道大約 111 公里,在台北只剩大約 100 公里。直接把經緯度當 x、y 丟進 3D 引擎,城市會被橫向拉長。
在幾百公尺的範圍內,用等距長方投影就夠了:
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, north那個 cos 就是在修正經度的收縮。用正式的投影函式庫會更嚴謹,但在這個尺度下看不出差別。
然後是那個負號
算出公尺之後,還要放進 Three.js 的座標系,而這裡有一個所有人都會踩一次的坑。
Blender 是 Z 軸朝上,glTF 規格是 Y 軸朝上。匯出時打開 Y-up 轉換,Blender 會幫你旋轉整個場景——結果是,原本的「北」會變成 Three.js 的負 Z 方向。
所以路線資料存的時候就要先轉好:
# Blender 的 Y-up GLB 轉換會把北方對應到 Three.js 的負 Z
"points": [
{"x": round(east, 3), "y": 0, "z": round(-north, 3)}
for east, north in route
],漏掉這個負號,城市和路線會沿著南北軸鏡像。畫面看起來還是一座城市,一切都很正常——只是你會發現自己在建築物裡面跑。這種 bug 特別討厭,因為它不會報錯。
建築物的高度,OSM 常常沒寫
路線搞定後才輪到城市。這部分在 Blender 裡用 Python 跑,因為要直接建 mesh、給材質、然後匯出。
最麻煩的是高度。OSM 的建築輪廓幾乎都有,高度卻經常缺。所以要一層一層往下退:
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.5先看有沒有 height 標籤。沒有就看樓層數,一層算 3.25 公尺(含樓板)。兩個都沒有,就用 way 的 id 去推。
最後那行是整支腳本裡我最喜歡的一行。它不是隨機——way_id % 7 對同一棟建築永遠得到同一個值。這代表兩件事:天際線不會變成一排等高的方塊,而且每次重建,同一棟樓都會長成同樣高度。用真正的亂數就沒有第二個性質了。
至於 parse_number,存在的理由是 OSM 的高度標籤是自由文字。你會拿到 30、30 m、30 meters,甚至 30;35。這是社群協作資料的日常。
101 得自己來
把所有建築都當成「輪廓往上擠出」的柱體,整個街廓看起來還行——除了 101。它會變成一根 508 公尺的方形柱子,非常好笑。
所以它是特例。只要一棟建築超過 120 公尺、而且離已知的 101 位置不到 95 公尺,就換一組專門的幾何:疊起來的梯形做出那八層斗拱、加上發光的層間帶、再插一根塔尖。
一座城市裡總有一兩個地標是不能用通則處理的。與其把規則寫得越來越複雜去涵蓋它,不如承認它是特例。
多邊形預算要花在玩家看得見的地方
手機 GPU 的預算有限,而這條路線是固定的——玩家永遠沿著同一條線移動。這件事可以拿來省很多。
所以細節是依「離賽道多遠」來分配的:
| 細節 | 條件 |
|---|---|
| 裙樓(podium) | 離賽道 105 公尺內、高於 9 公尺 |
| 屋頂機房單元 | 離賽道 145 公尺內、高 18–180 公尺、佔地 120–16,000 ㎡ |
| 純輪廓量體 | 其餘全部 |
裙樓是街道層的那一段——你跑過去時最靠近眼睛的地方,加了立刻有城市感。屋頂單元則是遠景的輪廓細節,讓天際線不要太乾淨。離賽道兩百公尺外的建築,你只會看到一個剪影,那就給它一個剪影就好。
這是遊戲業的老技巧,但它在這裡特別划算,因為路線是固定的:我不需要動態 LOD,離線就能算好誰值得細節。
匯出
最後匯出 GLB。幾個設定值得說明:
bpy.ops.export_scene.gltf(
filepath=str(GLB_PATH),
export_format="GLB",
use_selection=True,
export_apply=True, # 套用修改器,不要留給瀏覽器算
export_yup=True, # Blender Z-up → glTF Y-up
export_animations=False,
export_cameras=False, # 相機和燈光由 Three.js 那邊控制
export_lights=False,
export_extras=True, # 帶著來源與授權一起走
)export_extras 那行是刻意的。我在根物件上掛了 source、license 和快照日期,這樣即使 GLB 檔案被單獨拿走,OSM 的授權資訊也還跟著它。
重跑一次,會產生一樣的東西嗎?
寫這篇的時候我實際重跑了整條管線,然後比對檔案雜湊:
| 產物 | 是否逐位元組相同 |
|---|---|
| 路線 JSON | 相同 |
| 城市 GLB | 相同 |
| Blender .blend 原始檔 | 不同 |
真正上線的那兩個檔案是決定性的——因為輸入是版控裡的快照,而所有「隨機」都來自 way id。這代表任何人拿到這個 repo,都能重建出一模一樣的城市。
.blend 不同是因為它內嵌了時間戳這類的工作階段資料,跟幾何無關。知道這件事之後,我就不會再被那個永遠出現的 git diff 困擾了。
最後的數字
| 階段 | 數量 |
|---|---|
| OSM 路徑(輸入) | 3,139 |
| 建築特徵 | 401 |
| 建築量體(含裙樓、屋頂單元) | 532 |
| 道路 | 73 |
| 路線取樣點 | 724 |
| 路線長度 | 1,011.8 公尺 |
| 城市 GLB | 1,329 KB |
| 立面貼圖(4 張) | 74–92 KB |
1.3MB 換一整個街廓的台北,我覺得划算。
在瀏覽器裡跑跑看:Midnight Run你可以自己跑一次
我把這條管線的精簡版包成一個 starter。給它一個經緯度範圍,它會從 Overpass API 下載真實地圖資料,然後用 Blender 產生 GLB。
兩個指令:
# west south east north — 這組就是 101 周邊
python3 fetch_osm.py 121.5602 25.0312 121.5718 25.0418
blender --background --python build_city.py我用同一份信義區資料實測過,跑出 1,122 棟建築、1,000 條道路、44,113 個三角形、2.1MB 的 GLB。比正式版多,是因為 starter 沒有做距離篩選——它把範圍內的東西全部蓋出來。
starter 刻意留白的部分,都是需要判斷的部分:屋頂形狀、貼圖、地標特例、細節分級。那些才是你自己場景的個性所在。
下載 OSM City GLB Starter授權不是小事
OpenStreetMap 的資料採 ODbL 授權。用它做出來的東西要標示來源,衍生的資料庫也要維持同樣的授權。
這不是法務上的形式。那些建築輪廓是真的有人一棟一棟畫上去的——101 周邊那個街廓的細緻程度,是很多人花時間標出來的結果。標一行來源,成本趨近於零。
最後
整條管線裡沒有一段特別難。難的是它有很多段,而每一段都有一個只有做過才會知道的細節:way 的方向不一定對、接縫會重複、高度標籤是自由文字、北方會變成負 Z。
這些東西不會出現在教學裡,因為教學通常用乾淨的資料。真實世界的地圖資料是幾百萬人協作出來的,它反映的是現實的樣子,不是規格書的樣子。
但也正因為這樣,跑起來的時候才有那個感覺——那不是一條我畫的賽道,那是我跑過的那條路。