背景
如果遇到什么错误请在本文指出:https://www.jianshu.com/p/4710b707e3ae
为什么学习OpenGL,在启动篇中已经说的很清楚。实际上OpenGL实际上很多显卡厂商根据这一套规则对接上OpenGL的api,开放给各大系统调用api通过显卡指令绘制到屏幕上。也是这个原因,OpenGL实际是一个客户端-服务端的经典C/S交互模式。
本文将会在Mac上实现OpenGL的代码。这里就不详细讲解,如何安装OpenGL在Mac OS上的环境。
我是根据这篇文章搭建的环境:https://www.jianshu.com/p/891d630e30af
正文
为了能够清晰明了OpenGL的基本编程流程。我先从创建一个窗体开始。
创建一个窗体
创建窗体大致分为如下几个步骤:
- 1.初始化OpenGL中glfw的版本
- 2.创建一个GLFWwindow对象,并且设置为上下文中的主窗体,并且设置窗口变化回调
- 3.创建一个事件循环,该循环是用来显示窗体。
初始化glfw
首先先了解什么GLFW。
GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口
很多时候,我们也是借助GLFW进行进行一些基础渲染操作。
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//如果是mac的操作系统需要加上这一段
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
同时设置当前glfw的主版本号为3,副版本号为3,glfw的模式为核心模式。
创建一个窗口
GLFWwindow *window = glfwCreateWindow(800, 600, "Learn opengl", NULL, NULL);
if(!window){
cout <<"fail open window"<
藏着我们能够看到,全局将会创建GLFWwindow窗口作为整个线程上下文。
这里面有出现了一个新的对象GLAD。这里稍微介绍一下glad。
因为OpenGL只是一个标准/规范,具体的实现是由驱动开发商针对特定显卡实现的。由于OpenGL驱动版本众多,它大多数函数的位置都无法在编译时确定下来,需要在运行时查询。所以任务就落在了开发者身上,开发者需要在运行时获取函数地址并将其保存在一个函数指针中供以后使用
GLAD是一个开源的库,它能解决我们上面提到的那个繁琐的问题
如果想要窗体能够根据拉动变化
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
void framebuffer_size_callback(GLFWwindow *window,int width,int height){
glViewport(0,0,width,height);
}
创建一个事件循环,用来处理具体显示逻辑
就算我们不知道这个事件循环该如何实现,但是我们阅读这么源码,就能知道,像这种事件都会有一个核心的Looper处理事件。
//并不希望智慧之一个图像之后,进程就退出。因此可以在主动关闭之前接受用户输入
//判断当前窗口是否被要求退出。
while(!glfwWindowShouldClose(window)){
processInput(window);
//交换颜色缓冲,他是一个存储着GLFW窗口每一个像素颜色值的大缓冲
//会在这个迭代中用来绘制,并且显示在屏幕上
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glfwSwapBuffers(window);
//检查有没有触发事件,键盘输入,鼠标移动
glfwPollEvents();
}
//双缓冲体系
//应用使用单缓冲绘图会造成图像闪烁问题。因为图像不是一下子被绘制出来
//而是按照从左到右,从上到下逐个像素绘制出来。最终图像不是在瞬间显示给用户
//而是一步步生成,这导致渲染结果布政使。
//为了规避这些问题,我们使用双缓冲渲染创酷应用。前缓冲保存着最终输出图像,显示在屏幕
//而所有的渲染指令都会在后缓冲上绘制,当所哟肚饿渲染指令执行完毕之后,
//我们交换前后缓冲,这样图像就显示出来。
glfwTerminate();
glfwWindowShouldClose代表着每一次循环之前都会判断一次glfw的窗口是否要求被退出,一旦判断为true则推出,调用glfwTerminate,结束进程。
processInput这个方法如下:
void processInput(GLFWwindow *window){
//glfwGetKey确认这个窗口有没有处理按键
//GLFW_KEY_ESCAPE 代表esc按键
//GLFW_PRESS代表按下 GLFW_RELEASE 代表没按下
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS){
//关闭窗口
glfwSetWindowShouldClose(window, true);
}
}
该方法实际上是监听窗口有没有按下esc按键。
glfwPollEvents在检查有没有触发什么事件(比如键盘输入、鼠标移动等)、更新窗口状态,并调用对应的回调函数(可以通过回调方法手动设置)。
glfwSwapBuffers函数会交换颜色缓冲(它是一个储存着GLFW窗口每一个像素颜色值的大缓冲),它在这一迭代中被用来绘制,并且将会作为输出显示在屏幕上。
之所以使用glfwSwapBuffers,是因为使用这里使用了双缓冲技术。因为OpenGL本质上是从屏幕的左到右,从上到下,逐个像素点绘制的,那么就会造成图像闪烁问题。为了规避这个问题,我们应该使用双缓冲绘制图像,前缓冲将会保存着最终的图像,并且在屏幕上显示。所有的缓冲的指令都会在后缓冲中绘制,当后缓冲所有的渲染指令都完成,将会做一次前后缓冲交换,这样就显示了后缓冲的图像。
这种思路也会一直沿用到各大操作系统的显示中。
绘制一个三角形
着色器
当我们绘制一个图形在上面的事件循环。试着思考一下,当我们尝试着绘制一个图形需要经历什么步骤?
- 1.准备好一些顶点
- 2.把这些顶点通过某种方式连接起来
- 3.再上色
大致上分为这么几步骤。但是实际上,仅仅提供几个api是无法很好处理这个丰富多彩的世界,需要更加灵活的方式进行绘制。
在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。这个从3d往2d坐标系变化的工作称为OpenGL的图形渲染管道。
图形渲染管道实际上指一堆原始图形数据途径一个输送管道,期间经过各种变化处理最后输出到屏幕上。
因此OpenGL在绘制屏幕的时候。
- 1.会先绑定顶点
- 2.绑定缓冲区
- 3.接着把顶点输入到顶点缓冲去中
- 4.把缓冲区的数据传送到着色器中
- 5.输出到屏幕。
这里就衍生出一个新的概念,着色器。
图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)
这些着色器本身拥有自己的语言:GLSL。开发者能够通过这种语言高度定制OpenGL每个处理阶段(着色器)本身的逻辑。
因此,我们能够把OpenGL看成一个着色器的编译器。
接下来介绍一下OpenGL本身存在的几个基本着色阶段:
还有可以有更加复杂的着色器阶段:
稍微介绍一下每个阶段着色器究竟做了什么。
顶点着色器
对于绘制命令传输的每个顶点,opengl都会调用一个顶点着色器。根据光栅化之前着色器是否活跃,着色器可能会十分简单。比如将数据复制并传递到下一个着色阶段,叫做传递着色器。他也可能十分复杂,需要大量计算来得到顶点在屏幕上的位置,或者通过光照计算来判断顶点的颜色。
通常的,一个复杂的程序可能包括许多顶点着色器,但是同一时刻只有一个顶点着色器起作用
细分着色
顶点着色处理每个顶点的关联数据之后,如果同时激活了细分着色器,那么他将进一步处理这些数据。比如,细分着色器会使用path来描述物体形状,并使用相对简单的patch几何体联机来完成细分工作,其结果是几何图元的数量增加。并且模型的外观变得更加平滑。细分着色阶段会用到两个着色器来分别管理patch数据并且最终生成最终形状。
几何着色
下一个着色阶段,--几何着色,允许在光栅化之前对每个几何图元做更进一步的处理,如创建新的图元。这额阶段是可选。
图元装配
前面介绍的着色阶段所处理的是顶点数据,此外这些顶点之间如何构成几何图元的所有信息都会被传递到opengl。图元装配阶段将这些顶点与相关的几何图元之间组织起来,准备下一个的光栅化。
剪切
顶点可能落在视口外,也就是我们进行绘制的区域。此时与顶点相关的图元做出改动,保证相关的像素不再视口外绘制。由opengl自己完成。
光栅化
裁剪玩之后马上要执行的工作,就是将更新之后的图元传递到光栅单元生成对应的片元。我们可以将一个片元视为一个候选的像素,也就是说可以放置到帧缓存中的像素,但是他也有可能被剔除,不更新对应位置的像素。
片元着色
最后一个可以通过编程控制屏幕上显示颜色的阶段,叫做片元着色阶段,在这个阶段我们使用着色器来计算片元的最终颜色和它的深度值。
片元着色器十分强大,在这里我们会使用纹理映射的方式,对顶点处理阶段所计算的颜色色纸进行补充。如果我们觉得不应该继续执行某个片元,在片元着色器中可以终止这个片元处理,这一步叫做片元丢弃。
顶点着色决定了一个图元位于屏幕什么位置,而片元着色使用这些信息决定片元是什么颜色。
逐片元的操作
出了在片元着色器里做的工作之外,片元操作的下一步就是最后的独立片元处理过程。这个阶段会使用深度测试和模版测试的方式来决定一个片元是否可见。
如果一个片元成功通过了所有的测试,那么他就可以直接绘制到帧缓存中。它对应的像素颜色值也会更新。如果开启了融合模式,片元的颜色与当前像素颜色叠加,形成新的颜色值写入帧缓存。
实际上,从上面几个阶段的描述,我们可以察觉到有两个着色器是必须存在的,顶点着色器以及片元着色器。
当我们绘制最简单的三角形,就只需要使用到两个着色器。顶点着色器以及片元着色器。
为什么选择使用三角形?因为OpenGL本质上就是绘制三角形的图形第三方库,而三角形正好是基本图元。而不是绘制不了矩形,只是显卡本身绘制三角形会轻松很多,而要把矩形作为OpenGL的基本图元将会消耗更多的性能。
为什么说OpenGL实际上是一个着色器的编译器。看看着色器是怎么编写。
按照上面的逻辑,先编写一个顶点着色器。
使用GLSL编写一个顶点着色器
#define VERTEX_SHADER ("#version 330 core\n\
layout (location = 0) in vec3 aPos;\n\
void main(){\n\
gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0);\n\
}\0")
能够看到define声明了下面一个类似c的方法体。
#version 330 core
layout (location = 0) in vec3 aPos;
void main(){
gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0);
}
这里稍微解释一下,这个着色器中GLSL编写的逻辑。能看到在这个小型程序中,首先有一个main的主函数作为小程序主体。
layout代表着这个顶点着色器从哪个位置绘制。location=0.代表着(0,0,0)的位置。
in 后面带着 vec3 修饰的aPos.
这里我们要倒过来看,就能明白轻易的知道意思。 声明一个aPos属性,其类型是vec3。vec3 是指是一个三维向量。in是指这个属性是从着色器外传送进来的顶点数据。
gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0);
这里能看到会声明一个vec4修饰4d向量的gl_Position。
能看到一个很熟悉的习惯,在一个三维空间中,x代表向量中x轴,y代表向量中y轴,z代表向量的z轴。而4d向量并不是说OpenGL在处理4维空间,这个最后一个分量是w,代表透视除法(这里不多赘述)。
对于向量来说,可以直接通过.x/.y/.z/.w直接获取向量的分量。这个习惯和Octave有点像。我们只要把其抽象看成一个结构体就很好理解了。
因此此时的意思是创建一个4d向量,把从着色器外面传进来的vec3向量赋值给gl_Position.
使用GLSL编写一个片元着色器
此时我们在顶点着色器已经获得了从外部进来的顶点,接下来我们编写一个片元着色器,让这个顶点构成的图形上色。
#define FRAGMENT_SHADER ("#version 330 core\n\
out vec4 FragColor;\n\
void main(){\n\
FragColor = vec4(1.0f,0.5f,0.2f,1.0f);\n\
}\0")
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
在这里面,会绘制一个新的4d向量,FragColor。上面说了片元着色器,此时是为了做出渲染出颜色。因此这个4d向量代表一个rbga一个颜色向量。最后用out修饰,代表这个FragColor将作为输出向量。当输出的时候,就会把顶点着色上这个颜色向量。
编写着色器小程序
为什么说是小程序呢?让我们回忆一下,C语言编程的时候。编译的流程分为几步?
编译的四个阶段:
- 1.预处理阶段 生成.i文件
- 2.编译阶段 生成.S文件
- 3.汇编阶段 生成目标文件.o文件
- 4.链接阶段 生成可执行文件。
同理在编写着色器小程序的时候,很相似。
着色器编写几个步骤:
- 1.创建一个着色器类型
- 2.拷贝GLSL代码到着色器类型中
- 3.编译生成着色器链接库
- 4.创建一个着色器执行程序
- 5.把着色器链接到着色器执行程序
- 6.链接生成带着着色器的执行程序
- 7.删除之前创建的着色器
生成一个顶点着色器
//1.初始化着色器
const char* vertexShaderSource = VERTEX_SHADER;
GLuint vertexShader;
//创建一个着色器类型
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);
cout<< "error when vertex compile:"<
生成一个片段着色器
///下一个阶段是片段着色器
const char* fragmentShaderSource = FRAGMENT_SHADER;
GLuint fragmentShader;
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);
cout<< "error when fragment compile:"<
生成一个着色器可执行程序,并且链接着色器链接库
//链接,创建一个程序
GLuint shaderProgram;
shaderProgram = glCreateProgram();
//链接上共享库
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
//链接
glLinkProgram(shaderProgram);
可执行程序是否编译链接成功
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success){
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
cout<< "error when link compile:"<
删除着色器链接库
//编译好了之后,删除着色器
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
准备传入着色器的数据
传输顶点数据到着色器中,实际上有两个十分关键的对象起作用。
一个是VAO,一个是VBO。
VAO 为顶点数组对象。顶点数组对象可以像顶点缓冲对象那样被绑定,任何随后的顶点属性的调用都会存储到这个VAO。这样做的好处是,当配置了顶点属性指针之后,你只需要执行这些调用一次,之后再绘制物体只需要绑定这个顶点数组对象即可。
VBO 顶点缓冲对象。顶点缓冲对象管理GPU内存内存中的大量顶点。使用缓冲对象做的好处,就是我们可以一次性发送一批数据到GPU上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
VBO的思路实际上可以从Linux的fwrite的源码中看到一样,会在进入内核之前有个缓冲,等到缓冲满了就会输入到内核。因为从用户空间到内核传输数据也是一个相对耗时的工作。
在传送着色器之前,需要介绍以下在OpenGL中的坐标系。
标准化设备坐标(Normalized Device Coordinates, NDC)
OpenGL是一个3d图形渲染库,所以我们的传入的坐标系都是3d坐标(x,y,z轴)。
但是OpenGL不是简单的把所有的3d坐标系都转化为在屏幕上的2d像素;、
一旦你的顶点坐标已经在顶点着色器中处理过,它们就应该是标准化设备坐标了,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。
与通常的屏幕坐标不同,y轴正方向为向上,(0, 0)坐标是这个图像的中心,而不是左上角。最终你希望所有(变换过的)坐标都在这个坐标空间中,否则它们就不可见了。
你的标准化设备坐标接着会变换为屏幕空间坐标(Screen-space Coordinates),这是使用你通过glViewport函数提供的数据,进行视口变换(Viewport Transform)完成的。所得的屏幕空间坐标又会被变换为片段输入到片段着色器中。
因此,为了达到这个效果,在传入数据的时候,还有是否归一化的选项,而归一化正好能把数据缩减到-1到1的区间。这么做的好处,就不用担心溢出和效率问题。
因此我们定义一个三角形坐标
//我们要绘制三角形
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
绑定VAO以及VBO,做好传送的准备
当我们编写OpenGl的时候要记住下面一幅图:
这就是OpenGL中VAO和VBO的关系。能看到的是,当我们声明一个VAO顶点数组对象的时候,里面保存着大量的顶点属性指针,而每个指针又会关联VBO顶点缓冲对象。
而这种指针该怎么移动解释VBO的内容,是由开发者决定。因为VBO中可能拥有各种类型的数据。
同时在OpenGL中,如果不绑定VAO,以及打开VAO顶点数组对象的开关,将会拒绝绘制任何东西。
生成VAO对象,并且绑定
GLuint VAO;
//生成分配VAO
glGenVertexArrays(1,&VAO);
//绑定VAO,注意在core模式,没有绑定VAO,opengl拒绝绘制任何东西
glBindVertexArray(VAO);
生成VBO对象,并且绑定
GLuint VBO;
//生成一个VBO缓存对象
glGenBuffers(1, &VBO);
//绑定VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
//类型为GL_ARRAY_BUFFER 第二第三参数说明要放入缓存的多少,GL_STATIC_DRAW当画面不懂的时候推荐使用
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
能看到的是此时生成VAO和VBO的逻辑十分相似,都经历2个步骤通过glGen的方法获得一个VAO/VBO的句柄,调用glBind方法绑定对应的VAO/VBO。
复制数据到顶点缓冲对象
//类型为GL_ARRAY_BUFFER 第二第三参数说明要放入缓存的多少,GL_STATIC_DRAW当画面不动的时候推荐使用
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
把数据从对象传输到缓存对象中
任务有二:
- 分配顶点数据需要的存储空间
- 将数据从应用程序的数组拷贝到opengl服务端内存。
后面的标志有下面几种:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
设置顶点属性指针如何解析缓存数据
//设定顶点属性指针
//第一个参数指定我们要配置顶点属性,对应vertex glsl中location 确定位置
//第二参数顶点大小,顶点属性是一个vec3,由3个值组成,大小是3
//第三参数指定数据类型,都是float(glsl中vec*都是float)
//第四个参数:是否被归一化
//第五参数:步长,告诉我们连续的顶点属性组之间的间隔,这里是每一段都是3个float,所以是3*float
//最后一个是偏移量
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
可能上面的注释还不够清晰,记住下面这幅图
主要这个方法是为了告诉OpenGL遇到这个顶点数组对象的时候,该如何移动指针解析数据。
最后通过glEnableVertexAttribArray 打开该顶点数组对象的绘制开关。
接棒顶点以及缓存对象
glBindBuffer(GL_ARRAY_BUFFER,0);
glBindVertexArray(0);
为避免出现多线程等干扰,在进行下一次执行的时候,最好先解绑。能看到此时还是调用glBind系列的函数。
glBindVertexArray
glGenVertexArrays返回的数据,则创建一个新的新的顶点数组对象并且和名称关联起来。
如果绑定到已经创建的顶点数组中,初始化则激活绑定顶点数据
当array为0,则不分配任何对象.
glBindBuffer
激活当前的缓存对象。
- 如果是第一次绑定buffer,且是一个非零的无符号整型,创建一个与名称相对应的新缓存对象
- 如果绑定到一个已经创建的缓存对象,那么它将会成为当前激活缓存对象
- 如果绑定buffer为0,则不会给任何缓存
把绘制事件添加到渲染循环中。
while(!glfwWindowShouldClose(window)){
processInput(window);
//交换颜色缓冲,他是一个存储着GLFW窗口每一个像素颜色值的大缓冲
//会在这个迭代中用来绘制,并且显示在屏幕上
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
//我们已经告诉,程序要数据什么数据,以及怎么解释整个数据
//数据传输之后运行程序
glUseProgram(shaderProgram);
//绑定数据
glBindVertexArray(VAO);
//绘制一个三角形
//从0开始,3个
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glfwSwapBuffers(window);
//检查有没有触发事件,键盘输入,鼠标移动
glfwPollEvents();
}
glDeleteVertexArrays(1,&VAO);
glDeleteBuffers(1, &VBO);
能看到此时先调用glUseProgram,运行之前的程序,接着glBindVertexArray重新绑定顶点数据,就能直接通过glDrawArrays绘制。
glDrawArrays 是指此时绘制的是三角形以及,绘制的启示顶点是第0个,绘制3个。
这样就完成了一次绘制。
总结
当然,在自己编写的时候遇到绘制了两个三角形的情况,先显示小的,接着出现大。
怎么看都无法想象究竟出现哪路有问题,接着在初始化窗体的时候发现自己先去调用glViewport变化了窗口大小,接着才进行绘制。
glViewport(0,0,800,600);
这样导致了一个结果,还记得我在开篇就说了,实际上OpenGL是一个C/S架构的第三方库,当我们每一次渲染的时候,调用的是每一条渲染指令。
也就是说,在渲染循环中,当我在第一轮循环中,由于窗体比较小,因此先按照该窗体的相对的标准化坐标中绘制比较小的三角形。接着第二条渲染指令来了,让窗体变大,这个时候整个窗口坐标系产生了变化,这个时候渲染循环与根据此时相对的标准化坐标绘制了一个更大的三角形。
经过这一次的小踩坑,让我对OpenGL产生了更加深刻的理解。