(x, y, z)离开坐标系就没有完整意义;渲染用模型、观察、投影、透视除法和视口变换,逐层把局部顶点转换成屏幕上的像素位置。
我追问的链
- 顶点写着
(1, 2, 3),这三个数到底相对谁? - 点和向量都能写成三个数,它们有什么本质区别?
- 为什么平移、旋转、缩放都可以交给矩阵?
- 为什么 3D 变换常用
4 × 4矩阵,而不是3 × 3? - 局部空间、世界空间和观察空间分别是谁的视角?
- 为什么移动相机,数学上却像把整个世界反向移动?
- 正交投影和透视投影究竟差在哪里?
- 裁剪空间为什么有四个分量?
w是从哪里来的? - 为什么必须做透视除法,为什么不一开始就除?
- NDC 还不是像素,它怎样变成屏幕坐标?
- 左手系、右手系、行向量、列向量是不是一回事?
- 为什么位置可以直接乘模型矩阵,法线有时却要乘逆转置矩阵?
1. 坐标只有放进坐标系才完整
一个顶点位置:
1 | (1, 2, 3) |
只说明三个数字,还没有说明:
1 | 原点在哪里? |
同一组数字放进不同坐标系,代表不同位置。
例如一个角色右手上的剑尖:
1 | 剑模型局部坐标:(0, 1, 0) |
这可能表示"相对剑自己的原点,向上 1 个单位"。角色移动到世界另一侧后,这个局部坐标没有变,但剑尖的世界坐标已经变了。
所以坐标的完整含义是:
1 | 一组分量 |
2. 一个坐标系由什么组成
三维笛卡尔坐标系可以先理解为:
1 | 原点 O |
三个轴方向也叫这个坐标系的基向量:
1 | X = (1, 0, 0) |
某个点:
1 | P = (2, 3, 4) |
表达的其实是:
1 | 从原点 O 出发 |
也就是:
1 | P = O + 2X + 3Y + 4Z |
改变原点或基向量,同样的三个分量就会落到不同的真实位置。
矩阵变换的一个核心视角正是:
用一套新的原点和基向量,重新解释一个点或方向。
3. 点和向量不是一回事
点表示"在哪里",向量表示"方向和位移"。
1 | 点 P:角色当前在哪里 |
合法且有意义的运算包括:
1 | 点 + 向量 = 另一个点 |
例如:
1 | P = (2, 3) |
平移会改变点的位置,却不应该改变一个纯方向:
1 | 点 (1, 0, 0) 平移 5 |
后面的齐次坐标会让矩阵能够区分这两种数据。
4. 向量的几个基本运算
长度
三维向量:
1 | v = (x, y, z) |
长度为:
1 | |v| = sqrt(x² + y² + z²) |
例如:
1 | v = (3, 4, 0) |
归一化
把向量除以自身长度,得到长度为 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 | 点积 > 0:大致同向 |
后面的光照、视野判断和投影都会反复用到点积。
叉积
三维叉积:
1 | a × b |
会得到一个垂直于 a、b 所在平面的向量。它的方向依赖坐标系手性和操作数顺序:
1 | a × b = -(b × a) |
叉积常用于:
- 根据两条边计算三角形法线。
- 构建相机的右、上、前方向。
- 判断旋转方向和几何朝向。
5. 矩阵不是魔法,而是批量执行线性组合
考虑二维矩阵:
1 | | a b | |
乘列向量:
1 | | x | |
结果是:
1 | | ax + by | |
也可以从"基向量"理解。矩阵的两列:
1 | 第一列 = (a, c) |
分别告诉我们原来的 x 轴和 y 轴经过变换后指向哪里。输入 (x, y) 的结果,就是:
1 | x × 新 X 轴 + y × 新 Y 轴 |
因此,一个矩阵可以同时编码:
1 | 缩放 |
矩阵真正有价值的地方是:
把多个分量之间固定的线性关系整理成一种可以组合、缓存并批量应用的形式。
可交互图例:亲手改变矩阵的两列
先不要把 a、b、c、d 当成四个孤立的数字:
- 拖动
a、c:你改变的是第一列,也就是原 x 轴变换后的方向。 - 拖动
b、d:你改变的是第二列,也就是原 y 轴变换后的方向。 - 观察浅蓝色平行四边形:它是单位正方形变换后的样子。
- 观察面积倍数:它是行列式绝对值,等于这次变换对面积的缩放比例。
- 面积变为
0时,两根新基向量共线,二维平面被压扁成了一条线。
建议先只动一个滑块,确认“矩阵的一列就是一根变换后的基向量”,再组合多个变化。
6. 缩放与旋转怎样写进矩阵
阅读矩阵公式前,先确认约定: 本章统一使用列向量,并把变换矩阵写在向量左侧。其他资料或引擎可能采用行向量,公式顺序会相反,但表达的几何变换可以完全相同。
本章的公式写作:
1 | 列向量 |
也就是:
1 | p' = M × p |
缩放
二维缩放:
1 | | sx 0 | |
作用于:
1 | | x | |
得到:
1 | | sx × x | |
旋转
二维逆时针旋转 θ:
1 | | cosθ -sinθ | |
当 θ = 90° 时:
1 | cosθ = 0 |
点 (1, 0) 变成:
1 | (0, 1) |
从基向量看,这个矩阵把:
1 | 原 X 轴 (1, 0) → (0, 1) |
所以所有由这两个基向量组合出的点都会一起旋转。
7. 为什么平移需要齐次坐标
普通 3 × 3 线性矩阵乘三维零向量,结果永远还是零:
1 | M × (0, 0, 0) = (0, 0, 0) |
但平移希望做到:
1 | (0, 0, 0) → (tx, ty, tz) |
这不是普通三维线性变换能直接表达的。
解决方法是给三维数据增加第四个分量:
1 | (x, y, z) |
这叫齐次坐标(Homogeneous Coordinates)。
通常约定:
1 | 点: (x, y, z, 1) |
在列向量约定下,平移矩阵可以写成:
1 | | 1 0 0 tx | |
点的第四个分量是 1,因此平移列会参与计算:
1 | | x | | x + tx | |
方向的第四个分量是 0,平移列乘以 0 后不会影响结果:
1 | | x | | x | |
所以第四个分量不只是"为了凑成 4 × 4":
它让同一个矩阵系统既能处理线性变换,也能处理平移,并区分点与方向。
后面透视投影还会再次使用这个 w。
8. 变换顺序为什么不能随便换
矩阵乘法通常不满足交换律:
1 | A × B ≠ B × A |
先旋转再平移,与先平移再旋转,结果不同。
假设点:
1 | P = (1, 0) |
操作是:
1 | 旋转 90° |
先旋转:
1 | (1, 0) |
先平移:
1 | (1, 0) |
这是两个完全不同的位置。
在本章采用的列向量约定下:
1 | p' = T × R × S × p |
表示对点依次执行:
1 | 先缩放 S |
因为离向量最近的右侧矩阵最先作用。
如果使用行向量约定,书写顺序会反过来。因此看到陌生代码时,先确认约定,不要只凭矩阵名字猜执行顺序。
9. Model Matrix:局部空间到世界空间
模型文件中的顶点通常保存在自己的局部空间,也叫:
1 | Local Space |
例如一个立方体以自身中心为原点:
1 | (-0.5, -0.5, -0.5) |
无论把立方体放到场景哪里,这套局部顶点都不必重写。
物体的缩放、旋转和平移组合成模型矩阵:
1 | M = T × R × S |
局部顶点变成世界顶点:
1 | p_world = M × p_local |
这允许许多物体共用一份 Mesh:
1 | 同一个立方体 Mesh |
几何数据相同,变换不同。
10. 父子层级怎样组合
如果剑是角色手的子节点,它的局部坐标先相对手,再相对角色,最后来到世界。
在列向量约定下:
1 | M_world_sword |
一个剑顶点:
1 | p_world |
从右向左看执行顺序:
1 | 剑局部 |
父节点移动,子节点的局部坐标可以完全不变,但最终世界矩阵会跟着改变。
这就是场景树变换传播的数学地基。
11. View Matrix:世界来到相机眼前
世界空间解决了"所有物体在同一个场景中在哪里",但屏幕画面还取决于相机。
相机也可以有一个世界变换:
1 | Camera World Transform |
它描述相机在世界中的位置和朝向。
渲染真正需要的是反过来回答:
站在相机的位置和朝向看,世界中的点位于相机前后左右哪里?
因此观察矩阵(View Matrix)是相机世界变换的逆:
1 | V = inverse(CameraWorld) |
世界顶点进入观察空间:
1 | p_view = V × p_world |
如果相机向右移动 5 个单位,从相机视角看,就像整个世界向左移动 5 个单位。
1 | 相机向右 |
所以 View Matrix 看起来总在做与相机自身变换相反的操作。它不是相机移动错了,而是在把世界改写成"以相机为原点"的坐标。
观察空间中,相机通常位于原点,朝固定轴向观察。至于朝 +z 还是 -z,取决于所采用的坐标约定。
12. 正交投影:距离不改变大小
相机空间仍然是三维的,最终屏幕却是二维的。投影要决定:
1 | 三维位置怎样映射到二维画面? |
正交投影(Orthographic Projection)的特点是:
1 | 平行线投影后仍然平行 |
直观上像从一个长方体观察区域直接压到屏幕:
1 | ┌─────────┐ |
它常用于:
- 2D 游戏。
- UI。
- 地图和编辑器视图。
- 不希望产生近大远小效果的画面。
正交投影仍然会处理可见范围和深度,只是不使用距离缩小物体。
13. 透视投影:为什么会近大远小
透视投影可以用针孔相机模型理解。
假设相机在原点并朝某个固定方向观察。一个点离中心轴越远,投到成像平面的位置也越远;但它距离相机越远,投影尺寸越小。
在一种常见的"相机看向 -z"约定下,可以先把水平投影关系写成:
1 | x_projected = f × x / (-z) |
其中:
x是点在相机空间的水平位置。-z是它到相机前方的距离。f与焦距或视野角有关。
两个宽度相同的物体:
1 | 近处:除以较小距离 → 屏幕上更大 |
这就是近大远小的数学来源:
透视感不是 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 | Model:局部 → 世界 |
15. 为什么先裁剪,再做透视除法
在裁剪空间中,可见区域可以通过与 w 比较来描述。常见形式类似:
1 | -w ≤ x ≤ w |
三角形如果跨过可见边界,管线可以在齐次坐标中求交并生成新的边界顶点。
为什么不先除以 w?
因为透视除法会引入非线性,尤其在:
1 | w = 0 |
这些位置附近直接处理会变得麻烦甚至发散。
保留齐次裁剪空间,可以先用线性边界判断并裁掉不可见部分,再对留下的顶点做除法。
所以顺序是:
1 | 投影得到裁剪空间 |
16. 透视除法:裁剪空间到 NDC
裁剪完成后,用 w_clip 除前三个分量:
1 | x_ndc = x_clip / w_clip |
得到标准化设备坐标:
1 | NDC:Normalized Device Coordinates |
NDC 把不同相机参数和视锥范围统一压到一个标准立方体。
常见情况下:
1 | x_ndc ∈ [-1, 1] |
z_ndc 的范围会因图形 API 约定不同而不同,常见有:
1 | [-1, 1] |
不要把某一种 API 的深度范围当作所有平台的唯一真相。
在透视投影中,w_clip 通常与相机空间深度相关。距离越远,除数越大,因此屏幕上的 x、y 越接近中心,物体看起来越小。
17. Viewport Transform:NDC 到屏幕像素
NDC 仍然不是屏幕像素。它只是在标准范围中的坐标。
假设视口宽高为:
1 | width |
如果屏幕 x 轴向右,并把 NDC [-1, 1] 映射到像素范围,可以写成:
1 | x_screen = (x_ndc + 1) / 2 × width |
于是:
1 | x_ndc = -1 → 屏幕左边 |
y 坐标的公式取决于屏幕原点约定:
1 | 原点在左下:y 通常向上增加 |
深度也会映射到深度缓冲使用的范围。
这一阶段叫视口变换(Viewport Transform):
1 | NDC 标准立方体 |
同一个 NDC 画面可以映射到全屏,也可以映射到屏幕角落的小视口。
18. 从局部顶点到屏幕的完整链
现在可以把一条顶点位置链完整写出来:
1 | p_local |
对应公式:
1 | p_clip = P × V × M × p_local |
顶点着色器最关键的职责之一,就是输出裁剪空间位置。后续固定管线才能据此执行裁剪、透视除法、光栅化和插值。
19. 用一个二维例子理解组合变换
假设局部点:
1 | P = (1, 0) |
物体依次:
1 | 沿 x 放大 2 倍 |
第一步缩放:
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 | 先把一连串变换合成一个矩阵 |
不必为每个顶点重新组织整条业务层级。
20. 左手系与右手系是什么
三维坐标系还要约定三个轴之间的方向关系。
可以用叉积判断:
1 | X × Y 得到 Z 的哪一个方向? |
按一种约定得到的轴关系称为右手系,另一种镜像关系称为左手系。
手性会影响:
- 正方向怎样解释。
- 叉积结果方向。
- 相机默认朝向约定。
- 三角形投影后的绕序。
- 模型在不同系统间导入时是否镜像。
但要注意:
左右手系、行向量/列向量、矩阵行主序/列主序,是三组不同概念。
它们经常一起出现,却不能互相直接推出。
一个系统完全可以:
- 使用右手坐标系。
- 使用列向量数学约定。
- 在内存中按列主序保存矩阵。
另一个系统也可能组合成别的选择,只要内部规则一致,几何结果都可以正确。
21. 行向量、列向量与内存布局不要混为一谈
行向量与列向量
这是公式怎样书写的问题。
列向量:
1 | p' = M × p |
行向量:
1 | p' = p × M |
同一串几何变换在两种约定中的矩阵形式和组合顺序会互相转置、反向。
行主序与列主序
这是矩阵元素怎样线性排进内存的问题:
1 | 先连续保存一整行 |
内存布局影响上传、读取和 API 交互,却不自动决定代码使用行向量还是列向量。
因此阅读引擎或 Shader 代码时应分别确认:
1 | 坐标系手性是什么? |
只看到数组排列,不能直接推断完整数学语义。
22. 为什么法线不能总按普通方向变换
法线不是普通表面方向,它还必须保持:
与变换后的切平面继续垂直。
如果模型只做旋转或均匀缩放,使用模型矩阵的方向部分通常仍能得到正确方向。
但非均匀缩放会破坏垂直关系。例如:
1 | x 方向缩放 2 倍 |
直接用同一个矩阵变换法线,结果可能不再垂直于变换后的表面。
为了保持:
1 | normal' · tangent' = 0 |
法线需要使用模型矩阵线性部分的逆转置矩阵:
1 | NormalMatrix = transpose(inverse(M_linear)) |
然后通常还要归一化:
1 | normal = normalize(normal); |
这里的 M_linear 一般指模型矩阵左上角负责旋转、缩放和错切的 3 × 3 部分,不包含平移。
所以:
1 | 位置:使用完整模型矩阵 |
三者不能因为都写成三个分量,就用完全相同的变换方式。
23. 负缩放为什么可能让面消失
假设某一轴缩放为负数:
1 | scaleX = -1 |
它会产生镜像。镜像不只改变位置,还可能翻转坐标系手性和三角形绕序:
1 | 原本正面为 CCW |
如果背面剔除仍按原规则判断,模型可能出现:
- 整体从外侧消失。
- 一部分面被错误剔除。
- 法线方向与几何正面不一致。
因此处理负缩放、镜像模型或跨坐标系导入时,要一起检查:
1 | 顶点位置 |
只把 x 坐标乘 -1,不一定完成了全部语义转换。
24. 把常见现象放回坐标链
| 现象 | 优先追问的方向 |
|---|---|
| 模型位置完全不对 | 当前数据属于局部、世界还是观察空间 |
| 子节点跟随异常 | 父子矩阵组合和乘法顺序 |
| 相机向右,世界却向左 | 这是 View Matrix 的逆变换,可能是正常现象 |
| 模型大小随距离变化异常 | 正交/透视投影、相机空间深度、投影参数 |
| 近处模型被切开 | 近裁剪面与裁剪空间 |
| 画面上下或深度颠倒 | NDC、视口原点、深度范围等 API 约定 |
| 模型镜像后消失 | 负缩放造成绕序翻转,触发背面剔除 |
| 非均匀缩放后光照错误 | 法线是否使用逆转置并重新归一化 |
| 矩阵看似正确但结果顺序相反 | 行/列向量约定和组合顺序 |
| 远离世界原点后抖动 | 浮点精度与巨大世界坐标 |
继续沿用同一套诊断方式:
1 | 先确认当前坐标属于哪一层 |
逻辑闭环 / 锚点
这一课的完整闭环是:
1 | 局部空间顶点 |
几个不能混淆的锚点:
- 坐标必须带空间语义:
(1, 2, 3)自己不能说明它相对谁。 - 点和方向不同:齐次坐标用
w = 1与w = 0让平移只影响点。 - 矩阵顺序有意义:缩放、旋转、平移不能任意交换。
- View 是相机世界变换的逆:它把整个世界改写到相机坐标系。
- Projection 不直接产生像素:它先产生裁剪空间,再裁剪、除以
w得到 NDC。 - NDC 仍不是屏幕:视口变换才把标准范围映射到具体像素区域。
- 手性、向量侧和内存布局不同:它们必须分别确认。
- 法线不是普通位置:非均匀缩放时要保持与表面垂直,需要逆转置。
下一篇要继续追的是:
顶点和矩阵已经准备好了,CPU 怎样把 Buffer、纹理、Shader 和渲染状态组织起来,并真正命令 GPU 画一次?
这会把我们带到 CPU/GPU 分工、图形 API、资源创建、状态绑定、Draw Call、命令缓冲、同步与流水线并行。
关联
- 和 顶点、三角形与插值 直接衔接:上一章定义顶点属性,这一章追清其中的位置和法线怎样进入后续管线。
- 和 分层 + 封装 是同一个母题:局部、世界、观察、裁剪、NDC 和屏幕不是重复坐标,而是每层为下一步准备的不同语义。
- 和 JavaScript 内存与生命周期 的边界思维相同:名字相似的数据可能属于不同层,结束或改变其中一层,不等于其他层自动同步完成。
来源:与 Codex 的对话,2026-07。