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

1. 本书的矩阵约定
全书使用列向量:
p= xyzw
变换写在向量左边:
p′=Ap
连续执行"先 A,后 B"写作:
p′′=B(Ap)=(BA)p
所以矩阵乘积的阅读顺序是从右向左。PVMp 表示先模型 M,再观察 V,最后投影 P。
Three.js 的 Vector3.applyMatrix4(matrix) 采用这个列向量语义。Matrix4.elements 为了上传和计算以 column-major 顺序存储,但存储顺序不等于乘法约定。讨论变换时先看代数语义,不要根据数组的前四个数猜矩阵"横着还是竖着"。
2. 点与方向的 w 不同
三维点扩展为:
p= xyz1
三维方向扩展为:
d= xyz0
平移矩阵为:
T(tx,ty,tz)= 100001000010txtytz1
对点:
Tp= x+txy+tyz+tz1
对方向:
Td= xyz0
因此移动建筑不会改变法线或朝向。若把方向错误地设为 w=1,平移就会污染光照、射线方向或切线。
注 :对于表面法线(Normal),若模型矩阵包含非均匀缩放 ,仅设置 w=0 仍会导致法线不再垂直于表面。此时需使用模型视图矩阵的逆转置矩阵(Normal Matrix,即 (VM)−T)对法线进行变换。
3. 模型矩阵 M
模型矩阵把顶点从几何自身的局部空间放入世界或 render-local 空间。常见分解为:
M=TRS
对列向量意味着:先缩放 S,再旋转 R,最后平移 T。
地图瓦片常有两级变换:
- 构建阶段把 MVT
(u,v)/extent转成瓦片局部平面; - 实例矩阵把瓦片局部平面放到当前 wrap 和 render origin 下。
若顶点已经在 Worker 中构建成 tile-local typed arrays,改变相机或 wrap 时不必重写所有顶点;更新实例/统一变量即可。这个分工会在渲染章节展开。
4. 观察矩阵 V
相机在世界中也有一个变换 C,描述相机位置和朝向。要从世界坐标改写成"相机眼中"的坐标,使用其逆矩阵:
V=C−1
直觉上,如果相机向东移动 +100,世界在相机坐标中就向西移动 −100。
Three.js 中:
camera.matrixWorld对应相机变换C;camera.matrixWorldInverse对应观察矩阵V;camera.updateMatrixWorld()后二者才与当前位置、旋转一致。
地图相机通常还带 bearing(绕上轴旋转)和 pitch(从俯视向地平线倾斜)。它们应体现在相机变换/观察矩阵中,而不是逐瓦片修改几何。
5. 投影矩阵 P
透视投影要表达"距离越远,看起来越小"。这是齐次坐标真正超过普通仿射矩阵的地方:投影矩阵把相机空间深度编码进输出 w_c,随后统一做除法。
设相机空间点经过投影得到:
pc=Ppv= xcyczcwc
这里的下标 c 表示 clip。GPU 随后计算:
pndc= xc/wcyc/wczc/wc
这一步称为透视除法。若一个点离相机更远,典型透视矩阵会令 |w_c| 更大,于是 NDC x/y 更接近中心。
注 :本文讨论以 WebGL / OpenGL 规范为准,其 NDC Z 轴范围为 −1,1;若在 WebGPU 渲染后端运行,NDC Z 轴范围则为 0,1。
6. 完整变换链
局部顶点 p_local 到裁剪坐标:
pclip=PVMplocal
在 Three.js / GLSL 中常见写法:
glsl
gl_Position = projectionMatrix
* modelViewMatrix
* vec4(position, 1.0);
其中:
modelViewMatrix=VM
不要改成 MVP 的字符串顺序去机械拼乘法。对列向量,代码从右向左执行;Three.js 已提供正确的 modelViewMatrix 和 projectionMatrix。
7. NDC 到屏幕像素
设透视除法后为 (x_n,y_n,z_n),画布设备像素宽高为 (W,H):
xs=2xn+1W
ys=21−ynH
第二式中的 1-y_n 把 NDC 向上的 y 翻为 DOM/画布向下的 y。
若 CSS 大小为 W_css×H_css,设备像素比为 dpr:
W=Wcss⋅dpr,H=Hcss⋅dpr
交互事件通常给 CSS 像素,而渲染缓冲区使用设备像素。把二者混用会让高 DPI 屏幕上的拾取偏移一个 dpr 倍数。
8. 逆变换与反投影
屏幕拾取和视口地面覆盖需要反向走变换链。先把 CSS 屏幕点转为 NDC:
xn=2Wxs−1,yn=1−2Hys
构建 NDC 空间下的齐次点 pndc=xnynzn1T,乘逆矩阵得到世界空间齐次坐标:
pworld_h=(PVM)−1pndc
逆变换结果仍是齐次坐标,必须再次除以其第四分量 w 才能还原为真实的 3D 世界坐标:
pworld= xwh/wwhywh/wwhzwh/wwh
分别选取近平面(如 zn=−1)与远平面(如 zn=1)求出两个世界点,即可定义一条屏幕拾取射线,再与地图平面求交。
矩阵只有在可逆时才能这样使用。零缩放会让模型矩阵奇异;near≤0、near=far 或非法相机参数会破坏投影矩阵。公共相机接口应先拒绝这些状态,而不是等逆矩阵产生 NaN。
9. 变换顺序的反例
取二维简化点 p=(1,0):
- 先绕原点旋转 90°,再向 x 平移 10,结果是
(10,1); - 先平移到
(11,0),再旋转,结果是(0,11)。
二者矩阵分别为 TR 与 RT,显然不相等:
TR=RT
地图中的典型后果是:若先把瓦片移到巨大世界坐标,再绕错误原点旋转 bearing,瓦片会围绕世界原点公转;正确做法是由相机观察矩阵统一旋转视图,或围绕明确的局部锚点构造模型矩阵。
10. 调试矩阵管线的顺序
当画面为空、翻转或漂移时,按以下顺序观察:
- 原始顶点属于哪个空间,
w应为 0 还是 1; - 模型矩阵后的位置是否在预期 render-local 范围;
- 相机空间中的点是否位于相机前方;
- clip
w是否有限且不接近 0; - 除法后的 NDC 是否落入
[−1,1]; - viewport 使用的是 CSS 还是设备像素。
不要一开始就同时调整相机、几何和矩阵顺序。逐空间打印一个已知点,才能定位是哪一个边界首次偏离。
下一章会把这条矩阵链放进地图相机,进一步推导透视参数和"屏幕角点如何落到地面"。