本文已参与「新人创作礼」活动,一起开启掘金创作之路。
前言
之前推导过OpenGL的投影矩阵,学了GAMES101之后,发现老师的推导方式很有意思,且GAMES101的坐标系约定和OpenGL不一样。最近在填新坑URasterizer 的过程中,发现了一些问题,比如透视投影在clip space做裁剪时为啥w必须取反,以及之前GAMES101作业中做深度测试时为啥z值要取反的问题,因此重新推导GAMES101的投影矩阵并分析一下。由于GAMES101的推导思路很有趣,因此整个过程会轻松愉快很多。
GAMES101投影矩阵相关坐标系的约定
GAMES101使用右手坐标系,包括View space和NDC space(Clip space)。
对于View space,camera的位置为原点,看向负Z轴,这和OpenGL一致。GAMES101推导投影矩阵时,近裁面坐标n和远裁面坐标f都是使用的是坐标值 ,因此有 f < n < 0。
对于NDC space, GAMES101的NDC中,x,y,z的坐标范围都是[-1,1]。虽然OpenGL也是[-1,1],但是需要注意的是,GAMES101中,由于View space和NDC space都是右手系,所以变换后z轴方向并没有变化,因此n被映射到了1,而f被映射到了-1,仍然符合 f < n,且。GAMES101讲义上也说了,near and far not intuitive (n>f) ,而OpenGL使用左手系的NDC更加直觉一些。
下图是右手系的view space(以正交投影视景体为例),由于camera从原点看向负Z轴,因此n更靠近正Z,f更靠近负Z。
当变换到NDC后,由于还是右手系,因此 n 映射到了 +1,f 映射到了 -1
而对于OpenGL,NDC是左手坐标系,因此n被映射到-1,f被映射到1。(图就免了,上图反转Z轴)
正交投影矩阵的推导
由于正交投影只是把一个长方体变换到一个中心位于原点,坐标范围[-1,1]的立方体,因此通过移动加缩放即可完成变换。借用讲义中的图:
首先把长方体的原点移动到坐标系原点,由于view space中长方体的中心点为P o = [ ( r + l ) / 2 , ( t + b ) / 2 , ( n + f ) / 2 ] Po = [(r+l)/2, (t+b)/2, (n+f)/2] P o = [( r + l ) /2 , ( t + b ) /2 , ( n + f ) /2 ] ,因此移动到原点只要移动− P o -Po − P o 即可,写成平移矩阵就是:
缩放也很简单,视景体在x,y,z三个轴上的长度分别是( r − l ) (r-l) ( r − l ) , ( t − b ) (t-b) ( t − b ) 和( n − f ) (n-f) ( n − f ) ,注意这儿都是大数减小数,上面说过n>f,虽然它们都是小于0的负数,但( n − f ) (n-f) ( n − f ) 仍然是正数。而NDC的标准立方体的长宽高都是2。因此x,y,z轴的缩放系数分别是2 / ( r − l ) 2/(r-l) 2/ ( r − l ) ,2 / ( t − b ) 2/(t-b) 2/ ( t − b ) 和2 / ( n − f ) 2/(n-f) 2/ ( n − f ) ,写成缩放矩阵就是:
将上面两个矩阵相乘,注意GAMES101和OpenGL一样,都是矩阵在左,向量在右进行向量变换的,因此矩阵连乘时,先起作用的矩阵在右边(更靠近向量),所以最终的正交投影矩阵就是:
手动乘了一下,结果是:
M o r t h o = [ 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 2 n − f − f + n n − f 0 0 0 1 ] M_{ortho} =
\left[\begin{matrix}
\frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\
0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\
0 & 0 & \frac{2}{n-f} & -\frac{f+n}{n-f} \\
0 & 0 & 0 & 1
\end{matrix}\right] M or t h o = ⎣ ⎡ r − l 2 0 0 0 0 t − b 2 0 0 0 0 n − f 2 0 − r − l r + l − t − b t + b − n − f f + n 1 ⎦ ⎤
和OpenGL正交投影矩阵的比较
如上所述,GAMES101的投影坐标系约定和OpenGL基本一致,区别就在于NDC手向性不同。在参数方面,经典的OpenGL函数g l O r t h o ( l e f t , r i g h t , t o p , b o t t o m . n e a r , f a r ) glOrtho(left, right, top, bottom. near, far) g lO r t h o ( l e f t , r i g h t , t o p , b o tt o m . n e a r , f a r ) ,前四个参数是左右上下剪裁面的坐标值,这和GAMES101一致。但near, far并不是坐标值(负数),而是距离near/far plane的距离值:
Specify the distances to the nearer and farther depth clipping planes. These distances are negative if the plane is to be behind the viewer.
既然是距离值,正常情况就是正数了。但是glOrtho中也可使用负数near,far,但其含义并不是坐标值,而是表示平面在视点后面(这有什么意义?)。
综合以上两点差异,对于NDC的差异,就是z轴方向反了,那么只要将GAMES101的变换矩阵再乘一个Z轴缩放-1的矩阵就可以右手系变左手系,而n,f参数意义的差异,只要n和f各自取负就行。那么:
M g l o r t h o = [ 1 0 0 0 0 1 0 0 0 0 − 1 0 0 0 0 1 ] ∗ [ 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 2 f − n f + n f − n 0 0 0 1 ] = [ 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 − 2 f − n − f + n f − n 0 0 0 1 ] M_{glortho} = \left
[\begin{matrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & -1 & 0 \\
0 & 0 & 0 & 1
\end{matrix}\right] *
\left[\begin{matrix}
\frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\
0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\
0 & 0 & \frac{2}{f-n} & \frac{f+n}{f-n} \\
0 & 0 & 0 & 1
\end{matrix}\right] =
\left[\begin{matrix}
\frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\
0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\
0 & 0 & \frac{-2}{f-n} & -\frac{f+n}{f-n} \\
0 & 0 & 0 & 1
\end{matrix}\right] M g l or t h o = ⎣ ⎡ 1 0 0 0 0 1 0 0 0 0 − 1 0 0 0 0 1 ⎦ ⎤ ∗ ⎣ ⎡ r − l 2 0 0 0 0 t − b 2 0 0 0 0 f − n 2 0 − r − l r + l − t − b t + b f − n f + n 1 ⎦ ⎤ = ⎣ ⎡ r − l 2 0 0 0 0 t − b 2 0 0 0 0 f − n − 2 0 − r − l r + l − t − b t + b − f − n f + n 1 ⎦ ⎤
乘式右边的矩阵是GAMES101正交投影矩阵n和f分别取负的结果,最终得到了OpenGL的正交投影矩阵。
正交投影clip space中顶点的w值
我们知道投影矩阵的作用是将顶点从view space变换到clip space(而不是NDC),NDC是由clip space经过透视除法( x / w , y / w , z / w ) (x/w,y/w,z/w) ( x / w , y / w , z / w ) 得到的。对于正交投影,其实不存在透视除法,但是为了流水线的统一,还是需要经过一个除以w的过程。而我们推导的正交投影矩阵将顶点从view space变换到clip space后,其坐标的w值是1(因为矩阵最后一行是( 0 , 0 , 0 , 1 ) (0,0,0,1) ( 0 , 0 , 0 , 1 ) ),因此可兼容于流水线。
为啥要强调一下w为1呢?我们思考一下这个问题,我们经常说NDC中的坐标范围为[-1,1],而clip space是[-w,w]。所以如果我们在clip space中判断一个点是否在视景体内,只要判断 -w <= p <= w 是否成立。但是以上式子成立的条件是 w > 0,因为如果 w < 0, 那么clip space的坐标范围就是[w, -w]了,比较也要反过来。而正交投影时,w为1,是个正数,所以天然满足 -w <= p <= w,但是下面推导透视投影矩阵后会发现,在GAMES101的约定下,透视投影后,clip space的w值是一个负数。
透视投影矩阵的推导
GAMES101的推导方法很有趣,首先将透视投影的Frustum挤压成一个正交投影那样的长方体视景体,然后对长方体进行正交投影,得到NDC立方体:
所以主要的工作就是推导这个挤压矩阵。
推导透视投影frustum挤压到正交投影视景体的矩阵
看上面的图,需要分别沿着x轴和y轴进行挤压。先看y轴的情况,x轴可以类比。
这是讲义上的示例图,但要注意并不是说把( x , y , z ) (x,y,z) ( x , y , z ) 点挤压到( x ′ , y ′ , z ′ ) (x',y',z') ( x ′ , y ′ , z ′ ) ,如果点在远裁面上,那么挤压后应该是在绿色点的位置,如果点在近裁面上,那么不用挤压了,就是( x ′ , y ′ , z ′ ) (x',y',z') ( x ′ , y ′ , z ′ ) 。那么中间的点呢?显然挤压后是在绿色的虚线上,但是其z坐标如何变化,是向n n n 移动,还是向f f f 移动?这是GAMES101的思考题,下面再说。
虽然示例图标识的不是挤压后点的位置,但是挤压后,y坐标确实变成了y',利用相似三角形是可以得到y'和y的关系:
y ′ = n z y y' = \frac{n}{z}y y ′ = z n y
同样类比可得,挤压后x'和x的关系:
x ′ = n z x x' = \frac{n}{z}x x ′ = z n x
这里出现了除z,而我们使用的是齐次坐标,因此可以将变换后的点乘以z,得到表示同一个点的齐次坐标:
这样,我们需要推导的Persp to ortho矩阵的作用就是将view space 的顶点变换到这样的齐次坐标:
从x,y坐标的对应关系:x = > n x , y = > n y x => nx, y => ny x => n x , y => n y ,可以填入矩阵的前两行:
为了解出最后一行,需要用到z坐标的映射关系,当z为n时,挤压后的点的z也是n,因此齐次坐标的z就是n 2 n^2 n 2 :
设第三行为( 0 , 0 , A , B ) (0, 0, A, B) ( 0 , 0 , A , B ) ,有:
同样,位于远裁面的点,z值为f,挤压后仍然为f,代入方程,可得:
利用上面两个关于A和B的方程式,可计算出A和B:
这就得到了从透视投影变换到正交投影的矩阵:
[ n 0 0 0 0 n 0 0 0 0 n + f − n f 0 0 1 0 ] \left[\begin{matrix}
n & 0 & 0 & 0 \\
0 & n & 0 & 0 \\
0 & 0 & n+f & -nf \\
0 & 0 & 1 & 0
\end{matrix}\right] ⎣ ⎡ n 0 0 0 0 n 0 0 0 0 n + f 1 0 0 − n f 0 ⎦ ⎤
最终的GAMES101透视投影矩阵
使用上面推导的正交投影矩阵乘上这个矩阵就得到最终的透视投影矩阵了:
M p e r s p = M o r t h o M p e r s p − > o r t h o = [ 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 2 n − f − f + n n − f 0 0 0 1 ] [ n 0 0 0 0 n 0 0 0 0 n + f − n f 0 0 1 0 ] = M_{persp} = M_{ortho}M_{persp->ortho} =
\left[\begin{matrix}
\frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\
0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\
0 & 0 & \frac{2}{n-f} & -\frac{f+n}{n-f} \\
0 & 0 & 0 & 1
\end{matrix}\right]
\left[\begin{matrix}
n & 0 & 0 & 0 \\
0 & n & 0 & 0 \\
0 & 0 & n+f & -nf \\
0 & 0 & 1 & 0
\end{matrix}\right] = M p ers p = M or t h o M p ers p − > or t h o = ⎣ ⎡ r − l 2 0 0 0 0 t − b 2 0 0 0 0 n − f 2 0 − r − l r + l − t − b t + b − n − f f + n 1 ⎦ ⎤ ⎣ ⎡ n 0 0 0 0 n 0 0 0 0 n + f 1 0 0 − n f 0 ⎦ ⎤ =
[ 2 n r − l 0 − r + l r − l 0 0 2 n t − b − t + b t − b 0 0 0 n + f n − f − 2 n f n − f 0 0 1 0 ] \left[\begin{matrix}
\frac{2n}{r-l} & 0 & -\frac{r+l}{r-l} & 0 \\
0 & \frac{2n}{t-b} & -\frac{t+b}{t-b} & 0 \\
0 & 0 & \frac{n+f}{n-f} & -\frac{2nf}{n-f} \\
0 & 0 & 1 & 0
\end{matrix}\right] ⎣ ⎡ r − l 2 n 0 0 0 0 t − b 2 n 0 0 − r − l r + l − t − b t + b n − f n + f 1 0 0 − n − f 2 n f 0 ⎦ ⎤
和OpenGL透视投影矩阵的比较
如前所述,区别首先在于NDC的手向性,另外就是OpenGL的经典函数g l F r u s t u m ( l e f t , r i g h t , b o t t o m , t o p , n e a r V a l , f a r V a l ) glFrustum(left, right, bottom, top, nearVal, farVal) g lF r u s t u m ( l e f t , r i g h t , b o tt o m , t o p , n e a r Va l , f a r Va l ) , 前4个参数都是坐标值,而后两个是距离值(正值)。那么我们像正交投影一样操作,将n和f取负,并且左乘一个翻转z轴的缩放矩阵:
M g l P e r s p = [ 1 0 0 0 0 1 0 0 0 0 − 1 0 0 0 0 1 ] ∗ [ − 2 n r − l 0 − r + l r − l 0 0 − 2 n t − b − t + b t − b 0 0 0 − n + f f − n − 2 n f f − n 0 0 1 0 ] = M_{glPersp} = \left
[\begin{matrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & -1 & 0 \\
0 & 0 & 0 & 1
\end{matrix}\right] *
\left[\begin{matrix}
\frac{-2n}{r-l} & 0 & -\frac{r+l}{r-l} & 0 \\
0 & \frac{-2n}{t-b} & -\frac{t+b}{t-b} & 0 \\
0 & 0 & -\frac{n+f}{f-n} & -\frac{2nf}{f-n} \\
0 & 0 & 1 & 0
\end{matrix}\right]= M g lP ers p = ⎣ ⎡ 1 0 0 0 0 1 0 0 0 0 − 1 0 0 0 0 1 ⎦ ⎤ ∗ ⎣ ⎡ r − l − 2 n 0 0 0 0 t − b − 2 n 0 0 − r − l r + l − t − b t + b − f − n n + f 1 0 0 − f − n 2 n f 0 ⎦ ⎤ =
[ − 2 n r − l 0 − r + l r − l 0 0 − 2 n t − b − t + b t − b 0 0 0 f + n f − n 2 n f f − n 0 0 1 0 ] \left[\begin{matrix}
\frac{-2n}{r-l} & 0 & -\frac{r+l}{r-l} & 0 \\
0 & \frac{-2n}{t-b} & -\frac{t+b}{t-b} & 0 \\
0 & 0 & \frac{f+n}{f-n} & \frac{2nf}{f-n} \\
0 & 0 & 1 & 0
\end{matrix}\right] ⎣ ⎡ r − l − 2 n 0 0 0 0 t − b − 2 n 0 0 − r − l r + l − t − b t + b f − n f + n 1 0 0 f − n 2 n f 0 ⎦ ⎤
但是OpenGL的透视投影矩阵并不是这样的啊?别的不说,至少最后一行明明是( 0 , 0 , − 1 , 0 ) (0,0,-1,0) ( 0 , 0 , − 1 , 0 ) 。这是因为OpenGL设计出来的矩阵,clip space的w坐标值为− Z v i e w -Z_{view} − Z v i e w 。而GAMES101的w值是Z v i e w Z_{view} Z v i e w 。这是上面没有说的第3个差异。所以我们需要w值取反,对于齐次坐标来说,所有元素乘以同一个系数和原来的坐标是一样的,因此为了让w取反,只要所有坐标同时乘-1就行。那么我们在前面再乘一个x,y,z,w都缩放-1的矩阵:
M g l P e r s p = [ − 1 0 0 0 0 − 1 0 0 0 0 − 1 0 0 0 0 − 1 ] ∗ [ − 2 n r − l 0 − r + l r − l 0 0 − 2 n t − b − t + b t − b 0 0 0 f + n f − n 2 n f f − n 0 0 1 0 ] = M_{glPersp} = \left
[\begin{matrix}
-1 & 0 & 0 & 0 \\
0 & -1 & 0 & 0 \\
0 & 0 & -1 & 0 \\
0 & 0 & 0 & -1
\end{matrix}\right] *
\left[\begin{matrix}
\frac{-2n}{r-l} & 0 & -\frac{r+l}{r-l} & 0 \\
0 & \frac{-2n}{t-b} & -\frac{t+b}{t-b} & 0 \\
0 & 0 & \frac{f+n}{f-n} & \frac{2nf}{f-n} \\
0 & 0 & 1 & 0
\end{matrix}\right]= M g lP ers p = ⎣ ⎡ − 1 0 0 0 0 − 1 0 0 0 0 − 1 0 0 0 0 − 1 ⎦ ⎤ ∗ ⎣ ⎡ r − l − 2 n 0 0 0 0 t − b − 2 n 0 0 − r − l r + l − t − b t + b f − n f + n 1 0 0 f − n 2 n f 0 ⎦ ⎤ =
[ 2 n r − l 0 r + l r − l 0 0 2 n t − b t + b t − b 0 0 0 − f + n f − n − 2 n f f − n 0 0 − 1 0 ] \left[\begin{matrix}
\frac{2n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\
0 & \frac{2n}{t-b} & \frac{t+b}{t-b} & 0 \\
0 & 0 & -\frac{f+n}{f-n} & -\frac{2nf}{f-n} \\
0 & 0 & -1 & 0
\end{matrix}\right] ⎣ ⎡ r − l 2 n 0 0 0 0 t − b 2 n 0 0 r − l r + l t − b t + b − f − n f + n − 1 0 0 − f − n 2 n f 0 ⎦ ⎤
这就对了。所以约定真的是对投影矩阵的影响很大,随便搞错一个地方,得到的矩阵就不对,算出来的坐标就需要在哪儿莫名其妙的取个负。
透视投影clip space中顶点的w值
如前所述,在GAMES101的约定下,最终clip space的w值为Z v i e w Z_{view} Z v i e w ,由于Camera从原点看向-Z轴,所以view space中,在Frustum内的顶点的Z v i e w Z_{view} Z v i e w 应该是一个负数,既w是负数。因此在clip space中使用w值判断点是否在Frustum内时,需要判断的区间为[w,-w]。是不是很不直觉?我是在实现URasterizer的clip功能时发现的这个问题,一开始我就是直接判断 -w <= P <= w,结果是所有的点都被裁剪掉了,什么都没剩下。上面也说了,OpenGL设计的w值为− Z v i e w -Z_{view} − Z v i e w ,由于OpenGL的约定下,符合条件的点的Z v i e w Z_{view} Z v i e w 也是负数,因此w就是正数,这样就科学多了。
GAMES101思考题:视景体挤压后z值为(n+f)/2的点会挤向n还是f
在GAMES101课上,闫老师提出一个思考题,在挤压之后,近裁面和远裁面上的点的z坐标保持不变,那么中间的点的z坐标如何变化呢,是向n n n 移动,还是向f f f 移动,以z值为( n + f ) / 2 (n+f)/2 ( n + f ) /2 的点为例。
这个问题很难用脑子想,反正我是想不通,所以直接算一下。设原来的点为( x , y , ( n + f ) / 2 ) (x,y,(n+f)/2) ( x , y , ( n + f ) /2 ) ,那么挤压后的点为:
P = [ n 0 0 0 0 n 0 0 0 0 n + f − n f 0 0 1 0 ] ∗ [ x y n + f 2 1 ] = [ n x n y n 2 + f 2 2 n + f 2 ] = [ − − − − n 2 + f 2 n + f 1 ] P =
\left[\begin{matrix}
n & 0 & 0 & 0 \\
0 & n & 0 & 0 \\
0 & 0 & n+f & -nf \\
0 & 0 & 1 & 0
\end{matrix}\right] *
\left[\begin{matrix}
x\\
y \\
\frac{n+f}{2} \\
1
\end{matrix}\right] =
\left[\begin{matrix}
nx\\
ny \\
\frac{n^2+f^2}{2} \\
\frac{n+f}{2}
\end{matrix}\right] =
\left[\begin{matrix}
--\\
-- \\
\frac{n^2+f^2}{n+f} \\
1
\end{matrix}\right] P = ⎣ ⎡ n 0 0 0 0 n 0 0 0 0 n + f 1 0 0 − n f 0 ⎦ ⎤ ∗ ⎣ ⎡ x y 2 n + f 1 ⎦ ⎤ = ⎣ ⎡ n x n y 2 n 2 + f 2 2 n + f ⎦ ⎤ = ⎣ ⎡ − − − − n + f n 2 + f 2 1 ⎦ ⎤
所以z值从( n + f ) / 2 (n+f)/2 ( n + f ) /2 挤压到了( n 2 + f 2 ) / ( n + f ) (n^2+f^2)/(n+f) ( n 2 + f 2 ) / ( n + f ) , 用前值减去后值,化解之后得到:
− ( n − f ) 2 / 2 ( n + f ) -(n-f)^2/2(n+f) − ( n − f ) 2 /2 ( n + f )
由于n和f是负数,所以整个结果是正数,这说明原来的z坐标值大于挤压后的z坐标,因此中间点向近裁面移动了。