three.js最小地图运行时(一):视图状态

ViewState:地图运行时唯一的状态权威

地图相机看起来只是一个 Three.js PerspectiveCamera,但相机对象不适合作为地图状态。它保存矩阵、四元数、父子关系和内部脏标记;交互、resize 或动画如果各自直接修改它,运行时就无法回答"这一帧究竟对应哪一个地图视图"。

本手册把地图输入集中为不可变 ViewState。相机、坐标转换、调试网格和后续的视口 cover 都只消费同一个快照,不从渲染结果反向猜测状态。

1. ViewState 描述什么

规范的结构定义为:

js 复制代码
{
  center: [longitude, latitude],
  zoom,
  bearing,
  pitch,
  viewport: {
    width,
    height,
    devicePixelRatio,
    deviceWidth,
    deviceHeight,
  },
}

每个字段都必须写清单位:

字段 单位 允许范围 含义
center[0] degrees 任意有限值 中心经度;允许超出 ±180° 以保留连续世界
center[1] degrees 截断到 ±85.0511287798066° 中心纬度
zoom zoom level 任意有限值 连续缩放,不等于某个 source tile 层级
bearing degrees 归一化到 [−180,180) 绕世界上轴的地图朝向
pitch degrees 截断到 [0,85] 为正俯视,增大后看向地平线
viewport.width/height CSS pixels >0 页面布局中的视口大小
devicePixelRatio device px / CSS px >0 渲染缓冲区的像素倍率
deviceWidth/deviceHeight device pixels 正整数 CSS 大小乘 DPR 后 Math.round 计算得到

center 是地理输入,不是 normalized Mercator;viewport 是屏幕输入,不是相机视锥。它们会由下一章的 PlanarTransform 转换成渲染事实。

2. 为什么 zoom 可以是小数

XYZ 内容层级是整数,但地图相机应连续缩放。定义一整个 Mercator 世界的渲染宽度:
worldSize=tileSize⋅2zoomworldSize = tileSize \cdot 2^{zoom} worldSize=tileSize⋅2zoom

zoom12 变化到 12.5
worldSize(12.5)worldSize(12) =20.5=2 \frac{worldSize(12.5)}{worldSize(12)} = 2^{0.5} = \sqrt{2} worldSize(12)worldSize(12.5)=20.5=2

画面尺度连续变化,不需要在整数边界跳跃。内容选择稍后会将连续 display zoom 映射到整数 tile 层级,并明确处理 overzoom;不能把相机 zoom 直接当作 source identity。

3. bearing 为什么归一化

10°370°−350° 表示同一朝向。如果状态 key 保留原输入,等价视图会触发重复相机更新、cover 和请求。

归一化采用欧几里得模运算,将角度映射到半开区间 [−180, 180)(此时 180° 规范化写为 −180°):
bnormalized =((b+180)  mod  360+360)  mod  360−180 b_{normalized} = ((b + 180) \bmod 360 + 360) \bmod 360 - 180 bnormalized=((b+180)mod360+360)mod360−180

JavaScript 实现陷阱 :JavaScript 中的 % 是取余(Remainder)而不是取模(Modulo)。直接写 (b + 180) % 360 - 180 在处理负数(如 -350°)时会得到错误结果 -350°。代码中需引入正数偏移修正:

js 复制代码
const normalizeBearing = (b) => ((b + 180) % 360 + 360) % 360 - 180;

4. pitch 为什么不是普通欧拉角

本手册的 pitch 使用地图语义:

  • :相机正对地面;
  • 85°:接近地平线,但仍保留数值和交互余量。

它不是直接赋给 camera.rotation.x 的欧拉角。PlanarTransform 会把地图 pitch、bearing 和中心组合成相机位置与四元数。

上限 85° 是教学运行时的明确策略,不是 Web Mercator 纬度上限。两个 85 来自不同问题:一个限制相机视线,一个限制投影定义域,不能共用同一个常量。

5. CSS 像素与设备像素必须同时存在

指针事件的 clientX/clientY 使用 CSS pixels;WebGL drawing buffer 通常使用 device pixels。若:

text 复制代码
width  = 800 CSS px
height = 600 CSS px
DPR    = 2

设备像素遵循 Math.round 策略四舍五入计算:

text 复制代码
deviceWidth  = Math.round(800 * 2) = 1600 device px
deviceHeight = Math.round(600 * 2) = 1200 device px

相机 aspect 使用 CSS 或 device 尺寸的比值都相同,但以下工作不能混用:

  • DOM 指针到 NDC:使用 CSS width/height;
  • renderer.setPixelRatio:使用 DPR;
  • drawing buffer 预算:使用 deviceWidth/deviceHeight;
  • 每像素显存和填充率统计:使用设备像素。

把五个字段存入同一不可变 viewport 快照,可以证明一次 resize 对交互、相机和渲染缓冲使用了同一版本。

6. 不可变不是语法偏好

如果交互处理器直接执行:

js 复制代码
runtime.viewState.center[0] += delta;

其他模块持有的"旧快照"也被修改。此时:

  • 旧 key 不再描述旧内容;
  • viewchange 事件的 previous/current 可能相同;
  • Worker 请求无法判断自己基于哪一版视图;
  • 一帧中先运行的模块与后运行的模块看到不同事实。

因此 createViewState 冻结:

  1. center 元组;
  2. viewport 对象;
  3. 顶层 ViewState。

更新必须创建新对象:

js 复制代码
next = updateViewState(previous, patch);

这不是为了阻止所有错误,而是让越权写入尽早暴露,并让引用相等可表示"是否还是同一快照"。

7. Patch 是一次事务

updateViewState(previous, patch) 的执行必须遵循严格的更新管道(Sanitization Pipeline)

  1. 合并 Patch :浅层合并顶层属性与 viewport 属性;
  2. 规范化与截断 :将 center[1] 截断至 ±85.0511287798066°pitch 截断至 [0, 85]bearing 执行半开区间模运算;
  3. 强制计算派生字段 :只要 widthheightdevicePixelRatio 任意一项发生变更,必须强制重新计算 deviceWidth/deviceHeight = Math.round(size * DPR),不允许直接信任外部传入的不匹配像素值;
  4. 递归冻结 :对最终生成的对象执行 Object.freeze
js 复制代码
const next = updateViewState(previous, {
  center: [121.48, 31.24],
  zoom: 13.5,
});

中心与 zoom 同时提交。运行时不应先改中心、派生一次相机,再改 zoom、派生第二次相机。一个用户意图对应一个新 ViewState 和一次 viewchange

8. 稳定 key 的用途

getViewStateKey 按固定顺序序列化规范字段:

text 复制代码
lng, lat, zoom, bearing, pitch,
CSS width, CSS height, DPR, device width, device height

为了规避 JavaScript 浮点数微小抖动(例如 12.500000000000002)导致无意义的缓存失效和重复渲染, Key 序列化时应对浮点数实施固定精度截断(例如 lng/lat 保留 6 位,zoom/bearing/pitch 保留 4 位小数)。

key 用于:

  • 判断 transform 快照是否仍对应当前视图;
  • 给异步 cover 或调试输出标记输入版本;
  • 合并等价更新;
  • 记录可复现的运行时状态。

它不是公开 URL 格式,也不承诺跨版本永久兼容。不要把它用作瓦片缓存 key;瓦片身份由独立 TileKey 定义。

9. 单向权威链

第二篇的状态流为:

text 复制代码
DOM intent / API patch / resize
  → updateViewState(previous, patch)
  → new frozen ViewState
  → PlanarTransform.update(viewState)
  → camera + TransformSnapshot
  → render / debug grid / conversions

禁止的反向路径:

text 复制代码
camera.position / quaternion
  → 猜 center, zoom, bearing, pitch
  → 回写 ViewState

相机可以作为派生对象暴露给 renderer,但不能成为主状态。否则一次浮点矩阵分解就可能改变原本稳定的地图输入。

10. 本章契约

ViewState 必须满足:

  1. 所有数字有限;viewport 尺寸与 DPR 为正;
  2. 纬度、bearing、pitch 在创建与 patch 事务时强制清洗和规范化;
  3. 经度和 zoom 不因内容源范围被偷偷夹取;
  4. deviceWidth/deviceHeight 始终由 Math.round(size * DPR) 派生,不可手动篡改;
  5. center、viewport 和顶层对象被递归冻结;
  6. key 由精度裁剪后的规范字段生成,不受浮点抖动干扰;
  7. 相机永远是消费者,不是 ViewState 的反向权威。

相关推荐
Orange_sparkle1 小时前
从五个 TypeScript 文件看懂 Coding Agent:一次 nano-pi 学习复盘
javascript·学习·typescript
摸鱼研究员1 小时前
neverthrow,ts 中优雅的异常处理方案
前端·javascript
烬羽1 小时前
求第 K 大,为什么反而要用最小堆?
javascript·数据结构·算法
jingchao19982 小时前
Cannot read properties of null (reading ‘insertBefore‘)
前端·javascript·vue.js
梦醒沉醉2 小时前
2、JavaScript控制流和错误处理
javascript
晓得迷路了2 小时前
栗子前端技术周刊第 142 期 - DeepSeek Harness、pnpm 12 RC、crypto‑js...
前端·javascript·ai编程
Fluxart.ai2 小时前
Etsy手工制品换背景,用什么AI能保留手作质感?
前端·javascript·人工智能
小玮看世界3 小时前
[Python] str() 和 join() 的区别与实战避坑指南
前端·javascript·python
谢慧琼15 小时前
2026零基础做企业网站,低成本搭建官方网站
服务器·前端·javascript