处理顶点数据的最佳实践
要使用 OpenGL ES 渲染帧,您的应用程序需要配置图形管道并提交要绘制的图形基元。在某些应用程序中,所有基元都使用相同的管道配置绘制;其他应用程序可能会使用不同的技术渲染框架的不同元素。但无论您在应用程序中使用哪些基元或如何配置管道,您的应用程序都会向 OpenGL ES 提供顶点。本章提供了顶点数据的复习,并提供了有关如何有效处理顶点数据的有针对性的建议。
一个顶点由一个或多个属性组成,例如位置、颜色、法线或纹理坐标。 OpenGL ES 2.0 或 3.0 应用程序可以自由定义自己的属性;顶点数据中的每个属性都对应一个属性变量,作为顶点着色器的输入。 OpenGL 1.1 应用程序使用由固定功能管道定义的属性。
您将属性定义为由一到四个分量组成的向量。属性中的所有组件共享一个公共数据类型。例如,一种颜色可能被定义为四个 GLubyte 分量(红色、绿色、蓝色、alpha)。当一个属性被加载到一个着色器变量中时,任何未在应用程序数据中提供的组件都会被 OpenGL ES 填充为默认值。最后一个组件填充为 1,其他未指定的组件填充为 0,如图 8-1 所示。
您的应用程序可以将属性配置为常量,这意味着相同的值用于作为绘制命令的一部分提交的所有顶点,或数组,这意味着每个顶点都有一个该属性的值。当您的应用程序调用 OpenGL ES 中的函数来绘制一组顶点时,顶点数据会从您的应用程序复制到图形硬件。图形硬件然后作用于顶点数据,处理着色器中的每个顶点,组装图元并将它们光栅化到帧缓冲区中。 OpenGL ES 的一个优点是它标准化了一组函数以将顶点数据提交给 OpenGL ES,从而消除了 OpenGL 提供的旧的和效率较低的机制。
必须提交大量图元以渲染帧的应用程序需要仔细管理其顶点数据以及如何将其提供给 OpenGL ES。本章描述的实践可以概括为几个基本原则:
- 减少顶点数据的大小。
- 减少在 OpenGL ES 将顶点数据传输到图形硬件之前必须进行的预处理。
- 减少将顶点数据复制到图形硬件所花费的时间。
- 减少为每个顶点执行的计算。
简化您的模型
基于 iOS 的设备的图形硬件非常强大,但它显示的图像通常很小。您不需要极其复杂的模型来在 iOS 上呈现引人注目的图形。减少用于绘制模型的顶点数量会直接减少顶点数据的大小以及对顶点数据执行的计算。
您可以使用以下一些技术来降低模型的复杂性:
- 以不同的细节级别提供模型的多个版本,并在运行时根据对象与相机的距离和显示器的尺寸选择合适的模型。
- 使用纹理消除对某些顶点信息的需要。例如,凹凸贴图可用于向模型添加细节,而无需添加更多顶点数据。
- 某些模型添加顶点以改善照明细节或渲染质量。这通常在为每个顶点计算值并在光栅化阶段对三角形进行插值时完成。例如,如果您将聚光灯指向三角形的中心,它的效果可能会被忽视,因为聚光灯最亮的部分并不指向某个顶点。通过添加顶点,您可以提供额外的插值点,代价是增加顶点数据的大小和对模型执行的计算。与其添加额外的顶点,不如考虑将计算移动到管道的片段阶段:
- 如果您的应用程序使用 OpenGL ES 2.0 或更高版本,则您的应用程序将在顶点着色器中执行计算并将其分配给可变变量。变化的值由图形硬件内插并作为输入传递给片段着色器。相反,将计算的输入分配给不同的变量并在片段着色器中执行计算。这样做将执行该计算的成本从每个顶点的成本更改为每个片段的成本,减少了顶点阶段的压力和管道片段阶段的压力。当您的应用程序在顶点处理时被阻止时执行此操作,计算成本低且顶点数可以通过更改显着减少。
- 如果您的应用程序使用 OpenGL ES 1.1,您可以使用 DOT3 照明执行每个片段的照明。您可以通过添加凹凸贴图纹理来保存法线信息并使用纹理组合操作和 GL_DOT3_RGB 模式应用凹凸贴图来实现此目的。
避免在属性数组中存储常量
如果您的模型包含使用在整个模型中保持不变的数据的属性,请不要为每个顶点复制该数据。 OpenGL ES 2.0 和 3.0 应用程序可以设置常量顶点属性或使用统一着色器值来保存该值。 OpenGL ES 1.1 应用程序应该使用每个顶点的属性函数,例如 glColor4ub 或 glTexCoord2f。
对属性使用最小的可接受类型
在指定每个属性组件的大小时,请选择提供可接受结果的最小数据类型。以下是一些指导方针:
- 使用四个无符号字节组件 (GL_UNSIGNED_BYTE) 指定顶点颜色。
- 使用 2 或 4 个无符号字节 (GL_UNSIGNED_BYTE) 或无符号短整型 (GL_UNSIGNED_SHORT) 指定纹理坐标。不要将多组纹理坐标打包到一个属性中。
- 避免使用 OpenGL ES GL_FIXED 数据类型。它需要与 GL_FLOAT 相同的内存量,但提供的值范围更小。所有 iOS 设备都支持硬件浮点单元,因此可以更快地处理浮点值。
- OpenGL ES 3.0 上下文支持更广泛的小数据类型,例如 GL_HALF_FLOAT 和 GL_INT_2_10_10_10_REV。这些通常为法线等属性提供足够的精度,占用空间比 GL_FLOAT 小。
如果指定较小的组件,请确保对顶点格式重新排序以避免使顶点数据错位。请参见避免未对齐的顶点数据。
使用交错顶点数据
您可以将顶点数据指定为一系列数组(也称为数组结构)或每个元素包含多个属性的数组(结构数组)。 iOS 上的首选格式是具有单个交错顶点格式的结构数组。交错数据为每个顶点提供更好的内存局部性。
此规则的一个例外是当您的应用程序需要以不同于其余顶点数据的速率更新某些顶点数据时,或者某些数据可以在两个或多个模型之间共享时。在任一情况下,您可能希望将属性数据分成两个或多个结构。
避免未对齐的顶点数据
在设计顶点结构时,将每个属性的开头与偏移量对齐,该偏移量是其组件大小的倍数或 4 字节,以较大者为准。当属性未对齐时,iOS 必须在将数据传递给图形硬件之前执行额外的处理。
在图 8-4 中,位置数据和法线数据分别定义为三个短整数,总共六个字节。正常数据从偏移量 6 开始,它是本机大小(2 个字节)的倍数,但不是 4 个字节的倍数。如果将这些顶点数据提交给 iOS,iOS 将不得不花费额外的时间来复制和对齐数据,然后才能将其传递给硬件。要解决此问题,请在每个属性后显式添加两个字节的填充。
使用三角带批量处理顶点数据
使用三角形条显着减少了 OpenGL ES 必须在您的模型上执行的顶点计算数量。在图 8-5 的左侧,使用总共九个顶点指定了三个三角形。 C、E 和 G 实际上指定了同一个顶点!通过将数据指定为三角形带,您可以将顶点数从九个减少到五个。
有时,您的应用程序可以将多个三角形带组合成一个更大的三角形带。所有条带必须共享相同的渲染要求。这意味着:
- 您必须使用相同的着色器来绘制所有三角形条带。
- 您必须能够在不更改任何 OpenGL 状态的情况下渲染所有三角形条带。
- 三角形带必须共享相同的顶点属性。
要合并两个三角形条带,请复制第一个条带的最后一个顶点和第二个条带的第一个顶点,如图 8-6 所示。将此条带提交给 OpenGL ES 时,三角形 DEE、EEF、EFF 和 FFG 被视为退化且未处理或光栅化。
为获得最佳性能,您的模型应作为单个索引三角形条提交。为避免在顶点缓冲区中多次指定同一顶点的数据,请使用单独的索引缓冲区并使用 glDrawElements 函数(或 glDrawElementsInstanced 或 glDrawRangeElements 函数,如果合适)绘制三角形条。
在 OpenGL ES 3.0 中,您可以使用图元重新启动功能来合并三角形条带,而无需使用退化三角形。启用此功能后,OpenGL ES 将索引缓冲区中的最大可能值视为完成一个三角形条带并开始另一个三角形条带的命令。清单 8-1 演示了这种方法
// Prepare index buffer data (not shown: vertex buffer data, loading vertex and index buffers)
GLushort indexData[11] = {
0, 1, 2, 3, 4, // triangle strip ABCDE
0xFFFF, // primitive restart index (largest possible GLushort value)
5, 6, 7, 8, 9, // triangle strip FGHIJ
};
// Draw triangle strips
glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
glDrawElements(GL_TRIANGLE_STRIP, 11, GL_UNSIGNED_SHORT, 0);
在可能的情况下,对顶点和索引数据进行排序,以便共享公共顶点的三角形在三角形带中彼此合理靠近。图形硬件通常会缓存最近的顶点计算以避免重新计算顶点。
使用顶点缓冲区对象来管理复制顶点数据
清单 8-2 提供了一个简单的应用程序可以用来向顶点着色器提供位置和颜色数据的函数。它启用两个属性并将每个属性配置为指向交错的顶点结构。最后,它调用 glDrawElements 函数将模型渲染为单个三角形条。
typedef struct _vertexStruct
{
GLfloat position[2];
GLubyte color[4];
} vertexStruct;
void DrawModel()
{
const vertexStruct vertices[] = {...};
const GLubyte indices[] = {...};
glVertexAttribPointer(GLKVertexAttribPosition, 2, GL_FLOAT, GL_FALSE,
sizeof(vertexStruct), &vertices[0].position);
glEnableVertexAttribArray(GLKVertexAttribPosition);
glVertexAttribPointer(GLKVertexAttribColor, 4, GL_UNSIGNED_BYTE, GL_TRUE,
sizeof(vertexStruct), &vertices[0].color);
glEnableVertexAttribArray(GLKVertexAttribColor);
glDrawElements(GL_TRIANGLE_STRIP, sizeof(indices)/sizeof(GLubyte), GL_UNSIGNED_BYTE, indices);
}
此代码有效,但效率低下。每次调用 DrawModel 时,索引和顶点数据都会复制到 OpenGL ES,并传输到图形硬件。如果顶点数据在调用之间没有改变,这些不必要的副本会影响性能。为避免不必要的副本,您的应用程序应将其顶点数据存储在顶点缓冲区对象 (VBO) 中。由于 OpenGL ES 拥有顶点缓冲区对象的内存,因此它可以将缓冲区存储在图形硬件更易于访问的内存中,或者将数据预处理为图形硬件的首选格式。
- 注意:在 OpenGL ES 3.0 中使用顶点数组对象时,还必须使用顶点缓冲区对象。
示例 8-3 创建了一对顶点缓冲区对象,一个保存顶点数据,第二个保存条带的索引。在每种情况下,代码都会生成一个新对象,将其绑定为当前缓冲区,然后填充缓冲区。 CreateVertexBuffers 将在应用程序初始化时调用。
GLuint vertexBuffer;
GLuint indexBuffer;
void CreateVertexBuffers()
{
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glGenBuffers(1, &indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
}
清单 8-4 修改了清单 8-2 以使用顶点缓冲区对象。示例 8-4 的主要区别在于 glVertexAttribPointer 函数的参数不再指向顶点数组。相反,每个都是顶点缓冲区对象的偏移量。
缓冲区使用提示
前面的例子初始化了顶点缓冲区一次,之后就再也没有改变它的内容。您可以更改顶点缓冲区的内容。顶点缓冲区对象设计的一个关键部分是应用程序可以通知 OpenGL ES 它如何使用存储在缓冲区中的数据。 OpenGL ES 实现可以使用此提示来更改它用于存储顶点数据的策略。在清单 8-3 中,每次调用 glBufferData 函数都提供了一个使用提示作为最后一个参数。将 GL_STATIC_DRAW 传入 glBufferData 告诉 OpenGL ES 两个缓冲区的内容永远不会改变,这让 OpenGL ES 有更多机会优化数据的存储方式和位置。
OpenGL ES 规范定义了以下用例:
- GL_STATIC_DRAW 用于多次渲染的顶点缓冲区,其内容被指定一次并且永远不会改变。
- GL_DYNAMIC_DRAW 用于多次渲染的顶点缓冲区,其内容在渲染循环期间发生变化。
- GL_STREAM_DRAW 用于渲染少量然后丢弃的顶点缓冲区。
在 iOS 中,GL_DYNAMIC_DRAW 和 GL_STREAM_DRAW 是等价的。您可以使用 glBufferSubData 函数来更新缓冲区内容,但这样做会导致性能下降,因为它会刷新命令缓冲区并等待所有命令完成。双缓冲或三缓冲可以在一定程度上降低这种性能成本。 (请参阅使用双缓冲以避免资源冲突。)为了获得更好的性能,请使用 OpenGL ES 3.0 中的 glMapBufferRange 函数或 OpenGL ES 2.0 或 1.1 中的 EXT_map_buffer_range 扩展提供的相应函数。
如果顶点格式中的不同属性需要不同的使用模式,请将顶点数据拆分为多个结构,并为每个共享共同使用特征的属性集合分配一个单独的顶点缓冲区对象。清单 8-5 修改了前面的示例以使用单独的缓冲区来保存颜色数据。通过使用 GL_DYNAMIC_DRAW 提示分配颜色缓冲区,OpenGL ES 可以分配该缓冲区,以便您的应用程序保持合理的性能。
typedef struct _vertexStatic
{
GLfloat position[2];
} vertexStatic;
typedef struct _vertexDynamic
{
GLubyte color[4];
} vertexDynamic;
// Separate buffers for static and dynamic data.
GLuint staticBuffer;
GLuint dynamicBuffer;
GLuint indexBuffer;
const vertexStatic staticVertexData[] = {...};
vertexDynamic dynamicVertexData[] = {...};
const GLubyte indices[] = {...};
void CreateBuffers()
{
// Static position data
glGenBuffers(1, &staticBuffer);
glBindBuffer(GL_ARRAY_BUFFER, staticBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(staticVertexData), staticVertexData, GL_STATIC_DRAW);
// Dynamic color data
// While not shown here, the expectation is that the data in this buffer changes between frames.
glGenBuffers(1, &dynamicBuffer);
glBindBuffer(GL_ARRAY_BUFFER, dynamicBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(dynamicVertexData), dynamicVertexData, GL_DYNAMIC_DRAW);
// Static index data
glGenBuffers(1, &indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
}
void DrawModelUsingMultipleVertexBuffers()
{
glBindBuffer(GL_ARRAY_BUFFER, staticBuffer);
glVertexAttribPointer(GLKVertexAttribPosition, 2, GL_FLOAT, GL_FALSE,
sizeof(vertexStruct), (void *)offsetof(vertexStruct, position));
glEnableVertexAttribArray(GLKVertexAttribPosition);
glBindBuffer(GL_ARRAY_BUFFER, dynamicBuffer);
glVertexAttribPointer(GLKVertexAttribColor, 4, GL_UNSIGNED_BYTE, GL_TRUE,
sizeof(vertexStruct), (void *)offsetof(vertexStruct, color));
glEnableVertexAttribArray(GLKVertexAttribColor);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glDrawElements(GL_TRIANGLE_STRIP, sizeof(indices)/sizeof(GLubyte), GL_UNSIGNED_BYTE, (void*)0);
}
使用顶点数组对象合并顶点数组状态更改
仔细看看清单 8-5 中的 DrawModelUsingMultipleVertexBuffers 函数。它启用许多属性,绑定多个顶点缓冲区对象,并将属性配置为指向缓冲区。所有这些初始化代码本质上都是静态的;没有任何参数在帧与帧之间发生变化。如果每次应用程序渲染帧时都调用此函数,则重新配置图形管道会产生很多不必要的开销。如果应用程序绘制了许多不同类型的模型,重新配置管道可能会成为瓶颈。相反,使用顶点数组对象来存储完整的属性配置。顶点数组对象是核心 OpenGL ES 3.0 规范的一部分,可通过 OES_vertex_array_object 扩展在 OpenGL ES 2.0 和 1.1 中使用。
图 8-7 显示了具有两个顶点数组对象的示例配置。每个配置相互独立;每个顶点数组对象都可以引用一组不同的顶点属性,这些属性可以存储在同一个顶点缓冲区对象中,也可以拆分为多个顶点缓冲区对象。
清单 8-6 提供了用于配置上面显示的第一个顶点数组对象的代码。它为新的顶点数组对象生成一个标识符,然后将顶点数组对象绑定到上下文。在此之后,它会像代码不使用顶点数组对象一样调用相同的调用来配置顶点属性。配置存储到绑定的顶点数组对象而不是上下文
void ConfigureVertexArrayObject()
{
// Create and bind the vertex array object.
glGenVertexArrays(1,&vao1);
glBindVertexArray(vao1);
// Configure the attributes in the VAO.
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE,
sizeof(staticFmt), (void*)offsetof(staticFmt,position));
glEnableVertexAttribArray(GLKVertexAttribPosition);
glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_UNSIGNED_SHORT, GL_TRUE,
sizeof(staticFmt), (void*)offsetof(staticFmt,texcoord));
glEnableVertexAttribArray(GLKVertexAttribTexCoord0);
glVertexAttribPointer(GLKVertexAttribNormal, 3, GL_FLOAT, GL_FALSE,
sizeof(staticFmt), (void*)offsetof(staticFmt,normal));
glEnableVertexAttribArray(GLKVertexAttribNormal);
glBindBuffer(GL_ARRAY_BUFFER, vbo2);
glVertexAttribPointer(GLKVertexAttribColor, 4, GL_UNSIGNED_BYTE, GL_TRUE,
sizeof(dynamicFmt), (void*)offsetof(dynamicFmt,color));
glEnableVertexAttribArray(GLKVertexAttribColor);
// Bind back to the default state.
glBindBuffer(GL_ARRAY_BUFFER,0);
glBindVertexArray(0);
}
为了绘制,代码绑定顶点数组对象,然后像以前一样提交绘制命令。
- 注意:在 OpenGL ES 3.0 中,不允许客户端存储顶点数组数据——顶点数组对象必须使用顶点缓冲区对象。
为了获得最佳性能,您的应用程序应配置每个顶点数组对象一次,并且永远不要在运行时更改它。如果您需要在每一帧中更改一个顶点数组对象,请改为创建多个顶点数组对象。例如,使用双缓冲的应用程序可能会为奇数帧配置一组顶点数组对象,为偶数帧配置第二组。每组顶点数组对象将指向用于渲染该帧的顶点缓冲区对象。当顶点数组对象的配置不变时,OpenGL ES 可以缓存有关顶点格式的信息并改进它处理这些顶点属性的方式。
将缓冲区映射到客户端内存以实现快速更新
OpenGL ES 应用程序设计中更具挑战性的问题之一是使用动态资源,尤其是当您的顶点数据需要更改每一帧时。有效地平衡 CPU 和 GPU 之间的并行性需要仔细管理应用程序内存空间和 OpenGL ES 内存之间的数据传输。传统技术(例如使用 glBufferSubData 函数)会降低性能,因为它们会强制 GPU 在数据传输时等待,即使它可以从同一缓冲区中其他地方的数据进行渲染。
例如,您可能希望在每次通过高帧率渲染循环时修改顶点缓冲区并绘制其内容。来自渲染的最后一帧的绘制命令可能仍在使用 GPU,而 CPU 正在尝试访问缓冲存储器以准备绘制下一帧 — 导致缓冲区更新调用阻止进一步的 CPU 工作,直到 GPU 完成。在这种情况下,您可以通过手动同步 CPU 和 GPU 对缓冲区的访问来提高性能。
glMapBufferRange 函数提供了一种更有效的方法来动态更新顶点缓冲区。 (此函数可用作 OpenGL ES 3.0 中的核心 API,并通过 OpenGL ES 1.1 和 2.0 中的 EXT_map_buffer_range 扩展提供。)使用此函数检索指向 OpenGL ES 内存区域的指针,然后您可以使用该指针写入新数据。 glMapBufferRange 函数允许将缓冲区数据存储的任何子范围映射到客户端内存中。当您将该函数与 OpenGL 同步对象一起使用时,它还支持允许异步缓冲区修改的提示,如清单 8-7 所示。
Listing 8-7 使用手动同步动态更新顶点缓冲区
GLsync fence;
GLboolean updateAndDraw(GLuint vbo, GLuint offset, GLuint length, void *data)�{
GLuint vbo;
GLboolean success;
//bind and map buffer
glBindBuffer(GL_ARRAY_BUFFER, vbo);
void *old_data = glMapBufferRange(GL_ARRAY_BUFFER, offset, length,
GL_MAP_WRITE_BIT | GL_MAP_FLUSH_EXPLICIT_BIT |
GL_MAP_UNSYNCHRONIZED_BIT);
//wait for fence (set below) before modifying buffer
glClientWaitSync(fence, GL_SYNC_FLUSH_COMMANDS_BIT, GL_TIMEOUT_IGNORED);
//modify buffer, flush, umap
memcpy(old_data, data, length);
glFlushMappedBufferRange(GL_ARRAY_BUFFER, offset, length);
success = glUnmapBuffer(GL_ARRAY_BUFFER);
// Issue other OpenGL ES commands that use other ranges of the VBO's data.
// Issue draw commands that use this range of the VBO's data.
DrawMyVBO(vbo);
// Create a fence that the next frame will wait for.
fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
return success;
}
此示例中的 UpdateAndDraw 函数使用 glFenceSync 函数在提交使用特定缓冲区对象的绘图命令后立即建立同步点或栅栏。然后它使用 glClientWaitSync 函数(在下一次通过渲染循环时)在修改缓冲区对象之前检查该同步点。如果这些绘图命令在渲染循环回来之前在 GPU 上完成执行,则 CPU 执行不会阻塞,UpdateAndDraw 函数会继续修改缓冲区并绘制下一帧。如果 GPU 还没有完成这些命令的执行,glClientWaitSync 函数会阻止 CPU 进一步执行,直到 GPU 到达围栏。通过仅在具有潜在资源冲突的代码部分周围手动放置同步点,您可以最大限度地减少 CPU 等待 GPU 的时间。