从零开始游戏开发——3.2 投影变换

  在3.1节中的程序中,我们在RendererApplication::OnInitialize()中看到有下面一段代码,这段代码创建了一个转换到摄像机空间的矩阵和转换到投影空间的矩阵,并将他们传递给了渲染器。到目前为止,我们还没有创建摄像机类,因为对渲染器来讲,摄像机对象最终的主要作用就是将这两个矩阵数据传递给着色器,因此在还没涉及到复杂的场景管理之前,我们先介绍这两个矩阵的原理。

1 ...
2 Matrix4x4f vMat, proMat;
3 vMat.BuildCameraLookAtMatrix(Vector3f(0, 0, 0), Vector3f(0, 0, -1), Vector3f(0, 1, 0));
4 proMat.BuildProjectionMatrixPerspectiveFovRH(PI / 6.f,   1.0f * GetWindowHeight() / GetWindowWidth(), 1.0f, 1000.f);
5 Renderer->SetGlobalUniform("mvpMat", (proMat * vMat).m, sizeof(Matrix4x4f));
6 ...

  上面代码第三行创建了由世界坐标空间转换到摄像机空间的矩阵,这个矩阵的计算添加到了Matrix4x4这个类中,其代码如下:

 1 template <typename T>
 2 Matrix4x4<T> &Matrix4x4<T>::BuildCameraLookAtMatrix(const Vector3<T> &position, const Vector3<T> &dir, const Vector3<T> &upVector)
 3 {
 4     Vector3<T> zaxis = -dir;
 5     zaxis.Normalize();
 6 
 7     Vector3<T> xaxis = upVector.CrossProduct(zaxis);
 8     xaxis.Normalize();
 9 
10     Vector3<T> yaxis = zaxis.CrossProduct(xaxis);
11     yaxis.Normalize();
12 
13     m00 = (T)xaxis.x;
14     m01 = (T)xaxis.y;
15     m02 = (T)xaxis.z;
16     m03 = (T)-xaxis.DotProduct(position);
17 
18     m10 = (T)yaxis.x;
19     m11 = (T)yaxis.y;
20     m12 = (T)yaxis.z;
21     m13 = (T)-yaxis.DotProduct(position);
22 
23     m20 = (T)zaxis.x;
24     m21 = (T)zaxis.y;
25     m22 = (T)zaxis.z;
26     m23 = (T)-zaxis.DotProduct(position);
27 
28     m30 = 0;
29     m31 = 0;
30     m32 = 0;
31     m33 = 1;
32 
33     return *this;
34 }

2.2矩阵章节我们已经知道,3D空间中的点表示为基向量空间的线性组合,于是点要由世界空间转换到摄像机空间,首先要计算摄像机空间坐标系的三个基向量,通常我们通过摄像机的位置、方向和向上向量就可以计算这个矩阵了。我们定义摄像机的方向为z轴的负方向(使用右手坐标系),那么负的摄像机的方向就是摄像机空间坐标系的z轴方向,通过向上方向和z轴的叉乘,我们可以计算出x轴方向,再次进行z轴和x轴的叉乘,最后得到了y轴的方向(上面代码4~11行),我们将计算到的基向量赋值给矩阵的每一行,这就是得到了由世界到摄像机空间的旋转矩阵。但摄像机还有位置信息,这个位置就是摄像机坐标系在世界坐标系中旋转后进行的位移,而位移值就是位置在摄像机坐标系各轴上的投影大小,因此在计算最终摄相机空间的坐标时,需要减去这个位移值,在构造矩阵中代码为上面的第16、21、26行。下图为2D空间中的例子,由xOy先旋转到x'Oy',再位移到x''O''y'',p为空间中的一点。

 

   在空间中的点变换到相机空间后,就可以进行投影操作,摄像机的投影分为正交投影和透视投影,我们以透视投影为例,如果屏幕的宽度/高度为aspect,那么我们在这个步骤的目的是将相机空间中的xyz坐标映射到[-1, 1](OpenGL使用的z映射值为[-1,1],DX为[0, 1],这里以[-1,1]说明),只有落到这些范围内的坐标点才会进行最终的渲染计算, 先看代码,然后我们来讲解矩阵中的这些值是怎么计算来的:

 1 template <typename T>
 2 Matrix4x4<T> &Matrix4x4<T>::BuildProjectionMatrixPerspectiveFovRH(T fieldOfViewRadians, T aspectRatio, T zNear, T zFar)
 3 {
 4     const T h = static_cast<T>(1.0 / tan(fieldOfViewRadians * 0.5f));
 5     assert(aspectRatio != 0.f);
 6     const T w = static_cast<T>(h / aspectRatio);
 7     assert(zNear != zFar);
 8 
 9     m[0] = w;
10     m[1] = 0;
11     m[2] = 0;
12     m[3] = 0;
13 
14     m[4] = 0;
15     m[5] = h;
16     m[6] = 0;
17     m[7] = 0;
18 
19     m[8] = 0;
20     m[9] = 0;
21     m[10] = ((zNear + zFar) / (zNear - zFar));
22     m[11] = (T)(2.0f * zNear * zFar / (zNear - zFar));
23 
24     m[12] = 0;
25     m[13] = 0;
26     m[14] = (T)-1;
27     m[15] = 0;
28 
29     return *this;
30 }

上面函数是计算透视投影矩阵的代码,函数传入的参数为摄像机的fov、屏幕的宽高比、近裁剪面和远裁剪面的距离,如下图,摄像机的fov为θ,点P在垂直方向投影到近裁剪面上一点Q,近裁剪面为我们的目的投影面,因此利用三角函数可以计算出投影平面上的点

为:

 

 

而这个值要映射到[-1,1] ,由于宽高比的存在,宽度方向需要除以aspect,最终P'x为

正常来讲,图形计算出了x和y的裁剪坐标,就可以转到屏幕空间坐标进行绘制了,但是在光栅化时,我们还需要用到z值。这里z值主要有两个作用:

  1. 深度测试,光栅化阶段会对每个像素位置进行深度测试,以保证最前面的图形被正确绘制。

  2. 计算顶点属性的插值。

第二点需要特殊说明,顶点属性除了位置之外还有颜色、纹理坐标等信息,光栅化阶段,需要对三角形内的这些信息进行插值计算出每个片元像素的顶点属性,下图为一条线段向平面投影的示意图,在投影平面上均匀采样,但实际线段上采样点的间隔却是随着位置离投影平面的距离增大而增大,这表明投影后顶点属性的插值计算不是线性的。

 

 

 

 上图中的线段我们可用方程表示为:

由相似三角形知道:

 

解关于x的方程,上式代入直线方程得到:

 

 将直线方程写成1/z的形式:

 对于投影平面上的一点p3,令:

则有:

 由上式知道,三角形面投影后z值的倒数是线性插值。对于一个顶点属性b,光栅化时需要根据b1和b2计算插值属于b3,这两个顶点的z值为z1和z2,则有:

 而z3的值为:

最终可以解得:

由上面的公式,我们就可以在光栅化阶段计算出正确的属性插值结果,这个过程叫做透视校正。

  通过上面我们知道,透视校正计算插值时需要乃至1/z的值,因此最终需要求得的z'值可以表示为下面的线性关系:

其中右手坐标系中z的范围为(-n,-f),z'的范围为(-1,1),代入上式有

解上面方程得到:

于是有投影后的z'值为:

这里点P进行投影后的点值都进行了除以z值的操作,因此投影后的Q点的齐次坐标为:

 用一个矩阵来表示这个计算为:

 

  到目前为止,我们计算出了ViewMatrix和ProjectMatrix,这是后面用到摄像机的核心内容,此外还有正交投影矩阵计算,其计算过程与上述类似,正交投影的代码如下:

 1 template <typename T>
 2 Matrix4x4<T> &Matrix4x4<T>::BuildProjectionMatrixOrthoRH(T widthOfViewVolume, T heightOfViewVolume, T zNear, T zFar)
 3 {
 4     assert(widthOfViewVolume != 0.f);
 5     assert(heightOfViewVolume != 0.f);
 6     assert(zNear != zFar);
 7 
 8     m[0] = (T)(2 / widthOfViewVolume);
 9     m[1] = 0;
10     m[2] = 0;
11     m[3] = 0;
12 
13     m[4] = 0;
14     m[5] = (T)(2 / heightOfViewVolume);
15     m[6] = 0;
16     m[7] = 0;
17 
18     m[8] = 0;
19     m[9] = 0;
20     m[10] = (T)(2 / (zNear - zFar));
21     m[11] = (T)((zNear + zFar) / (zNear - zFar));
22 
23     m[12] = 0;
24     m[13] = 0;
25     m[14] = 0;
26     m[15] = 1;
27 
28     return *this;
29 }

  通过矩阵计算获取了裁剪空间的坐标之后,需要将坐标转换到屏幕空间才能进行光栅化操作,以Windows窗口为例左上角为屏幕空间的(0,0)点,向中为x正方向,向下为y正方向,则对每个顶点位置进行转换的计算过程为:

 

 

 到此,我们已经为光栅化所需要的数据做好了准备,下一步就是进行光栅化操作了。

 

posted @ 2022-08-27 15:07  毅安  阅读(199)  评论(0编辑  收藏  举报