Three.jsBlenderOpenStreetMapWeb 3D3D PipelinePythonGLB

把真實城市變成 3D 場景:OpenStreetMap、Blender 與 Three.js 的一條管線

·閱讀約 11 分鐘

做 Midnight Run 的時候,我本來可以直接畫一條賽道。用幾個彎、幾條直線,兜出一個好跑的形狀,兩個小時就有東西可以玩。

但我想跑的是台北。

不是「有台北感覺」的場景,是真的那條路:從 101 東南側起跑,沿松智路、松壽路,繞過市府路,再從信義路五段接回來。你如果在那一帶跑過步,應該認得出來。

這篇是那條管線的拆解——從一份地圖資料,到瀏覽器裡跑得動的 3D 城市。

一座城市可以下載嗎?

可以。OpenStreetMap 是一個開放的地圖資料庫,任何人都能匯出一塊範圍的原始資料。它不是圖片,是結構化的節點與路徑:每一棟建築的輪廓、每一條路的走向、還有一大堆標籤。

我拉的範圍是 101 周邊一個街廓,經緯度從 121.5602, 25.0312 到 121.5718, 25.0418。這份快照長這樣:

項目數值
壓縮後檔案782 KB
解壓後 XML11.4 MB
節點(node)25,735
路徑(way)3,139
授權ODbL
2026-07-17 的信義區 OSM 快照

3,139 條路徑裡,有建築輪廓、有道路、有行道樹、有停車格。接下來的工作就是從這堆東西裡,挑出能變成場景的部分。

一個實際的決定:我把這份快照存進版控。地圖資料是會變的,OSM 隨時有人在編輯,如果每次重建都重新下載,同一份程式碼會產生不同的城市。存下來的那一刻起,這個場景就有了確定的來源。

第一件事是路,不是建築

直覺上會想先蓋房子,但路線才是決定一切的東西——玩家的相機沿著它移動,多邊形預算依它分配,連城市要蓋多大都由它決定。

OSM 裡的一條路不是一整條,是被切成很多段的 way,每段有自己的 id。所以第一步是手動挑出構成這個迴圈的五段:

python
ROUTE_WAY_IDS = [
    1335559366,  # 松智路,從 101 東南側起跑
    1335559368,
    1335559369,  # 松壽路
    231534776,   # 市府路
    334841514,   # 信義路五段
]

這裡沒有自動化。我在 OSM 網站上一段一段點出來、抄下 id。自動找路徑不是做不到,但一條固定賽道只需要挑一次,寫演算法反而比較慢。

way 不會乖乖照順序接好

把五段接起來時會遇到第一個真實世界的問題:每一段 way 的方向是編輯者當初畫的方向,不保證跟你要跑的方向一致。第二段的起點可能接在第一段的終點,也可能是它的起點接在你的終點。

解法是每接一段就比一次距離:如果這段的終點離目前路線的末端比較近,就把它整段反過來。

python
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 演算法跑三輪。它的作法很單純:每個線段取四分之一和四分之三兩個點,用它們取代原本的頂點。跑一輪角就鈍一點,跑三輪就接近曲線了。

python
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 引擎,城市會被橫向拉長。

在幾百公尺的範圍內,用等距長方投影就夠了:

python
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 方向。

所以路線資料存的時候就要先轉好:

python
# 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 的建築輪廓幾乎都有,高度卻經常缺。所以要一層一層往下退:

python
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。幾個設定值得說明:

python
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 公尺
城市 GLB1,329 KB
立面貼圖(4 張)74–92 KB
從 3,139 條 OSM 路徑到 1.3MB 的瀏覽器資產

1.3MB 換一整個街廓的台北,我覺得划算。

在瀏覽器裡跑跑看:Midnight Run

你可以自己跑一次

我把這條管線的精簡版包成一個 starter。給它一個經緯度範圍,它會從 Overpass API 下載真實地圖資料,然後用 Blender 產生 GLB。

兩個指令:

bash
# 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。

這些東西不會出現在教學裡,因為教學通常用乾淨的資料。真實世界的地圖資料是幾百萬人協作出來的,它反映的是現實的樣子,不是規格書的樣子。

但也正因為這樣,跑起來的時候才有那個感覺——那不是一條我畫的賽道,那是我跑過的那條路。

參考資料