Deep Read

【空间变换】投影变换

经过 【空间变换】观察变换【空间变换】观察变换经过 [[【空间变换】基本变换]] 的模型变换,物体已经摆放在世界空间里。接下来要换个视角看世界——把相机放到合适的位置和角度,这一步叫观察变换(View Transformation),也叫视图变换。 打个拍照的比方:先把要拍的东西摆放好,这是模型变换;再挑一个角度架好相机,这是观察变换;最后按下快门把三维场景拍成二维照片,那是后面的 [[【空间变换】投影变换]]。 观察空间 观察空间(View Space)就是以相机原点为坐标原点建立起来的坐标系。它的三个基向量在世界空间里的取值,描述的正是相机的朝向。 按照惯例,观察空间是右手坐标系,相机看向 $-Z$ 方向。也就是说,相机的视线方向对应的是观察空间的 $-Z$ 轴。如果记相机位置为 $ViewPos$、注视目标为 $ViewTarget$,那么视线方向(gaze)指向目标,而观察空间的 $+Z$ 轴与视线相反: $$Z_{view} = -(ViewTarget - ViewPos) = ViewPos - ViewTarget$$ 要唯一确定一个相机,需要三个量: 1. 相机的位置。 2. 相机看向的方向(gaz,顶点已经来到以相机为原点的观察空间。投影变换要做的,是把观察空间里相机能看到的那块区域,映射到一个规整的立方体里,方便后续裁剪。这一步的矩阵叫裁剪矩阵,也叫投影矩阵。本篇沿用 Games101 的思路推导。

先把结论列在前面,推完再回看会更清楚:

  • 把顶点变换到裁剪空间的目的,是方便做裁剪
  • 正交相机的裁剪空间,就是一个标准立方体(CVV)。
  • 推导透视投影时,我们会乘上一个观察空间的 zz 值(zviewz_{view}),这个 zz 最终成为裁剪空间的 ww 分量
  • 透视相机的裁剪,是判断坐标 (x,y,z)(x, y, z) 是否落在 (w,w)(-w, w) 之内。

裁剪空间

顶点从观察空间变换到裁剪空间(Clip Space,也叫齐次裁剪空间),用的就是投影矩阵。之所以要这块空间,是为了方便对图元做裁剪:

  • 完全落在这块空间内部的图元,保留;
  • 完全落在外部的,剔除;
  • 与边界相交的,裁剪掉超出的部分。

这块空间由视锥体(View Frustum) 决定——它是空间中一块由六个裁剪平面(Clip Planes)围成的区域,界定了相机能看到的范围。视锥体分两类,对应两种投影:

  • 正交投影(Orthographic Projection)
  • 透视投影(Perspective Projection)

正交投影

正交投影下,相机在原点看向 Z-Z、顶部朝 +Y+Y。它的做法是把视锥体(一个长方体)里的物体,平移并缩放到 [1,1]3[-1, 1]^3 的标准立方体中。分两步:

  • 先把长方体中心平移到原点;
  • 再缩放到 [1,1]3[-1, 1]^3 的标准立方体。

image.png

Mortho=[2rl00002tb00002nf00001][100r+l2010t+b2001n+f20001]M_{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}

右边是平移矩阵,左边是缩放矩阵。

平移量取的是每根轴的中心坐标,取负号是因为把任意点挪回原点都是负向平移。缩放量则是用标准长度 22(即 [1,1][-1, 1] 的跨度)去除以物体在每根轴上的实际长度。要注意由于相机看向 Z-Z,近平面 nn 比远平面 ff 更大。

更细致的缩放推导、以及 OpenGL 与 DirectX 平台在 zz 值范围上的差异,见 正交投影矩阵推导过程正交投影矩阵推导过程这篇是 [[【空间变换】投影变换]] 里正交投影缩放矩阵的详细推导。核心问题很简单:观察空间里一段区间 $[l, r]$,要线性映射到裁剪空间的 $[-1, 1]$,这个缩放系数怎么来。$x$、$y$、$z$ 三个方向各推一遍,其中 $z$ 还要区分 OpenGL 和 DirectX 的平台差异。 缩放矩阵的目标 image.png 要找一个矩阵,把观察空间坐标 $(x_{view}, y_{view}, z_{view}, 1)$ 变换到裁剪空间 $(x_p, y_p, z_p, 1)$: $$ \begin{bmatrix} ? & ? & ? & ? \\ ? & ? & ? & ? \\ ? & ? & ? & ? \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x_{view} \\ y_{view} \\ z_{view} \\ 1 \end{bmatrix} = (x_p,\ y_p,\ z_p,\ 1) $$ 已知 $x$ 在相机空间的范围是 $(l, r)$,变换后要落到裁剪空间的 $(-1, 1)$。下面用一串

透视投影

透视投影要还原"近大远小",它的视锥体是一个上宽下窄的锥台。核心技巧是分两步:

  1. 先把视锥体"挤"成一个长方体;
  2. 再对这个长方体做上面的正交投影。

image.png

在"挤压"之前,先约定三条不变量,它们是后面解方程的依据:

  • 近平面上所有点的坐标不变;
  • 远平面上所有点的 zz 值不变,都是 ff
  • 远平面中心点的坐标不变,为 (0,0,f)(0, 0, f)

推导挤压矩阵

image.png

取视锥体内任意一点 (x,y,z)(x, y, z),挤压后它的 xyxy 坐标应当与它在近平面上的投影点重合。由相似三角形可得:

y=nzy,x=nzxy' = \frac{n}{z} y, \qquad x' = \frac{n}{z} x

所以点 (x,y,z,1)(x, y, z, 1) 挤压后的坐标是 (nzx, nzy, ?, 1)\left(\frac{n}{z}x,\ \frac{n}{z}y,\ ?,\ 1\right)zz 分量暂时未知:

(xviewyviewzview1)(nx/zny/zunknown1)\begin{pmatrix} x_{view} \\ y_{view} \\ z_{view} \\ 1 \end{pmatrix} \Rightarrow \begin{pmatrix} nx/z \\ ny/z \\ \text{unknown} \\ 1 \end{pmatrix}

现在要找一个矩阵 MpersporthoM_{persp \to ortho},让上面这个变换成立。

难点在哪: 假设矩阵第一行是 A,B,C,DA, B, C, D,那么要满足 Ax+By+Cz+D=nx/zAx + By + Cz + D = nx/z。这个等式很难凑——若让 A=n/zA = n/z、其余为 00 固然成立,但矩阵元素必须是常数,n/zn/z 却是个变量。第二行 Ex+Fy+Gz+H=ny/zEx + Fy + Gz + H = ny/z 也是同样的麻烦。

破解办法,用的正是 【空间变换】齐次坐标【空间变换】齐次坐标    空间变换这一系列笔记,讲的是一个顶点从建模软件里的局部坐标,一路变换到屏幕像素的完整过程,也就是渲染管线里常说的 MVP 变换加上后续的屏幕映射。而这一切的数学地基,是齐次坐标。所以先从它讲起。 为什么需要齐次坐标 顶点变换无非三种:平移、旋转、缩放。旋转和缩放都能写成一个矩阵乘以坐标: $$P' = M \cdot P$$ 但平移不行。在三维笛卡尔坐标下,平移是把每个分量各加一个偏移量: $$(x, y, z) \rightarrow (x + t_x,\ y + t_y,\ z + t_z)$$ 这是一次加法,没法用一个 $3\times3$ 矩阵乘出来。于是就出现了一个别扭的局面:旋转缩放是乘法,平移是加法,两类操作凑不到一起。如果一个顶点既要旋转又要平移,就得写成 $P' = M \cdot P + t$,既有乘又有加。当变换一多,这种混合写法会变得非常难组合。 齐次坐标就是为了解决这件事:给坐标加上第四个分量 $w$,把平移也塞进矩阵里,让平移、旋转、缩放全都变成统一的矩阵乘法。 一旦所有变换都是矩阵,它们就能通过矩阵连乘预先合并成一个矩阵 里那条性质:(x,y,z,1)(x, y, z, 1)(kx,ky,kz,k)(kx, ky, kz, k)k0k \neq 0)表示同一个点。于是让 k=zviewk = z_{view},把目标坐标 (nx/z, ny/z, unknown, 1)(nx/z,\ ny/z,\ \text{unknown},\ 1) 整体乘以 zz,变成 (nx, ny, still unknown, z)(nx,\ ny,\ \text{still unknown},\ z)——分母上的 zz 就被消掉了:

Mpersportho(4×4)(xviewyviewzview1)=(nxny?zviewzview)M_{persp \to ortho}^{(4\times4)} \begin{pmatrix} x_{view} \\ y_{view} \\ z_{view} \\ 1 \end{pmatrix} = \begin{pmatrix} nx \\ ny \\ ?\,z_{view} \\ z_{view} \end{pmatrix}

对照左右两边,矩阵的第一、二、四行已经可以填出来,只剩第三行未知:

Mpersportho=(n0000n00????0010)M_{persp \to ortho} = \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & 1 & 0 \end{pmatrix}

即:

(n0000n00????0010)(xviewyviewzview1)=(nxny?zviewzview)\begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & 1 & 0 \end{pmatrix} \begin{pmatrix} x_{view} \\ y_{view} \\ z_{view} \\ 1 \end{pmatrix} = \begin{pmatrix} nx \\ ny \\ ?\,z_{view} \\ z_{view} \end{pmatrix}

求第三行

先用约定近平面上所有点坐标不变。近平面上的点形如 (x,y,n,1)(x, y, n, 1),代入应得回自身:

(n0000n00????0010)(xyn1)=(xyn1)\begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & 1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \\ n \\ 1 \end{pmatrix} = \begin{pmatrix} x \\ y \\ n \\ 1 \end{pmatrix}

看第一、二、四行:

{nx=xny=yn=1\begin{cases} nx = x \\ ny = y \\ n = 1 \end{cases}

这显然有问题,只有当 n=1n = 1 时才成立,而 nn 应是任意常数。解决办法还是老套路——把等号右边的 (x,y,n,1)(x, y, n, 1) 整体乘上 nn,等价地写成 (nx,ny,n2,n)(nx, ny, n^2, n)

(n0000n00????0010)(xyn1)=(nxnyn2n)\begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & 1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \\ n \\ 1 \end{pmatrix} = \begin{pmatrix} nx \\ ny \\ n^2 \\ n \end{pmatrix}

现在第一、二、四行都对上了:

{nx=nxny=nyn=n\begin{cases} nx = nx \\ ny = ny \\ n = n \end{cases}

设第三行为 A,B,C,DA, B, C, D,则由第三行得到 Ax+By+Cn+D=n2Ax + By + Cn + D = n^2。由于结果 n2n^2x,yx, y 无关,可知 A=0A = 0B=0B = 0,剩下 Cn+D=n2Cn + D = n^2

再用约定远平面中心点 (0,0,f)(0, 0, f) 不变。这个点齐次化为 (0,0,f,1)(0, 0, f, 1),同样整体乘 ff,第三行应得 f2f^2

(n0000n0000CD0010)(00f1)=(00f2f)\begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & C & D \\ 0 & 0 & 1 & 0 \end{pmatrix} \begin{pmatrix} 0 \\ 0 \\ f \\ 1 \end{pmatrix} = \begin{pmatrix} 0 \\ 0 \\ f^2 \\ f \end{pmatrix}

联立近、远平面两个方程:

{Cn+D=n2Cf+D=f2    {C=n+fD=nf\begin{cases} Cn + D = n^2 \\ Cf + D = f^2 \end{cases} \implies \begin{cases} C = n + f \\ D = -nf \end{cases}

于是挤压矩阵 MpersporthoM_{persp \to ortho} 为:

Mpersportho=(n0000n0000n+fnf0010)M_{persp \to ortho} = \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0 & 0 & 1 & 0 \end{pmatrix}

它把透视投影的锥台压成了正交投影的长方体。

合并

把压好的长方体再乘上正交投影矩阵,就得到完整的透视投影矩阵:

Mpersp=[2rl00002tb00002nf00001][100r+l2010t+b2001n+f20001][n0000n0000n+fnf0010]M_{persp} = \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} \begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0 & 0 & 1 & 0 \end{bmatrix}

经过投影矩阵后,顶点落在裁剪空间里,ww 分量保留着观察空间的 zz 信息。接下来交给硬件做透视除法和视口变换,见 【空间变换】屏幕映射(Screen Mapping)【空间变换】屏幕映射(Screen Mapping)经过 [[【空间变换】投影变换]],顶点已经在裁剪空间里了。屏幕映射是这条管线的最后一站,负责把裁剪空间的顶点落到屏幕上具体的像素位置。它包含两步: - 透视除法(Perspective Division) - 视口变换(Viewport Transform) 透视除法 image.png 上图是顶点数据的完整变换流程。局部空间的顶点,依次经过模型矩阵 Model、观察矩阵 View、投影矩阵 Projection,来到裁剪空间,此时满足 $-w_{clip} \leq x, y, z \leq w_{clip}$。接着做透视除法——每个分量都除以 $w$ 分量——就得到了标准设备坐标(NDC),它所在的空间也叫标准视体(Canonical View Volume,CVV)。 为什么这一步能实现近大远小? 关键在 $w$。回顾 [[【空间变换】投影变换]] 的推导,投影矩阵刻意把观察空间的 $z$ 坐标塞进了裁剪空间的 $w$ 分量。于是透视除法(除以 $w$,等于除以 $z$)就让离相机越远、$z$ 越大的顶点,坐标被压缩得越狠——这正是透视效果的来源。这也回扣了 [[【空

参考