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 的反向权威。

相关推荐
名字还没想好☜1 小时前
React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
前端·javascript·react.js·ecmascript·react·next.js
自动化监测Learner2 小时前
主流 Web 端地图引擎对比:选型指南与优劣分析
javascript
宿6746 小时前
vue3-config
前端·javascript·vue.js
ShineWinsu9 小时前
对于 Vue 3:从为什么学 Vue,到声明式渲染与数据响应式的解析
前端·javascript·vue.js
leoZ23110 小时前
第 6 篇:SchemaForm 渲染器核心实现
前端·javascript·vue.js·人工智能·神经网络·机器学习·自然语言处理
CoderYanger11 小时前
前端基础——JavaScript(基础语法)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
还是大剑师兰特11 小时前
vue项目浏览器版本判别,低于IE11跳转到新页面
前端·javascript·vue.js
想吃火锅100511 小时前
【leetcode】42.接雨水js
开发语言·javascript·ecmascript
雪芽蓝域zzs12 小时前
第十六节:递归组件实现无限层级折叠侧边栏菜单
开发语言·javascript·ecmascript
BillKu14 小时前
vue3 字符串排序 localeCompare,确保排序为 “B01“, “B10“, “B2“
前端·javascript·vue.js