DirectX12(D3D12)基础教程(八)——多显卡渲染基础、共享纹理、多GPU同步

目录

1、前言

2、为什么要多显卡渲染

3、多显卡渲染核心原理

3.1、多GPU拓扑模型及工作方式

3.1.1、隐式多显卡系统

3.1.2、显式多显卡系统

3.1.3、链接的多显卡系统

3.1.4、无链接的多显卡系统

3.2、无链接显式多显卡系统的核心关键点——共享资源

4、异构多显卡渲染框架示例——核显+独显方式

4.1、创建多个设备对象

4.2、创建交换链及渲染目标

4.3、创建跨GPU共享资源堆和共享资源

4.4、创建单位矩形和模拟后处理渲染

4.5、创建跨GPU的围栏同步对象

4.6、渲染过程

4.7、多GPU间同步

5、性能评估和分析

6、全部代码和下载链接

7、后记


1、前言

再过几天就是2019年的春节了,在这里先给大家拜个早年,提前祝大家新春吉祥!阖家欢乐!

按照计划在春节前我终于把D3D12中最重要的基础性话题之一——多显卡渲染搞定了。现在分享出来,算是给大家的一个新年礼物吧。

按照常理,春节前一般都该是做总结的时候了,可是我觉得至少我永远是在追逐梦想的路上,总结什么呢?不停的做就是了,总结留给生命结束的时候去吧!或者几千年来,其实古圣先贤已经都替我们总结差不多了,最简单的像孔子说的:不贰过,不迁怒!其实人生能做到这简单的两点已经算是很圆满了,而且我一直以:志于道、拘于德、依于仁、游于艺,作为人生的全部法则,或者说座右铭吧。因此我也很少再去过多的总结,只是每日三省吾身尔,当然离古圣先贤的要求还相去甚远,目前也只能勉强算是游于艺吧!

言归正传,前段时间,我跟几个专业搞游戏开发的朋友聊天,谈到我后面打算写多显卡渲染,尤其是异构多显卡渲染的时候,我发现这个话题居然备受争议,甚至他们认为多显卡渲染其实是个降低性能的骗局。怎么说呢?至少我部分的赞同他们的观点,当然前提就是那是在有D3D12以前的情况了。

而现在,有了D3D12接口,才能使异构多显卡渲染成为可能,也才能真正使多显卡渲染性能爆棚!当然为什么要追求极致的性能,我想在多线程渲染那一章中我已经说的很明白了,请大家去垂阅。

本篇教程中,我将先把多显卡渲染,尤其是异构多显卡渲染的基本框架原理讲清楚,至于最终怎么去提高性能,我想留到后面的教程再去啰嗦。这一章我们将实现的效果依然如下:

OK,不要纠结怎么又是这堆东西,毕竟是研究代码怎么写的,不是研究美工的,请恕我实在是找不到其它的场景物体了,当然也懒得去找了。同时我也认为,过于复杂的场景其实于我们的基础性原理和基本代码编写来说也毫无益处,咱们还是专心的搞明白D3D12接口的方方面面先!

2、为什么要多显卡渲染

在我之前的系列文章“D3D11和D3D12多线程渲染框架的比较(1-5)”中我已经提过,在满街智能手机的今天,其实CPU常常和GPU是做在一个芯片上的。而在我们的笔记本、PC,甚至服务器上到处都可见核显,同时很多笔记本、PC、甚至服务器上都还配有独显。有些机器上甚至不止有一块独显,这也很常见。

但是在有D3D12接口以前,核显和独显的工作方式还是一个干活一个休闲的“倒班制”。而这种方式最常见就在笔记本上。比如我现在用来工作、写代码、写博客文章的这台笔记本,在没有升级Win10升级DirectX12以前,核显G530和独显GTX965m基本就是这样的工作方式。而升级之后这俩货终于有机会可以一起工作了,当然现在仍然需要我自个写程序让它俩一起工作。

总之这里要说的第一点就是现在多显卡的系统基本已经随处可见了,而这其中异构多显卡是比较常见的情况。同时我估计在不久的将来手机上也可能会出现独显+核显的情况(这个别跟我争,记得当年我说5年内手机上马上会用上多核CPU,结果一哥们不信跟我来抬杠,后来2年时间手机就用上多核CPU了,拭目以待吧)。

第二点就是现在有了D3D12接口,同时也有了完全支持异步执行渲染命令的显卡驱动支持,那么多显卡同时工作,在软件层面上也就没有什么大的障碍了。而过去只能是硬件层面使用STL或CrossFire,让两个近乎相同的GPU实现协同工作,如果是不同的显卡,哪怕是同一个厂商的不同型号GPU都是没法简单的协同工作的。

第三点,现在的光栅化渲染中,渲染技术也越来越复杂化,大多数主流的渲染技术已经不是简单的在一遍(Pass)渲染中就能完成的,同时很多渲染技术还有很多后期画面处理运算,这些简称为后处理。后处理其实主要针对一帧固定大小的画面进行,相较于它之前的那些渲染来说,其性能开销是可以预期的,实质往往也就是处理一张固定大小纹理而已,因此使用比如核显来做这些处理也是比较合适的,这样就可以让独显空出后处理的时间去渲染下一帧,而核显就可以完成后处理并且负责最终画面的呈现。甚至对于一些性能较强悍的核显比如AMD的核显,还可以将一些多Pass任务中的若干Pass交给它来完成。总之,也就是说将大的整体串行的渲染过程,拆分在不同的显卡上来完成,形成接力渲染并行执行的效果,从而达到性能的优化效果。

第四点,现在已经出现的实时光线追踪渲染,可以更简单的利用多显卡渲染来提升性能,甚至是用异构多显卡。这是因为如果你看过我的文章“光线追踪渲染(RayTracing Render)核心原理详解”,你就会明白,发射光线其实可以根据每个不同显卡能力的大小来分配不同数量的光线来完成同一帧画面的渲染的。而且在目前发布的DXR中也是支持这种异构多显卡的实时光追渲染的。因此使用多显卡渲染对实时光追渲染性能的提升也是有益处的。

总之,也就是说在现代,多核系统随处可见、软硬件也已经做好了充分准备、而渲染过程本身,无论是传统的光栅化渲染还是实时光追渲染理论上都可以在利用多显卡渲染来提升整体性能。

3、多显卡渲染核心原理

要能够利用多显卡渲染带来的好处,首先我们就需要深刻的掌握目前GPU的一些拓扑链接方式。正如我在本系列教程的第三篇中所讲的一样,要理解显存管理,就需要我们了解现在GPU存储模型。在这里我们需要更进一步,在当时讲的模型基础上,深刻理解GPU在系统中的拓扑模型。

3.1、多GPU拓扑模型及工作方式

在D3D12多显卡渲染的官宣PPT中,对目前多显卡的系统做了一些拓扑上的划分,从大的层面上来讲,就分为隐式和显式多显卡两种模型。下面我就详细的介绍一下。

3.1.1、隐式多显卡系统

所谓的隐式多显卡其实就是多GPU显卡,即在一个显卡线路板上集成至少两个以上的GPU,组成一个多GPU显卡。在驱动和D3D12中看到的将是完整的一个适配器。核心的它们基本都是使用AFR(交替渲染并行)工作方式,即一个GPU负责渲染一帧,交替执行。而从其工作方式来看,这种方式并没有实现真正的多个GPU同时渲染一帧的情况,并不是真正意义上的渲染同一帧。因此其性能提升方式比较有限,并且极度依赖于单个GPU的性能。而且这种显卡因其高昂的价格,所以也不是很多见。

因为在编程上这样的系统与使用单个适配器的情况几乎没什么差别,我们就不多着重介绍了。

3.1.2、显式多显卡系统

而与隐式多显卡相对应的就是显示多显卡系统,也就是说我们在编程的过程中可以明显“感知”到有多个GPU可供利用,因此这样的方式就被称为显示多显卡系统。

这种方式优点比较多,它几乎可以利用所有的硬件资源,也就是说包括完全异构的来自不同厂商的GPU都可以自由的同时工作。它也给我们程序以充分的控制权,可以实现渲染命令级的真正的并行命令执行,即每个GPU都对应一个命令队列。我们可以自行调配每个GPU上的任务负载,同时我们还可以相对独立并主动的控制每个GPU的显存。

总之显示多显卡系统给我们在编程实现及使用上带来了极大的灵活性。

具体的显示多显卡系统主要有链接的多显卡系统和离散的多显卡系统。

3.1.3、链接的多显卡系统

主要指的就是Nvidia的SLI链接的多个显卡,或用AMD的CrossFire链接的多个显卡。

这种系统在编程的角度上我们看到的可能是一个适配器,而其上有多个GPU节点(Node),也就是它上面可以有多个3D引擎、复制引擎、计算引擎等。直观的理解上就像使用的是一个多核超线程CPU一样,虽然物理上是一个处理器,但是具体编程上,我们可以使用多线程来充分发挥多核CPU并行执行线程的优势。

在具体编程上,每个链接的适配器都会被枚举为一个IDXGIAdapter3接口,我们在其上创建一个ID3D12Device接口,然后可以调用ID3D12Device::GetNodeCount方法知道具体有多少个GPU节点在这个适配器上。

然后我们在创建的一系列围绕设备的相关对象时,在其结构体的NodeMask(GPU节点掩码)参数中指定具体是将对象创建在哪个或哪几个GPU节点上。

NodeMask参数是一个按照从低位到高位,指定对应GPU序号二进制位为1的一个二进制值,与线程亲缘性参数的CPU Mask掩码类似。当然序号为0的GPU就默认指定第1位Bit位为1即可,即0x1,而序号为1的GPU就指定第2位的Bit位为1,即0x2,以此类推。一般可以使用C++的<<左移位运算符来快速指定NodeMask:NodeMask =1<<GPUIndex。当有多个GPU需要联合指定时,就可以使用C++的位或“|”运算符指定多个GPU节点。

当然因为NodeMask是一个UINT的32位值,因此我们最多可以索引到第32个GPU或者同时引用32个GPU,但实际上一般也不会有这么多的GPU同时组成这样的适配器节点。目前SLI和CF都最多支持4路GPU。

而ID3D12Device接口方法创建的各种对象,则根据能否跨GPU节点创建,则被分为单节点对象,和可以跨GPU节点引用的多节点对象。

其中单节点对象的相关结构体和函数如下:

类型

名称

说明

结构体

D3D12_COMMAND_QUEUE_DESC

有NodeMask成员

函数

CreateCommandQueue

根据D3D12_COMMAND_QUEUE_DESC 结构体指定的GPU节点创建对应GPU节点的命令队列。

函数

CreateCommandList

有NodeMask参数,创建指定GPU节点上的命令列表.

结构体

D3D12_DESCRIPTOR_HEAP_DESC

有NodeMask成员

函数

CreateDescriptorHeap

根据D3D12_DESCRIPTOR_HEAP_DESC结构体指定的NodeMask创建对应GPU节点上的描述符堆

结构体

D3D12_QUERY_HEAP_DESC

有NodeMask成员

函数

CreateQueryHeap

根据D3D12_QUERY_HEAP_DESC结构体指定的NodeMask创建对应GPU节点上的性能参数查询堆

多节点对象对应的结构体和函数如下:

类型

名称

说明

结构体

D3D12_COMMAND_QUEUE_DESC

有NodeMask成员

枚举值

D3D12_CROSS_NODE_SHARING_TIER

用于确定Tier方式资源可以跨节点支持到什么程度的枚举类型,详细的内容可以看该枚举值的MSDN文档。

结构体

D3D12_FEATURE_DATA_D3D12_OPTIONS

 有D3D12_CROSS_NODE_SHARING_TIER枚举类型成员变量

结构体

D3D12_FEATURE_DATA_ARCHITECTURE

包含一个NodeIndex参数,用于查询具体的GPU节点的架构

结构体

D3D12_GRAPHICS_PIPELINE_STATE_DESC

拥有NodeMask成员

函数

CreateGraphicsPipelineState

根据D3D12_GRAPHICS_PIPELINE_STATE_DESC结构体指定的NodeMask创建渲染管线状态对象

结构体

D3D12_COMPUTE_PIPELINE_STATE_DESC

拥有NodeMask成员

函数

CreateComputePipelineState

根据D3D12_COMPUTE_PIPELINE_STATE_DESC结构体指定的NodeMask创建计算管线状态对象

函数

CreateRootSignature

拥有NodeMask参数

结构体

D3D12_COMMAND_SIGNATURE_DESC

拥有NodeMask成员

函数

CreateCommandSignature

根据D3D12_COMMAND_SIGNATURE_DESC结构体指定的NodeMask创建命令标记对象

其他的一些没有特殊说明的函数和方法一般就不需要再去指定NodeMask参数了,比如各种描述符,因为它们所在的描述符堆已经被指定了GPU NodeMask。

另外就是作为各种资源,资源堆等就不需要特别指定GPU的NodeMask了,因为作为链接的多显卡系统,这些资源可以映射到其内部的任何一个GPU节点上,或者直白的理解就是所有显存都是对这样的一个SLI或CF系统下的GPU是共享的。

最终下图形象的展示了各种资源与GPU节点之间的关系(该图来自MSDN):

其中橙色部分就是指可以跨GPU节点创建并可应用于多个GPU节点的对象。而其他的各种命令队列对象就只能在指定的GPU节点上被创建和使用了。

需要注意的就是在链接的多显卡系统中,最终交换链、渲染目标甚至深度缓冲等作为可以直接被共享的资源,创建后则可以在所有GPU节点间共享,因此最终如何使用多个3D引擎等来并行执行渲染操作等,就跟多线程编程有些类似了,只是这里我们可以直接利用多个3D命令队列来并行执行不同的命令列表,从而实现真正的并行渲染而已。

因为我手头没有带有SLI或CF的系统,因此我没法展示这种链接的多显卡系统如何具体的编程,所以只能做下原理性的描述。请各位见谅(实际是因为我太穷,搞不起这样的系统)。

3.1.4、无链接的多显卡系统

与使用SLI和CF链接在一起的系统不同,还有一大类系统中多个显卡是完全独立的,这样的系统被D3D12称为无链接的多显卡系统,或者称之为离散多显卡系统。

这类系统就是比较常见的了,比如我的笔记本电脑中Intel CPU中的核显和来自Nivdia的GPU就组成了一个离散多显卡系统。具体的根据离散显卡的类型,这类系统还可以细分为:多个离散独显的系统和比较常见的核显+独显的系统。当然这类系统的最大特点就是每个显卡的GPU等都可能来自不同的厂商及不同的品牌。

在没有D3D12之前我们基本没法让这样的不同的多个GPU同时来工作。只能在同一时间为同一任务使用某一个GPU。比如我在写这篇文章时,系统使用的就是集显在工作,而当我玩3D游戏时系统就使用GTX965m独显来工作。

而本质上,如果你已经深度的学习和掌握了我们说的现代显卡的多引擎架构的话,也就是说现代的GPU上都会有至少一个3D引擎,计算引擎和若干个复制引擎组成,而这些引擎之间是可以并行执行的。更进一步,我们可以想到不同的GPU上的3D引擎之间也是可以并行执行的,这样我们就可以将一些因为只能使用单个3D引擎按渲染命令顺序执行的串行渲染过程,改造为可以使用多个不同的3D引擎来并行执行的过程,从而达到并行优化,并提升性能的效果。

仔细想想的话,我们会发现其实这是完全可能的,只是说与链接的多显卡系统可以直接共享使用渲染目标、深度缓冲等资源不同,在D3D12以前,可能没有什么直接而简单的方法,在多个离散的显卡间共享资源。这样也就无法简单的去共享渲染目标、深度缓冲等,从而也就无法简单的完成并行渲染的任务。

从编程角度来分析的话,就是说我们可以为每个离散显卡创建各自的Device对象、Queue对象、Command List对象等,然后我们可以将场景的不同部分创建在不同的Device对象所代表的不同显卡上,但是最终因为我们无法简单的共享资源,从而渲染最终的画面无法简单的统一到一起。

3.2、无链接显式多显卡系统的核心关键点——共享资源

那么上述关于离散显卡间共享资源的最终问题,实际在D3D12中为我们提供了一个比较好的解决方案,那就是直接创建共享资源。

首先,我们需要检测显卡是否支持共享资源,具体方法如下:

D3D12_FEATURE_DATA_D3D12_OPTIONS stOptions = {};
// 通过检测带有显示输出的显卡是否支持跨显卡资源来决定跨显卡的资源如何创建
GRS_THROW_IF_FAILED( stGPUParams[nIDGPUSecondary].m_pID3DDevice->CheckFeatureSupport(
    D3D12_FEATURE_D3D12_OPTIONS, reinterpret_cast<void*>(&stOptions), sizeof(stOptions)));
bCrossAdapterTextureSupport = stOptions.CrossAdapterRowMajorTextureSupported;

其次,我们可以使用下面一组函数完成GPU资源堆在多个显卡间甚至多个不同的进程间的共享:

ID3D12Device::CreateSharedHandle
ID3D12Device::OpenSharedHandle
ID3D12Device::OpenSharedHandleByName

像下面这样调用,就可以完成资源堆的跨显卡共享:

// 创建跨显卡共享的资源堆
CD3DX12_HEAP_DESC stCrossHeapDesc(
    n64szTexture * g_nFrameBackBufCount,
    D3D12_HEAP_TYPE_DEFAULT,
    0,
    D3D12_HEAP_FLAG_SHARED | D3D12_HEAP_FLAG_SHARED_CROSS_ADAPTER);

GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pID3DDevice->CreateHeap(&stCrossHeapDesc
    , IID_PPV_ARGS(&stGPUParams[nIDGPUMain].m_pICrossAdapterHeap)));

HANDLE hHeap = nullptr;
GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pID3DDevice->CreateSharedHandle(
    stGPUParams[nIDGPUMain].m_pICrossAdapterHeap.Get(),
    nullptr,
    GENERIC_ALL,
    nullptr,
    &hHeap));

HRESULT hrOpenSharedHandle = stGPUParams[nIDGPUSecondary].m_pID3DDevice->OpenSharedHandle(hHeap
    , IID_PPV_ARGS(&stGPUParams[nIDGPUSecondary].m_pICrossAdapterHeap));

// 先关闭句柄,再判定是否共享成功
::CloseHandle(hHeap);
GRS_THROW_IF_FAILED(hrOpenSharedHandle);

当然如果我们在调用CreateSharedHandle方法时指定了Name名称这个参数,为共享的句柄设置了一个名称,那么在另一个不同的进程中我们就可以使用相同的名字来调用OpenSharedHandleByName从而跨进程打开并共享一个显存堆。当然这就属于更高级的话题了,已经超出了基础教程的范畴。有兴趣的朋友可以自己试一下,在进程间共享显存堆。

当然被共享的堆是有限制的,一般的隐式堆也可以跨显卡共享,但不能跨进程共享。保留资源和堆也不能跨显卡或跨进程共享。

有了共享资源堆,我们就可以使用“定位”方式在共享堆上创建共享资源。这时就需要我们为D3D12_RESOURCE_DESC的Flags 参数指定枚举值D3D12_RESOURCE_FLAG_ALLOW_CROSS_ADAPTER,这也是我上一讲之所以特别强调这个结构体及它的这个枚举成员的目的。具体的创建共享资源的代码如下:

UINT64 n64szTexture = 0;
D3D12_RESOURCE_DESC stCrossAdapterResDesc = {};

if ( bCrossAdapterTextureSupport )
{
	// 如果支持那么直接创建跨显卡资源堆
	stCrossAdapterResDesc = stRenderTargetDesc;
	stCrossAdapterResDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_CROSS_ADAPTER;
	stCrossAdapterResDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;

	D3D12_RESOURCE_ALLOCATION_INFO stTextureInfo = stGPUParams[nIDGPUMain].m_pID3DDevice->GetResourceAllocationInfo(0, 1, &stCrossAdapterResDesc);
	n64szTexture = stTextureInfo.SizeInBytes;
}
else
{
	// 如果不支持,那么我们就需要先在主显卡上创建用于复制渲染结果的资源堆,然后再共享堆到辅助显卡上
	D3D12_PLACED_SUBRESOURCE_FOOTPRINT stResLayout = {};
	stGPUParams[nIDGPUMain].m_pID3DDevice->GetCopyableFootprints(&stRenderTargetDesc, 0, 1, 0, &stResLayout, nullptr, nullptr, nullptr);
	n64szTexture = GRS_UPPER(stResLayout.Footprint.RowPitch * stResLayout.Footprint.Height, D3D12_DEFAULT_RESOURCE_PLACEMENT_ALIGNMENT);
	stCrossAdapterResDesc = CD3DX12_RESOURCE_DESC::Buffer(n64szTexture, D3D12_RESOURCE_FLAG_ALLOW_CROSS_ADAPTER);
}

// 以定位方式在共享堆上创建每个显卡上的资源
for ( UINT nFrameIndex = 0; nFrameIndex < g_nFrameBackBufCount; nFrameIndex++ )
{
	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pID3DDevice->CreatePlacedResource(
		stGPUParams[nIDGPUMain].m_pICrossAdapterHeap.Get(),
		n64szTexture * nFrameIndex,
		&stCrossAdapterResDesc,
		D3D12_RESOURCE_STATE_COPY_DEST,
		nullptr,
		IID_PPV_ARGS(&stGPUParams[nIDGPUMain].m_pICrossAdapterResPerFrame[nFrameIndex])));

	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pID3DDevice->CreatePlacedResource(
		stGPUParams[nIDGPUSecondary].m_pICrossAdapterHeap.Get(),
		n64szTexture * nFrameIndex,
		&stCrossAdapterResDesc,
		bCrossAdapterTextureSupport ? D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE : D3D12_RESOURCE_STATE_COPY_SOURCE,
		nullptr,
		IID_PPV_ARGS(&stGPUParams[nIDGPUSecondary].m_pICrossAdapterResPerFrame[nFrameIndex])));

	if ( ! bCrossAdapterTextureSupport )
	{
		// If the primary adapter's render target must be shared as a buffer,
		// create a texture resource to copy it into on the secondary adapter.
		GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pID3DDevice->CreateCommittedResource(
			&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
			D3D12_HEAP_FLAG_NONE,
			&stRenderTargetDesc,
			D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
			nullptr,
			IID_PPV_ARGS(&pISecondaryAdapterTexutrePerFrame[nFrameIndex])));
	}
}

这里有个细节需要引起大家注意,就是当我们判断出第二个显卡支持跨显卡共享资源时,其含义并不是说我们能不能创建共享的显存堆,其实从上面代码大家应该看出来,首先无论我们的bCrossAdapterTextureSupport是否为真,显存堆是一定能够共享的,其次这标志是说第二个显卡支不支持Row Major(行主序)排列方式的2D资源,如果支持我们就直接创建成2D Texture即可,否则我们就需要创建成一个Buffer,并且将这个共享的资源作为复制的源,然后我们额外的创建一组对应的2D纹理,用于将共享过来的资源复制到这组纹理中。最终形成如下图所示的共享资源的形式:

所以最终我们就可以理解之前那个检测显卡是否支持共享资源的判定调用其真实含义只是说能不能以行主序形式直接共享成2D纹理,而不是说不能共享资源或显存堆。这其实也是说共享堆上的资源格式被限定死为Row Major 2D Texture而已。或者用流行的话说,这个判定为我们指定了正确的打开方式!

综上,其实截止目前,D3D12还只能支持在离散显卡间共享Row Major 形式的2D Texture,还无法直接共享渲染目标纹理和深度缓冲等。要想共享渲染目标和深度缓冲的话,就需要额外创建与Render Target和Depth Buffer格式一致的共享资源,然后当第一个显卡渲染完成后(也就是先渲染到纹理,上一讲已经讲过该技术),通过复制引擎,将渲染结果复制到共享资源中,然后第二个显卡再将共享资源形式的渲染结果Copy件,通过复制引擎复制到第二个显卡的渲染目标纹理和深度缓冲中,然后不Clear就直接接着渲染即可。或者可以使用这个渲染结果的Copy件作为纹理,或做一些其他的后处理等。下面的示意图就形象的演示了最终我们利用离散多显卡渲染形成的渲染过程:

从上图我们就可以看出来这样至少两个显卡的3D引擎被充分利用了,然后先后接力形成并行运行的状态,这样本来串行的Post Proc(后处理)过程,就可以在第二个GPU的3D引擎上被并行的执行,从而整体上提高了性能。

4、异构多显卡渲染框架示例——核显+独显方式

碍于我手头硬件的限制,我没法使用两个差不多的离散显卡,比如一个Nvidia的显卡和一个AMD的差不多类似等级的显卡做接力渲染示例。

所以我就演示下在像我这样的笔记本上,使用一个Intel的核显+一个Nvidia的独显,如何具体实现异构多显卡渲染的基本框架。

需要注意的是为了大家能够快速的理解异构多显卡渲染的基本框架,所以我把D3D12微软官方例子中的Blur(运动模糊)特效后处理直接给省略了。只是让第二个显卡简单的使用渲染单位矩形的方式将共享过来的渲染结果最终显示出来即可。这样虽然效果看上去和之前的几章教程没有什么大的差别,但实质的代码中已经是利用了两个显卡进行了接力渲染。大家有兴趣的可以再仿照那个例子(D3D12HeterogeneousMultiadapter),将Blur后处理加回来,就当是练习,当然我想如果你弄明白了本章的内容以及上一章渲染到纹理的内容,那么这个改造其实很简单。

4.1、创建多个设备对象

既然要多显卡渲染,那么第一步我们首先要做的就是创建每个显卡的设备对象。在我的笔记本上,因为Intel核显能力较弱,而Nvidia的独显能力相对较强,所以我们选择使用Intel的核显作为辅助显卡,而将Nvidia的显卡作为主显卡。

在代码中这就需要相对“智能”点的方法来判定主次。与微软官方例子不同,我是提供了另外两种方法,来做这个主次区分,第一种方法是通过看那个显卡带有显示器来判定,因为笔记本上通常显示器是直接接驳在核显上的,这与台式机不同,所以有显示输出的在我的笔记本上就是核显;第二种方法就是使用我们之前教程中讲过的看谁是UMA架构的显卡,谁就是辅助显卡。而在微软官方的例子中它就是使用序号0的显卡作为辅助显卡,因为在系统中一般核显的序号是0。

具体代码如下:

D3D12_FEATURE_DATA_ARCHITECTURE stArchitecture = {};
IDXGIAdapter1*	pIAdapterTmp	= nullptr;
ID3D12Device4*	pID3DDeviceTmp	= nullptr;
IDXGIOutput*	pIOutput		= nullptr;
HRESULT			hrEnumOutput	= S_OK;

for ( UINT nAdapterIndex = 0; DXGI_ERROR_NOT_FOUND != pIDXGIFactory5->EnumAdapters1(nAdapterIndex, &pIAdapterTmp); ++ nAdapterIndex)
{
	DXGI_ADAPTER_DESC1 stAdapterDesc = {};
	pIAdapterTmp->GetDesc1(&stAdapterDesc);

	if (stAdapterDesc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE)
	{//跳过软件虚拟适配器设备
		GRS_SAFE_RELEASE(pIAdapterTmp);
		continue;
	}

	//第一种方法,通过判定那个显卡带有输出
	hrEnumOutput = pIAdapterTmp->EnumOutputs(0, &pIOutput);

	if ( SUCCEEDED(hrEnumOutput) && nullptr != pIOutput)
	{//该适配器带有显示输出,通常是集显(针对笔记本的情况)
		//我们将集显称为Main Device,因为用它来后处理和最终输出
		GRS_THROW_IF_FAILED(D3D12CreateDevice(pIAdapterTmp, D3D_FEATURE_LEVEL_12_1, IID_PPV_ARGS(&stGPUParams[nIDGPUMain].m_pID3DDevice)));
	}
	else
	{//不带显示输出的,通常是独显(针对笔记本的情况)
		//我们用独显来完成主场景渲染,当然它就是渲染到纹理,后面会看到我们使用的是共享显存的纹理
		GRS_THROW_IF_FAILED(D3D12CreateDevice(pIAdapterTmp, D3D_FEATURE_LEVEL_12_1, IID_PPV_ARGS(&stGPUParams[nIDGPUSecondary].m_pID3DDevice)));
	}

	GRS_SAFE_RELEASE(pIOutput);


	//第二种判定主次显卡的方法,就是看谁是UMA的谁不是,这个在之前的教程示例中已经详细讲解过

	//GRS_THROW_IF_FAILED(D3D12CreateDevice(pIAdapterTmp, D3D_FEATURE_LEVEL_12_1, IID_PPV_ARGS(&pID3DDeviceTmp)));
	//GRS_THROW_IF_FAILED(pID3DDeviceTmp->CheckFeatureSupport(D3D12_FEATURE_ARCHITECTURE
	//	, &stArchitecture, sizeof(D3D12_FEATURE_DATA_ARCHITECTURE)));
	或者我们通过判定是否UMA架构来决定谁主谁辅
	//if ( stArchitecture.UMA )
	//{
	//	if ( nullptr == pID3DDeviceSecondary.Get() )
	//	{
	//		pID3DDeviceSecondary.Attach(pID3DDeviceTmp);
	//		pID3DDeviceTmp = nullptr;
	//	}
	//	else
	//	{
	//		//你的显卡太多了,你自己看怎么用吧,方法都告诉你了
	//	}
	//}
	//else
	//{
	//	if ( nullptr == pID3DDeviceMain.Get() )
	//	{
	//		pID3DDeviceMain.Attach(pID3DDeviceTmp);
	//		pID3DDeviceTmp = nullptr;
	//	}
	//	else
	//	{
	//		//你的显卡太多了,你自己看怎么用吧,方法都告诉你了
	//	}

	//}

	//GRS_SAFE_RELEASE(pID3DDeviceTmp);

	GRS_SAFE_RELEASE(pIAdapterTmp);
}

//---------------------------------------------------------------------------------------------
if ( nullptr == stGPUParams[nIDGPUMain].m_pID3DDevice.Get() || nullptr == stGPUParams[nIDGPUSecondary].m_pID3DDevice.Get() )
{// 可怜的机器上居然没有两个以上的显卡 还是先退出了事 当然你可以使用软适配器凑活看下例子
	throw CGRSCOMException(E_FAIL);
}

GRS_SET_D3D12_DEBUGNAME_COMPTR(stGPUParams[nIDGPUMain].m_pID3DDevice);
GRS_SET_D3D12_DEBUGNAME_COMPTR(stGPUParams[nIDGPUSecondary].m_pID3DDevice);

需要注意的是,为了方便,我将每个GPU单独的设备对象及相关的单节点对象都放在了一个统一的结构体里,如下:

// 显卡参数集合
struct ST_GRS_GPU_PARAMS
{
	UINT								m_nIndex;
	UINT								m_nszRTV;
	UINT								m_nszSRVCBVUAV;

	ComPtr<ID3D12Device4>				m_pID3DDevice;
	ComPtr<ID3D12CommandQueue>			m_pICmdQueue;
	ComPtr<ID3D12DescriptorHeap>		m_pIDHRTV;
	ComPtr<ID3D12Resource>				m_pIRTRes[g_nFrameBackBufCount];
	ComPtr<ID3D12CommandAllocator>		m_pICmdAllocPerFrame[g_nFrameBackBufCount];
	ComPtr<ID3D12GraphicsCommandList>	m_pICmdList;
	ComPtr<ID3D12Heap>					m_pICrossAdapterHeap;
	ComPtr<ID3D12Resource>				m_pICrossAdapterResPerFrame[g_nFrameBackBufCount];
	ComPtr<ID3D12Fence>					m_pIFence;
	ComPtr<ID3D12Fence>					m_pISharedFence;
	ComPtr<ID3D12Resource>				m_pIDSTex;
	ComPtr<ID3D12DescriptorHeap>		m_pIDHDSVTex;
};

代码中的stGPUParams数组变量就是这个结构体类型。

4.2、创建交换链及渲染目标

因为笔记本构造的特殊性,通常其显示器都是直接接驳在核显上,所以我们就在核显Intel的G530显卡的命令队列上创建交换链。同时,我们在作为主显卡的Nvidia的独显上创建用作渲染目标的纹理。具体代码如下:

DXGI_SWAP_CHAIN_DESC1 stSwapChainDesc = {};
stSwapChainDesc.BufferCount = g_nFrameBackBufCount;
stSwapChainDesc.Width = iWndWidth;
stSwapChainDesc.Height = iWndHeight;
stSwapChainDesc.Format = emRTFmt;
stSwapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
stSwapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
stSwapChainDesc.SampleDesc.Count = 1;

GRS_THROW_IF_FAILED(pIDXGIFactory5->CreateSwapChainForHwnd(
	stGPUParams[nIDGPUSecondary].m_pICmdQueue.Get(),		// 使用接驳了显示器的显卡的命令队列做为交换链的命令队列
	hWnd,
	&stSwapChainDesc,
	nullptr,
	nullptr,
	&pISwapChain1
));
GRS_SET_DXGI_DEBUGNAME_COMPTR(pISwapChain1);

//注意此处使用了高版本的SwapChain接口的函数
GRS_THROW_IF_FAILED(pISwapChain1.As(&pISwapChain3));
GRS_SET_DXGI_DEBUGNAME_COMPTR(pISwapChain3);

// 获取当前第一个供绘制的后缓冲序号
nCurrentFrameIndex = pISwapChain3->GetCurrentBackBufferIndex();

//创建RTV(渲染目标视图)描述符堆(这里堆的含义应当理解为数组或者固定大小元素的固定大小显存池)
D3D12_DESCRIPTOR_HEAP_DESC		stRTVHeapDesc = {};
stRTVHeapDesc.NumDescriptors	= g_nFrameBackBufCount;
stRTVHeapDesc.Type				= D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
stRTVHeapDesc.Flags				= D3D12_DESCRIPTOR_HEAP_FLAG_NONE;

for (int i = 0; i < nMaxGPUParams; i++)
{
	GRS_THROW_IF_FAILED(stGPUParams[i].m_pID3DDevice->CreateDescriptorHeap(&stRTVHeapDesc, IID_PPV_ARGS(&stGPUParams[i].m_pIDHRTV)));
	GRS_SetD3D12DebugNameIndexed(stGPUParams[i].m_pIDHRTV.Get(), _T("m_pIDHRTV"), i);
}
CD3DX12_CLEAR_VALUE   stClearValue(stSwapChainDesc.Format, arf4ClearColor);
stRenderTargetDesc = CD3DX12_RESOURCE_DESC::Tex2D(
	stSwapChainDesc.Format,
	stSwapChainDesc.Width,
	stSwapChainDesc.Height,
	1u, 1u,
	stSwapChainDesc.SampleDesc.Count,
	stSwapChainDesc.SampleDesc.Quality,
	D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET,
	D3D12_TEXTURE_LAYOUT_UNKNOWN, 0u);

for ( UINT iGPUIndex = 0; iGPUIndex < nMaxGPUParams; iGPUIndex++ )
{
	CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(stGPUParams[iGPUIndex].m_pIDHRTV->GetCPUDescriptorHandleForHeapStart());

	for (UINT j = 0; j < g_nFrameBackBufCount; j++)
	{
		if ( iGPUIndex == nIDGPUSecondary )
		{
			GRS_THROW_IF_FAILED(pISwapChain3->GetBuffer(j, IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pIRTRes[j])));
		}
		else
		{
			GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommittedResource(
				&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
				D3D12_HEAP_FLAG_NONE,
				&stRenderTargetDesc,
				D3D12_RESOURCE_STATE_COMMON,
				&stClearValue,
				IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pIRTRes[j])));
		}

		stGPUParams[iGPUIndex].m_pID3DDevice->CreateRenderTargetView(stGPUParams[iGPUIndex].m_pIRTRes[j].Get(), nullptr, rtvHandle);
		rtvHandle.Offset(1, stGPUParams[iGPUIndex].m_nszRTV);

		GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT
			, IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pICmdAllocPerFrame[j])));

		if ( iGPUIndex == nIDGPUMain )
		{ //创建主显卡上的复制命令队列用的命令分配器,每帧使用一个分配器
			GRS_THROW_IF_FAILED( stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommandAllocator( D3D12_COMMAND_LIST_TYPE_COPY
				, IID_PPV_ARGS(&pICmdAllocCopyPerFrame[j])));
		}
	}

	// 创建每个GPU的命令列表对象
	GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommandList(0
		, D3D12_COMMAND_LIST_TYPE_DIRECT
		, stGPUParams[iGPUIndex].m_pICmdAllocPerFrame[nCurrentFrameIndex].Get()
		, nullptr
		, IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pICmdList)));

	if ( iGPUIndex == nIDGPUMain )
	{// 在主显卡上创建复制命令列表对象
		GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommandList(0
			, D3D12_COMMAND_LIST_TYPE_COPY
			, pICmdAllocCopyPerFrame[nCurrentFrameIndex].Get()
			, nullptr
			, IID_PPV_ARGS(&pICmdListCopy)));

	}

	// 创建每个显卡上的深度蜡板缓冲区
	D3D12_CLEAR_VALUE stDepthOptimizedClearValue = {};
	stDepthOptimizedClearValue.Format = emDSFmt;
	stDepthOptimizedClearValue.DepthStencil.Depth = 1.0f;
	stDepthOptimizedClearValue.DepthStencil.Stencil = 0;

	//使用隐式默认堆创建一个深度蜡板缓冲区,
	//因为基本上深度缓冲区会一直被使用,重用的意义不大,所以直接使用隐式堆,图方便
	GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateCommittedResource(
		&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT)
		, D3D12_HEAP_FLAG_NONE
		, &CD3DX12_RESOURCE_DESC::Tex2D(
			emDSFmt
			, iWndWidth
			, iWndHeight
			, 1
			, 0
			, 1
			, 0
			, D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL)
		, D3D12_RESOURCE_STATE_DEPTH_WRITE
		, &stDepthOptimizedClearValue
		, IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pIDSTex)
	));
	GRS_SetD3D12DebugNameIndexed(stGPUParams[iGPUIndex].m_pIDSTex.Get(), _T("m_pIDSTex"), iGPUIndex);

	D3D12_DEPTH_STENCIL_VIEW_DESC stDepthStencilDesc = {};
	stDepthStencilDesc.Format = emDSFmt;
	stDepthStencilDesc.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
	stDepthStencilDesc.Flags = D3D12_DSV_FLAG_NONE;

	D3D12_DESCRIPTOR_HEAP_DESC dsvHeapDesc = {};
	dsvHeapDesc.NumDescriptors = 1;
	dsvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
	dsvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
	GRS_THROW_IF_FAILED(stGPUParams[iGPUIndex].m_pID3DDevice->CreateDescriptorHeap(&dsvHeapDesc, IID_PPV_ARGS(&stGPUParams[iGPUIndex].m_pIDHDSVTex)));
	GRS_SetD3D12DebugNameIndexed(stGPUParams[iGPUIndex].m_pIDHDSVTex.Get(), _T("m_pIDHDSVTex"), iGPUIndex);

	stGPUParams[iGPUIndex].m_pID3DDevice->CreateDepthStencilView(stGPUParams[iGPUIndex].m_pIDSTex.Get()
		, &stDepthStencilDesc
		, stGPUParams[iGPUIndex].m_pIDHDSVTex->GetCPUDescriptorHandleForHeapStart());
}

这里要注意的就是,在这个例子中我们保留了与微软官方例子一致的创建与后缓冲区数量相同的命令分配器对象,这主要是为了开拓大家的思路,不要局限于一个命令列表就只能配一个命令分配器的思路上。同时这也是说,其实最终的渲染命令是写在命令分配器中的,渲染过程中命令分配器不用动,就可以继续执行,而命令列表在ExecuteCommandList之后就可以使用下一个命令分配器去记录下一轮的命令了。

最后要注意的是虽然我们引用了链接多显卡系统中的单节点对象的概念,但其实在离散多显卡系统中,不用去指定Node Mask。因为每个独立的显卡都是一个独立的Adapter,然后Device对象也是独立的,而这时每个Device上其实可能只有一个GPU node。当然如果你家里有矿的话,可以组一个带核显和独立SLI显卡的复杂系统出来,然后多显卡渲染走一波试试看。​​​​​​​

4.3、创建跨GPU共享资源堆和共享资源

共享堆和共享资源的代码前一节已经讲解并演示了,就不在重复了。

4.4、创建单位矩形和模拟后处理渲染

与上一章使用的正交投影渲染矩形不同,我们这一章使用的方法是利用正交标准化渲染一个大小为单位1 的全屏矩形。具体的矩形框的定义如下:

ST_GRS_VERTEX_QUAD stVertexQuad[] =
{   //(   x,     y,    z,    w   )  (  u,    v   )
    { { -1.0f,  1.0f, 0.0f, 1.0f }, { 0.0f, 0.0f } },   // Top left.
    { {  1.0f,  1.0f, 0.0f, 1.0f }, { 1.0f, 0.0f } },   // Top right.
    { { -1.0f, -1.0f, 0.0f, 1.0f }, { 0.0f, 1.0f } },   // Bottom left.
    { {  1.0f, -1.0f, 0.0f, 1.0f }, { 1.0f, 1.0f } },   // Bottom right.
};

尤其请注意其中的纹理坐标顺序,因为纹理坐标的V轴实质也是竖直向下为正方向,所以如果你使用了微软官方例子中那个矩形的顶点定义的话,你会发现你的画面会上下颠倒。

对应的shader中也就没有透视变换等过程,shader代码如下:

struct PSInput
{
    float4 position : SV_POSITION;
    float2 uv : TEXCOORD;
};

Texture2D g_texture : register(t0);
SamplerState g_sampler : register(s0);

PSInput VSMain(float4 position : POSITION, float2 uv : TEXCOORD)
{
    PSInput result;
    result.position = position;
    result.uv = uv;
    return result;
}

float4 PSMain(PSInput input) : SV_TARGET
{
    return g_texture.Sample(g_sampler, input.uv);
}

因为在光栅化之后所有的坐标都会标准化到-1至1之间,所以不用额外的变换,我们的矩形就正好贴满了整个窗口。因此这里的渲染全屏矩形的技术与我们在前一章中使用的方法是完全不同的。这里的方法仅适用于类似渲染后处理的全屏纹理方法中,因为它的坐标,大小、位置都是不好控制的。

在这章的教程中,我特意还保留了将顶点坐标上传到默认堆中的过程,不再使用类似之前教程中的直接使用上传堆的偷懒方法了,算是作为教程完整性的一个补充吧。具体代码如下:

const UINT nszVBQuad = sizeof(stVertexQuad);

GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pID3DDevice->CreateCommittedResource(
	&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
	D3D12_HEAP_FLAG_NONE,
	&CD3DX12_RESOURCE_DESC::Buffer(nszVBQuad),
	D3D12_RESOURCE_STATE_COPY_DEST,
	nullptr,
	IID_PPV_ARGS(&pIVBQuad)));

// 这次我们特意演示了如何将顶点缓冲上传到默认堆上的方式,与纹理上传默认堆实际是一样的
GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pID3DDevice->CreateCommittedResource(
	&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
	D3D12_HEAP_FLAG_NONE,
	&CD3DX12_RESOURCE_DESC::Buffer(nszVBQuad),
	D3D12_RESOURCE_STATE_GENERIC_READ,
	nullptr,
	IID_PPV_ARGS(&pIVBQuadUpload)));

D3D12_SUBRESOURCE_DATA stVBDataQuad = {};
stVBDataQuad.pData = reinterpret_cast<UINT8*>(stVertexQuad);
stVBDataQuad.RowPitch = nszVBQuad;
stVBDataQuad.SlicePitch = stVBDataQuad.RowPitch;

UpdateSubresources<1>(stGPUParams[nIDGPUSecondary].m_pICmdList.Get(), pIVBQuad.Get(), pIVBQuadUpload.Get(), 0, 0, 1, &stVBDataQuad);
stGPUParams[nIDGPUSecondary].m_pICmdList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pIVBQuad.Get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER));

pstVBVQuad.BufferLocation = pIVBQuad->GetGPUVirtualAddress();
pstVBVQuad.StrideInBytes = sizeof(ST_GRS_VERTEX_QUAD);
pstVBVQuad.SizeInBytes = sizeof(stVertexQuad);

4.5、创建跨GPU的围栏同步对象

正如前面所提到的,其实多显卡渲染之后,我们本质上利用的是多个显卡上的3D引擎来并行的执行渲染命令的方式来获取性能上的提升。在理解上,其实这时我们可以将3D命令队列,甚至复制命令队列及计算命令队列等想象为与CPU线程类似的等价物,在有些资料上甚至直接将这些队列称之为GPU线程。那么最终的问题其实就剩下我们如何控制这些队列间的同步了,尤其是如何控制多个GPU上的多个GPU线程队列间的同步了。

这时我们任然需要Fence围栏对象来帮忙。其实在之前我们已经讲过如果使用Fence对象来完成CPU与GPU之间的同步等待的方法。

那么要使用Fence对象来完成多个GPU线程(命令队列)之间的同步的话,首要的问题就是一个GPU怎么直接知道和理解另一个GPU创建的Fence对象呢?其实这时仍然需要使用CreateSharedHandle- OpenSharedHandle大法,直接在多个GPU之间创建共享围栏对象,具体的代码如下:

// 在主显卡上创建一个可共享的围栏对象
GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pID3DDevice->CreateFence(0
    , D3D12_FENCE_FLAG_SHARED | D3D12_FENCE_FLAG_SHARED_CROSS_ADAPTER
    , IID_PPV_ARGS(&stGPUParams[nIDGPUMain].m_pISharedFence)));

// 共享这个围栏,通过句柄方式
HANDLE hFenceShared = nullptr;
GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pID3DDevice->CreateSharedHandle(
    stGPUParams[nIDGPUMain].m_pISharedFence.Get(),
    nullptr,
    GENERIC_ALL,
    nullptr,
    &hFenceShared));

// 在辅助显卡上打开这个围栏对象完成共享
HRESULT hrOpenSharedHandleResult = stGPUParams[nIDGPUSecondary].m_pID3DDevice->OpenSharedHandle(hFenceShared
    , IID_PPV_ARGS(&stGPUParams[nIDGPUSecondary].m_pISharedFence));

// 先关闭句柄,再判定是否共享成功
::CloseHandle(hFenceShared);
GRS_THROW_IF_FAILED(hrOpenSharedHandleResult);

上面的代码不难理解,既然到了这里,那么让我们稍微的开一下脑洞。回忆刚才我们已经通过这种方式使显存堆完成了跨显卡共享,那么将来有没有可能很多对象都都能使用这个方法来完成共享呢?我估计有可能,同时未来,为了更充分利用多显卡渲染的优势,尤其是离散多显卡渲染的优势,我估计微软会将共享资源时的一些限制进一步打破,很可能直接可以在多个显卡间共享渲染目标和深度缓冲了。当然也不好说会不会这样,全当瞎猜吧。但是假如真的能这样的话,我想离散多显卡渲染应该很快会流行起来。因为现在AMD的APU中的显卡已经不弱鸡了,而且根据Intel的官宣它家的核显也将大大提升性能,同时它也开始准备研发独显了,所以可以预见的未来,系统中有多个比较强悍的GPU的时代已经不远了。彼时实时光追渲染技术也应该很流行了,为了极致的画面效果,天生就可以多GPU渲染的光追技术又怎会放过系统中那么多GPU呢?​​​​​​​

4.6、渲染过程

接下来在渲染时,我们就使用主显卡渲染到纹理中,接着利用主显卡上的复制命令队列将渲染结果复制到共享纹理资源中,再接着辅助显卡就使用渲染单位矩形的技术将这个纹理渲染到交换链的后缓冲中,Present之后,就完成了全部的渲染。当然当之前提到的bCrossAdapterTextureSupport为FALSE时,我们就还需要辅助显卡上的复制引擎将共享的资源复制到纹理中,然后再渲染。具体代码如下(实质是记录渲染命令的过程):

// 主显卡渲染
{
	stGPUParams[nIDGPUMain].m_pICmdList->SetGraphicsRootSignature(pIRSMain.Get());
	stGPUParams[nIDGPUMain].m_pICmdList->SetPipelineState(pIPSOMain.Get());
	stGPUParams[nIDGPUMain].m_pICmdList->RSSetViewports(1, &stViewPort);
	stGPUParams[nIDGPUMain].m_pICmdList->RSSetScissorRects(1, &stScissorRect);

	stGPUParams[nIDGPUMain].m_pICmdList->ResourceBarrier(1
		, &CD3DX12_RESOURCE_BARRIER::Transition(
			stGPUParams[nIDGPUMain].m_pIRTRes[nCurrentFrameIndex].Get()
			, D3D12_RESOURCE_STATE_COMMON
			, D3D12_RESOURCE_STATE_RENDER_TARGET));

	CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(
		stGPUParams[nIDGPUMain].m_pIDHRTV->GetCPUDescriptorHandleForHeapStart()
		, nCurrentFrameIndex
		, stGPUParams[nIDGPUMain].m_nszRTV);
	CD3DX12_CPU_DESCRIPTOR_HANDLE dsvHandle(stGPUParams[nIDGPUMain].m_pIDHDSVTex->GetCPUDescriptorHandleForHeapStart());

	stGPUParams[nIDGPUMain].m_pICmdList->OMSetRenderTargets(1, &rtvHandle, false, &dsvHandle);
	stGPUParams[nIDGPUMain].m_pICmdList->ClearRenderTargetView(rtvHandle, arf4ClearColor, 0, nullptr);
	stGPUParams[nIDGPUMain].m_pICmdList->ClearDepthStencilView(dsvHandle, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);
	//==================================================================================================
	//执行实际的物体绘制渲染,Draw Call!
	for (int i = 0; i < nMaxObject; i++)
	{
		ID3D12DescriptorHeap* ppHeapsSkybox[] = { stModuleParams[i].pISRVCBVHp.Get(),stModuleParams[i].pISampleHp.Get() };
		stGPUParams[nIDGPUMain].m_pICmdList->SetDescriptorHeaps(_countof(ppHeapsSkybox), ppHeapsSkybox);
		stGPUParams[nIDGPUMain].m_pICmdList->ExecuteBundle(stModuleParams[i].pIBundle.Get());
	}
	//==================================================================================================

	stGPUParams[nIDGPUMain].m_pICmdList->ResourceBarrier(1
		, &CD3DX12_RESOURCE_BARRIER::Transition(
			stGPUParams[nIDGPUMain].m_pIRTRes[nCurrentFrameIndex].Get()
			, D3D12_RESOURCE_STATE_RENDER_TARGET
			, D3D12_RESOURCE_STATE_COMMON));

	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pICmdList->Close());
}

// 将主显卡的渲染结果复制到共享纹理资源中
{
	if (bCrossAdapterTextureSupport)
	{
		// If cross-adapter row-major textures are supported by the adapter,
		// simply copy the texture into the cross-adapter texture.
		pICmdListCopy->CopyResource(stGPUParams[nIDGPUMain].m_pICrossAdapterResPerFrame[nCurrentFrameIndex].Get()
			, stGPUParams[nIDGPUMain].m_pIRTRes[nCurrentFrameIndex].Get());
	}
	else
	{
		// If cross-adapter row-major textures are not supported by the adapter,
		// the texture will be copied over as a buffer so that the texture row
		// pitch can be explicitly managed.

		// Copy the intermediate render target into the shared buffer using the
		// memory layout prescribed by the render target.
		D3D12_RESOURCE_DESC stRenderTargetDesc = stGPUParams[nIDGPUMain].m_pIRTRes[nCurrentFrameIndex]->GetDesc();
		D3D12_PLACED_SUBRESOURCE_FOOTPRINT stRenderTargetLayout = {};

		stGPUParams[nIDGPUMain].m_pID3DDevice->GetCopyableFootprints(&stRenderTargetDesc, 0, 1, 0, &stRenderTargetLayout, nullptr, nullptr, nullptr);

		CD3DX12_TEXTURE_COPY_LOCATION dest(stGPUParams[nIDGPUMain].m_pICrossAdapterResPerFrame[nCurrentFrameIndex].Get(), stRenderTargetLayout);
		CD3DX12_TEXTURE_COPY_LOCATION src(stGPUParams[nIDGPUMain].m_pIRTRes[nCurrentFrameIndex].Get(), 0);
		CD3DX12_BOX box(0, 0, iWndWidth, iWndHeight);

		pICmdListCopy->CopyTextureRegion(&dest, 0, 0, 0, &src, &box);
	}

	GRS_THROW_IF_FAILED(pICmdListCopy->Close());
}

// 开始辅助显卡的渲染,通常是后处理,比如运动模糊等,我们这里直接就是把画面绘制出来
{
	if (!bCrossAdapterTextureSupport)
	{
		// Copy the buffer in the shared heap into a texture that the secondary
		// adapter can sample from.
		D3D12_RESOURCE_BARRIER stResBarrier = CD3DX12_RESOURCE_BARRIER::Transition(
			pISecondaryAdapterTexutrePerFrame[nCurrentFrameIndex].Get(),
			D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
			D3D12_RESOURCE_STATE_COPY_DEST);

		stGPUParams[nIDGPUSecondary].m_pICmdList->ResourceBarrier(1, &stResBarrier);

		// Copy the shared buffer contents into the texture using the memory
		// layout prescribed by the texture.
		D3D12_RESOURCE_DESC stSecondaryAdapterTexture = pISecondaryAdapterTexutrePerFrame[nCurrentFrameIndex]->GetDesc();
		D3D12_PLACED_SUBRESOURCE_FOOTPRINT stTextureLayout = {};

		stGPUParams[nIDGPUSecondary].m_pID3DDevice->GetCopyableFootprints(&stSecondaryAdapterTexture, 0, 1, 0, &stTextureLayout, nullptr, nullptr, nullptr);

		CD3DX12_TEXTURE_COPY_LOCATION dest(pISecondaryAdapterTexutrePerFrame[nCurrentFrameIndex].Get(), 0);
		CD3DX12_TEXTURE_COPY_LOCATION src(stGPUParams[nIDGPUSecondary].m_pICrossAdapterResPerFrame[nCurrentFrameIndex].Get(), stTextureLayout);
		CD3DX12_BOX box(0, 0, iWndWidth, iWndHeight);

		stGPUParams[nIDGPUSecondary].m_pICmdList->CopyTextureRegion(&dest, 0, 0, 0, &src, &box);

		stResBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
		stResBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
		stGPUParams[nIDGPUSecondary].m_pICmdList->ResourceBarrier(1, &stResBarrier);
	}

	stGPUParams[nIDGPUSecondary].m_pICmdList->SetGraphicsRootSignature(pIRSQuad.Get());
	stGPUParams[nIDGPUSecondary].m_pICmdList->SetPipelineState(pIPSOQuad.Get());
	ID3D12DescriptorHeap* ppHeaps[] = { pIDHSRVSecondary.Get(),pIDHSampleSecondary.Get() };
	stGPUParams[nIDGPUSecondary].m_pICmdList->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);

	stGPUParams[nIDGPUSecondary].m_pICmdList->RSSetViewports(1, &stViewPort);
	stGPUParams[nIDGPUSecondary].m_pICmdList->RSSetScissorRects(1, &stScissorRect);

	D3D12_RESOURCE_BARRIER stRTSecondaryBarriers = CD3DX12_RESOURCE_BARRIER::Transition(
		stGPUParams[nIDGPUSecondary].m_pIRTRes[nCurrentFrameIndex].Get()
		, D3D12_RESOURCE_STATE_PRESENT
		, D3D12_RESOURCE_STATE_RENDER_TARGET);

	stGPUParams[nIDGPUSecondary].m_pICmdList->ResourceBarrier(1, &stRTSecondaryBarriers);

	CD3DX12_CPU_DESCRIPTOR_HANDLE rtvSecondaryHandle(
		stGPUParams[nIDGPUSecondary].m_pIDHRTV->GetCPUDescriptorHandleForHeapStart()
		, nCurrentFrameIndex
		, stGPUParams[nIDGPUSecondary].m_nszRTV);
	stGPUParams[nIDGPUSecondary].m_pICmdList->OMSetRenderTargets(1, &rtvSecondaryHandle, false, nullptr);
	float f4ClearColor[] = {1.0f,0.0f,0.0f,1.0f}; //故意使用与主显卡渲染目标不同的清除色,查看是否有“露底”的问题
	stGPUParams[nIDGPUSecondary].m_pICmdList->ClearRenderTargetView(rtvSecondaryHandle, f4ClearColor, 0, nullptr);

	// 开始绘制矩形
	CD3DX12_GPU_DESCRIPTOR_HANDLE srvHandle(
		pIDHSRVSecondary->GetGPUDescriptorHandleForHeapStart()
		, nCurrentFrameIndex
		, stGPUParams[nIDGPUSecondary].m_nszSRVCBVUAV);
	stGPUParams[nIDGPUSecondary].m_pICmdList->SetGraphicsRootDescriptorTable(0, srvHandle);
	stGPUParams[nIDGPUSecondary].m_pICmdList->SetGraphicsRootDescriptorTable(1, pIDHSampleSecondary->GetGPUDescriptorHandleForHeapStart());

	stGPUParams[nIDGPUSecondary].m_pICmdList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
	stGPUParams[nIDGPUSecondary].m_pICmdList->IASetVertexBuffers(0, 1, &pstVBVQuad);
	// Draw Call!
	stGPUParams[nIDGPUSecondary].m_pICmdList->DrawInstanced(4, 1, 0, 0);

	// 设置好同步围栏
	stGPUParams[nIDGPUSecondary].m_pICmdList->ResourceBarrier(1
		, &CD3DX12_RESOURCE_BARRIER::Transition(
			stGPUParams[nIDGPUSecondary].m_pIRTRes[nCurrentFrameIndex].Get()
			, D3D12_RESOURCE_STATE_RENDER_TARGET
			, D3D12_RESOURCE_STATE_PRESENT));
	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pICmdList->Close());
}

 

4.7、多GPU间同步

最后所有的命令都记录完成后,我们就使用各自显卡上的命令队列去执行这些命令列表,代码如下:

{// 第一步:在主显卡的主命令队列上执行主命令列表
	ID3D12CommandList* ppRenderCommandLists[] = { stGPUParams[nIDGPUMain].m_pICmdList.Get() };
	stGPUParams[nIDGPUMain].m_pICmdQueue->ExecuteCommandLists(_countof(ppRenderCommandLists), ppRenderCommandLists);

	n64fence = n64FenceValue;
	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUMain].m_pICmdQueue->Signal(stGPUParams[nIDGPUMain].m_pIFence.Get(), n64fence));
	n64FenceValue++;
}

{// 第二步:使用主显卡上的复制命令队列完成渲染目标资源到共享资源间的复制
	// 通过调用命令队列的Wait命令实现同一个GPU上的各命令队列之间的等待同步 
	GRS_THROW_IF_FAILED(pICmdQueueCopy->Wait(stGPUParams[nIDGPUMain].m_pIFence.Get(), n64fence));

	ID3D12CommandList* ppCopyCommandLists[] = { pICmdListCopy.Get() };
	pICmdQueueCopy->ExecuteCommandLists(_countof(ppCopyCommandLists), ppCopyCommandLists);

	n64fence = n64FenceValue;
	// 复制命令的信号设置在共享的围栏对象上这样使得第二个显卡的命令队列可以在这个围栏上等待
	// 从而完成不同GPU命令队列间的同步等待
	GRS_THROW_IF_FAILED(pICmdQueueCopy->Signal(stGPUParams[nIDGPUMain].m_pISharedFence.Get(), n64fence));
	n64FenceValue++;
}

{// 第三步:使用辅助显卡上的主命令队列执行命令列表
	// 辅助显卡上的主命令队列通过等待共享的围栏对象最终完成了与主显卡之间的同步
	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pICmdQueue->Wait(
		stGPUParams[nIDGPUSecondary].m_pISharedFence.Get()
		, n64fence));
	
	ID3D12CommandList* ppBlurCommandLists[] = { stGPUParams[nIDGPUSecondary].m_pICmdList.Get() };
	stGPUParams[nIDGPUSecondary].m_pICmdQueue->ExecuteCommandLists(_countof(ppBlurCommandLists), ppBlurCommandLists);
}

// 执行Present命令最终呈现画面
GRS_THROW_IF_FAILED(pISwapChain3->Present(1, 0));

n64fence = n64FenceValue;
GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pICmdQueue->Signal(stGPUParams[nIDGPUSecondary].m_pIFence.Get(), n64fence));
n64FenceValue++;


// 最后我们只需要在辅助显卡的围栏同步对象上等待即可完成GPU与CPU执行间的同步
UINT64 u64CompletedFenceValue = stGPUParams[nIDGPUSecondary].m_pIFence->GetCompletedValue();
if ( u64CompletedFenceValue < n64fence)
{
	GRS_THROW_IF_FAILED(stGPUParams[nIDGPUSecondary].m_pIFence->SetEventOnCompletion(
		n64fence
		, hFenceEvent));
	WaitForSingleObject(hFenceEvent, INFINITE);
}
// 此时所有GPU上的命令都已经执行完成了,完整的一帧绘制完成了,准备开始下一帧的渲染

仔细看上面的代码,会发现一个新的命令队列函数Wait,其实这个方法本身也是一个命令,是少有的几个不通过命令列表记录的命令之一,这样的命令还有我们常见的Signal和Present等。

那么命令队列的Wait函数是什么意思呢?其实它就是告诉某个GPU引擎执行到这里时就去等待某个围栏标记值,等到了之后就表示某个围栏标记值之前的命令已经全部完成了,然后再继续执行Wait之后的命令。当然对于一个GPU上的不同引擎之间,我们往往简单实用资源屏障就可以达到这个目的,因为同一个GPU上的不同引擎之间其实往往就是交互操作一些资源而已,所以只需要控制对资源访问的同步即可,而很少有单独命令级的同步需求。

而在本章例子中,多个GPU之间,就需要命令级的同步了。因为辅助显卡必须要在主显卡的复制命令队列执行完复制之后才能开始执行命令,也就是主显卡要把渲染纹理全部复制到共享资源中之后,辅助显卡才开始工作,此时就需要辅助显卡等待主显卡执行完成。因此我们就调用Wait命令在共享的围栏上完成等待。

这里需要注意的就是Wait函数是GPU命令队列上的等待命令,并不会影响当前记录这个命令的CPU线程的执行,从CPU调用来看Wait命令也是立即返回的,只是写入了GPU的命令队列而已。或者更直白的理解为CPU线程只是告诉GPU线程去等待,而自己并不需要等待。

5、性能评估和分析

整个异构多显卡渲染的基本框架搞明白之后,其实我觉得究竟这种方式会不会提升性能,或者怎样提升性能,可能各位还是比较迷茫的。那么最终让我们来看看实际渲染执行的过程示意图,也许能明白一些:

其实这幅图是来自微软官宣PPT中说的UE4引擎使用了本章介绍的异构多显卡渲染方式之后,实际测试的性能改进情况。图中横向就是时间轴,上半部分就是传统的只使用独立显卡渲染的时间耗费情况,而下半部分就是使用异构多显卡渲染之后的时间耗费情况。虽然整体来说如果累计每一帧的渲染时间的话,应该是变长了,因为毕竟Intel的核显实在是太弱了,即使是计算量相对固定的后处理,它也还是执行了很长时间,但最终得益于能够跟独显的主渲染过程并行执行,所以从总体上来看时间耗费还是下降了,性能得到了提升。只是目前这种提升还是比较有限的。但未来不好说随着核显性能的提升这种方式在实时光追渲染中会不会给我们带来什么惊喜呢?还是拭目以待吧!或者如果后处理更复杂,我估计性能提升就会更明显。

6、全部代码和下载链接

链接:GRSD3D12MultiAdapter.rar

提取码:zyv5

二维码:

7、后记

至此,本系列教程中我想讲的关于D3D12自身原理、接口、函数,以及基本使用方法等内容算是告一个小段落了。仅这些内容,我基本就耗时半年多,很感慨,一是庆幸于自己基本功还算好,没被D3D12给坑死;二是感觉自己需要学习的东西还有很多很多,照这么个进度,真不知何年何月是个头。而整体上这一系列教程其实还需要进一步加工整理,才算能让大家彻底搞得明白,而这点小事情都不知道什么时候会再有时间。

最终其实我最感慨的就是时间真的是太快了!人的一生只有短短的三万多天,而我剩下的可能只有一万多天了,愿未来我能跑快点再跑快点,不求跑赢时间,只求在有生之年,能够体验下完成一个梦想的感觉!

其实按原本的打算还有两个相对高级的话题,即多线程+多趟渲染和异构多显卡渲染+真正后处理的内容,现在不知道还要不要接着弄了,还是说照计划开始准备3D数学之旅了,而这些更高级的话题就放到最后的引擎封装中去呢?暂时我其实也没想明白,先过年吧,一切过完年再说!

© 版权声明
THE END
喜欢就支持一下吧
点赞2 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容