Link

city 教程代码库,处理了 125,000 个对象并在主视图和阴影视图中剔除,290 FPS。此视图由于 2 个网格通道而渲染超过 4000 万个三角形。在 RTX 2080 上渲染。

弃用警告

本章已过时,不适用于当前版本的 vkguide,但适用于旧版 Vkguide。其技术仍然可以应用于新 vkguide,但文本和代码是为旧版 vkguide 编写的。新版本的本章正在编写中,作为新 Vkguide 的第 7 章。

GPU 驱动渲染

在过去几年中,前沿渲染引擎越来越多地转向在 GPU 上的计算着色器中计算渲染本身。由于 MultiDrawIndirect 和类似功能的引入,现在可以在计算着色器内部完成大量渲染工作。好处是显而易见的

  • GPU 的性能比 CPU 在数据并行算法上高出几个数量级。渲染几乎都是数据并行算法。
  • 由于 GPU 决定自己的工作,因此最大限度地减少了延迟,因为无需从 CPU 到 GPU 再返回的往返过程。
  • 将 CPU 从大量工作中解放出来,现在可以用于其他事情。

结果是场景复杂性和对象数量提高了一个数量级或更多。在后续章节中我们将逐步介绍的代码中,基于第 5 章教程末尾的引擎,我们可以在 Nintendo Switch 上以超过 60 fps 的速度运行 250,000 个“绘制调用”。在 PC 上,它达到 500 fps。我们基本上拥有近乎无限的对象计数,瓶颈仅在于我们尝试让 GPU 绘制多少三角形。

perf CPU 处理时间少于 0.5 毫秒。与上面相同的视图。 Ryzen 1700

基于计算着色器渲染的技术在过去 5 年中变得越来越流行。在此之前,它们更多地用于 CAD 类型场景。著名的《刺客信条:大革命》及其续作使用这些技术实现了复杂程度更高的场景,巴黎拥有大量的物体,因为它还渲染了许多建筑物的内部。EA 的 Frostbite 引擎也使用这些技术在《龙腾世纪:审判》中实现非常高的几何细节。这些技术也是《彩虹六号:围攻》能够拥有数千个由其破坏系统创建的动态碎石对象的原因。《彩虹六号:围攻》的技术在 PS4 和 Xbox One 一代游戏机上变得非常流行,因为它们很容易受到三角形吞吐量的瓶颈限制,因此拥有非常精确的剔除功能可以带来非常大的性能提升。虚幻引擎 4 和 Unity 不使用这些技术,但虚幻引擎 5 看起来会使用它们。

间接绘制

这个想法的核心围绕着在图形 API 中使用间接绘制支持。这些技术适用于所有图形 API,但在 Vulkan 或 DX12 上效果最佳,因为它们可以更好地控制低级内存管理和计算屏障。它们在 PS4 和 Xbox One 游戏机上也效果很好,下一代游戏机具有更多倾向于它的功能,例如网格着色器和光线追踪。

间接绘制是一种绘制调用,它从 GPU 缓冲区而不是从调用本身获取其参数。当使用间接绘制时,您只需根据 gpu 缓冲区中的位置启动绘制,然后 GPU 将在该缓冲区中执行绘制命令。

伪代码


//normal drawing ---------------
vkCmdDrawIndexed(cmd, object.indexCount, 1 /* instance count */,object.firstIndex, object.vertexOffset, object.ID /* firstInstance */ );

//indirect drawing ---------------

Buffer* drawBuffer = create_buffer(sizeof(VkDrawIndexedIndirectCommand));

//we can immediately enqueue the draw indirect
vkCmdDrawIndexedIndirect(cmd, drawBuffer.buffer, 0 /* offset */, 1 /* drawCount */, sizeof(VkDrawIndexedIndirectCommand));


//we can write the actual draw command at any time we want before VkQueueSubmit(), or from a different thread, or from a compute shader
VkDrawIndexedIndirectCommand* command = map_buffer(drawBuffer);

command->indexCount = object.indexCount;
command->instanceCount = 1;
command->firstIndex = object.firstIndex ;
command->vertexOffset = object.vertexOffset;
command->firstInstance = object.ID;

因为它从缓冲区获取参数,所以可以使用计算着色器来写入这些缓冲区,并在计算着色器中进行剔除或 LOD 选择。以这种方式进行剔除是进行剔除的最简单和性能最高的方法之一。由于 GPU 的强大功能,您可以轻松期望在不到半毫秒的时间内剔除超过一百万个对象。正常场景往往不会走得那么远。在更高级的管线(如《龙腾世纪》或《彩虹六号》中的管线)中,它们更进一步,还从网格中剔除单个三角形。他们通过编写一个输出索引缓冲区来处理幸存的三角形,并使用间接绘制来绘制它。

当您设计 GPU 驱动的渲染器时,主要思想是所有场景都应该在 GPU 上。在第 4 章中,我们看到了如何将所有加载对象的矩阵存储到一个大的 SSBO 中。在 GPU 驱动的管线中,我们还希望存储更多数据,例如材质 ID 和剔除边界。一旦我们拥有一个所有内容都存储在大型 GPU 缓冲区中的渲染器,并且我们不使用每个对象的推送常量或描述符集,我们就准备好使用 GPU 驱动的渲染器了。本教程引擎的设计非常适合重构为极端性能的基于计算的引擎。

由于您希望尽可能多地将事物放在 GPU 上,因此如果您将此管线与“无绑定”技术结合使用,则此管线非常适用,在“无绑定”技术中,您不再需要为每种材质绑定描述符集或更改顶点缓冲区。在 Doom Eternal 引擎中,他们全力以赴地采用无绑定技术,并且该引擎最终每帧执行的绘制调用非常少。在本指南中,我们将不使用无绑定纹理,因为它们的支持有限,因此我们将为使用的每种材质执行 1 个间接绘制调用。我们仍然会将所有网格合并到一个大的顶点缓冲区中,以避免在绘制之间不断绑定它。拥有无绑定渲染器还可以使光线追踪更加高效和有效。

无绑定设计

perf NovusCore Wow 模拟研究项目。来自《魔兽世界》的整个大陆在不到 10 个绘制调用中以 100+ FPS 的速度渲染。无限绘制距离。

当绑定数量尽可能少时,GPU 驱动的管线效果最佳。最佳情况是执行极少量的 BindVertexBuffer、BindIndexBuffer、BindPipeline 和 BindDescriptorSet 调用。无绑定设计使 CPU 侧的工作速度更快,因为 CPU 必须做的工作少得多,并且 GPU 也可以更快地运行,因为更好的利用率,因为每个绘制调用都“更大”。您用于渲染场景的绘制调用越少越好,因为现代 GPU 非常大,并且具有很大的启动/停止时间。大型现代 GPU 喜欢您在每个绘制调用中给它们大量工作,因为这样它们就可以加速到 100% 的使用率。

要将顶点和索引缓冲区移动到无绑定,通常您可以通过将网格合并到非常大的缓冲区中来实现。您不必为每个顶点缓冲区和索引缓冲区对使用 1 个缓冲区,而是为场景中的所有顶点缓冲区使用 1 个缓冲区。渲染时,然后在绘制调用中使用 BaseVertex 偏移。

在某些引擎中,他们完全从管线中删除顶点属性,而是从顶点着色器中的缓冲区中获取顶点数据。这样做使得即使引擎中使用不同的顶点属性格式,也更容易为引擎中的所有绘制调用保留 1 个大型顶点缓冲区。它还允许一些高级解包/压缩技术,并且它是网格着色器的主要用例。

要将纹理移动到无绑定,您可以使用纹理数组。使用正确的扩展,纹理数组的大小在着色器中可以是无界的,就像您使用 SSBO 时一样。然后,当在着色器中访问纹理时,您可以通过索引访问它们,该索引从另一个缓冲区获取。如果您不使用描述符索引扩展,您仍然可以使用纹理数组,但它们将需要有界的大小。检查您的设备限制以查看可以有多大。

要使材质无绑定,您需要停止为每种材质使用 1 个管线。相反,您希望将材质参数移动到 SSBO 中,并采用 ubershader 方法。在 Doom 引擎中,它们在整个游戏中使用的管线数量非常少。《毁灭战士:永恒》的管线少于 500 个,而虚幻引擎游戏通常有 100,000 多个管线。如果您使用 ubershaders 大幅减少唯一管线的数量,您将能够以巨大的方式提高效率,因为 VkCmdBindPipeline 是在 vulkan 中绘制对象时最昂贵的调用之一。

可以使用推送常量和动态描述符,但它们必须是“全局”的。将推送常量用于相机位置等事物完全没问题,但您不能将它们用于对象 ID,因为那是每个对象的调用,并且您专门希望在 1 次绘制中绘制尽可能多的对象。

一般工作流程是将内容放入缓冲区,并拥有大型缓冲区,这样您就不需要每次调用都绑定它们。好处是您还可以从 GPU 写入这些缓冲区,这就是《龙腾世纪:审判》对索引缓冲区所做的事情,它从剔除着色器写入索引缓冲区,以便仅绘制可见的三角形。

Vkguide 引擎架构的计算渲染概述。

这里的技术可以直接在核心教程的 5 个章节之后实现。该引擎还实现了额外章节中的内容,但应该很容易理解。系统的细节将在后续章节中探讨。

第一件事是全力以赴地将对象数据放在 GPU 缓冲区中。每个对象的推送常量被删除,每个对象的动态统一缓冲区被删除,所有内容都替换为 ObjectBuffer,我们在其中存储对象矩阵,并从着色器中索引到它。

我们还改变了网格的工作方式。加载场景后,我们创建一个大的顶点缓冲区,并将整个地图的所有网格都塞进去。这样我们将避免重新绑定顶点缓冲区。

完成数据管理后,我们可以实现间接绘制本身。

我们将按网格通道拆分可渲染对象。每个网格通道将是渲染器中的特定通道。在本教程中,我们将有 2 个网格通道,一个用于前向渲染的网格,另一个用于阴影投射。某些对象将注册在一个通道中,但不会注册在另一个通道中,而大多数对象将同时注册在这两个通道中。通过将单个对象分隔到多个网格通道中,我们显着简化了渲染循环,并提高了性能。

管理间接绘制的代码在 RenderScene 类中,它将获取网格通道中的所有对象,并将它们排序到批次中。批次是一组匹配材质和网格的对象。每个批次将使用一个执行实例绘制的 DrawIndirect 调用进行渲染。每个网格通道(前向通道、阴影通道、其他通道)都包含一个批次数组,它将用于渲染。

在主 ObjectBuffer 中,我们存储对象矩阵以及每个已加载对象的裁剪边界。

在开始帧时,我们同步每个网格通道上的对象到一个缓冲区。此缓冲区将是 ObjectID + BatchID 的数组。BatchID 直接映射为网格通道的批次数组的索引。

一旦我们上传并同步了该缓冲区,我们就执行计算着色器,该着色器执行剔除。对于 ObjectID + BatchID 对的数组中的每个对象,我们使用 ObjectID 访问 ObjectBuffer 中的对象数据,并检查它是否可见。如果它可见,我们使用 BatchID 索引将绘制插入到批次数组中,批次数组包含间接绘制调用,从而增加实例计数。我们还将它写入间接缓冲区,该缓冲区从每个批次的实例 ID 映射到 ObjectID。

完成之后,在 CPU 侧,我们遍历网格通道中的批次,并按顺序执行每个批次,确保绑定每个批次管线和材质描述符集。然后,gpu 将使用它刚刚从剔除通道写入的参数来渲染对象。