在上一篇博客 iOS — OpenGLES之简单的图形绘制 中,使用OpenGLES绘制了基本的三角形和矩形。在矩形绘制过程中,使用到了VBO,即Vertex Buffer Object,可视为GPU中的一块缓冲区buffer,用于存储顶点的所有信息。OpenGL在GPU中记录着这个VBO的id和对应的显存地址(或地址偏移)。
如果不使用VBO,就直接从CPU主存中传递顶点数据到GPU中进行运算和渲染。绘制图形的过程,实际上就是将内存中存储的vertices和indices等数据通过glDrawElements/glDrawArrays拷贝到GPU中。而频繁地在CPU/GPU之间传递数据的效率很低,因此可使用VBO缓存顶点数据,只在初始化缓冲区及在顶点数据有变化时才需要对该缓冲区进行操作,能大大减少CPU/GPU之间的数据拷贝开销。
绘制红色三角形
设置viewPort,顶点数组
glViewport(0, 0, self.view.frame.size.width, self.view.frame.size.height);
GLfloat vertices[] = {
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f };
不使用VBO时
则不使用VBO时,绘制三角形如下:
// 给_positionSlot传递vertices数据
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(_positionSlot);
// Draw triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
[_eaglContext presentRenderbuffer:GL_RENDERBUFFER];
其中,glVertexAttribPointer用于传递顶点着色器的位置信息。函数原型:
void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride,const GLvoid * pointer)
参数意义如下:
index:顶点数据在着色器程序中的属性,这里即_positionSlot。
size:每个顶点属性的组件数量,这里3表示每个顶点由三个元素组成,如0.0f, 0.5f, 0.0f。
type:每个顶点属性的组件类型,这里即GL_FLOAT。
normalized:指定当被访问时,固定点数据值是否应该被归一化或直接转换成固定点值,这里即GL_FALSE。
stride:指定相邻两个顶点数据之间的偏移量,即间隔大小。OpenGL根据该间隔从由多个顶点数据组成的数据块中跳跃地读取相应的顶点数据。这里vertices数组中仅存储顶点数据(x,y,z),因此相邻两个顶点数据之间的间隔本应为 sizeof(float) * 3 。但此处传递默认参数0的原因在于:0表示在顶点数组中每个顶点数据都是紧密排列的,OpenGL会自动计算各个顶点数据的大小得到对应的间隔。
注意:该函数的最后一个参数pointer的意义会因是否使用VBO而不同。
pointer:未使用VBO时,其指向CPU内存中的顶点数据数组,因此这里是vertices。
而使用VBO时,表示该顶点数据在顶点缓存对象VBO(GL_ARRAY_BUFFER)中的起始偏移量,具体使用示例请看使用VBO绘制三角形的代码。
那么,详细解释了该函数的所有参数,再回头看其未使用VBO时的调用方式:
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, vertices);
是不是都清楚了呢?
使用VBO时
使用VBO时,先创建、绑定VBO,传递顶点数组
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(_positionSlot);
// Draw triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
[_eaglContext presentRenderbuffer:GL_RENDERBUFFER];
先看glVertexAttribPointer,之前说过了其各个参数的意义,这里调用如下:
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, 0);
最后一个参数pointer为0,即顶点数据在VBO(GL_ARRAY_BUFFER)中的起始偏移量是0。因上边代码初始化了vertices数组大小的空间,绑定到GL_ARRAY_BUFFER,因此这里的pointer理所当然就是0。
VBO的使用步骤
使用VBO的步骤一般比较固定,如下:
GLuint vertexBuffer; // VBO的id
glGenBuffers(1, &vertexBuffer); // 创建一个VBO
// 绑定该VBO到GL_ARRAY_BUFFER目标
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
// 为该VBO申请空间,初始化并传递数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer的第一个参数可以是GL_ARRAY_BUFFER, GL_ELEMENT_ARRAY_BUFFER, GL_PIXEL_PACK_BUFFER, GL_PIXEL_UNPACK_BUFFER等。GL_ARRAY_BUFFER用于顶点数据,而GL_ELEMENT_ARRAY_BUFFER用于顶点索引。
glBufferData的第二个参数指定了该VBO的大小,通常是顶点数组所占用空间的大小。第三个参数为顶点数组。第四个参数可以指定GL_STREAM_DRAW, GL_STREAM_READ, GL_STREAM_COPY, GL_STATIC_DRAW, GL_STATIC_READ, GL_STATIC_COPY, GL_DYNAMIC_DRAW, GL_DYNAMIC_READ, or GL_DYNAMIC_COPY等多种。这里GL_STATIC_DRAW指的是顶点数据一般不会改变,而如果顶点数组会经常被改动,则可使用GL_DYNAMIC_DRAW。
再来看glBufferData函数,假如将其第二个参数改为 sizeof(vertices) / 3 * 2,即申请的VBO大小仅能放置vertices数组中的两个顶点数据 {0.0f, 0.5f, 0.0f} 和 { -0.5f, -0.5f, 0.0f }。则绘制的区域即仅为坐标原点(0, 0, 0)及这两个顶点组成的区域,如图:
绘制矩形
接下来看看绘制矩形的情况。矩形都是有两个三角形组成的。
不使用VBO时
不使用顶点索引数组
首先,按照最基本的方式依次设置顶点位置及颜色,使用glDrawArrays依次绘制顶点。
// 顶点数组
const GLfloat Vertices[] = {
-1,-1,0,// 左下,黑色
1,-1,0, // 右下,红色
-1,1,0, // 左上,蓝色
1,-1,0, // 右下,红色
-1,1,0, // 左上,蓝色
1,1,0, // 右上,绿色
};
// 颜色数组
const GLfloat Colors[] = {
0,0,0,1, // 左下,黑色
1,0,0,1, // 右下,红色
0,0,1,1, // 左上,蓝色
1,0,0,1, // 右下,红色
0,0,1,1, // 左上,蓝色
0,1,0,1, // 右上,绿色
};
// 取出Vertex结构体的Position,赋给_positionSlot
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, Vertices);
glEnableVertexAttribArray(_positionSlot);
// Vertex结构体,偏移3个float的位置,即是Color值
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, 0, Colors);
glEnableVertexAttribArray(_colorSlot);
glDrawArrays(GL_TRIANGLES, 0, 6);
三角形的绘制方式
glDrawArrays函数的第一个参数可设置为GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN,分别是绘制三角形的三种方式。
GL_TRIANGLES是以每三个顶点绘制一个三角形。第一个三角形使用顶点V0,V1,V2,第二个使用V3,V4,V5,以此类推。如果顶点的个数n不是3的倍数,那么最后的1个或者2个顶点会被忽略。
以上方式即在glDrawArrays函数中使用GL_TRIANGLES。
而GL_TRIANGLE_STRIP第一个三角形为V0、V1、V2,第二个三角形为V1、V2、V3,第三个三角形为V2、V3、V4,依次类推。GL_TRIANGLE_FAN第一个三角形为V0、V1、V2,第二个三角形为V0、V2、V3,第三个三角形为V0、V3、V4,依次类推。
所以,如果使用GL_TRIANGLE_STRIP绘制以上矩形,顶点数组与颜色数组也要相应变化:
// 顶点数组
const GLfloat Vertices[] = {
-1,-1,0,// 左下,黑色
1,-1,0, // 右下,红色
-1,1,0, // 左上,蓝色
1,1,0, // 右上,绿色
};
// 颜色数组
const GLfloat Colors[] = {
0,0,0,1, // 左下,黑色
1,0,0,1, // 右下,红色
0,0,1,1, // 左上,蓝色
0,1,0,1, // 右上,绿色
};
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, Vertices);
glEnableVertexAttribArray(_positionSlot);
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, 0, Colors);
glEnableVertexAttribArray(_colorSlot);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
如果使用GL_TRIANGLE_FAN呢,因取得三角形顶点的方式不同,因此也必须对顶点数组中顶点的序列做相应调整如下,才能绘制出跟以上两种方式一样的矩形:
// 顶点数组
const GLfloat Vertices[] = {
-1,1,0, // 左上,蓝色
-1,-1,0,// 左下,黑色
1,-1,0, // 右下,红色
1,1,0, // 右上,绿色
};
// 颜色数组
const GLfloat Colors[] = {
0,0,1,1, // 左上,蓝色
0,0,0,1, // 左下,黑色
1,0,0,1, // 右下,红色
0,1,0,1, // 右上,绿色
};
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, Vertices);
glEnableVertexAttribArray(_positionSlot);
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, 0, Colors);
glEnableVertexAttribArray(_colorSlot);
glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
大家可以仔细比较一下GL_TRIANGLE_STRIP与GL_TRIANGLE_FAN两种方式的区别。
使用顶点索引数组
仅通过顶点数组的方式来绘制图形,会有很多顶点被重复绘制。那么可以引入了索引数组,结合glDrawElements函数避免顶点的重复绘制,以节约资源。
// 顶点数组
const GLfloat Vertices[] = {
-1,-1,0,// 左下,黑色
1,-1,0, // 右下,红色
-1,1,0, // 左上,蓝色
1,1,0, // 右上,绿色
};
// 颜色数组
const GLfloat Colors[] = {
0,0,0,1, // 左下,黑色
1,0,0,1, // 右下,红色
0,0,1,1, // 左上,蓝色
0,1,0,1, // 右上,绿色
};
// 索引数组
const GLubyte Indices[] = {
0,1,2, // 三角形0
1,2,3 // 三角形1
};
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, Vertices);
glEnableVertexAttribArray(_positionSlot);
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, 0, Colors);
glEnableVertexAttribArray(_colorSlot);
glDrawElements(GL_TRIANGLES, sizeof(Indices)/sizeof(Indices[0]), GL_UNSIGNED_BYTE, Indices);
glDrawElements的原型是
void glDrawElements(GLenum mode, GLsizei count, GLenum type, const GLvoid *indices);
mode:设置GL_TRIANGLES或其他三角形组合方式。
count:索引数组中的元素个数。
type:索引数组中的元素类型。
indices:这里是索引数组。
需要注意的是,最后一个参数indices的含义其实也是会因为是否使用VBO而不同,这里未使用VBO即为索引数组。而如果使用了VBO,即表示索引数据在VBO(GL_ELEMENT_ARRAY_BUFFER)中的偏移量,使用方式请看下边内容。
使用VBO时
有了以上的基础,接下来再来看使用索引数组+VBO的方式绘制矩形,就容易理解得多了。
首先,将顶点的位置和颜色封装到结构体Vertex中,
// 定义一个Vertex结构
typedef struct {
float Position[3];
float Color[4];
} Vertex;
// 顶点数组
const Vertex Vertices[] = {
{{-1,-1,0}, {0,0,0,1}},// 左下,黑色
{{1,-1,0}, {1,0,0,1}}, // 右下,红色
{{-1,1,0}, {0,0,1,1}}, // 左上,蓝色
{{1,1,0}, {0,1,0,1}}, // 右上,绿色
};
// index数组
const GLubyte Indices[] = {
0,1,2, // 三角形0
1,2,3 // 三角形1
};
接下来,设置VBO:
// setup VBOs
// GL_ARRAY_BUFFER用于顶点数组
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
// 绑定vertexBuffer到GL_ARRAY_BUFFER对象,
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
// 为VBO申请空间,初始化并传递数据
glBufferData(GL_ARRAY_BUFFER, sizeof(Vertices), Vertices, GL_STATIC_DRAW);
// GL_ELEMENT_ARRAY_BUFFER用于顶点数组对应的indices
GLuint indexBuffer;
glGenBuffers(1, &indexBuffer);
// 绑定vertexBuffer到GL_ELEMENT_ARRAY_BUFFER对象,
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(Indices), Indices, GL_STATIC_DRAW);
这里,用到了两个VBO,分别存储顶点数据和索引数据,其使用方式是一致的。要注意GL_ARRAY_BUFFER与GL_ELEMENT_ARRAY_BUFFER的区别,分别对应顶点数据和索引数据。
使用VBO时,glVertexAttribPointer的使用如下:
// 取出Vertex结构体的Position,赋给_positionSlot
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
glEnableVertexAttribArray(_positionSlot);
// Vertex结构体,偏移3个float的位置,即是Color值
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid *)(sizeof(float) * 3));
glEnableVertexAttribArray(_colorSlot);
对于顶点位置,glVertexAttribPointer函数的第二个参数依旧是3,但因Vertex结构体中不仅包含了位置数据,也包含了颜色数据,因此Vertices数组中描述顶点位置的数据不是紧密排列的,故第五个参数stride不能传递默认参数0,而是 sizeof(Vertex) 。最后一个参数pointer为0,即顶点位置数据在VBO中的偏移量为0。
对于顶点颜色,第二个参数是4,即颜色数据由4个float数据组成(分别对应RGBA),第五个参数stride同样是 sizeof(Vertex) 。最后一个参数pointer为 (GLvoid )(sizeof(float) 3) ,即顶点颜色数据在VBO中的偏移量为3个float的大小,即两个相邻的顶点颜色数据之间的间隔为Vertex结构体中的顶点位置数据大小。由此也可看出,该偏移量是针对每个顶点数据而言的。在OpenGL的世界中,都是针对一个个位置或像素进行绘制的。
最后,使用glDrawElements绘制矩形:
glDrawElements(GL_TRIANGLE_STRIP, sizeof(Indices)/sizeof(Indices[0]), GL_UNSIGNED_BYTE, 0);
该函数在每个vertex上调用我们的vertex shader,以及每个像素调用fragment shader。使用VBO时,最后一个参数0表示索引数据在VBO(GL_ELEMENT_ARRAY_BUFFER)中的偏移量,这里注意跟未使用VBO时进行对比。
如果不使用索引数组,使用glDrawArrays如下:
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
同时,以上关于索引VBO(GL_ELEMENT_ARRAY_BUFFER)的操作也不需要了。
相比glDrawArray, 使用顶点索引数组+glDrawElements可减少存储和绘制重复顶点的资源消耗。
总结
- VBO可大大减少GPU/CPU之间频繁传递数据的开销。
- VBO可分为GL_ARRAY_BUFFER和GL_ELEMENT_ARRAY_BUFFER两种,分别用于存储顶点数据和索引数据。
- 索引数组与glDrawElements结合使用,可减少存储和绘制重复顶点的资源消耗。
- glVertexAttribPointer的最后一个参数的含义会因为是否使用VBO而不同。
- glDrawElements的最后一个参数的含义会因为是否使用VBO而不同。
Demo地址
本文的一系列demo都可在github中找到,DemoOpenGL,如有不准确的地方,欢迎指正。
参考资料
iOS — OpenGLES之简单的图形绘制
【OpenGL】理解GL_TRIANGLE_STRIP等绘制三角形序列的三种方式
OpenGL Tutorial for iOS: OpenGL ES 2.0
OpenGL ES 06 使用VBO:顶点缓存