从 ViewState 推导平面地图相机
ViewState 只记录用户和布局能够直接表达的地图输入;Three.js 相机需要位置、四元数、视锥和矩阵。PlanarTransform.update(viewState) 的职责,是把前者一次性派生为后者,并发布一个与输入 key 对应的不可变 TransformSnapshot。
1. 一条不可逆向的派生链
ViewState
├─ center + zoom → Mercator origin + worldSize
├─ viewport + fov → camera-to-center distance + aspect
├─ bearing + pitch → camera position + quaternion
└─ near/far → projection matrix
↓
Three.js PerspectiveCamera + frozen TransformSnapshot
相机只在 update(viewState) 内被修改。renderer 可以读取它,调试工具可以投影点,但任何模块都不能把 camera.position 或 camera.quaternion 分解后回写 ViewState。
原因不只是整洁:矩阵分解有浮点误差和多组等价欧拉角。一旦相机成为反向权威,相同用户输入可能在每帧产生略有不同的新状态。
2. zoom 如何变成 worldSize
沿用第一篇定义:
tileSize=512、zoom=13 时:
Mercator 中相差 Δx 的两点,在 render-local X 上相差:
zoom 增大一,worldSize 加倍,同一地理差值在场景中也加倍。相机到中心的 render-local 距离不需要跟随 zoom 改变,因此屏幕上的地图尺度自然翻倍。
3. 为什么相机距离由 viewport height 决定
正俯视时,希望地面中心附近“一 render unit 对应一 CSS pixel”。垂直 FOV 为 θ,相机到中心距离为 d,目标平面的可见高度:
令它等于视口 CSS 高度 H:
例如 H=700px、FOV=45°:
这里使用 CSS height,而不是 device height。DPR 只提高 drawing buffer 采样密度,不应改变同一 CSS 视口看到的地图范围。
倾斜后,相机仍保持到中心点的欧氏距离 d,只是把一部分垂直距离转为水平距离。中心处投影尺度因此连续。
4. bearing 定义屏幕上方的地理方向
教学世界约定:
+X向东;+Y向上;+Z向南,所以北方为−Z。
令 bearing 为 β。屏幕上方在地面上的单位方向写为:
检查:
β=0°:n=(0,0,−1),屏幕上方为北;β=90°:n=(1,0,0),屏幕上方为东;β=−90°:屏幕上方为西。
相机要从屏幕下方一侧看向中心,所以水平位置在 −n_screen 方向。
5. pitch 把相机从天顶移向地平线
地图 pitch 为 p:
其中 h 是水平偏移,v 是相机高度。以 render-local 中心为原点,相机位置:
当 p=0°,h=0、v=d,相机位于中心正上方;当 pitch 增大,相机沿屏幕下方后退并降低。
代码将 camera.up 设为 n_screen,再调用 lookAt(0,0,0)。Three.js 会把它正交化成最终屏幕上方向,避免手写欧拉角顺序。
6. 为什么 local origin 选择视图中心
ViewState center 投影为 normalized Mercator:
本帧把它作为 render-local origin。于是中心点位置严格为:
任意 Mercator 点:
中心改变时 origin 会改变,但绝对 Mercator 身份不变。后续瓦片层用新 snapshot 重算实例变换,不需要改写 source content。
第二篇直接在每次 ViewState 事务后 rebase。更复杂的稳定 origin generation 和减少 rebasing 策略留到 GPU 生命周期章节。
7. near、far 与深度精度
PlanarTransform 构造时接收固定的 fov/near/far,每次 update 都把它们重新应用到相机。这样外部对相机的临时修改不会成为下一帧的隐藏输入。
near必须大于 0;far必须大于near;- 两者单位都是 render units;
- far 不是瓦片加载半径;
- 极大的 far/near 比值会降低深度精度。
教学网格位于零高度平面,没有厚度竞争。建筑和多层几何出现后,会重新审视深度预算。
8. TransformSnapshot 为什么保存矩阵数组
Three.js Matrix4 是可变对象。即使冻结外层 snapshot,调用者仍可修改 matrix.elements。因此快照发布的是冻结的 16 数字数组:
{
viewStateKey,
tileSize,
worldSize,
origin,
fov,
near,
far,
aspect,
cameraToCenterDistance,
cameraPosition,
projectionMatrix,
viewMatrix,
viewProjectionMatrix,
inverseViewProjectionMatrix,
}
renderer 仍使用 transform 私有维护的 PerspectiveCamera;Worker、调试器和异步任务消费 snapshot。它们可以比较 viewStateKey,但不能修改主相机。
9. 中心点是首要不变量
无论 bearing、pitch、viewport 或 zoom 如何变化,ViewState center 都必须投影到 NDC (0,0),也就是 viewport 中心。
如果这个不变量失败,优先检查:
- origin 是否等于中心 Mercator;
- 相机是否确实
lookAt(0,0,0); - worldSize 是否只作用于地理差值;
- bearing 旋转的是相机轨道而不是中心点;
- projection aspect 是否来自同一 viewport。
这条检查比“画面大致像地图”更精确,也会成为 Stage 02 HUD 的固定诊断项。
10. 本章边界
当前相机假定:
- 地面是
Y=0平面; - 高度暂以米直接进入 render-local Y;
- 没有 terrain elevation;
- 没有 globe 曲率和地平线遮挡;
- 没有相机惯性或动画插值;
- pitch 上限由 ViewState 保证为
85°。
下一章将使用同一相机做正向投影和屏幕射线反投影,并规定看向天空时必须返回 null。