【GAD翻译馆】无状态的、分层的多线程渲染(二):无状态API设计

发表于2017-10-16
评论0 317浏览

想免费获取内部独家PPT资料库?观看行业大牛直播?点击加入腾讯游戏学院游戏程序行业精英群

711501594

翻译:赵菁菁(轩语轩缘)审校:李笑达(DDBC4747


从我们上次结束的地方继续,今天我想介绍一些想法,关于如何设计能让我们进行无状态渲染的API

在讨论如何设计无状态渲染API之前,让我们先来看看有状态渲染API通常如何执行它的任务。

传统的、有状态的渲染

这是每个人都知道的一种渲染:你在这里设置了几个状态,提交了一个绘制调用,设置了一些状态,提交了一个绘制调用,等等。

通常,这看上去有点像下面的步骤:

1
2
3
4
5
6
7
8
9
10
11
12
// 1) 渲染第一个对象
backend::SetCullState(CULLSTATE_BACK);
backend::SetVertexBuffer(vb);
backend::SetIndexBuffer(ib);
backend::BindTexture(0u, diffuse);
backend::DrawIndexed(triCount*3, 0u, 0u);
  
// 2) 渲染第二个对象
backend::SetCullState(CULLSTATE_FRONT);
backend::BindTexture(0u, otherDiffuse);
backend::SetAlphaBlendState(ONE_ONE);
backend::DrawIndexed(triCount*3, 0u, 0u);

      这种抽象的问题在于,在渲染第一个对象时,无论设置了什么状态,都会影响第二个对象的渲染,这又会影响到第三个对象的渲染,等等。如果不明显的话,管道中设置的状态泄漏到后续的调用中,实际上上面的有状态抽象有两个问题(不止一个!):

      第一个问题:比如说在绘制第三个对象时,它会用一个逆转的剔除状态渲染,以防我们忘了把它设置回CULLSTATE_BACK。与alpha混合相同。这是这两个问题中较小的一个。

第二个问题:每当你需要改变一些绘制调用状态时,所有没有碰到同样的状态的、紧随其后的绘制调用都将被破坏。这比第一个问题要糟糕得多,因为你必须更改所有调用,然而你实际上并不想触碰这些调用,或者你总是要在提交调用后将所有触碰到的状态设置为默认值。这既容易出错,又乏味。

我们还没有开始谈论多线程渲染。

我在第二点上详细阐述一下,如果我们将上面的代码更改为以下内容,会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1) 渲染第一个对象
backend::SetCullState(CULLSTATE_BACK);
backend::SetVertexBuffer(vb);
backend::SetIndexBuffer(ib);
backend::SetRasterizerState(NO_DEPTH_WRITE); // <===
backend::BindTexture(0u, diffuse);
backend::DrawIndexed(triCount*3, 0u, 0u);
  
// 2) 渲染第二个对象
backend::SetCullState(CULLSTATE_FRONT);
backend::BindTexture(0u, otherDiffuse);
backend::SetAlphaBlendState(ONE_ONE);
backend::DrawIndexed(triCount*3, 0u, 0u);

通过引入一个新的命令SetRasterizerState 改变管道的状态,紧接着第一个的绘制调用也受我们的改变影响,因为其他的绘制调用从不触碰那状态。我们要在第二个绘制调用中显式设置,或在提交第一个DrawIndexed之后重置。当你想将某些渲染操作从这里移动到那里时,把它们放在函数中是非常糟糕的,因为你总是要意识到环绕状态。就像我说的,容易出错和乏味。


引入一个无状态的API

了解了上述有状态方法的明显错误,我们就可以想出更好的解决方案了。一种可能的解决方案是从每个帧的干净默认状态开始,每当我们提交一个绘制调用时,就把所有的状态重置到它们的默认状态。如果用户自己这样做,这可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 在帧的开始部分,所有的状态都被设为其默认值
// 1) 渲染第一个对象
backend::SetCullState(CULLSTATE_BACK);
backend::SetVertexBuffer(vb);
backend::SetIndexBuffer(ib);
backend::BindTexture(0u, diffuse);
backend::DrawIndexed(triCount*3, 0u, 0u);
backend::ResetDefault(); // <===
  
// 2) 渲染第二个对象
backend::SetCullState(CULLSTATE_FRONT);
backend::BindTexture(0u, otherDiffuse);
backend::SetAlphaBlendState(ONE_ONE);
backend::DrawIndexed(triCount*3, 0u, 0u);
backend::ResetDefault(); // <===

当然,我们也可以把该功能放到我们的API里,让它管理。

现在,让我们假设我们有一个大的渲染队列,用于在一个帧中排队所有的绘制调用,然后在帧末尾用渲染后端进行排序和发送。然后我们可以做以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1) 渲染第一个对象
renderQueue::SetCullState(CULLSTATE_BACK);
renderQueue::SetVertexBuffer(vb);
renderQueue::SetIndexBuffer(ib);
renderQueue::BindTexture(0u, diffuse);
renderQueue::SubmitIndexed(triCount*3, 0u, 0u);
  
// 2) 渲染第二个对象
renderQueue::SetCullState(CULLSTATE_FRONT);
renderQueue::BindTexture(0u, otherDiffuse);
renderQueue::SetAlphaBlendState(ONE_ONE);
renderQueue::SubmitIndexed(triCount*3, 0u, 0u);
  
//在帧的末尾
renderQueue::Sort();
renderQueue::Flush();

基本上,我们所有的renderQueue实现所要做的就是以下:

跟踪当前设置的顶点缓冲、索引缓冲区、剔除状态、α波状态、纹理采样器等等。每当有人调用renderQueue::Set*State()时,只需把相应的成员变更到新的状态。

为每个Submit*()调用,向队列插入一个新的绘制调用。在这种情况下,我们的队列是原始内存,我们只需存储操作的类型(并索引调用)、键(用于排序),以及所有与数据相关的绘制调用(在本例中为所有当前状态)。之后,我们将所有的内部状态成员设置为其默认值。

调用Sort(),我们只是简单地排序所有的键,如基数排序。

调用Flush(),我们遍历操作的排序数组,取出类型,取出数据,并调用相应的渲染后端功能。这与实现一个简单的虚拟机非常相似。

当然,有许多实现细节我们还没有讨论过,但这基本上是它的要点。然而,有一件事我真的不喜欢这种方法,只要多线程渲染进入图片。

当然,有许多实现细节我们还没有讨论过,但这基本上是它的要点。然而,我真的不喜欢这种方法的一点:就是当多线程渲染进入图片时。

有了多线程渲染,我们希望能够从任何线程调用任何renderQueue 函数,这意味着:尽管C 代码看起来像顺序代码,不同的线程调用各种renderQueue::Set*()函数,并因此交错。我们可以不再在renderQueue实现使用简单的成员来追踪目前的状态,我们甚至不需要用互斥(或类似的)包围每个函数,也可以有效,因为我们需要马上把所有属于一个绘制调用的操作包围起来。这种方式开销太大,我不太想这样做。

      当然还有一种更简单,更快的办法:线程本地存储。不是利用renderQueue 中的简单成员追踪当前设置的状态,每个线程都会跟踪其状态,例如使用保有所有状态的线程本地结构。

      但是,我仍然不满意这样的做法,因为这意味着每个 renderQueue 函数调用都必须访问一些线程本地变量,相比内存访问,这增加了开销。因此,我也在考虑以下方案。

选项一

第一个方法归结为创建能够保存所有堆栈上绘制调用状态的结构,然后在提交一个绘制调用的时候把所有内容复制进入队列,如下:

1
2
3
4
5
IndexedDrawCall dc;
dc.SetVertexBuffer(mesh->vertexBuffer);
dc.SetIndexBuffer(mesh->indexBuffer);
dc.SetCullState(CULLSTATE_BACK);
renderQueue::Submit(dc);

      首先,这几乎消除了我们在上面的方法中看到的所有多线程问题。如果我们想使用一个全局队列,我们要做的就是复制在调用 enderQueue::Submit()中给定的数据(还有排序的键)。为此,我们可以只使用一个线性分配器,它只会为每个分配增加指针。通过原子操作,我们可以很容易地使线程分配安全且迅速。如果我们不想使用原子操作,我们可以在每个线程中使用线程本地队列。

      其次,这让我们能够缓存一定绘制调用。在世界的某些静态部分中,我们可以建立一次绘制调用,把它存储在某个地方,并将它提交到renderQueue ,没有任何额外的工作。

      第三,每个像 IndexedDrawCallInstancedDrawCallComputeDrawCall等这样的绘制调用,可以确保只存储需要的数据,这样可以减少需要存储每个绘制调用的内存量需求。

      但是,关于这种做法我有两点不喜欢:

      绘制调用结构的每个实例都是有状态的,意思是用户可以在堆栈上创建一个绘制调用,提交一次,改变它的状态,然后再提交它。当然,这取决于用户,不推荐这种做法,但在这方面,可以这么说:我们回到了原点。

      我们比我们需要的更经常访问内存,因为我们首先改变堆栈上的结构状态,然后根据renderQueue::Submit()把数据复制到哪里,把所有数据复制到内存的某处。

      我的、最后的、目前的优先选择如下:

选择二

      不要在堆栈上创建绘制调用结构,你需要让renderQueue为你提供一个:

1
2
3
4
5
IndexedDrawCall* dc = renderQueue::CreateIndexedDrawCall();
dc->SetVertexBuffer(mesh->vertexBuffer);
dc->SetIndexBuffer(mesh->indexBuffer);
dc->SetCullState(CULLSTATE_BACK);
renderQueue::Submit(dc);

看起来区别不大,但是这里我们可以做几件事:

创建一个新的绘制调用时(例如使用 CreateIndexedDrawCall()),我们还可以选择使用一个全局队列和原子操作用于分配内存,或使用线程本地队列。我更喜欢后者(更多信息就在下一篇文章中详述),但重点是创建这样一个绘制调用,本质上只是在内部增加一个指针,将所有绘制调用数据的最终目的地交给用户。这意味着我们不再在堆栈上操作一个结构、之后复制该结构,而是直接写入内存中。然后,调用Submit()只需要存储键和一个指向数据存储位置的指针。

因为我们已经可以控制如何创建调用了,我们就可以很容易地确保用户不能提交两次绘制调用。我们能做的,例如检查作为renderQueue::Submit()参数的指针, 如果地址是小于等于最后提交绘制调用的地址,就是用户试图提交相同的绘制调用两次——该操作是无效的,因为这意味着无状态地使用了一个绘制调用。


总结    

  可以看出,我们有几种实现无状态API的方法。在设计这样一个API时,我认为记住以下两点很重要:像多线程渲染以及如何为处理的绘制调用数据分配内存。

      请注意,我只是简单地触及多线程渲染。还有更多的事情要考虑,像伪共享,分配是如何进行的,以及何时和如何将数据写入内存。在设计API时我会考虑这些事情,但是我(还)没有时间写下我所有的想法和观点——这篇帖子已经很长了。

      还要注意,我们还没有讨论如何生成数据排序的键,也没有讨论第一篇文章中介绍过的如何根据各个层分组绘制调用。这将是下一篇文章的主题!


免责声明

      我还没没有实现上述任何内容,所以请大家持保留意见来看待。它肯定不是最终的设计,因为这些东西通常需要几个迭代过程,直到你想出一些你真正满意的东西。

  如果我有任何疏忽或错误,请联系我,大家可以随意讨论其他内容,可能我会在评论中发现更好的方法!


【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

游戏学院公众号二维码
腾讯游戏学院
微信公众号

提供更专业的游戏知识学习平台