【GAD翻译馆】无状态的、分层的多线程渲染(三):API设计细节
翻译:赵菁菁(轩语轩缘)审校:李笑达(DDBC4747)
在本系列的前一部分中,我已经谈到了如何设计无状态的渲染API,但遗漏了一些细节。这一次,我将介绍这些细节以及在此期间发现的一些问题,还会展示当前实现的一些部分。
指令桶
在本系列的第一部分,我介绍了一种想法,不是所有的层都需要存储大小相同的键,例如:把物体渲染进入阴影图层可能只需要一个16位的键,而G缓冲的渲染层需要64位的键。
在分子说法中,负责存储绘制调用的东西(及其相关的键和数据)被称为指令桶(CommandBucket),这是一个以键类型作为模板参数的轻量级的类模板:
1 2 3 4 5 6 | template <typename t= "" > class CommandBucket { typedef T Key; ... }; |
渲染器将创建所有指令桶渲染场景所需的内容,例如把指令桶作为成员存储,在Render() 方法中用绘制调用填充桶。或者你可以创建&销毁指令桶,完全是数据驱动的、可配置的。
但是指令桶需要存储什么?我们如何存储?
l N个键,用于排序N个绘制调用
l N个绘制调用的数据
注意,每次绘制调用所存储的数据的数量和类型在很大程度上取决于绘制调用的类型。因此,绘制调用所需要的所有数据存储在一个单独的存储区域,指令桶只存储一个指向该区的指针:
1 2 3 4 5 6 7 8 9 10 | template <typename t= "" > class CommandBucket { typedef T Key; ... private : Key* m_keys; void ** m_data; }; |
请注意,键和数据分别存储在两个单独的数组中。这样做的原因是:某些排序算法在排序操作期间不交换,而是将一个单独的数组填充到排序的条目中,因此它们必须触碰较少的数据。
创建一个指令桶时,它负责分配键和数据指针的数组,并存储所有在提交桶中存储指令时要用到的渲染目标、视图矩阵、投影矩阵和视口。这背后的原理是,你很可能使用同一个摄像机和视口渲染到某个层,所以在每次调用中指定该信息是没有意义的。此外,这意味着,在创建如下的桶时,每一个指令桶只能容纳一定数量的绘制调用,如下面的例子:
1 | CommandBucket<uint64_t> gBufferBucket(2048, rt1, rt2, rt3, rt4, dst, viewMatrix, projMatrix); |
当然,视图和投影矩阵很可能是由摄像机对象或某种摄像机系统提供的,但这是一个完全不同的话题。
指令
现在我们有了存储绘制调用的桶,如何将指令添加到桶中呢?指令到底是什么?
指令是一条独立的信息,由渲染后端解析,并存储在指令桶中。一条指令可以识别任意一种绘制调用(非索引的、索引的、实例的……),或任何其他的操作,如把数据复制到一个常量缓冲区中。
每条指令都是一个简单的POD,它包含后端所需的所有数据,以便执行与某条指令相关联的操作。以下三个结构体都是简单指令的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | namespace commands { struct Draw { uint32_t vertexCount; uint32_t startVertex; VertexLayoutHandle vertexLayoutHandle; VertexBufferHandle vertexBuffer; IndexBufferHandle indexBuffer; }; struct DrawIndexed { uint32_t indexCount; uint32_t startIndex; uint32_t baseVertex; VertexLayoutHandle vertexLayoutHandle; VertexBufferHandle vertexBuffer; IndexBufferHandle indexBuffer; }; struct CopyConstantBufferData { ConstantBufferHandle constantBuffer; void * data; uint32_t size; }; } |
因为每条指令都是一个单独的POD,所以我们可以添加一个为存储指令取出键、分配空间的方法,将指向数据的指针存储到内部数组中,并将POD实例交给用户:
1 2 3 4 5 6 7 8 9 10 11 | template <typename u= "" > U* CommandBucket::AddCommand(Key key) { U* data = AllocateCommand<u>(); //存储键和指向数据的指针 AddKey(key); AddData(data); return data; } |
这仍然非常简单,但也有一些我们还没有讨论过的内容,例如在访问数组时添加同步,以及如何为指令分配内存。我们稍后会重新讨论这个问题,因为现在我们需要先讨论一些更重要的事情。
现在,假设我们有一个指令桶,我们按如下方式填充:
1 2 3 4 5 6 7 8 9 10 11 12 | for (size_t i=0; i < meshComponents.size(); i) { MeshComponent* mesh = &meshComponents[i]; commands::DrawIndexed* dc = gBuffer.AddCommand<commands::drawindexed>(GenerateKey(mesh->aabb, mesh->material)); dc->vertexLayoutHandle = mesh->vertexLayout; dc->vertexBuffer = mesh->vertexBuffer; dc->indexBuffer = mesh->indexBuffer; dc->indexCount = mesh->indexCount; dc->startIndex = 0u; dc->baseVertex = 0u; } |
与上一篇文章中提出的两种可选方案相比,注意:在创建绘制调用并插入到桶中之后,不需要调用另一个方法。在调用AddCommand()之后,指令完全属于你,你只需填充其所有成员。这些就够了。所有的存储操作都会直接写入一个连续的内存块中,而不需要任何额外的复制操作——但稍后会有更多的操作。
负责绘制调用的、用于G缓冲的指令桶现在为每个网格组件都保有一个索引的绘制调用。在所有的桶被填充之后,我们可以按它们的键排序:
1 2 3 4 5 | gBufferBucket.Sort(); lightingBucket.Sort(); deferredBucket.Sort(); postProcessingBucket.Sort(); hudBucket.Sort(); |
为了对桶中的指令进行排序,我们可以使用我们想要的排序算法。这里需要注意的是,每个CommandBucket::Sort() 可以运行在不同的线程上,并行对所有桶进行排序。
在所有桶排完序后,我们可以把结果提交到渲染后端:
1 2 3 4 5 | gBufferBucket.Submit(); lightingBucket.Submit(); deferredBucket.Submit(); postProcessingBucket.Submit(); hudBucket.Submit(); |
提交过程必须由一个线程完成,因为它不断地与图形API(D3D,OGL)交互,把任务提交给GPU。不管它是主线程还是专用的渲染线程,都只能是一个线程。
提交过程
但我们如何将指令提交给图形API?我们有的是一个键的和一个指向相关数据的指针。这显然不够,因此我们需要为每个指令添加某种附加标识符。
实现的方法是为每个指令添加一个标识符(例如一个枚举值),把标识符和键、数据一起存储,然后执行类似下面代码段的Submit()方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | void Submit( void ) { SetViewMatrix(); SetProjectionMatrix(); SetRenderTargets(); for (unsigned int i=0; i < commandCount; i) { Key key = m_keys[i]; void * data = m_data[i]; uint16_t id = m_ids[i]; //如果材质变化了,解码键,设置着色器、纹理、常量等 DecodeKey(); switch (id) { case command::Draw::ID: //从Draw指令中抽出数据,调用后端 break ; case command::DrawIndexed::ID: // 为DrawIndexed 指令抽出数据,调用后端 break ; ...; } } |
这是一个可能的解决方案,但是我不会推荐它。为什么?
首先,我们做同样的事情两次。第一次,我们在把指令存到桶中时确定了指令(例如:通过把U::ID 存储到我们的ID数组m_ids中),然后在庞大的switch语句中又确定了一次。
第二,硬编码的switch语句,使得添加新的指令困难且繁琐,无法如果我们没有访问源代码,添加自定义指令。
有一个更好更简单的解决方案:函数指针。
后台调度
我们可以直接存储一个指向函数的指针,该指针知道如何处理某个命令,并将其转发到后端,而不是将命令存储在桶中。这就是被称为分子的后台调度。
后端调度是一个仅由简单转发函数组成的命名空间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | namespace backendDispatch { void Draw( const void * data) { const commands::Draw* realData = union_cast< const commands::draw*= "" >(data); backend::Draw(realData->vertexCount, realData->startVertex); } void DrawIndexed( const void * data) { const commands::DrawIndexed* realData = union_cast< const commands::drawindexed*= "" >(data); backend::DrawIndexed(realData->indexCount, realData->startIndex, realData->baseVertex); } void CopyConstantBufferData( const void * data) { const commands::CopyConstantBufferData* realData = union_cast< const commands::copyconstantbufferdata*= "" >(data); backend::CopyConstantBufferData(realData->constantBuffer, realData->data, realData->size); } } |
在后台调度中每个函数都具有相同的签名,因此我们可以使用typedef来存储一个指向那些函数的指针:
1 | typedef void (*BackendDispatchFunction)( const void *); |
包含在后端命名空间的函数依然直接与图形API交互,例如通过使用D3D设备。
让我们回头审视的CommandBucket 及其AddCommand()方法。除了指令,我们现在也需要存储一个指向调度函数的指针。事实上,除了上面提到的,我们还需要存储两个我们还没有讨论过的东西:
第一个是指向任何需要与此指令同时提交并具有相同键的其他指令的指针。如果我们将指针存储到另一个指令中,我们就构建了一个介入式链表,它允许我们处理调用和指令,这些调用和指令总是必须按一定的顺序提交,不管分配给它们什么键。在注释中,这个问题不止一次出现,在提交调用时,我们首先需要将数据复制到一个常量缓冲区,然后提交调用。介入式链表允许我们将任意数量的指令链在一起。
第二个是某些命令需要辅助内存来存储在稍后向API提交绘制调用时所需的中间数据。最完美的例子就是更新带有几个字节数据的常量缓冲区,例如照明信息。这些字节隐藏在辅助内存中,并在指令提交时从中复制到常量缓冲区。
指令包
因为我们不再只是在桶中存储单个指令,我们引入了指令包的概念。一个桶现在存储指令包,每个包保存以下数据:
void* : 一个指向下一个指令包(如果有的话)的指针
BackendDispatchFunction:指向负责调度调用后台函数的指针
T:实际指令
char[]:指令所需的辅助存储器(可选)
每当用户想将一个T类型的指令添加到桶中时,我们也需要为其他内容留出空间。对此,我使用适当的sizeof()操作符,只是分配足以容纳所有数据的原始内存,将每个部分转换成它们想要的类型。为了实现,几个static_asserts确保所有的指令都是POD结构。
最后,一个帮助命名空间负责处理所有的偏移计算和转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | typedef void * CommandPacket; namespace commandPacket { static const size_t OFFSET_NEXT_COMMAND_PACKET = 0u; static const size_t OFFSET_BACKEND_DISPATCH_FUNCTION = OFFSET_NEXT_COMMAND_PACKET sizeof (CommandPacket); static const size_t OFFSET_COMMAND = OFFSET_BACKEND_DISPATCH_FUNCTION sizeof (BackendDispatchFunction); template <typename t= "" > CommandPacket Create(size_t auxMemorySize) { return :: operator new (GetSize<t>(auxMemorySize)); } template <typename t= "" > size_t GetSize(size_t auxMemorySize) { return OFFSET_COMMAND sizeof (T) auxMemorySize; }; CommandPacket* GetNextCommandPacket(CommandPacket packet) { return union_cast<commandpacket*>(reinterpret_cast< char *>(packet) OFFSET_NEXT_COMMAND_PACKET); } template <typename t= "" > CommandPacket* GetNextCommandPacket(T* command) { return union_cast<commandpacket*>(reinterpret_cast< char *>(command) - OFFSET_COMMAND OFFSET_NEXT_COMMAND_PACKET); } BackendDispatchFunction* GetBackendDispatchFunction(CommandPacket packet) { return union_cast<backenddispatchfunction*>(reinterpret_cast< char *>(packet) OFFSET_BACKEND_DISPATCH_FUNCTION); } template <typename t= "" > T* GetCommand(CommandPacket packet) { return union_cast<t*>(reinterpret_cast< char *>(packet) OFFSET_COMMAND); } template <typename t= "" > char * GetAuxiliaryMemory(T* command) { return reinterpret_cast< char *>(command) sizeof (T); } void StoreNextCommandPacket(CommandPacket packet, CommandPacket nextPacket) { *commandPacket::GetNextCommandPacket(packet) = nextPacket; } template <typename t= "" > void StoreNextCommandPacket(T* command, CommandPacket nextPacket) { *commandPacket::GetNextCommandPacket<t>(command) = nextPacket; } void StoreBackendDispatchFunction(CommandPacket packet, BackendDispatchFunction dispatchFunction) { *commandPacket::GetBackendDispatchFunction(packet) = dispatchFunction; } const CommandPacket LoadNextCommandPacket( const CommandPacket packet) { return *GetNextCommandPacket(packet); } const BackendDispatchFunction LoadBackendDispatchFunction( const CommandPacket packet) { return *GetBackendDispatchFunction(packet); } const void * LoadCommand( const CommandPacket packet) { return reinterpret_cast< char *>(packet) OFFSET_COMMAND; } }; |
注意,Create() 使用全局操作符new来分配原始内存。在实际的实现中,我们将使用我们自己的线性分配,确保所有的指令都是在内存中连续存储的,当我们需要遍历Submit() 方法中 指令时,这是更为缓存友好的。
重新访问指令桶
使用指令包,将指令添加到桶中的实际代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | template <typename u= "" > U* AddCommand(Key key, size_t auxMemorySize) { CommandPacket packet = commandPacket::Create<u>(auxMemorySize); //存储键和指向数据的指针 { // TODO:在这里加某种锁或原子操作 const unsigned int current = m_current ; m_keys[current] = key; m_packets[current] = packet; } commandPacket::StoreNextCommandPacket(packet, nullptr); commandPacket::StoreBackendDispatchFunction(packet, U::DISPATCH_FUNCTION); return commandPacket::GetCommand<u>(packet); } |
一旦我们处理了上面所标记的待办事项,我们也可以开始从任意数量的线程中添加命令。作为第一个实现,我们可以简单地添加一个关键部分来使代码工作,但显然有更好的解决方案,这是我想在本系列的下一篇文章中写的内容。
当然,现在每个指令还需要存储一个指向后端调度的指针,示例用于绘制命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct Draw { static const BackendDispatchFunction DISPATCH_FUNCTION; uint32_t vertexCount; uint32_t startVertex; VertexLayoutHandle vertexLayoutHandle; VertexBufferHandle vertexBuffer; IndexBufferHandle indexBuffer; }; static_assert(std::is_pod<draw>::value == true , "Draw must be a POD." ); const BackendDispatchFunction Draw::DISPATCH_FUNCTION = &backendDispatch::Draw; |
自定义指令
如前所述,使用函数指针这种方式使我们能够支持用户定义的指令。例如,你可以通过定义一个POD来构建自己的指令,为它实现一个完全自定义的调度函数,并将该指令添加到任何桶中,甚至将其链接到其他指令。
链接指令
现在我们的命令包还存储了指向下一个指令包的指针,我们可以将指令附加到其他指令上:
1 2 3 4 5 6 7 8 9 10 11 12 13 | template <typename u,= "" typename= "" v= "" > U* AppendCommand(V* command, size_t auxMemorySize) { CommandPacket packet = commandPacket::Create<u>(auxMemorySize); //把该指令附加到给定的指令上 commandPacket::StoreNextCommandPacket<v>(command, packet); commandPacket::StoreNextCommandPacket(packet, nullptr); commandPacket::StoreBackendDispatchFunction(packet, U::DISPATCH_FUNCTION); return commandPacket::GetCommand<u>(packet); } |
注意,在这种情况下,我们不需要在数组中存储一个新的键/值对,因为附加到另一个命令的每个命令都需要相同的键。
下面的示例演示如何使用新的指令桶API将指令链接在一起:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | for (unsigned int i=0; i < directionalLights.size(); i) { PerDirectionalLightConstants constants = { directionalLights[i].diffuse, directionalLights[i].specular }; commands::CopyConstantBufferData* copyOperation =lightingBucket.AddCommand<commands::copyconstantbufferdata>(someKey, sizeof (PerDirectionalLightConstants)); copyOperation->data = commandPacket::GetAuxiliaryMemory(copyOperation); copyOperation->constantBuffer = directionalLightsCB; memcpy(copyOperation->data, &constants, sizeof (PerDirectionalLightConstants)); copyOperation->size = sizeof (PerDirectionalLightConstants); commands::Draw* dc = lightingBucket.AppendCommand<commands::draw>(copyOperation, 0u); dc->vertexCount = 3u; dc->startVertex = 0u; } |
重新访问提交过程
当然,有了指令包,该Submit() 方法还需要适应。通过使用后端调度,我们可以摆脱switch语句,并且可以使用简单的循环来运行指令的链接列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | void Submit( void ) { //与之前一样 for (unsigned int i=0; i < m_current; i) { //与之前一样 CommandPacket packet = m_packets[i]; do { SubmitPacket(packet); packet = commandPacket::LoadNextCommandPacket(packet); } while (packet != nullptr); } } void SubmitPacket( const CommandPacket packet) { const BackendDispatchFunction function = commandPacket::LoadBackendDispatchFunction(packet); const void * command = commandPacket::LoadCommand(packet); function(command); } |
扼要重述
这篇文章很长,比我预期的更长。但是,让我们回顾一下,本文介绍概念:
指令:交给后台调度的独立信息。每条指令类似于一个简单的操作,如索引的绘制调用,将数据复制到常量缓冲区等。每条指令都是作为POD结构体实现的。
后端调度:从指令中提取数据的简单转发函数,并将其转发到图形后端。每个调度函数处理不同的指令。
指令包:一个指令包存储一个指令,另外还有数据,比如一个指向调度函数的指针,指令可能需要的任何辅助内存,以及一个用于链接命令的介入式链表。
指令链:需要按一定顺序提交的指令可以链接在一起。
指令桶:指令桶存储指令包,以及任意大小的键。
多线程渲染:指令可以从多个线程并行添加到桶中。同步的惟一两点是内存分配,和将键值对存储到指令包数组中。
多线程排序:每个指令桶可以独立、并行地排序。
尽管这已经是本系列的第三部分了,但仍有一些细节我们还没有详细讨论:
内存管理:我们如何分配内存来存储数据包的键和指针?在多个线程中向同一个桶中添加指令的情况下,我们如何有效地为单个指令包分配内存?在整个存储和提交指令包的过程中,我们如何确保良好的缓存利用率?我们能使用一个连续的内存块吗?
键生成:键保存了哪些信息?我们如何有效地构建一个键?
所以现在,我们的渲染过程是无状态的、分层的/分桶的,但其多线程渲染能力仍然可以大大提高。直到下一次!
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;