屏幕角点射线:从二维像素回到三维世界
视口 cover 的第一个问题不是"中心点在哪个瓦片",而是"整块屏幕看见了哪片地面"。屏幕是二维矩形,地图地面是三维场景中的平面;连接二者的工具是一组从相机出发、穿过屏幕角点的射线。
本章只解释射线如何由冻结的 TransformSnapshot 得到。下一章再把四条射线与零高程平面求交,组成 ground footprint。

1. 输入与输出的空间账本
一条角点射线经过四个空间:
| 阶段 | 表示 | 单位/范围 |
|---|---|---|
| CSS screen | (clientX, clientY) |
CSS pixels;原点在 canvas 左上 |
| NDC / clip | (x_ndc, y_ndc, z, w) |
x/y 在 [-1,1];近/远深度由投影约定 |
| render-local | (X,Y,Z) |
Three.js world units;当前地图中心附近为原点 |
| normalized Mercator | (x_m,y_m) |
无量纲;规范世界通常在 [0,1]² |
footprint resolver 不读取 DOM,也不读取 live camera。它消费 PlanarTransform.update(viewState) 已发布的冻结 snapshot,其中至少包含:
inverseViewProjectionMatrix;cameraPosition;origin;worldSize;viewStateKey。
这保证四个角点属于同一代 ViewState 和同一套矩阵。
2. CSS 角点如何变成 NDC
普通 screen point (s_x,s_y) 对应:
xndc=width2sx−1
yndc=1−height2sy
CSS y 向下,而 NDC y 向上,所以第二式有一次翻转。对于视口四角,换算结果固定为:
text
top-left (−1, +1)
top-right (+1, +1)
bottom-right (+1, −1)
bottom-left (−1, −1)
resolver 直接保存这四个 NDC 常量,不需要 viewport width/height。它们按屏幕边界顺序排列;这个顺序会一路保留到 Mercator polygon。
3. 为什么要选择远裁剪面上的点
透视投影把相机前方的一条视线压到同一个屏幕位置。仅有 (x_ndc,y_ndc) 无法决定射线方向,还要选择一个 clip-space 深度。
本实现取远平面:
pclip= xndcyndc11
然后用逆 ViewProjection 矩阵反投影:
ph=(PV)−1pclip
这里的 ph=(xh,yh,zh,wh) 仍是齐次坐标。只有做完齐次除法,才得到 render-local 点:
pfar=(whxh,whyh,whzh)
若 w_h 非有限数或接近 0,这次反投影没有可用的有限欧氏点。resolver 返回 invalid-homogeneous-coordinate,而不是继续制造 Infinity 或 NaN。
4. 从相机原点构造参数射线
令 snapshot 中的相机位置为:
o=(ox,oy,oz)
从相机指向远平面点的单位方向为:
d=∥pfar−o∥pfar−o
于是射线是:
r(t)=o+td,t≥0
t 的单位与 render-local 相同,因为 d 已归一化。成功求交后,本书把 t 直接保存为 point 的 distance,供后续请求优先级和调试可视化使用;它不是 Mercator 距离,也不是米。
远平面只是构造方向的一个方便点。只要近点和远点不重合,从相机到任意同一视线上的点都给出相同归一化方向。
5. 为什么不能从 live camera 逐角读取
假设一次输入导致 ViewState A 更新到 B,而 resolver 在四个角点之间读取 live camera:
text
top-left / top-right → matrix A
bottom-right / bottom-left → matrix B
这四条射线不再来自一个投影视锥。它们可能组成自交 polygon、错误 wrap 范围,甚至在相同输入下产生不同结果。
冻结 snapshot 把以下事实原子绑定:
text
viewStateKey
camera pose
view matrix
projection matrix
inverse ViewProjection
origin / worldSize
resolver 只读该快照。相同规范输入必然得到相同有序射线和相同 reason code,这对应地基契约 F1、F2 与 F13。
6. 地平线 miss 是几何结果
相机高度 oy>0。当地面为 Y=0 时,一条射线只有在方向向下,即 dy<0,才会在相机前方命中地面。
三种退化都意味着没有前向地面交点:
- ∣dy∣ 接近 0:射线几乎平行于地面;
- dy>0:射线朝向天空;
- 求得 t<0:交点在相机背后。
高 pitch 时,通常是顶部两个屏幕角点先越过地平线。它们 miss 并不代表相机或地图损坏,而是说明"整个屏幕矩形不能投影成有限的零高程四边形"。
本阶段对此采取 fail-closed 语义:只要一条角点射线 miss,就不发布部分 footprint。后续 cover 不能把余下三个点或中心点伪装成完整视口。
7. snapshot 为什么必须先验证
矩阵函数对 NaN 很宽容:一次非法乘法会把非有限数扩散到所有分量,却未必立即抛异常。因此 resolver 在创建 Three.js 矩阵前检查:
- 逆矩阵恰好有 16 个有限数;
- camera position 的 x/y/z 有限;
- Mercator origin 的 x/y/altitude 有限;
worldSize是正有限数;viewStateKey是字符串。
任一条件失败都返回:
js
{
valid: false,
coverageComplete: false,
points: [],
reason: "invalid-transform-snapshot",
}
这是数据边界验证,不是异常恢复策略。它让坏矩阵停在 cover 入口,而不会污染 TileKey、请求优先级或缓存。
8. 本章建立的不变量
- 四条射线固定按 TL → TR → BR → BL 排列;
- NDC 角点来自屏幕矩形,不依赖设备像素比;
- 反投影使用同一个冻结 inverse ViewProjection;
- 齐次除法发生在方向归一化之前;
- 射线原点是 snapshot camera position;
- 地平线 miss 是显式失败,不以中心点替代;
- 任何失败都不会发布部分角点集合。