绘制三角形
上节中完成了窗口的绘制,这节我们主要实现在窗口中完成一个最简单的三角形的绘制,同样,要完成一个三角形的绘制,需要以下内容:
- Vertex Array 存放顶点数据的数组(实际上存放的是顶点数据的指针,后面会详细说一下)
- Vertex Buffer 顶点缓冲区,真正保存顶点数据的一块内存
- Index Buffer 索引缓冲区,指定绘制顶点的顺序
- [Shader](着色器 - 维基百科,自由的百科全书 (wikipedia.org)) GPU执行的一段针对3D对象进行操作的程序
渲染管线(Render Pipeline)
进行绘制之前,简单回顾一下图形渲染管线,更加详细的讲解推荐大家阅读这篇文章:细说图形学渲染管线 - 知乎 (zhihu.com),渲染管线指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程,如果不算应用阶段的话,GPU渲染管线部分包括几何阶段,光栅化阶段,在OpenGL中,图形渲染管线的各个阶段如下图所示:
上面的图中,蓝色部分代表可编程的部分,其余部分是高度可配置的,在应用阶段,我们会准备好顶点数据传到GPU管线当中,顶点数据是一系列顶点的集合,每一个顶点包含了3D坐标的一系列数据,这些数据称之为顶点属性(Vertex Attribute),比如我们熟悉的顶点的位置坐标xyz,顶点的颜色,顶点的纹理坐标等等,这些均属于顶点属性,总之我们想用到的顶点的各种信息都包含在顶点数据当中。
有了顶点的数据,在顶点着色器中进行的过程主要就是各种坐标变换以及最简单的顶点着色,简单来讲,在这个阶段,我们传入的顶点的3D坐标会转换为我们需要的坐标系的3D坐标,同时还允许我们对顶点的数据进行一些基本处理,例如计算顶点的颜色之后进行插值计算(Flat Shading,Gouraud Shading,Phong Shading)。
之后是图元装配阶段,这个阶段负责将我们的顶点装配成图元,可以是顶点,可以是线段,也可以是三角形等,经过图元装配阶段,输出的图元可以被几何着色器进行处理,几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。几何着色器这部分一般情况下是可选的,我们可以跳过它直接到下个阶段。
再之后是光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁剪(Clipping),会丢弃超出你的视图以外的所有像素,用来提升执行效率。
片元着色器是用来计算一个像素最终颜色的,通过3D场景中的光照,阴影等其他数据,计算得到每个片元的最终颜色,然后将片元传入下个阶段:Alpha测试和混合(Blending)阶段,这个阶段检测片段的对应的深度(和模板(Stencil))值(后面会讲),用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。
绘制三角形
1. 准备顶点数据
本节主要任务是完成一个三角形的绘制,所以这里我们准备一个三角形的顶点数据,定义一个float数组:
//Create an array of vertices
float vertices[3 * 3] =
{
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
在这个数组当中,我们仅放置了三角形的三个顶点坐标,还可以放其他的数据到里面,例如颜色,纹理坐标等。注意顶点坐标的范围为[-1.0f, 1.0f]。
2. 创建顶点数组、顶点缓冲区对象
-
顶点数组对象:
顶点数组对象(Vertex Array Object, VAO),任何顶点属性调用都会储存在这个VAO中。
一个顶点数组对象会储存以下这些内容:
- glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
- 通过glVertexAttribPointer设置的顶点属性配置。
- 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。
创建并绑定一个顶点数组:
unsigned int VertexArray; glGenVertexArrays(1, &VertexArray); glBindVertexArray(VertexArray);
-
顶点缓冲区对象:
顶点数据输入GPU之后,会被存储在显存上,通过顶点缓冲区对象(Vertex Buffer Objects, VBO)来进行管理,使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
创建并绑定一个顶点缓冲区:
unsigned int rVertexBuffer; glGenBuffers(1, &VertexBuffer); glBindBuffer(GL_ARRAY_BUFFER, VertexBuffer); //指定缓冲区为顶点缓冲区,还有其他的缓冲区类型
创建好缓冲区之后,还需要将刚才创建的顶点数据复制到缓冲区中,使用函数
glBufferData
:glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData
是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER
目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof
计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW
:数据不会或几乎不会改变。GL_DYNAMIC_DRAW
:数据会被改变很多。GL_STREAM_DRAW
:数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是
GL_STATIC_DRAW
。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW
或GL_STREAM_DRAW
,这样就能确保显卡把数据放在能够高速写入的内存部分。
3. 链接顶点属性
由于顶点数据实际可能包含了各种属性,因此我们需要向OpenGL解释我们顶点数据的内容,假设只有顶点坐标的情况下,顶点缓冲区的数据应该是下面的样子:
- 位置数据被储存为32位(4字节)浮点值。
- 每个位置包含3个这样的值。
- 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
- 数据中第一个值在缓冲开始的位置。
如何告诉OpenGL按照我们的要求解析顶点数据?答案是通过函数glVertexAttribPointer
来指定,
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
解释一下该函数的各个参数:
- 第一个参数指定要修改的通用顶点属性的索引。假设在顶点着色器中使用
layout(location = 0)
定义了position顶点属性的位置值(Location),它可以把顶点属性的位置值设置为0
。- 第二个参数指定顶点属性的大小。可以是1,2,3,4,本例中每个顶点坐标由3个FLOAT值组成,所以大小是3。
- 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中
vec*
都是由浮点数值组成的)。- 下个参数定义是否标准化(Normalize)。如果设置为GL_TRUE,所有数据都会被归一化到0(对于有符号型signed数据是-1)到1之间。这里设置为GL_FALSE。
- 第五个参数是步长(Stride),指定了连续的顶点属性组之间的间隔。由于下个顶点坐标数据在3个
float
之后,所以设置为3 * sizeof(float)
。也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。- 最后一个参数的类型是
void*
,它表示顶点坐标数据在缓冲中起始位置的偏移量(Offset)。由于顶点坐标数据在数组的开头,所以这里是0。
glEnableVertexAttribArray
是以顶点属性位置值作为参数,启用顶点属性。
4.在没有着色器的情况下进行绘制
我们先测试下没有着色器的情况下能否绘制三角形,在render Loop中添加绘制函数:
glBindVertexArray(VertexArray);
glDrawArrays(GL_TRIANGLES, 0, 3);
在进行绘制前要先绑定Vertex Array告诉OpenGL我们要绘制哪个顶点数组中的内容,然后使用glDrawArrays()
函数进行绘制,三个参数的含义分别为:绘制的图元类型,顶点数组的起始索引,绘制的顶点数量。运行结果如下:
可以看到在没有shader的情况下是可以绘制出三角形的,只不过是黑色的(有些电脑上可能是白色),这是因为OpenGL在用户没有指定shader的情况下会默认添加一个shader,就是这个黑色的或者白色的,因此没有shader也是可以绘制图形的。
5.添加 Shader
Shader包括两个部分:Vertex Shader 和 Fragment Shader,用着色器语言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);
}
暂时将顶点着色器的源代码硬编码在代码文件的字符串中:
std::string VertexSrc = R"(
#version 330 core
layout(location = 0) in vec3 a_Position;
out vec3 v_Position;
void main()
{
v_Position = a_Position;
gl_Position = vec4(a_Position, 1.0);
}
)";
Tips:字符串用()括起来外面加上R可以避免在里面添加换行符
片元着色器源代码:
std::string FragmentSrc = R"(
#version 330 core
layout(location = 0) out vec4 Fragcolor;
in vec3 v_Position;
void main()
{
Fragcolor = vec4(v_Position * 0.5 + 0.5, 1.0);
}
)";
之后创建并编译Vertex Shader
//--------------Create and Compile Shader-----------------------
unsigned int VertexShader, FragmentShader;
// Create an empty vertex shader handle
VertexShader = glCreateShader(GL_VERTEX_SHADER);
// Send the vertex shader source code to GL
// Note that std::string's .c_str is NULL character terminated.
const GLchar* source = VertexSrc.c_str();
glShaderSource(VertexShader, 1, &source, 0);
// Compile the vertex shader
glCompileShader(VertexShader);
编译的结果需要进行判断,如果编译失败,我们需要得到输出结果:
GLint isCompiled = 0;
glGetShaderiv(VertexShader, GL_COMPILE_STATUS, &isCompiled);
if (isCompiled == GL_FALSE)
{
GLint maxLength = 0;
glGetShaderiv(VertexShader, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetShaderInfoLog(VertexShader, maxLength, &maxLength, &infoLog[0]);
// We don't need the shader anymore.
glDeleteShader(VertexShader);
// Use the infoLog as you see fit.
std::cout << infoLog.data() << std::endl;
std::cout << "VertexShader Compilation failed!" << std::endl;
return -1;
}
glGetShaderiv
检查是否编译成功,结果会存放在我们创建的isCompiled
变量中,如果编译失败,那么需要得到错误消息,glGetShaderInfoLog
获取错误消息,得到错误信息之后,我们已经不需要没编译通过的shader了,所以删除它,然后将Log信息打印出来。
Fragment Shader同理:
// Create an empty Fragment shader handle
FragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// Send the Fragment shader source code to GL
// Note that std::string's .c_str is NULL character terminated.
source = FragmentSrc.c_str();
glShaderSource(FragmentShader, 1, &source, 0);
// Compile the Fragment shader
glCompileShader(FragmentShader);
isCompiled = 0;
glGetShaderiv(FragmentShader, GL_COMPILE_STATUS, &isCompiled);
if (isCompiled == GL_FALSE)
{
GLint maxLength = 0;
glGetShaderiv(FragmentShader, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetShaderInfoLog(FragmentShader, maxLength, &maxLength, &infoLog[0]);
// We don't need the shader anymore.
glDeleteShader(FragmentShader);
// Use the infoLog as you see fit.
// In this simple program, we'll just leave
std::cout << infoLog.data() << std::endl;
std::cout << "FragmentShader Compilation failed!" << std::endl;
return -1;
}
6. 添加着色器程序
如果要使用刚才编译的shader,必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。
首先使用glCreateProgram
函数创建一个程序,并返回新创建程序对象的ID引用。把之前编译的着色器附加到程序对象上,然后用glLinkProgram
进行链接:
unsigned int ShaderProgram;
ShaderProgram = glCreateProgram();
glAttachShader(ShaderProgram, VertexShader);
glAttachShader(ShaderProgram, FragmentShader);
glLinkProgram(ShaderProgram);
还需要检查链接状态,链接失败后需要输出错误信息:
// Note the different functions here: glGetProgram* instead of glGetShader*.
GLint isLinked = 0;
glGetProgramiv(ShaderProgram, GL_LINK_STATUS, (int*)&isLinked);
if (isLinked == GL_FALSE)
{
GLint maxLength = 0;
glGetProgramiv(ShaderProgram, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetProgramInfoLog(ShaderProgram, maxLength, &maxLength, &infoLog[0]);
// We don't need the program anymore.
glDeleteProgram(ShaderProgram);
// Don't leak shaders either.
glDeleteShader(VertexShader);
glDeleteShader(FragmentShader);
// Use the infoLog as you see fit.
// In this simple program, we'll just leave
std::cout << infoLog.data() << std::endl;
std::cout << "Shader link failed!" << std::endl;
return -1;
}
shader链接到程序对象之后,就可以删除了,不再需要他们了:
// Always detach shaders after a successful link.
glDetachShader(ShaderProgram, VertexShader);
glDetachShader(ShaderProgram, FragmentShader);
glDeleteShader(VertexShader);
glDeleteShader(FragmentShader);
在 Render Loop 中,对ShaderPrgram
进行使用:
glUseProgram(ShaderProgram);
glBindVertexArray(VertexArray);
glDrawArrays(GL_TRIANGLES, 0, 3)
运行观察结果:
Ohh~ 现在你可以得到一个带颜色的三角形了!
索引缓冲对象
既然我们可以用顶点数组来绘制三角形,那为什么还要设置一个索引缓冲对象呢?
举一个简单的例子,你想要绘制一个矩形,在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 // 左上角
};
发现问题了吗?右下角和左上角的顶点重复使用了两次,也就是说我们把右下角这个顶点和左上角这个顶点绘制了两次,产生了额外的开销,试想,如果你有一个成千上万的三角形面的模型,在绘制的时候都按照上述方法进行绘制的话,那么对于性能的浪费是致命的!因为每一个顶点中包含了大量的数据。因此OpenGL提供了索引缓冲对象,通过索引来进行绘制,同样以上面矩形为例,我们只需要准备绘制一个矩形所用到的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,2,3)就是顶点数组vertices的下标,
// 这样可以由下标代表顶点组合成矩形
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
按照这个索引给出的顺序进行绘制,我们就不需要额外存储那两个重复的顶点了,从而大幅提升了性能。
那么如何使用索引缓冲对象呢?
与数组缓冲对象类似,首先创建索引缓冲对象,并进行绑定,然后指定缓冲区的内容:
unsigned int IndexBuffer;
glGenBuffers(1, &IndexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, IndexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
最后,在Render Loop中使用函数glDrawElements
替换glDrawArray
进行绘制,:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, IndexBuffer);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
第一个参数指定了绘制的模式,和glDrawArrays的一样。第二个参数是打算绘制顶点的个数,这里填6,一共需要绘制6个顶点。第三个参数是索引的类型,这里是GL_UNSIGNED_INT。最后一个参数里可以指定 IndexBuffer 中的偏移量(或者传递一个索引数组,不使用索引缓冲对象的时候),在这里先使用0。
glDrawElements
函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER
目标的IndexBuffer中获取其索引。这意味着我们每次想要使用索引渲染对象时都必须绑定相应的索引缓冲区,有点麻烦。但是顶点数组对象是和索引缓冲区对象是绑定的。绑定到VAO也会自动绑定该IBO。
运行程序得到如下结果:
如果你想要绘制线框模式,可以添加如下代码:
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
第一个参数表示我们打算将其应用到所有的三角形的正面和背面,第二个参数告诉我们用线来绘制。之后的绘制调用会一直以线框模式绘制三角形,直到我们用glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
将其设置回默认模式。
绘制结果如下:
参考:
-
[你好,三角形 - LearnOpenGL CN (learnopengl-cn.github.io)](https://learnopengl-cn.github.io/01 Getting started/04 Hello Triangle/)
-
antongerdelan.net/hellotriangle:Anton Gerdelan的渲染第一个三角形教程。
-
open.gl/drawing:Alexander Overvoorde的渲染第一个三角形教程。
-
antongerdelan.net/vertexbuffers:顶点缓冲对象的一些深入探讨。
-
调试:这个教程中涉及到了很多步骤,如果你在哪卡住了,阅读一点调试的教程是非常值得的(只需要阅读到调试输出部分)。