three.js地图视口瓦片(一):屏幕角点射线

屏幕角点射线:从二维像素回到三维世界

视口 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 = 2sx width −1 x_{ndc}=\frac{2s_x}{width}-1 xndc=width2sx−1
yndc =1− 2sy height y_{ndc}=1-\frac{2s_y}{height} 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 = xndc yndc 1 1 p_{clip}= \begin{bmatrix} x_{ndc}\\ y_{ndc}\\ 1\\ 1 \end{bmatrix} pclip= xndcyndc11

然后用逆 ViewProjection 矩阵反投影:
ph=(P V)−1 pclip p_h=(P\,V)^{-1}p_{clip} ph=(PV)−1pclip

这里的 ph=(xh,yh,zh,wh) p_h=(x_h,y_h,z_h,w_h) ph=(xh,yh,zh,wh) 仍是齐次坐标。只有做完齐次除法,才得到 render-local 点:
pfar = ( xhwh , yhwh , zhwh ) p_{far}=\left(\frac{x_h}{w_h},\frac{y_h}{w_h},\frac{z_h}{w_h}\right) pfar=(whxh,whyh,whzh)

w_h 非有限数或接近 0,这次反投影没有可用的有限欧氏点。resolver 返回 invalid-homogeneous-coordinate,而不是继续制造 InfinityNaN

4. 从相机原点构造参数射线

令 snapshot 中的相机位置为:
o=(ox,oy,oz) o=(o_x,o_y,o_z) o=(ox,oy,oz)

从相机指向远平面点的单位方向为:
d= pfar −o ∥ pfar −o∥ d=\frac{p_{far}-o}{\lVert p_{far}-o\rVert} d=∥pfar−o∥pfar−o

于是射线是:
r(t)=o+t d,t≥0r(t)=o+t\,d,\qquad t\ge 0 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 o_y>0 oy>0。当地面为 Y=0 时,一条射线只有在方向向下,即 dy<0 d_y<0 dy<0,才会在相机前方命中地面。

三种退化都意味着没有前向地面交点:

  • ∣dy∣ |d_y| ∣dy∣ 接近 0:射线几乎平行于地面;
  • dy>0 d_y>0 dy>0:射线朝向天空;
  • 求得 t<0t<0 t<0:交点在相机背后。

高 pitch 时,通常是顶部两个屏幕角点先越过地平线。它们 miss 并不代表相机或地图损坏,而是说明"整个屏幕矩形不能投影成有限的零高程四边形"。

本阶段对此采取 fail-closed 语义:只要一条角点射线 miss,就不发布部分 footprint。后续 cover 不能把余下三个点或中心点伪装成完整视口。

7. snapshot 为什么必须先验证

矩阵函数对 NaN 很宽容:一次非法乘法会把非有限数扩散到所有分量,却未必立即抛异常。因此 resolver 在创建 Three.js 矩阵前检查:

  1. 逆矩阵恰好有 16 个有限数;
  2. camera position 的 x/y/z 有限;
  3. Mercator origin 的 x/y/altitude 有限;
  4. worldSize 是正有限数;
  5. viewStateKey 是字符串。

任一条件失败都返回:

js 复制代码
{
  valid: false,
  coverageComplete: false,
  points: [],
  reason: "invalid-transform-snapshot",
}

这是数据边界验证,不是异常恢复策略。它让坏矩阵停在 cover 入口,而不会污染 TileKey、请求优先级或缓存。

8. 本章建立的不变量

  1. 四条射线固定按 TL → TR → BR → BL 排列;
  2. NDC 角点来自屏幕矩形,不依赖设备像素比;
  3. 反投影使用同一个冻结 inverse ViewProjection;
  4. 齐次除法发生在方向归一化之前;
  5. 射线原点是 snapshot camera position;
  6. 地平线 miss 是显式失败,不以中心点替代;
  7. 任何失败都不会发布部分角点集合。

相关推荐
一水行22 分钟前
博客评论系统搭建回顾
javascript·后端
穗余1 小时前
Typescript let和var区别
前端·javascript·typescript
晓得迷路了2 小时前
栗子前端技术周刊第 144 期 - Rspack 2.2、pnpm 12、Solid 2.0 RC...
前端·javascript·css
奈斯先生Vector2 小时前
从“能调用”到“可运营”:AI Agent 进入多模型时代后的架构升级
java·javascript·数据库·人工智能·算法·架构·aigc
এ慕ོ冬℘゜4 小时前
前端实战:jQuery实现动态表格渲染与状态切换功能
前端·javascript·jquery
zzzzzz3104 小时前
react-bits:把酷炫动效从“灵感截图”变成可复用的 React 组件
javascript·react.js·动效
海兰11 小时前
【应用】基于 Next.js 16 + Python mplfinance的金融K线图与技术指标可视化平台(三)
javascript·python·金融
小磊哥er13 小时前
深入解构Claude Code - 第 7 篇 · 连接外面的世界
javascript·ai编程
小磊哥er13 小时前
深入解构Claude Code - 第 6 篇 · 终端里的图形界面
javascript·ai编程