GAMES101学习笔记(01)-Transform:从三维世界到裁剪空间
三维世界如何呈现在二维屏幕上?
先来看一张照片。

这是一张再普通不过的风景照,照片里的世界看起来是三维的:近处的礁石、远处的海岸沙滩、山脉、一直延伸到视野尽头的大海,我们甚至能一眼估算出它们之间的大致距离。
但照片中的图像其实都在同一个平面里,照片里的远山其实并不比近处的礁石更远,它们最终都只是落在同一个二维平面上的不同像素。
那么,一个三维世界,是怎么变成一张二维图像的?
答案是 投影(Projection)。
按下快门的瞬间,光线透过镜头,最终落到相机的传感器上,于是眼前的风光就被定格成了一张照片。
现代三维引擎实际上也在做类似的事情。
场景中的物体由一个个三维坐标定义,经过 Model、View、Projection 一系列变换后,被转换到裁剪空间;随后再经过裁剪、透视除法、光栅化、着色和深度测试等处理,最终生成我们看到的一帧画面。
而当这套过程以每秒几十次甚至上百次的速度不断重复,我们看到的,就不再是一张静止的“照片”,而是一个可以实时交互的三维世界。
现实中的相机通过镜头完成从三维到二维的投影,而计算机没有镜头,它只能用数学来描述这个过程。
这套数学变换的核心,就是我们经常听到的 MVP 矩阵。
什么是MVP矩阵?
MVP 矩阵本质上就是一组变换矩阵,它由下面三个矩阵构成:
- Model Matrix(模型矩阵)
- View Matrix(视图矩阵)
- Projection Matrix(投影矩阵)
一个模型中的顶点,需要依次经过这三个矩阵的变换,经历模型空间 → 世界空间 → 观察空间 → 裁剪空间,最终才能为后续的透视除法、光栅化以及真正显示到屏幕上做好准备。

把这三个矩阵组合起来,就是完整的MVP矩阵,公式可以写为:
$$
MVP = M_{projection} \times M_{view} \times M_{model}
$$
现在我们知道了MVP矩阵,那么什么又是模型空间、世界空间、观察空间和裁剪空间呢?
模型空间
假设你在 Blender 里做了一个边长 2 米的立方体。
最自然的建模方式,是以它自己为中心建立坐标系:

如果原点在立方体中心,那么它的顶点可能是:
1 | (-1, -1, -1) |
这些坐标完全不关心:
“这个立方体以后会被放到游戏世界的哪里?”
它们只描述:
这些顶点相对于这个模型自己的原点在哪里。
这就是 Model Space / Local Space(模型空间 / 局部空间)。
在 Blender、Maya 等建模软件中创建模型时,模型的顶点通常都是在这样一套局部坐标系下定义的。模型自身的原点,以及 X、Y、Z 轴的方向,共同构成了我们理解这个模型位置和方向的参考系。
因此,一个汽车模型无论以后被放到城市的哪条道路上,它的顶点数据本身都不需要跟着改变。
世界空间
但只有模型自己的坐标还不够。
当我们把汽车、建筑、道路等多个模型放进同一个三维场景时,还需要一套大家共同遵守的坐标系,用来描述它们各自在整个场景中的不同位置。
这就是 World Space(世界空间)。
世界空间是当前三维场景中所有对象共同使用的一套坐标系。 不同引擎对于坐标轴方向的约定可能有所不同。例如,Three.js 默认使用右手坐标系,并以 Y 轴作为“上”方向;而 Unreal Engine(UE)采用左手坐标系,以 Z 轴作为“上”方向。
有了世界坐标系,接下来的问题就是:
一个原本定义在自己局部坐标系中的模型,应该怎样被放到世界中的指定位置?
这就是 Model Matrix(模型矩阵) 要解决的问题。
模型矩阵描述了模型相对于世界坐标系的变换,通常包含三个最基本的操作:
- 平移(Translation):模型放在哪里;
- 旋转(Rotation):模型朝向哪里;
- 缩放(Scale):模型有多大。
模型中的每一个顶点经过 Model Matrix 变换后,就从模型空间进入了世界空间,这一步完成的事情很简单:
将一个模型从自己的局部坐标系,转换到整个三维场景的世界坐标系中。

观察空间
那么观察空间又是什么呢?
想象一下,现在有一个模特站在场景中,我们想给他拍一张照片。只有模特还不够,还需要一个摄影师拿着相机,选择合适的位置和角度进行拍摄。
在三维渲染中也是一样。
观察空间(View Space / Camera Space),就是以相机为参考建立的一套坐标系。在这个坐标系中,相机位于原点,场景中的所有物体都不再描述“自己位于世界的哪里”,而是描述“自己位于相机的哪里”。
而 View Matrix(视图矩阵),就是负责把世界空间中的坐标转换到观察空间。
它由相机在世界中的位置和朝向决定。
例如,当摄影师向前走一步,从相机的视角来看,也可以理解为相机保持不动,而整个世界向后移动了一步;当摄影师转动相机,也可以理解为整个世界朝相反的方向旋转。
因此经过 View Matrix 变换之后,相机就成为了整个观察空间的原点,我们也终于从“世界怎么看这些物体”,切换到了“相机怎么看这些物体”。

裁剪空间
裁剪空间的理解比前面几个空间稍微复杂一些。
裁剪空间不像模型空间、世界空间和观察空间那样,可以直接想象成一个普通的三维坐标系。它使用四维齐次坐标 (x,y,z,w) 来表示顶点的位置,是为了方便 GPU 完成裁剪和后续的透视投影。
要理解裁剪空间,需要我们先搞清楚两个概念——视锥体(Viewing Frustum) 和 齐次坐标
先理解视锥体
真实的相机并不能装下整个世界,它只能拍到镜头前方的一部分空间。
如果你了解摄影,那你一定听说过广角和长焦。广角镜头能够容纳更大的视野,画面具有更强的透视感;长焦镜头的视野更窄,在相同主体构图下往往呈现出更明显的空间压缩感。
三维引擎中的相机也有类似的概念。
以透视相机(Perspective Camera)为例,通常通过下面几个参数定义相机能够看到的空间范围:
FOV(Field of View):视场角,决定相机的视野范围;Aspect:画面的宽高比;Near:近裁剪面,相机能够看到的最近距离;Far:远裁剪面,相机能够看到的最远距离。
其中 FOV 本身就是一个角度,可以简单理解为三维相机中的“镜头”。
FOV 越大,相机能够看到的范围越广,效果越接近广角镜头;FOV 越小,看到的范围越窄,效果越接近长焦镜头。
例如在 Three.js 的 PerspectiveCamera 中,FOV 表示垂直方向的视场角。
FOV、Aspect、Near 和 Far 共同围出了一块类似截头四棱锥形状的空间,这就是 Viewing Frustum(视锥体)。

只有位于这个视锥体范围内的物体,才有可能最终出现在我们的画面中。
为什么要引入齐次坐标
先思考一个问题:三维空间中的物体,要显示到二维屏幕上,同时还要具有正确的透视关系,也就是我们熟悉的近大远小,应该怎么实现?
其实原理很简单。 对于透视投影来说,一个物体距离相机越远,它最终投影到画面上的尺寸就应该越小。因此,透视投影后的坐标需要与物体的深度产生类似这样的关系:
$$
x’=\frac{x}{z}
\qquad
y’=\frac{y}{z}
$$
也就是说,透视投影的计算中包含了一次“除以深度”的操作。 但问题来了:普通的矩阵乘法擅长处理平移、旋转、缩放这样的变换,却不能直接完成这种除法。 于是图形学引入了齐次坐标(Homogeneous Coordinates)。 原本的三维坐标,会使用四个分量来表示: $ (x,y,z,w) $ 这里的 $w$ 并不是现实世界中的“第四个空间方向”,而是为了方便进行投影等变换而引入的数学分量。
回到裁剪空间
现在我们已经知道:
- 视锥体定义了相机能够看到哪一部分空间;
- 齐次坐标为透视投影和裁剪提供了一种方便的数学表示。
接下来再来看裁剪空间,就容易理解多了。
Projection Matrix 会把观察空间中的顶点变换到裁剪空间:
$$
P_{clip}=M_{projection}P_{view}
$$
也就是,观察空间中的三维坐标:
$$
(x,y,z)
$$
经过 Projection Matrix 后,得到裁剪空间中的四维齐次坐标:
$$
(x_c,y_c,z_c,w_c)
$$
经过投影矩阵变换后,原本由视锥体定义的可见范围,被转换成了一套统一的齐次坐标边界。
以 OpenGL / WebGL 为例,可见范围满足:
$$
-w_c \le x_c \le w_c
$$
$$
-w_c \le y_c \le w_c
$$
$$
-w_c \le z_c \le w_c
$$
GPU 可以根据这些边界判断一个图元位于视锥体内部、外部,还是与视锥体相交。
如果一个图元完全位于范围之外,它可以被直接丢弃;如果只有一部分超出范围,则会沿裁剪边界进行裁剪,只保留位于可见范围内的部分。
这就是 Clip Space(裁剪空间)存在的意义。
至此,一个三维模型中的顶点,已经完成了从自身坐标系到裁剪空间的转换。接下来 GPU 还需要进一步进行裁剪、透视除法、光栅化,才能真正生成屏幕上的像素。

