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