1. 流程
对比于生成窗口,本次生成三角形所需的知识点多了顶点着色器和片段着色器,着色器程序与VAO、VBO、EBO,在此之前,先要了解一下OpenGL的图形渲染管线
1.1 OpenGL图形渲染管线
图形渲染管线的第一个部分是顶点着色器(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。
图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状;本节例子中是一个三角形。
图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。
几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做Alpha测试和混合(Blending)阶段。这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。
在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器
1.2 生成三角形
所以为了画出一个三角形,我们首先需要定义它的顶点着色器和片段着色器:
(要注意的是,着色器是使用GLSL编写的,所以得经过一点中间过程,首先是将着色器的定义存为字符串数组)
可见,在顶点着色器中,第一句是定义了版本,第二局定义了一个位置为0的(因为输入给顶点着色器的数据里可能不止包含顶点信息,所以要通过layout (location = 0)来指定顶点数据的位置),类型为vec3(长度为三的浮点数数组)的输入变量aPos,main函数里的gl_Position是预定义的顶点着色器输出,我们直接把输入的坐标转换成齐次坐标赋给它就行了。
片段着色器则是定义了一个输出变量FragColor作为顶点的颜色
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);//前两个参数控制窗口左下角的位置,第三个和第四个参数控制渲染窗口的宽度和高度(像素)
}
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)//按下ESC退出
glfwSetWindowShouldClose(window, true);
}
const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;
接下来就是之前提到的生成窗口对象等设置:
int main()
{
glfwInit();//初始化GLFW
//我们告诉GLFW我们要使用的OpenGL版本是3.3
//这样GLFW会在创建OpenGL上下文时做出适当的调整。这也可以确保用户在没有适当的OpenGL版本支持的情况下无法运行
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);//主版本号(Major)设为3
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);//次版本号(Minor)设为3
//明确告诉GLFW我们需要使用核心模式意味着我们只能使用OpenGL功能的一个子集(没有我们已不再需要的向后兼容特性)
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);//告诉GLFW我们使用的是核心模式(Core-profile)
//Mac OS X
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
//创建一个窗口对象,这个窗口对象存放了所有和窗口相关的数据,而且会被GLFW的其他函数频繁地用到
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);//窗口的宽和高作为它的前两个参数,第三个参数表示这个窗口的名称
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
//当用户改变窗口的大小的时候,视口也应该被调整
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);//注册回调函数,告诉GLFW我们希望每当窗口调整大小的时候调用这个函数
//GLAD是用来管理OpenGL的函数指针的,所以在调用任何OpenGL的函数之前我们需要初始化GLAD
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
接下来就是要使这两个着色器生效,首先就是调用glCreateShader创建着色器对象,利用glShaderSource将它与之前保存的着色器源码进行绑定,然后调用glCompileShader编译它(可以利用glGetShaderiv判断是否创建成功):
// vertex shader
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);//第二个参数是指第几个源码
glCompileShader(vertexShader);
// check for shader compile errors
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// fragment shader
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
// check for shader compile errors
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
因为这两个着色器之间可能有对应的输入输出关系,所以我们需要创建一个着色器程序来链接它们:
首先用glCreateProgram返回创建的着色器程序ID,然后利用glAttachShader将两个着色器附加在着色器程序上,最后用glLinkProgram完成链接(这里也可以用glGetProgramiv判断是否链接成功),完成后可以删除创建的两个着色器,因为之后只用调用着色器程序就行了
// link shaders
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// check for linking errors
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
定义完着色器,接下来就是要把顶点数据传给着色器了
float vertices[] = {
-0.5f, -0.5f, 0.0f, // left
0.5f, -0.5f, 0.0f, // right
0.0f, 0.5f, 0.0f // top
};
可是该怎么把这些数据传给GPU处理呢?
这里就引入了顶点缓冲对象(Vertex Buffer Objects, VBO)的概念,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。
那么之后GPU怎么解析这些顶点数据呢?我们可以给这些顶点指定属性,但是今后可能会出现不止一种顶点,不可能渲染的时候一个个设置吧,那么这时候就引入了顶点数组对象(Vertex Array Object, VAO),它可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。
如下, glGenVertexArrays和glGenBuffers分别是生成VAO和VBO对象的函数,我们需要在设置一切顶点属性前利用glBindVertexArray绑定VAO,然后使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上(顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER),之后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中(第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式,GL_STATIC_DRAW :数据不会或几乎不会改变 GL_DYNAMIC_DRAW:数据会被改变很多 GL_STREAM_DRAW :数据每次绘制时都会改变。)
接下来我们就可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据
第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0。
第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
第五个参数叫做步长(Stride), 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节。
最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。
设置完后就用glEnableVertexAttribArray启用顶点0的属性,接下来就将VAO,VBO解绑以等待之后使用
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
glBindBuffer(GL_ARRAY_BUFFER, 0);
// You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
// VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
glBindVertexArray(0);
接下来就到了渲染环节了:
我们在完成清屏后,通过glUseProgram调用生成过的着色器程序,然后绑定之前创建的VAO(这里只有一个VAO,其实只用绑定一次就够了,只不过为了规范化才这么写),使用glDrawArrays画出三角形(第一个参数是我们打算绘制的OpenGL图元的类型。由于我们在一开始时说过,我们希望绘制的是一个三角形,这里传递GL_TRIANGLES给它。第二个参数指定了顶点数组的起始索引,我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长))
while (!glfwWindowShouldClose(window))
{
// 输入
processInput(window);
// 渲染指令
//调用glClear函数,清除颜色缓冲之后,整个颜色缓冲都会被填充为glClearColor里所设置的颜色
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(VAO); // seeing as we only have a single VAO there's no need to bind it every time, but we'll do so to keep things a bit more organized
glDrawArrays(GL_TRIANGLES, 0, 3);
// 检查并调用事件,交换缓冲
glfwSwapBuffers(window);//交换颜色缓冲(它是一个储存着GLFW窗口每一个像素颜色值的大缓冲),它在这一迭代中被用来绘制,并且将会作为输出显示在屏幕上
glfwPollEvents();//检查有没有触发什么事件(比如键盘输入、鼠标移动等)、更新窗口状态,并调用对应的回调函数(可以通过回调方法手动设置)
}
glfwTerminate();
return 0;
}
得到效果为:
1.3 生成矩形
为了进一步拓展,我们还可以进一步生成矩形,矩形,众所周知可以划分为两个三角形,那我们是不是直接输入6个顶点就行了呢?当然可以,但是存储代价太大,因为一个矩形就多出了2个重复点,我们可以只存储4个顶点,然后再传入一个索引数组,表明每个三角形用了哪个顶点:
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
unsigned int indices[] = { // 注意索引从0开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
那么这时候GPU又该怎么解析呢?
这时候就引入了索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO),它的生成与绑定和VBO类似,只不过缓冲对象是GL_ELEMENT_ARRAY_BUFFER:
unsigned int VBO, VAO;
unsigned int EBO;
glGenBuffers(1, &EBO);
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
glBindBuffer(GL_ARRAY_BUFFER, 0);
// You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
// VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
glBindVertexArray(0);
渲染时绘画函数也不一样,glDrawArrays变成了glDrawElements(第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样。第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点。第三个参数是索引的类型,这里是GL_UNSIGNED_INT。最后一个参数里我们可以指定EBO中的偏移量):
//我们要把所有的渲染(Rendering)操作放到渲染循环中,因为我们想让这些渲染指令在每次渲染循环迭代的时候都能被执行
while (!glfwWindowShouldClose(window))
{
// 输入
processInput(window);
// 渲染指令
//调用glClear函数,清除颜色缓冲之后,整个颜色缓冲都会被填充为glClearColor里所设置的颜色
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(VAO); // seeing as we only have a single VAO there's no need to bind it every time, but we'll do so to keep things a bit more organized
//glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
// 检查并调用事件,交换缓冲
glfwSwapBuffers(window);//交换颜色缓冲(它是一个储存着GLFW窗口每一个像素颜色值的大缓冲),它在这一迭代中被用来绘制,并且将会作为输出显示在屏幕上
glfwPollEvents();//检查有没有触发什么事件(比如键盘输入、鼠标移动等)、更新窗口状态,并调用对应的回调函数(可以通过回调方法手动设置)
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);
glDeleteProgram(shaderProgram);
glfwTerminate();
return 0;
}
最后效果:
2. 练习
2.1 添加更多顶点到数据中,使用glDrawArrays,尝试绘制两个彼此相连的三角形
1.设置顶点:
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
2.渲染:
第二个三角形起始索引为3
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays(GL_TRIANGLES, 3, 3);
效果:
2.2 创建相同的两个三角形,但对它们的数据使用不同的VAO和VBO
顶点:
float firstTriangle[] = {
-0.9f, -0.5f, 0.0f, // left
-0.0f, -0.5f, 0.0f, // right
-0.45f, 0.5f, 0.0f, // top
};
float secondTriangle[] = {
0.0f, -0.5f, 0.0f, // left
0.9f, -0.5f, 0.0f, // right
0.45f, 0.5f, 0.0f // top
};
创建VAO、VBO数组,绑定不同的顶点属性:
unsigned int VBOs[2], VAOs[2];
glGenVertexArrays(2, VAOs); // we can also generate multiple VAOs or buffers at the same time
glGenBuffers(2, VBOs);
// first triangle setup
// --------------------
glBindVertexArray(VAOs[0]);
glBindBuffer(GL_ARRAY_BUFFER, VBOs[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(firstTriangle), firstTriangle, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); // Vertex attributes stay the same
glEnableVertexAttribArray(0);
// glBindVertexArray(0); // no need to unbind at all as we directly bind a different VAO the next few lines
// second triangle setup
// ---------------------
glBindVertexArray(VAOs[1]); // note that we bind to a different VAO now
glBindBuffer(GL_ARRAY_BUFFER, VBOs[1]); // and a different VBO
glBufferData(GL_ARRAY_BUFFER, sizeof(secondTriangle), secondTriangle, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); // because the vertex data is tightly packed we can also specify 0 as the vertex attribute's stride to let OpenGL figure it out
glEnableVertexAttribArray(0);
分别渲染:
glBindVertexArray(VAOs[0]);
glDrawArrays(GL_TRIANGLES, 0, 3);
// then we draw the second triangle using the data from the second VAO
glBindVertexArray(VAOs[1]);
glDrawArrays(GL_TRIANGLES, 0, 3);
效果:
2.3 创建两个着色器程序,第二个程序使用一个不同的片段着色器,输出黄色;再次绘制这两个三角形,让其中一个输出为黄色
同样的,我们定义两个片段着色器,一个输出黄色,一个输出橙色:
const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
const char* fragmentShader2Source = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 1.0f, 0.0f, 1.0f);\n"
"}\n\0";
创建两个片段着色器对象:
unsigned int fragmentShader[2];
fragmentShader[0] = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader[0], 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader[0]);
fragmentShader[1] = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader[1], 1, &fragmentShader2Source, NULL);
glCompileShader(fragmentShader[1]);
// check for shader compile errors
glGetShaderiv(fragmentShader[0], GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader[0], 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
glGetShaderiv(fragmentShader[1], GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader[1], 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
链接到两个着色器程序:
// link shaders
unsigned int shaderProgram[2];
shaderProgram[0] = glCreateProgram();
glAttachShader(shaderProgram[0], vertexShader);
glAttachShader(shaderProgram[0], fragmentShader[0]);
glLinkProgram(shaderProgram[0]);
shaderProgram[1] = glCreateProgram();
glAttachShader(shaderProgram[1], vertexShader);
glAttachShader(shaderProgram[1], fragmentShader[1]);
glLinkProgram(shaderProgram[1]);
// check for linking errors
glGetProgramiv(shaderProgram[0], GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram[0], 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
glGetProgramiv(shaderProgram[1], GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram[1], 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
最后分别调用两个着色器程序输出两个三角形:
glUseProgram(shaderProgram[0]);
glBindVertexArray(VAOs[0]);
glDrawArrays(GL_TRIANGLES, 0, 3);
// then we draw the second triangle using the data from the second VAO
glUseProgram(shaderProgram[1]);
glBindVertexArray(VAOs[1]);
glDrawArrays(GL_TRIANGLES, 0, 3);
效果: