DX12 计算着色器

GPGPU和GPU的区别

  • 概念:通用GPU程序(General Purpose GPU programming,GPGPU):GPGPU通常被集成至CPU中,是一个辅助CPU的工具,可以帮助CPU进行非图形渲染的运算,GPGPU programming 则主要负责非图形相关程序的计算

  • 为什么GPGPU会辅助CPU?因为GPU负责图形渲染,但这个任务十分艰巨,这其中也包括一些非图形渲染相关的计算,而这些运算会交给CPU来进行,这样就可以减轻GPU的负担

  • GPGPU的实际用途:粒子系统、布料模拟,加密解密

什么是计算着色器?

​ 为了实现GPGPU,NV推出了CUDA,Khronos推出了OpenCL,Microsoft推出了DirectCompute(也就是现在的compute shader).但计算着色器的用途不止于此它还可以实现许多图形特效

​ 虽然它是可编程的着色器,但D3D并未将其划入渲染管线部分,反而它是一种独立于渲染管线却可以访问GPU资源的着色器且他自己作为一个单独的流水线,这样能够使得计算着色器访问GPU资源来实现并行算法,而无需等待管线中部件的进行计算
image-20230301151506768

计算管线如下:

img

一个计算着色器由下列要素组成:

  • 通过常量缓冲区访问的全局变量
  • 输入与输出资源
  • [numthreads(X, Y, Z)]属性,指定 3D 线程网格中的线程数量
  • 每个线程都要执行的着色器指令
  • 线程ID系统值参数

着色器硬件编年史

  • 第一阶段:A basic GPU pipeline with separate vertex and pixel units

    最早的时候还未诞生着色器,GPU上的纹理单元和几何处理单元是分离的.到后来由于着色器的出现,GPU拥有独立的顶点处理单元和像素处理单元,尽管这两种单元在计算方式上有许多相似点,但内存访问方式有很大差异,比如顶点着色器不可以访问纹理资源,这种分割方式在当时是有益的,因为场景中一般包含很少的多边形,但覆盖了许多像素,所以在当时较弱的顶点着色能力通常不会导致性能平移
    image-20230301152139997

  • 第二阶段:unified vertex and pixel pipeline

    到后来第一阶段这种分离顶点像素计算单元的方式,衍生出一些问题,比如随着场景愈来愈复杂,顶点运算可能会成为性能瓶颈。同时期,GPGPU的需求量也开始增加,GPU不再仅仅用于图形渲染,还有通用计算

    于是后来unified shader pipeline的概念被提出:如下图所示,所有单元都可以通过纹理单元访问内存,也可以通过attribute buffer进行相互沟通
    image-20230301162353457

  • 第三阶段:The unified pipeline

    在这一阶段中,这些单元之前的差异性已经完全消失了,取而代之的是ALU(the units executing the math instructions),也就是说顶点着色器可以和像素着色器在同一单元上进行工作,如此便平衡了VS和PS间的负载

    如下图所示,原先的attribute buffer变成本地内存,纹理单元变成全局(共享)内存,并且这种内存的访问速度很快(和硬件高速缓存不分上下),顶点计算单元和像素计算单元现在已成通用计算单元,也就是说现在所有ALU单元既可以通过共享内存互相通信,也可以通过全局内存访问内存
    image-20230301163601131

    其中每个计算单元通过从可以同时处理多个elements,为了加速处理过程它有着更加复杂的内存系统.如下图展示了AMD GCN架构下的一个计算单元的内部结构,由四个16-wide SIMD 单元、共享内存、全局内存、各种缓冲组成,其中包含虚线轮廓的块在计算单元之间共享(尽量避免虚线轮廓的内存,而尽可能多的使用实现表示的内存).
    image-20230301165240228

    综上所述,GPU由许多计算单元组成,每个计算单元包含:

    1. 一组SIMD处理器,它们用于执行指令
    2. 每个计算单元内部的共享内存可用于不同阶段的着色器间的通信
    3. 每个SIMD单元可以同时对多个elements执行同一个操作

CS编程模型

需要知道的是,应该尽量避免需要cross-compute-unit的数据通信。尽管计算单元都和L2缓存相连,但使用L1缓存则无需进行同步,因此我们应该尽量假装不能cross-compute-unit,这意味着需要把工作集分成更小的单元,这些小单元可以对应到计算单元这一层,我们将该层称作work group,每个work group又划分为许多相互独立的work items,这些work items用于填充SIMD 单元

也就是说,计算着色器的样貌如下:

  1. work domain:指定所有工作区域
  2. work group:work domain会划分为多个work group,它们独立运行,每个work group允许在组内进行通信.在硬件方面,每个work group指派给一个计算单元(这些work group的执行顺序无法指定)。由于work groups间的信息交换需要通过共享内存,但这些访问又十分耗时,因此为了减少memory latency可能一个work domain会完整地交给一个计算单元来执行
  3. work item:多个work item构成一个work group,每个work group中真正的工作是由多个独立的work item组成的,一个SIMD单元可以同时执行多个work item

image-20230301174525388

CS的基本知识

注意:以下数据是基于GPU Fermi架构,不同架构数据不同

​ GPU被划分为多个GPC(Graphics Processing Cluste),每个GPC又含有多个流式多处理器(Stream Multiprocessor,SM)和一个光栅化引擎,而我们编写的shader便是在SM上完成的,每个SM又包含许多核心(Core),比如NVIDA的CUDA核心,Warp Scheduler管理一组32个线程并将要执行的指令交给Dispatch Units

​ 在GPU编程中,一个线程组(也就是work group)运行在一个多处理器上,但要获得最佳性能,一般每个多处理器至少拥有两个线程组,使得它们能切换至不同的线程组进行处理

​ 一个线程组含有n个线程,而硬件会将这些线程划分构成多个warp(每个warp32个线程),每个warp都有相应的warp编排器负责warp的调度,多处理器会以SIMD32的方式处理warp,其中一个Core处理一个线程。在D3D中为了更优的性能,我们应将线程组的大小设置为warp的整数倍,若非如此会有warp被掺入无事可做的线程

  • 分派线程组:ID3D12GraphicsCommandList::Dispatch()

    定义:

    void Dispatch(
      [in] UINT ThreadGroupCountX,
      [in] UINT ThreadGroupCountY,
      [in] UINT ThreadGroupCountZ
    );
    

    该方法可创建一组三维线程组网格:

    cmdList->Dispatch(x, y, z);
    

    以上总共分派 x * y * z 个线程组,好处是方便之后的三维索引

线程标识的系统值

  • 线程组ID

    对于上述分派线程组的操作,系统会为每个线程组都分配一个ID,即线程组ID(group ID),对应的系统值语义为SV_GroupID,对于上述操作的线程组ID范围为(0,0,0)至(x-1,y-1,z1)

    在着色器中,通过[numthreads(u, v, w)]来表示一个线程组中的线程数,好处是方便三位索引,如纹理采样

  • 线程ID

    在线程组中,每个线程都会被指定一个组内(局部)的唯一ID,即组内线程ID(group thread ID),对应的系统值语义为SV_GroupThreadID.若线程组指定如下[numthreads(u, v, w)],则组内线程ID的范围为(0,0,0)至(u-1, v-1, w-1)

  • 调度线程ID

    我们知道调用Dispatch()会分派一个线程组网格,而同时每个线程组网格内的每个线程都会生成全局的唯一标识,即调度线程ID(dispatch thread ID),对应的系统值语义为SV_DispatchThreadID

  • 指定组内线程ID

    通过使用系统值SV_GroupIndex可以求得组内线程ID的索引,换算方法如下:
    \(groupIndex = groupThreadID.z * ThreadGroupSize.x * ThreadGroupSize.y + groupThreadID.y * ThreadGroupSize.x + groupThreadID.x\)

image-20230301191011640

计算流水线状态对象

​ 在前面我们说过计算着色器被视为一个单独的流水线,因此为了启用计算着色器,我们需要为其使用计算流水线状态描述符

定义:

typedef struct D3D12_COMPUTE_PIPELINE_STATE_DESC {
  ID3D12RootSignature         *pRootSignature;
  D3D12_SHADER_BYTECODE       CS;	//计算着色器
  UINT                        NodeMask;
  D3D12_CACHED_PIPELINE_STATE CachedPSO;
  D3D12_PIPELINE_STATE_FLAGS  Flags;
} D3D12_COMPUTE_PIPELINE_STATE_DESC;

示例:

D3D12_COMPUTE_PIPELINE_STATE_DESC wavesUpdatePSO = {};
wavesUpdatePSO.pRootSignature = mWavesRootSignature.Get();
wavesUpdatePSO.CS =
{
    reinterpret_cast<BYTE*>(mShaders["wavesUpdateCS"]->GetBufferPointer()),
    mShaders["wavesUpdateCS"]->GetBufferSize()
};
wavesUpdatePSO.Flags = D3D12_PIPELINE_STATE_FLAG_NONE;
ThrowIfFailed(md3dDevice->CreateComputePipelineState(&wavesUpdatePSO, IID_PPV_ARGS(&mPSOs["wavesUpdate"])));

CS的输入、输出资源

​ 我们通过一个简单的实例,来看看CS的输入和输出资源类型.能和计算着色器绑定的资源类型缓冲区和纹理这两种

cbuffer cbSettings
{
	//...计算着色器能访问的常量缓冲区数据 
};

// 计算着色器的输入和输出资源
Texture2D gInputA;
Texture2D gInputB;
RWTexture2D<float4> gOutput;

[numthreads(16, 16, 1)]
void CS(int3 dispatchThreadID : SV_DispatchThreadID)
// Thread ID
{
    // 对两种源像素中横纵坐标分别为x、y处的纹素求和,将结果保存至gOutput纹素中
    gOutput[dispatchThreadID.xy] = gInputA[dispatchThreadID.xy] + gInputB[dispatchThreadID.xy];
}

纹理输入

​ 在上例中,我们采样纹理作为计算着色器的输入资源:gInputB、gInputA。和其他着色器相同,给输入资源纹理创建SRV,再将它们传入根参数中,就可以让这两个纹理绑定为计算着色器的输入资源。如:

cmdList->SetComputeRootDescriptorTable(1, mSrvA);
cmdList->SetComputeRootDescriptorTable(2, mSrvB);

纹理输出&UAV

​ 在上例中,定义的输出资源为:RWTexture2D<float4> gOutput,与其他着色器不同的是,计算着色器的输出类型有个前缀"RW"——可读可写,而gInputB、gInputA为只读,以及模板语法"<>"

​ 计算着色器的输出资源和输入资源的绑定方式有些不同的。若想要绑定在计算着色器中执行写操作的资源,我们需要将其和无序访问视图(Unordered Access View, UAV)关联,通过struct D3D12_UNORDERED_ACCESS_VIEW_DESC对资源进行描述

​ 为什么需要UVA?因为在Shader Model 5 前,在GPU中除了RenderTarget,资源全都是以只读形式存在,于是在Shader Model 5中引入了可读写的资源类型UAV,与其他类型不同,它可由多个GPU线程写入/读取,且和RenderTarget类型也有不同之处——UAV是无序写入

D3D12_UNORDERED_ACCESS_VIEW_DESC的定义:

typedef struct D3D12_UNORDERED_ACCESS_VIEW_DESC {
  DXGI_FORMAT         Format;
  D3D12_UAV_DIMENSION ViewDimension;
  union {
    D3D12_BUFFER_UAV      Buffer;
    D3D12_TEX1D_UAV       Texture1D;
    D3D12_TEX1D_ARRAY_UAV Texture1DArray;
    D3D12_TEX2D_UAV       Texture2D;
    D3D12_TEX2D_ARRAY_UAV Texture2DArray;
    D3D12_TEX3D_UAV       Texture3D;
  };
} D3D12_UNORDERED_ACCESS_VIEW_DESC;

​ 为纹理资源创建UAV的示例:

//资源描述符
D3D12_RESOURCE_DESC texDesc;
ZeroMemory(&texDesc, sizeof(D3D12_RESOURCE_DESC));
texDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
texDesc.Alignment = 0;
texDesc.Width = mWidth;
texDesc.Height = mHeight;
texDesc.DepthOrArraySize = 1;
texDesc.MipLevels = 1;
texDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
texDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS;	//若某纹理资源要和UVA绑定,该资源描述符的Flags需设为D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS

//为资源创建默认堆,并将资源上传至此堆
ThrowIfFailed(md3dDevice->CreateCommittedResource(
    &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
    D3D12_HEAP_FLAG_NONE,
    &texDesc,
    D3D12_RESOURCE_STATE_COMMON,
    nullptr,
    IID_PPV_ARGS(&mBlurMap0)));

//UAV视图描述符
D3D12_UNORDERED_ACCESS_VIEW_DESC uavDesc = {};
uavDesc.Format = mFormat;
uavDesc.ViewDimension = D3D12_UAV_DIMENSION_TEXTURE2D;
uavDesc.Texture2D.MipSlice = 0;

//创建UVA描述符
md3dDevice->CreateUnorderedAccessView(mBlurMap0.Get(),nullptr, &uavDesc, mBlur0CpuUav);

需要注意的是,大多时候我们会将一个纹理同时和UVA、SRV进行绑定,但是两者不可同时生效。原因是通常会在CS中对纹理执行某些操作,在PS中用该纹理进行贴图

​ 为UAV描述符创建堆:类型为D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV的描述符可以存放UAV描述符句柄,因此我们将UAV描述符放在该类型的堆即可

​ 将UAV描述符句柄和根签名进行绑定:

CD3DX12_DESCRIPTOR_RANGE srvTable;
srvTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);

CD3DX12_DESCRIPTOR_RANGE uavTable;
uavTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_UAV, 1, 0);	//把描述符表设为UAV类型,需要将D3D12_DESCRIPTOR_RANGE_TYPE设为D3D12_DESCRIPTOR_RANGE_TYPE_UAV

CD3DX12_ROOT_PARAMETER slotRootParameter[3];

slotRootParameter[0].InitAsConstants(12, 0);
slotRootParameter[1].InitAsDescriptorTable(1, &srvTable);
slotRootParameter[2].InitAsDescriptorTable(1, &uavTable);

CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(3, slotRootParameter,
                                        0, nullptr,
                                        D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

// create a root signature with a single slot which points to a descriptor range consisting of a single constant buffer
ComPtr<ID3DBlob> serializedRootSig = nullptr;
ComPtr<ID3DBlob> errorBlob = nullptr;
HRESULT hr = D3D12SerializeRootSignature(&rootSigDesc, D3D_ROOT_SIGNATURE_VERSION_1,
                                         serializedRootSig.GetAddressOf(), errorBlob.GetAddressOf());

if(errorBlob != nullptr)
{
    ::OutputDebugStringA((char*)errorBlob->GetBufferPointer());
}
ThrowIfFailed(hr);

ThrowIfFailed(md3dDevice->CreateRootSignature(
    0,
    serializedRootSig->GetBufferPointer(),
    serializedRootSig->GetBufferSize(),
    IID_PPV_ARGS(mPostProcessRootSignature.GetAddressOf())));

cmdList->SetComputeRootSignature(rootSig);

cmdList->SetComputeRoot32BitConstants(0, 1, &blurRadius, 0);
cmdList->SetComputeRoot32BitConstants(0, (UINT)weights.size(), weights.data(), 1);
cmdList->SetComputeRootDescriptorTable(1, mBlur0GpuSrv);
cmdList->SetComputeRootDescriptorTable(2, mBlur1GpuUav);

//创建启用线程组
UINT numGroupsY = (UINT)ceilf(mHeight / 256.0f);
cmdList->Dispatch(mWidth, numGroupsY, 1);

CS使用索引对纹理进行采样

​ 在上例中,我们已经用调度线程ID来索引纹理了,如下:

[numthreads(16, 16, 1)]
void CS(int3 dispatchThreadID : SV_DispatchThreadID)
// Thread ID
{
    // 对两种源像素中横纵坐标分别为x、y处的纹素求和,将结果保存至gOutput纹素中
    gOutput[dispatchThreadID.xy] = gInputA[dispatchThreadID.xy] + gInputB[dispatchThreadID.xy];
}

​ 但是,用CS进行采样时,还存在两个问题:

  1. 不能使用Sample(),而是使用SampleLevel()

    定义如下,可以看到该函数相较于Sample()多出一个LOD参数,该参数用于指定纹理的mipmap层级,而Sample()是自动选择最佳的mipmap层级:

    <Template Type> Object.SampleLevel( sampler_state S, float Location, float LOD [, int Offset] );
    

    为什么CS不能使用Sample()?因为CS不可直接参与渲染,所以他无从得知Sample()自动选择的最佳mipmap层级

  2. 对纹理进行采样时,使用[0,1]范围内的归一化坐标,而非整数索引

    注意:在CS中若进行越界,越界进行读操作总是返回0,而进行写操作时无事发生

​ 以下示例将展示CS是如何使用调度线程ID,以及如何使用SampleLevel():

//使用调度线程ID
cbuffer cbUpdateSettings
{
    float gWaveConstant0;
    float gWaveConstant1;
    float gWaveConstant2;

    float gDisturbMag;
    int2 gDisturbIndex;
};

RWTexture2D<float> gPrevSolInput : register(u0);
RWTexture2D<float> gCurrSolInput : register(u1);
RWTexture2D<float> gOutput       : register(u2);

[numthreads(16, 16, 1)]
void UpdateWavesCS(int3 dispatchThreadID : SV_DispatchThreadID)
{
    //以分派线程ID作为索引
    int x = dispatchThreadID.x;
    int y = dispatchThreadID.y;

    gOutput[int2(x,y)] = 
        gWaveConstant0 * gPrevSolInput[int2(x,y)].r +
        gWaveConstant1 * gCurrSolInput[int2(x,y)].r +
        gWaveConstant2 *(
        gCurrSolInput[int2(x,y+1)].r + 
        gCurrSolInput[int2(x,y-1)].r + 
        gCurrSolInput[int2(x+1,y)].r + 
        gCurrSolInput[int2(x-1,y)].r);
}

//SampleLevel()采样纹理
cbuffer cbUpdateSettings
{
    float gWaveConstant0;
    float gWaveConstant1;
    float gWaveConstant2;
    float gDisturbMag;
    int2 gDisturbIndex;
};

SamplerState samPoint : register(s0);
RWTexture2D<float> gPrevSolInput : register(u0);
RWTexture2D<float> gCurrSolInput : register(u1);
RWTexture2D<float> gOutput : register(u2);

[numthreads(16, 16, 1)]
void CS(int3 dispatchThreadID : SV_DispatchThreadID)
{
    // Equivalently using SampleLevel() instead of operator [].
    int x = dispatchThreadID.x;
    int y = dispatchThreadID.y;
    
    //规范化
    float2 c = float2(x,y)/512.0f;
    float2 t = float2(x,y-1)/512.0;
    float2 b = float2(x,y+1)/512.0;
    float2 l = float2(x-1,y)/512.0;
    float2 r = float2(x+1,y)/512.0;

    gNextSolOutput[int2(x,y)] =
        gWaveConstants0*gPrevSolInput.SampleLevel(samPoint, c, 0.0f).r +
        gWaveConstants1*gCurrSolInput.SampleLevel(samPoint, c, 0.0f).r +
        gWaveConstants2*(
        gCurrSolInput.SampleLevel(samPoint, b, 0.0f).r +
        gCurrSolInput.SampleLevel(samPoint, t, 0.0f).r +
        gCurrSolInput.SampleLevel(samPoint, r, 0.0f).r +
        gCurrSolInput.SampleLevel(samPoint, l, 0.0f).r);
}

UAV Counter

​ 在SM5中除了引入UAV类型,还提供了有统计能力的UAV Buferr类型,该类型将UAV和计数器关联。在HLSL中,支持Counter能力的Buffer如下:

  1. RWStructuredBuffer
  2. AppendStructuredBuffer(追加结构化缓冲区)
  3. ConsumeStructuredBuffer(消费结构化缓冲区)

​ UAV Counter在D3D11中引入,且在此时使用UAV Counter API是较为方便的,因为它将Counter的机制隐藏在API内部,由Runtime和驱动协助完成,其中Runtime 将原子计数器数据放置在 GDS(Global Data Storage)中,在shader计算完后,驱动会将Counter数据复制回系统内存,但坏处是将GPU数据复制到系统内存是很慢的

​ 在D3D12中针对该不足作出了优化.D3D12 与 D3D11 的资源管理有比较大的差异,D3D12提供更多的机制让开发者控制Counter——驱动不再每次都进行复制,而是由开发者在需要时才回读Counter数据,且由于Counter本质是Bufer,使用回读堆进行回读无序使用额外的API,因此D3D12拥有更好的性能。还有一点与D3D11不同,D3D12在API层面去掉了Append\Consume Buffer类型,但是在HLSL语法中依然保留这两者的语义,所有带Counter的缓冲区都必须是Structured类型

​ 创建带有Counter的UAV缓冲区资源,需要满足如下条件:

  1. Counter Buffer 必须是 Buffer 资源
  2. Counter Buffer 大小必须是 4 字节(32 位)的整数倍
  3. Counter Buffer 的 SRV\UAV 必须是对应的 RAW(ByteAddress)类型
  4. 关联的 UAV Buffer 必须是 Structured 类型,并符合 Structured 类型要求
  5. 关联的 UAV 的 D3D12_BUFFER_UAV 结构 CounterOffsetInBytes 成员必须是 4 字节(32位)的倍数,并且不得超出 Counter Buffer 资源范围

​ 需要注意的是:当一个 Counter 关联到多个 UAV 时,在同一个 Draw\Dispatch 调用中同时访问会导致校验层错误

RW结构化缓冲区

​ 结构化缓冲区是一种由相同类型元素所构成的简单缓冲区,该元素类型可以是HLSL中定义的结构体,结构化缓冲区的本质是数组,而RWStructuredBuffer提供 IncrementCounter()\DecrementCounter()用于增减计数。以下展示了HLSL如何定义结构化缓冲区:

struct Data
{
	float3 v1;
	float2 v2;
};

StructuredBuffer<Data> gInputA : register(t0);
StructuredBuffer<Data> gInputB : register(t1);
RWStructuredBuffer<Data> gOutput : register(u0);

​ 创建结构化缓冲区:创建结构化缓冲区的方式和创建顶点缓冲区/索引缓冲区的方式相同,除了将缓冲区资源的flags指定为D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS外,其他都一样

如下展示如何创建结构化缓冲区,并用于UAV

//生成data
std::vector<Data> dataA(NumDataElements);
std::vector<Data> dataB(NumDataElements);
for(int i = 0; i < NumDataElements; ++i)
{
    dataA[i].v1 = XMFLOAT3(i, i, i);
    dataA[i].v2 = XMFLOAT2(i, 0);

    dataB[i].v1 = XMFLOAT3(-i, i, 0.0f);
    dataB[i].v2 = XMFLOAT2(0, -i);
}

UINT64 byteSize = dataA.size()*sizeof(Data);

// Create some buffers to be used as SRVs.
mInputBufferA = d3dUtil::CreateDefaultBuffer(
    md3dDevice.Get(),
    mCommandList.Get(),
    dataA.data(),
    byteSize,
    mInputUploadBufferA);

mInputBufferB = d3dUtil::CreateDefaultBuffer(
    md3dDevice.Get(),
    mCommandList.Get(),
    dataB.data(),
    byteSize,
    mInputUploadBufferB);

// 创建用于UAV的缓冲区
ThrowIfFailed(md3dDevice->CreateCommittedResource(
    &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
    D3D12_HEAP_FLAG_NONE,
    &CD3DX12_RESOURCE_DESC::Buffer(byteSize, D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS),
    D3D12_RESOURCE_STATE_UNORDERED_ACCESS,
    nullptr,
    IID_PPV_ARGS(&mOutputBuffer)));

​ 将结构化缓冲区和管线绑定:

CD3DX12_ROOT_PARAMETER slotRootParameter[3];

// Perfomance TIP: Order from most frequent to least frequent.
slotRootParameter[0].InitAsShaderResourceView(0);
slotRootParameter[1].InitAsShaderResourceView(1);
slotRootParameter[2].InitAsUnorderedAccessView(0);

// A root signature is an array of root parameters.
CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(3, slotRootParameter,
                                        0, nullptr,
                                        D3D12_ROOT_SIGNATURE_FLAG_NONE);

mCommandList->SetComputeRootSignature(mRootSignature.Get());

mCommandList->SetComputeRootShaderResourceView(0, mInputBufferA->GetGPUVirtualAddress());
mCommandList->SetComputeRootShaderResourceView(1, mInputBufferB->GetGPUVirtualAddress());
mCommandList->SetComputeRootUnorderedAccessView(2, mOutputBuffer->GetGPUVirtualAddress());

mCommandList->Dispatch(1, 1, 1);	//分派调用

AppendStructuredBuffer & ConsumeStructuredBuffer

​ AppendStructuredBuffer & ConsumeStructuredBuffer分别提供Append()/Consume()实现生产者消费模型,隐含计数功能.AppendStructuredBuffer用于在尾部添加成员,而ConsumeStructuredBuffer用于处理(消费)尾部成员,这和栈很相似

​ 为什么需要这两种UAV缓冲区?与RWStructuredBuffer 不同,这两种缓冲区无需考虑索引这个因素,也就是说这两种缓冲区可用于像粒子碰撞这样无序考虑粒子的更新顺序及它们被写入输出缓冲区顺序

​ 需要注意的是:

  1. 一旦数据元素经过处理(消费)——该元素从消费缓冲区移除,其他线程就不能再对该元素进行操作
  2. 一个线程只能处理一个数据元素
  3. 由于我们无法得知元素具体的处理顺序和追加顺序,因此通常情况下某元素位于输入缓冲区的位置和处理后写入输出缓冲区的位置并不是一一对应的

​ 它们提供的函数:

//ConsumeStructuredBuffer
T Consume(void);	//从缓冲区尾部移除一个值

void GetDimensions(	//获取资源的维度
  out uint numStructs,	//该缓冲区包含的结构体的数量
  out uint stride	//每个元素的大小(字节)
);

//ConsumeStructuredBuffer
void Append(in T value);	//在缓冲区末尾添加一个值
void GetDimensions(	//获取资源的维度
  out uint numStructs,
  out uint stride
);

​ 它们的声明及使用方式:

struct Particle
{
    float3 Position;
    float3 Velocity;
    float3 Acceleration;
};

float TimeStep = 1.0f / 60.0f;

ConsumeStructuredBuffer<Particle> gInput;
AppendStructuredBuffer<Particle> gOutput;

[numthreads(16, 16, 1)]
void CS()
{
    // 对输入缓冲区的元素进行消费
    Particle p = gInput.Consume();
    p.Velocity += p.Acceleration*TimeStep;
    p.Position += p.Velocity*TimeStep;
    // 追加到输出缓冲区
    gOutput.Append( p );
}

将CS的执行结果复制到系统内存

​ 通常情况下,在CS对纹理进行处理后,需要将结果显示在屏幕上,并根据效果验证CS的计算是否正确。那么如何将显存的数据传回系统内存呢?方式如下:

  1. 创建一个回读堆,该堆属性为D3D12_HEAP_TYPE_READBACK
  2. 调用 ID3D12GraphicsCommandList::CopyResource()将GPU资源复制到系统内存缓冲区——回读堆。回读堆必须和待复制的资源有相同的类型和大小
  3. 对回读堆中的数据进行映射——Map(),便于CPU读取

​ 以下示例将展示如何创建回读堆,及如何将计算结果从GPU复制到CPU内存中:

struct Data
{
	float3 v1;
	float2 v2;
};

StructuredBuffer<Data> gInputA : register(t0);
StructuredBuffer<Data> gInputB : register(t1);
RWStructuredBuffer<Data> gOutput : register(u0);


[numthreads(32, 1, 1)]
void CS(int3 dtid : SV_DispatchThreadID)
{
	gOutput[dtid.x].v1 = gInputA[dtid.x].v1 + gInputB[dtid.x].v1;
	gOutput[dtid.x].v2 = gInputA[dtid.x].v2 + gInputB[dtid.x].v2;
}

//创建回读堆
ThrowIfFailed(md3dDevice->CreateCommittedResource(
		&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_READBACK),
		D3D12_HEAP_FLAG_NONE,
		&CD3DX12_RESOURCE_DESC::Buffer(byteSize),
		D3D12_RESOURCE_STATE_COPY_DEST,
		nullptr,
		IID_PPV_ARGS(&mReadBackBuffer)));

// Schedule to copy the data to the default buffer to the readback buffer.
mCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(
    mOutputBuffer.Get(),
    D3D12_RESOURCE_STATE_COMMON, 
    D3D12_RESOURCE_STATE_COPY_SOURCE));

//将结果从GPU复制到回读堆
mCommandList->CopyResource(mReadBackBuffer.Get(), mOutputBuffer.Get());

mCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(
    mOutputBuffer.Get(),
    D3D12_RESOURCE_STATE_COPY_SOURCE, 
    D3D12_RESOURCE_STATE_COMMON));

// 命令记录完成
ThrowIfFailed(mCommandList->Close());

ID3D12CommandList* cmdsLists[] = { mCommandList.Get() };
mCommandQueue->ExecuteCommandLists(_countof(cmdsLists), cmdsLists);

FlushCommandQueue();

// 对数据进行映射,便于CPU读取
Data* mappedData = nullptr;

ThrowIfFailed(mReadBackBuffer->Map(0, nullptr, reinterpret_cast<void**>(&mappedData)));

std::ofstream fout("results.txt");

for(int i = 0; i < NumDataElements; ++i)
{
    fout << "(" << mappedData[i].v1.x << ", " << mappedData[i].v1.y << ", " << mappedData[i].v1.z <<
        ", " << mappedData[i].v2.x << ", " << mappedData[i].v2.y << ")" << std::endl;
}

mReadBackBuffer->Unmap(0, nullptr);

共享内存和线程同步

  • 共享内存

​ 由于在前面已经介绍过共享内存和全局内存,此处只谈及其用法

​ 声明共享内存:

groupshared float4 gCache[256];

声明共享内存的注意事项:

  1. 数组大小没有限制,但线程组共享内存的上限是32kb

  2. 由于共享内存属于线程组的局部内存,因此需要通过SV_ThreadGroupID语义对其进行索引

  3. 使用过的共享内存会引发性能问题

    比如,假设有一款最多支持32KB共享内存的多处理器,而计算着色器需要的共享内存为20KB,这意味着最多为每一个多处理器设置一个线程组(因为20KB + 20KB ≥ 32KB),这样一来便限制了GPU的并发性

​ 共享内存的应用场景:存储纹理数据,如图像模糊,需要对同一个像素进行多次采样,而采样速度是较慢的,但是可以将线程组所需的纹理样本全部预加载至共享内存块,随后在共享内存块中查找纹理样本并处理,如此速度也就变快了

  • 线程同步

​ 为什么需要线程同步?因为我们无法保证线程组内所有线程能同时完成任务,比如还未等到纹理预加载至共享内存的任务完成,其他线程就开始访问纹理肯定是会出错的

​ 线程同步示例:

Texture2D gInput;
RWTexture2D<float4> gOutput;
groupshared float4 gCache[256];

[numthreads(256, 1, 1)]
void CS(int3 groupThreadID : SV_GroupThreadID,
        int3 dispatchThreadID : SV_DispatchThreadID)
{
    // 每个线程都对纹理进行采样,再将纹理数据存储在共享内存中
    gCache[groupThreadID.x] = gInput[dispatchThreadID.xy];
    
    // 线程同步
    GroupMemoryBarrierWithGroupSync();
    
    // 此时读取共享内存的操作才是安全的
    float4 left = gCache[groupThreadID.x - 1];
    float4 right = gCache[groupThreadID.x + 1];
    …
}

reference

https://zhuanlan.zhihu.com/p/417257953

https://zhuanlan.zhihu.com/p/63222250

https://anteru.net/blog/2018/intro-to-compute-shaders/

https://zhuanlan.zhihu.com/p/330852688

https://www.cnblogs.com/X-Jun/p/10359345.html#_label6

posted @ 2023-03-02 16:24  爱莉希雅  阅读(329)  评论(2编辑  收藏  举报