🔥C++和OpenGL实现3D游戏编程【目录】
1、本节课要实现的内容
在上一课我们了解了着色器,了解了部分核心模式编程内容,从中接触到了线性代数中向量和矩阵相关知识,我们已经能够感受到向量和矩阵在OpenGL编程中的重要性。特别是后期用去了解融合、光照效果,构建自己的三维世界,都需要大量向量和矩阵的相关知识。本节会就一些线性代数的基础知识进行了解和应用,并实现三维坐标向二维屏幕坐标的转换,这是后期实现鼠标选中物体功能的铺垫。但本文并非对线性代数进行专业的介绍,而是学习计算机图形学必备知识的了解及实战应用。
2、线性代数相关
线性代数是代数学的一个分支,主要处理线性关系问题,作为一个独立的分支在20世纪才形成,然而它的历史却非常久远。在线性代数发展过程中,向量概念的引入,形成了向量空间的概念。凡是线性问题都可以用向量空间的观点加以讨论。因此,向量空间及其线性变换,以及与此相联系的矩阵理论,构成了线性代数的中心内容。
3、向量
向量(Vector)在数学、物理、计算机科学中是非常常见的概念,与之相对的是标量(Scalar)。在几何学或物理学中,向量Vector被刻画成一个既有方向又有大小的量。
- 二维空间的向量
下图就是一些二维向量。向量最基本的定义就是一个方向,或者说向量有一个方向(Direction)和大小(或长度),由于向量表示的是方向,起始于何处并不会改变它的值,所以我们可以理解向量v和向量w是相等的,尽管他们的起点不相同。
- 三维空间的向量
在三维空间直角坐标系中,经常用向量表示三维物体的坐标(Position)等信息。由于向量是一个方向,所以有些时候会很难形象地将它们用位置(Position)表示出来。
在Opengl的三维空间中,为了让其更为直观,我们通常设定这个方向的原点为(0, 0, 0),然后指向一个方向,对应一个点,使其变为位置向量(Position Vector),可以写成向量v(x,y,z),或如下图形式:
3.1、向量加减
向量的加减可以被定义为是每个分量的加减,两个向量的加减结果仍然为一个向量。
二维向量的加减:
三维向量的加减:
3.2、向量点乘
点乘是通过将对应分量逐个相乘,然后再把所得积相加来计算的,计算出来的结果是一个数值。
3.3、向量叉乘
叉乘只在3D空间中有定义,它需要两个不平行向量作为输入,生成一个正交于两个输入向量的第三个向量。
三维几何中,三维向量a和三维向量b的叉乘结果还是一个三维向量,有个更通俗易懂的叫法是法向量,该向量垂直于a和b向量构成的平面。如下图所示:
在3D图像学中,叉乘的概念非常有用,可以用于生成垂直于a,b的法向量,前边章节讲的设置光照法线用的就是这个原理。
3.4、向量单位化
我们刚刚说过向量有大小,也就是长度。向量⻓度(也叫向量的模)计算公式:
如果⼀个向量的长度(模)为1,则称之为单位向量。长度(模)不为1,则不是单位向量。将一个向量缩放至长度(模)为1,这个过程称为标准化向量,也称为单位化向量。单位化向量的公式为:
在稍后计算视图矩阵时,会用到这个知识。
4、矩阵
我们通过罗列多元线性方程的系数,我们抽象出了向量。我们进一步罗列多元线性方程组的系数,我们可以抽象出矩阵(Matrix)。矩阵从形式上就是一个数字表,以行和列的形式呈现,简单的矩阵如下图所示:
矩阵的行数m和列数n可以不相同,m行n列矩阵记为矩阵。当行数和列数相等时,m = n ,矩阵也称为n阶方阵。在Opengl中大多使用方阵。
4.1、矩阵加减
矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算,矩阵加减后的结果仍然是一个矩阵。
4.2、矩阵与矩阵相乘
只有当前左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘。当然我们这里多数用到的都是行数和列数均为4的矩阵。同时,矩阵相乘不遵守交换律,两个矩阵的前后顺序不能改变,如果改变则结果会随之改变。下边是一个二维矩阵相乘。
以矩阵A第一行与矩阵B第一列对应元素分别乘积的和,就是矩阵结果C的第一个元素。
接下来我们开一个三维矩阵和三位矩阵相乘的情况。
同样四位矩阵的相乘原理也一样,我们后边会用到四维矩阵的相乘。
4.3、矩阵与向量相乘
矩阵的每一行和向量的一列进行相乘,乘完结果是一个向量。向量可以看成是一个N行1列的矩阵,那么按矩阵乘法的规则,一个M行N列的矩阵和一个N行1列的向量是可以相乘的。
5、OpenGL中的向量和矩阵
在OpenGL编程中,我们会大量使用向量和矩阵的知识,让我们来熟悉一下。
5.1、齐次向量和齐次矩阵
简单来说,齐次就是比原来使用的三维向量或矩阵多一维,使用四维代替三维。齐次向量多出来一个w分量,也叫齐次坐标。想要从齐次向量得到3D向量,我们可以把x、y和z坐标分别除以w坐标。我们通常不会注意这个问题,因为w分量通常是1.0。使用齐次坐标我们可以使用4x4的齐次矩阵来处理三维空间中的变换齐次坐标(Homogeneous Coordinates),也就是说,三维空间中某点的变换可以表示成点的齐次坐标与四阶的三维变换矩阵相乘。
这点很重要,比如,三维矩阵的平移必须使用矩阵的加法,而矩阵的旋转和放大缩小都使用乘法。因为我们需要矩阵操作可以叠加,那么我们在既有平移又有旋转或缩放的时候,一会儿要用加法 ,一会儿要用乘法,这样会给我们计算带来麻烦。使用齐次矩阵后,平移操作也可以转换成矩阵的乘法。那么也就是说,不管是平移还是旋转、放大缩小,都可以使用矩阵相乘来实现,这样要方便的多。但要记住操矩阵的操作是从右向左的,顺序不能改变。当然齐次坐标作用不止这一个作用,后期用到再讨论。
5.2、单位矩阵
在OpenGL中,我们通常使用4x4的齐次变换矩阵,而其中最重要的原因就是大部分的向量都是有4个分量的齐次坐标向量。我们能想到的最简单的变换矩阵就是单位矩阵(Identity Matrix)。单位矩阵是一个除了对角线以外都是0的矩阵。在下式中可以看到一个单位向量,我们常说的重置为单位矩阵,就是将矩阵初始化为以下矩阵。函数glLoadIdentity()的作用就是初始化当前矩阵为单位矩阵,仅此而已。
5.3、主列矩阵和主行矩阵
OpenGL中使用的矩阵,都是数学意义上的标准矩阵,我们在数学层面的计算没有差异,各个系统或教材上都一致。但是各个矩阵的存储使时,根据存储方式的不同,分为两个派别:行主序与列主序。行主序是指以行为优先单位,在内存中逐行存储;列主序是指以列为优先单位,在内存中逐列存储。Opengl是列主序矩阵。大致如下图:
我们举个例子来详细说明一下:比如下图这个数学意义上的标准矩阵,在矩阵相乘时不会受到影响。
但如果涉及到矩阵数据的存储,就会涉及到行主序矩阵和列主序矩阵,刚刚这个向量在不同方式下存储顺序。
- 行主序矩阵
如果以列主序存储该矩阵,在内存中的布局如下图所示:
- 列主序矩阵
如果以列主序存储该矩阵,在内存中的布局如下图所示:
在OpenGL中,矩阵通常使用列主序(column-major)的方式进行存储,这意味着矩阵的列是依次存储在内存中的。这与一些编程语言和数学教材中使用的行主序(row-major)方式有所不同。这一点在矩阵乘法运算中特别重要,因为你需要考虑到矩阵在内存中的存储方式。
在OpenGL中,你可能会使用类似于GLM(OpenGL Mathematics)这样的数学库来处理矩阵运算。GLM 提供了方便的接口和函数来处理矩阵操作,包括矩阵乘法。无论你是手动进行矩阵操作还是使用数学库,都需要考虑矩阵的存储顺序和维度,以确保正确的计算结果。
6、OpenGL中的矩阵转换操作
在OpenGL固定管线操作过程中,矩阵概念已经被包含到了gluPerspective函数、gluLookat、glTransform、glRotate、glScare函数中,这些函数封装了矩阵的操作。让我们在不了解线性代数的情况下,也可以感知到三维世界的变换,我们接下来就对以上操作进行剖析,了解他们的矩阵操作是怎样进行的。在OpenGL中所有的矩阵变换操作的都是4×4矩阵。
说到矩阵变换操作,就不得不提MVP矩阵。MVP变换就是我们常说的模型(Model)、视图(View)、投影(Projection)变换三个单词的缩写,分别对应模型矩阵、视图矩阵和投影矩阵。在更高流程中我们遇到最多的就是在着色器中获取定点的矩阵转换。
MVP矩阵转换操作是Opengl中矩阵转换的基本操作方式。我们本节要在此基础上,将进一步了解三维坐标转二维屏幕坐标的详细过程,完成以下流程的全过程。接下来,我们将对以上每个模块进行分析和说明,在了解每个模块的大致内容后,再通过实战融会贯通整个流程。
6.1、模型矩阵(Model Maxtrix)
通过矩阵,我们可以方便实现的位移、旋转和缩放功能,可以对三维空间中的向量顶点进行操作。
6.1.1、平移操作
位移(Translation)是在原始向量的基础上,加上另一个向量,从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量。这里T向量就是我们glTranslate*()中的位置参数。
比如我们现在有一个坐标是xyz(2,3,4),那么我们想给他做一个平移,想让他分别沿xyz轴平移一(1,2,3),我们只需用一个顶点的齐次向量乘以一个位移矩阵,即可得到我们最新的位置。
6.1.2、旋转操作
旋转(Rotation)是给定一个角度,可以把一个向量变换为一个经过旋转的新向量。这通常是使用一系列正弦和余弦函数(一般简称sin和cos)各种巧妙的组合得到的,这里θ就是我们平常glRotate*()中的第一个角度参数的弧度值。
-
沿x轴旋转:
-
沿y轴旋转:
-
沿z轴旋转:
6.1.3、矩阵的缩放
对一个向量进行缩放(Scaling)就是对向量的长度进行缩放,而保持它的方向不变。这里S向量就是我们glScare*()中的三个缩放比例参数。
6.1.4、矩阵的组合
使用矩阵进行变换的真正魅力在于,根据矩阵之间的乘法,我们可以把多个变换组合到一个矩阵中。让我们看看我们是否能生成一个变换矩阵,让它组合多个变换。假设我们有一个顶点(x, y, z),我们希望将其缩放2倍,然后位移(1, 2, 3)个单位。我们需要一个位移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:
注意,当矩阵相乘时我们先写位移再写缩放变换的。矩阵乘法是不遵守交换律的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该从右向左读这个乘法。建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则它们会(消极地)互相影响。比如,如果你先位移再缩放,位移的向量也会同样被缩放。用最终的组合变换矩阵左乘我们的向量,与分步变换效果一样,最后会得到以下结果:
6.1.5、模型矩阵深入拓展
模型矩阵操作主要包括位移、旋转和缩放,也就是OpenGL中提供的glTranslate* (),glRotate*(),glScare*()等函数实现的功能,这些函数的实质操作都是把一个新的4×4矩阵与当前的模型矩阵相乘,类似于glMultiMatrix*()的功能,产生并保持变换后新的模型矩阵。不同的是变换函数glTranslate*(),glRotate* (),glScare*()等会根据函数参数构造一个4×4矩阵,后续的矩阵是右乘当前矩阵。模型矩阵主要涉及到矩阵的平移、矩阵的旋转和矩阵的放大缩小。比如我们要实现以下操作,就是对当前的模型矩阵Model Maxtrix不断地进行改变并保持最新的模型矩阵。
glTranslated(0.0, 0.0, -5.0); //移动位置
glScaled(1.0, 2.0, 1.0); //放大缩小
glRotated(20., 1.0, 0.0, 0.0); //x为旋转轴
其实在OpenGL内部的实现就是将相应的变换矩阵与当前的矩阵相乘,所以下面我们就利用OpenGL提供的函数:glMultMatrixf(const GLFloat* m )实现相同的效果。有一点必须注意:在OpenGL中矩阵是列优先或者说列主序指定的矩阵,所以在存储时为转置矩阵。至于这些矩阵怎么得出来的,我们刚刚已经对矩阵操作进行了描述,这里直接使用就可以了。我们不对具体操作理论进行研究,只对计算机图形学必备知识的了解及实战应用,如果您确有需要可以在网上搜索很多详细了课程,此处不再详细了解。下面是用矩阵实现的代码,可以测试,效果一样。
//注意OpenGL里面的矩阵在存储方式主列矩阵,是我们上面数学层面矩阵的转置矩阵
float TransMatrix[16]={
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, -5.0, 1.0
};
glMultMatrixf(TransMatrix);
float ScaleMatrix[16]={
1.0, 0.0, 0.0, 0.0,
0.0, 2.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
};
glMultMatrixf(ScaleMatrix);
float RotateMatrix[16]={
1.0, 0.0, 0.0, 0.0,
0.0, cos(20.0), sin(20.0), 0.0,
0.0, -sin(20.0), cos(20.0), 0.0,
0.0, 0.0, 0.0, 1.0
};
glMultMatrixf(RotateMatrix);
上面用的是三个矩阵, 其实一个矩阵一步就可以实现,就是先计算上面的三个矩阵的乘积,经过计算的矩阵如下:
float FinalMatrix[16]={
1.000000, 0.000000, 0.000000, 0.000000,
0.000000, 0.816164, 0.912945, 0.000000,
0.000000, -1.825891, 0.408082, 0.000000,
0.000000, 0.000000, -5.000000, 1.000000
};
glMultMatrixf(FinalMatrix);
下边这种采用矩阵操作组合的方式非常的方便,与上边分步骤实现三种操作的效果完全一样。从这里可以看出,向量和矩阵是计算机图形学的有力工具,能够给编程带来巨大的便利性。
6.2、投影矩阵(Projection Matrix)
投影矩阵用于投影变换,投影变换是三维场景中的物体正确渲染到二维屏幕的重要过程之一。矩阵通常包括透视投影(Perspective Projection)和正交投影矩阵(Orthographic Projection)。
6.2.1 透视投影(Perspective Projection)
在透视矩阵中,有几个重要元素:视场角、成像设备的宽高比、场景中能看到的最近距离以及最远距离,通过这几个参数可以定义一个视锥体对象,从而模拟人眼或者相机的在三维空间中的成像原理,通常有这个几个值就可以构造一个4x4的矩阵,通过OpenGL提供的接口设置即可。
我们这里不研究矩阵是怎样推导过程,这个推导过程太过专业,而且网上有很多推导的流程,这里就不复述了。我们最主要就是知道实现过程就可以,透视投影矩阵其实就是根据我们glPerspective函数的参数,确定一个投影矩阵,也就是我们MVP里面ProjectionMatrix。具体的透视投影矩阵公式如下:
这是我们平时使用的透视矩阵函数,我们将结合这个函数来说明透视投影矩阵是怎样生成的,其中800和600为我们屏幕宽和高,也是glViewPort视口函数中设置的参数。
gluPerspective(45,800.0f/600.0f,0.01f,1000.0f);
gluPerspective的核心是确定glFrustum的参数:
void gluPerspective(
double fov,
double aspect,
double znear, float zfar)
{
double const height = znear * tanf(fovy * M_PI / 360.0);
double const width = height * aspect;
glFrustum(-width, width, -height, height, znear, zfar);
}
这就是gluPerspective所能做的。就是要注意一点,参数fovy 是我们视角的张开角度,我们在求tanf时,应该用参数fovy角度45度的一半 ,上面的计算理解成tanf(fovy / 2 * M_PI / 180.0)更便于理解,也就是先获取参数fov角度的一半,再转换成tanf函数需要的弧度值。M_PI是一个常量,代表圆周率π,通常在math.h头文件中定义。函数gluPerspective就是把几个参数传给了glFrustum函数,函数glFrustum本身也很简单。
void glFrustum(double l, double r, double b, double t, double n, double f)
{
double M[4][4];
M[0][0] = 2.f*n/(r-l);
M[0][1] = M[0][2] = M[0][3] = 0.f;
M[1][1] = 2.*n/(t-b);
M[1][0] = M[1][2] = M[1][3] = 0.f;
M[2][0] = (r+l)/(r-l);
M[2][1] = (t+b)/(t-b);
M[2][2] = -(f+n)/(f-n);
M[2][3] = -1.f;
M[3][2] = -2.f*(f*n)/(f-n);
M[3][0] = M[3][1] = M[3][3] = 0.f;
glMultMatrixd(&M[0][0]);
}
根据,我们的参数,以上透视投影矩阵公示中的参数n(表示near)为0.01f,参数f(表示far)为1000.0f,参数left=l, right=r, top=t, bottom=b,分别对应透视视图近平面的左、右、上、下边界值,已在gluPerspective中进行计算,并赋值给glFrustum函数。
glFrustum函数就更简单了,依次将参数保存到矩阵数组,就可以得到透视投影矩阵。因此,只要有了矩阵公式,获取透视投影矩阵的难度基本为零,我们就可以自己实现一个MVP矩阵中的透视投影P矩阵(ProjectionMatrix)。最终,我们获取了投影矩阵如下图:
6.2.2、正交投影(Orthographic Projection)
同样,正交投影矩阵也可以用相同的原理去理解。只不过正交投影的实现函数为glOrtho函数,不再是gluPerspective函数。同时,正交投影矩阵的公式也会和透视投影矩阵公示有差别。
6.3、视图矩阵(View Matrix)
在我们观察世界的过程中,视图矩阵(更形象的叫观察矩阵)可以把所有世界坐标变换成观察坐标。此前gluLookat函数的作用就是产生一个这样的视图矩阵,那么让我们首先先来熟悉一下gluLookat函数。
void gluLookAt(GLdouble eyex,GLdouble eyey,GLdouble eyez,
GLdouble centerx,GLdouble centery,GLdouble centerz,
GLdouble upx,GLdouble upy,GLdouble upz);
//eyex, eyey,eyez 指定摄像机(眼睛)视点的位置
//centerx,centery,centerz 指定摄像机(眼睛)视线前方参考点的位置
//upx,upy,upz 指定摄像机(眼睛)视点向上的方向
从gluLookat函数9个参数中我们获取了eye、center和up三个参数向量,分别指定视点位置、视线前方参考点的位置和视点向上的方向,这三个参数向量都是世界空间下的坐标。那么gluLookat函数是怎样通过以上输入参数向量获取视图矩阵的呢?如果要获取观察矩阵,我们需要通过以上三个参数向量去计算Position(摄像机位置)、Direction(下图蓝色箭头)、Right(下图红色箭头)和Up(下图绿色箭头)这四个向量,如下图所示:
注意这里的绿色箭头向量(Up)和灰色参数向量(up)是两个不同的向量,注意区分。
我们来详细介绍一下这些向量:
(1)摄像机位置向量(Position,简写为P):摄像机是在世界空间中的一个位置,其实就是参数的eye向量,即Position = eye。
(2)摄像机方向向量(Direction,简写为D):Direction方向向量获取方法非常简单,即Direction = eye – center,也就是两个参数向量相减,并把Direction方向向量单位化。
(3)摄像机右向量(Right,简写为R):参数向量up和Direction方向向量叉乘得到Right右向量,即Right = up X Direction,并把Right右向量单位化。
(4)摄像机上向量(Up,简写为U):然后Direction和Right向量叉乘得到Up上向量,即Up = Direction X Right,并把Up上向量单位化。
与局部坐标中UVN的关系:在一些教程中采用的是局部坐标的UVN向量,在视点或相机的局部坐标中,视点在局部坐标系处于坐标原点,向量UVN分别从原点指向右方、上方和后方,我们这里的Right对应U向量,Up对应V向量,Direction对应N向量,其实是一样的。在摄像机的局部坐标系中Right,Up,Direction分别指向右方、上方和后方从而构成右手坐标系,视点则在局部坐标系中处于坐标原点。
有了以上Position(相机位置)、Direction(下图蓝色箭头)、Right(下图红色箭头)和Up(下图绿色箭头)这四个向量,我们就可以轻松的通过矩阵公式获取到视图矩阵,这里我们不研究公式推导过程,推导过程在网上介绍的较多,具体公式如下:
我们来举个获取视图矩阵的例子:
//通过gluLookAt将计算获取的视图矩阵
gluLookAt(10.0f,10.0f,10.0f,0.0f,0.0f,0.0f,0.0f,1.0f,0.0f);
那么gluLookAt函数对应的参数向量分别为:eye(10,10,10),center(0,0,0)和up(0,1,0)。
这样,我们就获得了一个视图矩阵。
6.4、透视除法
这里将引入一个重要概念叫“透视除法”,即x,y,z分量分别与齐次分量w相除(w越大说明顶点越远),将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。
7、矩阵操作流程整合(三维坐标转二维屏幕坐标)
现在我们需要梳理一下上面介绍的知识,结合实际案例来一次实战操作。我们以下演示就是想讲清楚一个问题,三维顶点P1和顶点P2对应到程序窗口屏幕上的坐标是多少?看看系统是怎样通过下图一步一步将一个三维坐标转二维屏幕坐标的。
7.1、自定义局部坐标顶点
首先,我们定义个了两个局部坐标点P1(2,0,2)和点P2(5,6,2),这两个坐标就是局部空间坐标,后期通过VBO、VAO将顶点数据传递给着色器。
//定义顶点对象
float vertices[] = {
//position //color
2.0f, 0.0f, 2.0f, 1.0f,1.0f,0.0f,
5.0f, 6.0f, 2.0f, 1.0f,1.0f,0.0f,
};
7.2、自定义视口
接着,我们会使用glViewport函数设置视口,这里为了方便测试,我们设置程序窗口为800 * 600像素的宽高,那么使用glViewport也设置成窗口的宽和高800 * 600。
//设置视口为宽高为800*600的窗口
glViewport(0,0,800.0f,600.f);
7.3、设置透视投影模式
接着我们进入程序渲染循环,我们会运行glMatrixMode将当前矩阵模式设置为投影矩阵模式,那么此后的矩阵操作会改变系统的投影矩阵。
//设置当前模式为投影模式
glMatrixMode(GL_PROJECTION);
//将当前投影矩阵重置为单位矩阵(图1)(矩阵对角线为1,其余为0)
glLoadIdentity();
//通过gluPerspective将计算获取的透视投影矩阵(图2)并保存
gluPerspective(45,800.0f/600.0f,0.01f,1000.0f);
紧接着glLoadIdentity()函数会将当前投影矩阵重置为单位矩阵(图1)。
随后,我们通过gluPerspective函数计算透视投影矩阵(图2),并将透视投影矩阵保存到系统中。根据我们前面讲到的计算方法,最终的透视投影矩阵如下图:
经过以上操作我们的MVP中的(透视)投影矩阵ProjectionMatrix已处理完毕,后期可以直接用glGetFloatv函数调取使用。
7.4、视图矩阵
接下来,我们通过glMatrixMode函数重新设置当前矩阵模式为模型视图矩阵模式,也就是默认退出了投影矩阵模式,此后对矩阵的操作就是针对模型视图矩阵的。同时继续使用glLoadIdentity()对当前的模型视图矩阵进行重置为单位矩阵。
//设置当前模式为模型视图模式(细分可分为模型、视图两个部分)
glMatrixMode(GL_MODELVIEW);
//将当前模型视图矩阵重置为单位矩阵(图1)(矩阵对角线为1,其余为0)
glLoadIdentity();
//通过gluLookAt将计算获取的视图矩阵(图3)并保存,注意这里只涉及视图(视角)矩阵,不涉及模型矩阵,也就是我们眼睛视角矩阵
gluLookAt(10.0f,10.0f,10.0f,0.0f,0.0f,0.0f,0.0f,1.0f,0.0f);
在OpenGL中模型矩阵和视图矩阵是一起保存的,所以这里开始操作的是模型视图矩阵。只是我们目前还没有进行模型矩阵相关操作。随后,我们通过gluLookAt计算视图矩阵(图3),并将计算结果保存到模型视图矩阵中。根据我们前面讲到的视图矩阵计算方法,最终的视图矩阵如下图:
接下来,我们将继续在视图矩阵的基础上进行模型变换矩阵操作。
7.5、模型矩阵
在刚刚计算并保存视图矩阵后,我们继续进行模型变换操作。
//保存当前模型视图矩阵(图3)到栈,方便后续出栈后恢复当前保存的矩阵
glPushMatrix();
//通过glTranslatef将平移矩阵(图8)与当前视图矩阵(图7)相乘,得到新的模型视图矩阵(图4)
glTranslatef(1,2,3);
这里我们使用了glPushMatrix保存当前模型视图矩阵到栈,方便后续出栈后恢复当前保存的矩阵。比如现在系统模型视图矩阵中保存的仅仅是视图矩阵,我们将模型视图矩阵存入栈中,后期不管模型视图矩阵怎么变换,使用glPopMatrix就可恢复到原始的视图矩阵,这就是glPushMatrix和glPopMatrix的工作原理。
那么接下来,我们使用glTranslatef进行了模型平移,根据我们前边讲过的平移矩阵公式,平移矩阵如下:
函数glTranslatef完成的操作就是将上边这个平移矩阵(图4)与系统中的已存在的模型视图矩阵(图3)相乘,获取最新的模型视图矩阵(图5),这个最新的矩阵就是我们前边讲过的矩阵的组合,只不过这里是模型矩阵与视图矩阵的组合。如下图:
为了方便,我们这里只进行了平移操作,其实还可以继续添加旋转、缩放等操作,这些后续的操作都会成为组合矩阵保存到系统的模型视图矩阵中。
7.6、传递着色器MVP矩阵
MVP矩阵分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。通过我们刚才的操作,我们已经获取了透视投影矩阵(图2)、模型视图矩阵(图5)。接着我们启用着色器,并将投影矩阵和模型视图矩阵通过uniform变量传递给着色器,代码如下:
//使用着色器
glUseProgram(shaderProgram);
//获取当前模型视图矩阵(图7),并传递给着色器(shader)
glGetFloatv(GL_MODELVIEW_MATRIX, ModelViewMatrix);
unsigned int transformLoc0 = glGetUniformLocation(shaderProgram, "ModelViewMatrix");//获得着色器中变换矩阵位置
glUniformMatrix4fv(transformLoc0, 1, GL_FALSE, ModelViewMatrix);
//获取当前透视投影矩阵(图2),并传递给着色器(shader)
glGetFloatv(GL_PROJECTION_MATRIX, ProjectionMatrix);
unsigned int transformLoc1 = glGetUniformLocation(shaderProgram, "ProjectionMatrix");//获得着色器中变换矩阵位置
glUniformMatrix4fv(transformLoc1, 1, GL_FALSE, ProjectionMatrix);
7.7、顶点坐标通过矩阵转剪裁坐标
进入顶点着色器后,就可以直接由投影矩阵和模型视图矩阵计算出MVP矩阵(图6),如下图:
随后,就可以由MVP和顶点局部坐标相乘获取到剪裁坐标(图7和图8),P1的剪裁坐标为(-2.56,-3.94,11.53,11.55),P2的剪裁坐标为(1.28,4.93,6.33,6.55),并传递给顶点着色器的gl_Position变量,由系统后续自动进行透视除法和视口剪裁处理。
顶点着色器程序如下:
const char *vertexShaderSource = "#version 330 core\n"
"//位置变量的属性位置值为0,由glVertexAttribPointer函数指定\n"
"layout (location = 0) in vec3 position;\n"
"//位置变量的属性位置值为1,由glVertexAttribPointer函数指定\n"
"layout (location = 1) in vec3 color;\n"
"//定义输出颜色\n"
"out vec3 vertexColor;\n"
"//传入模型视图矩阵(图7)和投影矩阵(图2)\n"
"uniform mat4 ProjectionMatrix;\n"
"uniform mat4 ModelViewMatrix;\n"
"void main()\n"
"{\n"
" //通过矩阵转换获取转换后的坐标位置并传递到gl_Position中(共两个点的向量,分别为图9、图10)"
" gl_Position = ProjectionMatrix*ModelViewMatrix*vec4(position,1);\n"
" vertexColor = color;\n"
"}\0";
当然,你也可以直接用MVP矩阵来直接计算剪裁坐标,效果是一样的,但要记住我们需要从右往左阅读矩阵的乘法。先计算顶点局部坐标向量和模型矩阵的结果,在计算视图矩阵,最后计算透视投影矩阵。
通过以上计算,我们获取了局部顶点P1和P2的剪裁坐标。经过以上变换,再经过片段着色器的处理(这里由于篇幅和重要性问题没有写出片段着色器),我们就可以借助glDrawArrays函数绘制出线段P1P2(图13)。
7.8、探究透视除法
一般情况下,我们在顶点着色器处理完毕顶点MVP矩阵转换,并传递给顶点着色器的gl_Position变量后,由系统后续自动进行透视除法和视口转换处理,我们无需手动处理。但如果现在想了解三维坐标怎样转换为二维窗口屏幕坐标,我们就需要继续往后了解。这里我们根据前面讲到的透视除法算法,对两个顶点剪裁坐标进行透视除法处理。
经过透视除法处理后,我们的剪裁坐标就转换成为标准化设备坐标(图11)。标准化设备坐标是真正绘制在屏幕内顶点的坐标,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。P1的标准化设备坐标为(-0.22,-0.34,1.00,1.00),P2的标准化设备坐标为(0.20,0.78,1.00,1.00)。计算到这,我们已经完成三维坐标转换二维屏幕坐标90%的工作量。
7.9、视口转换获取最终二维屏幕坐标
我们刚刚得到了标准化设备坐标,标准化设备坐标转与屏幕坐标有一一对应的关系。比如
由于我们此前设定程序的屏幕是800 * 600宽和高的,也就是横向0到800像素,纵向0到600像素。而标准化坐标为-1到1的,也就是横向为-1到1的相对坐标,纵向也是-1到1的相对坐标。因此只需要将顶点从-1到1的标准化设备坐标再转换到800 * 600的实际屏幕坐标即可,其中,(-1,-1)对应(0,0)点,(1,1)对应(800,600)点,那这就简单了。这里要注意OpenGL的二维屏幕坐标左下角为(0,0)点,右上角就为(800,600)点。根据两个坐标的线性转换关系,就可计算出P1和P2点在屏幕坐标的位置,P1的屏幕坐标为(311.30,197.57),P2的屏幕坐标为(480.64,532.79)。
通过以上操作,我们就从局部坐标一步步经过转换,最终获得了正确的P1和P2顶点在显示屏幕上的坐标。
7.10、程序的主要源码部分
首先,我们定义个了两个局部坐标点P1(2,0,2)和点P2(5,6,2),后期通过VBO、VAO将顶点数据传递给着色器。
//定义顶点对象
float vertices[] = {
//position //color
2.0f, 0.0f, 2.0f, 1.0f,1.0f,0.0f,
5.0f, 6.0f, 2.0f, 1.0f,1.0f,0.0f,
};
接着我们设置视口、投影矩阵、视图矩阵,调整模型矩阵,并通过着色器程序将这两个黄色顶点的连线显示在程序屏幕窗口上。
//设置视口为宽高为800*600的窗口
glViewport(0,0,800.0f,600.f);
//设置当前模式为透视投影模式
glMatrixMode(GL_PROJECTION);
//将当前透视投影矩阵重置为单位矩阵(图1)(矩阵对角线为1,其余为0)
glLoadIdentity();
//通过gluPerspective将计算获取的透视投影矩阵(图2)并保存
gluPerspective(45,1024.0f/768.0f,0.01f,1000.0f);
......
//设置当前模式为模型视图模式(细分可分为模型、视图两个部分)
glMatrixMode(GL_MODELVIEW);
//将当前模型视图矩阵重置为单位矩阵(图1)(矩阵对角线为1,其余为0)
glLoadIdentity();
//通过gluLookAt将计算获取的视图矩阵(图3)并保存,注意这里只涉及视图(视角)矩阵,不涉及模型矩阵,也就是我们眼睛视角矩阵
gluLookAt(10.0f,10.0f,10.0f,0.0f,0.0f,0.0f,0.0f,1.0f,0.0f);
......
//保存当前模型视图矩阵(图3)到栈,方便后续出栈后恢复当前保存的矩阵
glPushMatrix();
//通过glTranslatef将平移矩阵(图8)与当前视图矩阵(图7)相乘,得到新的模型视图矩阵(图4)
glTranslatef(1,2,3);
......
//使用着色器
glUseProgram(shaderProgram);
//获取当前模型视图矩阵(图7),并传递给着色器(shader)
glGetFloatv(GL_MODELVIEW_MATRIX, ModelViewMatrix);
unsigned int transformLoc0 = glGetUniformLocation(shaderProgram, "ModelViewMatrix");//获得着色器中变换矩阵位置
glUniformMatrix4fv(transformLoc0, 1, GL_FALSE, ModelViewMatrix);
//获取当前透视投影矩阵(图2),并传递给着色器(shader)
glGetFloatv(GL_PROJECTION_MATRIX, ProjectionMatrix);
unsigned int transformLoc1 = glGetUniformLocation(shaderProgram, "ProjectionMatrix");//获得着色器中变换矩阵位置
glUniformMatrix4fv(transformLoc1, 1, GL_FALSE, ProjectionMatrix);
//绑定VAO
glBindVertexArray(VAO);
//绘制图元为线段,起始索引0,绘制顶点数量6
glDrawArrays(GL_LINES, 0, 6);
//解绑VAO
glBindVertexArray(0);
//退出当前着色器
glUseProgram(0);
.......
//当前模型视图模式下,将矩阵退出栈操作,以便恢复到此前的模型视图矩阵(图3)
glPopMatrix();
设置顶点着色器,这里进行了MVP矩阵的传递。VBO、VAO和片段着色器部分比较常规,这里省略。
const char *vertexShaderSource = "#version 330 core\n"
"//位置变量的属性位置值为0,由glVertexAttribPointer函数指定\n"
"layout (location = 0) in vec3 position;\n"
"//位置变量的属性位置值为1,由glVertexAttribPointer函数指定\n"
"layout (location = 1) in vec3 color;\n"
"//定义输出颜色\n"
"out vec3 vertexColor;\n"
"//传入模型视图矩阵(图7)和投影矩阵(图2)\n"
"uniform mat4 ProjectionMatrix;\n"
"uniform mat4 ModelViewMatrix;\n"
"void main()\n"
"{\n"
" //通过矩阵转换获取转换后的坐标位置并传递到gl_Position中(共两个点的向量,分别为图9、图10)"
" gl_Position = ProjectionMatrix*ModelViewMatrix*vec4(position,1);\n"
" vertexColor = color;\n"
"}\0";
8、小结
通过这节课,我们对OpenGL中矩阵的操作是不是通透多了,我们了解了此前编程中的为什么没有矩阵概念。其实矩阵概念已经被深深地包含到了gluPerspective、gluLlookat函数,以及glTransform,glRotate和glScare等函数中。这些函数封装了矩阵的操作,让我们在不了解线性代数的情况下,也可以通过视角感知到三维世界的移动、旋转及缩放。现在我们根据本节课的这些知识,就可以游刃有余的使用自定义矩阵或系统提供的矩阵操作函数。比如,如果我们可以从头开始完全使用自定义矩阵,通过MVP矩阵传递给着色器,由着色器将世界万物显示在三维空间中。当然我们也可以采用自定义矩阵和系统矩阵操作函数两者相结合的方式,相互穿插使用。我们教程就采用这种方式,因为完全自定义矩阵的操作对数学知识的要求还是蛮高的,同时如果全部使用着色器,所有的光照效果等常规操作都需要我们在着色器中自定义,所以我们采用了两者相结合的方式。相信大家在熟悉本节操作后,就能够基本了解图像渲染的大致流程,更好的掌握图像渲染的要点了。三维世界因为有了矩阵而变得如此完美。
【上一节】:🔥C++和OpenGL实现3D游戏编程【连载15】——着色器初步
【下一节】:🔥持续更新中…