三维世界如何呈现在二维屏幕上?

先来看一张照片。

这是一张再普通不过的风景照,照片里的世界看起来是三维的:近处的礁石、远处的海岸沙滩、山脉、一直延伸到视野尽头的大海,我们甚至能一眼估算出它们之间的大致距离。

但照片中的图像其实都在同一个平面里,照片里的远山其实并不比近处的礁石更远,它们最终都只是落在同一个二维平面上的不同像素。

那么,一个三维世界,是怎么变成一张二维图像的?

答案是 投影(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
2
3
4
(-1, -1, -1)
( 1, -1, -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 还需要进一步进行裁剪、透视除法、光栅化,才能真正生成屏幕上的像素。