Zack.Zhang Game Developer

DirectX12学习——描述符(Descriptor)管理

2022-10-12
zack.zhang
UE4

最近看了一下《DirectX 12 3D 游戏开发实战》,也就是俗称的龙书。这是一本好书,介绍了DirectX 12 的一些基础知识。

但是当读到第七章时候,发现一个问题,这本书仅仅介绍了一下DirectX 12 API怎么用,但是没有讲一些更深入的东西,这样是无法应用到工程上的。

其中的一个比较困惑的问题就是,既然DirectX 12 希望开发者自己管理描述符,按照龙书那种做法(本程序有多少要渲染的SRV就申请多大的描述符堆)肯定是不行的,那有没有什么推荐的方案呢?

LerningDirectX12

参考Jeremiah van Oosten的GitHub工程,以及其配套的教程,整理了一种可行的管理方案。

总体上思路比较简单。描述符堆分为CPU可见和GPU可见,CPU可见的描述符堆是用来存放所有资源描述符的,GPU可见的描述符用于绑定到根签名。也就是说,需要绑定到根签名描述符表的描述符,会在绘制的时候从CPU可见的描述符堆中拷贝到GPU可见的描述符中。

下面看一下主要代码逻辑。

CPU可见描述符

以贴图的SRV为例,从创建一张贴图来看。

Texture::Texture( Device& device, const D3D12_RESOURCE_DESC& resourceDesc, const D3D12_CLEAR_VALUE* clearValue )
: Resource( device, resourceDesc, clearValue )
{
    CreateViews();
}

贴图的构造函数调用了CreateViews。

void Texture::CreateViews()
{
    if ( m_d3d12Resource )
    {
        auto d3d12Device = m_Device.GetD3D12Device();

        CD3DX12_RESOURCE_DESC desc( m_d3d12Resource->GetDesc() );

        // Create RTV
        if ( ( desc.Flags & D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET ) != 0 && CheckRTVSupport() )
        {
            m_RenderTargetView = m_Device.AllocateDescriptors( D3D12_DESCRIPTOR_HEAP_TYPE_RTV );
            d3d12Device->CreateRenderTargetView( m_d3d12Resource.Get(), nullptr,
                                                 m_RenderTargetView.GetDescriptorHandle() );
        }
        // Create DSV
        if ( ( desc.Flags & D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL ) != 0 && CheckDSVSupport() )
        {
            m_DepthStencilView = m_Device.AllocateDescriptors( D3D12_DESCRIPTOR_HEAP_TYPE_DSV );
            d3d12Device->CreateDepthStencilView( m_d3d12Resource.Get(), nullptr,
                                                 m_DepthStencilView.GetDescriptorHandle() );
        }
        // Create SRV
        if ( ( desc.Flags & D3D12_RESOURCE_FLAG_DENY_SHADER_RESOURCE ) == 0 && CheckSRVSupport() )
        {
            m_ShaderResourceView = m_Device.AllocateDescriptors( D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV );
            d3d12Device->CreateShaderResourceView( m_d3d12Resource.Get(), nullptr,
                                                   m_ShaderResourceView.GetDescriptorHandle() );
        }
        // Create UAV for each mip (only supported for 1D and 2D textures).
        if ( ( desc.Flags & D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS ) != 0 && CheckUAVSupport() &&
             desc.DepthOrArraySize == 1 )
        {
            m_UnorderedAccessView =
                m_Device.AllocateDescriptors( D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV, desc.MipLevels );
            for ( int i = 0; i < desc.MipLevels; ++i )
            {
                auto uavDesc = GetUAVDesc( desc, i );
                d3d12Device->CreateUnorderedAccessView( m_d3d12Resource.Get(), nullptr, &uavDesc,
                                                        m_UnorderedAccessView.GetDescriptorHandle( i ) );
            }
        }
    }
}

CreateViews函数判断要创建的贴图是什么类型的贴图,例如要创建一般的贴图,代码就会运行到创建SRV的分支。从代码可以看出创建贴图的同时也会去创建描述符,接下来看一下m_Device.AllocateDescriptors做了什么。

DescriptorAllocation Device::AllocateDescriptors( D3D12_DESCRIPTOR_HEAP_TYPE type, uint32_t numDescriptors )
{
    return m_DescriptorAllocators[type]->Allocate( numDescriptors );
}

代码里抽象出一个叫做描述符分配器(DescriptorAllocator)的对象,所有的CPU可见描述符都需要用它来分配生成。

DescriptorAllocation DescriptorAllocator::Allocate( uint32_t numDescriptors )
{
	// 多线程保护
    std::lock_guard<std::mutex> lock( m_AllocationMutex );

    DescriptorAllocation allocation;

	// 从所有可用的描述符堆中找到可用的堆
    auto iter = m_AvailableHeaps.begin();
    while ( iter != m_AvailableHeaps.end() )
    {
		// 遍历每一个描述符页
        auto allocatorPage = m_HeapPool[*iter];
		
		// 通过描述符页分配描述符
        allocation = allocatorPage->Allocate( numDescriptors );

        if ( allocatorPage->NumFreeHandles() == 0 )
        {
            iter = m_AvailableHeaps.erase( iter );
        }
        else
        {
            ++iter;
        }

        // 如果分配了一个不为空的DescriptorAllocation则完成遍历
        if ( !allocation.IsNull() )
        {
            break;
        }
    }

    // 遍历完所有描述符堆都没有成功创建出DescriptorAllocation则创建一个新的描述符页
    if ( allocation.IsNull() )
    {
        m_NumDescriptorsPerHeap = std::max( m_NumDescriptorsPerHeap, numDescriptors );
        auto newPage            = CreateAllocatorPage();

        allocation = newPage->Allocate( numDescriptors );
    }

    return allocation;
}

从代码可以看到,描述符分配器包含了多个描述符页(DescriptorAllocatorPage),每个描述符页就是一个描述符堆(ID3D12DescriptorHeap)的包装。如果描述符满了就返回空DescriptorAllocation;如果描述符没满就返回新创建的DescriptorAllocation。如果所有描述符页(也就是描述符堆)都满了没有成功分配DescriptorAllocation,就创建一个新的描述符页来创建DescriptorAllocation。这里说的DescriptorAllocation就是描述符(D3D12_CPU_DESCRIPTOR_HANDLE)的包装。

dx12lib::DescriptorAllocation DescriptorAllocatorPage::Allocate( uint32_t numDescriptors )
{
    std::lock_guard<std::mutex> lock( m_AllocationMutex );
	
	if ( numDescriptors > m_NumFreeHandles )
    {
        return dx12lib::DescriptorAllocation();
    }
	
	// ... //
	
	return DescriptorAllocation(
        CD3DX12_CPU_DESCRIPTOR_HANDLE( m_BaseDescriptor, offset, m_DescriptorHandleIncrementSize ), numDescriptors,
        m_DescriptorHandleIncrementSize, shared_from_this() );
}

从代码大致可以看出来,如果还有空间申请描述符,就创建一个描述符并返回,如果没有空间了就返回一个空的描述符。

回到代码最初的位置,根据以上逻辑就获得了一张贴图的SRV并存放在合适的描述符堆上。

当然还有一个函数被我们忽略了,那就是申请CPU可见描述符堆是怎么申请的?按照以上过程,申请新的描述符堆也就是申请新的描述符页。下面是描述符页的构造函数。

DescriptorAllocatorPage::DescriptorAllocatorPage( Device& device, D3D12_DESCRIPTOR_HEAP_TYPE type,
                                                  uint32_t numDescriptors )
: m_Device( device )
, m_HeapType( type )
, m_NumDescriptorsInHeap( numDescriptors )
{
    auto d3d12Device = m_Device.GetD3D12Device();

    D3D12_DESCRIPTOR_HEAP_DESC heapDesc = {};
    heapDesc.Type                       = m_HeapType;
    heapDesc.NumDescriptors             = m_NumDescriptorsInHeap;

    ThrowIfFailed( d3d12Device->CreateDescriptorHeap( &heapDesc, IID_PPV_ARGS( &m_d3d12DescriptorHeap ) ) );

    m_BaseDescriptor                = m_d3d12DescriptorHeap->GetCPUDescriptorHandleForHeapStart();
    m_DescriptorHandleIncrementSize = d3d12Device->GetDescriptorHandleIncrementSize( m_HeapType );
    m_NumFreeHandles                = m_NumDescriptorsInHeap;

    // Initialize the free lists
    AddNewBlock( 0, m_NumFreeHandles );
}

从heapDesc对象的成员的赋值来看,并没有为它的Flags字段赋值,也即是说Flags字段默认为D3D12_DESCRIPTOR_HEAP_FLAG_NONE,即CPU可见。

GPU可见描述符

在主循环渲染函数里,我们需要将图片的SRV绑定到根签名上,以下例程展示了渲染主循环里的一段代码。

void TutorialX::OnRender()
{
	auto& commandQueue = m_Device->GetCommandQueue( D3D12_COMMAND_LIST_TYPE_DIRECT );
    auto  commandList  = commandQueue.GetCommandList();
	
	// Clear the render targets.
    {
        FLOAT clearColor[] = { 0.4f, 0.6f, 0.9f, 1.0f };

        commandList->ClearTexture( m_RenderTarget.GetTexture( AttachmentPoint::Color0 ), clearColor );
        commandList->ClearDepthStencilTexture( m_RenderTarget.GetTexture( AttachmentPoint::DepthStencil ),
                                               D3D12_CLEAR_FLAG_DEPTH );
    }
	
	commandList->SetPipelineState( m_PipelineState );
    commandList->SetGraphicsRootSignature( m_RootSignature );
	
	// ... //
	
	// 绑定图片到根签名的指定根参数索引位置
	commandList->SetShaderResourceView( RootParameters::Textures, 0, m_EarthTexture,
                                        D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE );
	
	
	// 绘制图形
	commandList.Draw( vertexCount, instanceCount, 0u, startInstance );
}

以上代码里,我们最关心的两个函数调用是SetShaderResourceView和Draw。

SetShaderResourceView

void CommandList::SetShaderResourceView( int32_t rootParameterIndex, uint32_t descriptorOffset,
                                         const std::shared_ptr<Texture>& texture, D3D12_RESOURCE_STATES stateAfter,
                                         UINT firstSubresource, UINT numSubresources )
{
    if ( texture )
    {
        // ... //

        m_DynamicDescriptorHeap[D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV]->StageDescriptors(
            rootParameterIndex, descriptorOffset, 1, texture->GetShaderResourceView() );
    }
}

以上代码只保留了最关键部分。可见在CommandList里有一个动态描述符堆(DynamicDescriptorHeap)的对象,这个动态描述符堆就是用来绑定到根签名上的。这个绑定过程,通过StageDescriptors这个函数了实现,这个函数的含义是“暂存描述符”。

void DynamicDescriptorHeap::StageDescriptors( uint32_t rootParameterIndex, uint32_t offset, uint32_t numDescriptors,
                                              const D3D12_CPU_DESCRIPTOR_HANDLE srcDescriptor )
{
    // 不能暂存多于单个堆的最大描述符数。
    // 不能暂存多于根参数最大个数MaxDescriptorTables。
    if ( numDescriptors > m_NumDescriptorsPerHeap || rootParameterIndex >= MaxDescriptorTables )
    {
        throw std::bad_alloc();
    }
	
	// 根据根参数索引,获取根描述符表的缓存
    DescriptorTableCache& descriptorTableCache = m_DescriptorTableCache[rootParameterIndex];

    // 检查要拷贝的描述符数量不超过描述符表期望的描述符数。
    if ( ( offset + numDescriptors ) > descriptorTableCache.NumDescriptors )
    {
        throw std::length_error( "Number of descriptors exceeds the number of descriptors in the descriptor table." );
    }

	// 暂存到对应缓存里
    D3D12_CPU_DESCRIPTOR_HANDLE* dstDescriptor = ( descriptorTableCache.BaseDescriptor + offset );
    for ( uint32_t i = 0; i < numDescriptors; ++i )
    { dstDescriptor[i] = CD3DX12_CPU_DESCRIPTOR_HANDLE( srcDescriptor, i, m_DescriptorHandleIncrementSize ); }

    // 设置根参数索引到一个整数位里,确保描述符表里在指定索引地方的描述符被绑定到命令列表里。
    m_StaleDescriptorTableBitMask |= ( 1 << rootParameterIndex );
}

从以上代码可以看出,暂存描述符就是把描述符的CPU指针赋值到对应缓存上。函数最后有一个位操作,用一个32位数字存放了根参数的32个索引是否已经暂存了描述符,也就是说根签名最多支持32个根参数。

Draw

void CommandList::Draw( uint32_t vertexCount, uint32_t instanceCount, uint32_t startVertex, uint32_t startInstance )
{
    FlushResourceBarriers();

    for ( int i = 0; i < D3D12_DESCRIPTOR_HEAP_TYPE_NUM_TYPES; ++i )
    {
        m_DynamicDescriptorHeap[i]->CommitStagedDescriptorsForDraw( *this );
    }

    m_d3d12CommandList->DrawInstanced( vertexCount, instanceCount, startVertex, startInstance );
}

从Draw函数看,主要做了两件事,第一是将描述符提交到管线,第二是调用绘制命令。我们目前关心的是如何将描述符提交到管线。

void DynamicDescriptorHeap::CommitStagedDescriptorsForDraw( CommandList& commandList )
{
    CommitDescriptorTables( commandList, &ID3D12GraphicsCommandList::SetGraphicsRootDescriptorTable );
    CommitInlineDescriptors( commandList, m_InlineCBV, m_StaleCBVBitMask,
                             &ID3D12GraphicsCommandList::SetGraphicsRootConstantBufferView );
    CommitInlineDescriptors( commandList, m_InlineSRV, m_StaleSRVBitMask,
                             &ID3D12GraphicsCommandList::SetGraphicsRootShaderResourceView );
    CommitInlineDescriptors( commandList, m_InlineUAV, m_StaleUAVBitMask,
                             &ID3D12GraphicsCommandList::SetGraphicsRootUnorderedAccessView );
}

内联描述符比较简单,我们关心描述符表,所以继续深入看CommitDescriptorTables。

void DynamicDescriptorHeap::CommitDescriptorTables(
    CommandList& commandList,
    std::function<void( ID3D12GraphicsCommandList*, UINT, D3D12_GPU_DESCRIPTOR_HANDLE )> setFunc )
{
    // 计算需要复制的描述符数量。
    uint32_t numDescriptorsToCommit = ComputeStaleDescriptorCount();

    if ( numDescriptorsToCommit > 0 )
    {
        auto d3d12Device              = m_Device.GetD3D12Device();
        auto d3d12GraphicsCommandList = commandList.GetD3D12CommandList().Get();
        assert( d3d12GraphicsCommandList != nullptr );

        if ( !m_CurrentDescriptorHeap || m_NumFreeHandles < numDescriptorsToCommit )
        {
			// 创建GPU可见描述符堆。
            m_CurrentDescriptorHeap      = RequestDescriptorHeap();
            m_CurrentCPUDescriptorHandle = m_CurrentDescriptorHeap->GetCPUDescriptorHandleForHeapStart();
            m_CurrentGPUDescriptorHandle = m_CurrentDescriptorHeap->GetGPUDescriptorHandleForHeapStart();
            m_NumFreeHandles             = m_NumDescriptorsPerHeap;
			
			// 将GPU可见描述符堆设置到命令列表里,生成GPU地址。
            commandList.SetDescriptorHeap( m_DescriptorHeapType, m_CurrentDescriptorHeap.Get() );

            // When updating the descriptor heap on the command list, all descriptor
            // tables must be (re)recopied to the new descriptor heap (not just
            // the stale descriptor tables).
            m_StaleDescriptorTableBitMask = m_DescriptorTableBitMask;
        }

        DWORD rootIndex;
        // 遍历根签名的所有根参数。
        while ( _BitScanForward( &rootIndex, m_StaleDescriptorTableBitMask ) )
        {
            UINT                         numSrcDescriptors     = m_DescriptorTableCache[rootIndex].NumDescriptors;
            D3D12_CPU_DESCRIPTOR_HANDLE* pSrcDescriptorHandles = m_DescriptorTableCache[rootIndex].BaseDescriptor;

            D3D12_CPU_DESCRIPTOR_HANDLE pDestDescriptorRangeStarts[] = { m_CurrentCPUDescriptorHandle };
            UINT                        pDestDescriptorRangeSizes[]  = { numSrcDescriptors };

            // 将所有CPU可见描述符拷贝到GPU可见描述符堆上。
            d3d12Device->CopyDescriptors( 1, pDestDescriptorRangeStarts, pDestDescriptorRangeSizes, numSrcDescriptors,
                                          pSrcDescriptorHandles, nullptr, m_DescriptorHeapType );

            // 通过传进函数的setter函数,将描述符设置到命令列表上。
            setFunc( d3d12GraphicsCommandList, rootIndex, m_CurrentGPUDescriptorHandle );

            // 偏移当前CPU和GPU描述符句柄。
            m_CurrentCPUDescriptorHandle.Offset( numSrcDescriptors, m_DescriptorHandleIncrementSize );
            m_CurrentGPUDescriptorHandle.Offset( numSrcDescriptors, m_DescriptorHandleIncrementSize );
            m_NumFreeHandles -= numSrcDescriptors;

            // 反转已经拷贝过的位,防止再次拷贝。
            m_StaleDescriptorTableBitMask ^= ( 1 << rootIndex );
        }
    }
}

从上面代码可以看出来,CommitDescriptorTables才是真正将描述传到GPU的函数。函数首先创建了GPU可见的描述符堆,然后遍历所有暂存的描述符,最后将暂存描述符,也就是CPU可见描述符,拷贝到GPU可见描述符的地址上。

当然还有一个函数被我们忽略了,那就是申请GPU可见描述符堆是怎么申请的?深入上面代码的RequestDescriptorHeap函数,里调用了一个叫做CreateDescriptorHeap的函数,深入CreateDescriptorHeap看看是怎么实现的。

Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> DynamicDescriptorHeap::CreateDescriptorHeap()
{
    auto d3d12Device = m_Device.GetD3D12Device();

    D3D12_DESCRIPTOR_HEAP_DESC descriptorHeapDesc = {};
    descriptorHeapDesc.Type                       = m_DescriptorHeapType;
    descriptorHeapDesc.NumDescriptors             = m_NumDescriptorsPerHeap;
    descriptorHeapDesc.Flags                      = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;

    Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> descriptorHeap;
    ThrowIfFailed( d3d12Device->CreateDescriptorHeap( &descriptorHeapDesc, IID_PPV_ARGS( &descriptorHeap ) ) );

    return descriptorHeap;
}

从descriptorHeapDesc对象的成员的赋值来看,为它的Flags字段赋值了D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE,即GPU可见。

小结

这就是完整的描述符管理逻辑。总结一下,CPU可见描述符存储所有已经产生资源的、需要管理的描述符,本方案用一种分页和类似数组的方式管理CPU可见描述符堆,对于描述符的分配和释放都比较灵活;GPU可见描述符是在主循环绘制时候选择需要提交的CPU可见描述符,拷贝到GPU可见描述符堆,再进行提交。

MiniEngine

MiniEngine是微软的使用DirectX 12 实现的一套示例代码,这套代码使用DirectX 12 实现了一个迷你渲染引擎,可以说这套代码就是官方推荐的普适解决方案的集合。

这里是MiniEngine的工程链接

从工程源码上看,其描述符的管理方式和Jeremiah van Oosten的实现大同小异,或者说Jeremiah van Oosten的实现或许参考了微软官方推荐的方案。

Unreal Engine 4.26

UE4源码关于DirectX 12 的调用都封装到D3D12RHI模块里了。

Offline Descriptor

同样我们还是从贴图入手,打开“UnrealEngine-4.26/Engine/Source/Runtime/D3D12RHI/Private/D3D12Texture.cpp”,找到CreateD3D12Texture2D函数。以下代码为删除我们不关心过程后的代码。

template<typename BaseResourceType>
TD3D12Texture2D<BaseResourceType>* FD3D12DynamicRHI::CreateD3D12Texture2D(FRHICommandListImmediate* RHICmdList, uint32 SizeX, uint32 SizeY, uint32 SizeZ, bool bTextureArray, bool bCubeTexture, EPixelFormat Format,
	uint32 NumMips, uint32 NumSamples, ETextureCreateFlags Flags, FRHIResourceCreateInfo& CreateInfo)
{
	// ... //
	
	TD3D12Texture2D<BaseResourceType>* NewTexture = new TD3D12Texture2D<BaseResourceType>(Device,
			SizeX,
			SizeY,
			SizeZ,
			NumMips,
			ActualMSAACount,
			(EPixelFormat)Format,
			bCubeTexture,
			Flags,
			CreateInfo.ClearValueBinding);
	// ... //
	
	if (bCreateShaderResource)
	{
		// ... //
		
		NewTexture->SetShaderResourceView(new FD3D12ShaderResourceView(Device, SRVDesc, Location));
	}
	
	// ... //
}

从代码可以看出,新创建的Resource为NewTexture,对应的为NewTexture创建的SRV就是通过FD3D12ShaderResourceView的构造函数来创建的。创建出来SRV之后通过SetShaderResourceView赋值给贴图,这个函数就是个简单的赋值语句,也就不详细看了。

FD3D12ShaderResourceView类本身包含一个描述符成员变量Descriptor,下面进入FD3D12ShaderResourceView构造函数看看。

FD3D12ShaderResourceView(FD3D12Device* InParent, D3D12_SHADER_RESOURCE_VIEW_DESC& InDesc, FD3D12ResourceLocation& InResourceLocation, uint32 InStride = -1, bool InSkipFastClearFinalize = false)
		: FD3D12ShaderResourceView(InParent)
{
	Initialize(InDesc, InResourceLocation, InStride, InSkipFastClearFinalize);
}

进入Initialize函数看一下。

void Initialize(D3D12_SHADER_RESOURCE_VIEW_DESC& InDesc, FD3D12ResourceLocation& InResourceLocation, uint32 InStride, bool InSkipFastClearFinalize = false)
{
	// ... //
	
	CreateView(InDesc, InResourceLocation);
}

进入CreateView看一下。

void CreateView(const TDesc& InDesc, FD3D12ResourceLocation& InResourceLocation)
{
	Initialize(InDesc, InResourceLocation);

	ID3D12Resource* D3DResource = ResourceLocation->GetResource()->GetResource();
	Descriptor.CreateView(Desc, D3DResource);
}

从这里可以看出,这个函数就是在创建SRV的描述符了。那么代码里面的这个Descriptor是从哪里来的呢?Descriptor里面有一个成员Handle,也就是CPU可见的描述符句柄,下面看看这个句柄是如何创建出来的。

template <typename TDesc> 
void TD3D12ViewDescriptorHandle<TDesc>::AllocateDescriptorSlot()
{
	if (Parent)
	{
		FD3D12Device* Device = GetParentDevice();
		FD3D12OfflineDescriptorManager& DescriptorAllocator = Device->template GetViewDescriptorAllocator<TDesc>();
		Handle = DescriptorAllocator.AllocateHeapSlot(Index);
		check(Handle.ptr != 0);
	}
}

又来到我们熟悉的描述符分配器DescriptorAllocator的逻辑了。进入AllocateHeapSlot看一下。

HeapOffset AllocateHeapSlot(HeapIndex &outIndex)
	{
		FScopeLock Lock(&CritSect);
		if (0 == m_FreeHeaps.Num())
		{
			AllocateHeap();
		}
		// ... //
	}

进入FD3D12OfflineDescriptorManager类的AllocateHeap函数里面看一下。

void AllocateHeap()
{
	TRefCountPtr<ID3D12DescriptorHeap> Heap;
	VERIFYD3D12RESULT(m_pDevice->CreateDescriptorHeap(&m_Desc, IID_PPV_ARGS(Heap.GetInitReference())));
	SetName(Heap, L"FD3D12OfflineDescriptorManager Descriptor Heap");

	HeapOffset HeapBase = Heap->GetCPUDescriptorHandleForHeapStart();
	check(HeapBase.ptr != 0);

	// Allocate and initialize a single new entry in the map
	// ... //
}

又看到我们熟悉的CreateDescriptorHeap函数了,这里我们甚至可以猜测m_Desc的Flags设置的是什么,没错就是CPU可见。

static D3D12_DESCRIPTOR_HEAP_DESC CreateDescriptor(FRHIGPUMask Node, D3D12_DESCRIPTOR_HEAP_TYPE Type, uint32 NumDescriptorsPerHeap)
{
	D3D12_DESCRIPTOR_HEAP_DESC Desc = {};
	Desc.Type = Type;
	Desc.NumDescriptors = NumDescriptorsPerHeap;
	Desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;// None as this heap is offline
	Desc.NodeMask = Node.GetNative();

	return Desc;
}

在UE4里,CPU可见描述符它给换了个叫法,叫做离线描述符(Offline Descriptor),AllocateHeap函数所属的类才叫做FD3D12OfflineDescriptorManager。

Online Descriptor

接下来按照之前的阅读顺序,我们应该去关心一下GPU可见描述符是如何管理的了。打开“UnrealEngine-4.26/Engine/Source/untime/D3D12RHI/Private/D3D12Commands.cpp”找到FD3D12CommandContext::RHIDrawPrimitive函数,从这里开始看。

void FD3D12CommandContext::RHIDrawPrimitive(uint32 BaseVertexIndex, uint32 NumPrimitives, uint32 NumInstances)
{
	RHI_DRAW_CALL_STATS(StateCache.GetGraphicsPipelinePrimitiveType(), FMath::Max(NumInstances, 1U) * NumPrimitives);

	CommitGraphicsResourceTables();
	CommitNonComputeShaderConstants();
	
	// ... //

	StateCache.ApplyState<D3D12PT_Graphics>();
	CommandListHandle->DrawInstanced(VertexCount, NumInstances, BaseVertexIndex, 0);
	
	// ... //
}

这个函数里我们最关心的两个调用就是CommitGraphicsResourceTables和StateCache.ApplyState

首先来看看CommitGraphicsResourceTables。

void FD3D12CommandContext::CommitGraphicsResourceTables()
{
	//SCOPE_CYCLE_COUNTER(STAT_D3D12CommitResourceTables);

	const FD3D12GraphicsPipelineState* const RESTRICT GraphicPSO = StateCache.GetGraphicsPipelineState();
	check(GraphicPSO);

	auto* PixelShader = GraphicPSO->GetPixelShader();
	if (PixelShader)
	{
		SetUAVPSResourcesFromTables(PixelShader);
	}
	if (auto* Shader = GraphicPSO->GetVertexShader())
	{
		SetResourcesFromTables(Shader);
	}
	if (PixelShader)
	{
		SetResourcesFromTables(PixelShader);
	}
	if (auto* Shader = GraphicPSO->GetHullShader())
	{
		SetResourcesFromTables(Shader);
	}
	if (auto* Shader = GraphicPSO->GetDomainShader())
	{
		SetResourcesFromTables(Shader);
	}
	if (auto* Shader = GraphicPSO->GetGeometryShader())
	{
		SetResourcesFromTables(Shader);
	}
}

进入SetResourcesFromTables看一下。

template <class ShaderType>
void FD3D12CommandContext::SetResourcesFromTables(const ShaderType* RESTRICT Shader)
{
	checkSlow(Shader);

	// Mask the dirty bits by those buffers from which the shader has bound resources.
	uint32 DirtyBits = Shader->ShaderResourceTable.ResourceTableBits & DirtyUniformBuffers[ShaderType::StaticFrequency];
	while (DirtyBits)
	{
		// Scan for the lowest set bit, compute its index, clear it in the set of dirty bits.
		const uint32 LowestBitMask = (DirtyBits)& (-(int32)DirtyBits);
		const int32 BufferIndex = FMath::FloorLog2(LowestBitMask); // todo: This has a branch on zero, we know it could never be zero...
		DirtyBits ^= LowestBitMask;
		FD3D12UniformBuffer* Buffer = BoundUniformBuffers[ShaderType::StaticFrequency][BufferIndex];
		check(Buffer);
		check(BufferIndex < Shader->ShaderResourceTable.ResourceTableLayoutHashes.Num());
		check(Buffer->GetLayout().GetHash() == Shader->ShaderResourceTable.ResourceTableLayoutHashes[BufferIndex]);

		// todo: could make this two pass: gather then set
		SetShaderResourcesFromBuffer_Surface<(EShaderFrequency)ShaderType::StaticFrequency>(*this, Buffer, Shader->ShaderResourceTable.TextureMap.GetData(), BufferIndex);
		SetShaderResourcesFromBuffer_SRV<(EShaderFrequency)ShaderType::StaticFrequency>(*this, Buffer, Shader->ShaderResourceTable.ShaderResourceViewMap.GetData(), BufferIndex);
		SetShaderResourcesFromBuffer_Sampler<(EShaderFrequency)ShaderType::StaticFrequency>(*this, Buffer, Shader->ShaderResourceTable.SamplerMap.GetData(), BufferIndex);
	}

	DirtyUniformBuffers[ShaderType::StaticFrequency] = 0;
}

这里我们比较关心SetShaderResourcesFromBuffer_SRV函数的调用,可以看到这里会把SRV传进去处理,这个函数里还会调用一个叫做SetResource的函数,我们直接进函数里去看。

template <EShaderFrequency Frequency>
FORCEINLINE void SetResource(FD3D12CommandContext& CmdContext, uint32 BindIndex, FD3D12ShaderResourceView* RESTRICT SRV)
{
	// We set the resource through the RHI to track state for the purposes of unbinding SRVs when a UAV or RTV is bound.
	CmdContext.StateCache.SetShaderResourceView<Frequency>(SRV, BindIndex);
}

再深入SetShaderResourceView看看。

template <EShaderFrequency ShaderFrequency>
void FD3D12StateCacheBase::SetShaderResourceView(FD3D12ShaderResourceView* SRV, uint32 ResourceIndex)
{
	// ... //
	FD3D12ShaderResourceViewCache& Cache = PipelineState.Common.SRVCache;
	auto& CurrentShaderResourceViews = Cache.Views[ShaderFrequency];
	
	// ... //
	
	CurrentShaderResourceViews[ResourceIndex] = SRV;
	FD3D12ShaderResourceViewCache::DirtySlot(Cache.DirtySlotMask[ShaderFrequency], ResourceIndex);
	
	// ... //
}

这里我们主要关心SRV被赋值给了一个FD3D12ShaderResourceViewCache,并且设置了Slot为脏数据。

下面我们回到最初的RHIDrawPrimitive函数里看一下,进入我们关心的第二个函数StateCache.ApplyState里面看看。

template <ED3D12PipelineType PipelineType>
void FD3D12StateCacheBase::ApplyState()
{
	// ... //
	
	FD3D12ShaderResourceViewCache& SRVCache = PipelineState.Common.SRVCache;

#define CONDITIONAL_SET_SRVS(Shader) \
	if (CurrentShaderDirtySRVSlots[##Shader]) \
	{ \
		DescriptorCache.SetSRVs<##Shader>(pRootSignature, SRVCache, CurrentShaderDirtySRVSlots[##Shader], NumSRVs[##Shader], ViewHeapSlot); \
	}
	
	// ... //
}

从代码可以看出刚才缓存的SRV被取出来,传入DescriptorCache.SetSRVs函数处理。

template <EShaderFrequency ShaderStage>
void FD3D12DescriptorCache::SetSRVs(const FD3D12RootSignature* RootSignature, FD3D12ShaderResourceViewCache& Cache, const SRVSlotMask& SlotsNeededMask, uint32 SlotsNeeded, uint32& HeapSlot)
{
	// ... //
	
	FD3D12CommandListHandle& CommandList = CmdContext->CommandListHandle;

	auto& SRVs = Cache.Views[ShaderStage];
	
	// Reserve heap slots
	uint32 FirstSlotIndex = HeapSlot;
	HeapSlot += SlotsNeeded;

	D3D12_CPU_DESCRIPTOR_HANDLE DestDescriptor = CurrentViewHeap->GetCPUSlotHandle(FirstSlotIndex);
	D3D12_CPU_DESCRIPTOR_HANDLE SrcDescriptors[MAX_SRVS];
	
	for (uint32 SlotIndex = 0; SlotIndex < SlotsNeeded; SlotIndex++)
	{
		if (SRVs[SlotIndex] != nullptr)
		{
			SrcDescriptors[SlotIndex] = SRVs[SlotIndex]->GetView();

			// ... //
		}
		else
		{
			SrcDescriptors[SlotIndex] = pNullSRV->GetHandle();
		}
		check(SrcDescriptors[SlotIndex].ptr != 0);
	}
	FD3D12ShaderResourceViewCache::CleanSlots(CurrentDirtySlotMask, SlotsNeeded);

	ID3D12Device* Device = GetParentDevice()->GetDevice();
	Device->CopyDescriptors(1, &DestDescriptor, &SlotsNeeded, SlotsNeeded, SrcDescriptors, nullptr, D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
	
	const D3D12_GPU_DESCRIPTOR_HANDLE BindDescriptor = CurrentViewHeap->GetGPUSlotHandle(FirstSlotIndex);

	if (ShaderStage == SF_Compute)
	{
		const uint32 RDTIndex = RootSignature->SRVRDTBindSlot(ShaderStage);
		CommandList->SetComputeRootDescriptorTable(RDTIndex, BindDescriptor);
	}
	else
	{
		const uint32 RDTIndex = RootSignature->SRVRDTBindSlot(ShaderStage);
		CommandList->SetGraphicsRootDescriptorTable(RDTIndex, BindDescriptor);
	}
}

从代码可以看到函数里面,把缓存的SRV拷贝到了CurrentViewHeap里面了,最终通过SetGraphicsRootDescriptorTable设置给了跟描述符表。

这里CurrentViewHeap的类型为FD3D12OnlineHeap,不出所料GPU可见的描述符在UE4里叫做在线描述符(Online Descriptor),同时如我们所料在它的初始化函数里,就是创建了一个GPU可见的描述符堆。

void FD3D12LocalOnlineHeap::Init(uint32 NumDescriptors, D3D12_DESCRIPTOR_HEAP_TYPE Type)
{
	Desc = {};
	Desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
	Desc.Type = Type;
	Desc.NumDescriptors = NumDescriptors;
	Desc.NodeMask = GetGPUMask().GetNative();

	//LLM_SCOPE(ELLMTag::DescriptorCache);
	VERIFYD3D12RESULT(GetParentDevice()->GetDevice()->CreateDescriptorHeap(&Desc, IID_PPV_ARGS(Heap.GetInitReference())));
	SetName(Heap, Desc.Type == D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV ? L"Thread Local - Online View Heap" : L"Thread Local - Online Sampler Heap");

	Entry.Heap = Heap;

	CPUBase = Heap->GetCPUDescriptorHandleForHeapStart();
	GPUBase = Heap->GetGPUDescriptorHandleForHeapStart();
	DescriptorSize = GetParentDevice()->GetDevice()->GetDescriptorHandleIncrementSize(Type);
	
	// ... //	
}

这里还有点小问题,就是既然创建了GPU可见描述符,那么是哪里设置给命令列表的呢?这里搜索FD3D12DescriptorCache::SetDescriptorHeaps,看一下调用,发现每次绘制开始都会去调用。进入函数去看。

bool FD3D12DescriptorCache::SetDescriptorHeaps()
{
	// ... //
	
	if (bHeapChanged)
	{
		ID3D12DescriptorHeap* /*const*/ ppHeaps[] = { pCurrentViewHeap, pCurrentSamplerHeap };
		CmdContext->CommandListHandle->SetDescriptorHeaps(UE_ARRAY_COUNT(ppHeaps), ppHeaps);

		pPreviousViewHeap = pCurrentViewHeap;
		pPreviousSamplerHeap = pCurrentSamplerHeap;
	}
	
	// ... //
}

可见UE4是发现有堆改变才会去调用命令列表的SetDescriptorHeaps函数。

总结

至此,描述符的管理在三套代码里都给出了相同的方案,这样就解决了龙书里只能固定分配好资源和描述符,对于需要动态分配的资源束手无策的问题。龙书的例程作为讲解API的Demo,不可能考虑很周全,学习龙书更要学习引擎代码,了解一个知识是如何被应用到工程里面的。


Similar Posts

评论