three.js最小地图运行时(三):地图相机

0 阅读4分钟

从 ViewState 推导平面地图相机

ViewState 只记录用户和布局能够直接表达的地图输入;Three.js 相机需要位置、四元数、视锥和矩阵。PlanarTransform.update(viewState) 的职责,是把前者一次性派生为后者,并发布一个与输入 key 对应的不可变 TransformSnapshot

屏幕截图 2026-08-20 220759.png

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.positioncamera.quaternion 分解后回写 ViewState。

原因不只是整洁:矩阵分解有浮点误差和多组等价欧拉角。一旦相机成为反向权威,相同用户输入可能在每帧产生略有不同的新状态。

2. zoom 如何变成 worldSize

沿用第一篇定义:

worldSize=tileSize2zoomworldSize=tileSize\cdot2^{zoom}

tileSize=512zoom=13 时:

worldSize=5128192=4194304worldSize=512\cdot8192=4\,194\,304

Mercator 中相差 Δx 的两点,在 render-local X 上相差:

ΔX=ΔxworldSize\Delta X=\Delta x\cdot worldSize

zoom 增大一,worldSize 加倍,同一地理差值在场景中也加倍。相机到中心的 render-local 距离不需要跟随 zoom 改变,因此屏幕上的地图尺度自然翻倍。

3. 为什么相机距离由 viewport height 决定

正俯视时,希望地面中心附近“一 render unit 对应一 CSS pixel”。垂直 FOV 为 θ,相机到中心距离为 d,目标平面的可见高度:

Hground=2dtanθ2H_{ground}=2d\tan\frac{\theta}{2}

令它等于视口 CSS 高度 H

d=H2tan(θ/2)\boxed{d=\frac{H}{2\tan(\theta/2)}}

例如 H=700pxFOV=45°

d844.97  render unitsd\approx844.97\;render\ units

这里使用 CSS height,而不是 device height。DPR 只提高 drawing buffer 采样密度,不应改变同一 CSS 视口看到的地图范围。

倾斜后,相机仍保持到中心点的欧氏距离 d,只是把一部分垂直距离转为水平距离。中心处投影尺度因此连续。

4. bearing 定义屏幕上方的地理方向

教学世界约定:

  • +X 向东;
  • +Y 向上;
  • +Z 向南,所以北方为 −Z

令 bearing 为 β。屏幕上方在地面上的单位方向写为:

nscreen=(sinβ,  0,  cosβ)n_{screen}=\left(\sin\beta,\;0,\;-\cos\beta\right)

检查:

  • β=0°n=(0,0,−1),屏幕上方为北;
  • β=90°n=(1,0,0),屏幕上方为东;
  • β=−90°:屏幕上方为西。

相机要从屏幕下方一侧看向中心,所以水平位置在 −n_screen 方向。

5. pitch 把相机从天顶移向地平线

地图 pitch 为 p

h=dsinph=d\sin p
v=dcospv=d\cos p

其中 h 是水平偏移,v 是相机高度。以 render-local 中心为原点,相机位置:

c=hnscreen+(0,v,0)\boxed{c=-h\,n_{screen}+(0,v,0)}

p=0°h=0v=d,相机位于中心正上方;当 pitch 增大,相机沿屏幕下方后退并降低。

代码将 camera.up 设为 n_screen,再调用 lookAt(0,0,0)。Three.js 会把它正交化成最终屏幕上方向,避免手写欧拉角顺序。

6. 为什么 local origin 选择视图中心

ViewState center 投影为 normalized Mercator:

o=(xc,yc)o=(x_c,y_c)

本帧把它作为 render-local origin。于是中心点位置严格为:

(X,Y,Z)=(0,0,0)(X,Y,Z)=(0,0,0)

任意 Mercator 点:

X=(xox)worldSizeX=(x-o_x)\cdot worldSize
Z=(yoy)worldSizeZ=(y-o_y)\cdot worldSize

中心改变时 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 中心。

如果这个不变量失败,优先检查:

  1. origin 是否等于中心 Mercator;
  2. 相机是否确实 lookAt(0,0,0)
  3. worldSize 是否只作用于地理差值;
  4. bearing 旋转的是相机轨道而不是中心点;
  5. projection aspect 是否来自同一 viewport。

这条检查比“画面大致像地图”更精确,也会成为 Stage 02 HUD 的固定诊断项。

10. 本章边界

当前相机假定:

  • 地面是 Y=0 平面;
  • 高度暂以米直接进入 render-local Y;
  • 没有 terrain elevation;
  • 没有 globe 曲率和地平线遮挡;
  • 没有相机惯性或动画插值;
  • pitch 上限由 ViewState 保证为 85°

下一章将使用同一相机做正向投影和屏幕射线反投影,并规定看向天空时必须返回 null