Skip to content

变换(模型、视图、投影)

在上一章中,我们已经掌握了如何对三维物体进行平移、旋转和缩放。但在真正的 3D 游戏中,模型并不是孤立存在的——它们处在一个庞大的世界中,并且需要通过一个“虚拟摄像机”的镜头,最终投影到你那二维的平面显示器上。

完成这一整套将三维坐标“拍扁”到屏幕上的数学管线,就是图形学中大名鼎鼎的 MVP 变换 (Model, View, Projection)

当我们在渲染管线中处理一个顶点向量 v\mathbf{v} 时,它通常会依次经历以下几个空间(Space)的跃迁:

  1. 模型空间 (Local/Model Space):模型刚被美术在建模软件中画出来时的原始坐标。
  2. 世界空间 (World Space):应用了模型矩阵 (Model Matrix, M\mathbf{M}) 后,模型被摆放到了游戏世界的绝对坐标系中。这部分就是我们在上一章学习的 TRS 组合变换。
  3. 观察空间 (View Space / Camera Space):应用了视图矩阵 (View Matrix, V\mathbf{V}) 后,世界坐标系被重新计算,使得摄像机永远位于坐标原点,并看向 Z-Z 轴方向。
  4. 裁剪空间 (Clip Space):应用了投影矩阵 (Projection Matrix, P\mathbf{P}) 后,三维场景被挤压进一个标准的规范化立方体中(准备被裁剪并输出到二维屏幕)。

所以,顶点在 Vertex Shader(顶点着色器)中执行的最核心代码永远是:

vclip=PVMvlocal\mathbf{v}_{clip} = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot \mathbf{v}_{local}

(注意:矩阵乘法从右向左结合,所以先执行 M\mathbf{M},再执行 V\mathbf{V},最后执行 P\mathbf{P}。)


在真实世界中,你想要拍一张照片,你需要移动相机去对准目标。但在计算机图形学中,要让算法变得简单,我们通常不移动相机,而是移动整个世界

“把相机向前推 5 米” 和 “把整个世界向后拉 5 米”,在相机的取景器里看起来是完全等价的。这就是视图变换的核心思想。 我们要找出一个视图矩阵 V\mathbf{V},将原本散落在世界各地的物体,统统移动到一个新的坐标系中。在这个新坐标系里:

  • 相机永远处于绝对原点 (0,0,0)(0,0,0)
  • 相机永远看着 Z-Z 轴方向。
  • 相机的正上方永远是 YY 轴方向。

你可以通过下面的交互组件来直观体验这种“相对运动”:

上帝视角 (World Space)
摄像机视角 (View Space)
观察右侧的“摄像机视角”——摄像机向前移动(Z减小),等价于整个世界向后倒退;摄像机向左转(Y正向),等价于世界向右转。

要计算 V\mathbf{V},我们首先需要知道相机的状态。通常用三个向量系来定义相机:

  • 位置 e\vec{e} (Eye / Position)
  • 观察方向 g\vec{g} (Gaze / LookAt direction)
  • 向上方向 t\vec{t} (Top / Up direction)

我们要做的,就是把这台相机通过变换,移回原点并对齐标准轴。这分为两步:

  1. 平移 Tview\mathbf{T}_{view}:把相机的坐标 e\vec{e} 平移回原点。
  2. 旋转 Rview\mathbf{R}_{view}:把 g\vec{g} 旋转对齐到 Z-Z 轴,t\vec{t} 旋转对齐到 YY 轴,g×t\vec{g} \times \vec{t} 旋转对齐到 XX 轴。

平移矩阵很容易写出(就是取位置的反方向):

Tview=[100xe010ye001ze0001]\mathbf{T}_{view} = \begin{bmatrix} 1 & 0 & 0 & -x_e \\ 0 & 1 & 0 & -y_e \\ 0 & 0 & 1 & -z_e \\ 0 & 0 & 0 & 1 \end{bmatrix}

但是计算直接将特定向量对齐到坐标轴的旋转矩阵很难。这里图形学采用了一种方法:逆向思维。 可以写出“把原点坐标轴旋转到相机的 g,t\vec{g}, \vec{t} 方向”的矩阵 Rcam\mathbf{R}_{cam}(只需把向量填入矩阵的列中即可)。

而所需的 Rview\mathbf{R}_{view} 即为 Rcam\mathbf{R}_{cam} 的逆矩阵。

Rview=Rcam1\mathbf{R}_{view} = \mathbf{R}_{cam}^{-1}

这意味着,我们连矩阵求逆都不需要算,只需把 Rcam\mathbf{R}_{cam} 按对角线翻转一下,就能得到我们想要的 Rview\mathbf{R}_{view}。最后,将旋转与平移结合,就得到了完整的视图矩阵:

V=RviewTview\mathbf{V} = \mathbf{R}_{view} \cdot \mathbf{T}_{view}

3. 投影变换 (Projection Transformation)

Section titled “3. 投影变换 (Projection Transformation)”

经过模型变换和视图变换后,所有的三维物体都已经整整齐齐地摆在相机的镜头前了。接下来的最后一步,就是将这些三维坐标“投影”出来。 投影分为两大类:正交投影 (Orthographic)透视投影 (Perspective)

正交投影没有“近大远小”的透视现象,相机的视域是一个标准的长方体 (Cuboid)。这在工程制图、等距视角(Isometric)游戏(如早期的《模拟城市》)中较为常用。

我们通过定义六个面来确定这个长方体视域:

  • 左右:ll (left), rr (right)
  • 上下:bb (bottom), tt (top)
  • 远近:ff (far), nn (near)

正交投影矩阵 Portho\mathbf{P}_{ortho} 的任务是:把这个空间中长方体视域,平移并缩放成一个范围是 [1,1]3[-1, 1]^3规范立方体 (Canonical View Volume)。一旦进入了这个规范立方体,显卡就能将它们光栅化到屏幕上了。

Portho=[2rl00002tb00002nf00001][100r+l2010t+b2001n+f20001]\mathbf{P}_{ortho} = \begin{bmatrix} \frac{2}{r-l} & 0 & 0 & 0 \\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{2}{n-f} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & -\frac{r+l}{2} \\ 0 & 1 & 0 & -\frac{t+b}{2} \\ 0 & 0 & 1 & -\frac{n+f}{2} \\ 0 & 0 & 0 & 1 \end{bmatrix}

你可以通过下方的交互组件,亲自拖动滑块,观察一个处于任意位置的 5×3×65 \times 3 \times 6 的长方体视域(包含里面的红色球体和绿色圆锥),是如何被“压缩并平移”到位于原点的 2×2×22 \times 2 \times 2 规范立方体中的:

注意观察:正交投影没有透视收缩效果,仅包含线性缩放和平移。

这才是图形学中的重头戏。人眼和真实世界的照相机都遵循透视投影:近大远小。 透视相机的视域不再是一个长方体,而是一个被切掉尖端的金字塔,我们称之为视锥体 (Frustum)

图形学界为了计算高效,并没有发明一套全新的体系来处理视锥体,而是采用了一种数学转换:先将视锥体“挤压”成正交长方体,随后直接复用已有的正交投影矩阵。

注意观察内部物体:离摄像机越远(-Z 越大)的物体,在挤压过程中被缩小得越厉害(透视收缩),且其 Z 轴位置发生了非线性偏移(被向近平面推挤)。
  1. 相似三角形映射 X 与 Y 观察上方交互图。假设视锥体内的任意一点为 (x,y,z)(x, y, z)。根据相似三角形原理,如果要把它“压”到与近平面(z=nz = n)相同的尺寸,它的新坐标 xx'yy' 将会被缩小: x=nzx,y=nzyx' = \frac{n}{z} x \quad , \quad y' = \frac{n}{z} y (注:因为 zznn 均为负数,相除后得到一个正的缩放系数。越远的物体其 zz 绝对值越大,对应的缩放系数越小,从而产生透视收缩效果。)

  2. 利用齐次坐标强行除以 Z 我们需要在矩阵运算中实现“除以 zz”的操作,但这在普通的线性乘法中无法直接实现。此时,我们可以借助齐次坐标的特性来完成这一步。 我们规定一个挤压矩阵 Mpersportho\mathbf{M}_{persp \to ortho},将第三行的 ZZ 信息转移到第四维的 WW 上: [n0000n00????0010][xyz1]=[nxnyunknownz]\begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & 1 & 0 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} nx \\ ny \\ \text{unknown} \\ z \end{bmatrix} 当转化为三维坐标(整体除以 WW,即除以 zz)时,前两维便转换为 nxz\frac{nx}{z}nyz\frac{ny}{z}

  3. 解密神秘的第三行(求解 Z) 对于挤压前后的 ZZ 坐标,我们有两条明确的几何约束:

    • 近平面(z=nz=n)上的任何点,挤压后 ZZ 坐标不能变。
    • 远平面(z=fz=f)上的任何中心点,挤压后 ZZ 坐标不能变。 通过解这两个约束条件构成的二元一次方程组,我们可以推导出矩阵的第三行为 (0,0,n+f,nf)(0, 0, n+f, -nf)
  4. 最终的透视投影矩阵 结合了上述所有推导,完整的透视投影矩阵其实就是:先进行视锥体挤压,再执行标准的正交投影。 首先,我们终于可以写出那个名震图形学界的“视锥体挤压矩阵”了:

    Mpersportho=[n0000n0000n+fnf0010]\mathbf{M}_{persp \to ortho} = \begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0 & 0 & 1 & 0 \end{bmatrix}

    将它与之前的正交投影矩阵相乘,我们就得到了终极的透视投影矩阵(通常在代码里直接调用这个结果): Ppersp=PorthoMpersportho\mathbf{P}_{persp} = \mathbf{P}_{ortho} \cdot \mathbf{M}_{persp \to ortho}

定义视锥体:FOV 与宽高比 (Aspect Ratio)

Section titled “定义视锥体:FOV 与宽高比 (Aspect Ratio)”

在实际使用游戏引擎(如 Unity 或 Unreal)时,较少需要手动填写 l,r,b,tl, r, b, t 这四个边界值。大多数时候,主要设置两个参数:

  1. 视野 (Field of View, FOV):通常指垂直视野角度(fovY)。这就像换摄像机镜头,长焦镜头的 FOV 小,广角镜头的 FOV 大。
  2. 宽高比 (Aspect Ratio):屏幕的宽度除以高度,比如常见的 16:9(即 1.777...)。

有了这两个直观的参数,加上给定的近平面距离 n|n|,我们可以通过简单的三角函数瞬间反推出 ttrr(假设视域是对称的):

t=ntan(fovY2)t = |n| \cdot \tan\left(\frac{fovY}{2}\right) r=tAspect Ratior = t \cdot \text{Aspect Ratio}

有了上下左右的边界,就可以直接套用上面推导的 Ppersp\mathbf{P}_{persp} 矩阵了。这正是现代 3D 引擎底层每天都在狂飙计算的代码逻辑。


当顶点经过了 MVP 矩阵的变换后,它们的坐标均被映射到了一个 X,Y,ZX, Y, Z 均在 [1,1][-1, 1] 范围内的规范立方体中。 但别忘了,你的显示器可不是 [1,1][-1, 1] 这么小的方块,它可能是一块 1920×10801920 \times 1080 分辨率的屏幕。

渲染管线的最后临门一脚,就是视口变换。 它的任务是忽略深度的 ZZ 坐标(后续留给 Z-Buffer 处理),仅仅把 XXYY 组成的 [1,1]2[-1, 1]^2 的正方形,贴合拉伸到屏幕物理像素的矩形框内:[0,width]×[0,height][0, \text{width}] \times [0, \text{height}]

由于这只是一个从中心在原点、边长为 2 的正方形,变换到中心在 (width2,height2)(\frac{width}{2}, \frac{height}{2})、宽高为 width,heightwidth, height 的矩形的简单过程,它也是一个基础的缩放加平移矩阵:

Mviewport=[width200width20height20height200100001]\mathbf{M}_{viewport} = \begin{bmatrix} \frac{width}{2} & 0 & 0 & \frac{width}{2} \\ 0 & \frac{height}{2} & 0 & \frac{height}{2} \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

经过此步骤,三维坐标被转换为屏幕像素坐标,进入后续的光栅化 (Rasterization) 阶段。