MapLibre GL JS 完整坐标变换流程详解

MapLibre GL JS 完整坐标变换流程详解

本文档详细解释了 MapLibre GL JS 中,从原始经纬度数据到最终屏幕像素位置的完整变换流程。

场景设定

1
2
3
4
5
6
7
8
9
10
11
要显示的点:故宫 (116.397°E, 39.916°N)
地图中心:天安门 (116.4°E, 39.9°N)
缩放级别:zoom = 10.3 (小数!)
倾斜角度:pitch = 45°
旋转角度:bearing = 0°
屏幕尺寸:1024 × 768 逻辑像素
设备像素比:devicePixelRatio = 2.0 (Retina屏幕)

常量:
tileSize = 512 (逻辑像素,zoom=0时世界大小)
EXTENT = 8192 (瓦片内坐标范围)

第1步:经纬度 → Mercator坐标 [0, 1]

为什么需要这一步?

  • 经纬度是球面坐标,不能直接用于平面显示
  • Mercator投影将球面”展开”成平面
  • 结果范围 [0,1]×[0,1] 表示整个地球

X方向(经度)—— 简单线性映射

1
2
3
4
mercatorX = (180 + 经度) / 360
= (180 + 116.397) / 360
= 296.397 / 360
= 0.82332

Y方向(纬度)—— 非线性映射(保持角度不变形)

1
2
3
4
5
6
7
8
9
10
mercatorY = (180 - 180/π × ln(tan(π/4 + lat×π/360))) / 360

计算步骤:
① angle = π/4 + 39.916 × π/360 = 1.134 弧度
② tan(1.134) = 2.20
③ ln(2.20) = 0.788
0.788 × 180/π = 45.16°
⑤ (180 - 45.16) / 360 = 0.3746

mercatorY ≈ 0.3746

结果

  • 故宫的 Mercator 坐标:(0.82332, 0.3746)
  • 天安门的 Mercator 坐标:(0.8233, 0.3750)

图示 [0,1]×[0,1] 范围

1
2
3
4
5
6
7
8
9
10
     0         0.5       0.82332    1
├──────────┼──────────┼────────┤
0 │ │ │ │
│ │ │ │
0.37 │ │ ·故宫 │
0.38 │ │ ·天安门 │
│ │ │
0.5 │ ├──赤道─────────────│
│ │ │
1 └──────────┴───────────────────┘

第2步:确定瓦片层级和世界大小

zoom 与 tileZ 的关系

1
2
zoom = 10.3 (用户设置的缩放级别,可以是小数)
tileZ = floor(zoom) = floor(10.3) = 10 (瓦片层级,整数)

为什么分开?

  • zoom 支持平滑缩放(用户体验)
  • tileZ 必须是整数(瓦片服务器只有整数层级的瓦片)

worldSize 的计算(使用小数 zoom!)

1
2
3
4
worldSize = tileSize × 2^zoom
= 512 × 2^10.3
= 512 × 1261.9
= 646,092 逻辑像素

对比整数 zoom 的情况:

  • zoom=10: worldSize = 512 × 1024 = 524,288
  • zoom=11: worldSize = 512 × 2048 = 1,048,576
  • zoom=10.3: worldSize = 646,092 (在两者之间)

关于 tileSize = 512

  • 这是”逻辑像素”,不是物理像素
  • 在 Retina 屏幕(DPR=2)上,实际渲染 1024 物理像素
  • 它是 zoom=0 时整个地球的逻辑像素大小
  • 也是计算 worldSize 的基准单位

第3步:Mercator坐标 → 世界坐标

公式

1
worldPos = mercatorPos × worldSize

故宫的世界坐标

1
2
worldX = 0.82332 × 646,092 = 531,928
worldY = 0.3746 × 646,092 = 242,026

天安门(地图中心)的世界坐标

1
2
centerWorldX = 0.8233 × 646,092 = 531,863
centerWorldY = 0.3750 × 646,092 = 242,285

故宫相对于天安门的偏移

1
2
deltaX = 531,928 - 531,863 = +65 逻辑像素 (偏东)
deltaY = 242,026 - 242,285 = -259 逻辑像素 (偏北)

⚠️ 注意:这是”世界坐标”中的像素差,不是最终屏幕上的像素差!(透视投影会改变这个值)


第4步:确定瓦片坐标 & 瓦片内坐标

瓦片定位

tileZ = 10 时,世界被分成 2^10 × 2^10 = 1024 × 1024 个瓦片

1
2
3
4
5
6
7
8
9
10
故宫所在的瓦片:
tileX = floor(mercatorX × 2^tileZ)
= floor(0.82332 × 1024)
= floor(843.08)
= 843

tileY = floor(mercatorY × 2^tileZ)
= floor(0.3746 × 1024)
= floor(383.6)
= 383

故宫在瓦片 (z=10, x=843, y=383)

瓦片内的相对位置 [0, 1]

1
2
relativeX = 0.82332 × 1024 - 843 = 0.08
relativeY = 0.3746 × 1024 - 383 = 0.59

转换到 EXTENT=8192 范围(存储在顶点缓冲区)

1
2
tileLocalX = 0.08 × 8192 = 655
tileLocalY = 0.59 × 8192 = 4833

瓦片内坐标:(655, 4833) 范围 [0, 8192]

这个坐标以 Int16 格式存储在 GPU 顶点缓冲区中。

瓦片内坐标系图示

1
2
3
4
5
6
7
8
9
     0 ──────────────────────────→ 8192 (X)
┌──────────────────────────────┐
0 │ │
│ │
│ ·(655, 4833)
│ 故宫 │
│ │
8192 └──────────────────────────────┘
(Y)

第5步:TileMatrix(瓦片坐标 → 世界坐标)

每个瓦片在世界坐标中的大小

1
2
3
tileWorldSize = worldSize / 2^tileZ
= 646,092 / 1024
= 631 逻辑像素

⚠️ 注意:因为 zoom=10.3 > tileZ=10,每个瓦片显示为 631px,而不是标准的 512px。这就是小数 zoom 的效果——瓦片被放大显示。

TileMatrix 的构造

1
2
3
4
5
TileMatrix = Translate(843 × 631, 383 × 631, 0)
× Scale(631/8192, 631/8192, 1)

= Translate(531,833, 241,673, 0)
× Scale(0.077, 0.077, 1)

变换计算

1
2
3
4
5
6
7
8
9
输入: tileLocal = (655, 4833)

① 缩放: (655 × 0.077, 4833 × 0.077)
= (50.4, 372.1)

② 平移: (50.4 + 531,833, 372.1 + 241,673)
= (531,883, 242,045)

世界坐标: (531,883, 242,045)

第6步:ViewProjMatrix(世界坐标 → 裁剪空间)—— 透视投影!

这是最复杂的一步,包含:

  • 平移到地图中心
  • 旋转(bearing, pitch, roll)
  • 相机后退
  • 透视投影

pitch = 45° 时的相机位置

1
2
3
4
5
6
7
      📷 相机
\
\ 45°
\

────────────────────── 地图平面
·天安门(中心)

ViewProjMatrix 的构建顺序

1
2
3
4
5
M = Perspective(fov=36.87°, aspect, near, far)
× Scale(1, -1, 1) // Y轴翻转
× Translate(0, 0, -cameraDist) // 相机后退
× RotateX(45°) // pitch倾斜
× Translate(-centerX, -centerY, 0) // 移到中心

相机到中心的距离(逻辑像素)

1
2
3
4
cameraToCenterDistance = height / 2 / tan(fov/2)
= 768 / 2 / tan(18.43°)
= 384 / 0.333
= 1,152 逻辑像素

故宫的透视变换计算

输入:

  • 故宫世界坐标 = (531,883, 242,045, 0)
  • 天安门(中心) = (531,863, 242,285, 0)

① 相对于中心的位置:

1
2
3
4
relativePos = (531,883 - 531,863, 242,045 - 242,285)
= (20, -240, 0)

故宫在天安门的:东边20像素,北边240像素

② 应用 pitch=45° 旋转(绕X轴):

1
2
3
4
5
6
7
8
9
10
11
旋转后的Y和Z会混合

y' = y × cos(45°) - z × sin(45°)
= -240 × 0.707 - 0 × 0.707
= -170

z' = y × sin(45°) + z × cos(45°)
= -240 × 0.707 + 0 × 0.707
= -170

旋转后: (20, -170, -170)

③ 相机后退:

1
2
3
4
5
z'' = z' - cameraDist
= -170 - 1152
= -1322

相机空间: (20, -170, -1322)

④ 透视投影(关键!):

1
2
3
4
5
透视投影后,w 分量不再是 1

w = -z'' × 某系数 ≈ 1322 × 某系数

这个 w 值决定了透视缩放程度

第7步:透视除法 & 屏幕坐标

⭐ 这是透视效果的关键步骤!

1
2
clipPos = (x', y', z', w')
ndcPos = (x'/w', y'/w', z'/w')

近大远小的原理

  • w 与距离成正比
  • 除以 w 后,远处的点被”缩小”
  • 近处的点被相对”放大”

视口变换(NDC → 屏幕像素)

1
2
screenX = (ndcX + 1) × 0.5 × screenWidth
screenY = (ndcY + 1) × 0.5 × screenHeight

故宫的最终屏幕位置(逻辑像素):假设计算结果为 screenPos = (527, 340)

⚠️ 这个位置考虑了:

  • 透视缩放(pitch=45°带来的近大远小)
  • 故宫在天安门北边,所以在屏幕上方
  • 屏幕上方是”远处”,所以实际偏移比世界坐标差值小

第8步:逻辑像素 → 物理像素

devicePixelRatio = 2.0 (Retina屏幕)

1
2
3
4
5
物理像素 = 逻辑像素 × devicePixelRatio

故宫屏幕位置:
逻辑像素: (527, 340)
物理像素: (1054, 680)

Canvas 设置

1
2
3
4
canvas.width = 1024 × 2 = 2048 物理像素
canvas.height = 768 × 2 = 1536 物理像素
canvas.style.width = '1024px' CSS/逻辑尺寸
canvas.style.height = '768px' CSS/逻辑尺寸

WebGL 实际渲染分辨率是 2048×1536,但显示大小是 1024×768,所以在 Retina 屏幕上看起来更清晰。


第9步:Shader 中的实际执行

CPU 端预计算

1
2
3
4
posMatrix = viewProjMatrix × tileMatrix

// 这个矩阵包含了所有变换:
// 瓦片定位 + 缩放 + 旋转 + 透视

GPU 端执行(每个顶点)

1
2
3
4
5
6
7
8
9
10
11
// Vertex Shader
in vec2 a_pos; // 瓦片内坐标,如 (655, 4833)

void main() {
// 一次矩阵乘法完成所有变换!
gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);

// gl_Position = (x', y', z', w')
// GPU 自动进行透视除法: pos/w
// 然后进行视口变换
}

透视投影对像素的影响

世界坐标中的差距

故宫在天安门北边 240 逻辑像素

如果 pitch=0(无透视)

1
2
屏幕上的差距 = 240 逻辑像素
故宫在屏幕中心上方 240 像素处

但 pitch=45°(有透视)

1
2
屏幕上的差距 < 240 逻辑像素 (假设约 180 像素)
因为故宫在"远处",被透视缩小了

同一个瓦片在屏幕上的显示大小

理论大小(zoom=10.3):631×631 逻辑像素

实际显示(pitch=45°):

位置 显示大小 原因
屏幕底部(近处) 约 900×900 像素 透视放大
屏幕中央 约 631×631 像素 接近标准
屏幕上方(远处) 约 400×400 像素 透视缩小

同一个瓦片,近处和远处显示大小差 2 倍以上!


完整流程总图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
┌────────────────┐
│ 经纬度 │ 故宫: (116.397°E, 39.916°N)
└───────┬────────┘
mercatorXfromLng(), mercatorYfromLat()

┌────────────────┐
│ Mercator坐标 │ (0.82332, 0.3746) 范围[0,1]²
└───────┬────────┘
│ × worldSize (= tileSize × 2^zoom = 512 × 2^10.3)

┌────────────────┐
│ 世界坐标 │ (531,883, 242,045) 范围[0, 646,092]²
└───────┬────────┘

│ 数据存储时:转换为瓦片内坐标

┌────────────────┐
│ 瓦片内坐标 │ (655, 4833) 范围[0, 8192]²
│ │ 存储在 GPU 顶点缓冲区 (Int16)
└───────┬────────┘

│ ═══════════════════════════════════════════════════════
│ ║ GPU Shader: gl_Position = posMatrix × vec4(a_pos,0,1) ║
│ ═══════════════════════════════════════════════════════

│ posMatrix = ViewProjMatrix × TileMatrix
│ = (透视+旋转+平移) × (瓦片定位+缩放)

┌────────────────┐
│ 裁剪空间 │ (x', y', z', w') w ≠ 1(透视!)
└───────┬────────┘
│ GPU 自动:透视除法 (÷w) + 视口变换

┌────────────────┐
│ 屏幕逻辑像素 │ (527, 340) 范围[0, 1024]×[0, 768]
└───────┬────────┘
│ × devicePixelRatio (=2.0)

┌────────────────┐
│ 屏幕物理像素 │ (1054, 680) 范围[0, 2048]×[0, 1536]
│ │ 实际在屏幕上绘制的位置
└────────────────┘

关键概念总结

tileSize = 512

  • 是”逻辑像素”,不是物理像素
  • 是 zoom=0 时世界的逻辑大小
  • 是计算 worldSize 的基准
  • 不是瓦片在屏幕上的实际显示大小!

zoom vs tileZ

  • zoom=10.3 是用户看到的缩放级别(小数)
  • tileZ=10 是加载的瓦片层级(整数)
  • zoom-tileZ=0.3 使瓦片放大显示(631px vs 512px)

透视投影的影响

  • 近处物体显得大,远处物体显得小
  • 同一经纬度差,在屏幕上的像素差取决于距离
  • tileSize 与最终屏幕大小不是简单对应关系

坐标空间对照表

坐标空间 范围 说明
经纬度 [-180,180] × [-90,90] 原始地理坐标
Mercator [0, 1]² Web Mercator投影后的归一化坐标
瓦片内坐标 [0, 8192]² 单个瓦片内的局部坐标 (EXTENT)
世界坐标 [0, worldSize]² 整个地图的逻辑像素坐标
裁剪空间 [-1, 1]³ OpenGL标准裁剪空间(齐次坐标)
屏幕逻辑像素 [0, width] × [0, height] CSS像素位置
屏幕物理像素 [0, width×DPR] × [0, height×DPR] 实际渲染像素位置

相关代码位置

功能 文件位置
Mercator投影 src/geo/mercator_coordinate.ts
TileMatrix计算 src/geo/projection/mercator_utils.ts
ViewProjMatrix计算 src/geo/projection/mercator_transform.ts
Transform基础 src/geo/transform_helper.ts
瓦片坐标常量 src/data/extent.ts
几何数据加载 src/data/load_geometry.ts
顶点着色器 src/shaders/_projection_mercator.vertex.glsl

MapLibre GL JS 完整坐标变换流程详解
https://fuhongcui.github.io/posts/722913b1/
作者
WhiteGive
发布于
2025年12月8日
许可协议