修了一個 MapLibre GL JS 的開源 Bug:中文字上下顛倒
公司升級 MapLibre GL JS 到 v5.x 之後,中文地名在地圖上全部上下顛倒。Issue 開著沒人修,新版本就卡在那裡不能用。自己把整個渲染 pipeline 看懂、在本地搭起測試環境、一條一條縮小範圍,最後找到根本原因並發 PR 推進主線。
建立日期
2026/3/29
為什麼要動這個 Bug
公司的地圖平台用的是 MapLibre GL JS,我們會持續跟著官方版本升級——拿新功能、拿安全修正。
但從 v4.7.1 升到 v5.x 之後,地圖上的中文字全部上下顛倒了。樣式設定、資料來源完全沒動,只是升版本。
這個 Issue ( #5779 ) 在 2025 年 4 月被回報,maintainer 貼了 PR is more than welcomed,但放了一段時間沒有人送修。
新版本卡住、又不能無限期鎖在舊版,只好自己下去查。
在本地把環境跑起來
要查 MapLibre 的渲染問題,直接看線上 demo 是不夠的,需要能夠在本地修改原始碼、立即看到渲染結果。
首先按照官方開發者文件把 repo clone 下來,安裝依賴,把 dev server 跑起來。MapLibre GL JS 的 render test 框架會在本地渲染一張地圖截圖,然後跟 expected.png 做 pixel-level 比對——只要結果不一致,test 就 fail。
在理解這套框架之前,我需要先搞懂:
- render test 的
style.json怎麼寫 expected.png怎麼產出- test runner 怎麼跑單一 case
這些在 test/integration/render/ 底下的 README 都有說明,花了一些時間讀懂整個流程。接著複製 Issue 描述裡的復現條件,在本地確認 Bug 真的能重現。
理解文字渲染的 Pipeline
MapLibre 的渲染流程很複雜,文字渲染更是牽涉多個座標空間的轉換。在開始 trace code 之前,要先搞清楚文字從「資料」變成「螢幕上的像素」大概走哪幾關:
GeoJSON 資料(經緯度)
↓
Symbol Layout(決定文字放在地圖的哪個位置)
↓
Label Plane(世界空間,文字的 3D 位置)
↓
projectFromLabelPlaneToClipSpace(投影到螢幕座標)
↓
WebGL 渲染(最終畫出來)
Label Plane 是一個虛擬的平面,文字的位置先在這個平面上計算好;接著要把這個 3D 世界空間的座標投影到 Clip Space(剪裁空間),也就是螢幕可見範圍的標準化座標系(x、y 都在 -1 到 1 之間),最後才交給 WebGL 畫出來。
用參數排列組合縮小範圍
問題是「上下顛倒」,直覺上是某個地方的 Y 軸方向搞錯了。但 MapLibre 的文字渲染有很多條不同的 code path,不同的 Style 屬性組合會走不同的分支。
所以我把 Issue 描述裡的 Style 屬性一個一個拔掉、換掉,觀察每種組合下的渲染結果:
| 調整項目 | 結果 |
|---|---|
symbol-placement: 'point'(改成點位放置) |
文字正常 |
text-rotation-alignment: 'viewport'(改成不跟地圖旋轉) |
文字正常 |
text-pitch-alignment: 'map'(改成跟地圖傾斜) |
文字正常 |
原始組合:symbol-placement: 'line-center' + text-rotation-alignment: 'map' + text-pitch-alignment: 'viewport' |
❌ 顛倒 |
關鍵條件鎖定了:symbol-placement: 'line-center' + text-pitch-alignment: 'viewport' 這個組合。
text-pitch-alignment: 'viewport' 代表文字不會隨鏡頭俯仰角傾斜,這個設定在程式裡對應到 pitchWithMap = false,走的是跟 pitchWithMap = true 完全不同的投影路徑。
定位到 `projectFromLabelPlaneToClipSpace`
根據上面縮小的條件,去 src/symbol/projection.ts 裡找 pitchWithMap = false 的分支,找到 projectFromLabelPlaneToClipSpace 函數:
// src/symbol/projection.ts,第 685 行附近
function projectFromLabelPlaneToClipSpace(x: number, y: number, projectionContext) {
if (projectionContext.pitchWithMap) {
// pitchWithMap = true 的路徑(正常)
// ...
} else {
// pitchWithMap = false 的路徑(這裡有問題)
return {
x: (x / projectionContext.width) * 2.0 - 1.0,
y: (y / projectionContext.height) * 2.0 - 1.0 // ← 這行
};
}
}
這個函數的作用是把 Label Plane 的座標(以像素為單位,原點在左上角,Y 軸向下)轉換成 Clip Space 的座標(原點在中心,Y 軸向上,範圍 -1 到 1)。
找到真正的原因
這裡需要理解兩個座標系統的差異:
Label Plane / 螢幕座標系(Canvas 座標):
- 原點在左上角
- Y 軸向下(y 越大越往下)
- 範圍:
(0, 0)到(width, height)
Clip Space(WebGL NDC):
- 原點在中心
- Y 軸向上(y 越大越往上)
- 範圍:
(-1, -1)到(1, 1)
把 Canvas 座標的 Y 轉換成 Clip Space 的 Y,正確的公式應該是:
clipY = 1.0 - (y / height) * 2.0
推導過程:
- 當
y = 0(Canvas 頂部) →clipY = 1.0(Clip Space 頂部)✅ - 當
y = height(Canvas 底部) →clipY = -1.0(Clip Space 底部)✅
但 v5.x 的程式碼寫的是:
y: (y / projectionContext.height) * 2.0 - 1.0 // 錯誤
展開來看:
- 當
y = 0→clipY = -1.0(應該是 1.0,方向反了)❌ - 當
y = height→clipY = 1.0(應該是 -1.0,方向反了)❌
Y 軸的方向整個反過來了。這不是「多減一個 1.0」的問題,而是整個映射方向都錯了——正確的公式要把 (y / h) * 2.0 和 1.0 的相減順序對調。
X 軸的計算是對的:(x / width) * 2.0 - 1.0,因為 Canvas 和 Clip Space 的 X 軸方向一致(都是向右),不需要反轉。Y 軸因為兩個座標系方向相反,必須反轉。
修正與測試
修正只有一行,但這一行反映的是座標空間轉換的核心邏輯:
// Before(錯誤)
y: (y / projectionContext.height) * 2.0 - 1.0
// After(正確)
y: 1.0 - (y / projectionContext.height) * 2.0
修完之後,在本地跑 render test 確認復現案例通過,然後補上一個新的 render test case,對應 Issue 描述裡的觸發條件:
// test/integration/render/tests/symbol-placement/line-center-upperright/style.json
{
"layers": [{
"id": "line-center",
"type": "symbol",
"layout": {
"text-field": "高速公路",
"symbol-placement": "line-center",
"text-pitch-alignment": "viewport",
"text-keep-upright": true,
"text-font": ["NotoCJK"]
}
}]
}
Test case 用「高速公路」這個真實的中文字串,配上觸發 Bug 的完整 Style 組合,然後產出對應的 expected.png——這樣之後任何 regression 都會被 CI 抓到。
PR 合入
發出 PR 之後,maintainer HarelM 很快來 review,程式碼邏輯沒有問題,要求補上 CHANGELOG 條目。補完之後直接 approve + auto-merge,整個流程很順。
2025 年 6 月 19 日合入 main,Issue #5779 關閉。
( PR #6021 )
fix: correct Y axis transformation in projectFromLabelPlaneToClipSpace
add change log
Add symbol test with text-keep-upright enabled
這個 Bug 為什麼不好找?
回過頭來看,這個 Bug 難定位有幾個原因:
症狀有迷惑性。 「上下顛倒」很直覺地讓人覺得是旋轉矩陣或 text-keep-upright 的問題,但實際上跟這些完全無關,是投影函數的座標系換算搞錯了。
只有特定參數組合才觸發。 單獨看某個參數都沒問題,必須是 line-center + text-pitch-alignment: 'viewport' 這個組合才會走到出問題的 code path,所以很難靠直覺猜到在哪裡。
公式差異很細微。 (y / h) * 2.0 - 1.0 和 1.0 - (y / h) * 2.0,數學上看起來只是調換了相減的順序,但背後的意義是兩個座標系的 Y 軸方向完全相反——要先理解兩個座標系統的定義才能看出來哪裡錯了。
後記
這次最大的收穫不是「修了一個開源 Bug」這件事本身,而是為了找到這個 Bug,把 MapLibre 的 symbol 渲染 pipeline 從頭到尾看了一遍 Label Plane、Clip Space 的轉換邏輯、render test 框架的運作方式。這些在平常使用套件的時候完全不需要知道,但遇到問題的時候,正是這些底層知識讓你能看出別人看不出來的東西。( •̀ ω •́ )✧
繼續閱讀