本人不是计算机专业出身,本科是数学专业的,最近读在职研究生的课程,图形学的课程老师要求用着色器实现一个简单的纹理映射和法线映射,因为之前没什么编程基础,很多基本的东西都不会,因此是从零学起,先在网上下载了一个VS2017,学了一下C++,然后按照教程配置GLUT和GLEW,老师给了一个叫FreeImage的库,用来加载图片的。有了这几个工具就可以直接做了,不需要再下载glm库之类的。
网上很多教程需要各种扩展的函数库,我之所以没用一是因为配置起来比较麻烦,二是毕竟我要交作业,如果我包含很多其他的函数,老师那里配置起来也麻烦,因此最终我的代码只有一段,不包含除了上面说的几个工具之外任何一个头文件。
而且我在学习的过程中发现,网上有很多教程,因为写的时间不一样,用的标准也不相同。因为OpenGL的标准经历过很多的变化,因此我特意查了一下,到底各个标准之间是什么关系。也会写在里面。
我学了这段时间有一些体会,至少我实现的这些功能都弄明白了,因此写了这篇东西,算是对自己学习的一个总结。由于篇幅原因,我没有写得太细致,很多细节的问题看一眼源代码就可以很清楚,再不清楚的话网上查一查也就知道了,我就把一些基本思想以及我遇到的小问题写下来。
由于本人刚入门,以后有一些新的想法也会陆续写下来。
我之前由于不是计算机专业,基本没接触过编程语言,只用过MATLAB。以为OpenGL就跟MATLAB一样,上网下载一个东西,进去有个窗口就能编程了。后来才明白,OpenGL是一个开放的平台,你可以用任何语言,在任何平台上实现,而且已经内置在里面,不用再下载新东西。我看到网上有人用JAVA,而我用的C++,在VS2017上面实现的,也就是说,你想学习OpenGL只需要有一个Visual Studio 2017,里面有C++就行了,除了我下面说的几个外,就不需要再下载别的东西了。
下面说说GLUT。OpenGL虽然功能强大,但并没有窗口管理界面,也就是说你弄了半天,它不能创建窗口,把你想看见的东西显示出来,这就很尴尬了,因此有一个专门做窗口管理的库,叫做GLUT。这个东西网上就可以直接下载,配置实际上就是把几个文件拷到几个文件夹中就行了。你会看到还有一个叫FREEGLUT的,功能与GLUT其实是一样的。只是GLUT貌似1998年之后就没有更新过了,而FREEGLUT还有人更新,但由于我们也不需要它来实现什么复杂的工作,只是用来作窗口管理,因此GLUT足够了。我也试过FREEGLUT,但因为要自己生成文件,总配置不成功,因此就直接用GLUT了。
GLEW是为了实现着色器的某些功能而用的,具体都有哪些功能我现在也不是太清楚,但它也是必须的。跟GLUT一样下载下来,按照网上教的配置好就行了。
而FreeImage是一个图形库,在我这里它就是一个读取图片文件的东西,因为这个库的存在,使得读取图片变得很简单。OpenGL也可以直接读取图片,但一是代码比较麻烦,二是有很多图片格式的限制,用这个库就省了很多麻烦。我后面要讲的纹理贴图法线贴图等都需要读取图片,因此跟前面一样,把它下载下来,一样的方法配置好就行了。
看过网上教程的可能会发现,有些人没有用GLUT,而是用的GLFW,这两个东西都是用来管理窗口的,功能类似,因此有些程序中某些函数看到glfw开头的不要陌生,类比glut开头的就行了。
下面我们就要开始正式的编程了。
首先我们打开vs2017,新建项目->Windows桌面向导,输入个名称->选控制台应用程序,只勾选空项目,确定就可以了。
然后我们新建一个源文件(.cpp),就可以开始写了。
我们先把头文件都包含进去,注意#include
要写在#include
之前,具体我也不知道为什么,如果写在之后会有错误。
再有就是main函数就是管理窗口用的,其中:
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowPosition(100, 100);
glutInitWindowSize(winWidth, winHeight);
glutCreateWindow("不忘初心方得始终");
功能是初始化GLUT,并给窗口定位,定义窗口大小,并且给窗口命名的,到时候在我的源码上改一改就明白作用了。类似的我们还可以在main函数中定义窗口的背景颜色等等,具体请看我源码注释。
有了窗口,我们就要画一些图形,我这个程序中画了一个立方体。OpenGL和GLUT中有一些自带的函数,比如很经典的犹他茶壶glutSolidTeapot,就是用贝塞尔曲线画的,这些书本上都有,但我们事实上不需要知道具体的过程,我们只需要用这个函数就可以画出来了。自带的函数虽然简单,但有些东西,比如法线,纹理坐标等我们不方便自己设置,因此我们应该知道如何来画一个简单的图形。
首先我们要知道顶点缓冲区,我们可以这么理解,我们要画一个东西,首先要知道这个东西在空间中的位置,这个位置用什么刻画呢,就是顶点,比如立方体有8个顶点,我们把这些顶点的坐标信息输入到GPU中就可以画出来了。储存这些顶点信息的地方就叫顶点缓冲区。当然画的时候并不是直接就画出了这个立体图形,而是一个面一个面地画,还要标明我们到底要画什么图形,这个用OpenGL中一些定义好的常量表示,比如我要画四边形,就是GL_QUADS。
系统是这样操作的:比如我们要画三角形,它就会从第一个顶点开始,依次读入三个点,画一个三角形,再读入三个点,再画一个。而在实际中,我们不可能只画一个三角形,可能需要画很多三角形把他们连起来取近似一个曲面,因此这些三角形有很多共享的顶点。比如两个三角形共用一条边,事实上只有四个顶点,但我们为了画它,就必须输入六个顶点信息,让它画两个,否则就画不出来了。这样增大了我们的工作量,因此我们引入一个新的概念,就是索引缓冲。
比如我们画立方体,如果按照四边形来画,总共要画六个面,每个面4个顶点,总共要输入24个顶点信息,而事实上这24个顶点每个都有三个重复的,因为每个顶点都是三个面共用的。所以我们将立方体的8个顶点编号,每次绘制一个面就从这8个编号中选,这样我们可以定义一个包含24个编号的区域,称为索引缓冲,这24个编号是从0-7这8个数字中取的。因为每个顶点都是浮点型,空间很大,而索引可以用整型,第一是空间小,第二是我们可以减少重复的输入,也方便修改。
当然,我这个程序中,因为就画一个立方体,而且每个面的法向量不同,所以没有用索引缓冲,但这个概念需要理解。
以顶点缓冲为例,索引缓冲也类似,过程是这样实现的:我们要先创立一块顶点缓冲区,然后用一个变量绑定到这个缓冲区,还要声明它的大小:
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(Vertices), Vertices, GL_STATIC_DRAW);
之后我们就可以用一个函数来完成绘制的过程,在这个函数中我们可以指定很多东西,比如消除隐藏面,视见体,观察坐标系选择,最重要的是画出这个图形
glDrawArrays(GL_QUADS, 0, 24);
第一个参数是指画的四边形,第二个参数是指从第几个顶点开始画,第三个参数是指总共画多少个顶点,刚才我们已经说了,每个面都要画,因此是24个顶点。
当然,顶点信息也不只是坐标,还包括纹理坐标,法向量,切向量等,我们后面要一一说明。
我们在前面画完了一个图形后,就要把它显示出来,那么怎么显示呢?
我们要先定义一个投影的方式,我们一般都用透视投影,这是符合正常观察规律的,就是所谓的近大远小。那么我们就要定义一个观察的方向,这个方向包括我们眼睛的位置以及看向的位置。
gluLookAt(0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0);
其中前三个分量表示眼睛的位置,4-6个分量表示看向的中心的方向,后三个分量表示头顶向上的方向,这个也可以自己改变数值去试一下。
下面这个函数:
glFrustum(-1.0, 1.0, -1.0, 1.0, 2.0, 8.0);
定义了一个称为视见体的东西,事实上就是一个平截头体。具体的图我就不画了,感兴趣的可以上网找,或者任意的计算机图形学的书上都有。前四个分量分别是眼睛前面那个面的上下限,后两个分量表示前面和后面距离眼睛的距离。
我们再看一下物体的变换,刚才我们通过在空间中定义物体的顶点来定义物体的大小和位置。这个物体画好后,我们可以对它进行一些变换,包括移动位置,旋转它,或者是放大/缩小它。
无论我们怎么变化它,它顶点的坐标都会发生一些变化,而这个变化如何表示就是我们主要研究的问题。事实上,无论怎样变化,都相当于是把顶点坐标乘上一个矩阵,这个矩阵具体推导我就不写了,(主要是编辑公式太麻烦~~)感兴趣也可以去查书,我想说明的就是无论怎么变换,我们都需要把坐标乘上一个相应的矩阵。
在一些教程中我们经常能看到坐标用一个四维的向量来表示,我们经常用的是三维的坐标,四维的是怎么回事呢?这就是所谓的齐次坐标系,具体也可以查书,我们只需要知道这样做的目的是为了让各种变化更方便用矩阵乘法表示就可以了。而一边前三个分量就是坐标,而第四个分量通常取1。(当然有时不是1,但这里我们暂时不涉及)
比如我程序中是将物体旋转:
glRotatef(angley, 0.0, 1.0, 0.0);
glRotatef(anglex, 1.0, 0.0, 0.0);
当然,前面说的投影也可以用矩阵表示出来,这个在后面还会用到。
下面说到核心的阶段了,在讲具体内容之前,我们要先知道图形是怎么在显示器上显示出来的。
刚才我们通过程序画了一个图形,并设定好了我们如何观察它,但它究竟是什么颜色,我们并不知道。而我们现在应该知道,显示器唯一能显示出的就是颜色,不管多复杂的图形,最终显示器都是通过一个一个的像素的颜色把物体显示出来的。而颜色就是我们通常知道的红绿蓝(RGB)三种颜色的叠加。所以每一种颜色都是三个分量,用一个向量来表示,每个分量的范围都是0-1,这一点很重要,我们后面还会用到。一般的,我们还会将颜色中再加入一个分量A,表示透明度,比如我们在一个玻璃中看到外面的景色,就是透明玻璃的颜色和外面东西的叠加,现在我们还涉及不到,因此一般也将这个分量设置为1,即不透明。
计算机会先对图形的顶点进行各种变换,然后投影,这些其实都是做矩阵的乘法,最后得到最终顶点的坐标,然后开始光栅化,简单地说就是把这个图形分解成一个一个的小片,让显示器的像素把它显示出来。之后会再计算每一个小片的颜色,把这些传递到缓存中,最后打到显示器上。
这个过程中尤其重要的就是处理顶点坐标的环节和计算像素颜色的环节,我们把这两个过程叫做着色器(shader)。虽然我们现在不知道着色器具体是怎么操作的,但我们至少应该了解,着色器顾名思义就是通过各种计算让显示器知道窗口中每个像素是什么颜色,不管是纹理贴图还是光照什么的,都是通过着色器来实现的。
在过去,因为硬件的局限性,能显示的东西也有限,因此很多着色器都是编好的,我们只要像调用函数那样调用它就行了,实现的功能很单一。后来随着硬件的发展,我们已经不满足于这些固定功能的着色器,这就需要自己编写一些功能,在OpenGL中编写着色器的语言就称为GLSL语言。
在一些教程中我们会看到固定管线和可编程管线的说法,就是指的着色器是固定功能的还是可编程的,我们的显卡现在一般也都支持可编程的管线。我这个作业的重点也是着色器的编写,即GLSL语言的使用。
下面我们来看一下如何使用可编程着色器及GLSL语言。
我们不能像编译程序那样直接在VS中编写,因为着色器通过一种独特的方式被读入程序,因此我当时看的时候是一头雾水。后来明白了,简单地说,我们要先在记事本中用GLSL语言编写着色器程序,然后在我们的程序中读取出记事本中的字符串,然后用一些命令去编译。这些放着色器程序的文本文件要放在工程的文件夹下。(也就是你放源代码.cpp文件的那个文件夹)读取字符串的函数是这样的:
char *readTextFile(const char *name)
{
FILE *fp;
char *content = NULL;
int count = 0;
if (name == NULL)
return NULL;
//fopen函数有可能提示要求用fopen_s
fp = fopen(name, "rt");
if (fp == NULL)
return NULL;
fseek(fp, 0, SEEK_END);
count = ftell(fp);
rewind(fp);
if (count > 0)
{
content = (char*)malloc(sizeof(char)*(count + 1));
if (count != NULL)
{
count = fread(content, sizeof(char), count, fp);
content[count] = '\0';
}
}
fclose(fp);
return content;
}
而在程序中编译着色器的函数是这样的:
void setShaders(void)
{
//创建着色器对象
GLuint vertShader, fragShader;
vertShader = glCreateShader(GL_VERTEX_SHADER);
fragShader = glCreateShader(GL_FRAGMENT_SHADER);
GLchar *vertSource, *fragSource;
//读入着色器程序字符串
vertSource = readTextFile("vertexshader.vert");
fragSource = readTextFile("fragmentshader.frag");
//将字符串关联到着色器上
glShaderSource(vertShader, 1, (const GLchar **)&vertSource, NULL);
glShaderSource(fragShader, 1, (const GLchar **)&fragSource, NULL);
free(vertSource);
free(fragSource);
//编译着色器
glCompileShader(vertShader);
glCompileShader(fragShader);
//创建程序对象
GLuint program;
program = glCreateProgram();
//将程序对象与着色器对象关联起来,并链接整个程序
glAttachShader(program, vertShader);
glAttachShader(program, fragShader);
glLinkProgram(program);
//将着色器设置为活动的
glUseProgram(program);
//在着色器中寻找变量位置
gSampler0 = glGetUniformLocation(program, "gSampler0");
gSampler1 = glGetUniformLocation(program, "gSampler1");
}
具体细节可以看注释。
注意在编译着色器的函数中,有些书上与我这里的不同。比如glCreateShader函数,有些书里用的是glCreateShaderObjectARB,我上课的课件中用的也是后者。这两种都是可以的。ARB是制定OpenGL标准的组织,每次有修改或添加新的函数,为了区别以往的,函数名一般都带ARB后缀。可编程管线是后来才加入OpenGL的,所以才会有带ARB的函数名。等逐渐成熟了,就会用一些更通俗明显的函数名来代替带ARB的函数名。而旧的也会保留,因此才会有两种不同的函数名表示同样的意思。我用的是后来新的函数名。
通过看源代码我们发现,一般着色器都分为两个部分,第一部分是顶点着色器(vertexshader),第二部分是片元着色器(fragmentshader)。第一部分是要输入顶点及各种变换的信息,对逐个顶点进行计算,最终输出一个重要的信息就是最终变换完成后顶点的坐标。除了输出顶点坐标之外,顶点着色器中还会输出诸如法向量,纹理坐标,颜色等信息。这些信息会在传入片元着色器之前进行插值。插值简单地说就是比如我们知道两个顶点的法向量,通过计算得到顶点之间各个片元的法向量。
从这里看出,顶点着色器传出的是每个顶点的信息,而之后的插值会计算出各个片元相应的信息。注意,这里非常重要。片元着色器就是对逐个片元进行各种计算,最终输出每个片元的颜色值,即一个四维向量。
从前面的描述中我们看出,顶点着色器是对逐个顶点进行的,而片元着色器要对每个片元(像素)逐个执行计算,因此片元着色器计算量要远远大于顶点着色器。而顶点着色器在给片元着色器传递信息的过程中,要对传递的信息进行插值,保证每个片元都有需要的信息,否则片元着色器中的计算就无法进行。
到此,我们就知道了整个图形从绘制到显示的流程,下面着重说一下着色器和GLSL语言。
我们前面看到,着色器用一种很特殊的方式被读入程序,那么程序中我们指定的信息要被读入到着色器中也是有一定的规则的。
先看着色器中变量的定义,无论顶点还是片元着色器,输入的变量一般用in表示,输出的变量一般用out表示,这很容易理解。而且我前面说了,顶点着色器除了输出最终的顶点坐标之外,其余的诸如纹理坐标,法向量等都是要插值然后传入片元着色器的,因此在顶点着色器中有out形式的变量,在片元着色器中就有in形式的变量一一对应。
我们看一些教程中,变量不是用in,out来表示的,而是用attribute,varying等来表示,这也是GLSL逐渐变化的一个例子。attribute,varying等是最初的标准,后来统一都用in,out来表示了,但现在也支持老的标准,用哪种都行,混着用也可以。但还是建议用新的标准。
我们看看标uniform这个变量,这个变量称为一致变量,也就是我们从外部传入的变量,比如光源,变换矩阵等信息。这种信息一般都是在外面程序中设置的。因为从前面编译的过程中,我们发现着色器在记事本中编写,在执行过程即使有问题也不会报错,不方便调试,我们希望能编写一个着色器运用到不同的地方,而不是反复修改着色器代码。
我的程序中传入的外部变量只有两图图片的变量,在着色器中是这样的:
//纹理一致变量
uniform sampler2D gSampler0;
uniform sampler2D gSampler1;
在程序中,我们通过:
//在着色器中寻找变量位置
gSampler0 = glGetUniformLocation(program, "gSampler0");
gSampler1 = glGetUniformLocation(program, "gSampler1");
来查找着色器中变量的位置,并通过:
//设置纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex0);
glUniform1i(gSampler0, 0);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, tex1);
glUniform1i(gSampler1, 1);
将外部的变量传入着色器中。类似的我们可以将矩阵之类的信息传入着色器中。
在一些教程中,经常不用我们上面第4节中讲的各种函数进行坐标变换,而是通过一些库函数直接来设置变换和观察矩阵,用一致变量导入着色器,这种方法更有通用性,方便程序复用。这也是现在的标准所提倡的。而我为了简单,所以用的传统的方法来进行变换。
那现在就有了一个问题,我用的传统的方法,也是要在着色其中对各个顶点进行变换,也是要用到变换矩阵,那这些矩阵是用什么办法导入着色器的呢?
这就用到着色器中事先定义好的一些变量,这些变量都是用gl_开头的,比如gl_Position表示的就是最终输出的顶点坐标,gl_ModelViewProjectionMatrix表示所有的变换矩阵相乘得到的矩阵,将这个矩阵乘上原顶点坐标得到的就是gl_Position。这些变量在着色器中可以直接使用,不用声明。因此无论我怎样变换,由于用的是自带的函数,gl_ModelViewProjectionMatrix这个矩阵就是系统知道的,就不用我再往里面传递了。
一致变量还有一个应用就是利用变量的变化来做简单的动画,这个网上有教程,我的程序中没有,就不细说了。
再有就是注意顶点坐标的传递。我们在外面每个顶点可以有一堆分量,每个分量表示的含义都不同,到底哪个分量代表什么含义,我们需要让着色器知道。当然我们也可以用自带变量,但我们还是要了解一下传递的方式。例如我的程序中,每个顶点有11个分量,前三个是位置坐标,4-5是纹理坐标,6-8是法向量,9-11是切向量,我们也用索引的方式在外面定义:
//设置顶点中每个分量的含义
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glEnableVertexAttribArray(3);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)12);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)20);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)32);
我们先定义0123四个索引,然后通过偏移量来定义每个分量对应的索引值,然后在着色器中这样声明:
//定义顶点坐标的含义
layout (location = 0) in vec3 Vertex;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
layout (location = 3) in vec3 Tangent;
这样我们就把顶点坐标传入了着色器。
下面我们来说一下纹理贴图。我们画了一个图形,可以给它上颜色,颜色可以直接指定,或者按照我们的想法来进行插值,但都不如找一个我们想看到的图片直接贴上去。
纹理贴图原理是这样的:我们先定义一个叫纹理坐标的东西,这个名称前面已经说过了,比如一个长方形的图片,不管它真正有多长多宽,我们就把它的长记为u,宽记为v,u和v的范围都是0-1,也可以理解为我把一个图片拉长或者压缩放到一个1*1大小的框框里。然后我们把我们画的图形上的顶点坐标和这个uv对应,比如我画一个正方形,四个顶点分别对应的uv是(0,0),(0,1),(1,0),(1,1),那么着色器就可以把这个图片贴到这个四边形上,就相当于是把图片映射到我画的四边形上,因此也叫纹理映射。
我们要先把图片读到缓存中,才能进行下一步的映射。前面我说过,读图片我用到了FreeImage,读入之后要获得图片的宽和高,然后和前面的顶点缓冲区一样,建立一个图片缓冲区,用一致变量把图片传入着色器,然后就可以进行着色器操作。读取并加载图片的函数如下:
//用FreeImage加载图片
GLuint loadtexture(const char *filename)
{
GLuint textureID;
//获取文件格式
FREE_IMAGE_FORMAT fif = FreeImage_GetFileType(filename, 0);
//加载文件
FIBITMAP *dib = FreeImage_Load(fif, filename, 0);
//转换图片为32位
dib = FreeImage_ConvertTo32Bits(dib);
//每个像素的句柄
BYTE *pixels = (BYTE*)FreeImage_GetBits(dib);
//获取图片的宽和高
int width = FreeImage_GetWidth(dib);
int height = FreeImage_GetHeight(dib);
//创建图片缓冲,与顶点缓冲,索引缓冲类似
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
//生成纹理,有9个参数其中GL_RGBA的选择与上面32位图像有关,如果是24位,可以是GL_RGB
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
FreeImage_Unload(dib);
return textureID;
}
在着色器中用于纹理贴图的函数很简单:
vec4 color=texture2D(gSampler0, TexCoord0).bgra;
用texture2D这个函数,第一个参数是传入的图片变量,第二个参数是纹理坐标。注意最后.bgra很重要,GLSL语言中支持直接读取向量的分量,因为我们通过贴图我们得到的颜色向量是四维的,因此可以通过“向量.xyz”或者“向量.rgb’”等来读取前三个分量,具体可以参照GLSL教程。而我这里不是按RGBA的顺序来读的,而是按照BGRA的顺序,也就是蓝绿红透明度四个分量来读。与网上的一些教程不同。具体原因我还不明白,但如果按照RGBA来读颜色会出现错误,后面的法线贴图更能说明这一点。
现在物体的颜色有了,我们来看光照。
光照就要涉及法向量了。我们前面提过好几次法向量,但还没说过它究竟有什么用,现在它要派上用场了。法向量可以理解为垂直于物体表面的一个向量。
首先我们要知道,世界上所有的东西我们能看见都是因为有光,没有光我们只能看到漆黑一片。刚才通过纹理贴图我们让物体有了颜色,现在要通过光线照到物体上,我们才能真正看到物体。
在这里我们只说一下简单光照明模型:
1、环境光:可以理解为不受光源影响。就比如即使我们关了灯,周围也有一些光,这个光打到物体上,有一定的反射系数,用这个光强乘以反射系数,就得到我们看到的颜色。
2、漫反射光:这里引入点光源的概念,就好比一个灯泡,向四面八方发射光线,我们暂且不考虑光线减弱的问题,光照到物体上,物体反射多少取决于光线和和物体表面法向量的夹角。夹角越大反射的越少,夹角为零反射的最多,这和我们生活中的经验是相符的,如果夹角>90度,就照不到了。
3、镜面反射:也就是所谓的高光。这取决于反射光的方向和观察方向,夹角越大看到的光就越弱,夹角越小看到的光就越强。但这两个方向夹角不好算,我们可以退而求其次计算入射光与观察方向的角平分线与法线的夹角,这算起来比较容易,而且可以证明,这个夹角正好是我们要计算的那个夹角的一半。
这三个光线叠加到一起就得到了我们想要的光照明模型。还是要注意,在着色器中进行的都是数学计算,所有的颜色、法向量、光线方向,观察方向等都是一个向量,我们所有的处理也都是对向量计算,最终得到的还是颜色向量。
后面我会贴上我的着色器源代码,注释写得很清楚,在这里细节就不多说了。
还拿我做的立方体举例,每个面都有法向,这个法向在一个面上的各个点来说都是一样的,如果我们计算光照,就可以看到只是一个图片被照亮了,就像一面光滑的镜子一样,反映不出上面的细节。现实中我们大部分的表面都是凹凸不平的,如何实现这个凹凸不平的效果呢,就是我下面要说的法线贴图了。
我们之前贴的纹理是这样的:
只是一个颜色而已,而法线贴图则是用了下面这个图片:
可以看到,这个图主基调是蓝色的,为什么呢?
我们可以这样理解,这个图每个像素都有一个颜色值,它是一个向量,现在我们不把它当成是一个颜色的向量,而把它当做是一个法向量,并把它映射到图形的每个像素上去,那么我就给了一个平面上的每个点一个法向量,现在每个点的法向量都有略微的不同(为什么是“略微的”我一会再说),因此现在再进行光照看起来就不像是一块光滑的镜子了。现在拿一个平面图举例,我们一般默认是从z轴方向去观察,如果不进行纹理映射,平面的法向量应该是朝向我们的,也就是正z方向,也就是(0,0,1),而进行法线映射之后,法向量虽然有变化,但也不至于变化太大(原因还是一会再说)。也就是法向量也是接近(0,0,1)的,如果这是三个颜色分量的话,蓝色值也是接近1的,而红绿分量很小,这也就说明了,为什么这幅图片看起来那么蓝,在变化比较大的地方才会有一些红色和绿色。在着色器程序中还要注意三个分量的顺序为BGR。
我们为什么不能让法向量偏离z轴太多呢,偏离地越多不就显得越凹凸不平,纹理效果不就越明显么?这是因为事实上可以认为这种法线映射只是一种“偷懒”的行为,我们只改变了平面上的法向量,平面本身并没有发生变化,平面上每一点本来的坐标也没有发生改变。我们只是让平面“看起来”凹凸不平,因此如果法向量偏离z轴太多,而平面上的点实际坐标有没有变化的话,这种“偷懒”的行为就“露馅”了,反而会让人觉得很不真实。所以这种法线贴图一般用在一些表面稍有凹凸感的地方,比如大理石墙面,砖面等,但我要弄一座山恐怕就不行了。这就解释了前面“略微的”原因。
还要注意的是,刚才我说的都是以z轴为观察方向,图形也与z轴垂直的情况,像立方体这样,只有一个面与z轴垂直,其他五个面怎么办呢,如果不变化只是直接贴上的话,法向量肯定不对,那我们就要引入一个“切线空间”的概念。切线空间简单地说就是这个纹理本身的空间,我们希望通过把它乘上一个矩阵,让它能够贴到任意的面上,这个矩阵称为TBN矩阵,这个矩阵具体计算方法我不细说了,网上教程都有,只需要明白它的概念就行,我在着色器程序中也有注释。
但要注意一点,在计算TBN矩阵的时候不需要在程序中指定切向量,只需要知道纹理坐标以及法向量就可以算出来,但需要在程序中用到矩阵乘法等,就需要关联数学库,为了方便(事实上立方体本来就比较方便),我直接在顶点坐标那里指定了切向量,也相当于是偷了个懒。
法向图是可以根据纹理图制作出来的,具体制作我还没有研究过,就不说了,我们现在只要能把给我们的纹理图和法向图贴上就行了。
以上我们就把所有关于纹理映射,光照,法线映射的原理讲完了。
这个图形已经画完了,我们希望能让它转一转来实时地看一下光照效果,因此我在程序中加入了两个函数来实现它。
//截取按下鼠标左键的动作
void mouse(int button, int state, int x, int y)
{
if (state == GLUT_DOWN)
{
oldx = x;
oldy = y;
}
}
//用鼠标移动控制旋转角度变化
void motion(int x, int y)
{
GLint deltax = x - oldx;
GLint deltay = y - oldy;
angley += 0.5*(GLfloat)deltax;
anglex += 0.5*(GLfloat)deltay;
oldx = x;
oldy = y;
}
调用时是这样的:
//鼠标纵向移动,沿x轴旋转;鼠标横向移动,沿y轴旋转
glutMouseFunc(mouse);
glutMotionFunc(motion);
glRotatef(angley, 0.0, 1.0, 0.0);
glRotatef(anglex, 1.0, 0.0, 0.0);
glutMouseFunc和glutMotionFunc是GLUT中自带的函数,可以记录鼠标按下的动作,以及获取拖动时的实时鼠标位置。
由于我观察坐标系的选取,实际上屏幕的横向是x轴,屏幕纵向是y轴,我设定的鼠标横向拖动沿y轴旋转,横向拖动沿x轴旋转,也符合我们的使用感受。
当然要有动态效果,我们也不能只是绘制,需要加入一个函数:
glutIdleFunc(display);
这是GLUT中的一个回调函数,可以反复调用绘制,就可以实现动态效果,我前面说的用一致变量来做动画也是通过调用这个函数。
GLSL语言中还有很多有意思的东西,我也还在学习中,下面一些是我认为要注意的:
1、法向量变换的问题。虽然我们要进行法线贴图,顶点本身的法向貌似不那么重要了,但事实上由于我们要计算TBN矩阵,因此还是需要每个顶点的法向,并通过插值得到每个片元的法向,这个可以通过我的代码看到。顶点坐标在我们通过坐标变换旋转或者缩放图形之后,直接乘上gl_ModelViewProjectionMatrix矩阵就可以了,但法向量怎么变化呢?如果只是旋转和平移,法向量乘上变换矩阵是没问题的,但如果涉及到缩放,变换就不一定保证法向还与切向垂直了,就需要乘上gl_NormalMatrix矩阵,这个矩阵是变换矩阵(4*4)矩阵左上方3*3矩阵子阵逆矩阵的转置,这个原因可以参照其他教程,我在这里不细说了。
2、所有的向量在计算完要归一化。因为在计算夹角之类的cos值的时候,正常是将两个向量做内积再除以两个向量的模。因此为了简便,归一化后,直接计算内积就可以了,计算TBN矩阵的时候还涉及到外积的计算,因此也要归一化。
3、前面说过两次了,但还要说一下,顶点着色器中out的变量对应片元着色器中in的变量,这些变量在传递的过程中是要进行插值的,顶点着色器是对逐个顶点调用,片元着色器是对逐个片元调用。这个过程很重要,因为我初学时会有两个疑惑:一是顶点着色器中out的变量和片元着色器中in的变量是不是一样的,事实上是不完全一样的,片元着色器中in的变量是在顶点着色器计算出的数据上插值得出的,插值过程是自动的,当然我们可以用一些语句去控制,但这里不涉及。二是没有任何循环语句是如何对每个顶点和片元进行操作的,事实上这是自动进行的。了解了这些能对GLSL语言有一个更深入的认识,也利于下一步自学。
4、顶点着色器和片元着色器最终只输出两个东西,一个是顶点着色器中的gl_Position,即顶点坐标,另一个是片元着色器中片元的颜色,这也可以用内部定义好的变量来表示:gl_FragColor。但我看大部分在片元着色器中都是定义一个out变量,来表示输出片元的颜色。这个变量名定义为什么都行,因为片元着色器中只有一个输出变量,就默认是输出颜色了。具体为什么不直接用gl_FragColor这个变量我也不是很清楚,可能也是一种约定,毕竟现在都倾向于用自定义的变量,而减少使用自带的变量。
最后我把我的源代码贴到下边:
主程序:
//该程序用了纹理贴图,法线贴图和光照明模型
#include
#include
#include
#include
#include
#include
#pragma comment(lib,"glew32.lib")
#pragma comment(lib,"FreeImage.lib")
//用于控制鼠标拖动图形运动的全局变量
GLfloat anglex = 0.0;
GLfloat angley = 0.0;
GLfloat oldx;
GLfloat oldy;
//设置显示窗口大小
GLsizei winWidth = 600, winHeight = 600;
//顶点缓冲对象的句柄
GLuint VBO;
//纹理句柄
GLuint tex0;
GLuint tex1;
GLuint gSampler0;
GLuint gSampler1;
//截取按下鼠标左键的动作
void mouse(int button, int state, int x, int y)
{
if (state == GLUT_DOWN)
{
oldx = x;
oldy = y;
}
}
//用鼠标移动控制旋转角度变化
void motion(int x, int y)
{
GLint deltax = x - oldx;
GLint deltay = y - oldy;
angley += 0.5*(GLfloat)deltax;
anglex += 0.5*(GLfloat)deltay;
oldx = x;
oldy = y;
}
//创建顶点缓冲器
static void CreateVertexBuffer()
{
//输入包含顶点坐标,纹理坐标,法向量,切向量的顶点信息
float Vertices[24][11] =
{
{ -1.0f, -1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f },
{ -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f },
{ -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f },
{ -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f },
{ -1.0f, -1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f },
{ -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f },
{ -1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f },
{ -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f },
{ -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f },
{ -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f },
{ 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f },
{ 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f },
{ 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f },
{ 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f },
{ 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f },
{ 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f },
{ -1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f },
{ -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f },
};
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(Vertices), Vertices, GL_STATIC_DRAW);
}
//渲染函数
static void display()
{
//深度测试,消隐
glEnable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
//视见体选择
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glFrustum(-1.0, 1.0, -1.0, 1.0, 2.0, 8.0);
//观察坐标系选择
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0);
//鼠标纵向移动,沿x轴旋转;鼠标横向移动,沿y轴旋转
glutMouseFunc(mouse);
glutMotionFunc(motion);
glRotatef(angley, 0.0, 1.0, 0.0);
glRotatef(anglex, 1.0, 0.0, 0.0);
//设置顶点中每个分量的含义
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glEnableVertexAttribArray(3);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)12);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)20);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 44, (const GLvoid*)32);
//设置纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex0);
glUniform1i(gSampler0, 0);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, tex1);
glUniform1i(gSampler1, 1);
//绘制图形
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glDrawArrays(GL_QUADS, 0, 24);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glDisableVertexAttribArray(3);
glutSwapBuffers();
}
//读取文件字符串的函数,用于将着色器读入程序,着色器程序须放入程序文件夹中
char *readTextFile(const char *name)
{
FILE *fp;
char *content = NULL;
int count = 0;
if (name == NULL)
return NULL;
//fopen函数有可能提示要求用fopen_s
fp = fopen(name, "rt");
if (fp == NULL)
return NULL;
fseek(fp, 0, SEEK_END);
count = ftell(fp);
rewind(fp);
if (count > 0)
{
content = (char*)malloc(sizeof(char)*(count + 1));
if (count != NULL)
{
count = fread(content, sizeof(char), count, fp);
content[count] = '\0';
}
}
fclose(fp);
return content;
}
//编译着色器的函数
void setShaders(void)
{
//创建着色器对象
GLuint vertShader, fragShader;
vertShader = glCreateShader(GL_VERTEX_SHADER);
fragShader = glCreateShader(GL_FRAGMENT_SHADER);
GLchar *vertSource, *fragSource;
//读入着色器程序字符串
vertSource = readTextFile("vertexshader.vert");
fragSource = readTextFile("fragmentshader.frag");
//将字符串关联到着色器上
glShaderSource(vertShader, 1, (const GLchar **)&vertSource, NULL);
glShaderSource(fragShader, 1, (const GLchar **)&fragSource, NULL);
free(vertSource);
free(fragSource);
//编译着色器
glCompileShader(vertShader);
glCompileShader(fragShader);
//创建程序对象
GLuint program;
program = glCreateProgram();
//将程序对象与着色器对象关联起来,并链接整个程序
glAttachShader(program, vertShader);
glAttachShader(program, fragShader);
glLinkProgram(program);
//将着色器设置为活动的
glUseProgram(program);
//在着色器中寻找变量位置
gSampler0 = glGetUniformLocation(program, "gSampler0");
gSampler1 = glGetUniformLocation(program, "gSampler1");
}
//用FreeImage加载图片
GLuint loadtexture(const char *filename)
{
GLuint textureID;
//获取文件格式
FREE_IMAGE_FORMAT fif = FreeImage_GetFileType(filename, 0);
//加载文件
FIBITMAP *dib = FreeImage_Load(fif, filename, 0);
//转换图片为32位
dib = FreeImage_ConvertTo32Bits(dib);
//每个像素的句柄
BYTE *pixels = (BYTE*)FreeImage_GetBits(dib);
//获取图片的宽和高
int width = FreeImage_GetWidth(dib);
int height = FreeImage_GetHeight(dib);
//创建图片缓冲,与顶点缓冲,索引缓冲类似
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
//生成纹理,有9个参数其中GL_RGBA的选择与上面32位图像有关,如果是24位,可以是GL_RGB
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
FreeImage_Unload(dib);
return textureID;
}
//主函数,用于窗口管理和显示图形
int main(int argc, char** argv)
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowPosition(100, 100);
glutInitWindowSize(winWidth, winHeight);
glutCreateWindow("不忘初心方得始终");
//初始化glew
glewInit();
//背景颜色置为白色
glClearColor(1.0, 1.0, 1.0, 1.0);
glutDisplayFunc(display);
glutIdleFunc(display);
CreateVertexBuffer();
//操作着色器
setShaders();
//读取图片文件
tex0 = loadtexture("色彩纹理图.gif");
tex1 = loadtexture("法向图.gif");
glutMainLoop();
return 0;
}
顶点着色器程序,文件名是:vertexshader.vert,这里用什么扩展名都可以,但我主程序中用到了读这个文件名的操作,如果想换文件名需要把主程序也改一下。放到记事本中就行:
//定义顶点坐标的含义
layout (location = 0) in vec3 Vertex;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
layout (location = 3) in vec3 Tangent;
//输送到片元着色器中的变量
out vec2 TexCoord0;
out vec3 Normal0;
out vec3 Tangent0;
out vec3 lightVec;
out vec3 viewVec;
void main()
{
//输出的顶点坐标
gl_Position = gl_ModelViewProjectionMatrix * vec4(Vertex,1.0);
//纹理坐标
TexCoord0 = TexCoord;
//顶点法向量
Normal0 = gl_NormalMatrix * Normal;
//顶点切向量
Tangent0 = gl_NormalMatrix * Tangent;
//设置点光源位置
vec4 lightPos=vec4(2.0,-2.0,3.0,1.0);
//变换后的顶点坐标
vec4 vert = gl_ModelViewMatrix * vec4(Vertex,1.0);
//光线方向
lightVec = vec3(lightPos - vert);
//观察方向
viewVec = -vec3(vert);
}
片元着色器程序,文件名是fragmentshader.frag,同样并不限制扩展名
//从顶点着色器中输入的变量
in vec2 TexCoord0;
in vec3 Normal0;
in vec3 Tangent0;
in vec3 lightVec;
in vec3 viewVec;
//输出的片元颜色
out vec4 FragColor;
//纹理一致变量
uniform sampler2D gSampler0;
uniform sampler2D gSampler1;
//通过计算TBN矩阵来计算纹理的法向量
vec3 CalcBumpedNormal()
{
//法向量归一化
vec3 Normal = normalize(Normal0);
//切向量归一化
vec3 Tangent = normalize(Tangent0);
//施密特正交化
Tangent = normalize(Tangent - dot(Tangent, Normal) * Normal);
//计算切向量和法向量的外积
vec3 Bitangent = cross(Tangent, Normal);
//取向量图的rgb分量作为法向,应取bgr的顺序
vec3 BumpMapNormal = texture2D(gSampler1, TexCoord0).bgr;
//将向量的范围由0.0-1.0变为-1.0-1.0
BumpMapNormal = 2.0 * BumpMapNormal - vec3(1.0, 1.0, 1.0);
//定义TBN矩阵
mat3 TBN = mat3(Tangent, Bitangent, Normal);
//将法向量与TBN矩阵相乘,得到各个面的法向量
vec3 NewNormal = TBN * BumpMapNormal;
//法向量归一化
NewNormal = normalize(NewNormal);
return NewNormal;
}
void main()
{
//n为每个片元的法向量
vec3 n = CalcBumpedNormal();
//取纹理贴图作为片元基本颜色,在这个基础上计算光照,三色顺序也要取bgr
vec4 color=texture2D(gSampler0, TexCoord0).bgra;
//光照方向归一化
vec3 L = normalize(lightVec);
//观察方向归一化
vec3 V = normalize(viewVec);
//观察方向与入射光方向的角平分线方向
vec3 halfAngle = normalize(L + V);
//入射光与法线夹角
float NdotL = clamp(dot(L, n), 0.0, 1.0);
//角平分线与法线夹角,用来近似出射光与观察方向夹角
float NdotH = clamp(dot(halfAngle, n), 0.0, 1.0);
//环境光
vec4 ambient=color*0.1;
//漫反射
vec4 diffuse = color * NdotL*0.4;
//镜面反射
vec4 specular = color*pow(NdotH,256.0)*0.5;
FragColor = ambient + diffuse + specular;
}
两个图片文件也贴在下边,纹理贴图的文件名是:色彩纹理图.gif,注意复制下来自己改一下文件名,这些图片文件也要放到源代码的那个文件夹里。
法向图的文件名是:法向图.gif,也要自己改一下文件名
这样程序应该直接就能运行。
再附几个我在网上找的其他图片,可以自己把文件名改一下贴了玩玩: