three.js最小地图运行时(六): 地图运行时

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、再更新相机、最后碰运气触发渲染。中间状态可能被监听者读到。

正确顺序固定为:

  1. 以旧 ViewState 和 patch 创建新的、完整的 ViewState;
  2. 比较规范化后的状态 key;没有语义变化就停止;
  3. 用新 ViewState 更新 PlanarTransform
  4. 若 viewport 变化,同步 renderer 的 DPR 和尺寸;
  5. 发布同时携带 ViewState 与 TransformSnapshot 的 viewchange
  6. 请求一帧。

事件中的两个快照必须同代:

js 复制代码
{
  type: "viewchange",
  previousViewState,
  viewState,
  transformSnapshot,
}

监听者不需要再读取半更新的相机。它可以把 transformSnapshot.viewStateKey 当作代际证据,并用其中的 originworldSize 与矩阵更新自己的场景数据。

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 + 左键 或右键拖动 修改 bearingpitch
滚轮 修改 zoom,尽量保持光标下地理点不动
ResizeObserver 修改 viewport 与 DPR

交互代码只能调用 runtime.setViewState(...)。它不允许访问 camera.positioncamera.quaternionprojectionMatrix 进行写操作。

这条规则阻止双重状态源。例如右键拖动若直接旋转相机,ViewState 的 bearing 仍是旧值;下一次 resize 由 ViewState 重建相机时,刚才的旋转会突然消失。

7. 平移为什么使用两条地面射线

普通屏幕拖动不是简单的"经度加 dx"。在不同 zoom、pitch 和 bearing 下,一个 CSS 像素对应不同地面距离和方向。

每次 pointermove 都在当前 transform 上求两个地面交点:

  • 上一个指针位置对应 P0 P_0 P0;
  • 当前指针位置对应 P1 P_1 P1。

若当前地图中心的 normalized Mercator 坐标为 CC C,新中心是:
C′=C+P0−P1 C' = C + P_0 - P_1 C′=C+P0−P1

这相当于抓住 P0 P_0 P0 所在的地面并把它拖到当前指针位置。只要两条射线都命中地面,这个方法天然尊重 bearing 与 pitch;任一射线看到天空时,本次平移不提交猜测值。

8. 光标锚定缩放的推导

滚轮缩放前,先取得光标地面命中点 AA A 及其 render-local 坐标 LL L。旧中心为 CC C,有:
A=C+ LworldSiz eold A = C + \frac{L}{worldSize_{old}} A=C+worldSizeoldL

zoom 变化后,相机在 CSS 像素空间的构型不变,同一屏幕点对应的 render-local 向量仍为 LL L。为了让 AA A 留在光标下,新中心应满足:
C′=A− LworldSiz enew C' = A - \frac{L}{worldSize_{new}} C′=A−worldSizenewL

于是 center 与 zoom 可以在一个 ViewState patch 中提交,而不需要先缩放、再补一次中心修正。这也避免一个滚轮事件发出两次 viewchange

若光标射线没有命中地面,运行时退化为以地图中心缩放;它不会从天空射线制造无限远坐标。

9. resize 的两个尺度

ResizeObserver 返回 CSS pixels。renderer 同时需要 DPR 来决定 drawing buffer:
deviceWidth=round(cssWidth⋅devicePixelRatio)deviceWidth = round(cssWidth \cdot devicePixelRatio) 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. 本章固定的运行时不变量

  1. ViewState 是唯一地图状态权威;
  2. TransformSnapshot 总与事件中的 ViewState 同代;
  3. 状态先更新,viewchange 后发出,render 最后发生;
  4. 任意数量的失效请求最多保留一个待执行 RAF;
  5. 交互只提交状态意图,不直接修改相机;
  6. screen input 一律是 element-local CSS pixels;
  7. dispose 取消待执行帧,并且所有资源恰好释放一次。

下一教程会把这些不变量落实成两个独立模块。再下一章才把瓦片身份和线框加入 scene。


相关推荐
小白勇闯网安圈1 小时前
Vue 工程化开发:组件复用、插槽、项目结构与 ES6 模块化
javascript·vue.js·es6
这是个栗子2 小时前
前端开发中的常用工具函数(十):JavaScript 数组遍历
javascript·map·遍历·filter·foreach·for·for...of
FYKJ_20102 小时前
django学习成绩预警系统10905
java·javascript·spring boot·python·spark·django·php
云浪2 小时前
给大模型装上"手":三分钟看懂 AI Agent 的 Tool 工具调用
javascript·人工智能·node.js
风月说与山鬼2 小时前
六、React事件
前端·javascript·react.js
岁岁种桃花儿2 小时前
Vue核心语法第十四篇:列表过滤
前端·javascript·vue.js
晓得迷路了2 小时前
栗子前端技术周刊第 143 期 - npm 攻击事件、Bun 1.4...
前端·javascript
常宇佳2 小时前
vue3 实战系列之踩过的一些坑
javascript·vue.js·elementui
研☆香16 小时前
聊一聊前端的常见字 关键字
开发语言·前端·javascript