MapRuntime:状态事务与按需渲染循环
ViewState 已经回答"地图想看哪里",PlanarTransform 已经回答"这一视图对应什么相机和矩阵"。还缺一层运行时把状态提交、场景更新与浏览器帧调度组织起来。
这一层叫 MapRuntime。它不是地图业务的总管,也不负责加载或解码瓦片;它只拥有一组必须保持同步的对象,并规定它们的更新顺序。

1. MapRuntime 拥有什么
最小运行时拥有:
| 所有物 | 角色 | 不应该承担的职责 |
|---|---|---|
immutable ViewState |
当前地图输入事实 | 保存相机矩阵或瓦片对象 |
PlanarTransform |
从 ViewState 派生相机与坐标转换 | 处理 DOM 事件 |
THREE.Scene |
当前可绘制对象树 | 决定哪些瓦片应该存在 |
WebGLRenderer |
把 scene + camera 写入画布 | 成为地图状态源 |
| RAF handle | 合并画面失效请求 | 永久无条件循环 |
| event listeners | 向图层发布状态代际 | 隐式修改 ViewState |
这是一条刻意收窄的边界。后续的 tile cover、加载器、替换器和缓存都会有自己的状态机;它们可以向运行时请求一帧,却不能把运行时变成一个混合所有策略的巨型类。
2. setViewState 是一次原子事务
交互层不能按顺序做以下操作:先改中心、再改 zoom、再更新相机、最后碰运气触发渲染。中间状态可能被监听者读到。
正确顺序固定为:
- 以旧 ViewState 和 patch 创建新的、完整的 ViewState;
- 比较规范化后的状态 key;没有语义变化就停止;
- 用新 ViewState 更新
PlanarTransform; - 若 viewport 变化,同步 renderer 的 DPR 和尺寸;
- 发布同时携带 ViewState 与 TransformSnapshot 的
viewchange; - 请求一帧。
事件中的两个快照必须同代:
js
{
type: "viewchange",
previousViewState,
viewState,
transformSnapshot,
}
监听者不需要再读取半更新的相机。它可以把 transformSnapshot.viewStateKey 当作代际证据,并用其中的 origin、worldSize 与矩阵更新自己的场景数据。
3. 为什么不是永久 requestAnimationFrame 循环
很多 Three.js 示例从页面打开到关闭都执行:
js
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
它适合持续运动的游戏场景,却不是静态地图的最佳默认值。地图停止交互后,大多数帧完全相同;无条件循环仍会占用 CPU/GPU、妨碍设备降频,并让"是哪次变化要求了这帧"变得模糊。
手册采用 invalidation-driven rendering:
- ViewState 改变时请求一帧;
- 网格、瓦片或标注进入/退出场景时请求一帧;
- 淡入或惯性仍在进行时,当前帧结束后再请求下一帧;
- 没有画面变化时,不保留 RAF。
因此 requestRender() 不是 render() 的别名。它是一道 one-frame gate。
4. 一帧闸门如何合并请求
运行时只有一个 frameHandle:
js
requestRender() {
if (this.disposed || this.frameHandle !== null) return false;
this.frameHandle = requestAnimationFrame((time) => {
this.frameHandle = null;
this.render(time);
});
return true;
}
同一个浏览器刷新周期内可能发生:
- pointermove 产生新的中心;
- tile grid 收到
viewchange后重建线框; - 某个异步资源完成;
- HUD 更新调试值。
它们都可以安全调用 requestRender()。第一个调用登记 RAF,后续调用看到已有 handle,只合并意图。这样不是丢掉更新,因为 scene 和相机在 RAF 执行前已经更新到最新状态;被合并的是重复 draw,不是状态。
5. 为什么回调一开始就清空 handle
RAF 回调进入后必须先把 frameHandle 置为 null,再执行 render。这样 render 事件监听者如果发现淡入未结束,可以请求下一帧:
text
frame N callback
├─ clear pending handle
├─ renderer.render(...)
└─ render listener → requestRender() → frame N+1
若在 render 之后才清空 handle,监听者会误以为仍有待执行帧,动画在第一帧后停止。
6. 交互层提交意图,不操作相机
installMapInteractions(runtime, element) 负责把浏览器输入翻译为 ViewState patch:
| 输入 | ViewState 意图 |
|---|---|
| 左键拖动 | 修改 center |
Shift + 左键 或右键拖动 |
修改 bearing 与 pitch |
| 滚轮 | 修改 zoom,尽量保持光标下地理点不动 |
| ResizeObserver | 修改 viewport 与 DPR |
交互代码只能调用 runtime.setViewState(...)。它不允许访问 camera.position、camera.quaternion 或 projectionMatrix 进行写操作。
这条规则阻止双重状态源。例如右键拖动若直接旋转相机,ViewState 的 bearing 仍是旧值;下一次 resize 由 ViewState 重建相机时,刚才的旋转会突然消失。
7. 平移为什么使用两条地面射线
普通屏幕拖动不是简单的"经度加 dx"。在不同 zoom、pitch 和 bearing 下,一个 CSS 像素对应不同地面距离和方向。
每次 pointermove 都在当前 transform 上求两个地面交点:
- 上一个指针位置对应 P0;
- 当前指针位置对应 P1。
若当前地图中心的 normalized Mercator 坐标为 C,新中心是:
C′=C+P0−P1
这相当于抓住 P0 所在的地面并把它拖到当前指针位置。只要两条射线都命中地面,这个方法天然尊重 bearing 与 pitch;任一射线看到天空时,本次平移不提交猜测值。
8. 光标锚定缩放的推导
滚轮缩放前,先取得光标地面命中点 A 及其 render-local 坐标 L。旧中心为 C,有:
A=C+worldSizeoldL
zoom 变化后,相机在 CSS 像素空间的构型不变,同一屏幕点对应的 render-local 向量仍为 L。为了让 A 留在光标下,新中心应满足:
C′=A−worldSizenewL
于是 center 与 zoom 可以在一个 ViewState patch 中提交,而不需要先缩放、再补一次中心修正。这也避免一个滚轮事件发出两次 viewchange。
若光标射线没有命中地面,运行时退化为以地图中心缩放;它不会从天空射线制造无限远坐标。
9. resize 的两个尺度
ResizeObserver 返回 CSS pixels。renderer 同时需要 DPR 来决定 drawing buffer:
deviceWidth=round(cssWidth⋅devicePixelRatio)
PlanarTransform 的 aspect 和相机距离使用 CSS width/height;renderer.setPixelRatio 与 drawing buffer 使用 DPR。混用会让高 DPI 屏幕上的地理尺度和普通屏幕不同。
演示默认把 DPR 上限设为 2,这是教学页面的性能护栏,不是坐标数学的一部分。生产引擎可以根据设备能力和内存预算另定策略。
10. 三种事件的精确语义
viewchange
在 ViewState 与 transform 都完成更新后同步发出。适合更新 tile grid、cursor 转换上下文和后续 cover。
render
在 renderer 完成 draw 调用后发出,携带 RAF 的时间戳。适合判断动画是否还要下一帧,不适合修改本帧已经绘制的内容。
dispose
只发出一次。交互模块在这里断开 observer、DOM listener 和 pointer capture;随后 renderer 释放 GPU 资源,运行时清空监听器。
11. "恰好释放一次"是生命周期合同
页面隐藏、组件卸载和应用销毁可能在很短时间内重复触发。dispose() 必须幂等:
- 第一次返回
true,取消待执行 RAF 并释放所有权; - 后续调用返回
false,不重复 dispose renderer; - 销毁后的
setViewState抛出明确错误; - 销毁后的
requestRender不再排队。
重复调用 WebGLRenderer.dispose() 虽未必立即崩溃,但重复释放更复杂的纹理、Worker 和缓存会产生真实竞态。把幂等规则从最小运行时开始固定,后续生命周期才能组合。
12. 本章固定的运行时不变量
- ViewState 是唯一地图状态权威;
- TransformSnapshot 总与事件中的 ViewState 同代;
- 状态先更新,
viewchange后发出,render 最后发生; - 任意数量的失效请求最多保留一个待执行 RAF;
- 交互只提交状态意图,不直接修改相机;
- screen input 一律是 element-local CSS pixels;
- dispose 取消待执行帧,并且所有资源恰好释放一次。
下一教程会把这些不变量落实成两个独立模块。再下一章才把瓦片身份和线框加入 scene。