three.js地图数学基础(六):齐次坐标与矩阵

齐次坐标与矩阵:为什么一个点需要四个分量

地图数据最终要经过平移、旋转、缩放、相机观察和透视投影。三维向量只有三个分量,而平移不是纯线性变换:任何 3×3 矩阵都把零向量映射回零向量,无法仅靠乘法表达"所有点都加上同一个偏移"。齐次坐标增加第四个分量 w,把仿射变换和透视投影统一到矩阵乘法中。

1. 本书的矩阵约定

全书使用列向量
p= x y z w p=\begin{bmatrix}x\\y\\z\\w\end{bmatrix} p= xyzw

变换写在向量左边:
p′=App'=Ap p′=Ap

连续执行"先 A,后 B"写作:
p′′=B(Ap)=(BA)pp''=B(Ap)=(BA)p p′′=B(Ap)=(BA)p

所以矩阵乘积的阅读顺序是从右向左。PVMp 表示先模型 M,再观察 V,最后投影 P

Three.js 的 Vector3.applyMatrix4(matrix) 采用这个列向量语义。Matrix4.elements 为了上传和计算以 column-major 顺序存储,但存储顺序不等于乘法约定。讨论变换时先看代数语义,不要根据数组的前四个数猜矩阵"横着还是竖着"。

2. 点与方向的 w 不同

三维点扩展为:
p= x y z 1 p=\begin{bmatrix}x\\y\\z\\1\end{bmatrix} p= xyz1

三维方向扩展为:
d= x y z 0 d=\begin{bmatrix}x\\y\\z\\0\end{bmatrix} d= xyz0

平移矩阵为:
T(tx,ty,tz)= 1 0 0 tx 0 1 0 ty 0 0 1 tz 0 0 0 1 T(t_x,t_y,t_z)= \begin{bmatrix} 1&0&0&t_x\\ 0&1&0&t_y\\ 0&0&1&t_z\\ 0&0&0&1 \end{bmatrix} T(tx,ty,tz)= 100001000010txtytz1

对点:
Tp= x+tx y+ty z+tz 1 Tp= \begin{bmatrix}x+t_x\\y+t_y\\z+t_z\\1\end{bmatrix} Tp= x+txy+tyz+tz1

对方向:
Td= x y z 0 Td= \begin{bmatrix}x\\y\\z\\0\end{bmatrix} Td= xyz0

因此移动建筑不会改变法线或朝向。若把方向错误地设为 w=1,平移就会污染光照、射线方向或切线。

:对于表面法线(Normal),若模型矩阵包含非均匀缩放 ,仅设置 w=0w=0 w=0 仍会导致法线不再垂直于表面。此时需使用模型视图矩阵的逆转置矩阵(Normal Matrix,即 (VM)−T(VM)^{-T} (VM)−T)对法线进行变换。

3. 模型矩阵 M

模型矩阵把顶点从几何自身的局部空间放入世界或 render-local 空间。常见分解为:
M=TRSM=TRS M=TRS

对列向量意味着:先缩放 S,再旋转 R,最后平移 T

地图瓦片常有两级变换:

  1. 构建阶段把 MVT (u,v)/extent 转成瓦片局部平面;
  2. 实例矩阵把瓦片局部平面放到当前 wrap 和 render origin 下。

若顶点已经在 Worker 中构建成 tile-local typed arrays,改变相机或 wrap 时不必重写所有顶点;更新实例/统一变量即可。这个分工会在渲染章节展开。

4. 观察矩阵 V

相机在世界中也有一个变换 C,描述相机位置和朝向。要从世界坐标改写成"相机眼中"的坐标,使用其逆矩阵:
V=C−1V=C^{-1} V=C−1

直觉上,如果相机向东移动 +100,世界在相机坐标中就向西移动 −100

Three.js 中:

  • camera.matrixWorld 对应相机变换 C
  • camera.matrixWorldInverse 对应观察矩阵 V
  • camera.updateMatrixWorld() 后二者才与当前位置、旋转一致。

地图相机通常还带 bearing(绕上轴旋转)和 pitch(从俯视向地平线倾斜)。它们应体现在相机变换/观察矩阵中,而不是逐瓦片修改几何。

5. 投影矩阵 P

透视投影要表达"距离越远,看起来越小"。这是齐次坐标真正超过普通仿射矩阵的地方:投影矩阵把相机空间深度编码进输出 w_c,随后统一做除法。

设相机空间点经过投影得到:
pc=Ppv= xc yc zc wc p_c=Pp_v= \begin{bmatrix}x_c\\y_c\\z_c\\w_c\end{bmatrix} pc=Ppv= xcyczcwc

这里的下标 c 表示 clip。GPU 随后计算:
pndc = xc/wc yc/wc zc/wc p_{ndc}= \begin{bmatrix} x_c/w_c\\ y_c/w_c\\ z_c/w_c \end{bmatrix} pndc= xc/wcyc/wczc/wc

这一步称为透视除法。若一个点离相机更远,典型透视矩阵会令 |w_c| 更大,于是 NDC x/y 更接近中心。

:本文讨论以 WebGL / OpenGL 规范为准,其 NDC Z 轴范围为 −1,1-1, 1 −1,1;若在 WebGPU 渲染后端运行,NDC Z 轴范围则为 0,10, 1 0,1

6. 完整变换链

局部顶点 p_local 到裁剪坐标:
pclip =P V M  plocal \boxed{p_{clip}=P\,V\,M\,p_{local}} pclip=PVMplocal

在 Three.js / GLSL 中常见写法:

glsl 复制代码
gl_Position = projectionMatrix
  * modelViewMatrix
  * vec4(position, 1.0);

其中:
modelViewMatrix=V MmodelViewMatrix=V\,M modelViewMatrix=VM

不要改成 MVP 的字符串顺序去机械拼乘法。对列向量,代码从右向左执行;Three.js 已提供正确的 modelViewMatrixprojectionMatrix

7. NDC 到屏幕像素

设透视除法后为 (x_n,y_n,z_n),画布设备像素宽高为 (W,H)
xs= xn+1 2 W x_s=\frac{x_n+1}{2}W xs=2xn+1W
ys= 1−yn 2 H y_s=\frac{1-y_n}{2}H ys=21−ynH

第二式中的 1-y_n 把 NDC 向上的 y 翻为 DOM/画布向下的 y。

若 CSS 大小为 W_css×H_css,设备像素比为 dpr
W= Wcss ⋅dpr,H= Hcss ⋅dpr W=W_{css}\cdot dpr,\qquad H=H_{css}\cdot dpr W=Wcss⋅dpr,H=Hcss⋅dpr

交互事件通常给 CSS 像素,而渲染缓冲区使用设备像素。把二者混用会让高 DPI 屏幕上的拾取偏移一个 dpr 倍数。

8. 逆变换与反投影

屏幕拾取和视口地面覆盖需要反向走变换链。先把 CSS 屏幕点转为 NDC:
xn=2 xsW −1,yn=1−2 ysH x_n=2\frac{x_s}{W}-1, \qquad y_n=1-2\frac{y_s}{H} xn=2Wxs−1,yn=1−2Hys

构建 NDC 空间下的齐次点 pndc = xn yn zn 1 T p_{ndc} = \begin{bmatrix}x_n & y_n & z_n & 1\end{bmatrix}^T pndc=xnynzn1T,乘逆矩阵得到世界空间齐次坐标:
pworld_h =(PVM)−1 pndc p_{world\h}=(PVM)^{-1}p{ndc} pworld_h=(PVM)−1pndc

逆变换结果仍是齐次坐标,必须再次除以其第四分量 ww w 才能还原为真实的 3D 世界坐标:
pworld = xwh / wwh ywh / wwh zwh / wwh p_{world}= \begin{bmatrix} x_{wh}/w_{wh}\\ y_{wh}/w_{wh}\\ z_{wh}/w_{wh} \end{bmatrix} pworld= xwh/wwhywh/wwhzwh/wwh

分别选取近平面(如 zn=−1 z_n = -1 zn=−1)与远平面(如 zn=1 z_n = 1 zn=1)求出两个世界点,即可定义一条屏幕拾取射线,再与地图平面求交。

矩阵只有在可逆时才能这样使用。零缩放会让模型矩阵奇异;near≤0near=far 或非法相机参数会破坏投影矩阵。公共相机接口应先拒绝这些状态,而不是等逆矩阵产生 NaN

9. 变换顺序的反例

取二维简化点 p=(1,0)

  • 先绕原点旋转 90°,再向 x 平移 10,结果是 (10,1)
  • 先平移到 (11,0),再旋转,结果是 (0,11)

二者矩阵分别为 TRRT,显然不相等:
TR≠RTTR\ne RT TR=RT

地图中的典型后果是:若先把瓦片移到巨大世界坐标,再绕错误原点旋转 bearing,瓦片会围绕世界原点公转;正确做法是由相机观察矩阵统一旋转视图,或围绕明确的局部锚点构造模型矩阵。

10. 调试矩阵管线的顺序

当画面为空、翻转或漂移时,按以下顺序观察:

  1. 原始顶点属于哪个空间,w 应为 0 还是 1;
  2. 模型矩阵后的位置是否在预期 render-local 范围;
  3. 相机空间中的点是否位于相机前方;
  4. clip w 是否有限且不接近 0;
  5. 除法后的 NDC 是否落入 [−1,1]
  6. viewport 使用的是 CSS 还是设备像素。

不要一开始就同时调整相机、几何和矩阵顺序。逐空间打印一个已知点,才能定位是哪一个边界首次偏离。

下一章会把这条矩阵链放进地图相机,进一步推导透视参数和"屏幕角点如何落到地面"。


上一步:实现 XYZ 与 wrap · 下一步:地图相机

相关推荐
AI砖家1 小时前
React Native 开发规范与完整流程指南
javascript·react native·react.js
Being--1 小时前
搭建OsgEarth验证GIS数据发布服务的Agent
gis·agent·osgearth·ai落地
小林ixn1 小时前
全栈项目实战:前端独立开发,不再傻等后端接口
前端·javascript·react.js
Asize2 小时前
前端接口工程:axios + mock,前端不再傻等后端
前端·javascript
张元清2 小时前
React useElementSize Hook:用 ResizeObserver 实时追踪元素宽高 (2026)
javascript·react.js
渣波2 小时前
React 性能优化与状态管理双雄:useMemo 与 useReducer 深度解析
前端·javascript
渣波2 小时前
告别 LLM 幻觉:用 Harness 工程化思维打造生产级 AI 应用
前端·javascript
cyadyx2 小时前
【vue】Pinia相对Vuex
前端·javascript·vue.js
IMPYLH3 小时前
HTML 的 <figure> 元素
前端·javascript·html