教程:构建 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 只负责三件事:
- 加载共享
handbook.css; - 用 import map 把裸导入
three指向项目静态资源; - 创建
#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 原点。场景遵循标准右手坐标系 (),定义如下:
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);
计算瓦片索引 与 时,必须使用 Math.floor(normalizedX * numTiles),绝对不能使用位运算截断 (normalizedX * numTiles) | 0。因为在负数 wrap(如 ,)情况下,−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,但在大值输入时建议进行边界防抖与极值截断(如 ),避免在计算
512 * 2**zoom时超出 JS 安全整数范围Number.MAX_SAFE_INTEGER; - 空值或非有限数字回退到上海教学视图,但公共生产 API 应返回明确校验错误;
- 所有 inspector 数值都带单位,避免把 degrees、unitless 和 world units 混读。
10. 为什么这里不加载 PBF
第一阶段只验证坐标不变量。若同时引入网络请求、MVT extent、Worker 和 GPU buffer,任何画面错误都可能来自五个不同环节。
保持 Stage 01 纯数学有两个好处:
- 后续阶段可用它核对任意瓦片的地址、边界和局部位置;
- 加载或几何出现问题时,可以先证明坐标链正确,再缩小排查范围。
11. 完成标准
浏览器通过三组预设后,确认:
- 控制台无异常;
- equator
(0°,0°)的 Mercator 为(0.5,0.5); - 上海往返误差小于
1e-9°; ±180°两侧的 canonical x 在范围内,wrap 正确变化;- marker 与 inspector 的 local X/Z 一致;
- 每个数字标明 degrees、unitless、tile index、world units 或 meters;
- resize 后相机比例与画布分辨率同步。
Stage 01 至此完成。第二部分将保留这些数学模块,加入不可变 ViewState、地图相机和交互循环;第三部分再把屏幕射线扩展为视口瓦片 cover。