现代图形 API ,如 Direct3D 12 和 Vulkan ,旨在提供对 GPU 的较低级别访问,并消除与 API 转换相关的 GPU 驱动程序开销。此低级接口允许应用程序对系统进行更多控制,并提供以最适合每个应用程序的方式管理管道、着色器编译、内存分配和资源描述符的能力。
另一方面,这更接近于对 GPU 的硬件访问,这意味着应用程序必须自己管理这些东西,而不是依赖 GPU 驱动程序。使用这些 API 绘制单个三角形的基本“ hello world ”程序可以扩展到 1000 行或更多代码。在复杂的渲染器中,如果不系统地管理 GPU 内存、描述符等,可能会很快变得难以控制。
如果应用程序或引擎必须使用多个图形 API ,可以通过两种方式完成:
复制渲染代码以分别使用每个 API 。这种方法有一个明显的缺点,就是必须开发和维护多个独立的实现。
在图形 API 上实现一个抽象层,在公共接口中提供必要的功能。这在开发和维护抽象层方面有一个不同的缺点。大多数主要的游戏引擎都实现了第二种方法。
NVIDIA 渲染硬件接口( NVRHI )是一个处理这些缺点的库。它定义了一个定制的、更高级的图形 API ,可以很好地映射到三个受支持的本机图形 API : Vulkan 、 D3D12 和 D3D11 。它以安全、自动的方式管理资源、管道、描述符和屏障,必要时可以轻松禁用或绕过这些资源,以减少 CPU 开销。除此之外, NVRHI 还提供了一个验证层,以确保应用程序正确使用 API ,类似于 Direct3D 调试运行时或 Vulkan 验证层的功能,但在更高的级别上。
NVRHI 没有提供一些与便携性相关的功能。首先,它不会在运行时编译着色器或读取着色器反射数据以动态绑定资源。事实上, NVRHI 根本不在运行时处理着色器。该应用程序提供特定于平台的着色器二进制文件,即 DXBC 、 DXIL 或 SPIR-V blob 。 NVRHI 将其直接传递给底层图形 API 。匹配绑定布局由应用程序决定,并由底层图形 API 验证。其次, NVRHI 不创建图形设备或窗口。这也取决于应用程序或其他库,如GLFW。
在本文中,我将介绍 NVRHI 的主要功能,并解释每个功能如何帮助图形工程师提高工作效率和编写更安全的代码。
资源生命周期管理
绑定布局和绑定集
自动资源状态跟踪
上传管理
与图形 API 的交互
着色器置换
资源生命周期管理
在 Vulkan 和 D3D12 中,应用程序必须注意仅销毁 GPU 不再使用的设备资源。如果仔细规划资源使用情况,这可以用很少的开销完成,但问题在于规划。
NVRHI 几乎完全遵循 D3D11 资源生命周期模型。资源(如缓冲区、纹理或管道)具有引用计数。复制资源句柄时,引用计数将递增。当句柄被销毁时,引用计数将递减。当最后一个句柄被销毁并且引用计数达到零时,资源对象被销毁,包括底层图形 API 资源。但 D3D12 也是这么做的,对吗?不完全是。
NVRHI 还保留对命令列表中使用的资源的内部引用。打开命令列表进行录制时,将创建命令列表的新实例。该实例保存对其使用的每个资源的引用。当命令列表关闭并提交以供执行时,实例与围栏或信号量值一起存储在队列中,可用于确定实例是否已在 GPU 上完成执行。之后可以立即重新打开相同的命令列表进行录制,即使之前的实例仍在 GPU 上执行。
应用程序应该偶尔调用nvrhi::IDevice::runGarbageCollection方法,每帧至少调用一次。此方法查看正在运行的命令列表实例队列,并清除已完成执行的实例。清除实例会自动删除对实例中使用的资源的内部引用。如果一个资源没有剩下其他引用,它将在那个时候被销毁。
此行为可通过以下代码示例显示:
// Creates an internal instance of the command list commandList->open(); // Adds a buffer reference to the instance, which increases reference count to 2 commandList->clearBufferUInt(buffer, 0); commandList->close(); // The local reference to the buffer is released here, decrements reference count to 1 } // Puts the command list instance into the queue device->executeCommandList(commandList); // Likely doesn't do anything with the instance // because it's just been submitted and still executing on the GPU device->runGarbageCollection(); device->waitForIdle(); // This time, the buffer should be destroyed because // waitForIdle ensures that all command list instances // have finished executing, so when the finished instance // is cleared, the buffer reference count is decremented to zero // and it can be safely destroyed device->runGarbageCollection();
与 D3D12 和 Vulkan 不同,在 NVRHI 中,当应用程序创建资源、使用资源并立即释放资源时,此处显示的“触发并忘记”模式非常好。
如果应用程序执行多个draw调用,并且为每个draw调用绑定了大量资源,那么这种类型的资源跟踪是否会变得昂贵。不是真的。Draw调用和分派不处理单个资源。纹理和缓冲区被分组为不可变的绑定集,这些绑定集被创建,保存对其资源的永久引用,并作为单个对象进行跟踪。
因此,当在命令列表中使用某个绑定集时,命令列表实例仅存储对该绑定集的引用。如果绑定集已绑定,则跳过该存储,以便使用相同绑定重复调用 draw 不会增加跟踪成本。我将在下一节更详细地解释绑定集。
另一个有助于减少资源生存期跟踪带来的 CPU 开销的方法是绑定集和加速结构上的trackLiveness设置。当此参数设置为false时,不会为该特定资源创建内部引用。在这种情况下,应用程序负责保留自己的引用,而不是在资源使用时释放它。
绑定布局和绑定集
NVRHI 具有独特的资源绑定模型,旨在实现安全性和运行效率。如前所述,图形或计算管道使用的各种资源被分组到绑定集中。
简言之,绑定集是绑定到管道中特定插槽的资源视图数组。例如,绑定集可能包含绑定到插槽t1的结构化缓冲区 SRV 、绑定到插槽u0的单个纹理 mip 级别的 UAV 以及绑定到插槽b2的常量缓冲区。集合中的所有绑定共享相同的可见性遮罩(着色器阶段将看到该绑定)和寄存器空间,两者都由绑定布局指定。
绑定布局是 D3D12 根签名和 Vulkan 描述符集布局的 NVRHI 版本。绑定布局类似于绑定集的模板。它声明哪些资源类型绑定到哪些插槽,但不说明使用了哪些特定资源。
与根签名和描述符集布局一样, NVHRI 绑定布局用于创建管道。可以使用多个绑定布局创建单个管道。根据资源的修改频率将资源分为不同的组,或者将不同的资源集绑定到不同的管道阶段,这些都很有用。
以下代码示例显示了如何使用一个绑定布局创建基本计算管道:
auto layoutDesc = nvrhi::BindingLayoutDesc() .setVisibility(nvrhi::ShaderType::All) .addItem(nvrhi::BindingLayoutItem::Texture_SRV(0)) // texture at t0 .addItem(nvrhi::BindingLayoutItem::ConstantBuffer(2)); // constants at b2 // Create a binding layout. nvrhi::BindingLayoutHandle bindingLayout = device->createBindingLayout(layoutDesc); auto pipelineDesc = nvrhi::ComputePipelineDesc() .setComputeShader(shader) .addBindingLayout(bindingLayout); // Use the layout to create a compute pipeline. nvrhi::ComputePipelineHandle computePipeline = device->createComputePipeline(pipelineDesc);
只能从匹配的绑定布局创建绑定集。匹配意味着布局必须具有相同数量、相同类型、绑定到相同插槽、顺序相同的项目。这看起来可能是冗余的, D3D12 和 Vulkan API 在其描述符系统中的冗余更少。这种冗余非常有用:它使代码更加明显,并且允许 NVRHI 验证层捕获更多的 bug 。
auto bindingSetDesc = nvrhi::BindingSetDesc() // An SRV for two mip levels of myTexture. // Subresource specification is optional, default is the entire texture. .addItem(nvrhi::BindingSetItem::Texture_SRV(0, myTexture, nvrhi::Format::UNKNOWN, nvrhi::TextureSubresourceSet().setBaseMipLevel(2).setNumMipLevels(2))) .addItem(nvrhi::BindingSetItem::ConstantBuffer(2, constantBuffer)); // Create a binding set using the layout created in the previous code snippet. nvrhi::BindingSetHandle bindingSet = device->createBindingSet(bindingSetDesc, bindingLayout);
由于绑定集描述符也包含创建绑定布局所需的几乎所有信息,因此可以通过一个函数调用同时创建这两个信息。这在创建仅需要一个绑定集的某些渲染过程时可能很有用。
#include... nvrhi::BindingLayoutHandle bindingLayout; nvrhi::BindingSetHandle bindingSet; nvrhi::utils::CreateBindingSetAndLayout(device, /* visibility = */ nvrhi::ShaderType::All, /* registerSpace = */ 0, bindingSetDesc, /* out */ bindingLayout, /* out */ bindingSet); // Now you can create the pipeline using bindingLayout.
绑定集是不可变的。创建绑定集时, NVRHI 从 D3D12 上的堆中分配描述符,或在 Vulkan 上创建描述符集,并用必要的资源视图填充它。
稍后,当绑定集用于绘制或分派调用时,绑定操作是轻量级的,并转换为相应的图形 API 绑定调用。渲染时不会创建或复制描述符。
自动资源状态跟踪
在 D3D12 和 Vulkan API 中,改变资源状态并在图形管道中引入依赖关系的显式屏障都是一个重要部分。它们允许应用程序最小化管道依赖项和气泡的数量,并优化它们的位置。通过从驱动程序中删除该逻辑,它们同时减少了 CPU 开销。这主要与绘制大量几何体的紧密渲染循环有关。大多数情况下,尤其是在编写新的渲染代码时,处理障碍非常烦人且容易出现错误。
NVHRI 实现了一个系统,该系统跟踪每个资源的状态,以及每个命令列表的子资源(可选)。当命令与资源交互时,资源将转换为该命令所需的状态(如果尚未处于该状态)。例如,writeTexture命令将纹理转换为CopyDest状态,随后从纹理读取的绘制操作将纹理转换为ShaderResources状态。
当两个连续命令的资源处于UnorderedAccess状态时,将应用特殊处理:不涉及转换,但在命令之间插入无人机屏障。如有必要,可以暂时禁用无人机屏障的插入。
我前面说过, NVRHI 会根据每个命令列表跟踪每个资源的状态。应用程序可以以任意顺序或并行方式记录多个命令列表,并在每个命令列表中以不同方式使用相同的资源。因此,您无法全局或每个设备跟踪资源状态,因为在记录命令列表时需要导出屏障。执行命令列表时,全局跟踪可能不会按照与设备命令队列上实际资源使用情况相同的顺序进行。
因此,您可以分别跟踪每个命令列表中的资源状态。在某种意义上,这可以看作是一个微分方程。您知道命令列表中的状态是如何变化的,但不知道边界条件,也就是说,当您按执行顺序进入和退出命令列表时,每个资源都处于哪个状态。
应用程序必须为每个资源提供边界条件。有两种方法可以做到这一点:
Explicit:打开命令列表后使用beginTrackingTextureState和beginTrackingBufferState功能,关闭命令列表前使用setTextureState和setBufferState功能。
Automatic:创建资源时使用TextureDesc和BufferDesc结构的initialState和keepInitialState字段。然后,使用资源的每个命令列表在进入命令列表时都假定它处于初始状态,并在离开命令列表之前将其转换回初始状态。
在这里,您 MIG 想知道如何避免资源状态跟踪的 CPU 开销,或者手动优化屏障放置。好吧,你可以!命令列表具有setEnableAutomaticBarriers功能,可完全禁用自动安全栅。在此模式下,在需要屏障的位置使用setTextureState和setBufferState功能。它仍然使用相同的状态跟踪逻辑,但频率可能更低。
上传管理
NVRHI 自动化了现代图形 API 的另一个方面,这一点通常很烦人。这就是 GPU 对上传缓冲区的管理和对其使用情况的跟踪。
通常,当必须从 CPU 对每帧或每帧多次更新某些纹理或缓冲区时,会分配一个分级缓冲区,其大小比资源内存需求大数倍。这将在 GPU 上启用多个正在运行的帧。或者,大型暂存缓冲区的部分在运行时进行子分配。使用 NVRHI 实现相同的策略是可能的,但是有一个内置的实现可以很好地适用于大多数用例。
每个 NVRHI 命令列表都有自己的上载管理器。调用writeBuffer或writeTexture时,上载管理器会尝试查找 GPU 不再使用的现有缓冲区,该缓冲区可以容纳必要的数据。如果没有可用的缓冲区,将创建一个新的缓冲区并将其添加到上载管理器的池中。将提供的数据复制到该缓冲区中,然后将复制命令添加到命令列表中。 GPU 使用的缓冲区的跟踪是自动执行的。
ConstantBufferStruct myConstants; myConstants.member = value; // This is all that's necessary to fill the constant buffer with data and have it ready for rendering. commandList->writeBuffer(constantBuffer, myConstants, sizeof(myConstants));
上载管理器从不释放其缓冲区,也不会与其他命令列表共享缓冲区。也许一个应用程序正在进行大量的上传,例如在场景加载期间,然后切换到上传强度较小的操作模式。在这种情况下,最好为上传活动创建一个单独的命令列表,并在上传完成后释放它。这将释放与命令列表关联的上载缓冲区。
无需等待 GPU 完成从上载缓冲区复制数据。在复制完成之前,前面描述的资源生存期跟踪系统不会释放上载缓冲区。
与图形 API 的交互
有时,有必要避开抽象层,直接使用底层图形 API 进行操作。也许您必须使用 NVRHI 不支持的某些功能,在示例应用程序中演示一些 API 用法,或者使可移植呈现代码与来自其他地方的本机资源一起工作。 NVRHI 使做这些事情相对容易。
每个 NVRHI 对象都有一个getNativeObject函数,该函数返回所需类型的底层 API 资源。预期的类型被传递给该函数,如果该类型可用,它只返回非 NULL 值,以提供某种类型安全性。
支持的类型包括ID3D11Device或ID3D12Resource等接口和vk::Image等句柄。此外, NVRHI 纹理对象具有getNativeView功能,可以创建和返回纹理视图,如 SRV 或 UAV 。
例如,为了在 NVRHI 命令列表的中间发布一些本地的 D3D12 渲染命令,您 MIG HT 使用代码,如下面的示例:
ID3D12GraphicsCommandList* d3dCmdList = nvrhiCommandList->getNativeObject( nvrhi::ObjectTypes::D3D12_GraphicsCommandList); D3D12_CPU_DESCRIPTOR_HANDLE d3dTextureRTV = nvrhiTexture->getNativeView( nvrhi::ObjectTypes::D3D12_RenderTargetViewDescriptor); const float clearColor[4] = { 0.f, 0.f, 0.f, 0.f }; d3dCmdList->ClearRenderTargetView(d3dTextureRTV, clearColor, 0, nullptr);
着色器置换
这里要提到的最后一个生产力特性是 NVRHI 附带的批处理着色器编译器。这是一项可选功能,没有它, NVRHI 完全可以正常工作。 NVRHI 接受通过其他方式编译的着色器。尽管如此,它还是一个有用的工具。
通常需要使用多个预处理器定义组合编译同一着色器。但是,例如, VisualStudio 为着色器编译提供的本机工具根本无法轻松完成此任务。
NVRHI 着色器编译器正好解决了这个问题。由列出着色器源文件和编译选项的文本文件驱动,它生成选项排列并调用底层编译器( DXC 或 FXC )生成二进制文件。然后,同一着色器的不同版本的二进制文件被打包成一个自定义块格式的文件,该文件可以使用《nvrhi/common/shader-blob.h》中声明的函数进行处理。
应用程序可以加载包含所有着色器排列的文件,并将其连同预处理器定义及其值的列表一起传递给nvrhi::utils::createShaderPermutation或nvrhi::utils::createShaderLibraryPermutation。如果文件中存在请求的置换,则会创建相应的着色器对象。如果没有,将生成一条错误消息。
除了置换处理之外,着色器编译器还有其他很好的功能。首先,它扫描源文件以构建包含在每个文件中的标题树。它检测是否修改了任何标题,以及是否必须重建特定着色器。其次,它可以使用所有可用的 CPU 内核并行构建所有过时的着色器。
结论
在这篇文章中,我介绍了 NVRHI 的一些最重要的功能,在我看来,使用这些功能是一种乐趣。
关于作者
Alexey Panteleev 是 NVIDIA 开发人员和性能技术团队的杰出工程师,他专注于新渲染技术的优化、产品化和集成。他最近的工作包括地震 II RTX 、带有 RTX 的地雷探测器以及各种技术演示和样品,如 ReSTIR 和小行星。亚历克赛拥有博士学位。莫斯科工程和物理研究所(梅菲州立大学)计算机科学专业。
审核编辑:郭婷
全部0条评论
快来发表一下你的评论吧 !