一.概述
上一篇博文讲述了如何使用VisualStudio2022配置和开发OpenGL,并绘制了一个空窗口。
这一篇博文会讲述如何使用OpenGL绘制一个最基础的三角形,相当于OpenGL的"Hello World"
本篇博文内容主要是参考OpenGL的官方学习文档:
你好,三角形 - LearnOpenGL CN
在官方文档的基础上将其内容的精华部分抽离总结
OpenGL的开发学习是一个实践性很强的过程,初学者有时会容易陷入对概念,术语,流程等的揣测之中
初学阶段OpenGL的一些概念、术语和流程,有时理解的可能不会太透彻
没有关系,先把它们背下来,先不要问为什么,带着疑问去实践
大量地读和写代码,在漫长的动手实践过程中,自然而然就能理解透彻了
书读百遍、其义自见
1.1 记下这三个词汇:
VAO:Vertex Array Object,顶点数组对象
VBO:Vertex Buffer Object,顶点缓冲对象
EBO:Element Buffer Object,元素缓冲对象 或 IBO:Index Buffer Object,索引缓冲对象
1.2 在OpenGL的世界里,任何事物都在3D空间中,但是屏幕和窗口却是2D的
OpenGL的大部分工作是把3D坐标转变为2D像素,这个过程由图形渲染管线来处理
1.3 图形渲染管线(Graphics Pipeline):也叫管线,是指一堆原始图形数据经过一个传输管道,进行各种处理变化,最终显示在屏幕的过程
1.4 图形渲染管线分为两个主要部分:
第一部分把3D坐标转换为2D坐标
第二部分是把2D坐标转变为实际有颜色的像素。
1.5 图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入
所有这些阶段都是并行执行的,所以大多数显卡都有成千上万的小处理核心
GPU上为渲染管线每一个阶段运行各自的小程序,从而在图形渲染管线中快速处理数据。
这些小程序叫做着色器(Shader)
1.6 OpenGL处理过程经典图示:
背下这张图:
顶点着色器:将3D坐标转为OpenGL标准设备化3D坐标,同时对顶点属性进行处理(申请显存、绑定和解释Buffer等)
图元装配:将顶点着色器输出的所有顶点作为输入,并装配成指定图元的形状
本博文中的示例是一个三角形
几何着色器:把图元形式的一系列顶点的集合作为输入,通过产生新顶点构造出新的图元来生成其他形状
本博文示例中,它生成了另一个三角形
光栅化:把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段(Fragment)
片段着色器:计算一个像素的最终颜色,这也是OpenGL高级效果产生的地方
对OpenGL的开发主要也是在片段着色器部分:
它涉及到计算3D场景最终像素颜色:光照、材质、阴影、光的颜色、alpha测试和混合(Blending)等等
1.7 在现代OpenGL中,必须至少定义一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)
对于大多数场合,我们只需要配置顶点和片段着色器就行了
几何着色器是可选的,通常使用它默认的着色器就行了
二.顶点数据
2.1 顶点(Vertex) :一个3D坐标数据的集合
顶点数据(Vertex Data) :一系列顶点的集合
顶点属性(Vertex Attribute):描述顶点数据
我们要绘制的三角形的顶点数据,就是一个包含了三角形3个顶点3D坐标的数组作为图形渲染管线的输入
我们要绘制的三角形是个2D平面的,所以z值为0
2.3 三角形顶点数据:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0
2.4 生成Buffer:glGenBuffers()
unsigned int VBO;
glGenBuffers(1, &VBO);
生成的Buffer用于定义顶点数据
· 定义顶点数据后,顶点着色器就会向GPU申请显存
· 定义的数据会先存放在CPU内存中,顶点着色器需要向GPU解释申请这块内存的使用明细
· 这就需要我们调用OpenGL的相关函数进行配置,配置完成后,CPU就会一次性将所有顶点发送到GPU
顶点缓冲对象(Vertex Buffer Objects, VBO)就是用来管理这块显存的
2.5 绑定Buffer到OpenGL:glBindBuffer()
OpenGL有许多Buffer对象
顶点Buffer对象的类型是:GL_ARRAY_BUFFER
可以同时绑定多个不同类型的Buffer到OpenGL上
glBindBuffer()函数用于绑定buffer
glBindBuffer(GL_ARRAY_BUFFER, VBO); //新创建的Buffer绑定到GL_ARRAY_BUFFER目标上
一旦调用glBindBuffer()函数后,我们使用的任何GL_ARRAY_BUFFER Buffer调用都是当前绑定的Buffer
2.6 复制数据到Buffer:glBufferData()
看看之前定义的顶点数据是怎么用glBufferData()复制到VBO中的
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
参数一:Buffer的类型
参数二:数据的大小(以字节为单位)
参数三:实际数据
参数四:指定了我们希望显卡如何管理给定的数据,它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW。
如果一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW
这样就能确保显卡把数据放在能够高速写入的内存部分
三.着色器
3.1 着色器简介
在写着色器的代码之前,先对着色器有个简单的了解
(1).着色器语言(GLSL):OpenGL用来编写着色器的一种类似于C语言专用语言(OpenGL Shading Language)
(2).OpenGL需要至少设置一个顶点着色器(Vertex Shader)和一个片段着色器(Fragment Shader)
官方参考文档:
https://registry.khronos.org/OpenGL/specs/gl/GLSLangSpec.1.20.pdf
docs.gl
(3).着色器的开头要先声明版本,接着是输入和输出变量、uniform和main()函数。
每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中
(4).一个典型的着色器有下面的结构:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
int main()
{
// 处理输入并进行一些图形操作
...
// 输出处理过的结果到输出变量
out_variable_name = weird_stuff_we_processed;
}
· 顶点着色器的每个输入变量也叫顶点属性(Vertex Attribute)
· 能声明的顶点属性是有上限的,一般由硬件决定
· OpenGL确保至少有16个包含4分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,
可以查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限:
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;
· uniform是全局变量,其用法本章未涉及到,先不做详细讲解
3.2 顶点着色器
从顶点着色器可以了解到一个着色器完整的过程
3.2.1 写一个顶点着色器
#version 330 core //OpenGL版本号,与OpenGL版本对应
layout (location = 0) in vec3 aPos;
void main(){
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0); //有四个分量的坐标向量
}
GLSL中一个向量(Vector)最多有4个分量,前三个x,y,z分量表示坐标,最后一个w分量用在透视除法上(暂不关注)
3.2.2 着色器源代码赋值字符串指针
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";
3.2.3 编译着色器源码
(1) 创建一个着色器对象,得到它的ID
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
(2) 着色器源码附加到着色器对象
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
第二参数表示传递的源码字符串数量,只有1个就写1,其他就不做解释了,看名称就知道了
(3).检测编译是否成功
会撸代码的都能看懂,不用过多解释,Api是OpenGL的Api
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;
}
3.3 片段着色器
片段着色器的作用:上色!
颜色的4个分量:红,绿,蓝,透明度(alpha)
OpenGL中颜色每个分量的强度:[0.0---0.1]
这三个分量不同数值可以生成超过1600万种颜色
3.2.1 写一个片段着色器
片段着色器只需要一个输出变量:就是(红,绿,蓝,透明度)向量
#version 330 core
out vec4 FragColor; //out表示用于输出的变量
void main(){
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f); //FragColor变量表示片段颜色,在GLSL中定义
}
3.2.1 创建、编译片段着色器
过程与顶点着色器类似
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
3.4 着色器程序
·着色器对象编译完后,必须要链接到一个着色器程序(Shader Program)上
·调用渲染前要先激活着色器程序
·着色器程序会按附加(Attach)顺序,把每个着色器的输出链接到下个着色器的输入,当输出和输入 不匹配的时候,就会报错
3.4.1 创建着色器程序对象:
获取着色器程序对象Id
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
3.4.2 着色器链接到着色器程序:
先附加(Attach)再链接(Link)
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
3.4.3 检测链接
与检测Shader编译的Api略有不同
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
3.4.3 激活着色器程序对象
着色器程序对象激活后,每个着色器调用和渲染调用都会使用这个着色器程序对象
glUseProgram(shaderProgram);
3.3.4 删除着色器对象
着色器对象链接到着色器程序对象后,就不再需要它们了,可以删掉
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
四.链接顶点属性
顶点属性是OpenGL中一个十分重要的概念
上面的章节里,我们已经完成了顶点数据的Buffer分配,绑定等,并将其发送给了GPU
但是OpenGL还不知道如何解析为顶点数据分配的Buffer,需要在渲染前向OpenGL进行解释
必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。
配置顶点属性的目的在于:告诉OpenGL如何把顶点数据链接到顶点着色器的顶点属性上
4.1 顶点Buffer数据需要被解析成下面这样:
· 位置数据被存储为32位(4字节)浮点值
· 每个位置包含3个这样的值(x,y,z)
· 这三个值在内存中是紧密排列(Tightly Packed),中间没有缝隙
· 第一个值在Buffer开始的位置
4.2 glVertexAttribPointer()函数:
glVertexAttribPointer()函数负责解析顶点属性:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
参数:glVertexAttribPointer()函数的参数非常多,逐一介绍:
参数一:这一组顶点属性的起始index
我们这里只有一组,所以配置0
也是顶点着色器中使用layout(location = 0)定义的顶点属性的位置值
参数二:顶点属性大小
示例中每个顶点是一个包含了(x,y,z)三个分量的Vec3,由3个值组成,所以配3
参数三:数据的类型
示例中是GL_FLOAT,GLSL中vec*都是由浮点数值组成
参数四:是否希望数据被标准化
GL_TRUE:所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间
GL_FALSE:不希望被标准化
参数五:步长
一组顶点属性的长度,单位:byte
参数六:位置数据在缓冲中起始位置的偏移量(Offset)
它表示由于位置数据在数组的开头,所以这里是0
这个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换
每个顶点属性从一个VBO管理的内存中获得它的数据
具体是从哪个VBO获取,则是在调用glVertexAttribPointer()时,使用glBufferData()绑定到 GL_ARRAY_BUFFER 的 VBO
顶点属性这个概念对初学者来说可能会比较难以理会,做个如下比喻就容易理解了
比如你向市政局申请一块地盖一个小区,这就是向CPU申请一块内存存放顶点数据
小区盖好之后,也就是顶点数据定义好之后,你得按照市政局标准向其提供小区的详细介绍资料,也就是对顶点数据向CPU进行详细解释
这样市政局才能全面了解到你盖的小区的方方面面,才能给你的小区和小区里每一户房子上门牌号,在市政系统里录入详细地址等
· 小区门牌号就是顶点数据在内存中的首地址;
· 小区里每一栋楼就是一个顶点;
· 每一栋楼里的一个单元就是顶点的其中一个属性;
· 楼间距就是步长;
· 每一栋楼离小区道路的距离就是偏移量等等
这些按市政局标准对小区进行详细介绍的过程,就是按照OpenGL标准对顶点内存向CPU解释其顶点属性的过程
4.3 绘制代码
在所有步骤都做好了的基础上,开始绘制三角形,绘制代码段如下:
// 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();
每当绘制一个物体的时候都必须重复这一过程,
如果需要配置的顶点属性一旦多起来,绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事
有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
这就引入了下面的概念:VAO
5.顶点数组对象(Vertex Array Object, VAO)
OpenGL要求我们使用VAO,如果绑定VAO失败,OpenGL会拒绝绘制任何东西
VAO绑定到OpenGL后,任何顶点属性调用都会储存在这个VAO中
其好处在于:当配置顶点属性指针时,只需要将绘制代码实现一次,之后再绘制物体的时候只需要绑定相应的VAO就行了
5.1 VAO图示
一个顶点数组对象会储存以下这些内容:
· glEnableVertexAttribArray和glDisableVertexAttribArray的调用
· 通过glVertexAttribPointer设置的顶点属性配置
· 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象
5.2 创建、绑定、使用一个VAO
和VBO很类似:
创建:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
绑定:
glBindVertexArray(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);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
6.终章:绘制三角形
前面做的一切都是等待这一刻
一个储存了我们顶点属性配置和应使用的VBO的顶点数组对象
一般要绘制多个物体时,首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用
当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO
要想绘制我们想要的物体,OpenGL给我们提供了glDrawArrays()函数
它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays()参数:
参数一:打算绘制的OpenGL图元的类型。我们要绘制的是一个三角形,所以配置GL_TRIANGLES。
参数二:指定了顶点数组的起始索引,我们从顶点0开始,所以填0
参数三:指定我们打算绘制多少个顶点,三角形只有3个顶点,所以填3
到此,使用OpenGL绘制一个三角形的基本准备工作就做完了
将写好的附件代码编译、运行,就能成功的看到如下一个三角形被绘制出来了: