页面

坐标系与矩阵变换:一个局部顶点怎样来到屏幕

← 返回兔子洞总览 · rendering 主题地图

(x, y, z) 离开坐标系就没有完整意义;渲染用模型、观察、投影、透视除法和视口变换,逐层把局部顶点转换成屏幕上的像素位置。

我追问的链

  • 顶点写着 (1, 2, 3),这三个数到底相对谁?
  • 点和向量都能写成三个数,它们有什么本质区别?
  • 为什么平移、旋转、缩放都可以交给矩阵?
  • 为什么 3D 变换常用 4 × 4 矩阵,而不是 3 × 3
  • 局部空间、世界空间和观察空间分别是谁的视角?
  • 为什么移动相机,数学上却像把整个世界反向移动?
  • 正交投影和透视投影究竟差在哪里?
  • 裁剪空间为什么有四个分量?w 是从哪里来的?
  • 为什么必须做透视除法,为什么不一开始就除?
  • NDC 还不是像素,它怎样变成屏幕坐标?
  • 左手系、右手系、行向量、列向量是不是一回事?
  • 为什么位置可以直接乘模型矩阵,法线有时却要乘逆转置矩阵?

1. 坐标只有放进坐标系才完整

一个顶点位置:

1
(1, 2, 3)

只说明三个数字,还没有说明:

1
2
3
4
原点在哪里?
x、y、z 轴分别指向哪里?
每个单位代表多远?
这是相对模型、父节点、世界还是相机?

同一组数字放进不同坐标系,代表不同位置。

例如一个角色右手上的剑尖:

1
剑模型局部坐标:(0, 1, 0)

这可能表示"相对剑自己的原点,向上 1 个单位"。角色移动到世界另一侧后,这个局部坐标没有变,但剑尖的世界坐标已经变了。

所以坐标的完整含义是:

1
2
3
一组分量
+
解释这些分量的原点和基向量

2. 一个坐标系由什么组成

三维笛卡尔坐标系可以先理解为:

1
2
3
4
原点 O
x 轴方向
y 轴方向
z 轴方向

三个轴方向也叫这个坐标系的基向量

1
2
3
X = (1, 0, 0)
Y = (0, 1, 0)
Z = (0, 0, 1)

某个点:

1
P = (2, 3, 4)

表达的其实是:

1
2
3
4
从原点 O 出发
+ 沿 X 走 2
+ 沿 Y 走 3
+ 沿 Z 走 4

也就是:

1
P = O + 2X + 3Y + 4Z

改变原点或基向量,同样的三个分量就会落到不同的真实位置。

矩阵变换的一个核心视角正是:

用一套新的原点和基向量,重新解释一个点或方向。

3. 点和向量不是一回事

点表示"在哪里",向量表示"方向和位移"。

1
2
点 P:角色当前在哪里
向量 v:角色应该向哪里移动多远

合法且有意义的运算包括:

1
2
3
4
点 + 向量 = 另一个点
点 - 点 = 从一个点指向另一个点的向量
向量 + 向量 = 合成后的向量
向量 × 标量 = 改变长度的向量

例如:

1
2
3
4
P = (2, 3)
v = (4, 1)

P + v = (6, 4)

平移会改变点的位置,却不应该改变一个纯方向:

1
2
3
4
5
点 (1, 0, 0) 平移 5
→ 点的位置改变

方向 (1, 0, 0) 平移 5
→ 仍然朝向 x 轴,不应改变

后面的齐次坐标会让矩阵能够区分这两种数据。

4. 向量的几个基本运算

长度

三维向量:

1
v = (x, y, z)

长度为:

1
|v| = sqrt(x² + y² + z²)

例如:

1
2
v = (3, 4, 0)
|v| = 5

归一化

把向量除以自身长度,得到长度为 1 的单位向量:

1
normalize(v) = v / |v|

它只保留方向,去掉原长度。零向量没有可用方向,不能直接正常归一化。

点积

两个向量的点积:

1
a · b = a.x × b.x + a.y × b.y + a.z × b.z

也可以写成:

1
a · b = |a||b|cosθ

因此它能回答两个方向有多接近:

1
2
3
点积 > 0:大致同向
点积 = 0:互相垂直
点积 < 0:大致反向

后面的光照、视野判断和投影都会反复用到点积。

叉积

三维叉积:

1
a × b

会得到一个垂直于 ab 所在平面的向量。它的方向依赖坐标系手性和操作数顺序:

1
a × b = -(b × a)

叉积常用于:

  • 根据两条边计算三角形法线。
  • 构建相机的右、上、前方向。
  • 判断旋转方向和几何朝向。

5. 矩阵不是魔法,而是批量执行线性组合

考虑二维矩阵:

1
2
| a  b |
| c d |

乘列向量:

1
2
| x |
| y |

结果是:

1
2
| ax + by |
| cx + dy |

也可以从"基向量"理解。矩阵的两列:

1
2
第一列 = (a, c)
第二列 = (b, d)

分别告诉我们原来的 x 轴和 y 轴经过变换后指向哪里。输入 (x, y) 的结果,就是:

1
x × 新 X 轴 + y × 新 Y 轴

因此,一个矩阵可以同时编码:

1
2
3
4
5
缩放
旋转
错切
镜像
它们的组合

矩阵真正有价值的地方是:

把多个分量之间固定的线性关系整理成一种可以组合、缓存并批量应用的形式。

可交互图例:亲手改变矩阵的两列

先不要把 a、b、c、d 当成四个孤立的数字:

  • 拖动 a、c:你改变的是第一列,也就是原 x 轴变换后的方向。
  • 拖动 b、d:你改变的是第二列,也就是原 y 轴变换后的方向。
  • 观察浅蓝色平行四边形:它是单位正方形变换后的样子。
  • 观察面积倍数:它是行列式绝对值,等于这次变换对面积的缩放比例。
  • 面积变为 0 时,两根新基向量共线,二维平面被压扁成了一条线。

建议先只动一个滑块,确认“矩阵的一列就是一根变换后的基向量”,再组合多个变化。

6. 缩放与旋转怎样写进矩阵

阅读矩阵公式前,先确认约定: 本章统一使用列向量,并把变换矩阵写在向量左侧。其他资料或引擎可能采用行向量,公式顺序会相反,但表达的几何变换可以完全相同。

本章的公式写作:

1
2
列向量
变换写在向量左侧

也就是:

1
p' = M × p

缩放

二维缩放:

1
2
| sx  0 |
| 0 sy |

作用于:

1
2
| x |
| y |

得到:

1
2
| sx × x |
| sy × y |

旋转

二维逆时针旋转 θ

1
2
| cosθ  -sinθ |
| sinθ cosθ |

θ = 90° 时:

1
2
cosθ = 0
sinθ = 1

(1, 0) 变成:

1
(0, 1)

从基向量看,这个矩阵把:

1
2
原 X 轴 (1, 0) → (0, 1)
原 Y 轴 (0, 1) → (-1, 0)

所以所有由这两个基向量组合出的点都会一起旋转。

7. 为什么平移需要齐次坐标

普通 3 × 3 线性矩阵乘三维零向量,结果永远还是零:

1
M × (0, 0, 0) = (0, 0, 0)

但平移希望做到:

1
(0, 0, 0) → (tx, ty, tz)

这不是普通三维线性变换能直接表达的。

解决方法是给三维数据增加第四个分量:

1
2
3
(x, y, z)

(x, y, z, w)

这叫齐次坐标(Homogeneous Coordinates)。

通常约定:

1
2
点:    (x, y, z, 1)
方向: (x, y, z, 0)

在列向量约定下,平移矩阵可以写成:

1
2
3
4
| 1  0  0  tx |
| 0 1 0 ty |
| 0 0 1 tz |
| 0 0 0 1 |

点的第四个分量是 1,因此平移列会参与计算:

1
2
3
4
| x |       | x + tx |
| y | | y + ty |
| z | → | z + tz |
| 1 | | 1 |

所以第四个分量不只是"为了凑成 4 × 4":

它让同一个矩阵系统既能处理线性变换,也能处理平移,并区分点与方向。

后面透视投影还会再次使用这个 w

8. 变换顺序为什么不能随便换

矩阵乘法通常不满足交换律:

1
A × B ≠ B × A

先旋转再平移,与先平移再旋转,结果不同。

假设点:

1
P = (1, 0)

操作是:

1
2
旋转 90°
平移 (5, 0)

先旋转:

1
2
3
4
5
(1, 0)
↓ 旋转
(0, 1)
↓ 平移
(5, 1)

先平移:

1
2
3
4
5
(1, 0)
↓ 平移
(6, 0)
↓ 绕原点旋转
(0, 6)

这是两个完全不同的位置。

在本章采用的列向量约定下:

1
p' = T × R × S × p

表示对点依次执行:

1
2
3
先缩放 S
再旋转 R
最后平移 T

因为离向量最近的右侧矩阵最先作用。

如果使用行向量约定,书写顺序会反过来。因此看到陌生代码时,先确认约定,不要只凭矩阵名字猜执行顺序。

9. Model Matrix:局部空间到世界空间

模型文件中的顶点通常保存在自己的局部空间,也叫:

1
2
3
Local Space
Object Space
Model Space

例如一个立方体以自身中心为原点:

1
2
3
(-0.5, -0.5, -0.5)

( 0.5, 0.5, 0.5)

无论把立方体放到场景哪里,这套局部顶点都不必重写。

物体的缩放、旋转和平移组合成模型矩阵

1
M = T × R × S

局部顶点变成世界顶点:

1
p_world = M × p_local

这允许许多物体共用一份 Mesh:

1
2
3
4
同一个立方体 Mesh
├─ Model Matrix A → 放在房间左边
├─ Model Matrix B → 放在房间右边并放大
└─ Model Matrix C → 放在空中并旋转

几何数据相同,变换不同。

10. 父子层级怎样组合

如果剑是角色手的子节点,它的局部坐标先相对手,再相对角色,最后来到世界。

在列向量约定下:

1
2
3
4
M_world_sword
= M_world_character
× M_local_hand
× M_local_sword

一个剑顶点:

1
2
3
4
5
p_world
= M_world_character
× M_local_hand
× M_local_sword
× p_sword

从右向左看执行顺序:

1
2
3
4
5
6
7
剑局部

手局部

角色局部

世界空间

父节点移动,子节点的局部坐标可以完全不变,但最终世界矩阵会跟着改变。

这就是场景树变换传播的数学地基。

11. View Matrix:世界来到相机眼前

世界空间解决了"所有物体在同一个场景中在哪里",但屏幕画面还取决于相机。

相机也可以有一个世界变换:

1
Camera World Transform

它描述相机在世界中的位置和朝向。

渲染真正需要的是反过来回答:

站在相机的位置和朝向看,世界中的点位于相机前后左右哪里?

因此观察矩阵(View Matrix)是相机世界变换的逆:

1
V = inverse(CameraWorld)

世界顶点进入观察空间:

1
p_view = V × p_world

如果相机向右移动 5 个单位,从相机视角看,就像整个世界向左移动 5 个单位。

1
2
3
相机向右

世界在相机空间向左

所以 View Matrix 看起来总在做与相机自身变换相反的操作。它不是相机移动错了,而是在把世界改写成"以相机为原点"的坐标。

观察空间中,相机通常位于原点,朝固定轴向观察。至于朝 +z 还是 -z,取决于所采用的坐标约定。

12. 正交投影:距离不改变大小

相机空间仍然是三维的,最终屏幕却是二维的。投影要决定:

1
三维位置怎样映射到二维画面?

正交投影(Orthographic Projection)的特点是:

1
2
平行线投影后仍然平行
同样大小的物体不会因为距离更远而变小

直观上像从一个长方体观察区域直接压到屏幕:

1
2
3
4
5
      ┌─────────┐
近处 │ │
│ │ → 压成二维
远处 │ │
└─────────┘

它常用于:

  • 2D 游戏。
  • UI。
  • 地图和编辑器视图。
  • 不希望产生近大远小效果的画面。

正交投影仍然会处理可见范围和深度,只是不使用距离缩小物体。

13. 透视投影:为什么会近大远小

透视投影可以用针孔相机模型理解。

假设相机在原点并朝某个固定方向观察。一个点离中心轴越远,投到成像平面的位置也越远;但它距离相机越远,投影尺寸越小。

在一种常见的"相机看向 -z"约定下,可以先把水平投影关系写成:

1
x_projected = f × x / (-z)

其中:

  • x 是点在相机空间的水平位置。
  • -z 是它到相机前方的距离。
  • f 与焦距或视野角有关。

两个宽度相同的物体:

1
2
近处:除以较小距离 → 屏幕上更大
远处:除以较大距离 → 屏幕上更小

这就是近大远小的数学来源:

透视感不是 Shader 随意缩放出来的,而是投影坐标中出现了"除以深度"。

实际投影矩阵还要同时处理:

  • 垂直视野角。
  • 屏幕宽高比。
  • 近裁剪面。
  • 远裁剪面。
  • 深度映射约定。

14. Projection Matrix 先产生裁剪空间

模型和观察变换后:

1
p_view = V × M × p_local

再乘投影矩阵:

1
p_clip = P × p_view

得到的不是立即可用的三维屏幕坐标,而是四维裁剪空间坐标

1
p_clip = (x_clip, y_clip, z_clip, w_clip)

这里的 w_clip 对透视投影非常关键。投影矩阵会把相机空间深度信息编码进 w,为后面的透视除法做准备。

完整链可以写成:

1
p_clip = P × V × M × p_local

也就是常说的:

1
MVP

但 MVP 不是一个神秘阶段名,它只是三次坐标变换的组合:

1
2
3
Model:局部 → 世界
View:世界 → 相机
Projection:相机 → 裁剪

15. 为什么先裁剪,再做透视除法

在裁剪空间中,可见区域可以通过与 w 比较来描述。常见形式类似:

1
2
3
-w ≤ x ≤ w
-w ≤ y ≤ w
z 的范围按图形 API 约定判断

三角形如果跨过可见边界,管线可以在齐次坐标中求交并生成新的边界顶点。

为什么不先除以 w

因为透视除法会引入非线性,尤其在:

1
2
3
w = 0
相机后方
跨越近裁剪面

这些位置附近直接处理会变得麻烦甚至发散。

保留齐次裁剪空间,可以先用线性边界判断并裁掉不可见部分,再对留下的顶点做除法。

所以顺序是:

1
2
3
4
5
投影得到裁剪空间

裁剪可见范围

透视除法

16. 透视除法:裁剪空间到 NDC

裁剪完成后,用 w_clip 除前三个分量:

1
2
3
x_ndc = x_clip / w_clip
y_ndc = y_clip / w_clip
z_ndc = z_clip / w_clip

得到标准化设备坐标

1
NDC:Normalized Device Coordinates

NDC 把不同相机参数和视锥范围统一压到一个标准立方体。

常见情况下:

1
2
x_ndc ∈ [-1, 1]
y_ndc ∈ [-1, 1]

z_ndc 的范围会因图形 API 约定不同而不同,常见有:

1
2
3
[-1, 1]

[0, 1]

不要把某一种 API 的深度范围当作所有平台的唯一真相。

在透视投影中,w_clip 通常与相机空间深度相关。距离越远,除数越大,因此屏幕上的 xy 越接近中心,物体看起来越小。

17. Viewport Transform:NDC 到屏幕像素

NDC 仍然不是屏幕像素。它只是在标准范围中的坐标。

假设视口宽高为:

1
2
width
height

如果屏幕 x 轴向右,并把 NDC [-1, 1] 映射到像素范围,可以写成:

1
x_screen = (x_ndc + 1) / 2 × width

于是:

1
2
3
x_ndc = -1 → 屏幕左边
x_ndc = 0 → 屏幕中间
x_ndc = 1 → 屏幕右边

y 坐标的公式取决于屏幕原点约定:

1
2
原点在左下:y 通常向上增加
原点在左上:y 通常向下增加

深度也会映射到深度缓冲使用的范围。

这一阶段叫视口变换(Viewport Transform):

1
2
3
4
5
NDC 标准立方体

映射到指定 viewport 的位置、宽度、目标深度范围

屏幕坐标

同一个 NDC 画面可以映射到全屏,也可以映射到屏幕角落的小视口。

18. 从局部顶点到屏幕的完整链

现在可以把一条顶点位置链完整写出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
p_local
↓ Model Matrix
p_world
↓ View Matrix
p_view
↓ Projection Matrix
p_clip = (x, y, z, w)
↓ Clipping
裁掉视锥外部分
↓ Perspective Divide
p_ndc = (x/w, y/w, z/w)
↓ Viewport Transform
p_screen

对应公式:

1
2
3
p_clip = P × V × M × p_local
p_ndc = p_clip.xyz / p_clip.w
p_screen = viewport(p_ndc)

顶点着色器最关键的职责之一,就是输出裁剪空间位置。后续固定管线才能据此执行裁剪、透视除法、光栅化和插值。

19. 用一个二维例子理解组合变换

假设局部点:

1
P = (1, 0)

物体依次:

1
2
3
沿 x 放大 2 倍
逆时针旋转 90°
平移到 (5, 3)

第一步缩放:

1
(1, 0) → (2, 0)

第二步旋转:

1
(2, 0) → (0, 2)

第三步平移:

1
(0, 2) → (5, 5)

在列向量约定下:

1
P_world = T × R × S × P_local

结果:

1
P_world = (5, 5)

这个例子揭示矩阵组合的意义:

1
2
3
先把一连串变换合成一个矩阵

再让 Mesh 中每个顶点使用同一个组合结果

不必为每个顶点重新组织整条业务层级。

20. 左手系与右手系是什么

三维坐标系还要约定三个轴之间的方向关系。

可以用叉积判断:

1
X × Y 得到 Z 的哪一个方向?

按一种约定得到的轴关系称为右手系,另一种镜像关系称为左手系。

手性会影响:

  • 正方向怎样解释。
  • 叉积结果方向。
  • 相机默认朝向约定。
  • 三角形投影后的绕序。
  • 模型在不同系统间导入时是否镜像。

但要注意:

左右手系、行向量/列向量、矩阵行主序/列主序,是三组不同概念。

它们经常一起出现,却不能互相直接推出。

一个系统完全可以:

  • 使用右手坐标系。
  • 使用列向量数学约定。
  • 在内存中按列主序保存矩阵。

另一个系统也可能组合成别的选择,只要内部规则一致,几何结果都可以正确。

21. 行向量、列向量与内存布局不要混为一谈

行向量与列向量

这是公式怎样书写的问题。

列向量:

1
p' = M × p

行向量:

1
p' = p × M

同一串几何变换在两种约定中的矩阵形式和组合顺序会互相转置、反向。

行主序与列主序

这是矩阵元素怎样线性排进内存的问题:

1
2
3
先连续保存一整行

先连续保存一整列

内存布局影响上传、读取和 API 交互,却不自动决定代码使用行向量还是列向量。

因此阅读引擎或 Shader 代码时应分别确认:

1
2
3
4
5
坐标系手性是什么?
向量写在矩阵哪一侧?
矩阵组合按什么顺序?
内存怎样排列?
Shader API 是否在上传时转置或转换?

只看到数组排列,不能直接推断完整数学语义。

22. 为什么法线不能总按普通方向变换

法线不是普通表面方向,它还必须保持:

与变换后的切平面继续垂直。

如果模型只做旋转或均匀缩放,使用模型矩阵的方向部分通常仍能得到正确方向。

但非均匀缩放会破坏垂直关系。例如:

1
2
x 方向缩放 2 倍
y 方向缩放 0.5 倍

直接用同一个矩阵变换法线,结果可能不再垂直于变换后的表面。

为了保持:

1
normal' · tangent' = 0

法线需要使用模型矩阵线性部分的逆转置矩阵

1
2
NormalMatrix = transpose(inverse(M_linear))
normal' = NormalMatrix × normal

然后通常还要归一化:

1
normal = normalize(normal);

这里的 M_linear 一般指模型矩阵左上角负责旋转、缩放和错切的 3 × 3 部分,不包含平移。

所以:

1
2
3
位置:使用完整模型矩阵
普通方向:w = 0,不受平移影响
法线:非均匀缩放时需要逆转置

三者不能因为都写成三个分量,就用完全相同的变换方式。

23. 负缩放为什么可能让面消失

假设某一轴缩放为负数:

1
scaleX = -1

它会产生镜像。镜像不只改变位置,还可能翻转坐标系手性和三角形绕序:

1
2
3
原本正面为 CCW
↓ 镜像
投影后可能变成 CW

如果背面剔除仍按原规则判断,模型可能出现:

  • 整体从外侧消失。
  • 一部分面被错误剔除。
  • 法线方向与几何正面不一致。

因此处理负缩放、镜像模型或跨坐标系导入时,要一起检查:

1
2
3
4
顶点位置
三角形索引绕序
法线 / 切线
正面与剔除状态

只把 x 坐标乘 -1,不一定完成了全部语义转换。

24. 把常见现象放回坐标链

现象 优先追问的方向
模型位置完全不对 当前数据属于局部、世界还是观察空间
子节点跟随异常 父子矩阵组合和乘法顺序
相机向右,世界却向左 这是 View Matrix 的逆变换,可能是正常现象
模型大小随距离变化异常 正交/透视投影、相机空间深度、投影参数
近处模型被切开 近裁剪面与裁剪空间
画面上下或深度颠倒 NDC、视口原点、深度范围等 API 约定
模型镜像后消失 负缩放造成绕序翻转,触发背面剔除
非均匀缩放后光照错误 法线是否使用逆转置并重新归一化
矩阵看似正确但结果顺序相反 行/列向量约定和组合顺序
远离世界原点后抖动 浮点精度与巨大世界坐标

继续沿用同一套诊断方式:

1
2
3
4
5
6
7
先确认当前坐标属于哪一层

确认这一步应该变换点、方向还是法线

确认矩阵约定和乘法顺序

检查输出是否进入下一层期望的范围

逻辑闭环 / 锚点

这一课的完整闭环是:

1
2
3
4
5
6
7
8
9
10
11
12
13
局部空间顶点
↓ Model
世界空间
↓ View
观察空间
↓ Projection
裁剪空间 (x, y, z, w)
↓ Clipping
保留视锥内几何
↓ Perspective Divide
NDC
↓ Viewport Transform
屏幕坐标

几个不能混淆的锚点:

  1. 坐标必须带空间语义(1, 2, 3) 自己不能说明它相对谁。
  2. 点和方向不同:齐次坐标用 w = 1w = 0 让平移只影响点。
  3. 矩阵顺序有意义:缩放、旋转、平移不能任意交换。
  4. View 是相机世界变换的逆:它把整个世界改写到相机坐标系。
  5. Projection 不直接产生像素:它先产生裁剪空间,再裁剪、除以 w 得到 NDC。
  6. NDC 仍不是屏幕:视口变换才把标准范围映射到具体像素区域。
  7. 手性、向量侧和内存布局不同:它们必须分别确认。
  8. 法线不是普通位置:非均匀缩放时要保持与表面垂直,需要逆转置。

下一篇要继续追的是:

顶点和矩阵已经准备好了,CPU 怎样把 Buffer、纹理、Shader 和渲染状态组织起来,并真正命令 GPU 画一次?

这会把我们带到 CPU/GPU 分工、图形 API、资源创建、状态绑定、Draw Call、命令缓冲、同步与流水线并行。

关联

  • 顶点、三角形与插值 直接衔接:上一章定义顶点属性,这一章追清其中的位置和法线怎样进入后续管线。
  • 分层 + 封装 是同一个母题:局部、世界、观察、裁剪、NDC 和屏幕不是重复坐标,而是每层为下一步准备的不同语义。
  • JavaScript 内存与生命周期 的边界思维相同:名字相似的数据可能属于不同层,结束或改变其中一层,不等于其他层自动同步完成。

来源:与 Codex 的对话,2026-07。