three.js地图数学基础(二):Web Mercator

Web Mercator:把经纬网压进单位正方形

矢量瓦片最常见的平面坐标基础是 Web Mercator(在 GIS 行业标准中对应 EPSG:3857,历史上曾使用过 EPSG:900913、EPSG:3785 等标识)。它把经度方向线性映射,把纬度方向按正割函数逐渐拉伸,使局部角度得以保持。代价是面积和距离在高纬度严重放大,而且南北极永远无法映射到有限坐标。

1. 模型和符号

本书采用球面模型,而不是椭球 Mercator:

  • λ:经度弧度,范围通常为 [−π,π]
  • φ:纬度弧度;
  • R:球体半径;
  • (X_m,Y_m):以米或任意同尺度单位表示的未归一化投影;
  • (x,y):归一化到单位世界的坐标;
  • 屏幕/瓦片约定让 y 向南增大。

角度输入来自用户时通常是度。必须先换为弧度:
θrad = θdeg π180 \theta_{rad}=\theta_{deg}\frac{\pi}{180} θrad=θdeg180π

JavaScript 的 Math.sinMath.tanMath.atan 等函数全部接收弧度。

1.1 球面模型与椭球 Mercator 的区别

在地理信息系统中,Mercator 投影有两种主流实现:

  1. 椭球 Mercator(如 EPSG:3395): 严格按照 WGS84 椭球体长短半轴公式计算。推导积分时需引入第一偏心率 ee e,其纵坐标公式为: Ym=Rln⁡ tan⁡ (π4+φ2) ⋅ ( 1−esin⁡φ1+esin⁡φ ) e/2 Y_m = R \ln \left \\tan\\left(\\frac{\\pi}{4}+\\frac{\\varphi}{2}\\right) \\cdot \\left( \\frac{1 - e\\sin\\varphi}{1 + e\\sin\\varphi} \\right)\^{e/2} \\right Ym=Rlntan(4π+2φ)⋅(1+esinφ1−esinφ)e/2
  2. 球面 Web Mercator(EPSG:3857): 假定地球为完美正球体(半径取 WGS84 长半轴 R=6,378,137 mR = 6,378,137 \text{ m} R=6,378,137 m),直接采用前述简化积分公式。

为什么 Web Mercator 放弃了更精确的椭球模型?

  • 计算性能极高: 球面公式省去了偏心率修正项,计算量显著降低,极其适合渲染引擎和客户端进行实时坐标变换;
  • 完美正方形切割: 球面模型下,南北边界截断后,整个投影世界是一个严格的 2πR×2πR2\pi R \times 2\pi R 2πR×2πR 正方形,非常适合用四叉树(Quadtree)进行逐级切片(XYZ 瓦片);而椭球模型很难直接导出完美的正方形瓦片结构;
  • 定位与测绘的区别: Web 电子地图的核心目标是视觉渲染与交互 而非测绘工程。虽然球面模型会导致最大约 20 公里左右的绝对纬度定位偏移(若不经转换直接对比),但由于所有 Web 瓦片与要素都统一使用这套球面规则,因此在地图上叠加矢量数据时,相对位置完全对齐,不存在视觉错位

2. 经度为什么是线性的

Mercator 圆柱投影把赤道方向展开为直线。经度相差 正好绕球一周,所以最自然的未归一化横坐标为:
Xm=Rλ X_m=R\lambda Xm=Rλ

λ=-π 平移到 0,再除以完整周长 ,得到单位世界横坐标:
x= λ+π2π \boxed{x=\frac{\lambda+\pi}{2\pi}} x=2πλ+π

于是:

  • −180° → x=0
  • 0° → x=0.5
  • +180° → x=1

公式本身不必截断经度。λ>πλ<−π 会自然产生 [0,1] 外的 x,后续由 wrap 记录重复世界。这一点对跨反经线的连续平移很重要。

3. 纬度公式从哪里来

3.1 保角条件

球面上一小段纬线在东西方向的实际长度随纬度缩短。纬度为 φ 时,平行圈半径为 R cosφ,因此经度微分 对应的地表长度为:
dsx=Rcos⁡φ dλ ds_x=R\cos\varphi\,d\lambda dsx=Rcosφdλ

南北方向的纬度微分长度为:
dsy=R dφ ds_y=R\,d\varphi dsy=Rdφ

投影横向微分为 dX_m=R dλ。若希望局部东西、南北使用同一放大比例,纵向投影必须满足:
dYm R dφ = dXm Rcos⁡φ dλ = 1cos⁡φ \frac{dY_m}{R\,d\varphi} = \frac{dX_m}{R\cos\varphi\,d\lambda} = \frac{1}{\cos\varphi} RdφdYm=RcosφdλdXm=cosφ1

因此:
dYm=Rsec⁡φ dφ dY_m=R\sec\varphi\,d\varphi dYm=Rsecφdφ

对纬度积分:
Ym=R∫sec⁡φ dφ=Rln⁡∣sec⁡φ+tan⁡φ∣+C Y_m=R\int\sec\varphi\,d\varphi =R\ln|\sec\varphi+\tan\varphi|+C Ym=R∫secφdφ=Rln∣secφ+tanφ∣+C

取赤道 φ=0Y_m=0,可得 C=0。利用恒等式:
sec⁡φ+tan⁡φ=tan⁡ (π4+φ2) \sec\varphi+\tan\varphi =\tan\left(\frac{\pi}{4}+\frac{\varphi}{2}\right) secφ+tanφ=tan(4π+2φ)

得到经典球面 Mercator 纵坐标:
Ym=Rln⁡tan⁡ (π4+φ2) Y_m=R\ln\tan\left(\frac{\pi}{4}+\frac{\varphi}{2}\right) Ym=Rlntan(4π+2φ)

3.2 从向北为正改为向南为正

数学投影的 Y_m 向北增大;XYZ 瓦片和屏幕坐标却从北向南编号。完整世界在横向的跨度为 2πR,将赤道放在 y=1/2 并翻转轴:
y=12− ln⁡tan⁡ (π4+φ2) 2π \boxed{ y=\frac{1}{2} -\frac{\ln\tan\left(\frac{\pi}{4}+\frac{\varphi}{2}\right)}{2\pi} } y=21−2πlntan(4π+2φ)

赤道上 tan(π/4)=1ln1=0,所以 y=0.5

在代码实现(如 JavaScript)中,利用双曲函数恒等式 ln⁡(sec⁡φ+tan⁡φ)=atanh(sin⁡φ)\ln(\sec\varphi + \tan\varphi) = \text{atanh}(\sin\varphi) ln(secφ+tanφ)=atanh(sinφ),该公式可进一步表达为:
y=12− atanh(sin⁡φ)2π \boxed{ y = \frac{1}{2} - \frac{\text{atanh}(\sin\varphi)}{2\pi} } y=21−2πatanh(sinφ)

工程优势: 使用 Math.atanh(Math.sin(phi)) 相比传统的 Math.log(Math.tan(...)),减少了一次加法、一次除法与多次三角函数嵌套,在计算机浮点运算中拥有更好的指令性能和数值稳定性。

4. 为什么必须截断纬度

φ→π/2 时,tan(π/4+φ/2)→∞,投影纵坐标也趋向无穷。南极同理。因此不可能把两个极点放进有限正方形。

XYZ 瓦片选择的截止纬度,使投影纵坐标刚好达到横向半周,即 normalized y=01。令北边界 y=0
ln⁡tan⁡ (π4+ φmax 2 ) =π \ln\tan\left(\frac{\pi}{4}+\frac{\varphi_{max}}{2}\right)=\pi lntan(4π+2φmax)=π

由反函数(下一节证明)可得:
φmax =arctan⁡(sinh⁡π) \varphi_{max}=\arctan(\sinh\pi) φmax=arctan(sinhπ)

换成角度:
φmax =85.0511287798066∘ \boxed{\varphi_{max}=85.0511287798066^\circ} φmax=85.0511287798066∘

任何更北或更南的输入都必须先截断到 ±φmax,否则会得到超出有限世界的数值,靠近极点时还可能出现 Infinity

截断不是"修复错误坐标",而是这套平面瓦片模型的定义域。若产品必须展示极点,应选择地球/极区投影,而不是继续扩大 Web Mercator。

工程截断时机: 在代码实现中,截断必须在传入任何三角函数计算之前(或紧随度转弧度之后)完成:

javascript 复制代码
// 正确:先做范围约束,再进入三角函数,防止 ±90° 计算抛出 Infinity/NaN
const clampedLat = Math.max(-85.0511287798066, Math.min(85.0511287798066, lat));
const phi = (clampedLat * Math.PI) / 180;

5. 逆投影推导

横坐标直接变形:
λ=2πx−π \boxed{\lambda=2\pi x-\pi} λ=2πx−π

对于纬度,令:
a=π(1−2y)a=\pi(1-2y) a=π(1−2y)

由正向公式可知:
a=ln⁡tan⁡ (π4+φ2) a=\ln\tan\left(\frac{\pi}{4}+\frac{\varphi}{2}\right) a=lntan(4π+2φ)

指数化后:
ea=tan⁡ (π4+φ2) e^a=\tan\left(\frac{\pi}{4}+\frac{\varphi}{2}\right) ea=tan(4π+2φ)

可写成常见反式:
φ=2arctan⁡(ea)−π2\varphi=2\arctan(e^a)-\frac{\pi}{2} φ=2arctan(ea)−2π

利用 atan(sinh a) 与上式等价,得到数值实现更直接的形式:
φ=arctan⁡ (sinh⁡(π(1−2y))) \boxed{\varphi=\arctan\left(\sinh\left(\pi(1-2y)\right)\right)} φ=arctan(sinh(π(1−2y)))

最后把 λ/φ 从弧度乘 180/π 转回度。

6. 两组可手算的检查

6.1 原点检查

输入 (0°,0°)
x= 0+π2π =0.5 x=\frac{0+\pi}{2\pi}=0.5 x=2π0+π=0.5
y=12− ln⁡tan⁡(π/4)2π =12− ln⁡12π =0.5 y=\frac12-\frac{\ln\tan(\pi/4)}{2\pi} =\frac12-\frac{\ln1}{2\pi}=0.5 y=21−2πlntan(π/4)=21−2πln1=0.5

所以结果必须精确(0.5,0.5)。若不是,最常见原因是把角度直接传给三角函数,或忘记 y 轴翻转。

6.2 上海检查

输入:
( λdeg , φdeg )=(121.4737,31.2304) (\lambda_{deg},\varphi_{deg})=(121.4737,31.2304) (λdeg,φdeg)=(121.4737,31.2304)

投影结果约为:
(x,y)=(0.8374269444444444,  0.40860372471489265)(x,y)=(0.8374269444444444,\;0.40860372471489265) (x,y)=(0.8374269444444444,0.40860372471489265)

将这两个值送入逆投影,应恢复:
(121.4737∘,31.2304∘)(121.4737^\circ,31.2304^\circ) (121.4737∘,31.2304∘)

双精度往返误差应远小于 10⁻⁹°。这里检验的是公式与实现的一致性,不代表地面测量精度达到纳米级。

7. 失真如何影响瓦片引擎

Mercator 的局部线性放大比例为:
k=sec⁡φ= 1cos⁡φ k=\sec\varphi=\frac{1}{\cos\varphi} k=secφ=cosφ1

赤道 k=1;纬度 60°k=2。这意味着投影平面上一段固定长度对应的真实地表距离随纬度变化。它会影响:

  • "每像素多少米"的计算;
  • 以米定义的建筑高度或线宽;
  • 相机高度与缩放级别之间的换算;
  • 距离标尺。

但瓦片寻址仍在 normalized Mercator 中进行。不要为了修正米制失真而改变 x/y 或 XYZ 规则;应该在需要物理尺度的模块中显式加入纬度比例。

7.1 归一化坐标向像素/瓦片坐标的映射

归一化坐标 (x,y)∈0,1(x,y) \in 0, 1 (x,y)∈0,1 是跨缩放级别的统一表达。在特定缩放级别(Zoom Level ZZ Z)及瓦片像素尺寸 SS S(通常为 256px 或 512px)下,坐标换算公式为:

  • 世界像素坐标: px=x⋅S⋅2Zpx = x \cdot S \cdot 2^Z px=x⋅S⋅2Z py=y⋅S⋅2Zpy = y \cdot S \cdot 2^Z py=y⋅S⋅2Z

  • 瓦片索引 (Tile X / Tile Y): TileX=⌊x⋅2Z⌋TileX = \lfloor x \cdot 2^Z \rfloor TileX=⌊x⋅2Z⌋ TileY=⌊y⋅2Z⌋TileY = \lfloor y \cdot 2^Z \rfloor TileY=⌊y⋅2Z⌋

8. 实现契约

数学模块应满足:

  1. projectLngLat 接收度,输出无量纲 normalized Mercator;
  2. 纬度在三角函数之前截断;
  3. 经度不自动折返,以保留连续世界信息;
  4. unprojectMercator 接收 normalized 数值,输出度;
  5. 模块不依赖 DOM、Three.js、瓦片缓存或当前相机;
  6. 返回值不可变,避免检查器或渲染代码意外改写共享结果。

下一篇教程将把这些公式写成一个依赖为零的 ES 模块,并用原点、上海和边界输入逐项核对。


相关推荐
谷哥的小弟1 小时前
TypeScript接口
javascript·typescript
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
渣波1 小时前
React 性能优化实战:从 memo 到 Hooks 的底层原理与进阶封装
前端·javascript
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
两只羊ovo1 小时前
listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解
前端·javascript
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js
今日无bug1 小时前
JS 同步与异步:从单线程到 Promise
javascript·promise
sugar__salt2 小时前
深入理解 React useContext:跨层级组件通信的核心利器
前端·javascript·react.js·前端框架·框架
谷哥的小弟3 小时前
TypeScript类型断言
前端·javascript·typescript