数据 - 缓冲
第 5 章的内容繁多,本篇文章先记录“缓冲”的这节内容。在之前的学习内容中,我们了解过一个叫做顶点数组对象(VAO)的东西,但是没有向它传递过数据:一开始是硬编码的顶点数据,紧接着我们可以通过顶点属性逐帧改变顶点的位置。现在这篇文章我们就开始学习如何向 VAO 传递批量的顶点数据。
创建缓冲和分配内存
首先我们需要关注的是,这些大量的顶点数据如何存放在 GPU 上?和 CPU 上一样,其也有缓冲的概念,我们需要了解如何创建缓冲和分配内存。逐一介绍每个接口函数难免枯燥,我们先直接看到代码:
我们看到清单 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(),此处便不再赘述。
glBufferSubData/glNamedBufferSubData 需要内存有 GL_DYNAMIC_STORAGE_BIT 标记。此点自己实验时排查了好久,再次提醒。
第二个方法是使用清单 5.3 中的 glMapNamedBuffer() 函数。使用 glMapNamedBuffer 会得到一个应用可访问的 CPU 地址,然后我们就可以直接往其中填充数据,最后需要使用 glUnmapNamedBuffer() 函数解除内存映射。
同样的,针对缓冲绑定点也有一组函数:glMapBuffer 和 glUnmapBuffer。以上这个操作过程非常好理解,可以看到和 linux 中的 map/unmap 如出一辙。
还有需要注意的一点是,使用映射更新缓冲内容时,分配内存时需要指定 GL_MAP_WRITE_BIT 标志。
使用缓冲为顶点着色器提供数据
在介绍完缓冲的概念后,我们继续回到顶点数组对象。这节的内容,自己初看非常迷糊,不知书中所云。网上找到图 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 和我们创建的顶点数据缓冲绑定,接着设置顶点数据格式,最后使能顶点数组对象向顶点着色器传输数据。这边没有指定属性和绑定点之间的连接,应该默认是连接第一项。
为此,我们的顶点着色器也大大简化:
当然,画出的三角形是没有差别的😂 即使使用了不同的方法。
使用多个顶点着色器输入数据
通过这个例子的学习,我们可以进一步感受图 1 中的连接关系:此时需要有两个顶点属性、两个顶点数据缓冲。
如清单 5.6 所示,我们又再次“改造”了顶点着色器,不仅使其顶点数据从顶点数组对象获取,也使颜色数据从顶点数组对象获取。
具体组织的连接方式如清单 5.7 所示。首先我们创建两个缓冲,一个存储顶点数据,一个存储颜色数据。glVertexArrayVertexBuffer() 函数连接绑定点和缓冲。glVertexArrayAttribBinding() 函数连接顶点属性和绑定点。
此外,glVertexArrayAttribFormat() 解释数据布局。glEnableVertexArrayAttrib() 开启顶点属性从顶点数组对象读取数据的功能。
交错顶点属性
通过这个例子的学习,我们可以进一步感受是如何“告诉” 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 参数,此处值为交错数据在整体结构上的偏移。同样我们可以想象,传入一组数据后,加上偏移,再加上大小,就定位到了我们想要的数据了。
同样,结果如图 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 的程序和前面所讲的内容大同小异,使用映射方式读取会更方面一点。最后画出的图形如下:
总结
这篇文章介绍了缓冲的概念,说明了如何创建。并且后续说明了如何在缓冲中存储顶点数据,并传给顶点数组对象。使用顶点数组对象是学到的新的渲染方式,可以一次性定义大量顶点,依据它我们绘制一个头戴式耳机。同时,我们也了解了 obj 格式,但是书中作者定义了一种自定义格式存储顶点等数据,等用到它时再做了解。