DirectX11 With Windows SDK--26 计算着色器:入门

前言

注意:这一章对应项目代码2.x.x版本,持有低版本项目代码的同学请到Github更新一份

计算着色器是一个比较庞大的分支,我们除了通过渲染管线来利用GPU的计算能力之外,还可以通过计算着色器来使用GPU的通用计算能力。接下来将会花费5章的内容讲述计算着色器的基础及应用。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

概述

这里所使用的计算着色器实际上是属于DirectCompute的一部分,DirectCompute是一种应用程序编程接口(API),最初与DirectX 11 API 一起发布,但如果你的显卡只支持到特性等级10.x,那么你只能使用到计算着色器的有限功能,这里不讨论。

GPU通常被设计为从一个位置或连续的位置读取并处理大量的内存数据(即流操作),而CPU则被设计为专门处理随机内存的访问。

由于顶点数据和像素数据可以分开处理,GPU架构使得它能够高度并行,在处理图像上效率非常高。但是一些非图像应用程序也能够利用GPU强大的并行计算能力以获得效益。GPU用在非图像用途的应用程序可以称之为:通用GPU(GPGPU)编程。

GPU需要数据并行的算法才能从GPU的并行架构中获得优势,并不是所有的算法都适合用GPU来实现。对于大量的数据,我们需要保证它们都进行相似的操作以确保并行处理。比如顶点着色器都是对大量的顶点数据进行处理,而像素着色器也是对大量的像素片元进行处理。

对于GPGPU编程,用户通常需要从显存中获取运算结果,将其传回CPU。这需要从显存将结果复制到内存中,这样虽然速度会慢一些,但起码还是比直接在CPU运算会快很多。如果是用于图形编程的话倒是可以省掉数据传回CPU的时间,比如说我们要对渲染好的场景再通过计算着色器来进行一次模糊处理。

在Direct3D中,计算着色器也是一个可编程着色器,它并不属于渲染管线的一个直接过程。我们可以通过它对GPU资源进行读写操作,运行的结果通常会保存在Direct3D的资源中,我们可以将它作为结果显示到屏幕,可以给别的地方作为输入使用,甚至也可以将它保存到本地。

线程和线程组

在GPU编程中,我们编写的着色器程序会同时给大量的线程运行,可以将这些线程按网格来划分成线程组。一个线程组由一个多处理器来执行,如果你的GPU有16个多处理器,你会想要把问题分解成至少16个线程组以保证每个多处理器都工作。为了获取更好的性能,让每个多处理器来处理至少2个线程组是一个比较不错的选择,这样当一个线程组在等待别的资源时就可以先去考虑完成另一个线程组的工作。

每个线程组都会获得共享内存,这样每个线程都可以访问它。但是不同的线程组不能相互访问对方获得的共享内存。

线程同步操作可以在线程组中的线程之间进行,但处于不同线程组的两个线程无法被同步。事实上,我们没有办法控制不同线程组的处理顺序,毕竟线程组可以在不同的多处理器上执行。

一个线程组由N个线程组成。硬件实际上会将这些线程划分成一系列warps(一个warp包含32个线程),并且一个warp由SIMD32中的多处理器进行处理(32个线程同时执行相同的指令)。在Direct3D中,你可以指定一个线程组不同维度下的大小使得它不是32的倍数,但是出于性能考虑,最好还是把线程组的维度大小设为warp的倍数。

将线程组的大小设为64或256通常是个比较好的选择,它适用于大量的硬件情况。如果修改线程组的大小意味着你还需要修改需要调度的线程组数目。

注意:NVIDIA硬件中,每个warp包含32个线程。而ATI则是每个wavefront包含64个线程。warp或者wavefront的大小可能随后续硬件的升级有所修改。

ID3D11DeviceContext::Dispatch方法--调度线程组执行计算着色器程序

方法如下:

void ID3D11DeviceContext::Dispatch(
	UINT ThreadGroupCountX,		// [In]X维度下线程组数目
	UINT ThreadGroupCountY,		// [In]Y维度下线程组数目
	UINT ThreadGroupCountZ);	// [In]Z维度下线程组数目

可以看到上面列出了X, Y, Z三个维度,说明线程组本身是可以3维的。当前例子的一个线程组包含了8x8x1个线程,而线程组数目为3x2x1,即我们进行了这样的调用:

m_pd3dDeviceContext->Dispatch(3, 2, 1);

第一份计算着色器程序

现在我们有这两张图片,我想要将它混合并将结果输出到一张图片:

下面的这个着色器负责对两个纹理的像素颜色进行分量乘法运算。

Texture2D g_TexA : register(t0);
Texture2D g_TexB : register(t1);

RWTexture2D<unorm float4> g_Output : register(u0);

// 一个线程组中的线程数目。线程可以1维展开,也可以
// 2维或3维排布
[numthreads(16, 16, 1)]
void CS( uint3 DTid : SV_DispatchThreadID )
{
    g_Output[DTid.xy] = g_TexA[DTid.xy] * g_TexB[DTid.xy];
}

上面的代码有如下需要注意的:

  1. Texture2D仅能作为输入,但RWTexture2D<T>类型支持读写,在本样例中主要是用于输出
  2. RWTexture2D<T>使用时也需要指定寄存器,u说明使用的是无序访问视图寄存器
  3. [numthreads(X, Y, Z)]修饰符指定了一个线程组包含的线程数目,以及在3D网格中的布局
  4. 每个线程都会执行一遍该函数
  5. SV_DispatchThreadID是当前线程在3D网格中所处的位置,每个线程都有独立的SV_DispatchThreadID
  6. Texture2D除了使用Sample方法来获取像素外,还支持通过索引的方式来指定像素

如果使用1D纹理,线程修饰符通常为[numthreads(X, 1, 1)][numthreads(1, Y, 1)]

如果使用2D纹理,线程修饰符通常为[numthreads(X, Y, 1)],即第三维度为1

2D纹理X和Y的值会影响你在调度线程组时填充的参数

注意:

  1. cs_4_x下,一个线程组的最大线程数为768,且Z的最大值为1.
  2. cs_5_0下,一个线程组的最大线程数为1024,且Z的最大值为64.

纹理输出与无序访问视图(UAV)

留意上面着色器代码中的类型RWTexture2D<T>,你可以对他进行像素写入,也可以从中读取像素。不过模板参数类型填写就比较讲究了。我们需要保证纹理的数据格式和RWTexture2D<T>的模板参数类型一致,这里使用下表来描述比较常见的纹理数据类型和HLSL类型的对应关系:

DXGI_FORMAT HLSL类型
DXGI_FORMAT_R32_FLOAT float
DXGI_FORMAT_R32G32_FLOAT float2
DXGI_FORMAT_R32G32B32A32_FLOAT float4
DXGI_FORMAT_R32_UINT uint
DXGI_FORMAT_R32G32_UINT uint2
DXGI_FORMAT_R32G32B32A32_UINT uint4
DXGI_FORMAT_R32_SINT int
DXGI_FORMAT_R32G32_SINT int2
DXGI_FORMAT_R32G32B32A32_SINT int4
DXGI_FORMAT_R16G16B16A16_FLOAT float4
DXGI_FORMAT_R8G8B8A8_UNORM unorm float4
DXGI_FORMAT_R8G8B8A8_SNORM snorm float4

其中unorm float表示的是一个32位无符号的,规格化的浮点数,可以表示范围0到1
而与之对应的snorm float表示的是32位有符号的,规格化的浮点数,可以表示范围-1到1

从上表可以得知DXGI_FORMAT枚举值的后缀要和HLSL的类型对应(浮点型对应浮点型,整型对应整型,规格化浮点型对应规格化浮点型),否则可能会引发下面的错误(这里举DXGI_FORMATunormHLSL类型为float的例子):

D3D11 ERROR: ID3D11DeviceContext::Dispatch: The resource return type for component 0 declared in the shader code (FLOAT) is not compatible with the resource type bound to Unordered Access View slot 0 of the Compute Shader unit (UNORM). This mismatch is invalid if the shader actually uses the view (e.g. it is not skipped due to shader code branching). [ EXECUTION ERROR #2097372: DEVICE_UNORDEREDACCESSVIEW_RETURN_TYPE_MISMATCH]

由于DXGI_FORMAT的部分格式比较紧凑,HLSL中能表示的最小类型通常又比较大。比如DXGI_FORMAT_R16G16B16A16_FLOATfloat4,个人猜测HLSL的类型为了能传递给DXGI_FORMAT,允许做丢失精度的同类型转换。

现在我们回到C++代码,现在需要创建一个2D纹理,然后在此基础上再创建无序访问视图作为着色器输出。

bool GameApp::InitResource()
{
    CreateDDSTextureFromFile(m_pd3dDevice.Get(), L"..\\Texture\\flare.dds",
        nullptr, m_pTextureInputA.GetAddressOf());
    CreateDDSTextureFromFile(m_pd3dDevice.Get(), L"..\\Texture\\flarealpha.dds",
        nullptr, m_pTextureInputB.GetAddressOf());

    // DXGI_FORMAT                     |  RWTexture2D<T>
    // --------------------------------+------------------
    // DXGI_FORMAT_R8G8B8A8_UNORM      |  unorm float4
    // DXGI_FORMAT_R16G16B16A16_UNORM  |  unorm float4
    // DXGI_FORMAT_R8G8B8A8_SNORM      |  snorm float4
    // DXGI_FORMAT_R16G16B16A16_SNORM  |  snorm float4
    // DXGI_FORMAT_R16G16B16A16_FLOAT  |  float4 或 half4?
    // DXGI_FORMAT_R32G32B32A32_FLOAT  |  float4
    DXGI_FORMAT format = DXGI_FORMAT_R8G8B8A8_UNORM;
    CD3D11_TEXTURE2D_DESC texDesc(format, 512, 512, 1, 1,
        D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS);
    m_pd3dDevice->CreateTexture2D(&texDesc, nullptr, m_pTextureOutput.GetAddressOf());
    
    // 创建无序访问视图
    CD3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc(D3D11_UAV_DIMENSION_TEXTURE2D, format);
    m_pd3dDevice->CreateUnorderedAccessView(m_pTextureOutput.Get(), &uavDesc,
        m_pTextureOutputUAV.GetAddressOf());

    // 创建计算着色器
    ComPtr<ID3DBlob> blob;
    D3DReadFileToBlob(L"Shaders\\TextureMul_CS.cso", blob.GetAddressOf());
    m_pd3dDevice->CreateComputeShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pTextureMul_CS.GetAddressOf());

    return true;
}

观察上面的代码,如果我们想要让纹理绑定到无序访问视图,就需要提供D3D11_BIND_UNORDERED_ACCESS绑定标签。

注意:如果你还为同一个纹理子资源创建了着色器资源视图,那么UAV和SRV不能同时绑定到渲染管线上。

ID3D11DeviceContext::CSSetUnorderedAccessViews--计算着色阶段设置无序访问视图

void ID3D11DeviceContext::CSSetUnorderedAccessViews(
    UINT                      StartSlot,                       // [In]起始槽,值与寄存器对应
    UINT                      NumUAVs,                         // [In]UAV数目
    ID3D11UnorderedAccessView * const *ppUnorderedAccessViews, // [In]UAV数组
    const UINT                *pUAVInitialCounts               // [In]忽略
);

调度过程实现如下:

void GameApp::Compute()
{
    assert(m_pd3dImmediateContext);

    m_pd3dImmediateContext->CSSetShaderResources(0, 1, m_pTextureInputA.GetAddressOf());
    m_pd3dImmediateContext->CSSetShaderResources(1, 1, m_pTextureInputB.GetAddressOf());

    m_pd3dImmediateContext->CSSetShader(m_pTextureMul_CS.Get(), nullptr, 0);
    m_pd3dImmediateContext->CSSetUnorderedAccessViews(0, 1, m_pTextureOutputUAV.GetAddressOf(), nullptr);
    m_pd3dImmediateContext->Dispatch(32, 32, 1);

    SaveDDSTextureToFile(m_pd3dImmediateContext.Get(), m_pTextureOutput.Get(), L"..\\Texture\\flareoutput.dds");
    MessageBox(nullptr, L"请打开..\\Texture文件夹观察输出文件flareoutput.dds", L"运行结束", MB_OK);
}

由于我们的位图是512x512x1大小,一个线程组的线程布局为16x16x1,线程组的数目自然就是32x32x1了。如果调度的线程组宽度或高度不够,输出的位图也不完全。而如果提供了过宽或过高的线程组并不会影响运行结果,只是提供的线程组资源过多有些浪费而已。

最后通过ScreenGrab库将纹理保存到文件,就可以结束程序了。

运行结束后,可以打开flareoutput.dds查看结果:

那么问题来了,如果我想要输出DXGI_FORMAT_R8G8B8A8_UNORM的纹理,那应该怎么做呢?

  1. 将纹理创建时使用的DXGI_FORMAT换成DXGI_FORMAT_R8G8B8A8_UNORM,连同UAV的Format也要替换
  2. 计算着色器将RWTexture2D<float4>类型替换成RWTexture2D<unorm float4>类型

修改后的着色器代码如下:

Texture2D g_TexA : register(t0);
Texture2D g_TexB : register(t1);

RWTexture2D<unorm float4> g_Output : register(u0);

// 一个线程组中的线程数目。线程可以1维展开,也可以
// 2维或3维排布
[numthreads(16, 16, 1)]
void CS( uint3 DTid : SV_DispatchThreadID )
{
    g_Output[DTid.xy] = (unorm float4)(g_TexA[DTid.xy] * g_TexB[DTid.xy]);
}

注意:如果你使用了HLSL Tools For Visual Studio插件,它不认unorm类型,从而引发所谓的语法错误提示。你可以直接无视去编译项目,它还是能成功编译出着色器的。

运行的图片显示结果基本上是一样的,只是输出的纹理格式不太一样:

纹理子资源的采样和索引

从上面的例子可以看到,我们能够使用2D索引来指定纹理的某一像素。如果2D索引越界访问,在计算着色器中是拥有良好定义的:读取越界资源将返回0,尝试写入越界资源的操作将不会执行。

但是上面这种采样只针对mip等级为0的纹理子资源,如果我们想指定其它mip等级的纹理子资源,可以使用mip.operator[][]方法:

R mips.Operator[][](
  in uint mipSlice,		// [In]mip切片值
  in uint2 pos			// [In]2D索引
);

返回值R视纹理数据类型而定。

用法如下:

g_Output.mip[g_MipSlice][DTid.xy] = 
	(unorm float4)(g_TexA.mip[gMipSlice][DTid.xy] * g_TexB.mip[g_MipSlice][DTid.xy]);

不过我们的演示程序用到的纹理Mip等级都为1,这里就不在代码端演示了。

纹理的Sample方法需要在像素着色器才可以使用,因为它需要根据ddx和ddy来计算mipmap等级。但是我们可以使用SampleLevel方法来指定要对纹理的哪个mip等级的子资源进行采样:

R SampleLevel(
	in SamplerState S,		// [In]采样器状态
	in float2 Location,		// [In]纹理坐标
	in float LOD			// [In]mip等级
);

当LOD为整数时,指定的就是具体某个mip等级的纹理,但如果LOD为浮点数,如3.3f,则会对mip等级为3和4的纹理子资源都进行一次采样,然后根据小数部分进行线性插值求得最终的插值颜色。

用法如下:

float4 texColor = g_Tex.SampleLevel(g_Sam, pIn.Tex, 0.0f);	// 使用第一个mip等级的纹理子资源

还有一种办法则是使用Load方法,它需要传递int3类型的参数,其中xy分量分别对应x和y方向基于0的索引(而不是0.0-1.0),z分量则为要使用的mipmap等级:

float4 texColor = g_Tex.Load(int3(DTid.xy, g_MipSlice))

Typed UAV Load

大多数情况下,我们是通过UAV进行写入操作的,如果我们想要通过UAV进行读取操作的话,就需要加以注意了。在某些情况下你会发现居然不能对RWTexture2D<float4>进行读取操作。编译器可能会报这种错误:

error X3676: typed UAV loads are only allowed for single-component 32-bit element types on old compilers/devices.

这是因为,在仅支持Direct3D 11.0 - 11.2的情况下,UAV可以操作下面三种类型:

  • R32_FLOAT
  • R32_UINT
  • R32_SINT

下面这些类型是Direct3D 11.3以上所支持的格式:

  • R32G32B32A32_FLOAT
  • R32G32B32A32_UINT
  • R32G32B32A32_SINT
  • R16G16B16A16_FLOAT
  • R16G16B16A16_UINT
  • R16G16B16A16_SINT
  • R8G8B8A8_UNORM
  • R8G8B8A8_UINT
  • R8G8B8A8_SINT
  • R16_FLOAT
  • R16_UINT
  • R16_SINT
  • R8_UNORM
  • R8_UINT
  • R8_SINT

而下述格式则是在Direct3D 11.3以上可选的 或 独立支持的,需要通过D3D设备询问的方式来检验是否支持:

  • R16G16B16A16_UNORM
  • R16G16B16A16_SNORM
  • R32G32_FLOAT
  • R32G32_UINT
  • R32G32_SINT
  • R10G10B10A2_UNORM
  • R10G10B10A2_UINT
  • R11G11B10_FLOAT
  • R8G8B8A8_SNORM
  • R16G16_FLOAT
  • R16G16_UNORM
  • R16G16_UINT
  • R16G16_SNORM
  • R16G16_SINT
  • R8G8_UNORM
  • R8G8_UINT
  • R8G8_SNORM
  • R8G8_SINT
  • R16_UNORM
  • R16_SNORM
  • R8_SNORM
  • A8_UNORM
  • B5G6R5_UNORM
  • B5G5R5A1_UNORM
  • B4G4R4A4_UNORM

练习题

粗体字为自定义题目

  1. 尝试修改ID3D11DeviceContext::Dispatch的参数,观察运行结果
  2. 尝试利用计算着色器来计算出两张纹理的颜色差异值,并保存为图片观察结果

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

posted @ 2019-01-31 10:28  X_Jun  阅读(6047)  评论(3编辑  收藏  举报
levels of contents