OpenGL学习笔记
碎碎念
这学期的CG课还是蛮有趣的,主要讲了一些关于计算机呈现图像的基本知识,然后第二次作业就让我们亲自用OpenGL来实践。
OpenGL是一个专门用于图形开发的库,它不是一个专门的什么语言,而是一种规范,定义了许多接口,不同的语言都有实现,如C、C++、Python等,而某些游戏引擎中也都有对它的接口有实现。
简单来说,如果你想制作一个窗口程序,或者对图像数据进行编辑,实现各种视觉效果,或者游戏开发,OpenGL都是一个非常好的工具。
现在我们只是想掌握一些基本的原理,看看怎么用代码实现。
不过最近要学的东西很多,Python、OS、计网、前端,还有一大堆作业DDL,有多少心思放在图形学上就很难讲了。但我也会尽量把学到的总结梳理一遍,若是日后有对这方面的需要也不至于茫然无措。
环境配置以及相关依赖
详细配置见创建窗口
- IDE: VS 2017
- OpenGL: OpenGL32.lib (VS自带)
- GLFW: glfw3(32-bit) 针对OpenGL的C语言库
- GLAD
关于GLAD
需要准备一下OpenGL的函数,为什么这么说呢,因为OpenGL只是一个规范,而因为显卡不同,具体实现是针对显卡来实现的,所以不同厂商都有不一样的实现方式,我们所用的glfw
只是其中的一种实现,我们需要查看它的文档才知道它的那些函数的实现是什么(比如函数名是什么)。
但如果实际中这么做是一件很费力的事,倘若我换了一套实现接口,那岂不是要全部改一遍。好在有人实现了一个转换的库——glad
,它的工作就是将你使用的库的OpenGL函数换一个函数名,也就是放在一个函数指针里,在Windows下的实现原理如下:
1 | // 定义函数原型 |
繁复的工作glad
会替我们完成,我们只需要到它的在线生成器那里输入我们OpenGL的版本,获取生成的头文件和glad.c
文件,并把其放入项目中即可。具体如何操作可以见之前的环境配置链接。
OpenGL学习
从OpenGL角度出发
OpenGL将一个个的窗口看作是一个状态机,在使用的前需要设置好相关的状态,以及要设置一些状态切换的事件和动作,然后转变为新的状态(当然也包括原先的那个状态)。
这有点类似于面向对象编程,需要定义一个对象的属性和行为,最重要的是这个行为是由事件驱动的,例如鼠标点击,键盘输入,窗口大小变化等等,说白了就是设置相关事件的回调函数,这又和Javascript的某些机制相似了,其实仔细想来,计算机这个领域中有很多基本概念和模型都会运用在不同的场合。
一个基本的框架
写代码前应该习惯在脑海中形成一个基本的框架,不然很容易到了中途就忘记了自己最初想实现的是什么。我开始学习OpenGL的时候也是,只知道跟着别人的代码敲,敲完了也不是很懂,直到后来慢慢梳理也才有了一个基本的框架形成。其实我觉得一个教程本身应该教的不是代码如何写,而是代码如何构造。
接触过一些GUI编程,它们有些基本的东西还是一样的,比如初始状态需要设置一个基本状态,也就是状态机的初始状态,接着要设置一些事件的回调函数,用以和用户交互。然后会有一个主循环,每一次循环都会渲染一个画面,也就是一帧,检查事件轮询中的事件并触发相应的回调函数,最后将结果呈现出来。
现在开始养成将要做的事做成列表,然后再以后的开发中慢慢扩充,当然也可以任何喜欢的方式,比如思维导图。
目前基本的框架如下:
- 设置初始状态
- 设置事件触发的动作
- 主循环
- 渲染
- 事件轮询
- 呈现
代码实现
有了一个基本的框架我们就开始实现。OpenGL的函数和常量一般都长的吓人,但还是有一些规律在里面的,甚至都是一些常见的英文,通常来说它们的名字直接反映了他们的功能。
记得先#include
相关的文件,以及确保项目已经知道依赖的库和头文件的路径。我的#include
的头文件在stdafx.h
中,注意顺序,GLAD头文件要在GLFW之前。
做好准备工作后我们就来开始设置初始状态。在main函数中添加如下内容:
1 | glfwInit(); |
glfw
库的函数通常都以glfw
开头,而常量则是以GLFW_*
开头,这里glfwInit()
用于初始化,glfwWindowHint()
用来设置OpenGL的版本信息,因为OpenGL有很多版本了,而且不同版本有些细微的区别,因此应该注明自己的项目所用的OpenGL版本。
设置好版本信息,下一步就是创建一个窗口。
1 | GLFWwindow *window = glfwCreateWindow(800, 600, "Learning OpenGL", NULL, NULL); |
窗口对象的变量类型是GLFWwindow *
,可以用glfwCreateWindow()
来创建,前两个参数是宽度和高度,第三个参数是窗口title,后面两个暂时忽略。glfwMakeContextCurrent()
函数将这个窗口的上下文设置为当前线程的主上下文,即后面的一大堆操作都是针对这个窗口的各种状态进行编辑。
接下来需要进行OpenGL函数的准备,当配置好GLAD
后,设置的过程就很简单了,之后的OpenGL一些函数就用gl
开头了,如下:
1 | if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { |
接下来我们来设置一下视口(viewport),所谓视图就是希望给用户看到的区域,它和窗口不一样,窗口只是一个承载视图的容器,当然一般来说视图的大小都和窗口一样,设置视图的函数(注意这里就是以gl
开头的函数)如下:
1 | glViewport(0, 0, 800, 600); |
glViewport()
前两个参数是视口左下角在窗口中的位置,哪个窗口呢?自然是当前线程的主上下文的窗口。后两个参数是视口的宽度和高度。
当然如果只是简单的在main函数设置,这就固定下来了,在窗口大小变化的时候视口就无法变化了。考虑到如果想让其跟上窗口变化,可以注册窗口变化的回调函数,在回掉函数中调用glViewport()
函数。声明并定义如下函数:
1 | void framebuffer_size_callback(GLFWwindow*, int width, int height) |
然后在main函数中注册:
1 | glfwSetFramebuffersSizeCallback(window, framebuffer_size_callback); |
ok!目前暂时只设置一个事件回调,毕竟只是一个简单开始,一个简单的框架。
主循环部分
接下来就是主循环中的部分了。我们先忽略其最关键也是最麻烦的一步——渲染,把它放在后面说,先写一个简单的主循环:
1 | while (!glfwWindowShouldClose(window)) { |
glfwWindowShouldClose()
函数用来检查GLFW是否被要求退出,比如点击右上角的关闭按钮,是的话返回True退出主循环。glfwSwapBuffers()
函数会交换颜色缓冲,由于单缓冲绘图可能存在图像闪烁的问题,故采用双缓冲,前缓冲保存最终输出的图像,后缓冲用来执行渲染指令,当渲染指令执行完毕后,交换前后缓冲图像就立即呈现出来了,之前的不真实感就消除了。glfwPollEvents()
函数用于监测事件,然后触发相应事件的回调函数。
最后不要忘了退出:
1 | glfwTerminate(); |
总结一下目前的代码:
1 |
|
渲染
我们最主要的任务还是要对画面进行渲染,那么问题来了,如何渲染呢?也就是如何去构造一个图像,这涉及到一个概念,图像渲染管线。这个概念这里就不详细说明了,可以参考图形渲染管线。这里主要说明渲染流程的代码,流程如下图。
拿出之前的框架进行完善:
- 设置初始状态
- 设置事件触发的动作
- 初始渲染画面(用以设置VBO、VAO和EBO)
- 绑定顶点数组对象(绑定VAO)
- 复制顶点数据到缓冲中(设置VBO数据)
- 复制索引数组到索引缓冲中(设置EBO数据)
- 设定顶点属性指针并激活属性(配置VAO)
- 主循环
- 渲染
- 调用着色器程序
- 绑定VAO
- 渲染绘画(调用相关Draw函数)
- 解绑VAO
- 事件轮询
- 呈现
- 渲染
如上,渲染其实可以再继续填充内容的,有一些内容我直接就添加上了。比如VAO,VBO和EBO等,关于这三个词可以在之前那个链接里找到,这里不再赘述(主要是因为我现在也只了解了个大概,说不好感觉会产生什么误会)。
着色器程序
如果有看刚刚的链接,就知道图形渲染管线主要是由几个着色器构成的,其中着色器中最重要的两个是顶点着色器和片段着色器,这个需要程序员自己去定义,定义的语言是GLSL
(OpenGL Shading Language)。
1 | const char *vertexShaderSource = "#version 330 core\n" |
那两个字符串是GLSL
源码,暂时先写成这样,具体语法可以先不考虑,但大致应该能看懂。顶点着色器的源码意思为输入顶点数据,它的location
属性(即位置属性,注意一个点可以有很多种属性,location=0
意味位置属性可以用0
索引,如何设置索引后面会讲)数据类型为vec3
(即包含3个浮点数的向量),每个点变量名为aPos,然后他用gl_Postion
来输出它经过顶点着色器处理后的位置参数,这个数据为vec4
。
片段着色器原理差不多,也是进行相应的变换。
接下来就需要编译一下生成着色器,编译的过程是一样的:
1 | // 定义着色器程序 |
用一个unsigned int
来保存着色器id,然后用glCreateShader()
函数来生成着色器,返回值为着色器id,传参GL_VERTEX_SHADER
表示生成顶点着色器,GL_FRAGMENT_SHADER
表示片段着色器。然后调用glShaderSource()
来传入id、传递源码字符串的数量(这里只有1个,所以为1),着色器的源码,第四个参数先设置为NULL。然后调用glCompileShader()
传入id进行编译。
得到编译好的着色器后下一步是将所有的着色器链接起来,形成真正的着色器程序。
1 | // 创建着色器程序,链接所有的着色器 |
从名字来看也能看出其功能,这里不再赘述。等到需要用着色器的时候(渲染的时候)调用glUseProgram(shaderProgram)
即可。
定义顶点数据
接下来我们需要定义顶点数据,这个过程为:
- 定义一个
float
数组存放所有的数据,以及另一个float
数组存放所有的索引。 - 定义VBO、VAO、EBO
- 绑定VAO(
glBindVertexArray(VAO)
)以记录顶点属性 - 绑定VBO为缓冲区,并复制
float
数组数据进入缓冲区 - 绑定EBO为缓冲区,复制索引数组进入缓冲区
- 设置顶点属性,用以告诉用什么索引对应属性
- 激活属性
补一张VBO、VAO和EBO的关系图:
首先是定义顶点最基本的数据和索引数据:
1 | float vertices[] = { |
vertices
定义了各个顶点的坐标,这里定义了一个矩形,分别表示4个顶点,OpenGL用[-1, 1]之间的数来表示位置,2维下的坐标表示如下图:
indices
存放索引数组,比如0就是指vertices
中的第0个点。由于这里准备采用的图元是三角形,一个举行可以用两个三角形表示:
第一行的0, 1, 3
表示右边那个三角形的点坐标,第二行则表示左边三角形。
但注意这里的两个数组均为1维数组,OpenGL怎么知道该如何去读取这个数据,所以我们要设置顶点属性,用以告诉着色器如何去划分。于是乎,接下来就是关于三种对象的使用。
1 | unsigned int VBO, VAO, EBO; |
首先是定义,定义没什麽特别的要说的。
接着按照我们那里的步骤走,可对应到函数名:
1 | // 复制顶点数组到缓冲中供OpenGL使用 |
绑定VAO就是直接glBindVerTtexArray(VAO)
即可,特别说明一下剩下两个,绑定VBO和EBO到GL_ARRAY_BUFFER
进行操作,glBufferData()
用于复制数据到缓冲区,第一个是缓冲区类型,第二个是传入数据的大小,第三个是数据首地址,最后一个用于告诉显卡如何管理给定数据,有以下选择:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
数据放入缓冲区后,再来设置顶点属性,也就是缓冲区的每一部分数据的具体含义,例如刚刚传入的vertices
数组,我想将其3个3个分为一组,表示的是每个点的位置信息,设置好后就需要激活:
1 | glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); |
glVertexAttribPointer()
方法告诉着色器如何解析数据,参数含义如下:
- 指定要配置的顶点属性。可以把顶点属性的位置值设为
0
(在顶点着色器中用layout(location = 0)
定义了position顶点属性的位置值),然后在这里就传入0。 - 第二个参数指定顶点属性的大小。这里是
vec3
,用到3个值,所以传入3。 - 第三个参数指定数据类型,因为是浮点数所以用
GL_FLOAT
。 - 第四个参数定义是否希望数据被标准化,如果为
GL_TRUE
,所有数据都会从[-1, 1]映射到[0, 1]中。我们设置为GL_FALSE
中。 - 第五个参数为步长。
- 最后一个参数类型是
void*
,必须要进行这一步转换,它表示位置数据在缓冲区中的开始位置的偏移量。
glEnableVertexAttribArray()
用于激活顶点属性,0
表示该顶点属性。
接下来在主循环中渲染:
1 | // 渲染循环 |
先使用glUseProgram(shaderProgram)
运行着色器程序,然后重新绑定VAO,用来获得定点属性,然后使用glDrawElements()
函数来绘画,第一个参数是图元,我们设置为三角形,第二个参数是打算绘制的顶点个数,第三个参数是索引的类型,第四个参数是EBO中的偏移量(或者可以传递一个索引数组,这是在不适用索引缓冲对象的时候)。glDrawElements()
函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER
目标的EBO中获取索引。故每次索引渲染都需要绑定相应的EBO。
运行效果
见图:
总结
虽然挺基本的,但是基本上是走了一个简单的呈现图像的流程。整个部分的代码点击这里可以查看。