VAO 与 VBO 的前世今生

VAO 与 VBO 的前世今生

在现代OpenGL(3.0+)的体系里,VAO和VBO已经是个很基本的概念了,是学习GL必须要理解的一个点。昨天,组内的同学在学习Learn OpenGL的时候,就被这两个概念给拦住了。当然,具体遇到的问题倒不是理解障碍,实质是不清楚这几个概念的本质。

我想了一下,空讲概念确实太虚,尤其是OpenGL这种带有历史尘埃的玩意。GL是一个工业上的标准,历史悠久,那么在设计上肯定是推陈出新,每一个新推出的特性概念都是为了解决实际使用中的问题,VAO,VBO也不例外。


数据传输与优化

拦住了。当然,具体遇到的问题倒不是理解障碍,实质是不清楚这几个概念的本质。

我想了一下,空讲概念确实太虚,尤其是OpenGL这种带有历史尘埃的玩意。GL是一个工业上的标准,历史悠久,那么在设计上肯定是推陈出新,每一个新推出的特性概念都是为了解决实际使用中的问题,VAO,VBO也不例外。

数据传输与优化
OpenGL作为图形API,制定的是绘图标准,采用的是CS模式。它将自己看作Server端,接收Client端传过来的数据,然后开启流水线,按需绘制出最终结果。所以,我们遇到的第一个阶段就是数据传输。

现在假设我们在client端(简单理解成CPU端)内存里定义了三个顶点数据,如何传输至GPU呢?如何高效大量地传输呢?如何高效大量灵活地传输呢?下述几种技术的出现本质就是为了解决这个问题。

GLfloat vertices[] = {
        0.0f, 0.0f,
        1.0f, 0.0f, 
        0.0f, 1.0f
    }

glVertex*

最简单的传输就是一个个传过去,在glBegin、glEnd(已废弃)之间通过 glVertex*逐个传输,每一次调用都会和GPU通讯一次。这种方式概念清晰,做法简洁粗暴,而缺点也明显,每一次绘制,所有顶点数据依次传输,效率瓶颈明显。

 // 每一次绘制都需要传输三次
    glBegin(GL_TRIANGLES);
        glVertex(0.0f, 0.0f);
        glVertex(1.0f, 0.0f);
        glVertex(0.0f, 1.0f);
    glEnd();

Display List

使用glVertex的方式传输数据,数据量膨胀,那么传输效率会迅速降低。早期图形需求简单,每一次绘制传输的数据,多数情况下是完全相同的。那能不能让每一个数据只传一次呢?

Display List(显示列表)应运而生。

在glNewList、glEndList(已废弃)之间,将顶点传输过程包裹了起来,意味着它收集好顶点,统一传输给GPU,并保存在GPU上,这样在重复绘制的时候可以直接从GPU端取数据,不再重新传输,对传输效率的提升是极大的。

显示列表的局限性也很明显:没法在绘制时修改顶点数据,如果要修改顶点数据,只有在CPU端修改再重新传输一份。极端情况下,如果场景顶点数据每帧需要变化,显示列表就完全退化成了 glVertex 模式。

 // 只在初始化的时候传输三次
    GLuint listName = glGenLists (1);
    glNewList (listName, GL_COMPILE);
        glBegin (GL_TRIANGLES);
            glVertex2f (0.0, 0.0);
            glVertex2f (1.0, 0.0);
            glVertex2f (0.0, 1.0);
        glEnd ();
    glEndList ();

    ...

    // 绘制(不传输数据)
    glCallList(listName);  

Vertex Array

针对灵活多变的顶点变化需求,VA(顶点数组)加入到了规范里。它每一次绘制,将收集的顶点通过一次API调用传输给GPU,俗称打包数据传输。

VA与上述显示列表区别在于,它收集的顶点保存在CPU端,每次绘制都需要重新传一次数据,所以绘制速度上面慢于显示列表。注意:顶点数组是GL内置的,开发者只能选择启用与否。

// 每次绘制都将 vertices 传输一次
    GLfloat vertices[] = {
        0.0f, 0.0f,
        1.0f, 0.0f, 
        0.0f, 1.0f
    }
    glEnableClientState(GL_VERTEX_ARRAY);
    glVertexPointer(2,GL_FLOAT,0,vertices);
    glDrawArray(GL_TRIANGLES, 0, 3);   

VBO (Vertex Buffer Object)

VBO出现之前,做OpenGL优化,提高顶点绘制效率的办法一般就两种:

  • 显示列表:把常规的绘制代码放置一个显示列表中(通常在初始化阶段完成,顶点数据还是需要一个个传输的),渲染时直接使用这个显示列表。优化点:减少数据传输次数
  • 顶点数组:把顶点以及顶点属性数据打包成单个数组,渲染时直接传输该数组。优化点:减少了函数调用次数(弃用glVertex)

VBO的目标就是鱼与熊掌兼得,想将显示列表的特性(绘制时不传输数据,快)和顶点数组的特性(数据打包传输,修改灵活)结合起来。

当然最终效果差强人意,效率介于两者之间,拥有良好的数据修改弹性。在渲染阶段,我们可以把该帧到达流水线的顶点数据映射回client端修改(vertex mapping),然后再提交回流水线(vertex unmapping),意味着顶点数据只在VBO里有一份;或者可以用 glBufferData(全部数据)\glBufferSubData(部分数据) 提交更改了的顶点数据,意味着顶点数据在client端和VBO里都有一份。

VBO本质上是一块服务端buffer(缓存),对应着client端的某份数据,在数据传输给VBO之后,client端的数据是可以删除的。系统会根据用户设置的 target 和 usage 来决定VBO最适合的存放位置(系统内存/AGP/显存)。当然,GL规范是一回事,显卡厂商的驱动实现又是另一回事了。

在初始化阶段,VBO是不知道它所存储的是什么数据,而是在渲染阶段(精确说是 glVertexAttribPointer 函数)才确定数据作用类型(顶点位置、float类型、从偏移量0处开始采集数据、2个float算一个采集步长等等)。到真正绘制(glDrawArray/glDrawElement)的时候才从VBO里读取需要的数据进入渲染流水线。

 // 初始化
    GLfloat vertices[] = {
        0.0f, 0.0f,
        1.0f, 0.0f, 
        0.0f, 1.0f
    }

    GLuint vbo;
    glGenBuffer(1, &vbo);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STREAM_DRAW);

    ...

    // 绘制
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2, (void*)0);
    glDrawArray(GL_TRIANGLES, 0, 3);

    ...

VAO (Vertex Array Object)

重看一遍上面的渲染阶段代码,如果我有两份不同的绘制代码,那就需要频繁的重复 glBindBuffer()-glEnableVertexAttribArray()-glVertexAttribPointer-glDrawArray()一套流程,那么本着偷懒的原则,优化方案来了——把这些绘制需要的信息状态在初始化的时候就完整记录下来,真正绘制时只需简单切换一下状态记录。

这就是 VAO 诞生的理由。

VAO 全称 Vertex Array Object,翻译过来叫顶点数组对象,但和Vertex Array(顶点数组)毫无联系!

VAO不是 buffer-object,所以不作数据存储;与顶点的绘制息息相关,即是说与VBO强相关。如上,VAO本质上是state-object(状态对象),记录的是一次绘制所需要的信息,包括数据在哪,数据格式之类的信息。如果抽象成数据结构,VAO 的数据结构如下:

 struct VertexAttribute  
    {  
        bool bIsEnabled = GL_FALSE;  
        int iSize = 4; //This is the number of elements in this attribute, 1-4.  
        unsigned int iStride = 0;  
        VertexAttribType eType = GL_FLOAT;  
        bool bIsNormalized = GL_FALSE;  
        bool bIsIntegral = GL_FALSE;  
        void * pBufferObjectOffset = 0;  
        BufferObject * pBufferObj = 0;  
    };  

    struct VertexArrayObject  
    {  
        BufferObject *pElementArrayBufferObject = NULL;  
        VertexAttribute attributes[GL_MAX_VERTEX_ATTRIB];  
    }  

从这个数据结构可以看出,VAO里面存了一个EBO的指针以及一个顶点属性数组,意味着上述一串操作的状态可以完全存储于VAO里面,而真正的数据依然在VBO里面。下面举一个示例代码:

  // 初始化
    unsigned int VAO;
    glGenVertexArrays(1, &VAO);  
    glBindVertexArray(VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0); 

    ...

    // 绘制
    glBindVertexArray(VAO);
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
    glBindVertexArray(0);

对比不使用VAO的代码可以发现,我们把原先放在绘制阶段的 glEnableVertexAttribArray()-glVertexAttribPointer()移动到了初始化里面,而在真正绘制的时候,只是简单的绑定了一个VAO(glBindVertexArray(VAO))就开始绘制了。这样的话,如果要绘制另一个内容,只需绑定另一个VAO就可以了。

所以,你应该看出来,VAO是用来简化绘制代码的。

后记

通过追本溯源,我们可以发现,现代GL里常用的VAO/VBO实质是为了解决传输效率而做的优化手段。VBO是为了均衡数据的传输效率与灵活修改性;VAO的本质是储存绘制状态,简化绘制代码。

回到最初,组内的同学在看到下方Learn OpenGL的示例代码时,提出了一个问题:

 // set up vertex data (and buffer(s)) and configure vertex attributes
    // ------------------------------------------------------------------
    float vertices[] = {
         0.5f,  0.5f, 0.0f,  // top right
         0.5f, -0.5f, 0.0f,  // bottom right
        -0.5f, -0.5f, 0.0f,  // bottom left
        -0.5f,  0.5f, 0.0f   // top left 
    };
    unsigned int indices[] = {  // note that we start from 0!
        0, 1, 3,  // first Triangle
        1, 2, 3   // second Triangle
    };
    unsigned int VBO, VAO, EBO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    glGenBuffers(1, &EBO);
    // bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
    glBindVertexArray(VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    // note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
    glBindBuffer(GL_ARRAY_BUFFER, 0); 

    // remember: do NOT unbind the EBO while a VAO is active as the bound element buffer object IS stored in the VAO; keep the EBO bound.
    //glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

    // You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
    // VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
    glBindVertexArray(0); 

为什么在VAO里面可以解绑VBO,却不能解绑EBO呢?

 // note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
    glBindBuffer(GL_ARRAY_BUFFER, 0); 

    // remember: do NOT unbind the EBO while a VAO is active as the bound element buffer object IS stored in the VAO; keep the EBO bound.
    //glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

作者用注释解释了原因,懂则懂之,不懂请结合前述VAO的数据结构,相信你能豁然开朗。

参考

AB是一家?VAO与VBO
学一学,VBO
Vertex Specification
Hello Triangle


文章转自:VAO 与 VBO 的前世今生

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值