three.js地图数学基础(九):坐标检查器

81 阅读6分钟

教程:构建 Stage 01 坐标检查器

这一教程把第一部分的数学模块接成一个可操作页面。它不是完整地图:没有相机交互、视口 cover 或网络瓦片。它只完成一件事——让同一个输入点在每个坐标空间中的身份同时可见,并用 Three.js 验证 render-local 位置。

完成页面:

http://localhost:3001/vector-tile-handbook/stages/01-coordinate-system/

源码:

yinqing/examples/vector-tile-handbook/stages/01-coordinate-system/
├── index.html
└── main.js

1. 页面结构

index.html 只负责三件事:

  1. 加载共享 handbook.css;
  2. 用 import map 把裸导入 three 指向项目静态资源;
  3. 创建 #app 并加载 main.js。
<script type="importmap">
  {
    "imports": {
      "three": "/vendor/three/build/three.module.js"
    }
  }
</script>

教学 stage 不依赖打包器,也不导入当前生产瓦片源码。这样每一步的依赖边界能在浏览器 Network 面板中直接看见。

2. 复用 stage shell

main.js 先构造输入表单,再交给:

mountStageShell({
  root,
  title,
  description,
  controls: [form],
  attributionHtml,
  onResize,
});

返回:

{ canvas, hud, inspector, attribution, dispose }

这里有两个重要约束:

  • 壳层拥有 DOM 布局和唯一的 ResizeObserver;
  • stage 拥有 Three.js renderer、相机、场景和表单监听。

dispose() 只清理壳层建立的观察者;stage 仍必须在 pagehide 或组件卸载时彻底释放 GPU 资源。除了调用 renderer.dispose() 外,还必须显式对场景中所有 Mesh/Grid 的 geometry 与 material 调用 .dispose(),防止 WebGL VRAM 内存泄漏。

3. 处理同步首次 resize

mountStageShell 会立刻测量 viewport,但此时 renderer 还没有创建。因此 onResize 不能假设 Three.js 已存在:

let renderer;
let camera;
let viewportSize = { width: 1, height: 1, dpr: 1 };

function resize(width, height, dpr) {
  viewportSize = {
    width: Math.max(1, width),
    height: Math.max(1, height),
    dpr: Math.min(2, Math.max(1, dpr)),
  };

  if (!renderer || !camera) return;

  renderer.setSize(viewportSize.width, viewportSize.height, false);
  renderer.setPixelRatio(viewportSize.dpr);

  // 透视相机需同步更新 aspect 比例与投影矩阵,防止画面缩放拉伸
  camera.aspect = viewportSize.width / viewportSize.height;
  camera.updateProjectionMatrix();
}

创建 renderer/camera 后再调用一次 resize(...),应用已记录的尺寸。DPR 上限设为 2,是教学页面的填充率预算,不影响 CSS 坐标。

4. 构造 512×512 瓦片局部场景

Stage 01 将当前 XYZ 瓦片的左上角作为 render-local 原点。场景遵循标准右手坐标系 (X×Y=ZX \times Y = Z),定义如下:

tile width = 512 world units
X = east (+X)
Y = altitude (+Y)
Z = south (+Z)

场景包含:

  • 一个 512×512 平面;
  • 每 64 world units 一格的 GridHelper;
  • 明亮边界框;
  • 东向与南向轴线;
  • 一个球形 marker 和地面圆环。

相机固定观察瓦片中心 (256, 0, 256)。它不模拟地图缩放;输入 zoom 只改变“该瓦片在完整世界中的绝对位置和大小换算”,局部瓦片仍稳定显示为 512 单位。

这个选择刻意把两个问题分开:

  • XYZ 决定当前 point 属于哪个空间 footprint;
  • render-local 决定该 point 在这个 footprint 内如何稳定显示。

5. 建立一次单向数据流

每当 longitude、latitude 或 zoom 改变,只执行一个 update():

form input
  ↓
projectLngLat
  ↓
lngLatToTileAddress
  ↓
tileAddressToMercatorBounds
  ↓
toRenderLocal + splitFloat64
  ↓
marker position + inspector HTML + render

检查器不重新实现公式,Three.js 也不自行推导地址。所有输出来自同一组纯数学函数,因此数字和画面不会因复制逻辑而漂移。

6. 计算瓦片局部原点

先得到地址和边界:

const tile = lngLatToTileAddress(longitude, latitude, zoom);
const bounds = tileAddressToMercatorBounds(tile);

计算瓦片索引 xx 与 yy 时,必须使用 Math.floor(normalizedX * numTiles),绝对不能使用位运算截断 (normalizedX * numTiles) | 0。因为在负数 wrap(如 −180.1∘-180.1^\circ,normalizedX<0normalizedX < 0)情况下,−0.05 | 0 会错误截断为 0,而 Math.floor(-0.05) 才能得到正确的瓦片索引 -1。

再定义世界宽度:

const worldSize = 512 * 2 ** zoom;

把瓦片西北角作为原点:

const origin = {
  x: bounds.west,
  y: bounds.north,
  altitudeMeters: 0,
};

最后计算:

const local = toRenderLocal(
  { ...mercator, altitudeMeters: 0 },
  origin,
  worldSize,
);

marker.position.set(local.x, 17, local.z);

由于 origin 已包含 wrap,−180.1° 不会被错误搬回中心世界。它与 +179.9° 会得到相似的 tile-local 位置,但 inspector 明确显示不同 wrap。

7. 展示绝对值与精度残差

绝对世界坐标:

const absoluteWorldX = mercator.x * worldSize;
const splitX = splitFloat64(absoluteWorldX);

检查器同时显示:

  • absolute X:CPU Float64 世界坐标;
  • X high:Math.fround 后可上传的 Float32 高位;
  • X low:absoluteX - high 的残差;
  • local X:先减瓦片原点后的小坐标。

这里展示了两种解决 GPU 抖动的思路:Stage 01 渲染选用了 Tile-RTC (Relative to Center/Origin) 方案,将坐标限定在局部 [0, 512] 范围;而同时展示 X high/low 残差(DS-Float / Double-Single 模拟)是为了直观验证在大世界坐标下直接上传 Float32 会丢失多少精度。

8. 三组浏览器核对

8.1 Shanghai

输入:

longitude = 121.4737°
latitude  = 31.2304°
zoom      = 13

应看到:

Mercator  ≈ 0.837426944444, 0.408603724715
canonical = 13 / 6860 / 3347
wrap      = 0

marker 必须落在 tile 正方形内部,local X/Z 都在 0..512 world units。

8.2 East +179.9°

点击 +179.9°:

canonical = 3 / 7 / 4
wrap      = 0

marker 靠近瓦片东边。

8.3 West −180.1°

点击 −180.1°:

canonical = 3 / 7 / 4
wrap      = −1

规范内容地址保持不变,但 unwrapped x 从 7 变为 −1。边界 west/east 也位于负 normalized 世界。marker 仍在相同局部位置附近,说明内容坐标和实例世界已经分离。

9. 输入边界

  • 纬度输入 UI 接受 ±90°,数学层会显示实际使用的 ±85.0511287798066°;
  • zoom 在 UI 和 update() 中都夹到配置的 0..15;
  • 经度不设置硬性限制以观察多重 wrap,但在大值输入时建议进行边界防抖与极值截断(如 ±1015\pm 10^{15}),避免在计算 512 * 2**zoom 时超出 JS 安全整数范围 Number.MAX_SAFE_INTEGER;
  • 空值或非有限数字回退到上海教学视图,但公共生产 API 应返回明确校验错误;
  • 所有 inspector 数值都带单位,避免把 degrees、unitless 和 world units 混读。

10. 为什么这里不加载 PBF

第一阶段只验证坐标不变量。若同时引入网络请求、MVT extent、Worker 和 GPU buffer,任何画面错误都可能来自五个不同环节。

保持 Stage 01 纯数学有两个好处:

  1. 后续阶段可用它核对任意瓦片的地址、边界和局部位置;
  2. 加载或几何出现问题时,可以先证明坐标链正确,再缩小排查范围。

11. 完成标准

浏览器通过三组预设后,确认:

  1. 控制台无异常;
  2. equator (0°,0°) 的 Mercator 为 (0.5,0.5);
  3. 上海往返误差小于 1e-9°;
  4. ±180° 两侧的 canonical x 在范围内,wrap 正确变化;
  5. marker 与 inspector 的 local X/Z 一致;
  6. 每个数字标明 degrees、unitless、tile index、world units 或 meters;
  7. resize 后相机比例与画布分辨率同步。

Stage 01 至此完成。第二部分将保留这些数学模块,加入不可变 ViewState、地图相机和交互循环;第三部分再把屏幕射线扩展为视口瓦片 cover。