【GAD翻译馆】无状态的、分层的多线程渲染(一)
发表于2017-10-12
翻译:赵菁菁(轩语轩缘)审校:李笑达(DDBC4747)
在这篇文章中,我想描述一下在现代渲染系统中我所期望的特性和性能特点:它应该支持无状态渲染,支持在不同的层/桶中渲染,还可以在尽可能多的内核上并行运行渲染。
最近我一直在考虑如何有效地实现这样一个渲染系统,在我去实现整个系统之前,我希望能够记录/分享目前为止我的想法和发现。
渲染后端
我说的渲染后端是什么意思?在我看来,渲染后端应该只负责一件事:用图形API(如D3D或OGL)提交绘制调用。高级系统的责任是确保只进行最少量的绘制调用,并对命令调用和状态更改进行排序和优化。
无状态渲染
我们通常处理的所有图形API都是有状态的。这意味着每当你为后续的绘制调用改变API中的任何状态时,这种状态变化也会影响到稍后提交的绘制调用。比如说,如果你把一些对象的剔除状态从背面(backface)改成前面(frontface),你需要在对象渲染完成后重置状态,或为其他所有对象设置默认状态,否则的话,一些对象最终会按照错误的剔除状态渲染。
向用户公开这种有状态的API容易出错,而且抽象性较差。理想情况下,无论我们想要什么状态,提交一个绘制调用都不会影响到任何其他绘制调用。这样我们就可以把每一个独立的绘制调用当作一个单独的“事情”来处理,它携带所需要的所有状态,而不是把任何状态泄漏到其他的调用中。
这也将使我们能够轻松地更改绘制调用的顺序(只要渲染结果保持不变),这样我们就可以摆脱冗余的状态变化,并通过特定的键(前到后、后到前、或其他一些标准)对绘制调用进行排序。
关于Civilization V中使用的Firaxis的LORE系统的介绍更加详细。
分层渲染
也被称为桶分化(bucketized)渲染,主要想法是给绘制调用分配一个之后用于排序的键。通常情况下,该键是一个32位或64位的整数,没什么别的。通常,一个键编码了绘制调用个别位的某些数据,如距离、材质、着色器等。根据这些位存储在整数中的位置,只要你知道键是如何构建的,就可以对同一个绘制调用数组应用不同的排序标准。
这是一种非常有效和直接的方法,因为你可以在整数上使用简单的基数排序,而不必担心如何对数据进行排序(按距离排序吗?通过纹理?根据材料?)。排序标准基本上是在整数的位上编码的——如果你想按材料排序而不是按距离排序,只需把各自的位放在不同的位置上就好。
如果你不熟悉它的话,读一下埃里克松(ChristerEricson)的博客,里面很好地解释了这个概念。
与埃里克松的方法相比,可能有一件事我最想改变。我认为渲染器本身(例如:延迟渲染,集群渲染,前向 渲染器)知道如何渲染实体,因此渲染器也知道需要多少层、需要哪些层。
例如,一个简单的延迟渲染器首先将对象渲染到G缓冲区,然后渲染贴花,再为每个阴影投射光源渲染阴影贴图,一个个地应用所有光源的照明,最终用前向通道渲染透明物体,之后是HUD元素和类似的东西。当然,可能还有几十个不同的实现方法,但你领悟思想就好。
我的观点是,我不想把每一层的每次绘制调用都塞进一个大小相同的键中、将它们全部存储在一个大的流中(或每个线程的本地流),而是为不同层使用大小不同的键。例如,当绘制阴影贴图时,对象应该由前向后排序,不需要按材质或着色器进行排序。因此,对于一个粗略的、基于到摄像机距离的、由前向后的排序,一个16位整数可能是足够的。因此,我会把那些16位键放在不同的“桶”中,而不是把它放在属于其他层的绘制调用中。
我可以为阴影贴图桶设置16位键,为透明物体桶设置32位键,为一般的G缓冲桶设置64位键。这里的想法是:更小的数据可以更快地排序,并且单个的桶可以在不同的线程上并行排序。
多线程渲染
采用这种分层的/ 桶分化的系统,我们得到的最大好处之一——当然是利用所有可用线程渲染的能力。通常的方法是把整个帧的所有绘图调用入队到层/桶中,然后按键排序,再使用前面提到的渲染后端将它们提交给图形API。API调用只在主线程中完成,但是让单个调用进入队列可以很容易地并行完成。
如果引擎遵循面向数据的方法,并且/或使用任务调度器,每个内核可以一次处理N个给定的实体,可能将其工作分解成几个任务,这些任务被交给调度程序处理。不是把所有绘制调用及其键存储在一个数据流中,我可能会使用类似于Bitsquid使用的方法:在每个线程本地流中存储绘制调用数据,然后在主线程提交之前进行排序和合并。
总体思路
最后但并非最不重要的是,只需考虑一般的渲染:实体的渲染应该通过将绘制调用推入一个桶中来实现,而不是为每个桶从每个实体中提取状态。
更具体地说,不应该这样做:
for each (bucket b)
{
for each (entity e)
{
submit_draw_call_to_bucket(b, get_draw_call(e, b));
}
}
而你应该这样做:
for each (entity e)
{
submit_draw_call_to_bucket(shadow_map, e);
submit_draw_call_to_bucket(g_buffer, e);
...
}
这可能看起来微不足道的一些吧,但我认为这是值得指出的。毕竟,还是有很多人通过遍历的矢量对象、为每个对象调用Render()方法来渲染GameObjects 的std::vector,调用每个对象的虚拟()方法。这样做没什么错,但你不能用这样的方法完成含有成百上千实体的游戏。
能够将绘制调用推入不同的桶中,确保了我们只触碰每个实体一次,而不是多次,如果你有许多实体的话,这将显著改进性能。
今天就到此为止。我希望下星期四有更多的东西可以分享,甚至还可以看一些代码!
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;
在这篇文章中,我想描述一下在现代渲染系统中我所期望的特性和性能特点:它应该支持无状态渲染,支持在不同的层/桶中渲染,还可以在尽可能多的内核上并行运行渲染。
最近我一直在考虑如何有效地实现这样一个渲染系统,在我去实现整个系统之前,我希望能够记录/分享目前为止我的想法和发现。
渲染后端
我说的渲染后端是什么意思?在我看来,渲染后端应该只负责一件事:用图形API(如D3D或OGL)提交绘制调用。高级系统的责任是确保只进行最少量的绘制调用,并对命令调用和状态更改进行排序和优化。
无状态渲染
我们通常处理的所有图形API都是有状态的。这意味着每当你为后续的绘制调用改变API中的任何状态时,这种状态变化也会影响到稍后提交的绘制调用。比如说,如果你把一些对象的剔除状态从背面(backface)改成前面(frontface),你需要在对象渲染完成后重置状态,或为其他所有对象设置默认状态,否则的话,一些对象最终会按照错误的剔除状态渲染。
向用户公开这种有状态的API容易出错,而且抽象性较差。理想情况下,无论我们想要什么状态,提交一个绘制调用都不会影响到任何其他绘制调用。这样我们就可以把每一个独立的绘制调用当作一个单独的“事情”来处理,它携带所需要的所有状态,而不是把任何状态泄漏到其他的调用中。
这也将使我们能够轻松地更改绘制调用的顺序(只要渲染结果保持不变),这样我们就可以摆脱冗余的状态变化,并通过特定的键(前到后、后到前、或其他一些标准)对绘制调用进行排序。
关于Civilization V中使用的Firaxis的LORE系统的介绍更加详细。
分层渲染
也被称为桶分化(bucketized)渲染,主要想法是给绘制调用分配一个之后用于排序的键。通常情况下,该键是一个32位或64位的整数,没什么别的。通常,一个键编码了绘制调用个别位的某些数据,如距离、材质、着色器等。根据这些位存储在整数中的位置,只要你知道键是如何构建的,就可以对同一个绘制调用数组应用不同的排序标准。
这是一种非常有效和直接的方法,因为你可以在整数上使用简单的基数排序,而不必担心如何对数据进行排序(按距离排序吗?通过纹理?根据材料?)。排序标准基本上是在整数的位上编码的——如果你想按材料排序而不是按距离排序,只需把各自的位放在不同的位置上就好。
如果你不熟悉它的话,读一下埃里克松(ChristerEricson)的博客,里面很好地解释了这个概念。
与埃里克松的方法相比,可能有一件事我最想改变。我认为渲染器本身(例如:延迟渲染,集群渲染,前向 渲染器)知道如何渲染实体,因此渲染器也知道需要多少层、需要哪些层。
例如,一个简单的延迟渲染器首先将对象渲染到G缓冲区,然后渲染贴花,再为每个阴影投射光源渲染阴影贴图,一个个地应用所有光源的照明,最终用前向通道渲染透明物体,之后是HUD元素和类似的东西。当然,可能还有几十个不同的实现方法,但你领悟思想就好。
我的观点是,我不想把每一层的每次绘制调用都塞进一个大小相同的键中、将它们全部存储在一个大的流中(或每个线程的本地流),而是为不同层使用大小不同的键。例如,当绘制阴影贴图时,对象应该由前向后排序,不需要按材质或着色器进行排序。因此,对于一个粗略的、基于到摄像机距离的、由前向后的排序,一个16位整数可能是足够的。因此,我会把那些16位键放在不同的“桶”中,而不是把它放在属于其他层的绘制调用中。
我可以为阴影贴图桶设置16位键,为透明物体桶设置32位键,为一般的G缓冲桶设置64位键。这里的想法是:更小的数据可以更快地排序,并且单个的桶可以在不同的线程上并行排序。
多线程渲染
采用这种分层的/ 桶分化的系统,我们得到的最大好处之一——当然是利用所有可用线程渲染的能力。通常的方法是把整个帧的所有绘图调用入队到层/桶中,然后按键排序,再使用前面提到的渲染后端将它们提交给图形API。API调用只在主线程中完成,但是让单个调用进入队列可以很容易地并行完成。
如果引擎遵循面向数据的方法,并且/或使用任务调度器,每个内核可以一次处理N个给定的实体,可能将其工作分解成几个任务,这些任务被交给调度程序处理。不是把所有绘制调用及其键存储在一个数据流中,我可能会使用类似于Bitsquid使用的方法:在每个线程本地流中存储绘制调用数据,然后在主线程提交之前进行排序和合并。
总体思路
最后但并非最不重要的是,只需考虑一般的渲染:实体的渲染应该通过将绘制调用推入一个桶中来实现,而不是为每个桶从每个实体中提取状态。
更具体地说,不应该这样做:
for each (bucket b)
{
for each (entity e)
{
submit_draw_call_to_bucket(b, get_draw_call(e, b));
}
}
而你应该这样做:
for each (entity e)
{
submit_draw_call_to_bucket(shadow_map, e);
submit_draw_call_to_bucket(g_buffer, e);
...
}
这可能看起来微不足道的一些吧,但我认为这是值得指出的。毕竟,还是有很多人通过遍历的矢量对象、为每个对象调用Render()方法来渲染GameObjects 的std::vector,调用每个对象的虚拟()方法。这样做没什么错,但你不能用这样的方法完成含有成百上千实体的游戏。
能够将绘制调用推入不同的桶中,确保了我们只触碰每个实体一次,而不是多次,如果你有许多实体的话,这将显著改进性能。
今天就到此为止。我希望下星期四有更多的东西可以分享,甚至还可以看一些代码!
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;