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

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

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

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

1. Three.js 地图世界约定

本书渲染空间是右手系:

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

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

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

2. 透视投影视锥

透视相机由四个量定义:

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

在距离相机 dd d 的平面上,可见高度为:
h(d)=2dtan⁡ fovy 2 h(d) = 2d \tan\frac{fov_y}{2} h(d)=2dtan2fovy

宽度为:
w(d)=a h(d)w(d) = a \, h(d) w(d)=ah(d)

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

3. OpenGL/WebGL 投影矩阵

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

在相机朝 −Z-Z −Z 看的右手系中,一个常见的 WebGL 透视矩阵为:
P= f/a 0 0 0 0 f 0 0 0 0 far+nearnear−far 2⋅far⋅nearnear−far 0 0 −1 0 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} P= f/a0000f0000near−farfar+near−100near−far2⋅far⋅near0

对相机空间点 (xv,yv,zv,1) (x_v, y_v, z_v, 1) (xv,yv,zv,1):
wc=−zv w_c = -z_v wc=−zv

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

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

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

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

正俯视时,如果相机距地面高度为 Hc H_c Hc,视口纵向覆盖约为:
groundHeight=2Hctan⁡ fovy 2 groundHeight = 2 H_c \tan\frac{fov_y}{2} groundHeight=2Hctan2fovy

若视口设备/CSS 高度为 Hpx H_{px} Hpx,一个屏幕像素对应的 render-local 地面尺寸约为:
unitsPerPixel= groundHeight Hpx unitsPerPixel = \frac{groundHeight}{H_{px}} unitsPerPixel=HpxgroundHeight

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

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

给定 CSS 屏幕点 (sx,sy) (s_x, s_y) (sx,sy) 和视口大小 (W,H)(W, H) (W,H),先变为 NDC:
xn=2 sxW −1 x_n = 2 \frac{s_x}{W} - 1 xn=2Wsx−1
yn=1−2 syH y_n = 1 - 2 \frac{s_y}{H} yn=1−2Hsy

取 NDC 近点和远点:
n=(xn,yn,−1,1)T,f=(xn,yn,1,1)T n = (x_n, y_n, -1, 1)^T, \qquad f = (x_n, y_n, 1, 1)^T n=(xn,yn,−1,1)T,f=(xn,yn,1,1)T

使用逆 view-projection 矩阵变换:
qn=(PV)−1n,qf=(PV)−1f q_n = (PV)^{-1} n, \qquad q_f = (PV)^{-1} f qn=(PV)−1n,qf=(PV)−1f

分别进行显式齐次除法后,得到世界坐标点 pn,pf p_n, p_f pn,pf:
pn= qn.x / qn.w qn.y / qn.w qn.z / qn.w ,pf= qf.x / qf.w qf.y / qf.w qf.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} pn= qn.x/qn.wqn.y/qn.wqn.z/qn.w ,pf= qf.x/qf.wqf.y/qf.wqf.z/qf.w

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

最终,构成的世界射线为:
r(t)=o+t dr(t) = o + t \, d r(t)=o+td

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

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

6. 射线与地图平面求交

地图平面为:
Y=0Y = 0 Y=0

射线 y 分量:
ry(t)=oy+t dy r_y(t) = o_y + t \, d_y ry(t)=oy+tdy

令其为 0:
t=− oydy \boxed{t = -\frac{o_y}{d_y}} t=−dyoy

t≥0t \ge 0 t≥0,交点在射线前方:
pground =o+t d p_{ground} = o + t \, d pground=o+td

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

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

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

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

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

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

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

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

8. 从地面交点回到 Mercator

若场景使用 render-local 原点 om=(ox,oy) o_m = (o_x, o_y) om=(ox,oy),一整个世界宽度为 worldSizeworldSize worldSize,地面交点 p=(X,0,Z)p = (X, 0, Z) p=(X,0,Z) 对应归一化 Mercator 坐标:
xm=ox+ XworldSize x_m = o_x + \frac{X}{worldSize} xm=ox+worldSizeX
ym=oy+ ZworldSize y_m = o_y + \frac{Z}{worldSize} ym=oy+worldSizeZ

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

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


相关推荐
mONESY1 小时前
React 前端如何不傻等后端接口?
前端·javascript·后端
vx-程序开发1 小时前
django汽车租赁系统---附源码25360
java·javascript·spring boot·python·eclipse·django·php
满栀5852 小时前
vue3动态路由详细效果
前端·javascript·vue.js·typescript·前端框架
山荷枝2 小时前
05-Vue
前端·javascript·vue.js
breeze jiang3 小时前
Next.js App Router 全栈实战:从 SPA 的 SEO 痛点到服务端组件与 Hydration 水合机制
开发语言·javascript·ecmascript
可爱的秋秋啊3 小时前
vue调用腾讯人脸组件封装+接口请求后端调用
前端·javascript·vue.js
绝世唐门三哥3 小时前
CSS 虚线下划线用法指南:text-decoration 完整解析
前端·javascript·css
苏灿烤鱼3 小时前
14MB 模型,凭什么跟 270M 对打?
javascript·python·agent
To_OC11 小时前
LC 560 和为 K 的子数组:前缀和配哈希表,这对组合我是真的服了
javascript·算法·程序员