渲染管线
固定管线和可编程管线
core-profile immediate mode,教程都针对core-profile进行。这些对应的是3.3及其以后的版本,相比固定管线来说效率更高。固定管线学习起来比较容易封装的好,但是渲染效率低。core-profile的话学习起来稍微困难点,需要真的理解相关机制和原理才可以。
现在许多高版本的opengl都支持很多新的特定,这些实现必须要更新显卡相关的驱动。但是一般情况下核心的函数基本都是能用的。
扩展是OpenGL的一大特色,提供使用思路:
1 | if(GL_ARB_extension_name) |
状态机
context说白了就是状态机的内容。在OpenGL我们通过设定选项,操控buffers来改变状态机,然后使用当下的context进行渲染。
对象
一个对象就是opengl里面状态的子集的集合。比如我们可以有一个对象表示绘制窗口的设置。然后我们可以设置它的大小,支持多少颜色等。
1 | struct object_name { |
There are objects for example that act as container objects for 3D model data (a house or a character) and whenever we want to draw one of them, we bind the object containing the model data that we want to draw (we first created and set options for these objects). Having several objects allows us to specify many models and whenever we want to draw a specific model, we simply bind the corresponding object before drawing without setting all their options again.
hellowindow的一些流程解释
创建窗口-定义context,然后处理输入输出。
固定管线
具体的代码在上一篇文章中已经给出了,都是基于glut的最基本的实现,比较简单,属于固定管线编程。基于固定管线我上手了一些例子,当时学习的时候进行过一些简单的绘制,比如立方体啊什么的。但是因为最后项目需要要用到shader,所以那块的进度也就基本到那个进度。当时给同事做了一个个基于硬件交互来进行动态绘制的demo。可惜并没有保存视频,大致就是基于视觉追踪进行用交互笔抓去一个立方体的实现,基本实现了整个AR的流程(识别,矩阵变换,现实渲染,虚拟内容渲染和shader融合等),以后会把当初的思路通过专题记录下来。
可编程管线
在这没什么好讲的,因为只是绘制一个窗口的话没用到具体内容,说下大致的流程:(使用glfw)
1 glfw初始化和相关配置,注意平台相关;
2 glfw创建窗口,包括创建窗口重绘回调;
3 glad加载所有的opengl函数指针;
4 渲染循环 input处理-渲染-交换缓冲区和poll IO events;
5 glfw结束,清除glfw资源;
相关完整代码:
1 |
|
绘制一个三角形
你的绘制永远是3D内容,但是opengl需要将3d空间变换为2D空间,这部分是由OpenGL的Graphic Pipeline完成的。这个管线可以分成两个大的阶段:第一部分将3d坐标转换成2d坐标,第二部分是将2d坐标转换成实际的颜色像素。该部分我们着重讨论这个流程。这个流程是高度specialized的(有一个专门的函数)而且很容易能够并行处理。相应,显卡里有巨多的小单元来处理这个过程,在管线的每一步都在它上面跑一个小程序,这就是shader。下图是可编程管线的流程,蓝色部分是可以自定义的。
见图opengl2-001
顶点数据:顶点shader-形状组装-几何shader-光栅化-片元shader-测试和渲染
作为输入,我们提供了一组可以组成三角形的3d坐标,叫做Vertex Data。这个vertexdata是一些顶点的集合。一个顶点就是每一个3d坐标的数据集合。这个数据集合可能被包含了任何可能的数据,这些东西用vertex attributes表示。
你告诉opengl你渲染的是什么类型的数据集,我们需要规定primitives(就是预定义好的渲染类型),包括GL_POINTS, GL_TRIANGLES and GL_LINE_STRIP等。
1 vertex shader
进行3d坐标转换,目的是我们可以基于顶点属性做一些基本的处理。
齐次坐标变换等。
2 primitive assembly
将所有顶点作为输入,基于给定的形状组织所有的顶点,比如一个三角形
3 几何shader
他会基于给定的形状增加一些新的顶点,比如我们要生成一个三角形,知道三个点,几何shader会根据预置类型-三角形,自动填补三角形三条边上的点。
4 光栅化
将上一步的结果转化成相应的屏幕上的像素,分割成可以被frgament shader处理的片元。同时进行裁剪,它会把所有位于视野外/屏幕外的片元裁剪掉,提高性能。
片元就是opengl来进行渲染要一个单独像素所需要的所有数据。
5 片元shader最主要的目的是计算像素的最终颜色,这是所有opengl高级特性发生的阶段。包含可以进行这些处理的数据:光照、阴影、光的颜色等
6 深度测试和渲染
所有的像素点颜色计算完成后,进行遮挡、半透明等的处理。
我们核心要关注的就是顶点shader和片元shader。
顶点输入
opengl最后渲染的的是一个在单位正方体内的齐次(normalized device ccordinate)坐标,xyz都从-1到1。
现在我们要渲染一个三角形,直接定义到NDC坐标:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
图002
顶点数据在被vertex shader处理后,肯定是在NDC空间内的坐标。其他的不被渲染。
接着,这些齐次坐标系内的坐标,会通过你配置的glViewport转换到屏幕坐标screen-space coordinate。这些屏幕坐标转换成片元就是片元shader的输入数据。
顶点数据准备好以后,我们把他们提供给vertex shader,通过在GPU上创建显存区域,在这部分区域存储vertex data。具体通过vertex buffet object(VBO)实现。他能够存储大量的顶点数据,所以我们要尽可能地将这些数据一次发送给GPU,剩下的GPU处理起来就很快了。VBO是一个对象,我们在OpenGL里都有一个唯一的ID指向这个buffer对象:
unsigned int VBO;
glGenBuffers(1, &VBO);
OpenGL有很多不同种类的buffer对象,顶点的buffer对象叫GL_ARRAY_BUFFER,OpenGL容许我们一次绑定到不同的buffers,只要他们有不同的buffer类型。我们使用glBindBuffer将新创建的buffer绑定给GL_ARRAY_BUFFER对象:
第一个参数是要生成的缓冲对象的数量,第二个是要输入用来存储缓冲对象名称的数组。该函数会在buffers里返回n个缓冲对象的名称。
个人理解如下,可以声明一个GLuint变量,然后使用glGenBuffers后,它就会把缓冲对象保存在vbo里,当然也可以声明一个数组类型,那么创建的3个缓冲对象的名称会依次保存在数组里。
GLuint vbo;
glGenBuffers(1,&vbo);
GLuint vbo[3];
glGenBuffers(3,vbo);
注意:这里我用的是VBO做的示范,解释一下,glGenBuffers()函数仅仅是生成一个缓冲对象的名称,这个缓冲对象并不具备任何意义,它仅仅是个缓冲对象,还不是一个顶点数组缓冲,它类似于C语言中的一个指针变量,我们可以分配内存对象并且用它的名称来引用这个内存对象。OpenGL有很多缓冲对象类型,那么这个缓冲对象到底是什么类型,就要用到下面的glBindBuffer()函数了。
glBindBuffer(GL_ARRAY_BUFFER, VBO);
绑定以后,针对GL_ARRAY_BUFFER对象的任何调用都被用来配置当前的VBO。使用glBufferData将定义好的顶点数据传给buffer的memory:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData解释说明:
glBufferData is a function specifically targeted to copy user-defined data into the currently bound buffer. Its first argument is the type of the buffer we want to copy data into: the vertex buffer object currently bound to the GL_ARRAY_BUFFER target. The second argument specifies the size of the data (in bytes) we want to pass to the buffer; a simple sizeof of the vertex data suffices. The third parameter is the actual data we want to send.
The fourth parameter specifies how we want the graphics card to manage the given data. This can take 3 forms:
GL_STATIC_DRAW: the data will most likely not change at all or very rarely.
GL_DYNAMIC_DRAW: the data is likely to change a lot.
GL_STREAM_DRAW: the data will change every time it is drawn.
The position data of the triangle does not change and stays the same for every render call so its usage type should best be GL_STATIC_DRAW. If, for instance, one would have a buffer with data that is likely to change frequently, a usage type of GL_DYNAMIC_DRAW or GL_STREAM_DRAW ensures the graphics card will place the data in memory that allows for faster writes.
我们已经通过VBO将定点数据存储到显卡的内存中,下一步就是创建vertex shader 和fragment shader来处理这些数据。
vertex shader
现代OpenGL中,我们至少需要一个vertex shader和一个fragment shader。这里简单讨论和配置两个shader 的实现。
1 |
|
使用GLSL语言,通过in关键字声明输入的顶点数据。我们现在只关注位置数据,所以我们是需要一个顶点属性。GLSL有一个向量类型包含1-4个浮点数据。同时,通过layout (location = 0)我们也设置了输入数据的位置。注意xyzw中w是用来处理perspective division的。
参考阅读:https://stackoverflow.com/questions/17269686/why-do-we-need-perspective-division
为了设置vertex shader的输出我们必须将位置数据赋值给预置的gl_position蓝色参数,在后台是一个vec4类型。在main函数的最后,gl_position都是vertex shader 的输出,不管我们怎么设置的。因为我们的输入是三维的向量所以转化成四维。
当前的shader只是一个特别简单的,没有做任何处理,也没有做到NDC的坐标变化-通常来讲这一步是必须的。
编译shader
1create a shader object:
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
2attach the shader source code to the shader object and compile
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
片元shader
这一步计算最终像素的颜色,这里只输出特定的颜色。
计算机系统中颜色是四维的向量,rgba,每一个范围在0.0和1.0之间。
1 |
|
fragment仅仅需要一个输出参数,是一个定义最终颜色的四维的向量,我们需要自己构建。利用关键字out,我们定义了FragColor。编译类似:
1 | unsigned int fragmentShader; |
最后一步,将shader对象链接进shader program绿色用来渲染。
sahder program
将编译好的shaderlink到shder program对象,然后在渲染前激活这个shader。
- 创建shaderprogram 对象by glcreateprogram:
unsigned int shaderProgram;
shaderProgram = glCreateProgram(); - 用gllinkprogram连接:
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram); - 连接最终得到的是一个program对象,使用:
glUseProgram(shaderProgram); 删除shader 对象,连接后shader program就不用了。
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
综上,我们将输入数据给了GPU,也通过两个shader告诉GPU应该如何处理这些数据。但是opengl依然不知道如何如何解释顶点中的数据、如何链接顶点数据和vertexshader中的属性。我们告诉他。连接顶点属性
vertex容许我们以vertex attributes的形式规定任何的输入,比较灵活,也需要更多的人工处理。我们需要在渲染前告诉opengl如何解释这些数据。
我们的vertex buffer data 结构现在这样:The position data is stored as 32-bit (4 byte) floating point values.
- Each position is composed of 3 of those values.
- There is no space (or other values) between each set of 3 values. The values are tightly packed in the array.
- The first value in the data is at the beginning of the buffer.
图003
所以我们用以下函数告诉OpenGL该怎么理解:1
2glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
1 定义了我们匹配哪个vertex 属性,跟location = 0对应
2 规定了vertex 属性的长度,我们现在是vec3,所以是3
3 数据类型
4 规定了我们是不是需要将数据齐次化
5 下一个数据出现的距离间隔,默认0的话就是连续的数据空间(stride)
6 where psition data begins in the buffer(offset)
总结来说就是以下两点:
1 每一个vertex 属性从VBO管理的显存中拿取它的数据;
2 从那个VBO中拿数据呢(可能有很多个VBO),取决于当前绑定给GL_ARRAY_BUFFER的VBO,这个过程发生在调用glVertexAttribPointer的时候。
然后,通过glEnableVertexAttribArray,我们使能vertex attributes,将vertex attributes location(也就是0)当作它的参数。因为默认vertex attributes是关闭的。接下来整个绘制流程就可以这样展开:
1 | // 0. copy our vertices array in a buffer for OpenGL to use |
最后考虑这个问题,如果有5个vertex attributes,100多个不同的物体,绑定buffer object和为每个物体配置verex attributes将很繁杂。
Vertex Array Object
wiki:https://www.khronos.org/opengl/wiki/Vertex_Specification#Vertex_Array_Object
主要目的是解决在不同的vertex data和attribute config切换时,只需要切换绑定的VAO即可。根据wiki的解释这个VAO里存放了提供顶点数据时所需要的状态数据。
准确来说,VAO对象存储了以下的信息:
1 glEnableVertexAttribArray或者glDisableVertexAttribArray的调用
2 通过 glVertexAttribPointer的vertex attributes 配置
3 通过glVertexAttribPointer的调用,找到与vertex attributes关联的VBO
图004
创建和绑定VAO:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
要使用VAO你必须使用glBindVertexArray绑定VAO。从这点上来讲,我们应该绑定或者配置相应的VBOS和属性pointers ,然后再将VAO解绑以便后续需要。一旦我们需要画一个图形,我们只需要将绑定VAOwith首选的设置:
1 | // ..:: Initialization code (done once (unless your object frequently changes)) :: .. |
以上就是所有的流程。一个VAO保存我们vertex atttributes配置数据和用哪个VBO。通常你需要绘制不同的对象时,首先生成/配置所有的VAOs(thus VBO和attributes pointers)并且保存以便后面需要。我们需要绘制我们的对象时,我们拿取相应的VAO,绑定它,然后再解绑。
绘制三角形
调用glDrawArrays绘制,使用:当前激活的shader,之前定义的vertex attributes 配置以及VBO的顶点数据(通过VAO间接绑定的)。
1 | glUseProgram(shaderProgram); |
完整代码
1 |
|
EBO
比如我们要画一个矩形,需要两个三角形,定一堆点,却发现两个点重复的。我们用EBO来处理画点的顺序(这里定四个点的顺序就好0123)。EBO也是个buffer,只不过多存了索引信息。这就是indexed drawing。
图005
代码流程:
1 | // ..:: Initialization code :: .. |
注意:
当目标是GL_ELEMENT_ARRAY_BUFFER的时候,VAO存储glBindBuffer调用。
这也意味着,在解绑你的VAO之前,不要解绑EBO对象。
全部代码如下:
1 |
|
总结
我这里根据教程将整个过程用自己的语言过额一遍,英语好直接去原post阅读,不好的话也有系列的翻译文章,但是翻译的实在是。。。个人体会,所以自己将整个流程记录了下来。关于shader更详细的文章可以自己再去阅读,比如现在shader都是写在源码里,你可以i定义自己的类去读取shader文本,灵活使用,现在这样硬怼实在是太麻烦了。
还有,看来矩阵变换和坐标系要单独写一篇文章了。
其实这里还有另外一个想法,写作和学习过程中,一帮国内的博客实在是没眼看,说来说去不是架子太空,专业字眼太多,就是西拼东凑,各有各的逻辑和理解。说实在的,官网的资料一堆既清晰又明白,以后这种学习性质的博客就不会再出了,可能还是更多地分享一些成形的想法、代码和项目。
话又说回来,公司的能在这写么。。。