我们已经学会了创建窗口,这一讲,我们将学习如何使用现代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_ #include <GL/glew.h> GLuint loadShader(const char * vFilename, const char * fFilename); #endif
这就是整个shader.h的内容。函数只有一个,用于读取着色器源代码文件,并创建相应的着色器程序(shader program)。
文件名:shader.cpp
#include "shader.h" #include <iostream> #include <fstream> using std::cout; using std::endl;
shader.cpp包含了3个头文件。第一个是shader.h,其余的是C++标准头文件<iostream>和<fstream>。包含<fstream>是因为需要读取着色器文件。
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)! "; return nullptr; }
用fin打开filename文件。而filename文件可能不存在,因此就要检查文件是否是打开的。如果不是,说明文件不存在或者存在其它问题,并返回nullptr。
fin.seekg(0, std::ios_base::end); size = fin.tellg(); source = new char[size + 1]{'