【RTR4笔记】 第四章(三) 投影

正交投影、透视投影的细节

Posted Kongouuu's Blog on January 20, 2022

4.7 投影

在这章节和旋转的中间有几个比较简单提到动画的部分。主要是讲动画中用到的顶点混合、插值、压缩的方案。书里提了一个大概,有时间再往细节研究。

先说一下投影的整体流程:

  1. 设立一个大的面积,面积内为要保留的顶点
  2. 把面积压缩到NDC坐标,用于压缩面积的矩阵就是作用于顶点的投影矩阵

4.7.1 正交投影

透视投影就是把物体平行的投影到一个空间内的投影。

从简单的开始,如果我们要投影到一个无穷大的平面上,那么我们所做的就是抹去z轴,并且其它数据保持不变,可以得到下面的矩阵:

\[P_o = \left[ \matrix{ 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \\ } \right]\\\\\]

当然我们最后要的不是投影到一个无穷大的平面,也不能完全抛弃深度。投影需要的是把顶点放到一个体积内部,并且需要一个范围剔除的机制,也就是说投影后在范围外的顶点会被剔除。为了达到剔除的效果,渲染管线中采用了NDC坐标,也就是说当我们投影完一个场景,所有在规范立方体[OpenGL为 [-1,1]3] 外面的顶点都要被进行裁剪。

所以投影的目标是,把物体投到规范立方体[OpenGL为 [-1,1]3]里。这时候我们会限定一个范围,也就是说我们希望保留每个轴上范围在多少以内的顶点,也就是说投影的 [上t,下b,左l,右r,近n,远f] 的范围。目标是让范围内的顶点,在投影后处于[-1,1]的范围。

所以会衍生出下面的投影矩阵:

\[M_{ortho} = \left[ \matrix{ \frac{2}{r-l} & 0 & 0 & 0 \\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{2}{f-n} & 0 \\ 0 & 0 & 0 & 1 \\ } \right]\\\\ 顶点的x轴除以宽度(r-l),会归一化到[-0.5,0.5]的范围,再乘以2到规范立方体\\ y轴和z轴同理\]

不过有的时候我们投影的范围并不是以摄像机朝向为中心的,也就是说:

\[l \ne -r\\ t \ne -b\\ f \ne -n\]

这种情况下我们的坐标即使除以了宽度,也没办法截取需要的范围,所以在那之前还需要进行一段平移。就是说在缩放前需要先把所有的顶点跟着要截取的面积一起平移,直到截取的面积的中心点为顶点:

\[T(t) =\left[ \matrix{ 1 & 0 & 0 & -\frac{r+l}{2} \\ 0 & 1 & 0 & -\frac{t+b}{2} \\ 0 & 0 & 1 & -\frac{f+n}{2} \\ 0 & 0 & 0 & 1 \\ } \right]\\\\ S(s) =\left[ \matrix{ \frac{2}{r-l} & 0 & 0 & 0 \\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{2}{f-n} & 0 \\ 0 & 0 & 0 & 1 \\ } \right]\\\\ M_{ortho} = S(s)T(t) = \left[ \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 \\ } \right]\\\\\]

通过上面的函数我们已经可以得到一个正确的正交投影函数,不过如果是要写OpenGL,则需要稍微改一下细节:

\[OpenGL\\ M_{ortho} = \left[ \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 \\ } \right]\left[ \matrix{ 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 1 \\ } \right] =\left[ \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 \\ } \right]\\\\\]

上面的计算是根据OpenGL来算的,在DirectX里不一样的不止是左右手坐标系,并且NDC空间的定义也不一样,DirectX的规范化立方体的深度范围为[0,1],所以使用的矩阵略有不同。

\[DirectX:\\ M_{ortho} =\left[ \matrix{ \frac{2}{r-l} & 0 & 0 & 0\\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{1}{f-n} & 0 \\ -\frac{r+l}{r-l} & -\frac{t+b}{t-b} & -\frac{n}{f-n} & 1 \\ } \right]\\\\\]

简单点来说就是为了能够放到NDC坐标,要先把我们的要截取的体积的中心点放到原点,然后再根据我们要截取的大小选择缩放。

4.7.2 透视投影

透视投影跟正交投影不一样的地方是,透视投影里面原本平行的线/面在投影后会变得不平行,为了达到一个近大远小的效果。

透视投影的流程就是把空间内所有的物体根据近平面和远平面的位置进行压缩,压缩成适合正交投影的缩放和平移矩阵去操作的空间。

简单来说就是把所有的顶点都根据相似三角形法则投影到一个平面上(这时候我们可以理解成顶点的xy坐标为最终的xy坐标,不过深度并不是直接变成近平面的深度)

这个过程我们使用下面的矩阵对定点操作:

\[M_{p->o} = \left[ \matrix{ n & 0 & 0 & 0\\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0&0&1&0 \\ } \right]\\\\ M_{p->o} \left[ \matrix{ p_x\\p_y\\p_z\\1 } \right] = \left[ \matrix{ np_x\\np_y\\np_z+fp_z-nf\\p_z } \right]\\\\ 归一化后:\left[ \matrix{ \frac{n}{p_z}p_x\\\frac{n}{p_z}p_y\\n+f-\frac{nf}{p_z}\\p_z } \right]\\\\\]

这个转换矩阵保证了顶点是根据相似三角形去缩放xy坐标,并且z坐标在n时为n,f时为f。通过这个矩阵我们直接的就把所有的顶点根据近平面压缩到适合透视投影处理的空间。整个透视投影的矩阵就是:

\[M_{perspect} = M_{ortho}M_{p->o}\\ \\ 正常:\\ M_{perspect} = \left[ \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 \\ } \right]\left[ \matrix{ n & 0 & 0 & 0\\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0&0&1&0 \\ } \right]= \left[ \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{2fn}{f-n}\\ 0&0&1&0 \\ } \right]\\\\ OpenGL:\\ \left[ \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{2fn}{f-n}\\ 0&0&1&0 \\ } \right]\left[ \matrix{ 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 1 \\ } \right]= \left[ \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{2fn}{f-n}\\ 0&0&-1&0 \\ } \right]\\\\ DirectX:\\ \left[ \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}{f-n} & -\frac{fn}{f-n}\\ 0&0&1&0 \\ } \right]\\\\\]

4.7.3 FOV

一般来说我们写摄像机的投影矩阵的适合可能给API的是FOV以及Aspect Ratio。然后像OpenGL就会用下面这样的矩阵去构建透视投影:

\[\cot{\phi}为FovY\\ M =\left[ \matrix{ \frac{\cot{\frac{\phi}{2}}}{aspect} & 0 & 0 & 0\\ 0 & \cot{\frac{\phi}{2}} & 0 & 0 \\ 0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n}\\ 0&0&-1&0 \\ } \right]\\\\\]

很明显的能看到arctan替代了我们本来应该有的2n/(t-b)

\[\cot{\frac{\phi}{2}}=\frac{1}{\tan{\frac{\phi}{2}}}\\ \tan{\frac{\phi}{2}} = \frac{\sin{\frac{\phi}{2}}}{\cos{\frac{\phi}{2}}}=\frac{half\ height}{depth}\\ \cot{\frac{\phi}{2}}=\frac{2*depth}{height} \\ 既然我们形容的是近平面的高度,那么depth=zNear\\ 展开来M_{11}的\cot{\frac{\phi}{2}}来看其实就是:\\ \frac{2n}{t-b}\\\\ 回到aspect, aspect=\frac{width}{height}\\ \frac{\cot{\frac{\phi}{2}}}{aspect}=\frac{\cot{\frac{\phi}{2}}*height}{width}\\ =\frac{2n}{height}*\frac{height}{width}\\=\frac{2n}{r-l}\]

深度的精度

我们最后深度一定会被放到一个[0,1]的区间,这个区间内我们用一个Float去表示的时候他的精度并不会很高。为了提高精度,可以使用reversed z去储存深度,也就是:

\[z' = 1.0-z\]

这个的原理有两个部分:

  1. 我们一般离近平面近的地方并不会渲染太多物体
  2. 浮点数靠近0的部分比靠近1的部分精度高

因为我们大部分时候用不到靠近0的深度,或者说物体不会多到出现深度上的冲突,所以我们用倒置的方法来更有效率的运用我们的深度。

其次Lloyed也提议使用了Log的办法去提高我们的精度,原因也是差不多的,浮点数在靠近0的地方比较准,普通的储存方法会造成很多精度的浪费:

\[f_c=\frac{2}{log_2{(f+1)}}\\ z=w(\log{_2}{(max(10^{-6},1+w))f_c-1})\ \ [OpenGL]\\ z=w(\log{_2}{(max(10^{-6},1+w))f_c})/2\ \ [DirectX]\]

个人感慨1 关于OpenGL

在上面推正交投影和透视投影的时候,有一个很大的困扰就是为什么每次一到OpenGL就要去翻转他们的z值。说简单点说是从右手坐标系换到左手坐标系,可以说是这个问题,也可以说不是。最根本的问题其实还是OpenGL内使用右手坐标系时对NearFar的形容其实是非常左手坐标系的。

我们一个右手坐标系的API往前看应该是-z方向,但是OpenGL的使用是把NearFar都写成正数,用来形容距离摄像机的深度距离。一开始学的时候我理解的是NearFar是我们要投影的Volume的前后的z轴坐标,如果使用这个理解,也就是说用负数当作这两个参数,那么我们用原始的投影矩阵就可以把右手坐标系转换成左手,因为在z的压缩流程中已经进行了翻转(红圈的部分会是负数):

很显然,GL不是这么设计的,所以那两个投影在OpenGL里本质上就是在投影前先把点的坐标直接转换成左手坐标系的z值…

个人感慨2 透视投影的w

之前学图形的时候没意识到w到底是什么,其实推一推投影矩阵就挺明显的,w就是顶点在透视投影前的z坐标。