Quantcast
Channel: 英特尔开发人员专区文章
Viewing all articles
Browse latest Browse all 583

 教程:将应用迁移到 DirectX* 12 – 第 3 部分

$
0
0

下载 PDF[PDF 471KB]

 

第 3 章 从 DirectX 11 迁移到 DirectX 12

3.0 前面章节的链接

第 1 章: DirectX* 12 概述
第 2 章: DirectX 12 工具

3.1 接口映射

如果上层渲染逻辑基于 DirectX*11 进行写入,那么迁移的最佳方式是构建一个与 DX11 完全兼容的接口层,这是因为上层逻辑不要求完成很多代码重构工作来适应 DX12。这种迁移的速度非常快。在实践中,我们一共只花六周左右的时间来完成绝大部分函数的迁移和测试。然而,它也存在一些缺点。因为很多 DX11 的渲染对象已在 DX12 中集成或删除,所以 DX12 的包装类需要做大量的运行时间状态转换工作。这些操作将花费一些 CPU 时间,并且您无法将其彻底删除。所以,如果您有足够的时间进行开发,建议您提取类似于 DX12 的图形接口,以便向后适应 DX11 特性。毕竟,DX12 将是未来的发展趋势。

为了适应 DX11 API,我们重新执行了 D3D11.h 文档中几乎所有的接口。以下是部分代码示例。

例如:

class CDX12DeviceChild : public IUnknown
{
public:
	void GetDevice(ID3D11Device **ppDevice);
	HRESULT GetPrivateData(REFGUID guid, UINT *pDataSize, void *pData);
	HRESULT SetPrivateData(REFGUID guid, UINT DataSize, const void *pData);
	HRESULT SetPrivateDataInterface(REFGUID guid, const IUnknown *pData);
	HRESULT QueryInterface(REFIID riid, void **ppvObject);
};
class CDX12Resource : public CDX12LDeviceChild
{
public:
	void GetType(D3D11_RESOURCE_DIMENSION *pResourceDimension);
	void SetEvictionPriority(UINT EvictionPriority);
	UINT GetEvictionPriority(void);
};
typedef class CDX12Resource ID3D11Resource;
typedef class CDX12DeviceChild ID3D11DeviceChild;

重要的是要注意,该项目不能包括 D3D11 头,否则会发生定义冲突。

3.2 管线状态对象

管线状态对象是 D3D12 的核心概念。它包含 Shader、RasterizerState、BlendState、DepthStencilState、InputLayout 和其他数据。一旦 PSO 对象被传送到系统,这些与 PSO 相关联的状态将在同一时间进行设置。然而,在 D3D11 的接口层,这些渲染参数使用不同的 API 进行设置。为了完成适应阶段,我们必须使用可查询的运行时间容器对其进行管理。最常见的对象容器是 HashMap,它可用于避免多余的 PSO 和对应的 API 调用。

在使用 HashMap 之前,我们必须先准备资源 ID。您可能会想到的第一件事就是资源的内存地址。它在整个应用生命周期具有全局唯一性,但它存在一个缺点,就是占用了太多的内存空间:尤其是 64 位系统上的 8 个字节。实证分析表明,大多数应用不使用如此数额巨大的对象,因此,我们可以采用序号方式减少表示资源对象的空间,也就是说,使用一个单调递增的整数值来表示一个资源对象。相同的整数还可以表示不同的资源,只要这些资源是不同类型的。例如,RasterizerState 和 BlendState 可以使用不同的资源计数器。这种管理方法的一个重要优势是,它让资源的编码空间更紧凑且易于生成较短的散列值。否则,如果使用拼接内存地址后生成的散列值,则由散列值占用的内存字节数将很大,这不仅会影响 PSO 的存储,而且也会影响查询速度。需要在实践中找到为计数器定义的上限。不同的项目可能存在很大的差异,但我们可以先在测试中使用一个较大值,并在分配序号的位置添加断言。一旦它超过上限,则系统会触发警报。然后,您可以决定是修改底层实施情况还是调整上层逻辑。

为了进一步减少 PSO 实例的数量,当生成 RasterizerState、BlendState 和 DepthStencilState 时,我们需要观察它们之间的状态依赖性。例如,当我们禁用 DepthStencilState 中的深度测试时,可以忽略 RasterizerState 中的深度偏移设置。为了避免产生多余的对象,在这些情况下,我们使用默认值。

RTV 和 DSV 也与 PSO 相关。由于 DSV 可以控制是否读取和写入深度图中的“深度”或“模版”,所以当启用深度测试且禁用深度写入时,您需要在系统中设置只读 DSV。DSV 存在三种只读模式:1) 深度只读 2) 模板只读 3) 深度及模板只读。此外,PSO 还需要 RTV 和 DSV 的格式信息,所以,您最好将 OMSetRenderTargets 操作推迟到设置 PSO 的时间。

ScissorEnable 财产已从 RasterizerState 删除。在硬件端,Scissor 测试将处于永远在线状态。因此,如果应用需要禁用 Scissor 测试,则您应设置 ScissorRect 的宽度和高度,以匹配视区或设置为硬件所允许的最大分辨率,如 16K。

原语的主要拓扑类型需要在 PSO 中设置,其中包括点、线、三角形和补丁。当调用 IASetPrimitiveTopology 直接将其转换为上述主要拓扑类型时,我们可以使用预建的转换表。PSO 的 HashMap 也可根据主要拓扑类型进行分类,其中,每种拓扑类型对应一个 HashMap,这样,它就将使用数组下标直接定位。

3.3 资源绑定

在了解资源绑定之前,我们必须先了解一个核心概念,即根签名。就资源绑定模型而言,D3D12 和 D3D11 之间存在巨大的差异。D3D11 中的资源绑定是固定的。运行时间为每个着色器安排一定量的资源槽孔,并且该应用只需调用相应的接口,以便能够将资源绑定到着色器。在 D3D12 中,资源绑定过程非常灵活,并且不限制绑定资源的方式以及所绑定资源的数量。您可以设置自己的资源绑定风格。绑定资源最常用的方法是描述符表和根描述符。描述符表方法有点复杂,这是因为它提前将一组资源的描述符放在一个描述符堆中,这样,当绘制调用需要引用这些资源时,您只需对其进行初步处理即可。着色器会根据此项处理发现所有后续的描述符。这是一种指针类数组,这意味着着色器需要做第二个寻址以定位最终的资源。而根描述符的优势在于,不是提前将描述符放在描述符堆中,您可以将资源的 GPU 地址设置到命令列表中,这就相当于在命令列表中动态地构建一个描述符,这样,只需一个寻址操作,着色器就可以定位资源。然而,根描述符消耗的参数空间是描述符表的两倍。由于根签名的最大尺寸有限,所以根描述符和描述符表之间的合理比例安排非常重要。

在正常情况下,我们把 SRV 和 UAV 放在描述符表中(而采样器只可存在于描述符表中),但将 CBV 放在根描述符中。因为由 CBV 消耗的大部分资源是动态的,它的地址变化频繁,所以使用描述符表会导致组合激增。不仅内存的占用量会急剧增加,而且管理起来也很麻烦。相比之下,采样器、SRV 和 UAV 组合的变化也比 CBV 少得多,尤其是采样器。只要上层渲染逻辑设计正确,采样器组合的数量可以小于 128。因此,直接将其放到描述符堆中更合适。此处,为了重复利用描述符堆中的描述符组合,我们必须使用 PSO 类对象管理技术,以便首先对每个采样器、SRV 和 UAV 编号,然后根据着色器的需求,将其放在一起,并生成用于创建和索引描述符堆中的描述符组合的唯一散列值。由于在着色器中使用的采样器的最大数量是 16,所以,每个采样器组合可被放置在每单元 16 的跨度。SRV 和 UAV 也可以使用采样器的方法进行管理,所以,对其进行引用的着色器的上限最好也是 16。当然,可变组合跨度单元也是一种选择,但是不方便跨框架对其重复利用,这是因为由 SRV 指向的纹理被释放时,它的序号将被该应用回收,并且所有引用它的描述符组合将被标记为“删除”。此时,如果描述符堆中的组合块在大小方面出现变化并且不连续,则它们会难以重新分配到内存池碎片,除非您花费大量的时间进行反碎片努力。出于这个原因,对于描述符组合,折衷的解决方法是使用固定长度的跨度。

在命令列表中最多只能设置两个描述符堆,每类描述符堆分别一个。“采样器”以及“SRV / UAV / CBV”属于两类不同的描述符堆,并且不能进行混合。

出于效率的考虑,当我们需要重写描述符堆的描述符时,我们可以先在 CPU 可见的描述符堆中完成更新,然后通过 CopyDescriptors* 命令将堆的内容复制到 GPU 可见的描述符堆。如果每个视图仅在一个位置,则它应该直接在着色器可见堆中创建/更新。

3.4 资源管理

3.4.1 静态资源

在 D3D11 中,可使用两种方法来初始化静态资源。第一种方法适用于不可更改的资源。它只允许这些资源中的数据更改一次,以及通过“创建*”接口将需要初始化的数据传递到系统中。第二种方法适用于默认资源。它可以更改这些资源中的数据几次,但需要分期资源的帮助。

在 D3D12 中,这两种资源的初始化过程合并成一个 - 第二种方法。通过上传堆中的资源,数据被更新到默认堆。正如 D3D11,从默认堆分配的所有资源无法被映射,这意味着应用无法直接访问其 CPU 地址,因此,需要使用从上传堆分配的中间资源作为桥梁,将数据从 CPU 端推动到 GPU 端。这里需要注意的一件事是,何时删除这个中间资源。在 D3D11 中,中间资源可以在执行复制命令之后直接删除,但是这在 D3D12 中不可行,因为 D3D12 不在运行时间提供资源生命周期管理函数。所有工作都必须由应用来完成,所以,应用需要了解这些执行的复制任务是否异步完成,换句话说,何时 GPU 不再引用这些资源。我们可以通过命令队列的围栏特性轻松地访问这些信息。此外,我们还可以通过动态资源的共享内存池完成上传资源的工作。毕竟,为每个默认堆资源分配上传堆资源的作法相当低效。不仅重复使用率很低,而且很容易在系统中产生过多的碎片。因此,使用后述动态资源内存池技术能够避免这些问题。

每次在向命令列表应用资源前,您应该首先记录当前的帧数。此帧号可用于确定,当出现采用此帧号的帧和当前帧之间的帧数超过命令列表总数情况时释放某一资源时,可否直接删除资源,或者您可以将其缓冲到命令列表的延迟释放列表,然后在 GPU 上完成执行命令列表后将其释放。

3.4.2 动态资源

我们应该对 D3D11 中的动态使用资源非常熟悉。它被广泛用于顶点缓冲区、索引缓冲区和常量缓冲区。相关的应用场景包括颗粒和接口。映射函数通常提供一个写放弃特性,它允许应用重复使用同一资源,前提是该资源的初始大小符合渲染逻辑的需求。正如前面提到的,我们了解到,D3D 的 API 进行异步执行,也就是说,API 调用的结束并不意味着在同一时间完成了任务执行,相反,很可能还需要一些时间才能最终完成。此时,如果后续的绘制调用已修改了该资源,则有可能引起对资源的竞争。但是,如果您已经使用了写放弃特性,它可以防止发生这种情况。由于运行时间或驱动程序将自动重命名资源,这样,它看起来像对同一个对象的外部引用,但实际上,它已经切换到另一个免费的内部资源。这种新资源将接管旧资源进行外部更新。为了避免长时间占用大量内存,系统将这些旧资源放入内存池中进行统一管理。当它们不再被 GPU 引用时,系统将其回收并再次利用。

在 D3D12 中,我们必须执行类似的函数。首先,我们需要构建由资源列表组成的资源池。因为我们每次请求的资源大小有所不同,所以,最好提前分配更大的资源,然后按不同的偏移分类子资源供上层逻辑使用。这样做,我们就可以减少系统分配的数量,以及由内存的不连续性所产生的过多碎片。一般情况下,我们建议在 4MB 单位中使用资源块。当资源池准备就绪,我们可以开始分配资源。但在此之前,我们还需要了解资源的内存地址。在 D3D11 中,内存地址从映射函数中导出。D3D12 还通过该函数返回了内存地址。所不同的是,不像 D3D11,D3D12 不需要每次在映射和填充数据时调用取消映射函数。由于 D3D12 动态资源的映射是连续的,这意味着它们的内存地址永远有效,所以,没必要告诉系统通过取消映射函数来取消资源的映射。所以在一般情况下,在动态资源的生命周期中,只需要调用映射函数一次。您可以保存它返回的内存地址,以便重复使用。在这种资源释放前,您需要调用取消映射函数一次,以确保这个内存地址空间可由系统进行回收。

回收所占用的资源也很简单。我们只需把当前帧所分配的资源块放入当前命令列表所编号的挂起队列中。每一帧将检测与此队列对应的命令列表是否完成了。如果完成了,您可以将此队列中的所有资源链接到空闲列表进行后续分配和重复使用。上述方法适用于每帧更新及使用的数据。对于跨帧引用及针对多个帧更新一次的资源,需要使用其他方法进行维护。我们应该先记录当前命令列表数,再引用各个资源。当它再次更名时,您应先检查与此数字对应的命令列表是否已执行。如果没有,您应该把它放入当前命令列表的待回收列表,并等待命令列表完成,然后再对其进行回收和使用。由于这种方法不丢弃每帧的资源,所以它可以确保跨帧使用资源,但它需要在每次更名资源时,查看资源的上一次使用情况。

由于动态缓冲区,GPU 地址会随着每个请求发生变化。出于这个原因,外部渲染逻辑最好在资源被设置为命令列表前进行缓冲请求,否则您需要将资源的设置推迟到绘制调用进行调用的时间。

特别提醒: CPU 端逻辑不得读取上传堆中分配的资源所制定的内存空间,否则会造成性能出现显著下降,这是因为这种内存应该在写合并模式下进行访问。

3.4.3 动态纹理的更新

在 D3D11 中,动态纹理基本上采用与动态缓冲区相同的方式进行更新。映射后,您可以直接提取内存地址,然后用数据填充纹理。然而,相对于缓冲区,需要额外考虑行间距和深度间距的跨度值。但在 D3D12 中,您无法像 D3D11 那样填充纹理,这是因为在 GPU 中纹理是以 Swizzle 形式存储在 GPU 中,而缓冲区的内存布局是线性的。所以缓冲区可以直接在 CPU 端进行填充,无需转换。然而,出于对 GPU 读取效率的考虑,纹理要以其他方式进行上传。正如缓冲区,第一步是分配适当大小的上传堆资源。根据 GetCopyableFootprints API,我们可以了解到(上传到上传堆中的默认堆的)纹理所允许的空间布局,然后使用复制* 命令上传 CPU 端所填充的数据。从描述可以看出,由 GPU 使用的纹理实际上也是静态资源。当我们看 D3D11 的实施情况时,可以映射的纹理资源也经历了类似于 D3D12 的内部流程,但 D3D12 外部化这些任务,从而为应用提供了更多的优化可能性。

3.4.4 GPU 数据的回读

在 D3D11 中,存在两类 GPU 数据回读。一个是缓冲区和纹理的 GPU 数据的回读,由分期资源处理。首先,您将需要回读的静态资源复制到分期资源,然后使用映射函数返回可由 CPU 读取的地址。但在真正开始读取数据前,您还需要确定是否完成了将资源复制到回读堆,这是因为复制操作是异步的。在 D3D12 中,这个流程相似。不像前面提到的上传堆,D3D12 提供了专用于数据回读的堆型。回读堆的使用方式几乎与上传堆相同。同样地,您首先分配来自堆中的资源,然后复制需要通过复制*函数回读到该资源的默认堆资源。与 D3D11 不同的是,它的映射函数不提供等待和查看是否完成了回读的能力。此时,我们需要应用我们在静态和动态资源管理部分中提到的机制,确定回读是否已采用围栏的方式完成。我们不希望鼓励回读(回写)内存的持续映射。您可以让它保持映射状态,但在读取由 GPU 写入的数据之前,您应该执行带范围的其他 Map(),以确保高速缓存一致。对于不需要它的系统则没有必要,但对于需要它的系统,则要确保正确性。

另一类是硬件查询的回读。这类回读的流程与缓冲区和纹理的回读基本相同,除了使用解析函数,而不是复制函数。由于解析函数可以回读大批查询数据,所以,当分配查询堆时,不要求每个查询对象调用创建函数。相反,您可以分配采用相邻内存空间的查询对象集,然后采用资源中的偏移方式执行后续的定位。

由于外表上我们使用的是 D3D11 接口封装,上传和回读操作可能都使用了分期资源。那么,我们如何在它们之间进行内部区分?首先,我们需要确定的是,当我们首次使用此资源时,映射操作是在执行复制*命令之前还是之后进行的。一般情况下,映射操作在执行复制*命令之前进行,因为我们认为,用户希望将数据上传到 GPU。当用户希望回读 GPU 的数据时,映射操作在执行复制* 命令之后进行。当然这里有一个前提条件,即对于相同的资源,外部逻辑不允许将其用于上传 CPU 数据和回读 GPU 数据。否则,无法从内部确定这种模糊性。幸运的是,这样的情况在实践中很少发生。

3.5 资源屏障

这是一个新概念。在 D3D12 之前,资源状态管理由驱动程序完成。现在,D3D12 将其从驱动程序层剥离出来,并让应用控制何时以及如何处理它。

存在三类不同的资源屏障。最常见的类型是转换,主要用来切换资源的状态。当资源的应用场景发生变化时,我们将先放置相应的资源壁垒,然后再使用该资源。

在实践中,非常常见的转换屏障是在 ShaderResourceView 和 RenderTargetView 之间来回切换的资源。因此,我们需要在资源的包装类中添加一个成员变量来记录当前的资源状态。当上层逻辑调用 OMSetRenderTargets 时,我们应该首先确认当前的状态是否为 RenderTargetView,如果不是,请放置一个屏障。其 StateBefore 填充了存储在成员变量中的状态值,而其 StateAfter 则填充了 RenderTargetView 状态。如果渲染逻辑调用 XXSetShaderResources,那么,我们继续按照上述流程执行类似的操作,除了 StateAfter 应填入 ShaderResourceView 状态。

最好推迟转换屏障的设置,直到我们真正开始使用资源,从而避免不必要的同步,因为它会阻止后续命令的执行。

通过复制*、解析* 和清除* 命令传输的资源都有相应的需要设置的目标状态。

3.6 命令列表/队列

D3D12 命令列表/队列是 D3D11 设备上下文变换出的特性。命令列表负责缓冲渲染命令,然后这些内置到由驱动程序已知且最后由命令队列执行的硬件命令。由于每个命令列表都可以在没有任何中间锁保护的情况下独立填写渲染命令,所以它的运行速度要比 D3D11 中的对应物快得多。

命令列表可反复重新使用。当应用要重新提交相同的命令列表来执行 GPU 时,它必须等待 GPU 上的命令列表完成执行;否则,其行为是未定义的。无论命令列表是否仍然在 GPU 上执行,应用在关闭后还可以重置命令列表。已重置的命令列表相当于一个空白的上下文。它不再保留任何以往的渲染状态,所以您需要再次设置,如 PSO、视区、ScissorRect、RTV 和 DSV。

在一般情况下,为了避免每个帧同步等待命令列表在前一帧中执行,我们可以准备很多备用命令列表,然后在每帧结束时查看之前待定的命令列表。如果最近的命令列表已执行,则表示之前的命令列表也已执行,这是因为命令列表严格按顺序执行,相当于一个 FIFO 队列。为了确定某个命令列表是否已执行,我们需要使用类似于围栏的对象。当命令队列调用 ExecuteCommandList 函数时,通过将传递给信号函数的预期值设置成围栏对象,信号函数允许系统立即通知围栏对象何时执行了命令列表。因此在正常情况下,我们将各帧的累计帧号用作预期值,并将它传递给信号函数。查询命令列表是否完成的方式决定了 GetCompletedValue 的返回值是等于还是大于预期值。

命令队列将与交换链绑定,所以当您创建交换链时传递的第一个参数是命令队列。目前,交换链的常见模式是翻转*。在这种模式下,BufferCount 应大于一,这意味着在交换链中存在多个后台缓冲区。为了让其交替渲染,您需要在执行“展示”操作后,将下一个后台缓冲区切换为当前的渲染目标,并根据您所创建的后台缓冲区的总数自动后退。如果您尚未在帧中执行“展示”操作,则您无法切换后台缓冲区,否则会导致系统崩溃。

存在三类命令队列:Direct、Copy 和 Compute。这三类命令队列可并行执行。

  • Direct 命令队列负责处理图形渲染命令。
  • Copy 命令队列负责数据的上传或回读操作。
  • Compute 命令队列负责处理通用计算命令(与光栅化无关)。

例如,当我们使用 Direct 命令队列来渲染某一工作线程上的场景时,我们可以使用其他线程上的 Copy 命令队列来处理纹理数据的上传。在 Direct 命令队列中引用后台中上传的纹理数据的操作必须等待执行完 Copy 命令队列。

即将推出:以下章节的链接

第 4 章: DirectX* 12 特性

第 5 章: DirectX* 12 优化


Viewing all articles
Browse latest Browse all 583

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>