Chapter12 The Compute Shader (Introduction to 3D Game Programming with DirectX 11)笔记

相比于CPU 被设计为 Random Memory Access,GPU 针对single location 以及 sequential location做了很多优化(streaming operation 流操作)。另外 vertex 和pixel由于是互相独立的,因此GPU可以对他们进行并行操作,比如 [NVIDIA09] NVIDIA “Fermi” architecture 可以支持 最多并行16个 流处理器,每个处理器 32 核,共512核。

GPU 的并行在图形渲染方面带来很大优势,而 非图形应用也可以利用GPU的并行优势,这种应用叫做 general purpose GPU (GPGPU) programming。并不是所有的程序都适合用GPU来计算,需要这些应用本身之间执行相同操作,互相独立,可以并行起来才可以。

如图中所示,CS并不属于pipeline的 一部分,而是 在一边作为独立的一部分,可以随时 读写 GPU Resources。它允许我们访问GPU实现数据并行的算法,但不绘制任何物件。

12.1 THREADS AND THREAD GROUPS

在GPU编程中,将许多thread 分成 thread groups里,一个group在一个处理器上运行。一般来说,如果是16核处理器,那最少需要分成 16个 thread group,但实际上,会至少一个处理器上两个thread group,这是因为单个的 group也有可能在等待,此时可以切换执行第二个group,因此可以分成32个froup。

每个thread group里的thread 共享一块内存,不同group内的thread不能访问对方的内存,同步操作也是在一个group发生,不同group内的不会同步。

一个thread group包含 n个thread。实际在硬件中,会把这些thread 分成 warps(Nvidia 一般的warp size是 32 threads,ATI 的“wavefont” 是 64 threads),然后一个warp 会被一个处理器同时处理SIMD32,即 Single Input Multi Data,一个Cuda processor 有32个 core,每个core处理一个 thread,所以是一个warp里有32个thread。在Dx里,也可以指定 thread group的size不是32的倍数,但从性能的方面考虑,最好还是32的倍数。

在Dx中,使用下面方法调用 thread group,生成 3D thread group grid,但书中之后只关心2D 的thread group。

void ID3D11DeviceContext::Dispatch(
        UINT ThreadGroupCountX,
        UINT ThreadGroupCountY,
        UINT ThreadGroupCountZ);

12.2 A SIMPLE COMPUTE SHADER

cbuffer cbSettings
{
    // Compute shader can access values in constant buffers.
};
// Data sources and outputs.
Texture2D gInputA;
Texture2D gInputB;
RWTexture2D<float4> gOutput;
// The number of threads in the thread group. The threads in a group can
// be arranged in a 1D, 2D, or 3D grid layout.
[numthreads(16, 16, 1)]
void CS(int3 dispatchThreadID : SV_DispatchThreadID) // Thread ID
{
    // Sum the xyth texels and store the result in the xyth texel of
    // gOutput.
    gOutput[dispatchThreadID.xy] =
    gInputA[dispatchThreadID.xy] +
    gInputB[dispatchThreadID.xy];
}
technique11 AddTextures
{
    pass P0
    {
        SetVertexShader(NULL);
        SetPixelShader(NULL);
        SetComputeShader(CompileShader(cs_5_0, CS()));
    }
}

1. Global variable access via constant buffers. 通过constant buffer访问全局变量。
2. Input and output resources, which are discussed in the next section. 输入输出资源下节12.3讨论。
3. The [numthreads(X, Y, Z)] attribute, which specifies the number of threads in the thread group as a 3D grid of threads.
4. The shader body that has the instructions to execute for each thread. CS的shader函数体会被每个thread执行。
5. Thread identification system value parameters (discussed in §12.4). 12.4节讨论thread identification。

注意 [numthreads(X, Y, 1)] 的 X 和 Y 可以变化,但正如之前提到的,thread group的数量需要是 32(Nvidia) / 64 (ATI)的倍数,因此最好是64的倍数,这样两种卡都支持。

12.3 DATA INPUT AND OUTPUT RESOURCES

两种资源可以被关联到 compute shader :buffers and textures。

12.3.1 Texture Inputs 输入

Texture2D gInputA;

通过 ID3D11ShaderResourceViews 创建 SRV 关联到shader,然后使用 ID3DX11EffectShaderResourceVariable 在compute shader中设置,和之前用的方法一样。注意这里 SRV 是只读的。

12.3.2 Texture Outputs and Unordered Access Views (UAVs) 输出

RWTexture2D<float4> gOutput;

CS的输出需要带 RW 前缀,表示 读写都可以,前面input是只读的。注意这里用了 <float4>,这跟输出的纹理格式有关,如果输出的格式是 DXGI_FORMAT_R8G8_SINT,那就得用 RWTexture2D<int2> gOutput; 了。

Bind输出的纹理到CS的过程也和input是不一样的,需要创建一个新的view类型: unordered access view (UAV),用ID3D11UnorderedAccessView 接口声明,声明方式类似之前的。

注意创建UAV的时候,被压缩的格式无法创建成功,会报错 ERROR: ID3D11Device::CreateTexture2D: The format (0x4d, BC3_UNORM)。

之后是创建 ID3D11UnorderedAccessView 的例子,介绍Input和output的声明过程。其中要注意的是 如果一个texture要被bind到 UAV,需要加上D3D11_BIND_UNORDERED_ACCESS 的flag,又由于要被bind到 SRV,因此声明应是 D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS.

ID3D11UnorderedAccessView 创建好之后,用 SetUnorderedAccessView 方法去设置output ID3DX11EffectUnorderedAccessViewVariable 变量。

12.3.3 Indexing and Sampling Textures

由于CS在GPU执行,可以使用一些常用的GPU工具。比如,可以在 采样texture的时候使用 纹理过滤。

这里有两个问题,第一个是不能直接使用 Sample方法,而是使用 SampleLevel 方法,这个方法多一个参数用于指定采样texture的mip层级,小数点位用于 两层之间混合。另一方面, Sample方法可以根据当前texture在屏幕上的占比 自动选择最合适的 mip层级,但CS阶段不知道这个,它不直接用于渲染,因此需要用SampleLevel显式的指定mip层级。 第二个问题是 当采样 纹理时,一般使用的是纹理坐标系 0~1,而不是整数下标,因此我们可以吧 texture 的width/height,写进 constant buffer,然后整数下标除以 长宽 计算得到 纹理坐标系的 uv。

下面两个例子中介绍了是否使用 SampleLevel 的写法。

12.3.4 Structured Buffer Resources

Buffer可以是hlsl里自定义的结构体,它的声明方式和普通buffer类似,使用D3D11_BUFFER_DESC,区别是必须指定 structured buffer flag 以及 指定 element 的 byte size。

而bind buffer到 shader resource,需要create SRV或 UAV ,使用ID3DX11EffectShaderResourceVariable 变量。

然后 Output = mFX->GetVariableByName(”gOutput”)->AsUnorderedAccessView();

Output->SetUnorderedAccessView(uav);  设置和读取 shader 的view。

另外是 使用buffer创建 view时,Format需要 指定为 DXGI_FORMAT_UNKNOWN,不管是 SRV还是 UAV. 这是因为buffer的结构体是自定义的,所以不跟一般的 DXGI_FORMAT 对应,比如

Buffer<float4> typedBuffer1;
Buffer<float> typedBuffer2;
Buffer<int2> typedBuffer3;

在最开始创建buffer时,不指定 D3D11_RESOURCE_MISC_BUFFER_STRUCTURED flag,在创建view时,就必须腰围Format property 指定正确的 DXGI_FORMAT。注意,一个buffer类型,可能有很多种  DXGI_FORMAT 可以对应,比如上面的typedBuffer1 就有 DXGI_FORMAT_R32G32B32A32_FLOAT, DXGI_FORMAT_R16G16B16A16_FLOAT, and
DXGI_FORMAT_R8G8B8A8_UNORM.都可以。 

还有一种buffer 是raw buffer,是单一的 byte array,需要使用byte offset,然后最终被cast成合适的type,这可以被用来存储多种数据类型,这本书里是没有用到的。

12.3.5 Copying CS Results to System Memory

一般,当我们用CS处理一张纹理,最后会把纹理绘制到屏幕来看正确与否。但如果是buffer的话,无法绘制,因此我们需要把GPU里的buffer读取到CPU,然后用cpu的方式打印出来。

一般的方法是创建一个 system memory buffer ,flag设为 D3D11_USAGE_STAGING ,CPU 的usage 设为D3D11_CPU_ACCESS_READ.然后使用 ID3D11DeviceContext::CopyResource 方法将buffer从GPU拷贝到 system memory。两个buffer必须有同样的 类型和大小。

demo中有一个例子“VecAdd”,展示这个过程。

最后注意从GPU拷贝到CPU 是非常慢的,但还好的是一般这种操作不是每帧调用的。

12.4 THREAD IDENTIFICATION SYSTEM VALUES

1.每一个 thread group 都有自己的 group ID, 系统语义 SV_GroupID,如果系统分发了 Gx × Gy × Gz 的group,那group ID 的范围就是 从(0,0,0) 到 (Gx − 1, Gy − 1, Gz − 1)。

2.在一个group内,每一个thread 都有相对与group的唯一ID,系统语义 SV_GroupThreadID,如果group的大小是 X × Y × Z,那group thread id 就是 从(0, 0, 0) 到 (X − 1, Y − 1, Z − 1)。

3.一个dispatch call 会分发 一个线程组的网格,在这个dispatch call中会相对于所有的thread 为每一个thread 标记一个唯一的dispatch thread ID,系统语义SV_DispatchThreadID。换句话,group thread ID会在一个thread group里唯一识别一个thread,而dispatch thread ID 会在一个 dispatch call里识别这个call 内dispatch 的所有thread的唯一ID。

假如 ThreadGroupSize = (X,Y,Z) ,下图是(8,8,0),而 GroupID =(1,1,0),ThreadGroupID=(2,5,0),那 dispatchThreadID= (1, 1, 0) ⊗ (8, 8, 0) + (2, 5, 0) = (10, 13, 0). 它的 group index id = 5*8+2 = 42.

dispatchThreadID.xyz = groupID.xyz * ThreadGroupSize.xyz + groupThreadID.xyz;

4. GroupIndex是线性的,系统语义 SV_GroupIndex。计算方式是 

groupIndex = groupThreadID.z*ThreadGroupSize.x*ThreadGroupSize.y +groupThreadID.y*ThreadGroupSize.x +groupThreadID.x;

上面的这些thread ID被用于索引input和output的 data structures。

12.5 APPEND AND CONSUME BUFFERS

即 ComputeShader中Consume与AppendStructuredBuffer ,假定粒子模拟,每个particle需要基于恒定的速度和加速度更新自身位置,并且不关心更新的particle的顺序以及它们写入的顺序,即不需要担心它们的index,那就非常适合这个情景。

一旦一个 元素 被一个线程消耗,它不会被另一个不同的线程消耗。这里要强调的是,这些元素被消耗和填充的顺序都是不确定的。另外就是,buffer的大小不是自动增长的,是需要一开始就设定好足够大小。

在C++端创建 UAV 时,唯一需要做的就是 指定 D3D11_BUFFER_UAV_FLAG_APPEND 的flag。

D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
uavDesc.Format = DXGI_FORMAT_UNKNOWN;
uavDesc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
uavDesc.Buffer.FirstElement = 0;
uavDesc.Buffer.Flags = D3D11_BUFFER_UAV_FLAG_APPEND;
uavDesc.Buffer.NumElements = mNumElements;

12.6 SHARED MEMORY AND SYNCHRONIZATION

ThreadGroups 有一段共享内存,访问这段内存的速度很快。在CS里,表示为groupshared float4 gCache[256];。

数组的大小可以任意,但是最大是 32kb,因为这需要根据 SV_ThreadGroupID 来索引,举个例子,在这段memory里,你可能给每个thread一个slot。

虽然最大是32kb,但需要比较多也会导致性能问题,假如一个处理器支持 32kb ,但CS需要 20kb,那代表这个只支持 一个 thread group,其他的group就没有空间了,因为上面12.1也提到了,一个group使用一断共享内存。这个限制会影响线程的并行性,由于之前也提到的,一个处理器希望最少有两个group,这样可以在两个之间切换,减少等待,那就需要20+20=40kb,不够。因此,需要支持32kb,需求的也要尽量少。

一般的shared memory用法是用来存储 texture 的values。一些特定的算法,比如blur,需要采集一个texel很多次。受限于内存带宽和内存延迟,一般的纹理采样是一个比较慢的GPU操作。一个thread group可以通过预加载所有需要的纹理到共享内存的数组来避免冗余的纹理获取。而共享内存的纹理采样会非常快。

注意有些情况需要使用 GroupMemoryBarrierWithGroupSync(); 来线程间同步,下面是一段错误的代码:

如注释里写的,当一个thread执行到下面的时候,可能左右的线程还没有执行完第一步,即把纹理存储到共享内存里面。因此就需要线程同步了。正确的写法:

12.7 BLUR DEMO

先介绍blur的数学原理,然后讨论 render-to-texture去生成一张纹理去blur,最后检查所有cs的相关实现并讨论一些实现中的tricky。

12.7.1 Blurring Theory

基本原理是 以Pij为中心,计算m*n矩阵的像素的平均权重,然后计算得到像素的平均值。例子中是3*3方形矩阵, blur radius=a=b=1。

a,b为水平和垂直的blur 半径,而 m = 2a + 1 and n = 2b + 1,这样强制n,n为奇数,m*n的矩阵就一直有一个center,矩阵也叫做 blur kernel。注意权重值的和要=1,如果<1,模糊之后的图像会变黑,像有些颜色被抽离,如果>1,会变亮,加上了某些颜色。

有很多种方法让其权重值和为1,其中比较多的叫 高斯模糊(Gaussian Blur),通过高斯方程计算,σ不同图像不同。

假定做1*5 高斯模糊(一维的水平方向模糊),算子σ =1,则G(x)中x=−2, −1, 0, 1, 2(因为是1*5,水平五个值)。

但它们的权重和并不为1,因此需要再除以他们的和,则得到5个值的权重。

,最终权重为

高斯模糊是可以分离的,可以将 2D 的blur,分成两个 1D 的blur,比如先水平blur,在垂直blur,得到Blur(I) = BlurV (BlurH (I))。这有个好处是原本9*9的矩阵,需要进行81次sample采样,现在变成2维的,只需要进行9+9=18次,减少了采样次数。

12.7.2 Render-to-Texture

ID3D11Texture2D* backBuffer;
HR(mSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D),reinterpret_cast<void**>(>backBuffer)));
HR(md3dDevice->CreateRenderTargetView(backBuffer, 0,>mRenderTargetView));

在之前的渲染到back buffer,其实代码里也是渲染一张纹理到swap chain,就是一个renderTarget,通过bind back buffer的renderTargetView到OM stage,md3dImmediateContext->OMSetRenderTargets(1,>mRenderTargetView, mDepthStencilView);最终调用 IDXGISwapChain::Present 交换渲染到屏幕。

上述过程可以看到,我们完全可以 create another texture,create a render target view to it,bind it to OM stage of rendering pipeline,甚至可以通过一个不同的camera,渲染一张 离线 纹理。这种技术叫做 render-to-off-screen-texture 或者 render-to-texture。

这种技术有很多应用,比如专门渲染一张offline texture 作为雷达地图等等,另外的应用包括:

1. Shadow mapping 阴影映射
2. Screen Space Ambient Occlusion 屏幕空间环境遮挡
3. Dynamic reflections with cube maps 基于立方体映射的动态反射

而在这个blur的demo里,具体应用是先正常渲染场景到一张texture,然后将texture传入blur算法,经CS执行,然后创建一个 quad 面片,应用blurred texture到quad,绘制到back buffer。

一个注意的地方是,实际开发要减少 normal render pipeline和cs 之间的上下文切换,比如上面就有一次来回的切换。

12.7.3 Blur Implementation Overview

假定blur是 从2D分离到1D的,一次垂直一次水平。实现这个需要两个texture buffer,并且同时都可以读写,因为也需要都同时创建 SRV 和 UAV。 声明两个texture 为 A和B。I为需要blur的image,那一般 blur算法:

1. Bind the SRV to A as an input to the compute shader (this is the input image that will be horizontally blurred). 
2. Bind the UAV to B as an output to the compute shader (this is the output image that will store the blurred result).
3. Dispatch the thread groups to perform the horizontal blur operation. After this, texture B stores the horizontally blurred result
BlurH (I), where I is the image to blur.
4. Bind the SRV to B as an input to the compute shader (this is the horizontally blurred image that will next be vertically blurred).
5. Bind the UAV to A as an output to the compute shader (this is the output image that will store the final blurred result).
6. Dispatch the thread groups to perform the vertical blur operation. After this, texture A stores the final blurred result Blur(I),
where I is the image to blur.

  1. bind SRV到texture A,作为cs的input,即将水平blur。
  2. bind UAV到texture B,作为cs的output,存储blur后的数据。
  3. 分发thread group执行水平blur,B中存储结果BlurH (I)。
  4. bind SRV到texture B,作为cs的input,即将垂直blur。
  5. bind UAV到texture A,作为cs的output,存储blur后的数据,最近结果。
  6. 分发thread group执行垂直blur,A中存储结果BlurV(BlurH (I)).

以上过程中,来回交替设置AB分别作为input和output,这是因为在Dx中,一个resource不能同时既是input又是output。

注意的地方,这个里面一开始A作为原始image,到最后已经被覆盖了,有些需求可能同时需要original image和blurred image。另一个是 A和B需要和back buffer 和 屏幕大小一样大。这个在OnResize中实现,在窗口设置大小,同时更新resource的大小。有时为了性能考虑,可能只用back buffer 1/4的大小,这样 更少pixel需要填充到texture,也更少pixel需要blur。

off-screen texture的flag是 

D3D11_BIND_RENDER_TARGET |D3D11_BIND_SHADER_RESOURCE |D3D11_BIND_UNORDERED_ACCESS。

假定image的宽高为wh,

为了水平1D blur,设定thread group为 水平line 256 threads,一个thread对应一个pixel,因此需要dispatch x方向 thread groups和y方向 h thread groups。。如果w/256除不尽,那会有空的thread,这个目前不能做什么,因为thread group的大小是固定的,可以再shader里处理这些超出范围的thread。

同样为了垂直1D blur,设定thread group为 垂直line 256 threads,一个thread对应一个pixel,因此需要dispatch y方向  thread groups和 x方向 w thread groups。

如图,一张 28*14 的texture,水平的 thread group为 8*1 ,垂直的group为 1*8,水平pass时,即水平x方向blur,一次8

个pixel,那h方向就是 h个group,即14,而水平w方向就是 ceil(28/8)=4,因此x方向4个,y方向14个,一共18个。垂直pass同理,垂直group为1*8,x方向一一对应,28个group,y方向ceil(14/8)=2个group,一共30个。

书中下面是一段示例代码,计算需要dispatch多少个group。

for(int i = 0; i < blurCount; ++i)
{
    // HORIZONTAL blur pass.
    D3DX11_TECHNIQUE_DESC techDesc;
    Effects::BlurFX->HorzBlurTech->GetDesc(>techDesc);
    for(UINT p = 0; p < techDesc.Passes; ++p)
    {
    Effects::BlurFX->SetInputMap(inputSRV);
    Effects::BlurFX->SetOutputMap(mBlurredOutputTexUAV);
    Effects::BlurFX->HorzBlurTech->GetPassByIndex(p)->Apply(0, dc);
    // How many groups do we need to dispatch to cover a
    // row of pixels, where each group covers 256 pixels
    // (the 256 is defined in the ComputeShader).
    UINT numGroupsX = (UINT)ceilf(mWidth / 256.0f);
    dc->Dispatch(numGroupsX, mHeight, 1);
}

12.7.4 Compute Shader Program

讨论一下cs里的水平的blur,垂直类似。

水平的group为256*1,一个thread负责一个pixel的blur,一个thread会去采样负责的pixel周围的多个pixel,这里有个问题是采样点会重复采样,如下所示每个点都会采样周围的几个,这个问题的一个优化策略是可以用 12.6 中提到的,使用shared memory,提高共享内存提高采样速度。

一个tricky thing是 一个thread group是 n=256 threads,那就需要 n+2R 的texls的shared memory ,R是blur radius。多出了的R的空间,用正常pixel的最前几个和最后几个填充。

最后一个要处理的是,当最左侧的thread group和最右侧的thread group,按照默认的超出范围的采样(读会返回0,写会导致no-op),但是我们并不像要超出范围的时候读0,这代表黑色,会加入blur,因此我们需要clamp texture address mode,限制在范围内,超出边界,就返回边界的颜色。

// Clamp out of bound samples that occur at left image borders.
int x = max(dispatchThreadID.x - gBlurRadius, 0);
gCache[groupThreadID.x] = gInput[int2(x, dispatchThreadID.y)];

// Clamp out of bound samples that occur at right image borders.
int x = min(dispatchThreadID.x + gBlurRadius, gInput.Length.x-1);
gCache[groupThreadID.x+2*gBlurRadius] =gInput[int2(x, dispatchThreadID.y)];

// Clamp out of bound samples that occur at image borders.
gCache[groupThreadID.x+gBlurRadius] =gInput[min(dispatchThreadID.xy, gInput.Length.xy-1)];

最后书中展示了完整的cs代码,最后提到 gOutput[dispatchThreadID.xy] = blurColor; 最后的写入可能会out-of-bound,但不需要担心,因为是no-op。

12.8 FURTHER RESOURCES

延伸阅读,介绍了 CUDA 和 OpenCL。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值