1 简介
本文围绕如下三个知识点详细叙述OpenGL管道是如何处理指令:
- 如何查询OpenGL指令在管道内的执行进度
- 如何测量指令执行的时长
- 如何同步OpenGL程序,以及如何同步多个OpenGL上下文
通过获取指令执行的时长信息,我们就能够调整程序的复杂度,从而使得其和当前的GPU效率相符,从而使得我们能够合理的控制渲染延迟,这在实时程序中非常重要。
2 查询
通过查询机制我们可以知道图像管道内正在执行什么操作,首先需要调用如下函数生成一组问题。
void glGenQueries(GLsizei n, GLuint *ids);
参数n是问题的个数,参数ids可以理解为是这些问题名字数组的地址,其使用方法如下。
GLuint one_query;
GLuint ten_queries[10];
glGenQueries(1, &one_query);
glGenQueries(10, ten_queries);
上面的代码块一共注册了11个问题,同时也告诉了OpenGL我们将会询问11个问题。函数glGenQueries()
注册问题失败时得到的问题索引(可以理解为问题名称)都为0,可以通过函数glGetError()
获取失败的详细信息。
注册问题会消耗OpenGL的资源,当我们不再使用这个问题时需要调用如下函数释放这部分资源。
void glDeleteQueries(GLsizei n, const GLuint *ids);
和glGenQueries()
的对应参数含义类似,这里n指的是想要删除的问题数量,参数ids是它们索引数组的地址。其使用方式如下。
glDeleteQueries(10, ten_queries);
ten_queries = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
glDeleteQueries(1, &one_query);
one_query = 0;
2.1 遮挡查询
默认情况下OpenGL并不会追踪处理的片段数量,调用函数glBeginQuery()
可以使OpenGL去计算处理过的片段数量。
glBeginQuery(GL_SAMPLES_PASSED, one_query);
参数one_query传入已经成功注册的问题索引,参数GL_SAMPLES_PASSED
得含义是有多少个样本通过了深度测试。这里使用样本计数是因为当开启多重采样特性时,我们得到的值会比像素的数量更大,默认情况下一个像素对应了一个样本。查询通过深度测试片段数量的问题称为遮挡查询(occlusion query)。
需要注意的是OpenGL默认情况下深度测试执行的时机会早于片段着色器,因此默认配置下,得到的值可以理解为片段着色器处理的样本数。但是在前面片段着色器相关文章中讲到过,可以通过开启一些片段着色器特性使得深度测试执行的时机晚于片段着色器,这是遮挡查询得到的结果就不能表示片段着色器处理的样本数。
执行该函数后,和普通的OpenGL程序一样正常的执行相关命令,此时OpenGL就会追踪所有通过深度测试的样本。需要注意的是,即使由于颜色混合或者覆盖到原因导致某个片段对最后的图像没有任何贡献,它也会被记入到总数中。通过调用函数glEndQuery()
可以使OpenGL停止追踪任务,其原型如下。
glEndQuery(GL_SAMPLES_PASSED);
这条指令执行后OpenGL会计算出函数调用glBeginQuery()
和glEndQuery()
之间所有通过深度测试,从而到达片段着色器的样本数。需要注意的是如果放弃了默认的早期测试特性,这个值仅仅表示通过深度着色器的样本数,可能比到达片段着色器的样本数更少。
2.1.1 检索查询结果
通过调用函数glGetQueryObjectuiv()
可以获取查询结果,其原型如下。
glGetQueryObjectuiv(the_query, GL_QUERY_RESULT, &result);
参数the_query我们需要检索的问题索引,result是一个指针参数,OpenGL会把被检索的问题结果写入到其指向到地址中。通过在成对的函数glBeginQuery()
和glEndQuery()
调用之间渲染一个模型,最后查询通过深度测试的样本数可以知道这个模型是否可见。
因为在CPU中调用OpenGL的接口很多都是异步执行的缘故,因此在调用函数glEndQuery()
时,在这之前的渲染指令并不能保证都会执行完毕。因此在我们调用glGetQueryObjectuiv()
时它会阻塞当前线程,直到OpenGL执完被查询问题关联的一组glBeginQuery()
和 glEndQuery()
函数之间的所有渲染都执行完毕,这样保证得到的结果是正确的。如果你打算使用查询功能来优化程序性能,这种线程阻塞是你不希望看到的,这会降低程序处理效率。为了解决这个问题,OpenGL提供了如下函数来判断当前某个问题的结果是否可以被检索,即它依赖的OpenGL指令是否执行完毕。
glGetQueryObjectuiv(the_query, GL_QUERY_RESULT_AVAILABLE, &result);
如果当前被查询的问题依赖的OpenGL指令还未被执行完毕,那么返回值为GL_FALSE,反之则为GL_TRUE。
2.1.2 使用查询到的结果
通过遮蔽查询避免一些不必要的任务可以提高程序的性能。假设有一个很复杂的模型,这个模型包含的三角形特别多,可能我们还使用了一个很复杂的片段着色器,其中包含大量的纹理查询和复杂的数学计算。可能完成这个模型的渲染需要很多的顶点属性和纹理,模型渲染的代价昂贵。但是很可能这个模型最后在场景中是不可见的,可能它会被其他模型所覆盖,甚至它可能已经在画布的外面。提前知道这些信息,不要渲染那些用户无法看到的模型对可以很好的提升程序性能。
常用的做法是使用一个比原始复杂的模型抽象出简单的版本来做遮挡查询。通常一个简单的边框就足够,首先开启遮挡查询,渲染一个边框,然后结束遮挡查询并检查其结果。如果这个边框内部没有任何样本通过深度测试,也就意味着整个复杂的模型都不会被看见,此时就没有必要再渲染该模型了。
当然,你并不希望渲染的边框出现最终的画面中,你可以使用多种方法使得OpenGL并不会实际的去渲染这个边框模型。最简单的办法是调用函数glColorMask()
和参数GL_FALSE
关闭颜色缓存,使其不再接受数据写入。同样你也可以调用函数glDrawBuffer()
传入参数GL_NONE
,无论你使用的是那种方法,在渲染正常的模型之前一定记得讲颜色缓存写入的功能打开。
这种优化方案的简单代码如下。
glBeginQuery(GL_SAMPLES_PASSED, the_query);
RenderSimplifiedObject(object);
glEndQuery(GL_SAMPLES_PASSED);
glGetQueryObjectuiv(the_query, GL_QUERY_RESULT, &the_result);
if (the_result != 0) {
RenderRealObject(object);
}
函数RenderSimplifiedObject
渲染了低保真度的模型,而函数RenderRealObject
渲染了全细节的模型,并且只有在至少1个样本通过深度测试时才会被调用。需要注意的是函数glGetQueryObjectuiv
会阻塞当前的线程,因此正确的方式是先判断当前查询的结果是否可用,如果不可用,或者通过深度测试的片段数为0都应该渲染复杂的模型,其代码如下。
GLuint the_result = 0;
glBeginQuery(GL_SAMPLES_PASSED, the_query);
RenderSimplifiedObject(object);
glEndQuery(GL_SAMPLES_PASSED);
glGetQueryObjectuiv(the_query, GL_QUERY_RESULT_AVAILABLE, &the_result);
if (the_result != 0) {
glGetQueryObjectuiv(the_query, GL_QUERY_RESULT, &the_result);
} else {
the_result = 1;
}
if (the_result != 0) {
RenderRealObject(object);
}
在OpenGL的图像管道内同一时刻可以可以存在多个遮蔽查询。使用多重遮蔽查询也是另外一种避免程序等待OpenGL指令的方式。在同一时刻,尽管OpenGL只能计算一个查询的结果,但是OpenGL可以管理多个查询对象并且连续执行这些查询。扩展上面的例子,假设我们有10个元素组成的数组需要渲染,每个元素都有一个简化版的模型,我们可以重构代码如下。
int n;
for (n = 0; n < 10; n++) {
glBeginQuery(GL_SAMPLES_PASSSED, ten_queries[n]);
RenderSimplifiedObject(&object[n]);
glEndQuery(GL_SAMPLES_PASSED);
}
for (n = 0; n < 10; n++) {
glGetQueryObjectuiv(ten_queries[n], GL_QUERY_RESULT, &the_result);
if (the_result != 0) {
RenderRealObject(&object[n]);
}
}
正如前文提到,OpenGL采用的是管线模式,它在同一时刻可以执行很多操作。即便你绘制的是简单模型,例如一个外接矩形,很有可能在查询结果该结果并不可用。也就是调用函数glGetQueryObjectuiv()
可能会阻塞当前的线程。
在新的例子中,我们循环渲染了10个外接矩形,这样当再检索第一个渲染模型的查询结果时,相比于渲染1个外接矩形后立即检索查询结果而言,留给OpenGL工作的时间更多,这样就更有可能我们去检索一个查询结果时它已经可用。一些复杂的程序将这个技巧运用到了极致,它们使用上一帧的渲染数据作为下一帧是否渲染某个模型的参考因素。
结合前文提到的先判断结果是否可用再做决定,以及渲染一组简单模型两种技巧,我们可以使用如下的代码来帮助我们提高程序性能。
int n;
for (n = 0; n < 10; n++) {
glBeginQuery(GL_SAMPLES_PASSSED, ten_queries[n]);
RenderSimplifiedObject(&object[n]);
glEndQuery(GL_SAMPLES_PASSED);
}
for (n = 0; n < 10; n+) {
glGetQueryObjectuiv(ten_queries[n], GL_QUERY_RESULT_AVAILABLE, &the_result);
if (the_result != 0) {
glGetQueryObjectuiv(ten_queries[n], GL_QUERY_RESULT, &the_result);
} else {
the_result = 1;
}
if (the_result != 0) {
RenderRealObject(&object[n]);
}
}
由于调用函数RenderRealObject
使OpenGL执行的工作远高于函数RenderSimplifiedObjec t
的调用,当我们第二次、第三次以及后面的检索操作都更容易直接得到可用的结果。正因如此,对于越复杂的场景,我们执行的查询操作越多,更容易得到明显的性能提升。
2.1.3 让OpenGL决定
在上面的例子中我们通过函数使OpenGL追踪通过深度测试的样本数,从而决定下一步执行的操作。然而,在这个程序中,实际上我们并不关心通过深度测试的具体样本数量。获取具体的值可能会消耗CPU的资源,甚至当你使用远程渲染系统时还会消耗网络资源,这样造成的负面影响有时比我们使用这种方式得到的性能优化更大。
更好的解决方法是如果有一种方式我们能够直接调用函数渲染全细节的模型,并告诉OpenGL只有当一个查询对象的结果认为有必要时才真正的去执行渲染操作。这种方式称为预测,可以通过条件渲染技术实现。条件渲染技术使我们可以包装一系列OpenGL渲染指令,并将其和一个查询对象一起发送到OpenGL,同时附带一条消息,其含义“如果查询到结果是0,则忽略这些渲染指令”。通过调用如下函数标识条件渲染指令的开始。
glBeginConditionalRender(the_query, GL_QUERY_WAIT);
通过如下函数标识条件渲染指令的结束。
glEndConditionalRender();
包括函数glDrawArrays()
、glClearBufferfv()
和glDispatchCompute()
等在内的渲染指令,其调用时机如果位于函数glBeginConditionalRender()
和glEndConditionalRender()
之间时,如果查询结果的值为0,那么它们将不会执行。这里OpenGL内部会去检索这个值,不需要我们做任何额外的操作,并且OpenGL内部会根据得到的值去决定其下一步执行的操作。需要注意的是如绑定纹理,开启或者关闭颜色混合等操作仍然一定会被执行,只有和渲染相关的指令才会受到上述条件限制。使用这个特性优化前面的代码如下。
// Ask OpenGL to count the samples rendered between the start
// and end of the occlusion query
glBeginQuery(GL_SAMPLES_PASSED, the_query);
RenderSimplifiedObject(object);
glEndQuery(GL_SAMPLES_PASSED);
// Only obey the next few commands if the occlusion query says something
// was rendered
glBeginConditionalRender(the_query, GL_QUERY_WAIT);
RenderRealObject(object);
glEndConditionalRender();
这里我们在函数glBeginConditionalRender()
中传入了参数GL_QUERY_WAIT
,这意味着当执行到这个函数时,如果前面它依赖的查询对象结果还不可用时,将会阻塞线程。如果不想让该函数阻塞线程,并且结果不可以用时直接渲染复杂版本,只需要将器替换该参数即可,调用方式如下。
glBeginConditionalRender(the_query, GL_QUERY_NO_WAIT);
需要注意的是如果使用了参数GL_QUERY_NO_WAIT
,那么实际渲染的几何行将会取决于执行到该函数时它关联的查询对象所依赖的渲染指令是否已经完成。也就是说你的程序执行结果依赖于你当前的硬件性能,并且每次执行的结果都可能不同,这也意味着你在高性能的运行环境下得到的结果可能不同于在低性能环境下的结果。
当然,我们可以配合使用多个查询和条件渲染函数对,结合本小节所有的技巧,我们得到如下的代码。
// Render simplified versions of 10 objects, each with its own occlusion query
int n;
for (n = 0; n < 10; n++) {
glBeginQuery(GL_SAMPLES_PASSSED, ten_queries[n]);
RenderSimplifiedObject(&object[n]);
glEndQuery(GL_SAMPLES_PASSED);
}
// Render the more complex versions of the objects, skipping them
// if the occlusion query results are available and zero
for (n = 0; n < 10; n++) {
glBeginConditionalRender(ten_queries[n], GL_QUERY_NO_WAIT);
RenderRealObject(&object[n]);
glEndConditionalRender();
}
2.1.4 高级遮蔽查询
使用参数GL_SAMPLES_PASSED
时,OpenGL会追踪完整的渲染流程,从而得到通过深度测试具体的样本数量。然而并不关心这个具体值,我们只关心是否有片段通过模版和深度测试。在开启和结束查询的函数中,OpenGL还提供了参数GL_ANY_SAMPLES_PASSED
和GL_ANY_SAMPLES_PASSED_CONSERVATIVE
,它们的返回值都是Boolean类型。
使用参数GL_ANY_SAMPLES_PASSED
时,OpenGL内部会做一些优化,如果有任何一个样本通过了模版和深度测试,就不会再继续计算通过测试的样本数量。当然如果没有任何片段通过测试,使用该参数就不会得到任何性能优化。
使用参数GL_ANY_SAMPLES_PASSED_CONSERVATIVE
可以得到更高的性能提升。需要注意的是它检测的是可能通过深度和模版测试的样本数是否为0,因此可能存在一些假信号。大多数OpenGL的实现都采取了分级深度测试的策略,即存储屏幕特定区域的最远和最近的深度值,然后对图元执行光栅化操作,先被分割出的图元块会和存储的这些分级深度值进行比较从而决定是否对块内的部分继续光栅化。一个conservative类型的遮挡查询可能仅仅统计的是这些大图元块是否通过分级深度测试。
2.2 时间查询
OpenGL支持查询渲染任务的执行时长,即时间查询。在调用函数glBeginQuery()
和glEndQuery()
时,传入参数GL_TIME_ELAPSED
,当再次调用函数glGetQueryObjectuiv()
时的到的结果就是这组函数之间的所有渲染指令实际花费的时间,单位为纳秒,即1秒/10亿。你可以使用这种方式找出场景中渲染最耗时的部分,示例代码如下。
// Declare our variables
GLuint queries[3]; // Three query objects that we’ll use
GLuint world_time; // Time taken to draw the world
GLuint objects_time; // Time taken to draw objects in the world
GLuint HUD_time; // Time to draw the HUD and other UI elements
// Create three query objects
glGenQueries(3, queries);
// Start the first query
glBeginQuery(GL_TIME_ELAPSED, queries[0]);
// Render the world
RenderWorld();
// Stop the first query and start the second...
// Note: we’re not reading the value from the query yet
glEndQuery(GL_TIME_ELAPSED);
glBeginQuery(GL_TIME_ELAPSED, queries[1]);
// Render the objects in the world
RenderObjects();
// Stop the second query and start the third
glEndQuery(GL_TIME_ELAPSED);
glBeginQuery(GL_TIME_ELAPSED, queries[2]);
// Render the HUD
RenderHUD();
// Stop the last query
glEndQuery(GL_TIME_ELAPSED);
// Now, we can retrieve the results from the three queries.
glGetQueryObjectuiv(queries[0], GL_QUERY_RESULT, &world_time);
glGetQueryObjectuiv(queries[1], GL_QUERY_RESULT, &objects_time);
glGetQueryObjectuiv(queries[2], GL_QUERY_RESULT, &HUD_time);
// Done. world_time, objects_time, and hud_time contain the values we want.
// Clean up after ourselves.
glDeleteQueries(3, queries);
在开发过程中分析代码非常有用,我们可以找到程序中最耗费性能的部分,从而集中精力优化这个部分的性能,我们也可以在程序运行的时候改变程序的行为从而最大的利用图像子系统的性能。例如,我们可以根据objects_time的值来决定应该向场景中增加或者减少模型的数量。你也可以根据图形硬件的性能从而在复杂和简单的着色器之间切换。如果你只想知道某个渲染指令完成的时间戳,可以调用如下函数。
void glQueryCounter(GLuint id, GLenum target);
参数id传入查询对象的索引,target传入GL_TIMESTAMP
。调用该函数后,当前执行的渲染指令结束时,OpenGL会将结束时的时间记录到查询结果中,需要注意的是时间戳为0时并没有任何意义。在使用时需要将两个时间戳相减得到成对函数glQueryCounter()
调用之间的渲染任务所耗费的时间。使用该函数改写后的示例代码如下。
// Declare our variables
GLuint queries[4]; // Now we need four query objects
GLuint start_time; // The start time of the application
GLuint world_time; // Time taken to draw the world
GLuint objects_time; // Time taken to draw objects in the world
GLuint HUD_time; // Time to draw the HUD and other UI elements
// Create four query objects
glGenQueries(4, queries);
// Get the start time
glQueryCounter(GL_TIMESTAMP, queries[0]);
// Render the world
RenderWorld();
// Get the time after RenderWorld is done
glQueryCounter(GL_TIMESTAMP, queries[1]);
// Render the objects in the world
RenderObjects();
// Get the time after RenderObjects is done
glQueryCounter(GL_TIMESTAMP, queries[2]);
// Render the HUD
RenderHUD();
// Get the time after everything is done
glQueryCounter(GL_TIMESTAMP, queries[3]);
// Get the result from the three queries, and subtract them to find deltas
glGetQueryObjectuiv(queries[0], GL_QUERY_RESULT, &start_time);
glGetQueryObjectuiv(queries[1], GL_QUERY_RESULT, &world_time);
glGetQueryObjectuiv(queries[2], GL_QUERY_RESULT, &objects_time);
glGetQueryObjectuiv(queries[3], GL_QUERY_RESULT, &HUD_time);
HUD_time -= objects_time;
objects_time -= world_time;
world_time -= start_time;
// Done. world_time, objects_time, and hud_time contain the values we want.
// Clean up after ourselves.
glDeleteQueries(4, queries);
在上面的代码块中创建了4个查询对象,通过计算相邻两个时间戳之间的时间差得到某个渲染指令的耗时。这里并不需要成对调用函数glBeginQuery()
和glEndQuery()
,减少了OpenGL接口调用。其实当使用GL_TIME_ELAPSED
类型的查询对象是,OpenGL内部会自动去创建两个时间戳并在渲染指令执行完毕的时候去计算它们之间的差值。
时间查询结果的单位是纳秒,因此即便是很短的时间这个值也很大。如果使用23位的无符号整形最多能记录比4秒稍长的时间,如果你想要计算更长的时间,需要使用查询64位无符号整形的结果,需要调用如下函数。
void glGetQueryObjectui64v(GLuint id, GLenum pname, GLuint64 * params);
和函数glGetQueryObjectuiv()
类似,参数id指的是想要检索的查询对象的索引,参数pname可选GL_QUERY_RESULT
和GL_QUERY_RESULT_AVAILABLE
分别检索真正的时间值或者该查询结果是否可用。
另外OpenGL还提供了如下函数用于查询当前的时间戳。
GLint64 t;
void glGetInteger64v(GL_TIMESTAMP, &t);
这个函数执行后将会得到当前GPU内部的时间戳,如果立即发起一个时间戳查询,那么查询结果和当前时间的差值即为查询指令到达OpenGL渲染管线所花费的时间,也被称为管线延迟,它近似等于程序发布一个指令到OpenGL真正执行这个指令需要花费的时间。
2.3 变换反馈查询
如果在使用转换反馈的程序中只包含顶点着色器而不包括几何着色器时,如果转换反馈缓存中可用的空间没有被浪费掉时,存储在顶点反馈中的顶点数量和传递到OpenGL中的顶点数量相同。当几何着色器存在时,可能会生成新的顶点或者丢弃部分顶点,这样写入到转换反馈缓存中的顶点数和传递到OpenGL中的顶点数目就可能不同。同样的,如果曲面细分特性被激活后,得到的图元数量将和曲面细分控制着色器中的参数相关。OpenGL提供了查询对象使我们可以追踪被写入到转换反馈缓存中的顶点数量。你可以利用这些信息来决定如何绘制这些场景,也可以知道需要从转换反馈缓存中读取多少数据。
OpenGL支持查询生成的图元数量,和写入到转换反馈缓存中的图元数量。首先我们还是要创建查询对象。创建单个查询对象的代码如下。
GLuint one_query;
glGenQueries(1, &one_query);
创建多个查询对象的代码如下。
GLuint ten_queries[10];
glGenQueries(10, ten_queries);
通过参数GL_PRIMITIVES_GENERATED
和GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
可以分别查询产生的图元数量和写入到转换反馈缓存中的图元数量,代码如下。
glBeginQuery(GL_PRIMITIVES_GENERATED, one_query);
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, one_query);
上面的代码执行后,OpenGL就会追踪被管线前端处理的图元数量,以及写入到转换反馈缓存中的图元数量,直到如下代码调用后就会停止追踪。
glEndQuery(GL_PRIMITIVES_GENERATED);
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
使用函数glGetQueryObjectuiv()
和参数GL_QUERY_RESULT
可以检索该查询的结果。和其他类型的查询一样,该查询结果并不一定立即可用,可以通过函数glGetQueryObjectuiv()
和参数GL_QUERY_RESULT_AVAILABLE
确定查询的结果是否可用。
GL_PRIMITIVES_GENERATED
和GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
类型的查询对象有如下差异。首先GL_PRIMITIVES_GENERATED
类型查询对象计算图像渲染管道前端产生的图元数量,而后者仅仅追踪成功写入到转换反馈缓存中的图元数量。管道前段生成的图元数量可能比我们在程序中传递给OpenGL的图元数量更多或者更少,这取决于这个OpenGL程序中管道前段着色器的具体逻辑。通常情况下,这两个查询的结果是相同的,但是如果转换反馈缓存的空间不足,那么这两个类型的查询对象获得的结果将不同。
你可以通过同时检索这两种查询对象的结构,通过比较它们的值来确定是否所有管道前端生成的图元都被成功写入到转换反馈缓存中。
第二个不同的地方是GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
类型的查询对象只有当转换反馈特性被开启的时候才有意义。如果该特性未开启,那么这种类型的查询结果将为0。相反GL_PRIMITIVES_GENERATED
类型的查询对象始终有效,你可以检索其结果确定几何着色器产生或者丢弃了多少顶点。
2.3.1 带索引的查询
如果你仅仅使用了单个流用于存储转换反馈中的顶点,通过调用函数glBeginQuery()
和glEndQuery()
并传入参数GL_PRIMITIVES_GENERATED
或者GL_TRANSFORM_FEEDBACK_PRIMITIVES _WRITTEN
,你可以得到正确的结果。但是如果在渲染管道中几何着色器被几何,则其在生成图元时最高可以有4个输出流。对于这种情况,可以使用OpenGL提供的带索引的查函数追踪每个输出流产生的数据量。函数glBeginQuery()
和glEndQuery()
默认追踪索引为0的输出流,带索引的查询函数原型如下。
void glBeginQueryIndexed(GLenum target, GLuint index, GLuint id);
void glEndQueryIndexed(GLenum target, GLuint index);
这两个函数比不带索引的版本仅仅多了参数index,其表示想要追逐的数据流索引,当几何着色器未启用,或者仅仅包含1个输出流时,你仍然可以调用这些函数,只是只有索引值为0的输出流查询结果才有意义。
实际上对于其他类型的查询你也可以调用带索引版本的相关函数,只是只有索引值为0的调用才有效。
2.3.1 使用图元查询的结果
在有些渲染程序中,第一阶段得到的存储有管道前端处理结果的缓存,以及在这个缓存中图元的数量,都是执行第二阶段渲染的必要参数。前面的章节已经讲过,要想使一个缓存称为转换反馈缓存,只需要将该缓存绑定至靶点GL_TRANSFORM_FEEDBACK_BUFFER
上。需要记住的是缓存对象是OpenGL中的一个通用对象,该缓存可以多次被绑定在任意靶点上从而可以满足各种使用需求。
通常情况下,第一阶段的渲染任务执行完后,转换反馈缓存中已经存储了必要的数据,再将该缓存绑定至靶点GL_ARRAY_BUFFER
上使之成为一个顶点缓存。如果你使用了几何着色器,那么生成的顶点数量是未知的,此时你需要使用GL_TRANSFORM_FEEDBACK_ PRIMITIVES_WRITTEN
类型的查询对象确定该缓存中的顶点数量,从而继续第二阶段的渲染逻辑。示例代码如下。
// We have two buffers, buffer1 and buffer2. First, we’ll bind buffer1 as the
// source of data for the draw operation (GL_ARRAY_BUFFER), and buffer2 as
// the destination for transform feedback (GL_TRANSFORM_FEEDBACK_BUFFER).
glBindBuffer(GL_ARRAY_BUFFER, buffer1);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFFER, buffer2);
// Now, we need to start a query to count how many vertices get written to
// the transform feedback buffer
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, q);
// Ok, start transform feedback...
glBeginTransformFeedback(GL_POINTS);
// Draw something to get data into the transform feedback buffer
DrawSomePoints();
// Done with transform feedback
glEndTransformFeedback();
// End the query and get the result back
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
glGetQueryObjectuiv(q, GL_QUERY_RESULT, &vertices_to_render);
// Now we bind buffer2 (which has just been used as a transform
// feedback buffer) as a vertex buffer and render some more points from it.
glBindBuffer(GL_ARRAY_BUFFER, buffer2);
glDrawArrays(GL_POINTS, 0, vertices_to_render);
无论什么时候你从OpenGL中检索一个查询对象的结果,都必须等待OpenGL执行完当前的命令才能得到正确的结果。当上面的代码块被运行后,函数glGetQueryObjectuiv()
执行后,会阻塞当前的线程。另外查询的结果需要从GPU中传递到CPU内,当我们得到查询结果后又将该结果传递回GPU内部,这种数据的传递是没有必要的。
为了避免这种无意义的性能浪费,OpenGL提供了新的特性,即转换反馈对象,它可以表示转换反馈阶段的状态。目前为止我们使用了默认的转换反馈对象,当然我们也可以创建自定义的转换反馈对象。其函数原型如下。
void glGenTransformFeedbacks(GLsizei n, GLuint * ids);
void glBindTransformFeedback(GLenum target, GLuint id);
对于函数glGenTransformFeedbacks()
,n为需要创建的转换反馈缓存对象个数,ids是这组对象的标识数组地址。在创建完转换反馈顶点缓存对象后,需要使用函数glBindTransformFeedback()
绑定,参数target必须是GL_TRANSFORM_FEEDBACK
,参数id传入需要绑定的转换反馈对象标识。调用函数glDeleteTransformFeedbacks()
可以删除转换反馈对象,调用函数glIsTransformFeedback()
可以确定某个给定的标识是否关联了一个转换反馈对象,其原型如下。
void glDeleteTransformFeedbacks(GLsizei n, const GLuint * ids);
GLboolean glIsTransformFeedback(GLuint id);
当转换反馈对象被绑定后,它会保存相关的状态,包括转换反馈缓存的绑定和写入到每个转换反馈流的数据量。使用转换反馈对象的方式更高效,数据同样会被返回给内部隐含的转换反馈查询对象,但是我们可以直接使用这个转换反馈对象参与后面的绘制工作,而不需要将具体的值从GPU读取到CPU中,从而避免性能浪费。相关的函数原型如下。
void glDrawTransformFeedback(GLenum mode, GLuint id);
void glDrawTransformFeedbackInstanced(GLenum mode, GLuint id, GLsizei primcount);
void glDrawTransformFeedbackStream(GLenum mode, GLuint id, GLuint stream);
void glDrawTransformFeedbackStreamInstanced(GLenum mode, GLuint id, GLuint stream,
GLsizei primcount);
对于上面4个函数,参数mode表示图元类型,它和其他绘制函数中的同名参数含义相同,如glDrawArrays()
和glDrawElements()
,参数id是转换反馈对象的标识,该对象内部包含了图元的数量。
- 函数
glDrawTransformFeedback()
和函数glDrawArrays()
类似,这里默认使用第一个流的图元数量设置场景渲染的图元数量。 - 函数
glDrawTransformFeedbackInstanced()
和函数glDrawArraysInstanced()
类似,同样这里默认使用第一个流的图元数量设置场景渲染的图元数量,参数primcount表示调用的次数。 - 函数
glDrawTransformFeedbackStream()
和glDrawTransformFeedback()
类似,但是这里参数stream指定使用哪个流的图元数量设置场景渲染的图元数量。 - 函数
glDrawTransformFeedbackStreamInstanced()
和glDrawTransformFeedbackInstanced()
类似,同样的这里参数stream指定使用哪个流的图元数量设置场景渲染的图元数量。
当使用了带参数stream的绘制函数时,数据必须被写入到和正确流关联的转换反馈缓存中,更多的知识点可以在前面关于几何着色器的章节中多重流存储中找到。
3 OpenGL中的同步
在一个高级程序中,OpenGL任务执行的顺序和系统的管道特性可能非常重要。这些程序通常包含多个上下文,它们运行在多个线程中,程序内的数据可能在OpenGL和其他API中共享,例如和OpenCL共享数据。在有些情况下,我们必须知道发送到OpenGL的指令是否执行完毕,或者这些指令得到的结果是否可用。在这个小节中,我们会讨论多种方法用于在OpenGL渲染管道中的不同部分做同步操作。
3.1 清空管道
在程序中发布的OpenGL指令并不会立即被执行,通过函数glFlush()
和函数glFinish()
我们能够在一定程度上控制指令执行状态,以及等待指令执行完成。
函数glFlush()
会确保其调用之前的OpenGL指令都被放置于渲染管线的起点,这些指令最终都会执行,但它并不会告诉你这些指令执行的状态。而函数glFinish()
它在该调用之前的所有指令执行完毕,图像管道情况之前阻塞当前的线程。该函数这种阻塞逻辑使得对应线程不能饱和的执行OpenGL指令,也可以理解为存在空洞,这样会降低程序的性能。总之,建议在任何情况下都不要调用函数glFinish()
。
3.2 同步以及围栏
有时我们并不需要强制清空图形管道或者强制所有指令立即进入OpenGL的执行队列,但是仍然想要知道OpenGL的指令是否执行完毕。当程序在多个OpenGL上下文之间,或者OpenGL和OpenCL之间共享数据时,这种情形很常见。管理同步类型的对象称为同步对象(sync objects)。和OpenGL内部其他对象一样,在使用它们之前必须创建这些对象,当它们不在被需要时应该销毁。同步对象有两种可能的状态,有信号的(signaled)和无信号的(unsignaled)。同步对象被创建的时候都是无信号状态的,当一些特殊事件发生时,它们会变成有信号状态。能够触发这种转变的事件取决于这些同步对象的类型。这里我们关注的围栏类型的同步对象,称为围栏同步(fence sync),它可以通过调用如下函数创建。
GLsync glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
第一个参数标识我们想要等待的事件,这里我们使用GL_SYNC_GPU_COMMANDS_COMPLETE
表示我们需要等待GPU完全执行完所有的OpenGL指令。第二个参数是一个标识位,这里传入0是因为这种类型的同步对象没有关联的标识。函数glFenceSync()
执行完毕后会返回一个新的同步对象。当围栏同步对象被成功创建后,它就以无信号状态进入到OpenGL的渲染管线中,当它到达管线末端时,在此之前的OpenGL指令都已经执行完毕,它的状态就会被置为有信号的。
我们可以检查一个同步对象的状态从而作出决策,我们也可以等待同步对象的状态变为有信号。确定一个同步对象的状态是否为有信号,可以调用如下函数。
glGetSynciv(sync, GL_SYNC_STATUS, sizeof(GLint), NULL, &result);
该函数的返回值类型规划 iGLint,如果同步对象的状态是无信号,则返回值是GL_SIGNALED,否则为GL_UNSIGNALED。这样我们就可以在GPU正在处理之前的渲染指令时做一些有用的工作。这种思想的示例代码如下。
GLint result = GL_UNSIGNALED;
glGetSynciv(sync, GL_SYNC_STATUS, sizeof(GLint), NULL, &result);
while (result != GL_SIGNALED) {
DoSomeUsefulWork();
glGetSynciv(sync, GL_SYNC_STATUS, sizeof(GLint), NULL, &result);
}
在上面代码块的while循环结构中,每一次迭代都做了少量有用的工作,直到同步对象的状态变为有信号。如果程序在每一帧的开始都创建了一个同步对象,那么通过这种方式他可以根据GPU的运输能力做更多或者更少的有用工作。这样的程序能够很好的平衡CPU(例如做一些音频特性,或者物理模拟)和GPU的运算能力。
等待同步对象变为有信号状态可以调用如下两个函数。
glClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, timeout);
glWaitSync(sync, 0, GL_TIMEOUT_IGNORED);
它们的第一个参数sync都表示想要等待的同步对象。
对于函数glClientWaitSync()
参数bitfield如果设置为GL_SYNC_FLUSH_COMMANDS_BIT
表示强制同部对象进入OpenGL的渲染管线,如果不指定该参数,那么可能这个同步对象不会进入渲染管线,那么它的状态也就不可能改编为有信号,这会使得程序永久等待。通常建议指定它为第二个参数。第三个参数timeout表示等待的超时时间,它的单位是纳秒(十亿分之1秒),如果在指定的时间内同部对象仍未变成有信号状态,则函数glClientWaitSync()
将会返回对应的值,可能返回的状态码如下。
返回值 | 含义 |
---|---|
GL_ALREADY_SIGNALED | 当该函数调用时,该同步对象就已经位于有信号状态,函数立即返回 |
GL_TIMEOUT_EXPIRED | 同步对象在指定的时间内未变成有信号状态 |
GL_CONDITION_SATISFIED | 同步对象在指定的时间内变成有信号状态 |
GL_WAIT_FAILED | 内部出现错误,如同步对象无效,可以通过函数glGetError() 获取更多信息 |
关于超时时间的设置还有两点需要知道。第一,尽管该参数的单位是纳秒,但是在OpenGL里面该方法并没有绝对的时间精度,即使你设置了1纳秒,该方法等待的时间可能是1毫秒,甚至更多。第二,如果超时时间指定为0,那么该函数绝不会返回GL_CONDITION_SATISFIED。
对于函数glWaitSync()
,程序并不会真正的去等待同步对象变为有信号状态,实际上只有GPU内部才会等待,因此该函数会立即返回。由于这个特性,该函数的第二个和第三个参数似乎在某种程度上讲都没有意义。程序不会被挂起,因此第二个参数无需指定为GL_SYNC_FLUSH_COMMANDS_BIT
,实际上当该值被指定时会引起错误。此外,参数timeout实际上取决于OpenGL在运行环境上的具体实现,因此需要指定为一个特殊的值GL_TIMEOUT_IGNORED
,如果你对该值感兴趣,可以通过调用函数glGetInteger64v()
并传入参数GL_MAX_SERVER_WAIT_TIMEOUT
来获取当前OpenGL实现的最大超时时间。
你可能想知道,什么时间我们应该调用该函数使GPU等待同步对象到达管道的末端。通过调用函数glWaitSync()
我们可以使得GPU在等待同步对象变为有信号状态之前不再执行新的绘制指令。
这个特性在复杂的程序中十分有用,如程序中包含多个OpenGL的上下文,或者同步对象在兼容的API中共享(如在OpenGL和OpenCL之间共享同步对象)。这意味着你可以在一个上下文中创建同步对象,在另外一个上下文中调用函数glClientWaitSync()
或者glWaitSync()
来同步GPU的渲染任务。
更具体的讲,你的程序可以分配多个线程,并且为每个线程关联一个OpenGL上下文。你可用在每个上下文中创建一个同步对象,并调用函数glClientWaitSync()
或者glWaitSync()
在该线程中等待其他线程创建的同步对象,这样你就能知道什么时候所有的上下文都完成渲染工作,它们之间就可以同步。结合操作系统提供的同步工具(如信号量),你就可以在多个窗口中同步渲染场景。
一个使用例子是当一个缓存对象被两个上下文共享时。第一个上下文使用转换反馈向这个缓存中写入对象,第二个上下文需要使用这个缓存中的数据来执行渲染任务。首先第一个上下文调用函数glEndTransformFeedback()
后立即调用函数glFenceSync()
。然后程序切换至第二个上下文,并调用函数glWaitSync()
等待刚刚创建的同步对象变为有信号状态。然后可以向OpenGL发布更多的渲染指令,由于同步机制的存在,只有当第一个上下文的数据写入完成后,OpenGL才会执行第二个上下文发布的渲染指令。这个方法在例如OpenCL等允许异步缓存写入的API中同样适用。
同步对象的状态只能由无信号变为有信号,即使手动调用任何方法也不能做逆向操作。这是因为手动逆向改变一个同步对象的状态可能会导致竞争,从而使得程序被永久挂起。例如线程A创建了一个同步对象,当前状态变为有信号时,再将其状态置为无信号,线程B此时再去等待这个同步对象,那么它将永远等不到其状态变为有信号,这意味着线程B将被永久阻塞。因此每个同步对象只能使用一次,当不再使用时需要调用如下函数删除。
glDeleteSync(sync);
该函数调用后同步对象可能并不会立即被删除,只有当所有线程中监听该同步对象的指令都执行完毕时,OpenGL才会真正的删除该同步对象。
4 总结
本文详细讨论了如何监听和获取OpenGL渲染管线内部指令执行的状态。讲到了如何查询某个指令执行的时长,也演示了如何获取图形管道指令执行的延时。这样你就可以适当的调整程序的复杂度,使其能够更好的运行在你当前的系统环境上,以及提升程序的性能。在后面的性能优化章节中,我们还会针对这个点详细说明。另外本文也介绍了同步程序中的CPU任务和GPU任务,以及如果在多线程的OpenGL上下文中进行同步渲染。