地图相机:从视锥到地面 footprint
相机不仅决定"画面看起来怎样",还决定当前应该选择哪些瓦片。视口瓦片技术的起点不是一个粗略中心点半径,而是:屏幕上的视锥经过逆投影后,在地图平面上覆盖了什么区域。
本章先建立透视相机的数学。真正的 cover 算法会在第三部分使用同一套射线与地面求交。
1. Three.js 地图世界约定
本书渲染空间是右手系:
+X向东;+Y向上(高度);+Z向南;- 地图位于 Y=0 的 X-Z 平面;
- Three.js 相机自身默认朝局部 −Z 观察。
相机放在地图上方后,用 camera.lookAt(target) 令局部 −Z 指向地图目标点。
关于 Bearing 与旋转方向的约定 地理学中的 Bearing(航向角) 定义为以正北为 0∘,顺时针 旋转(例如向东为 90∘)。而在本书的右手坐标系中, +Y 轴垂直向上,根据右手定则,绕 +Y 轴的旋转正方向是从 +Z(南)转向 +X(东),即俯视视角下的逆时针 方向。 因此,地理语义下的顺时针 Bearing,在右手系绕 +Y 轴变换矩阵中对应负旋转角 (即 Ry(−bearing))。明确这一点是避免 ViewState 转换为相机四元数时将东西方向调反的关键。
2. 透视投影视锥
透视相机由四个量定义:
- 垂直视场角 fovy;
- 宽高比 a=W/H;
- 近裁剪面 near>0;
- 远裁剪面 far>near。
在距离相机 d 的平面上,可见高度为:
h(d)=2dtan2fovy
宽度为:
w(d)=ah(d)
视场角越大,同一距离看到的地面越多,但边缘透视变形也越强。画布横向变宽时只需更新 aspect,不应修改垂直 FOV 来"凑画面"。
3. OpenGL/WebGL 投影矩阵
令:
f=tan(fovy/2)1
在相机朝 −Z 看的右手系中,一个常见的 WebGL 透视矩阵为:
P= f/a0000f0000near−farfar+near−100near−far2⋅far⋅near0
对相机空间点 (xv,yv,zv,1):
wc=−zv
相机前方的点具有 zv<0,所以 wc>0。除以 wc 后,横纵坐标都按距离缩小。
近裁剪面映射到 NDC z 为 −1,远裁剪面映射到 +1。深度不是线性的:精度更集中在 near 附近。把 near 设得极小、far 设得极大,会浪费深度缓冲精度并造成 z-fighting。
说明与扩展 :此处的投影矩阵适用传统的 WebGL 规范(NDC z∈−1,1)。若引擎未来扩展至 WebGPU(如 Three.js 的
WebGPURenderer),NDC z 范围变为 0,1,对应的投影矩阵第 3 行与第 4 行系数需同步调整,高精度渲染时也常搭配 Reversed-Z 方案。
4. 相机高度、FOV 与地图尺度
正俯视时,如果相机距地面高度为 Hc,视口纵向覆盖约为:
groundHeight=2Hctan2fovy
若视口设备/CSS 高度为 Hpx,一个屏幕像素对应的 render-local 地面尺寸约为:
unitsPerPixel=HpxgroundHeight
倾斜后,这个比例沿屏幕 y 改变:近处每像素覆盖较少地面,远处覆盖较多。此时不能只用中心点比例估算整个视口的瓦片层级;需要使用地面 footprint、屏幕误差或分区采样。
5. 从屏幕点构造世界射线
给定 CSS 屏幕点 (sx,sy) 和视口大小 (W,H),先变为 NDC:
xn=2Wsx−1
yn=1−2Hsy
取 NDC 近点和远点:
n=(xn,yn,−1,1)T,f=(xn,yn,1,1)T
使用逆 view-projection 矩阵变换:
qn=(PV)−1n,qf=(PV)−1f
分别进行显式齐次除法后,得到世界坐标点 pn,pf:
pn= qn.x/qn.wqn.y/qn.wqn.z/qn.w ,pf= qf.x/qf.wqf.y/qf.wqf.z/qf.w
齐次除法是极其关键的一步:由于逆矩阵 (PV)−1 包含透视分量,乘以 NDC 坐标后的向量 w 分量通常不等于 1,忘记除以 w 分量会导致世界坐标完全失真。
最终,构成的世界射线为:
r(t)=o+td
其中:
o=pn,d=∥pf−pn∥pf−pn
Three.js 的 Raycaster.setFromCamera(ndc, camera) 完成了同类工作,但理解推导十分重要:视口 cover、拾取和标签遮挡都会依赖射线的空间定义。
6. 射线与地图平面求交
地图平面为:
Y=0
射线 y 分量:
ry(t)=oy+tdy
令其为 0:
t=−dyoy
若 t≥0,交点在射线前方:
pground=o+td
两个退化条件必须显式处理:
- ∣dy∣<ε:射线几乎平行地面,交点在极远处或不存在;
- t<0:交点位于相机背后,屏幕点看到的是天空。
不能把这些情况强行夹到某个巨大坐标。那会让视口 cover 突然请求成千上万瓦片。
7. 四个屏幕角不总能形成四边形
正俯视时,四个角的射线都落到地面,得到凸四边形 footprint。高 pitch 时,屏幕上沿可能越过地平线:上方两个角没有前向地面交点。
工程实现中,一种最通用的实操方案是屏幕空间地平线截断(Horizon Line Clipping):
- 先算出世界坐标系地平线(即 Yworld=0 极远处)投影到屏幕上的 y 坐标;
- 若屏幕上沿高于地平线,则将顶部的屏幕采样点截断在地平线下方一个安全偏移量 ε(如地平线下方 2 个像素)处,再发射射线求交;
- 或者在屏幕边界上二分查找"最后一个仍命中地面"的点;
- 结合相机远裁剪面和配置的最大瓦片加载距离共同限制 footprint 范围;
- 在镜头快速变化时限制每帧 cover 扩张预算。
选择哪种策略属于视口模块契约,不能散落在调度器里临时补救。第三部分会把这些边界纳入 cover 算法。
8. 从地面交点回到 Mercator
若场景使用 render-local 原点 om=(ox,oy),一整个世界宽度为 worldSize,地面交点 p=(X,0,Z) 对应归一化 Mercator 坐标:
xm=ox+worldSizeX
ym=oy+worldSizeZ
注 :归一化 Mercator 坐标系的 Y 轴方向由北向南递增(原点 (0,0) 在西北角),与 Three.js 场景中 +Z 朝南的方向完全一致,因此计算 ym 时无须翻转符号。
经过此步骤后,才能进入瓦片地址或四叉树覆盖计算。这里再次体现空间账本:相机射线输出的是 render-local 世界单位,而 cover 输入的是 normalized Mercator;中间换算必须显式携带原点和 worldSize。
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 原点如何解决它。