three.js地图数学基础(七):地图相机

213 阅读2分钟

地图相机:从视锥到地面 footprint

相机不仅决定“画面看起来怎样”,还决定当前应该选择哪些瓦片。视口瓦片技术的起点不是一个粗略中心点半径,而是:屏幕上的视锥经过逆投影后,在地图平面上覆盖了什么区域。

本章先建立透视相机的数学。真正的 cover 算法会在第三部分使用同一套射线与地面求交。

1. Three.js 地图世界约定

本书渲染空间是右手系:

  • +X 向东;
  • +Y 向上(高度);
  • +Z 向南;
  • 地图位于 Y=0Y=0 的 X-ZX\text{-}Z 平面;
  • Three.js 相机自身默认朝局部 −Z-Z 观察。

相机放在地图上方后,用 camera.lookAt(target) 令局部 −Z-Z 指向地图目标点。

关于 Bearing 与旋转方向的约定 地理学中的 Bearing(航向角) 定义为以正北为 0∘0^\circ,顺时针旋转(例如向东为 90∘90^\circ)。而在本书的右手坐标系中,+Y+Y 轴垂直向上,根据右手定则,绕 +Y+Y 轴的旋转正方向是从 +Z+Z(南)转向 +X+X(东),即俯视视角下的逆时针方向。 因此,地理语义下的顺时针 Bearing,在右手系绕 +Y+Y 轴变换矩阵中对应负旋转角(即 Ry(−bearing)R_y(-\text{bearing}))。明确这一点是避免 ViewState 转换为相机四元数时将东西方向调反的关键。

2. 透视投影视锥

透视相机由四个量定义:

  • 垂直视场角 fovyfov_y;
  • 宽高比 a=W/Ha = W / H;
  • 近裁剪面 near>0near > 0;
  • 远裁剪面 far>nearfar > near。

在距离相机 dd 的平面上,可见高度为:

h(d)=2dtan⁡fovy2h(d) = 2d \tan\frac{fov_y}{2}

宽度为:

w(d)=a h(d)w(d) = a \, h(d)

视场角越大,同一距离看到的地面越多,但边缘透视变形也越强。画布横向变宽时只需更新 aspectaspect,不应修改垂直 FOV 来“凑画面”。

3. OpenGL/WebGL 投影矩阵

令:

f=1tan⁡(fovy/2)f = \frac{1}{\tan(fov_y/2)}

在相机朝 −Z-Z 看的右手系中,一个常见的 WebGL 透视矩阵为:

P=[f/a0000f0000far+nearnear−far2⋅far⋅nearnear−far00−10]P = \begin{bmatrix} f/a & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \frac{far+near}{near-far} & \frac{2 \cdot far \cdot near}{near-far} \\ 0 & 0 & -1 & 0 \end{bmatrix}

对相机空间点 (xv,yv,zv,1)(x_v, y_v, z_v, 1):

wc=−zvw_c = -z_v

相机前方的点具有 zv<0z_v < 0,所以 wc>0w_c > 0。除以 wcw_c 后,横纵坐标都按距离缩小。

近裁剪面映射到 NDC z 为 −1-1,远裁剪面映射到 +1+1。深度不是线性的:精度更集中在 near 附近。把 near 设得极小、far 设得极大,会浪费深度缓冲精度并造成 z-fighting。

说明与扩展:此处的投影矩阵适用传统的 WebGL 规范(NDC z∈[−1,1]z \in [-1, 1])。若引擎未来扩展至 WebGPU(如 Three.js 的 WebGPURenderer),NDC zz 范围变为 [0,1][0, 1],对应的投影矩阵第 3 行与第 4 行系数需同步调整,高精度渲染时也常搭配 Reversed-Z 方案。

4. 相机高度、FOV 与地图尺度

正俯视时,如果相机距地面高度为 HcH_c,视口纵向覆盖约为:

groundHeight=2Hctan⁡fovy2groundHeight = 2 H_c \tan\frac{fov_y}{2}

若视口设备/CSS 高度为 HpxH_{px},一个屏幕像素对应的 render-local 地面尺寸约为:

unitsPerPixel=groundHeightHpxunitsPerPixel = \frac{groundHeight}{H_{px}}

倾斜后,这个比例沿屏幕 y 改变:近处每像素覆盖较少地面,远处覆盖较多。此时不能只用中心点比例估算整个视口的瓦片层级;需要使用地面 footprint、屏幕误差或分区采样。

5. 从屏幕点构造世界射线

给定 CSS 屏幕点 (sx,sy)(s_x, s_y) 和视口大小 (W,H)(W, H),先变为 NDC:

xn=2sxW−1x_n = 2 \frac{s_x}{W} - 1
yn=1−2syHy_n = 1 - 2 \frac{s_y}{H}

取 NDC 近点和远点:

n=(xn,yn,−1,1)T,f=(xn,yn,1,1)Tn = (x_n, y_n, -1, 1)^T, \qquad f = (x_n, y_n, 1, 1)^T

使用逆 view-projection 矩阵变换:

qn=(PV)−1n,qf=(PV)−1fq_n = (PV)^{-1} n, \qquad q_f = (PV)^{-1} f

分别进行显式齐次除法后,得到世界坐标点 pn,pfp_n, p_f:

pn=[qn.x/qn.wqn.y/qn.wqn.z/qn.w],pf=[qf.x/qf.wqf.y/qf.wqf.z/qf.w]p_n = \begin{bmatrix} q_{n.x} / q_{n.w} \\ q_{n.y} / q_{n.w} \\ q_{n.z} / q_{n.w} \end{bmatrix}, \qquad p_f = \begin{bmatrix} q_{f.x} / q_{f.w} \\ q_{f.y} / q_{f.w} \\ q_{f.z} / q_{f.w} \end{bmatrix}

齐次除法是极其关键的一步:由于逆矩阵 (PV)−1(PV)^{-1} 包含透视分量,乘以 NDC 坐标后的向量 ww 分量通常不等于 1,忘记除以 ww 分量会导致世界坐标完全失真。

最终,构成的世界射线为:

r(t)=o+t dr(t) = o + t \, d

其中:

o=pn,d=pf−pn∥pf−pn∥o = p_n, \qquad d = \frac{p_f - p_n}{\|p_f - p_n\|}

Three.js 的 Raycaster.setFromCamera(ndc, camera) 完成了同类工作,但理解推导十分重要:视口 cover、拾取和标签遮挡都会依赖射线的空间定义。

6. 射线与地图平面求交

地图平面为:

Y=0Y = 0

射线 y 分量:

ry(t)=oy+t dyr_y(t) = o_y + t \, d_y

令其为 0:

t=−oydy\boxed{t = -\frac{o_y}{d_y}}

若 t≥0t \ge 0,交点在射线前方:

pground=o+t dp_{ground} = o + t \, d

两个退化条件必须显式处理:

  1. ∣dy∣<ε|d_y| < \varepsilon:射线几乎平行地面,交点在极远处或不存在;
  2. t<0t < 0:交点位于相机背后,屏幕点看到的是天空。

不能把这些情况强行夹到某个巨大坐标。那会让视口 cover 突然请求成千上万瓦片。

7. 四个屏幕角不总能形成四边形

正俯视时,四个角的射线都落到地面,得到凸四边形 footprint。高 pitch 时,屏幕上沿可能越过地平线:上方两个角没有前向地面交点。

工程实现中,一种最通用的实操方案是屏幕空间地平线截断(Horizon Line Clipping):

  • 先算出世界坐标系地平线(即 Yworld=0Y_{\text{world}}=0 极远处)投影到屏幕上的 yy 坐标;
  • 若屏幕上沿高于地平线,则将顶部的屏幕采样点截断在地平线下方一个安全偏移量 ε\varepsilon(如地平线下方 2 个像素)处,再发射射线求交;
  • 或者在屏幕边界上二分查找“最后一个仍命中地面”的点;
  • 结合相机远裁剪面和配置的最大瓦片加载距离共同限制 footprint 范围;
  • 在镜头快速变化时限制每帧 cover 扩张预算。

选择哪种策略属于视口模块契约,不能散落在调度器里临时补救。第三部分会把这些边界纳入 cover 算法。

8. 从地面交点回到 Mercator

若场景使用 render-local 原点 om=(ox,oy)o_m = (o_x, o_y),一整个世界宽度为 worldSizeworldSize,地面交点 p=(X,0,Z)p = (X, 0, Z) 对应归一化 Mercator 坐标:

xm=ox+XworldSizex_m = o_x + \frac{X}{worldSize}
ym=oy+ZworldSizey_m = o_y + \frac{Z}{worldSize}

注:归一化 Mercator 坐标系的 Y 轴方向由北向南递增(原点 (0,0)(0,0) 在西北角),与 Three.js 场景中 +Z+Z 朝南的方向完全一致,因此计算 ymy_m 时无须翻转符号。

经过此步骤后,才能进入瓦片地址或四叉树覆盖计算。这里再次体现空间账本:相机射线输出的是 render-local 世界单位,而 cover 输入的是 normalized Mercator;中间换算必须显式携带原点和 worldSizeworldSize。

9. bearing、pitch 与 footprint 的影响

  • bearing 旋转 footprint,但不改变正俯视时的面积;
  • pitch 令 footprint 沿远方拉长,并让远端分辨率需求与近端不同;
  • FOV 增大令 footprint 扩大;
  • aspect 改变横向跨度;
  • 相机高度增加令 footprint 近似线性放大;
  • near/far 不应该被用来替代业务上的最大加载距离。

“视口当前需要哪些瓦片”必须从上述相机状态派生,而不能只在 zoom 改变时更新。平移、旋转、俯仰、窗口 resize 和 DPR/投影参数变化都可能使结果失效。

10. 选择与渲染必须共享相机事实

如果 cover 使用一套近似相机参数,而 Three.js 用另一套实际矩阵,就会出现:

  • 屏幕边缘露洞;
  • 画面外请求过多;
  • pitch 时远端瓦片层级错误;
  • resize 后覆盖范围滞后;
  • wrap 实例与可见区域不一致。

正确的架构是让不可变 ViewState 生成相机矩阵与 cover 输入,两者共享同一版本。后续即使把 cover 放进 Worker,也应通过版本化快照传递,而不是让 Worker 猜主线程相机。

下一章将讨论另一类跨模块问题:为什么数学正确的世界坐标传到 GPU 后仍会抖动,以及 render-local 原点如何解决它。