我们已经学会了创建窗口,这一讲,我们将学习如何使用现代OpenGL画一个三角形。在开始写代码之前,我们需要先了解一些OpenGL概念。本文会很长,请大家做好心理准备~
注:以下OpenGL概念翻译自https://learnopengl.com/#!Getting-started/Hello-Triangle,有删减。(实际上LearnOpenGL的教程有中文翻译,但是我还是自己翻译了。)代码则是原创。
图形管线(graphics pipeline)和着色器(shader)
在OpenGL中所有的东西都在3D空间中,而屏幕和窗口是一个2D像素数组,因此将3D坐标转换成屏幕上的2D像素就成了OpenGL的很大一部分的工作。而这一过程是由OpenGL的图形管线(graphics pipeline)进行管理的。图形管线可以被分成两个部分,第一部分是把3D坐标变换成2D坐标,第二部分是把2D坐标变换成涂了颜色的像素。注意2D坐标和像素的区别:2D坐标是一个点在2D空间中的精确表示,而像素则是受限于屏幕分辨率时,该2D坐标的近似值。
图形管线接受一组坐标作为输入,并将该坐标变换成屏幕上的上了色的2D像素。图形管线可以被分成几步,每一步都需要用上一部的输出作为输入。这些步骤都是高度特化的(原文是highly specialized)(它们有一个具体的功能),可以被轻易地并发执行。因为它们的平行特点,今天的显卡基本都有几千个小的处理内核,可以在每一步时,通过在GPU上运行小程序,迅速在图形管线中处理你的数据。这些小程序被称为着色器(shader)。
一些着色器允许用户自己去设置,这样我们就可以自己写着色器去替代默认的着色器。着色器是用OpenGL着色语言(GLSL)编写的。下图描述了整个图形管线,蓝色的框所代表的阶段我们可以自己添加着色器(图片来自LearnOpenGL)。
如你所见,图形管线包含很多部分,每个部分都有特定的工作。下面我们将简要解释一下图形管线的每个部分。
顶点数据(vertex data):作为输入,我们会给图形管线传入一组数据,叫做顶点数据。顶点数据描述了一组顶点的信息,这些顶点构成一个或多个图元(primitive)(关于图元将在后面解释)。顶点数据用顶点属性(vertex attribute)表示,顶点属性可以包含任何我们喜欢的数据,但通常包含的是顶点位置、颜色、贴图坐标(texture coordinates)等信息。
这里还有一个图元的概念:提供了顶点数据后,OpenGL是将这些顶点解释成一个三角形,还是一条线段,还是其它图形呢?因此,调用OpenGL绘制命令时,你需要告诉OpenGL要绘制的图形,叫做图元。
顶点着色器(vertex shader):图形管线的第一个阶段,接受一个顶点作为输入,将这个顶点进行相应的变换(以后会更详细地讲到)。顶点着色器允许我们对顶点属性做些基本处理。
图元装配(primitive assembly):将顶点着色器输出的所有组成一个图元的顶点作为输入(如果画点,则只有一个顶点),将所有的点按照所给的图元类型进行装配(这里是三角形)。
几何着色器(geometry shader):可选项,这里不做介绍。
光栅化(rasterization):将图元转换成最终屏幕上的像素,得到许多片元(fragment)给片元着色器(fragment shader)使用。片元指渲染一个像素所需的全部数据。这一步还会有剪切(clipping),将不可见的片元全部丢弃。
片元着色器(fragment shader):计算一个像素的最终颜色。通常高级OpenGL效果都会应用在这里(例如光照、阴影效果)。
测试与混合(test and blending):图形管线的最后一步,检查片元的深度,例如如果发现有片元位于其它片元的后面,就会被丢弃。这一步还会检查片元的alpha值(代表透明度),并将对象进行混合。(所以即使片元着色器计算出了颜色,最终颜色还可能不同。)
可以看出,图形管线是一个复杂的整体,含有很多可设置的部分。但我们一般只会与顶点着色器和片元着色器打交道。几何着色器一般会使用默认的。
在现代OpenGL中我们需要定义至少一个顶点着色器和片元着色器。因此,学习现代OpenGL比学习旧版OpenGL要困难很多,因为在开始渲染之前需要知道大量的知识。在本讲最后您渲染出三角形时,您将会学到更多的图形学知识。
NDC坐标
顶点坐标被顶点着色器处理完毕后,顶点的x、y、z值应位于-1.0~1.0这一范围之内,否则就不会被渲染。具有这种范围限制的系统被称为规格化设备坐标系统(normalized device coordinate,NDC)。x、y、z位于-1.0~1.0这一范围内的坐标叫做NDC坐标(这种解释不是很好,但是为了新手好理解,就先这样说吧)。
对于NDC坐标,原点(0, 0)位于窗口中央;点(-1, -1)位于窗口左下角;点(1, -1)位于窗口右下角;点(-1, 1)位于窗口左上角;点(1, 1)位于窗口右上角。
开始编写代码
我们先从着色器开始。这里我们把顶点着色器和片元着色器分别写到两个文本文件里,分别命名为shader.vert和shader.frag。.vert和.frag分别表示vertex shader和fragment shader。(如果愿意,你也可以使用其它扩展名,或者直接使用.txt。)在后面我们将读取这两个文件,动态加载两个着色器。OpenGL的着色器使用OpenGL着色语言(OpenGL Shading Language,GLSL)编写。
顶点着色器(vertex shader)
文件名:shader.vert
#version 330 core
layout (location = 0) in vec4 position;
void main()
{
gl_Position = position;
}
顶点着色器用于计算一个顶点的最终位置(NDC坐标)。可以看到顶点着色器非常简单。从这里也可以看出,GLSL的语法和C/C++很相似。
先来看第一行:
#version 330 core
这是GLSL的#version预处理器指令,用于指定着色器的版本。“330”表示我们使用OpenGL 3.3对应的GLSL(在OpenGL 3.3以前,这个数字和OpenGL版本号完全不同,这里不做详细讨论),与之前用glfwWindowHint()设置的OpenGL版本一致。而“core”表示我们要使用OpenGL的核心模式(core profile)。“core”可以省略,但这个#version指令不能省略。
下一行:
layout (location = 0) in vec4 position;
创建了一个着色器变量。为方便理解,这里从右往左依次解释。这个变量叫“position”,表示顶点的位置。“vec4”是position的类型,表示一个含有4个float分量的向量,4个分量分别是x、y、z、w。“in”表示position是输入变量,如果是顶点着色器,“in”声明的变量将从顶点数据获得相应的值。“layout (location = 0)”是布局限定符(layout qualifier),将position变量的location值指定为0,它的用处将在后面的章节讨论。
前面说过,OpenGL中所有东西都在3D空间中。你可能会问:我们要画的不是2D三角形吗?是的,但是2D可以被看作3D的一部分,2D三角形可以被看作每个点的z值都为0的三角形(先忽略w)。
然后是main()函数:
void main()
{
gl_Position = position;
}
与ANSI C/C++不同,main()返回void,即没有返回值。gl_Position是GLSL的内置变量(类型为vec4),代表顶点的NDC位置(也就是x、y、z应位于-1.0~1.0的范围内)。这里只是简单地将position赋给gl_Position。(以后还会有顶点变换,就不是直接将position赋给gl_Position了。)
片元着色器(fragment shader)
文件名:shader.frag
#version 330 core
out vec4 color;
void main()
{
color = vec4(0.0, 0.5, 0.5, 1.0);
}
第一行不解释了,和前面是一样的。
out vec4 color;
与前面相反,这里使用了out关键字,声明了一个输出变量。变量名为color,类型为vec4。所有的片元着色器都需要输出一个vec4变量(一个有4个float元素的向量),该变量代表了一个像素的最终颜色(不像顶点着色器,position也是一个vec4,但因为我们将它赋给了gl_Position,因此它表示的是一个位置)。这里所有像素都是一个颜色。
然后是main()函数:
void main()
{
color = vec4(0.0, 0.5, 0.5, 1.0);
}
在main()中,我们把color设置为一个4个元素分别为0.0、0.5、0.5、1.0的vec4向量。当用一个vec4来表示颜色时,它的4个分量分别表示该颜色的R、G、B、A值。(如果你还不知道RGB颜色,请自己先百度或Google。)在OpenGL中,R、G、B分量的范围是0.0~1.0(在画图中该范围是0~255)。(0.0, 0.5, 0.5)这一RGB值代表的是一种蓝绿色。
除了R、G、B,A分量是什么意思呢?A是alpha值的意思,表示透明度,范围也是0.0~1.0。这里我们直接将A分量设为1.0,表示完全不透明。很长一段时间我们都会这么做,直到学到混合。
加载着色器
写完了着色器,我们还需要在我们的程序中,加入对着色器的支持,也就是在运行程序时动态加载着色器。这里我们创建了新的源代码文件。
文件名:shader.h
#ifndef SHADER_H_ #define SHADER_H_ #includeGLuint loadShader(const char * vFilename, const char * fFilename); #endif
这就是整个shader.h的内容。函数只有一个,用于读取着色器源代码文件,并创建相应的着色器程序(shader program)。
文件名:shader.cpp
#include "shader.h" #include#include using std::cout; using std::endl;
shader.cpp包含了3个头文件。第一个是shader.h,其余的是C++标准头文件
const int PROGRAM = 0;
一个常量,后面会使用到。这里先不作说明。
GLuint loadShader(const char * filename, GLenum type); char * loadShaderFromFile(const char * filename); GLuint makeProgram(GLuint vShader, GLuint fShader); bool getCompileStatus(GLuint id, bool isProgram); void printInfoLog(GLuint id, GLenum type); const char * getShaderName(GLenum type);
一些会使用到的函数的原型。这里简要地解释它们的用处(看不懂也没关系,有些概念后面会讲到)。
loadShader():读取filename文件,加载类型为type的着色器,并返回该着色器对象。
loadShaderFromFile():读取filename文件,返回读取的文件内容。
makeProgram():将顶点着色器、片元着色器vShader、fShader链接成一个着色器程序,并返回该着色器程序对象。
getCompileStatus():获取着色器编译情况或着色器程序链接情况。id为一个OpenGL对象ID,isProgram表示该ID是否是着色器程序(isProgram是false时,该ID是着色器对象)。
printInfoLog():打印着色器/着色器程序的编译/链接日志。type为OpenGL表示着色器的常量或PROGRAM。
getShaderName():获取type表示的着色器类型的名字。
GLuint loadProgram(const char * vFilename, const char * fFilename) { GLuint vShader = loadShader(vFilename, GL_VERTEX_SHADER); GLuint fShader = loadShader(fFilename, GL_FRAGMENT_SHADER); GLuint program = makeProgram(vShader, fShader); return program; }
在讲解这段代码前,需要了解OpenGL的对象(object)概念。在OpenGL中,对象的意思和C++不太一样。OpenGL中,对象指表示OpenGL状态的一个子集的一组选项(a collection of options that represents a subset of OpenGL's state)。例如这里就有着色器对象、着色器程序对象。每个类型相同的OpenGL对象,都具有一个独一无二的ID(不同类型则可能重复)。ID的类型是GLuint,这是OpenGL定义的一个类型(一个简单的typedef),代表32位无符号整数。我们不能直接访问OpenGL对象,只能通过对象的ID进行间接访问。这一点和上一课所讲的窗口句柄(GLFWwindow指针)类似。
这里的vShader、fShader和program都是OpenGL对象ID。为了方便,我们会将OpenGL对象ID说成OpenGL对象。
loadProgram()函数有两个const char *参数,分别表示顶点着色器和片元着色器的文件名。loadShader()将读取相应的着色器并编译。makeProgram接受两个GLuint参数表示两个着色器,并把两个着色器链接成相应的着色器程序。loadProgram将返回该着色器程序对象。
loadShader的第一个参数是文件名,第二个是着色器类型。GL_VERTEX_SHADER和GL_FRAGMENT_SHADER是OpenGL的常量,分别表示顶点着色器和片元着色器。
GLuint loadShader(const char * filename, GLenum type) {
loadShader函数从文件中加载着色器并编译。它有两个参数,一个是着色器文件名filename,另一个是着色器类型type。type的类型是GLenum,也是32位无符号整形,这里type只应该是两个值:GL_VERTEX_SHADER和GL_FRAGMENT_SHADER,表示顶点着色器和片元着色器。
char * source; GLuint shader;
这里声明了两个变量。source是着色器的源代码,shader是着色器对象。
source = loadShaderFromFile(filename); if (source == nullptr) return 0;
因为filename是着色器文件的文件名,所以这里使用loadShaderFromFile()读取该文件的内容。文件内容被保存在了char指针source里,loadShaderFromFile()将会使用new动态分配一个char数组。如果打开文件失败,loadShaderFromFile()会返回nullptr。如果source为nullptr,说明加载失败,loadShader()将会返回0表示加载失败。
shader = glCreateShader(type); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader);
glCreateShader()创建一个着色器对象(shader object),并返回其ID。glCreateShader()接受一个参数表示着色器类型,在这个程序里,应该是GL_VERTEX_SHADER和GL_FRAGMENT_SHADER(实际上还可以是更多的值,例如GL_GEOMETRY_SHADER)。glCreateShader()的返回值保存在GLuint变量shader里,表示该着色器对象。着色器对象在后面有时被简称为着色器。
从这里开始我们需要注意区分着色器(shader)和着色器程序(shader program)。后者将前者组合起来,这个将在后面讨论。
shader虽然已经创建完毕,但它还是空的。使用glShaderSource()给它提供源代码。glShaderSource()在GLEW中原型如下:
void glShaderSource(GLuint shader, GLsizei count, const GLchar *const *string, const GLint *length);
shader:着色器对象。这里将传入shader。
count:string包含的字符串个数。我们只用了一个字符串表示着色器源代码,因此传入1。
string:一个GLchar二级指针,可以理解为一个字符串数组(数组的每个元素都是一个字符串),组合成着色器源代码。这里传入source的地址&source,表示该数组(虽然只有一个元素)。
length:有些复杂,暂不解释。这里直接传入nullptr,表示每一个字符串(这里只有一个)都以空字符结尾。
glCompileShader()很简单,有一个shader参数,它将编译shader。注意,着色器的编译和一般编程语言的编译类似,但有不同。着色器在程序的运行时间(runtime)编译。
if (!getCompileStatus(shader, false)) { printInfoLog(shader, type); glDeleteShader(type); return 0; }
着色器编译不一定成功,因为着色器源代码中可能有错误。因此就需要检查是否编译成功。getCompileStatus()的第一个参数是一个OpenGL对象(着色器或着色器程序),第二个参数表示该对象是否是着色器程序。这里shader是着色器而不是着色器程序,所以getCompileStatus()的第二个参数,我们传入false。如果编译成功,getCompileStatus()就会返回true,否则返回false。如果失败,使用printInfoLog()函数打印着色器编译日志,并使用glDeleteShader()删除该shader,返回0。
delete [] source; return shader; }
加载成功后,delete掉source指向的内存,返回shader。loadShader()函数编写完成。
char * loadShaderFromFile(const char * filename) {
loadShaderFromFile()用于读取着色器文件的内容。
std::ifstream fin; int size; char * source;
fin是一个ifstream对象,在后面用于读取文件内容。size用于记录文件大小。source是着色器源代码。
fin.open(filename); if (!fin.is_open()) { cout << "Cannot open shader file " << filename << " (maybe not exist)!\n"; return nullptr; }
用fin打开filename文件。而filename文件可能不存在,因此就要检查文件是否是打开的。如果不是,说明文件不存在或者存在其它问题,并返回nullptr。
fin.seekg(0, std::ios_base::end); size = fin.tellg(); source = new char[size + 1]{'\0'};
获得文件大小size(以字节为单位),分配一个有size+1个元素的char数组。之所以是size+1,是因为要为末尾的空字符流出空间。
还有一个值得注意的地方,第二行最后是{'\0'},表示将该数组的每个元素都设为空字符。因为在Windows上,换行符是\r\n两个字符(size算入了这2个字符),而C/C++读取时会将\r\n转换成\n,因此读取的字符数实际上小于size。如果不初始化为空字符,数组结尾的元素就是随机的,这会导致glCompileShader()失败。
fin.seekg(0, std::ios_base::beg); fin.read(source, size);
将文件指针重置到文件头,然后读取size个字节(即整个文件)。实际上,前面说过,C/C++读取文件时,如果文件里有换行,实际读取的字符数会小于size。但C++遇到EOF(文件尾)时就不会继续读取了,所以这样是安全的。
fin.close(); return source; }
关闭文件,返回读取到的文件内容。loadShaderFromFile()函数结束。
接下来是makeProgram()函数。
GLuint makeProgram(GLuint vShader, GLuint fShader)
{
makeProgram()接受两个参数vShader和fShader(表示顶点着色器和片元着色器),链接这两个着色器,创建并返回相应的着色器程序。
if (vShader == 0 || fShader == 0) return 0;
如果任意一个着色器编译失败(值为0),则返回0表示失败。
GLuint program = glCreateProgram();
glAttachShader(program, vShader);
glAttachShader(program, fShader);
glLinkProgram(program);
这几行代码应该很直观。
glCreateProgram()创建一个着色器程序(shader program)。这里使用program保存该ID。
创建完了着色器程序,还不行,因为着色器程序是空的。我们需要使相应的着色器对象与它关联。glAttachShader(GLuint program, GLuint shader)将shader与program关联。这里我们调用了两次glAttachShader(),分别将顶点着色器(vShader)、片元着色器(fShader)和着色器对象关联。
关联完着色器后,需要使用glLinkProgram()链接着色器程序(这里是program)的着色器对象,这类似于编译器的链接(linking)。编译器的链接将源代码文件、.lib文件链接成一个.exe,OpenGL将着色器链接成一个着色器程序。
if (!getCompileStatus(program, true)) { printInfoLog(program, PROGRAM); program = 0; }
注意到这里getCompileStatus()的第二个参数是true,表示program是着色器程序(而不是着色器)。如果链接失败,getCompileStatus()将返回false,这时使用printInfoLog()打印错误信息,并将program设为0表示失败。
这里用到了前面定义的常量PROGRAM。实际上PROGRAM的值只要不同于GL_VERTEX_SHADER和GL_FRAGMENT_SHADER就可以了,不一定要是0(定义为0可以说是习惯)。printInfoLog()的第二个参数传入PROGRAM表示program是着色器程序,对于着色器程序,获取日志的方式略有不同。
glDeleteShader(vShader); glDeleteShader(fShader); return program; }
链接完毕,两个着色器就不需要了,因此应该将它们删除。glDeleteShader()用于删除着色器。最后返回着色器程序program(如果链接出错,返回0)。
接下来是getCompileStatus()函数。
bool getCompileStatus(GLuint id, bool isProgram) { GLint status; if (isProgram) glGetProgramiv(id, GL_LINK_STATUS, &status); else glGetShaderiv(id, GL_COMPILE_STATUS, &status); return status == GL_TRUE; }
这个函数相对前面的简单了许多。让我们看看glGetShaderiv()和glGetProgramiv()的定义:
void glGetShaderiv(GLuint shader, GLenum pname, GLint *param); void glGetProgramiv(GLuint program, GLenum pname, GLint *param);
两个函数分别用来获取着色器和着色器程序的一些信息,并且该信息可以用一个整数表达(结尾的iv,i表示GLint,v表示指针)。第一个参数是相应的对象;第二个参数是要获取的信息类型,对于glGetShaderiv(),GL_COMPILE_STATUS表示着色器编译情况,对于glGetProgramiv(),GL_LINK_STATUS表示着色器程序链接情况。第三个参数是一个GLint指针,用于存储相应的信息。
对于glGetShaderiv(),pname为GL_COMPILE_STATUS时,*param将为GL_TRUE或GL_FALSE表示编译是否成功;对于glGetProgramiv(),pname为GL_LINK_STATUS时,*param也是GL_TRUE或GL_FALSE表示链接是否成功。因此status为GL_TRUE时,就说明成功。
接下来是倒数第二个函数printInfoLog()。
void printInfoLog(GLuint id, GLenum type) { char * infoLog; int len; if (type == PROGRAM) { glGetProgramiv(id, GL_INFO_LOG_LENGTH, &len); infoLog = new char[len + 1]; glGetProgramInfoLog(id, len + 1, nullptr, infoLog); cout << "Program linking failed, info log:\n" << infoLog << endl; } else { glGetShaderiv(id, GL_INFO_LOG_LENGTH, &len); infoLog = new char[len + 1]; glGetShaderInfoLog(id, len + 1, nullptr, infoLog); cout << "Shader compilation failed, type: " << getShaderName(type) << ", info log:\n" << infoLog << endl; } delete [] infoLog; }
又遇见了glGetShaderiv()和glGetProgramiv()两个函数。如果第二个参数为GL_INFO_LOG_LENGTH,表示我们要获取的是着色器程序或着色器的信息日志长度。获取了这一长度len之后,申请一个长度为len+1的char数组,分别用glGetShaderInfoLog()和glGetProgramInfoLog()获取相应的日志,并输出。
void glGetShaderInfoLog(GLuint shader, GLint bufSize, GLsizei *length, GLchar *infoLog); void glGetProgramInfoLog(GLuint program, GLint bufSize, GLsizei *length, GLchar *infoLog);
两个函数用于获取信息日志。shader/program为着色器/着色器程序。bufSize为infoLog的长度。length暂不介绍,直接传入nullptr。infoLog用来存储信息日志。
最后,printInfoLog()通过判断id是否等于PROGRAM来判断id是否是着色器程序。
总算到最后一个函数getShaderName()了。用处就是获得一种着色器类型的字符串表示,没什么难的。
const char * getShaderName(GLenum type) { switch (type) { case GL_VERTEX_SHADER: return "vertex"; case GL_FRAGMENT_SHADER: return "fragment"; default: return "UNKNOWN"; } }
呼,shader.cpp总算完结了~
下面是完整的源代码:
#include "shader.h" #include#include using std::cout; using std::endl; const int PROGRAM = 0; GLuint loadShader(const char * filename, GLenum type); char * loadShaderFromFile(const char * filename); GLuint makeProgram(GLuint vShader, GLuint fShader); bool getCompileStatus(GLuint id, bool isProgram); void printInfoLog(GLuint id, GLenum type); const char * getShaderName(GLenum type); GLuint loadProgram(const char * vFilename, const char * fFilename) { GLuint vShader = loadShader(vFilename, GL_VERTEX_SHADER); GLuint fShader = loadShader(fFilename, GL_FRAGMENT_SHADER); GLuint program = makeProgram(vShader, fShader); return program; } GLuint loadShader(const char * filename, GLenum type) { char * source; GLuint shader; source = loadShaderFromFile(filename); if (source == nullptr) return 0; shader = glCreateShader(type); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader); if (!getCompileStatus(shader, false)) { printInfoLog(shader, type); glDeleteShader(type); return 0; } delete [] source; return shader; } char * loadShaderFromFile(const char * filename) { std::ifstream fin; int size; char * source; fin.open(filename); if (!fin.is_open()) { cout << "Cannot open shader file " << filename << " (maybe not exist)!\n"; return nullptr; } fin.seekg(0, std::ios_base::end); size = fin.tellg(); source = new char[size + 1]{'\0'}; fin.seekg(0, std::ios_base::beg); fin.read(source, size); fin.close(); return source; } GLuint makeProgram(GLuint vShader, GLuint fShader) { if (vShader == 0 || fShader == 0) return 0; GLuint program = glCreateProgram(); glAttachShader(program, vShader); glAttachShader(program, fShader); glLinkProgram(program); if (!getCompileStatus(program, true)) { printInfoLog(program, PROGRAM); program = 0; } glDeleteShader(vShader); glDeleteShader(fShader); return program; } bool getCompileStatus(GLuint id, bool isProgram) { GLint status; if (isProgram) glGetProgramiv(id, GL_LINK_STATUS, &status); else glGetShaderiv(id, GL_COMPILE_STATUS, &status); return status == GL_TRUE; } void printInfoLog(GLuint id, GLenum type) { char * infoLog; int len; if (type == PROGRAM) { glGetProgramiv(id, GL_INFO_LOG_LENGTH, &len); infoLog = new char[len + 1]; glGetProgramInfoLog(id, len + 1, nullptr, infoLog); cout << "Program linking failed, info log:\n" << infoLog << endl; } else { glGetShaderiv(id, GL_INFO_LOG_LENGTH, &len); infoLog = new char[len + 1]; glGetShaderInfoLog(id, len + 1, nullptr, infoLog); cout << "Shader compilation failed, type: " << getShaderName(type) << ", info log:\n" << infoLog << endl; } delete [] infoLog; } const char * getShaderName(GLenum type) { switch (type) { case GL_VERTEX_SHADER: return "vertex"; case GL_FRAGMENT_SHADER: return "fragment"; default: return "UNKNOWN"; } }
总结:
创建着色器过程:
1*. 从文件里读取其源代码
2. 使用glCreateShader()创建一个着色器
3. 使用glShaderSource()给其提供源代码
4. 使用glCompileShader()编译着色器
5*. 检查编译是否成功
创建着色器程序过程:
1. 创建好所有的着色器(这里只有顶点着色器和片元着色器)
2. 使用glCreateProgram()创建一个着色器程序
3. 使用glAttachShader()将所有着色器与该着色器程序关联
4. 使用glLinkProgram()链接着色器程序
5. 检查是否链接成功
6. 使用glDeleteShader()删除所有着色器
(注:有*的步骤表示,该步骤是可选的)
顶点数据
下面我们进入main.cpp。
前面说过,作为输入,我们会给图形管线传入一组数据,叫做顶点数据。顶点数据描述了一组顶点的信息。顶点着色器接受一个顶点作为输入,这个顶点就来自我们提供了顶点数据。
因为我们这一讲要画一个三角形,所以我们传入的顶点数据包含了三角形的三个顶点的位置信息。前面说过,顶点着色器中如果声明了in变量,该变量的数值将会来自顶点数据。这里,顶点着色器的position变量的数据就是来自下面的数组(顶点数据)。我们将其命名为vertexes(意思是顶点)。
const GLfloat vertexes[] = { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f };
因为vertexes数组不需要被修改,因此将其声明为const。vertexes数组的每一行分别表示三角形每个顶点的x、y坐标。需要注意的是,我们在顶点着色器中,直接把position(来自顶点数据)赋给gl_Position。而gl_Position是NDC坐标,因此position也需要是NDC坐标,进而顶点数据指定的顶点也需要是NDC坐标。在顶点数据中,我们指定了(-0.5, -0.5)、(0.5, -0.5)、(0.0, 0.5)这3个顶点。注意,我们没有使用二维数组,而是简单地定义了一个一维的float数组,将每个点的X、Y坐标一个接一个地写在vertexes数组中。他们(NDC坐标)在屏幕上的位置如下(图片来自LearnOpenGL):
还有一个要注意的地方,我们提供的顶点数据只包含了顶点的x、y坐标,但是着色器的position变量类型却是vec4。当我们只提供x、y坐标时,position的z、w分量就会被设置为默认的0.0和1.0。
顶点缓存对象(VBO)和顶点数组对象(VAO)
接下来需要做的事就是将顶点数据传给图形管线的第一步——顶点着色器。
我们的顶点数据是这么存储的:
也就是说:
1. 顶点位置的数据以32位浮点值(float类型)的形式存储;
2. 每个顶点的数据都占有2个32位浮点值(float类型);
3. 每组(2个)数据表示的都是顶点坐标,它们之间没有间隔;
4. 数据中的第一个值处于缓存(buffer)的开头处。
(未完)