sean.
Back to notes

修了一個 MapLibre GL JS 的開源 Bug:中文字上下顛倒

公司升級 MapLibre GL JS 到 v5.x 之後,中文地名在地圖上全部上下顛倒。Issue 開著沒人修,新版本就卡在那裡不能用。自己把整個渲染 pipeline 看懂、在本地搭起測試環境、一條一條縮小範圍,最後找到根本原因並發 PR 推進主線。

Created

Mar 29, 2026

01

為什麼要動這個 Bug

公司的地圖平台用的是 MapLibre GL JS,我們會持續跟著官方版本升級——拿新功能、拿安全修正。

但從 v4.7.1 升到 v5.x 之後,地圖上的中文字全部上下顛倒了。樣式設定、資料來源完全沒動,只是升版本。

這個 Issue ( #5779 ) 在 2025 年 4 月被回報,maintainer 貼了 PR is more than welcomed,但放了一段時間沒有人送修。

新版本卡住、又不能無限期鎖在舊版,只好自己下去查。

02

在本地把環境跑起來

要查 MapLibre 的渲染問題,直接看線上 demo 是不夠的,需要能夠在本地修改原始碼、立即看到渲染結果

首先按照官方開發者文件把 repo clone 下來,安裝依賴,把 dev server 跑起來。MapLibre GL JS 的 render test 框架會在本地渲染一張地圖截圖,然後跟 expected.png 做 pixel-level 比對——只要結果不一致,test 就 fail。

在理解這套框架之前,我需要先搞懂:

  1. render test 的 style.json 怎麼寫
  2. expected.png 怎麼產出
  3. test runner 怎麼跑單一 case

這些在 test/integration/render/ 底下的 README 都有說明,花了一些時間讀懂整個流程。接著複製 Issue 描述裡的復現條件,在本地確認 Bug 真的能重現。

03

理解文字渲染的 Pipeline

MapLibre 的渲染流程很複雜,文字渲染更是牽涉多個座標空間的轉換。在開始 trace code 之前,要先搞清楚文字從「資料」變成「螢幕上的像素」大概走哪幾關:

GeoJSON 資料(經緯度)
    ↓
Symbol Layout(決定文字放在地圖的哪個位置)
    ↓
Label Plane(世界空間,文字的 3D 位置)
    ↓
projectFromLabelPlaneToClipSpace(投影到螢幕座標)
    ↓
WebGL 渲染(最終畫出來)

Label Plane 是一個虛擬的平面,文字的位置先在這個平面上計算好;接著要把這個 3D 世界空間的座標投影到 Clip Space(剪裁空間),也就是螢幕可見範圍的標準化座標系(x、y 都在 -1 到 1 之間),最後才交給 WebGL 畫出來。

04

用參數排列組合縮小範圍

問題是「上下顛倒」,直覺上是某個地方的 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 完全不同的投影路徑。

05

定位到 `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)。

06

找到真正的原因

這裡需要理解兩個座標系統的差異:

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 = 0clipY = -1.0(應該是 1.0,方向反了)❌
  • y = heightclipY = 1.0(應該是 -1.0,方向反了)❌

Y 軸的方向整個反過來了。這不是「多減一個 1.0」的問題,而是整個映射方向都錯了——正確的公式要把 (y / h) * 2.01.0 的相減順序對調。

X 軸的計算是對的:(x / width) * 2.0 - 1.0,因為 Canvas 和 Clip Space 的 X 軸方向一致(都是向右),不需要反轉。Y 軸因為兩個座標系方向相反,必須反轉。

07

修正與測試

修正只有一行,但這一行反映的是座標空間轉換的核心邏輯:

// 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 抓到。

08

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
09

這個 Bug 為什麼不好找?

回過頭來看,這個 Bug 難定位有幾個原因:

症狀有迷惑性。 「上下顛倒」很直覺地讓人覺得是旋轉矩陣或 text-keep-upright 的問題,但實際上跟這些完全無關,是投影函數的座標系換算搞錯了。

只有特定參數組合才觸發。 單獨看某個參數都沒問題,必須是 line-center + text-pitch-alignment: 'viewport' 這個組合才會走到出問題的 code path,所以很難靠直覺猜到在哪裡。

公式差異很細微。 (y / h) * 2.0 - 1.01.0 - (y / h) * 2.0,數學上看起來只是調換了相減的順序,但背後的意義是兩個座標系的 Y 軸方向完全相反——要先理解兩個座標系統的定義才能看出來哪裡錯了。

10

後記

這次最大的收穫不是「修了一個開源 Bug」這件事本身,而是為了找到這個 Bug,把 MapLibre 的 symbol 渲染 pipeline 從頭到尾看了一遍 Label Plane、Clip Space 的轉換邏輯、render test 框架的運作方式。這些在平常使用套件的時候完全不需要知道,但遇到問題的時候,正是這些底層知識讓你能看出別人看不出來的東西。( •̀ ω •́ )✧

Continue reading