three.js地图数学基础(三):实战墨卡托

教程:实现并核对 Web Mercator

这一节把上一章的推导落实为一个可复用的浏览器 ES 模块。完成后,你将拥有后续瓦片地址、视口覆盖和渲染坐标共同依赖的四个接口:

js 复制代码
MAX_MERCATOR_LAT
clampLatitude(lat)
projectLngLat(lng, lat)
unprojectMercator(x, y)

源码位于:

text 复制代码
yinqing/examples/vector-tile-handbook/shared/math/mercator.js

1. 先写清输入输出

在敲公式之前,先固定契约:

函数 输入 输出 单位
clampLatitude 纬度 截断后的纬度 度
projectLngLat 经度、纬度 {x,y} 输入为度;输出无量纲
unprojectMercator normalized x/y {lng,lat} 输入无量纲;输出为度

经度不截断也不折返。181° 会投影到略大于 1 的 x;这样下一章才能把它规范化为 x=0 附近、wrap=1 的瓦片地址。

2. 定义常量和纬度边界

js 复制代码
export const MAX_MERCATOR_LAT = 85.0511287798066;

const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;

export function clampLatitude(lat) {
  const num = Number(lat);
  if (Number.isNaN(num)) return NaN;
  return Math.max(
    -MAX_MERCATOR_LAT,
    Math.min(MAX_MERCATOR_LAT, num),
  );
}

显式使用 Number(lat),让 DOM <input> 的字符串值也能进入计算。同时通过 Number.isNaN 校验防止无效输入伪装成合法数值。UI 层必须负责向用户报告无效输入,数学层不偷偷替换成某个默认位置。

3. 实现正向投影

js 复制代码
export function projectLngLat(lng, lat) {
  const numLng = Number(lng);
  const phi = clampLatitude(lat) * DEG_TO_RAD;

  return Object.freeze({
    x: (numLng + 180) / 360,
    y: 0.5
      - Math.log(Math.tan(Math.PI / 4 + phi / 2))
        / (2 * Math.PI),
  });
}

实现细节与推导说明:

  1. 经度映射 :经度范围 [-180, 180] 到归一化 [0, 1] 本质上是纯线性映射 (lng + 180) / 360,无需经过弧度转换,避免了额外的浮点转换与精度积累误差;
  2. 纬度映射:先截断,再转换为弧度进行墨卡图投影计算;
  3. 不可变对象:冻结返回的小型对象,确保数据契约安全。

性能提示 :Object.freeze 在教程与工具库模块中能够防止误修改坐标对象。但若后续用于海量顶点转换(如每帧上万次调用)的热点代码中,应移除 Object.freeze 或改用数组 / TypedArray,以减少 GC(垃圾回收)开销。

4. 实现逆投影

js 复制代码
export function unprojectMercator(x, y) {
  const numX = Number(x);
  const numY = Number(y);

  return Object.freeze({
    lng: numX * 360 - 180,
    lat: Math.atan(
      Math.sinh(Math.PI * (1 - 2 * numY)),
    ) * RAD_TO_DEG,
  });
}

逆投影同样对 x 到经度的转换使用线性逆变换 x * 360 - 180。

逆投影没有强制把 x/y 限制在 [0,1]。这允许:

  • x<0 或 x>1 还原为连续世界中的经度;
  • 调试瓦片边界外的缓冲几何;
  • 让"是否规范化"成为瓦片地址模块的显式责任。

5. 用 Node 直接核对同一个浏览器模块

在仓库根目录执行:

powershell 复制代码
node --input-type=module -e "import('./yinqing/examples/vector-tile-handbook/shared/math/mercator.js').then(({ projectLngLat, unprojectMercator }) => { const origin = projectLngLat(0, 0); const shanghai = projectLngLat(121.4737, 31.2304); const roundTrip = unprojectMercator(shanghai.x, shanghai.y); console.log({ origin, shanghai, roundTrip }); })"

应看到三类事实:

  • origin.x 与 origin.y 都是 0.5;
  • 上海的 x 约为 0.8374269444444444,y 约为 0.40860372471489265;
  • roundTrip 回到 121.4737, 31.2304,误差小于 1e-9°。

再检查纬度边界:

js 复制代码
projectLngLat(0, 90);   // y 接近 0
projectLngLat(0, -90);  // y 接近 1

如果没有先截断,第一行会越来越接近负无穷,最终破坏后续的瓦片索引计算。

6. 在浏览器检查器中展示什么

Stage 01 创建后,会直接导入这个模块,并同时展示:

  • 输入的度;
  • 截断后的纬度;
  • normalized x/y(至少 10 位小数);
  • 逆投影结果与往返误差;
  • 由同一个 x/y 推出的 XYZ 瓦片地址。

检查器不是第二套计算实现。它只格式化数学模块的结果。这样文字示例、Node 核对与浏览器画面共享同一事实来源。

7. 常见错误与定位顺序

结果非常大或出现 NaN

先检查输入是否是有限数字,再检查是否把度直接交给了三角函数。不要先在结果上做 || 0;那会把真正的输入错误伪装成赤道。

南北颠倒

确认归一化公式是 0.5 - ...,不是 0.5 + ...。XYZ 的 y 从北向南增大。

180° 落到世界外

这是连续投影的正确结果:180° → x=1。下一章在离散寻址时会把它规范化成下一世界的 x=0, wrap=1。不要在投影函数里提前 % 1,否则会丢失 wrap。

高纬输入得到 Infinity

检查纬度是不是在换弧度之前调用了 clampLatitude,并确认上限常量没有被四舍五入成 85。

8. 完成标准

完成本教程后,你应该能解释:

  1. 为什么输入角度而输出没有单位;
  2. 为什么 y 轴从北向南;
  3. 为什么纬度截断而经度不折返;
  4. 为什么 (0°,0°) 必须得到 (0.5,0.5);
  5. 为什么投影模块不应该知道相机、瓦片缓存或 Three.js。

接下来将把连续的 normalized Mercator 离散化为 XYZ 四叉树地址,并给重复世界建立稳定身份。


相关推荐
程序员Sunday5 小时前
JavaScript 事件循环面试题,宏任务与微任务怎么执行|Sunday面试指南
开发语言·javascript·面试·校招·事件循环·程序员sunday
默_笙6 小时前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
思无邪667 小时前
用 AI 做 JS 逆向:从抓包到复现的完整方法论
开发语言·javascript·人工智能
福兮说9 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
可乐鸡翅yeah_9 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放
用户1179536547811 小时前
TRAE vs Claude Code 性能对比:从代码生成、多文件重构到底层吞吐,谁更适合你的日常开发?
javascript
kill52212 小时前
useRef
前端·javascript·react.js
米多科技12 小时前
我做了一个 3D 虚拟世界基底,在上面再搭 3D 世界或元宇宙,事半功倍
javascript·人工智能·github
霍营_托尼13 小时前
纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器
前端·javascript·音视频开发
西柚小萌新13 小时前
【LLM&&AI应用开发 八股文】--4.2.Agent智能体(中)
前端·javascript·react.js