学习链接:你好,三角形
本课目标:
掌握绘制一个简单图形的基本步骤以及涉及到的重要概念。
OpenGL坐标
真实世界中的物体处在一个三维的坐标系中,三个坐标轴分别是X、Y、Z,在OpenGL中也使用三维坐标系描述空间中位置,但是OpenGL的坐标系有一个范围,三个轴允许显示的值都在-1到1之间,即[-1,1],在这个范围内的坐标称为标准化设备坐标(Normalized Device Coordinates),超过范围的点不予显示。
在屏幕上OpenGL坐标系中X轴向右为正方向,Y轴向上为正方向,Z轴从屏幕向外为正方向。
假如我们现在先忽略Z轴,希望在屏幕上绘制一个三角形,需要怎么做呢?
在OpenGL中绘制的图形是由三种基本的形状构成的,它们是:点、线和三角形,也就是图元(Primitive)GL_POINTS、GL_LINE_STRIP和GL_TRIANGLES。我们想绘制一个三角形,使用三角形图元就可以了。
绘制一个三角形图元需要使用三个顶点(Vertex)来表示,这三个顶点放在一个数组中,这个数组叫做顶点数据(Vertex Data),一个顶点(Vertex)代表一个3D坐标,一个顶点由若干顶点属性(Vertex Attribute)构成(位置、颜色等等)。我们先只表示位置位置,忽略颜色信息,那我们可以用下面的数组在OpenGL中表示一个三角形:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
顶点缓冲对象
我们上文中使用一个数组保存三角形的顶点数据:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
这个数据是处于CPU中的,而OpenGL渲染是在GPU中进行的,所以需要使用将数据从CPU传进去,那就需要一块GPU的内存来接收顶点数据,它就是顶点缓冲对象(Vertex Buffer Objects, VBO)。
创建一个VBO比较简单,使用glGenBuffers函数和一个缓冲ID生成一个VBO对象:
//创建一个VBO:
unsigned int VBO;
glGenBuffers(1, &VBO);
现在只是生成了一个OpenGL缓冲对象,还没有指定具体类型之前并没有什么作用,下面来制定它的类型为顶点缓冲对象:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
将顶点缓冲对象绑定到OpenGL上下文的GL_ARRAY_BUFFER全局变量之后对GL_ARRAY_BUFFER变量的配置都会作用到顶点缓冲对象VBO上。下面我们把CPU中的顶点数据复制到GPU内存中的VBO上:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。
它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。
第二个参数指定传输数据的大小(以字节为单位)。
第三个参数是我们希望发送的实际数据。
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
OpenGL图形渲染管线
有了图形的顶点数据,OpenGL通过图形渲染管线(Graphics Pipeline)将顶点数据转换成屏幕上的像素点, 图形渲染管线可以被划分为两个主要部分:第一部分3D坐标转换为2D坐标,第二部分把2D坐标转变为像素。再细一点的话图形渲染管线可以被分为多个阶段执行的,每个阶段将会把前一个阶段的输出作为输入。
图形渲染管线可以被划分为以下几个阶段:
- 顶点着色器(Vertex Shader)输入单独的顶点,将3D坐标转化为标准化设备坐标,同时允许对顶点属性运行一些基本处理。
- 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点装配成指定的图元。
- 几何着色器(Geometry Shader)把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。
- 光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。
- 片段着色器(Fragment Shader)的主要目的是计算一个像素的最终颜色。
- Alpha测试和混合(Blending)阶段检测片段的对应的深度和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值并对物体进行混合(Blend)。
以上6个阶段中,顶点着色器和片段着色器没有提供默认实现,需要开发者去自定义。
顶点着色器
使用着色器需要三个步骤:编写着色器代码、编译着色器代码、将着色器加入着色器程序并链接、删除着色器。
编写着色器
编写着色器需要使用GLSL(OpenGL Shading Language)语言,下面是一个非常基础的顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
第一行含义是:OpenGL版本使用3.3,使用核心模式,意味着我们使用的是OpenGL功能的一个字集。
第二行含义是:使用in关键声明类型为vec3向量的输入顶点属性aPos。
下面main函数是着色器运行起来之后执行的代码,将位置数据赋值给预定义的gl_Position变量,做顶点着色器的输出。
编译着色器
//将顶点着色器的源代码硬编码在代码文件顶部的C风格字符串中
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";
//创建着色器对象,类型为顶点着色器
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
//将着色器代码加载到到着色器对象上
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
//编译着色器
glCompileShader(vertexShader);
//查询着色器是否编译成功
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;
}
片段着色器
编写着色器
片段着色器的作用是计算像素最后的颜色输出。下面是一个会一直输出橘黄色的片段着色器。
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片段着色器需要声明一个out修饰的vec4类型的向量变量,表示RGBA,这个变量的名字可以自定义。
编译着色器
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
着色器程序
着色器程序对象(Shader Program Object)的作用是将多个着色器合并并链接起来用于渲染。
//创建一个着色器对象
unsigned int shaderProgram = glCreateProgram();
//将顶点着色器和片段着色器添加到着色器对象中
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
//将着色器链接起来
glLinkProgram(shaderProgram);
//获取链接结果
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);
链接顶点属性
我们现在将顶点数据复制到GPU中,也准备好了着色器程序,但是还没有告诉着色器怎样使用顶点数据。编写顶点着色器的时候我们声明了一个顶点属性layout (location = 0) in vec3 aPos,一个位置值为0的vec3向量,用来向程序传入顶点的位置数据(x,y,z)。那么我们怎么把顶点数据按照规则链接到着色器的顶点属性中呢?
我们的顶点数据会被解析为这个样子:
- 位置数据被储存为32位(4字节)浮点值。
- 每个位置包含3个这样的值。
- 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
- 数据中第一个值在缓冲开始的位置。
有了这些信息我们就可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer函数的参数含义如下:
- 第一个参数指定我们要配置的顶点属性。对应顶点着色器中使用
layout(location = 0)定义的属性aPos的位置值(Location)。 - 第二个参数指定顶点属性的大小。顶点属性是一个
vec3,它由3个值组成,所以大小是3。 - 第三个参数指定数据的类型。
- 第四个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为
GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。 - 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个
float之后,我们把步长设置为3 * sizeof(float)。 - 第六个参数表示位置数据在缓冲中起始位置的偏移量(Offset)。
现在我们已经定义了OpenGL该如何解释顶点数据,我们现在应该使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。
自此,所有东西都已经设置好了:我们使用一个顶点缓冲对象将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();
每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢(这其实并不罕见)。绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
顶点数组对象
顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中。
一个顶点数组对象会储存以下这些内容:
- glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
- 通过glVertexAttribPointer设置的顶点属性配置。
- 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。
创建一个VAO和创建一个VBO很类似:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
要想使用VAO,要做的只是使用glBindVertexArray绑定VAO。从绑定之后起,我们应该绑定和配置对应的VBO和属性指针,之后解绑VAO供之后使用。当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把VAO绑定到希望使用的设定上就行了。这段代码应该看起来像这样:
// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
glBindVertexArray(VAO);
// 2. 把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// 4. 激活着色器程序
glUseProgram(shaderProgram);
//绑定VAO
glBindVertexArray(VAO);
//制定三角形图元绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);
绘制三角形
要想绘制我们想要的物体,OpenGL给我们提供了glDrawArrays函数,它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays函数第一个参数是我们打算绘制的OpenGL图元的类型。第二个参数指定了顶点数组的起始索引。最后一个参数指定我们打算绘制多少个顶点。
索引缓冲对象
上面是一个绘制三角形的例子,那如果我们想绘制一个长方形,就需要用三角形的图元去拼,那么有下面这个长方形:
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 // 左上角
};
可以看到,有几个顶点叠加了。我们指定了右下角和左上角两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。使用元素缓冲对象(Element Buffer Object,EBO),也叫索引缓冲对象(Index Buffer Object,IBO)可以指定绘制顶点的索引,避免多余的绘制。
EBO的使用方法如下,首先创建EBO对象:
unsigned int EBO;
glGenBuffers(1, &EBO);
然后将EBO与OpenGL上下文中的全局变量绑定并将索引数据传入:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
我们将EBO作为缓冲对象,绘制的时候需要先绑定EBO再绘制:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
使用EBO之后绘制长方形的代码如下,首先确定顶点坐标和坐标绘制顺序索引数组:
//指定长方形的四个顶点
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,2,3)就是顶点数组vertices的下标,
// 这样可以由下标代表顶点组合成矩形
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 设定顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
//步骤3(复制索引数组到一个索引缓冲中)可以放在2之前,也可放在4之后,并不是一定要放在示例中的位置
[...]
//绘制代码
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);