数据 - 缓冲

第 5 章的内容繁多,本篇文章先记录“缓冲”的这节内容。在之前的学习内容中,我们了解过一个叫做顶点数组对象(VAO)的东西,但是没有向它传递过数据:一开始是硬编码的顶点数据,紧接着我们可以通过顶点属性逐帧改变顶点的位置。现在这篇文章我们就开始学习如何向 VAO 传递批量的顶点数据。

创建缓冲和分配内存

首先我们需要关注的是,这些大量的顶点数据如何存放在 GPU 上?和 CPU 上一样,其也有缓冲的概念,我们需要了解如何创建缓冲和分配内存。逐一介绍每个接口函数难免枯燥,我们先直接看到代码:

原书清单 5.1 创建并初始化缓冲
  • // The type used for names in OpenGL is GLuint
  • GLuint buffer;
  •  
  • // Create a buffer
  • glCreateBuffers(1, &buffer);
  •  
  • // Specify the data store parameters for the buffer
  • glNamedBufferStorage(
  •     buffer,  // Name of the buffer
  •     1024 * 1024,  // 1 MiB of space
  •     NULL// No initial data
  •     GL_MAP_WRITE_BIT);  // Allow map for writing
  •  
  • // Now bind it to the contex using the GL_ARRAY_BUFFER binding point
  • glBindBuffer(GL_ARRAY_BUFFER, buffer);

我们看到清单 5.1 中,buffer 管理单位的术语是缓冲名称,即代码中的 GLuint buffer。这个概念和 windows 中的句柄以及 linux 中的文件描述符非常类似。

可以使用 glCreateBuffers() 函数创建缓冲对象。这个函数好理解,第一个参数是想要创建的数量,第二个参数是存储创建好的各个缓冲名称的数组。

缓冲名称只是一个管理单位,我们还需要根据它分配其对应的内存。分配的内存在 OpenGL 中的术语是 数据库。代码中使用 glNamedBufferStorage() 函数分配:

  • void glNamedBufferStorage(GLuint buffer,
  •                           GLsizei size,
  •                           const void *data,
  •                           GLbitfield flags);

第一个参数是缓冲名称;第二个参数是需要分配的缓冲大小;第三个参数可以指定需要初始化的数据,不需要则置为 NULL;第四个参数指定一些权限,比如读、写、映射等权限,这部分和后续的数据更新有关,我们放在后面再提及。

可以看到清单中还使用到了 glBindBuffer() 函数。这边涉及到的一个术语为缓冲绑定点,可以把缓冲名称绑定到各个缓冲绑定点上。有这么一个概念的一个原因是,glNamedBufferStorage 从 OpenGL 4.5 才开始支持,之前分配内存需要使用 glBufferStorage() 函数:

  • void glBufferStorage(GLenum target,
  •                      GLsizeiptr size,
  •                      const void *data,
  •                      GLbitfield flags);

可以看到仅仅是第一个参数发生了变化,需要传递缓冲绑定点。所以我们需要先使用 glBindBuffer() 将现有缓冲名称绑定到想要的缓冲绑定点上,再使用 glBufferStorage() 分配内存。中间多了一层单一映射,多了一个绑定的步骤。

即以下步骤也同样可以分配内存:

  • glBindBuffer(GL_ARRAY_BUFFER, buffer);
  • glBufferStorage(GL_ARRAY_BUFFER, sizeof(data), data, 0);

更新缓冲内容

除了可以在分配内存时指定初始化数据之外,后续还可以有方法更新缓冲内容。

第一个方法是使用清单 5.2 中的 glBufferSubData() 函数。它的第一参数指定缓冲绑定点;第二个参数指定内存偏移;第三个参数指定写入数据大小;第四个参数指定写入的数据。

需要特别注意的是,使用 glBufferSubData() 更新的缓冲,其所对应的分配内存的标志必须含有 GL_DYNAMIC_STORAGE_BIT,代表缓冲内容可直接更新。

glNamedBufferStorage/glBufferStorage 类似,同样也有一个缓冲名称版本的函数:glNamedBufferSubData(),此处便不再赘述。

原书清单 5.2 用 glBufferSubData() 更新缓冲的内容
  • // This is the data that we will place into the buffer object
  • static const float data[] =
  • {
  •      0.25, -0.25, 0.5, 1.0,
  •      -0.25, -0.25, 0.5, 1.0,
  •      0.25,  0.25, 0.5, 1.0
  • };
  •  
  • // Put the data into the buffer at offset zero
  • glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(data), data);

glBufferSubData/glNamedBufferSubData 需要内存有 GL_DYNAMIC_STORAGE_BIT 标记。此点自己实验时排查了好久,再次提醒。

第二个方法是使用清单 5.3 中的 glMapNamedBuffer() 函数。使用 glMapNamedBuffer 会得到一个应用可访问的 CPU 地址,然后我们就可以直接往其中填充数据,最后需要使用 glUnmapNamedBuffer() 函数解除内存映射。

同样的,针对缓冲绑定点也有一组函数:glMapBufferglUnmapBuffer。以上这个操作过程非常好理解,可以看到和 linux 中的 map/unmap 如出一辙。

还有需要注意的一点是,使用映射更新缓冲内容时,分配内存时需要指定 GL_MAP_WRITE_BIT 标志。

原书清单 5.3 用 glMapNamedBuffer() 映射缓冲的数据库
  • // This is the data that we will place into the buffer object
  • static const float data[] =
  • {
  •      0.25, -0.25, 0.5, 1.0,
  •      -0.25, -0.25, 0.5, 1.0,
  •      0.25,  0.25, 0.5, 1.0
  • };
  •  
  • // Get a pointer to the buffer's data store
  • void* ptr = glMapNamedBuffer(buffer, GL_WRITE_ONLY);
  •  
  • // Copy our data into it...
  • memcpy(ptr, data, sizeof(data));
  •  
  • // Tell OpenGL that we're done with the pointer
  • glUnmapNamedBuffer(buffer);

使用缓冲为顶点着色器提供数据

在介绍完缓冲的概念后,我们继续回到顶点数组对象。这节的内容,自己初看非常迷糊,不知书中所云。网上找到图 1 所示内容,才大致了解其中机制。

看到图 1 最左边的一列,这个我们已经了解过了,是顶点着色器中的顶点位置属性。图中最右的一列,我们也已经了解过了,就是存储顶点数据的缓冲。只有中间一列我们第一次看到,它叫顶点缓存绑定。顶点缓存绑定的设计机制未知,看着可能是想更方便的映射顶点属性和缓冲之间的联系。

图 1 顶点数组对象

顶点属性和顶点缓存绑定点之间的连接使用 glVertexArrayAttribBinding() 函数:

  • void glVertexArrayAttribBinding(GLuint vaobj,
  •                                 GLuint attribindex,
  •                                 GLuint bindingindex);

第一个参数 vaobj 指定所在的顶点数组对象;第二个参数 attribindex 指定连接属性的索引;第三个属性 bindingindex 指定绑定点的索引。这样就建立了 attribindex 和 bindingindex 之间的联系,即图 1 中左侧两列之间的连线。

顶点缓存绑定点和顶点数据缓冲之间的连接使用 glVertexArrayVertexBuffer() 函数:

  • void glVertexArrayVertexBuffer(GLuint vaobj,
  •                                GLuint bindingindex,
  •                                GLuint buffer,
  •                                GLintptr offset,
  •                                GLsizei stride);

第一个参数 vaobj 指定所在的顶点数组对象;第二个属性 bindingindex 指定绑定点的索引;第三个参数 buffer 指定缓冲;第四个参数 offset 指定缓冲数据的偏移;第五个参数 stride 指定顶点缓冲数据中一组顶点数据的大小(因为可能是 vec3 或 vec4 等等),即数据的步长。这样就建立了 bindingindex 和 buffer 之间的联系,即图 1 中右侧两列之间的连线。

绑定了之间的联系之后,我们还需要进一步解释缓冲中顶点数据的布局情况,需要使用 glVertexArrayAttribFormat() 函数:

  • void glVertexArrayAttribFormat(GLuint vaobj,
  •                                GLuint attribindex,
  •                                GLint size,
  •                                GLenum type,
  •                                GLboolean normalized,
  •                                GLuint relativeoffset);

第一个参数 vaobj 指定所在的顶点数组对象;第二个参数 attribindex 指定顶点属性的索引;第三个参数 size 注意指定的是分量个数,比如 vec4 则对应的值 4;第四个参数 type 指定每个分量的属性;第五个参数 normalized 指定值是否要归一化;第六个参数 relativeoffset 指定顶点的偏移。

glEnableVertexArrayAttrib() 函数使能顶点数组对象向顶点着色器传输数据的功能。禁止此功能使用 glDisableVertexArrayAttrib() 函数。

清单 5.4 的内容紧承之前的内容,后续创建了顶点数组对象,然后将绑定点 0 和我们创建的顶点数据缓冲绑定,接着设置顶点数据格式,最后使能顶点数组对象向顶点着色器传输数据。这边没有指定属性和绑定点之间的连接,应该默认是连接第一项。

原书清单 5.4 设置顶点属性
  • GLuint vao;
  • glCreateVertexArrays(1, &vao);
  • glBindVertexArray(vao);
  •  
  • // First, bind a vertex buffer to the VAO
  • glVertexArrayVertexBuffer(vao,     // Vertex array object
  •                           0,       // First vertex buffer binding
  •                           buffer,  // Buffer object
  •                           0,       // Start from the beginning
  •                           sizeof(vmath::vec4));  // Each vertex is one vec4
  •  
  • // Now, describe the data to OpenGL, tell it where it is, and turn on automatic
  • // vertex fetching for the specified attribute
  • glVertexArrayAttribFormat(vao,       // Vertex array object
  •                           0,         // First attribute
  •                           4,         // Four components
  •                           GL_FLOAT,  // Floating-point data
  •                           GL_FALSE,  // Normalized - ignored for floats
  •                           0);  // First elements of the vertex
  •  
  • glEnableVertexArrayAttrib(vao, 0);

为此,我们的顶点着色器也大大简化:

原书清单 5.5 在顶点着色器中使用属性
  • #version 450 core
  •  
  • layout (location = 0) in vec4 position;
  •  
  • void main()
  • {
  •     gl_Position = position;
  • }

当然,画出的三角形是没有差别的😂 即使使用了不同的方法。

图 2 使用顶点数组对象画出的三角形

使用多个顶点着色器输入数据

通过这个例子的学习,我们可以进一步感受图 1 中的连接关系:此时需要有两个顶点属性、两个顶点数据缓冲。

如清单 5.6 所示,我们又再次“改造”了顶点着色器,不仅使其顶点数据从顶点数组对象获取,也使颜色数据从顶点数组对象获取。

原书清单 5.6 为一个顶点着色器声明两个输入
  • #version 450 core
  •  
  • layout (location = 0) in vec3 position;
  • layout (location = 1) in vec3 color;
  •  
  • out vec3 vs_color;
  •  
  • void main()
  • {
  •     gl_Position = vec4(position, 1.0);
  •     vs_color = color;
  • }

具体组织的连接方式如清单 5.7 所示。首先我们创建两个缓冲,一个存储顶点数据,一个存储颜色数据。glVertexArrayVertexBuffer() 函数连接绑定点和缓冲。glVertexArrayAttribBinding() 函数连接顶点属性和绑定点。

此外,glVertexArrayAttribFormat() 解释数据布局。glEnableVertexArrayAttrib() 开启顶点属性从顶点数组对象读取数据的功能。

原书清单 5.7 多个独立顶点属性
  • GLuint buffer[2];
  • GLuint vao;
  •  
  • static const GLfloat positions[] =
  • {
  •     0.25, -0.25, 0.5,
  •     -0.25, -0.25, 0.5,
  •     0.25,  0.25, 0.5,
  • };
  •  
  • static const GLfloat colors[] =
  • {
  •     1.0, 0.0, 0.0,
  •     0.0, 1.0, 0.0,
  •     0.0, 0.0, 1.0,
  • };
  •  
  • // Create the vertex array object
  • glCreateVertexArrays(1, &vao);
  • glBindVertexArray(vao);
  •  
  • // Get create two buffers
  • glCreateBuffers(2, &buffer[0]);
  •  
  • // Initialize the first buffer
  • glNamedBufferStorage(buffer[0], sizeof(positions), positions, 0);
  •  
  • // Bind it to the vertex array - offset zero, stride = sizeof(vec3)
  • glVertexArrayVertexBuffer(vao, 0, buffer[0], 0, sizeof(vmath::vec3));
  •  
  • // Tell OpenGL what the format of the attribute is
  • glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
  •  
  • // Tell OpenGL which vertex buffer binding to use for this attribute
  • glVertexArrayAttribBinding(vao, 0, 0);
  •  
  • // Enable the attribute
  • glEnableVertexArrayAttrib(vao, 0);
  •  
  • // Perform similar initialization for the second buffer
  • glNamedBufferStorage(buffer[1], sizeof(colors), colors, 0);
  • glVertexArrayVertexBuffer(vao, 1, buffer[1], 0, sizeof(vmath::vec3));
  • glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 0);
  • glVertexArrayAttribBinding(vao, 1, 1);
  • glEnableVertexArrayAttrib(vao, 1);

交错顶点属性

通过这个例子的学习,我们可以进一步感受是如何“告诉” OpenGL 顶点数据是如何布局的。如果了解过 YUV 的平面排布和交错排布,就会发现交错顶点属性和这个类似。这里,顶点数据和颜色数据是交错排布的,就和 NV12 中 U、V 分量交错排布一样。

定义的紧凑数据结构如下所示:

  • struct vertex
  • {
  •     // Position
  •     float x;
  •     float y;
  •     float z;
  •  
  •     // Color
  •     float r;
  •     float g;
  •     float b;
  • };

再看到清单 5.8,首先我们定义了交错结构的 vertices 数组。glVertexArrayAttribBinding() 将两个属性都连接同一个绑定点;所以就只有一个绑定点使用 glVertexArrayVertexBuffer() 函数连接缓冲。

最重要的,我们要看 OpenGL 是如何区分和定位交错数据的。首先看到 glVertexArrayVertexBuffer() 的 stride 参数,为 sizeof(vertex),我们可以想象内部每次给定一组数据都为一个 stride 大小。接着看到 glVertexArrayAttribFormat() 函数的 offset 参数,此处值为交错数据在整体结构上的偏移。同样我们可以想象,传入一组数据后,加上偏移,再加上大小,就定位到了我们想要的数据了。

原书清单 5.8 多个交错顶点属性
  • GLuint vao;
  • GLuint buffer;
  •  
  • static const vertex vertices[] =
  • {
  •     { 0.25, -0.25, 0.5, 1.0, 0.0, 0.0 },
  •     { -0.25, -0.25, 0.5, 0.0, 1.0, 0.0 },
  •     { 0.25,  0.25, 0.5, 0.0, 0.0, 1.0 },
  • };
  •  
  • // Create the vertex array object
  • glCreateVertexArrays(1, &vao);
  • glBindVertexArray(vao);
  •  
  • // Allocate and initialize a buffer object
  • glCreateBuffers(1, &buffer);
  • glNamedBufferStorage(buffer, sizeof(vertices), vertices, 0);
  •  
  • // Set up two vertex attributes - first position
  • glVertexArrayAttribBinding(vao, 0, 0);
  • glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, offsetof(vertex, x));
  • glEnableVertexArrayAttrib(vao, 0);
  •  
  • // Now colors
  • glVertexArrayAttribBinding(vao, 1, 0);
  • glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, offsetof(vertex, r));
  • glEnableVertexArrayAttrib(vao, 1);
  •  
  • // Finally, bind our one and only buffer to the vertex array object
  • glVertexArrayVertexBuffer(vao, 0, buffer, 0, sizeof(vertex));

同样,结果如图 3 所示,画的三角形也没有新意。

图 3 使用顶点数组对象画出的三角形

想画复杂一点的东西

上面实验做了一番,到头来还是之前出现过的两个三角形。难免有点“扫兴”。如开头所说,缓冲就是为了给顶点数组对象提供大量数据的,为此决定尝试画复杂一点的东西。

巧的是,之前一段时间学过一丢丢的 Maya,跟着教程做过几个模型。在此,我们可以将其导出为 obj 格式。obj 格式相对来说比较简单,目前我们只需要用到顶点信息。

v 开头的就是顶点的 x、y、z 坐标,像

  • v 2.450994 2.163355 -0.258819

f 开头的就是面的索引信息,像

  • f 206/373 275/372 274/374

其中包含了不止顶点的索引,还有其他量的索引。第一个数代表顶点索引,此处 206、275、274 下标的顶点构成了一个三角面。

在 Maya 中,“网格-结合”可使模型为单一的个体,obj 格式更容易解析。

在 Maya 中,“网格-三角化”可以使模型都转为三角面。

obj 格式的解析并不复杂,首先按 v 依次读取记录每个顶点坐标。需要注意的是,坐标值并未在 (-1,1) 之间,为此我们读取时需要顺便记录一下 x、y、z 方向上的最大值和最小值:

  • void PreProcessV(const char* s)
  • {
  •     float v[3];
  •     memset(v, 0x00, sizeof(v));
  •  
  •     sscanf(s, "v %f %f %f", &v[0], &v[1], &v[2]);
  •     gVertices.push_back({ v[0], v[1], v[2] });
  •  
  •     gMax.x = std::max(gMax.x, v[0]);
  •     gMax.y = std::max(gMax.y, v[1]);
  •     gMax.z = std::max(gMax.z, v[2]);
  •  
  •     gMin.x = std::min(gMin.x, v[0]);
  •     gMin.y = std::min(gMin.y, v[1]);
  •     gMin.z = std::min(gMin.z, v[2]);
  • }

之后选用 x、y、z 方向上最大的范围作为基准:

  • gRange = std::max(gMax.x - gMin.x, gMax.y - gMin.y);
  • gRange = std::max(gRange, gMax.z - gMin.z);

最后依据基准进行归一化,并把三角面的各个顶点写入文件供后续的 OpenGL 程序读取。

  • void ProcessF(const char* s, std::ofstream& out)
  • {
  •     uint32_t index[3], t;
  •  
  •     sscanf(s, "f %u/%u %u/%u %u/%u",
  •          &index[0], &t,
  •          &index[1], &t,
  •          &index[2], &t);
  •  
  •     TPos pos;
  •     for (int i = 0; i < 3; i++)
  •     {
  •          assert(index[i] <= gVertices.size());
  •          pos = gVertices[index[i] - 1];
  •          pos.x = (pos.x - gMin.x) / gRange * 2 - 1;
  •          pos.y = (pos.y - gMin.y) / gRange * 2 - 1;
  •          pos.z = (pos.z - gMin.z) / gRange * 2 - 1;
  •          out.write((const char*)&pos, sizeof(pos));
  •     }
  • }

OpenGL 的程序和前面所讲的内容大同小异,使用映射方式读取会更方面一点。最后画出的图形如下:

图 4 头戴式耳机

总结

这篇文章介绍了缓冲的概念,说明了如何创建。并且后续说明了如何在缓冲中存储顶点数据,并传给顶点数组对象。使用顶点数组对象是学到的新的渲染方式,可以一次性定义大量顶点,依据它我们绘制一个头戴式耳机。同时,我们也了解了 obj 格式,但是书中作者定义了一种自定义格式存储顶点等数据,等用到它时再做了解。