透视矩阵Perspective Matrix
所谓的透视矩阵,指的是一个“降维”的转换过程。设想下一个在3维空间里的3D模型,它必然拥有一些顶点信息,设其中任意顶点的坐标为(x,y,z,1)(后面的1是齐次坐标的意思),当我们需要把这个模型投影到某个平面上时,它就从3维变成了2维,而顶点坐标(x,y,z,1)则变成(x,y,d,?)。
可以注意到,经过透视变换后的顶点,依然是四维的形式,只是含义变了,其中的(x,y)分量指的是这个顶点在投影平面上的坐标(显然是因为投影平面相当于一个2维坐标系)。d指的是这个投影点的深度(depth),d一般是规范化的,范围是[-1,1]。d的作用在下一个渲染阶段(Depth Test)大有用处。而后面的?,无法一言蔽之,下文会讲到这个问题。
视锥体 Frustum
视锥体,指的是一个有限的椎体空间,处于这个视锥体里的对象,才是“可见”的对象,可见的对象会被渲染到“视平面”上(三维到二维的投影)。视锥体有4个参数:
- aspect ratio,简称ar,ar = 视平面width/视平面height
- (vertical)field of view,简称fov,指yz平面的视角大小,即下文的\alpha角。
- near Z Plane,简称near面,是一个平行于xy平面的面,世界坐标系下是一个浮点值,可以用来裁剪太靠近摄像机的物体
- far Z Plane,简称far面,含义类似near面,可以用来裁剪太远离摄像机的物体
视平面可以认为是视锥体的near面;far面相对来说并没有那么重要,因为我们知道人眼的“视锥体”是没有far面的(比如裸眼可以看到月亮星星,far面其实是无限远的),在图形学中,far面主要是用来裁剪太过遥远的物体、提高渲染效率的。
下面这个是我找到的一个视锥体的演示程序,非常直观地展示了视锥体的作用:演示程序来源
从摄像机位置(一个点)观察视平面的话,是长这样子的:
y轴范围是[-1,1],x轴范围是[-ar,ar],因为ar = 视平面width/视平面height,其实也就是ar=屏幕width/屏幕height,因为大部分屏幕都是宽屏,所以ar的值一般是大于1的。当屏幕宽高一致时,视平面才是上面这幅图的样子。
现在,换成侧视角来观察这个视锥体(yz平面):
红线是投影面(视平面),绿色线是摄像机到投影面的向量d,$\alpha$角即是fov。注意,OpenGL在“世界坐标系”中,用的是右手坐标系,所以上图中,z轴的左边才是1、右边是-1。因此,向量d的长度是-d(要取反,不然会计算错误)。综上,可以得出:
$tan(\frac {\alpha } { 2 } ) = \frac {1} {|\vec d|}$
$|\vec d| = -d = \frac {1} { tan(\frac {\alpha } { 2 } ) }$
接下来是求某顶点$(x,y,z,w)$在投影面上的投影坐标$(x_{p},y_{p},z_{p},w_{p})$。 看下面的侧视图,我们可以先求解$y_{p}$:
根据相似三角形定理,可以得到:
$\frac {y_{p} } { |\vec d| } = \frac { y } { -z }$
$y_{p} = \frac { y * |\vec d| } { -z } = \frac { y } { -z * tan(\frac {\alpha } { 2 } ) }$
注意,这里的z需要取反,因为上面的等式里,$y_{p}$和y同符号,$|\vec d|$是正号,而z显然本身是负值,所以z要取反。
同样的,x分量也可以用相同的公式求得:
$\frac {x_{p} } { |\vec d| } = \frac { x } { -z }$
$$x_{p} = \frac { x * |\vec d| } { -z } = \frac { x } { -z * tan(\frac {\alpha } { 2 } ) }$$
此时要考虑到一个问题:$ y_{p}$的范围是[-1,1],而$x_{p}$是$[-ar, ar]$。为了让$x_{p}$和$y_{p}$一致,需要让$x_{p}$除以ar,从而得到:
$x_{p} = \frac { x } { -z * ar * tan(\frac {\alpha } { 2 } ) }$
$y_{p} = \frac { y } { -z * tan(\frac {\alpha } { 2 } ) }$
到了这里,我们可以开始构造下透视矩阵了:
$$Perspective Matrix = M = \left[ \begin{matrix} a&b&c&d\ e&f&g&h\ i&j&k&l\ m&n&o&p\ \end{matrix} \right]$$
被转换的顶点的坐标(矩阵)是:$V = \left[ \begin{matrix} x\ y\ z\ w\ \end{matrix} \right]$
转换后的投影点是:$V_{p} = \left[ \begin{matrix} x_{p} \ y_{p} \ z_{p} \ w_{p} \ \end{matrix} \right]$
转换过程:
$MV = V_{p}$
$\left[ \begin{matrix} a&b&c&d\ e&f&g&h\ i&j&k&l\ m&n&o&p\ \end{matrix} \right] \left[ \begin{matrix} x\ y\ z\ w\ \end{matrix} \right] = \left[ \begin{matrix} x_{p} \ y_{p} \ z_{p} \ w_{p} \ \end{matrix} \right]$
从以上等式可以得到:
$ax + by + cz + dw = x_{p} = \frac { x } { -z * ar * tan(\frac {\alpha } { 2 } ) }$
这是M矩阵第一行和V的点积等式。求解这个等式的话,会发现可以让b=0、d=0,从而等式简化成:
$ax + cz = \frac { x } { -z * ar * tan(\frac {\alpha } { 2 } ) }$
这样做后就有了个问题:找不到可以代入a、c的常量值。其中左边比较多余的cz项,如果可以干掉的话,意味着c必须等于0。这么做后,等式进而变成:
$ax = \frac { x } { -z * ar * tan(\frac {\alpha } { 2 } ) }$
观察等式,可以发现等式右边有个多余的z。OpenGL中对这个问题的处理是,在变换过程中强(偷)制(偷)插入一个步骤:把矩阵相乘的结果值再统一除以-z。对,没错,确实是-z而不是z,负号的作用是把坐标从右手坐标系转换到左手坐标系,原因是NDC(Normalized Device Coord)坐标系是左手坐标系,即NDC的z轴的正方向是朝向屏幕里面的。这个除以-z的技巧被称为Perspective Divide。
这么做之后,事情就简单了,上面的等式可以推出:
$a = \frac { 1 } { ar * tan(\frac {\alpha } { 2 } ) }$
对于M矩阵的f,用同样的做法可以得到:
$f = \frac { 1 } { tan(\frac {\alpha } { 2 } ) }$
从而得到了M的前两行的值:
$M = \left[ \begin{matrix} \frac { 1 } { ar * tan(\frac {\alpha } { 2 } ) }&0&0&0\ 0&\frac { 1 } { tan(\frac {\alpha } { 2 } ) }&0&0\ i&j&k&l\ m&n&o&p\ \end{matrix} \right]$
到了这里,其实透视变换问题已经解决大半了,因为$x_{p}$和$y_{p}$都可以算了,并且可以规范化到[-1,1]范围。剩下的问题是$z_{p}$,即顶点的深度信息。
前面提到的Perspective Divide会导致一个问题:z分量在转换过程中会因为Perspective Divide而导致变成-1。针对这个问题,OpenGL的解决方案是,把V的z值取反并复制覆盖到w上,从而把原始z值保存起来(也就是M矩阵的第四行所负责的事情),同时Perspective Divide仅对x、y、z有效(跳过w)。
因此,M的后两行也可以得到了:
$M = \left[ \begin{matrix} \frac { 1 } { ar * tan(\frac {\alpha } { 2 } ) }&0&0&0\ 0&\frac { 1 } { tan(\frac {\alpha } { 2 } ) }&0&0\ 0&0&0&0\ 0&0&-1&0\ \end{matrix} \right]$
然而,事情还没有结束。现在用这个新的M去做透视变换后,得不到规范化的z分量。规范化的,可以使得后续的渲染步骤不需要知道near Z和far Z。为了完成这个事情,需要对M做改进,着手点就是row 3,全为0的第三行。
再阐述一下问题:我们需要求出row3=(i,j,k,l),使得row3和V做点积运算能得到规范化的$z_{p}$。用公式表示:
$z_{p} = Az + B , z_{p}\in [-1,1]$
再考虑上Perspective Divide,上式变成:
$z_{p} = A + \frac {B}{-z} , z_{p}\in [-1,1]$
思路非常明确了:把公式中的A、B求出来,代入row3,就能解决问题。
因为当z等于near Z时,z_{p}必然等于1;当z等于far Z时,z_{p}必然等于-1 (Note:这里用的是右手坐标系)。因此得到:
$A + \frac {B}{-NearZ} = 1$
$A = 1 - \frac {B}{-NearZ} = 1 + \frac {B}{NearZ}$
接着:
$A + \frac {B}{-FarZ} = -1$
$1 + \frac {B}{NearZ} - \frac {B}{FarZ} = -1$
$\frac {BFarZ - BNearZ}{NearZ*FarZ} = -2$
$B = \frac {-2NearZFarZ}{FarZ - NearZ} = \frac {2NearZFarZ}{NearZ - FarZ}$
B解决了,求A:
$A = 1 + \frac {B}{NearZ} = 1 + \frac {2FarZNearZ}{NearZ*(NearZ - FarZ)}$
$A = 1 + \frac {2*FarZ}{NearZ - FarZ}$
$A =\frac {NearZ - FarZ + 2*FarZ}{NearZ - FarZ}$
$A = \frac {NearZ + FarZ}{NearZ - FarZ}$
有了A、B后,就可以求row3了:
$ix +jy +kz +lw = Az + B$
显然,可让i = j = 0,那么上式变成:
$kz + lw = Az + B$
因为V的w分量必然是1,所以可以得知:k = A,l = B。
代入M,得到最终完善的M:
$M = \left[ \begin{matrix} \frac { 1 } { ar * tan(\frac {\alpha } { 2 } ) }&0&0&0\ 0&\frac { 1 } { tan(\frac {\alpha } { 2 } ) }&0&0\ 0&0&\frac {NearZ + FarZ}{NearZ - FarZ}&\frac {2FarZNearZ}{NearZ - FarZ}\ 0&0&-1&0\ \end{matrix} \right]$
再对比下superbible7中构造透视矩阵的代码:
1 | static inline mat4 perspective(float fovy, float aspect, float n, float f) |
仔细观察,发现有1处不同:这个函数构造的矩阵是列主导的矩阵。其中元素的取值和本文的推导完全一致!
本文部分内容翻译自:Tutorial 12: Perspective Projection
本文介绍的是OpenGL中的透视矩阵。