瓦片调试网格:让身份、位置与坐标转换同时可见
到目前为止,地图可以平移、缩放、旋转和倾斜,但场景还是空的。直接进入 MVT 下载与解码会把网络、Protobuf、样式、几何和相机问题叠在一起。更好的下一步是只画瓦片边界。
TileGridLayer 是一件诊断仪器:它不加载任何 PBF,只把一组明确给定的 TileKey 变成 world-space 线框与标签。若线框能稳定贴在地面、跨 wrap 连续、地址与 cursor 转换一致,就说明运行时的坐标主干已经可用。

1. 调试网格要证明什么
它同时检验五件事:
- TileKey 是否把内容身份和空间实例分开;
- canonical XYZ 边界能否恢复为正确的 Mercator 范围;
- wrap 是否只改变水平位置,而不制造新内容身份;
- Mercator 边界能否用当前 snapshot 转成 render-local 坐标;
- 线框、标签、cursor inspector 是否读取同一代 ViewState/TransformSnapshot。
它不证明:
- 当前集合完整覆盖倾斜视口;
- PBF URL、解码或几何构建正确;
- 父子替换没有洞;
- cache 和并发调度满足生产合同。
这些分别属于后续章节。诊断工具的价值来自边界清楚,而不是假装已经实现全部引擎。
2. TileKey 的五个字段
本手册第二部分固定结构:
js
{
displayZ,
canonicalZ,
canonicalX,
canonicalY,
wrap,
}
| 字段 | 单位/类型 | 约束 | 作用 |
|---|---|---|---|
displayZ |
non-negative integer | displayZ >= canonicalZ |
当前显示/过缩放级别 |
canonicalZ |
non-negative integer | 通用构造器上限 30;本实验剖面上限 15 | 数据内容的源层级 |
canonicalX |
tile index | [0,2^canonicalZ−1] |
规范世界水平地址 |
canonicalY |
tile index | [0,2^canonicalZ−1] |
规范世界垂直地址 |
wrap |
safe integer | 可正可负 | 水平重复世界编号 |
TileKey 创建后冻结。不要在对象上后来补 loaded、visible 或 opacity;身份值与生命周期状态属于不同领域。
3. 两种 ID 解决两种问题
内容 ID
js
getTileId(key) === "canonicalZ/canonicalX/canonicalY"
它忽略 displayZ 与 wrap。同一份源内容在更高显示 zoom 或相邻重复世界出现时,仍应共享请求、解码结果和缓存条目。
空间 ID
js
getTileSpatialId(key)
// "displayZ/canonicalZ/canonicalX/canonicalY@wrap"
它保留呈现所需字段。场景实例、可见集合与替换判断不能只用内容 ID,否则同一瓦片在 wrap=0 和 wrap=1 的两个位置会互相覆盖。
假设:
text
A = display 15, canonical 15/26821/13398, wrap 0
B = display 15, canonical 15/26821/13398, wrap 1
C = display 17, canonical 15/26821/13398, wrap 0
三者内容 ID 相同;空间 ID 全部不同。A/B 的差异是重复世界,A/C 的差异是过缩放呈现代际。
4. displayZ 不改变 canonical footprint
displayZ > canonicalZ 表示视图继续放大,但数据源没有更细一级内容。它不会把一个 canonical tile 凭空拆成四个未知子瓦片。
在本 TileKey 结构中,地理 footprint 始终由 canonicalZ/X/Y + wrap 决定;displayZ 留给:
- 过缩放比例和样式判断;
- 空间实例代际;
- 后续替换/保留策略。
真正请求更细 source tile 时,canonicalZ/X/Y 本身会改变。
5. 从 TileKey 恢复 Mercator 边界
令:
n=2canonicalZ
先恢复连续世界的 x:
unwrappedX=canonicalX+wrap⋅n
边界为:
west=nunwrappedX,east=nunwrappedX+1
north=ncanonicalY,south=ncanonicalY+1
north < south,因为 normalized Mercator y 向南增加。wrap 只进入 west/east,不进入 north/south。
6. 从 Mercator 边界进入 render-local
TransformSnapshot 给出本帧的 origin 与 worldSize。四条边分别转换:
X=(xm−originx)⋅worldSize
Z=(ym−originy)⋅worldSize
于是西北角、东北角、东南角、西南角依次为:
text
(westLocal, 0.5, northLocal)
(eastLocal, 0.5, northLocal)
(eastLocal, 0.5, southLocal)
(westLocal, 0.5, southLocal)
Y=0.5 只是让线框略高于零高程地面,避免未来增加底色后发生 z-fighting;它不是海拔。
边界转换必须使用传给 setKeys 的 snapshot,而不是在循环中读取 live camera。否则一次异步更新可能拿 TileKey A、origin B 和 matrix C,线框会在平移时抖动或错位。
7. 为什么用 THREE.LineLoop
每个 tile 只有四个顶点,LineLoop 会自动闭合最后一条边,适合教学调试:
- 顶点顺序直接对应 NW → NE → SE → SW;
- geometry 可以独立标记
tileSpatialId; - 不需要三角化或 shader;
- canonical world 与 wrapped world 可以使用不同颜色。
这不是高性能生产实现。生产网格若一次显示几百个边界,应合批到一个动态 buffer;本阶段最多绘制一个很小的邻域,保留"一 key 一对象"更利于检查。
8. world-space Sprite 标签
标签先在 2D canvas 上绘制,再作为 CanvasTexture 绑定到 THREE.Sprite:
text
13/6745/3342@0
d17 · 15/26821/13398@1
Sprite 会面向相机,但它仍是 scene 中的 world-space 对象:
- position 位于 tile 中心的 render-local 坐标;
- scale 使用 world units,而不是固定屏幕像素;
- bearing/pitch 改变时,标签跟随瓦片位置;
- 每次 origin/zoom 变化时,和边界一起由新 snapshot 重建。
"面向相机"不等于"屏幕 HUD"。若把标签做成绝对定位 DOM,线框变换与标签投影会变成两条独立路径,更难发现坐标错误。
9. 本阶段如何产生调试 keys
Stage 02 先取 floor(viewState.zoom),再把这个局部调试层级 夹到 DEBUG_TILE_PROFILE.minZoom/maxZoom。ViewState 自身仍保留连续 zoom。随后求中心点所在 XYZ,并枚举固定半径:
js
const displayZ = clamp(
Math.floor(viewState.zoom),
DEBUG_TILE_PROFILE.minZoom,
DEBUG_TILE_PROFILE.maxZoom,
);
text
dy = -3 ... +3
dx = -3 ... +3
水平 x 先保留为 unwrappedX,再拆成 canonical x + wrap;垂直 y 超出 [0,n−1] 就跳过。这样可以在反经线附近看到同一 canonical 内容进入不同 wrap。
这是 center neighborhood,不是 viewport cover。
当 viewport 很宽、pitch 很高或 zoom 较低时,7×7 邻域可能不覆盖全部可见地面;当 viewport 很小时,它又可能多画许多线框。第 3 部分会从四角射线与视口地面 polygon 计算真正的候选集合,并处理 horizon、边界相交和安全扩张。
10. cursor inspector 是坐标链路探针
pointermove 时,Stage 02 把 clientX/clientY 先减去 canvas 边界,得到 CSS screen point,再调用:
js
const hit = runtime.transform.screenPointToGround(x, y);
const lngLat = runtime.transform.unprojectScreen(x, y);
面板同时显示:
- screen
(x,y),CSS pixels; - render-local
(X,Y,Z),world units; - normalized Mercator
(x_m,y_m),unitless; - geographic
(lng,lat),degrees; - 当前 debug TileKey。
若 cursor 跨过某条线时地址没有随之变化,问题位于 Mercator → XYZ;若地址正确但线在别处,问题位于 TileKey bounds → render-local;若只有旋转/倾斜后错位,问题通常在 camera 或 screen ray。
11. 更新与释放
每次 viewchange,调试阶段重新计算小邻域并调用:
js
gridLayer.setKeys(keys, transformSnapshot);
runtime.requestRender();
setKeys 先按 spatial ID 去重并生成稳定 signature。若 signature 没变,旧 geometry 与 CanvasTexture 全部复用;layer 只根据"建立对象时的 base snapshot"和"当前 snapshot"更新 group transform:
scale=worldSizebaseworldSizenew
offset=(originbase−originnew)⋅worldSizenew
代入旧顶点 (mercator−originbase)⋅worldSizebase 后,结果正好是新局部坐标。只有跨入新中心瓦片或切换整数层级导致 spatial key 集合改变时,layer 才释放旧 geometry、label material 与 CanvasTexture,并建立新对象。共享线材质只在整个 layer dispose 时释放一次。
这仍不是瓦片缓存。group transform 复用只避免连续交互时重复绘制文字纹理,不跟踪请求、加载状态或替换依赖;真正 MVT geometry 会在后续引入保留、复用和 cache。
12. 本章固定的不变量
- TileKey 所有字段是已验证、冻结的整数;
- 内容 ID 忽略 displayZ/wrap,空间 ID 保留它们;
- wrap 只改变水平 footprint,不改变 URL 内容身份;
- tile bounds 来自 canonical 地址,不从相机或 label 反推;
- 所有 render-local 顶点使用同一个 TransformSnapshot;
- label 是附着在 tile 中心的 world-space Sprite;
- Stage 02 邻域只受离线逻辑剖面约束,不读取网络或内容状态;
- Stage 02 邻域只是诊断样本,不宣称视口覆盖完整。