第十二课 透视投影
背景
在这一节中我们将会介绍如何在保持深度外观的情况下将三维世界中的物体投影到二维平面上去。最有代表性的例子是:当我们站在一条笔直的马路的中间向前看时,我们会发现马路的两边会越来越靠近,并最终汇聚成一个点。这就是图形学中常说的透视投影。
为了实现上面的效果,在本节中我们需要生成一个投影矩阵,这个投影矩阵需要满足能够将所有的顶点都投影到范围位于 -1 到 1 之间的规范化空间中(normalizedspace,一个中心位于原点,边长为 2 的正方体),这样才能方便裁剪器对场景进行裁剪,通过这种方法使得裁剪过程中不需要考虑屏幕的尺寸以及远近裁剪面的位置。
透视投影矩阵需要以下四个参数来确定:
- 屏幕的宽高比(aspect ratio)——显示区域(投影目标)的宽度与高度的比值;
- 垂直视野(field of view),相机的垂直视角。相机在垂直方向上视野的角度大小;
- 近裁剪面位置。这使得我们能够裁剪掉距离相机很近的对象
- 远裁剪面位置。这使得我们能够裁剪掉距离相机很远的对象
我们需要屏幕的宽高比,是因为我们的投影结果是处于规范化空间中的(宽高是相同的),但是一般情况下我们屏幕的宽度都会大于高度,这表示我们在屏幕水平方向上显示的内容会多于竖直方向上的内容。为了调和这个矛盾,我们需要在透视投影矩阵中对水平方向上的内容进行压缩,即在规范化空间中,X方向上会容纳更多的内容。
视野角度参数的设置使得我们能够对观察到的世界进行缩小和放大,正如下面的图片一样,我们可以看到左图视角较大,使得物体投影到投影平面上之后变得较小,而右图视角较小使得同样的物体投影到投影平面上之后变得较大。
要推倒透视投影矩阵,首先我们需要确定相机到投影平面的距离,投影平面是一个平行于XY平面的平面,当然并不是整个平面都是可见的,因为那实在是太大了,一般情况下我们只能看见与我们屏幕比例相同的一个矩形区域(投影窗口)内的东西。它的宽高比可以通过下面的方法计算出来:
ar = 屏幕宽度 / 屏幕高度
在这里我们简单的将投影窗口的高度设置为 2,这意味着投影窗口的宽度是 ar 的两倍。如果我们将相机放置在原点,并且从相机后面向前看,我们会看到下面的情况:
任何处于这个矩形窗口之外的的东西都会被裁剪掉,而之前我们介绍过我们需要将所有顶点都变换到规范化空间中,在这个投影窗口中,Y 轴的范围是与规范化空间所需要的范围吻合的。但是在 X 方向上,目前的窗口还是大了一些,我们会在之后对其进行修正。
现在,我们逆着 X 轴的负方向来看这个投影模型。
利用视野角(用 α 角表示)我们可以计算出相机到投影平面的距离:
下一步我们将要计算出某一点经过投影之后的 X 和 Y 坐标,我们可以参考下面的图片(依然逆着 X 轴看)
现在我们在三维世界中有一个位于(x,y,z)的点,现在我们需要找到这个点在投影平面上的投影点( xp,yp ),因为 X 分量超出本图范围(它指向页面内外)所以这儿我们从 Y 分量开始计算。根据相似三角形原理我们可以得出以下结果:
利用同样的方法我们来计算 X 分量。
由于我们将投影窗口的宽度和高度设置为 2 * a r和 2,所以如果一个点最终要能够被投影到屏幕之上,则其投影之后的坐标的 X 分量应该处于 -ar 和 ar 之间,并且投影结果的 Y 分量位于 -1 到 1 之间。我们可以看到投影结果的Y分量已经被规范化了(处于 -1 到 1 之间)而 X 分量确没有,但是我们可以通过将 xp 除以 ar 来将其规范化。这意味着经过投影之后,原本 X 分量值为 +ar 的点,现在其 X 分量的值变为了 1,并且它将处于规范化盒子的右边。
这样我们就能推测出 X、Y 分量经过投影之后的结果如下:
在我们完成整个投影过程之前,我们先看看现在投影矩阵是怎么样的。我们需要用一个矩阵来表示上述投影变换的过程,但是现在我们遇到一个问题:在上面的每个等式中我们都需要将 X 和 Y 分量除以 Z( Z 是表示顶点位置的向量中的一个分量)。但是由于 Z 分量对于每个顶点都是不一样的,所以我们无法将其作为矩阵的一部分来对所有顶点进行投影变换。为了更好的理解这一部分,我们先假设投影矩阵的第一行为(a,b,c,d),我们需要为这个向量选取合适的值,以使下面等式恒成立:
上面的式子是由矩阵是第一行和顶点位置坐标点乘之后的结果,而这个结果就是经过投影之后的位置向量的 X 分量。为了得到这个结果,我们可以将参数 “b” 和参数 “d” 设置为 0( X 分量上的结果与原向量的 Y、W 分量无关),但是对于参数 “a” 和 “c” 我们目前还不能确定它们的具体值。对于这个问题,OpenGL 采用的解决方法是将整个投影变换过程分为两步:首先用一个投影矩阵乘上需要投影的顶点,之后再将结果除以 Z 分量的值(即一般所说的透视除法)。投影矩阵由应用程序提供,并且我们需要在 Shader 程序中用这个投影矩阵对顶点坐标进行变换。透视除法被直接集成到 GPU 中并且在光栅化阶段发挥作用(光栅化阶段处于顶点处理器与片元处理器之间)。但是 GPU 如何知道顶点着色器的哪一个输出变量需要除以他们的 Z 值呢( VS 的输出不止一个),对于这个问题,OpenGL 提供了一个内置变量 gl_Position 用于存放需要进行透视除法的变量,现在我们只需要找到能够满足与上面等式的投影矩阵,再用这个矩阵乘以传入的顶点坐标,最后将此结果保存到 gl_Position 中即可。
当我们用透视投影矩阵对顶点进行变换之后,GPU 会自动执行透视除法,这样我们就能得到我们想要的结果。但是这里有一个难点:如果我们用投影矩阵乘上顶点位置,之后我们将其除以其 Z 值,这样我们就会失去 Z 值信息(深度信息),但是由于 Z 值需要在后面进行的深度测试中用到,所以原始的深度信息必须被保存下来。所以这里我们将原始的 Z 值保存到投影结果的 W 分量中,而在之后的透视除法中我们仅仅将 X、Y、Z 分量除以 W 分量而不是 Z 分量。在 W 中保存的原始 Z 值就可以被用于深度测试。将 gl/_Position 变量除以其自身的 W 分量,这一过程就是所谓的“透视分割”。
现在我们可以先生成一个中间矩阵来满足上面的等式,同时实现将 Z 分量拷贝到 W 分量中:
正如前面提到的那样,为了让裁剪器在裁剪过程中不受到远裁剪面和近裁剪面的影响,我们希望将 Z 分量也进行规范化,但实际上上面的矩阵却将 Z 值变成了 0。所以现在我们需要确定上面矩阵的第三行的值,使得在经过透视分割之后,任何位于可视范围内(Z 坐标值满足 NearZ <= Z <= FarZ)的 Z 值都会被映射到(-1,1)的范围中。这样的映射操作由两个部分组成。首先我们将近裁剪面和远裁剪面的范围缩小到宽度为 2 的范围中。之后我们对此范围进行移动,使范围的起点移动到 -1(这样远近裁剪面这个范围就被投影到了 -1 到 1 之间的范围中)。这两个步骤可以由下面的式子表示:
经过透视除法之后结果变为下面的式子:
接下来我们需要找到参数 A 和 B 的值来实现到[-1,1]的映射。我们知道,当 Z 值等于近裁剪面的位置时经过投影变换后的值是 -1,而当 Z 值等于远裁剪面的位置时经过投影变换后的结果将会是 1,所以我们可以得出如下结论:
现在我们需要确定上面的矩阵中第三行的向量(a,b,c,d),并且使这个向量满足下面的式子:
我们可以先将参数 a 和参数 b 的值设置为 0,因为我们不希望 X、Y 分量对 Z 分量的变换有任何影响。这样我们就能得出结论:A 的值应该等于 c,B 的值应该定于 d。(W 分量已经确定为 1,这由齐次坐标可知)
所以,最终的变换矩阵如下:
在用投影矩阵乘上顶点坐标之后,顶点坐标被变换到我们所说的裁剪坐标系之下,在执行透视除法之后顶点坐标被变换到了 NDC 坐标系(NormalizedDeviceCoordinates)之下。
在不经过透视投影矩阵处理的情况下,我们也可以直接从顶点着色器中输出顶点,但是只有当这个顶点坐标的各个分量都处于 -1 到 1 之间时,它才能被显示在屏幕上。为了使透视除法对结果不产生影响,我们可以将其 W 分量设置为 1。在这之后顶点将会被变换到屏幕坐标系之下。当我们使用投影矩阵的时候,透视除法成为 3D 到 2D 的投影投影变换过程中的一部分。
代码
void Pipeline::InitPerspectiveProj(Matrix4f& m)const
{
const float ar = m_persProj.Width / m_persProj.Height;
const float zNear = m_persProj.zNear;
const float zFar = m_persProj.zFar;
const float zRange = zNear - zFar;
const float tanHalfFOV = tanf(ToRadian(m_persProj.FOV / 2.0));
m.m[0][0] = 1.0f / (tanHalfFOV * ar);
m.m[0][1] = 0.0f;
m.m[0][2] = 0.0f;
m.m[0][3] = 0.0f;
m.m[1][0] = 0.0f;
m.m[1][1] = 1.0f / tanHalfFOV;
m.m[1][2] = 0.0f;
m.m[1][3] = 0.0f;
m.m[2][0] = 0.0f;
m.m[2][1] = 0.0f;
m.m[2][2] = (-zNear - zFar) / zRange;
m.m[2][3] = 2.0f * zFar * zNear / zRange;
m.m[3][0] = 0.0f;
m.m[3][1] = 0.0f;
m.m[3][2] = 1.0f;
m.m[3][3] = 0.0f;
}
我们向管线类中添加了一个 m_persProj 结构体用于存放透视投影矩阵所需要的配置参数。上面的函数用于生成我们在前面推导出来的透视投影矩阵。
m_transformation = PersProjTrans * TranslationTrans *RotateTrans * ScaleTrans;
我们将透视投影矩阵作为整个变换矩阵计算中的第一项。谨记由于位置向量是乘在整个变换矩阵的右边的,所以实际上投影矩阵是最后发挥作用的,即首先进行缩放,之后进行旋转、然后是平移,最后进行投影变换。
p.SetPerspectiveProj(30.0f, WINDOW_WIDTH,WINDOW_HEIGHT, 1.0f, 1000.0f);
在渲染函数中我们设置投影矩阵的参数。运行并查看效果。
操作结果