three.js最小地图运行时(八): 瓦片调试网格

瓦片调试网格:让身份、位置与坐标转换同时可见

到目前为止,地图可以平移、缩放、旋转和倾斜,但场景还是空的。直接进入 MVT 下载与解码会把网络、Protobuf、样式、几何和相机问题叠在一起。更好的下一步是只画瓦片边界。

TileGridLayer 是一件诊断仪器:它不加载任何 PBF,只把一组明确给定的 TileKey 变成 world-space 线框与标签。若线框能稳定贴在地面、跨 wrap 连续、地址与 cursor 转换一致,就说明运行时的坐标主干已经可用。

1. 调试网格要证明什么

它同时检验五件事:

  1. TileKey 是否把内容身份和空间实例分开;
  2. canonical XYZ 边界能否恢复为正确的 Mercator 范围;
  3. wrap 是否只改变水平位置,而不制造新内容身份;
  4. Mercator 边界能否用当前 snapshot 转成 render-local 坐标;
  5. 线框、标签、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 创建后冻结。不要在对象上后来补 loadedvisibleopacity;身份值与生命周期状态属于不同领域。

3. 两种 ID 解决两种问题

内容 ID

js 复制代码
getTileId(key) === "canonicalZ/canonicalX/canonicalY"

它忽略 displayZwrap。同一份源内容在更高显示 zoom 或相邻重复世界出现时,仍应共享请求、解码结果和缓存条目。

空间 ID

js 复制代码
getTileSpatialId(key)
// "displayZ/canonicalZ/canonicalX/canonicalY@wrap"

它保留呈现所需字段。场景实例、可见集合与替换判断不能只用内容 ID,否则同一瓦片在 wrap=0wrap=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=2canonicalZn=2^{canonicalZ} n=2canonicalZ

先恢复连续世界的 x:
unwrappedX=canonicalX+wrap⋅nunwrappedX=canonicalX+wrap\cdot n unwrappedX=canonicalX+wrap⋅n

边界为:
west= unwrappedXn ,east= unwrappedX+1n west=\frac{unwrappedX}{n},\qquad east=\frac{unwrappedX+1}{n} west=nunwrappedX,east=nunwrappedX+1
north= canonicalYn ,south= canonicalY+1n north=\frac{canonicalY}{n},\qquad south=\frac{canonicalY+1}{n} north=ncanonicalY,south=ncanonicalY+1

north < south,因为 normalized Mercator y 向南增加。wrap 只进入 west/east,不进入 north/south。

6. 从 Mercator 边界进入 render-local

TransformSnapshot 给出本帧的 originworldSize。四条边分别转换:
X=(xm−originx)⋅worldSize X=(x_m-origin_x)\cdot worldSize X=(xm−originx)⋅worldSize
Z=(ym−originy)⋅worldSize Z=(y_m-origin_y)\cdot 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);

面板同时显示:

  1. screen (x,y),CSS pixels;
  2. render-local (X,Y,Z),world units;
  3. normalized Mercator (x_m,y_m),unitless;
  4. geographic (lng,lat),degrees;
  5. 当前 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= worldSiz enew worldSiz ebase scale=\frac{worldSize_{new}}{worldSize_{base}} scale=worldSizebaseworldSizenew
offset=(origi nbase −origi nnew )⋅worldSiz enew offset=(origin_{base}-origin_{new})\cdot worldSize_{new} offset=(originbase−originnew)⋅worldSizenew

代入旧顶点 (mercator−origi nbase )⋅worldSiz ebase (mercator-origin_{base})\cdot worldSize_{base} (mercator−originbase)⋅worldSizebase 后,结果正好是新局部坐标。只有跨入新中心瓦片或切换整数层级导致 spatial key 集合改变时,layer 才释放旧 geometry、label material 与 CanvasTexture,并建立新对象。共享线材质只在整个 layer dispose 时释放一次。

这仍不是瓦片缓存。group transform 复用只避免连续交互时重复绘制文字纹理,不跟踪请求、加载状态或替换依赖;真正 MVT geometry 会在后续引入保留、复用和 cache。

12. 本章固定的不变量

  1. TileKey 所有字段是已验证、冻结的整数;
  2. 内容 ID 忽略 displayZ/wrap,空间 ID 保留它们;
  3. wrap 只改变水平 footprint,不改变 URL 内容身份;
  4. tile bounds 来自 canonical 地址,不从相机或 label 反推;
  5. 所有 render-local 顶点使用同一个 TransformSnapshot;
  6. label 是附着在 tile 中心的 world-space Sprite;
  7. Stage 02 邻域只受离线逻辑剖面约束,不读取网络或内容状态;
  8. Stage 02 邻域只是诊断样本,不宣称视口覆盖完整。

相关推荐
circuitsosk1 小时前
智能体任务拆解与执行:基于ReAct+Plan-and-Execute框架的行业Skill构建实录
前端·javascript·python·react.js·react·llm agent·智能体编排
daols881 小时前
vue 表格vxe-table 实现紧凑型表格的方式
前端·javascript·vue.js
Java搬码工2 小时前
VUE3使用教程
前端·javascript·vue.js
小白勇闯网安圈2 小时前
Vue 3 核心进阶:组合式 API、响应式系统与 <script setup>
前端·javascript·vue.js
雪芽蓝域zzs2 小时前
Vue3 + JS + pnpm 创建项目
开发语言·javascript·ecmascript
蜡台2 小时前
Uniapp 开发H5 页面跳转拦截
前端·javascript·uni-app
IT爱学堂2 小时前
Three.js可视化企业实战WEBGL课,2023年全新WEB 3D THREEJS技术
前端·javascript·webgl
rockey62711 小时前
C#脚本引擎之AScript与Jurassic、Jint对比
javascript·c#·.net·js·script·动态脚本
lzhdim12 小时前
12、JavaScript常见的内存泄露问题 - JavaScript学习系列文章
开发语言·前端·javascript·学习·ecmascript