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 ┌──────────────────────────────┐ 0 │ │ │ │ │ · │ │ 故宫 │ │ │ 8192 └──────────────────────────────┘ ↓
|
第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) × Translate(0, 0, -cameraDist) × RotateX(45°) × 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
| in vec2 a_pos;
void main() { gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);
}
|
透视投影对像素的影响
世界坐标中的差距
故宫在天安门北边 240 逻辑像素
如果 pitch=0(无透视)
1 2
| 屏幕上的差距 = 240 逻辑像素 故宫在屏幕中心上方 240 像素处
|
但 pitch=45°(有透视)
1 2
| 屏幕上的差距 < 240 逻辑像素 因为故宫在"远处",被透视缩小了
|
同一个瓦片在屏幕上的显示大小
理论大小(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 |