Unity Compute Shader - 2 脚本回顾

在上一章中,我们使用Compute Shader生成了一个图片,并且让这张图片显示在了Quad上,这张图看起来很奇怪,这种图形的名字是谢尔宾斯基三角形

Compute Shader所用的语言是HLSL(High Level Shader Language),其语法和C比较相似。

我们打开之前创建的FirstComputeShader脚本,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain

// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
RWTexture2D<float4> Result;

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
// TODO: insert actual code here!

Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}

首先看第一行 #pragma kernel CSMain,其中 CSMain 表示当前的内核名称。通过C#脚本中的 ComputeShader.FindKernel(string kernekName)就可以获得这个内核的索引,方便后续调用。也就是之前C#脚本中的 ComputeShader.Dispatch
Compute Shader中声明每一个kernel,前面都需要家关键字 #pragma
在当前内核中,运算入口函数名称要和频道名称相同,也就是 void CSMain

在声明 void CSMain 函数上方还有一行代码 [numthreads(8,8,1)],这是一个着色器编译指令,用于指定每个线程组的线程数量。这个指令放在着色器入口函数CSMain的前面,用来告诉编译器和运行时环境,当CSMain函数被调用时,应该以多少线程来组织线程组。当前这个指令表示8个线程在X维度,8个线程在Y维度,以及1个线程在Z维度。因此,当前线程组总共有 XYZ(8×8×1=64) 个线程
X,Y,Z三个值也并不是也可随便乱填的。它们在不同的版本里有如下的约束:

Compute Shader Maximum Z Maximum Threads (XYZ)
cs_4_x 1 768
cs_5_0 64 1024

在Compute Shader中,线程组的每个线程都会执行CSMain函数,并且每个线程都会接收到它自己的id。线程可以使用这个id来计算它应该处理的数据,或者访问全局内存中正确的位置。

在Direct11中,可以通过ID3D11DeviceContext::Dispatch(X,Y,Z)方法创建XYZ个线程组,一个线程组里又会包含多个线程(数量即numthreads定义)。注意顺序,先numthreads定义好每个核函数对应线程组里线程的数量,再用Dispatch定义用多少线程组来处理这个核函数。其中每个线程组内的线程都是并行的,不同线程组的线程可能同时执行,也可能不同时执行。一般一个GPU同时执行的线程数,在1000-10000之间。

接下来我们看核函数 void CSMain (uint3 id : SV_DispatchThreadID)

  • uint3 id 表示一个包含三个分量(x、y、z)的无符号整数向量,代表当前线程在线程组中的索引位置。Compute Shader中的线程是按组(group)组织的,每个线程组可以有多个线程,线程组又可以组成更大的网格(grid)。id向量指定了线程在其线程组内的相对位置。
  • SV_DispatchThreadID 这是参数的语义,SV_DispatchThreadID是一个系统值语义 (System Value Semantic),在这里我们将其他相关语义一同讲解
    • SV_GroupID:线程组的ID,其实就是一个int3的值,如果我们线程组定义为(X,Y,Z),那么SV_GroupID的取值范围即为(0,0,0)到(X-1,Y-1,Z-1)。
    • SV_GroupThreadID:线程组内的某个线程的ID,同样是一个int3的值。它不考虑与线程组的关系,例如不同线程组里的第一个线程的SV_GroupThreadID都是(0,0,0)。
    • SV_DispatchThreadID:所有线程组中的某个线程ID,也是一个int3的值。它和SV_GroupThreadID就不一样了,需要考虑线程组,例如我一个线程组有(X,Y,Z)个线程,那么SV_GroupID=(a,b,c)的线程组里的SV_GroupThreadID=(i,j,k)的线程的SV_DispatchThreadID为 (a*X+i, b*Y+j, c*Z+k)
    • SV_GroupIndex:线程组内的某个线程的下标,是一个int值。例如我一个线程组有(X,Y,Z)个线程,其中第一个线程(0,0,0)的下标为0,下标增长的顺序是从左往右(x),然后从上往下(y),最后从前往后(z),例如:(1,0,0)=1,(1,0,0)=2,…,(0,1,0)=X,…,(0,0,1)=X*Y,… 因此可以得到如下公式
      1
      SV_GroupIndex = SV_GroupThreadID.z*X*Y + SV_GroupThreadID.y*X + SV_GroupThreadID.x
      为了更好理解,下图计算示例如下:

除了 SV_DispatchThreadID 以外,我们刚才介绍的参数都可以加入核函数的参数中,如下所示:

1
2
3
4
5
6
7
void KernelFunction(uint3 groupId : SV_GroupID,
uint3 groupThreadId : SV_GroupThreadID,
uint3 dispatchThreadId : SV_DispatchThreadID,
uint groupIndex : SV_GroupIndex)
{

}

接着是变量部分,这里只有一个变量 RWTexture2D<float4> Result;

  • RWTexture2D 表示这是一个可读写(Read-Write)的二维纹理。与只读纹理不同,可读写纹理允许Compute Shader在其中存储数据,这在进行图像处理或其他需要输出结果到纹理的操作时非常有用
  • <float4> 指定了纹理存储的数据类型。float4 是一个包含四个浮点数的向量,通常用于表示颜色(RGBA)或向量数据。在纹理中,每个像素将存储一个 float4 类型的值
  • Result 这是变量的名称,Compute Shader中使用这个变量来访问纹理。

最后是函数体部分,这里只有一行

1
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
  • id.xy 这里使用id.xy获取了线程在其线程组内的二维索引位置,即线程在X和Y维度上的位置
  • Result[id.xy] 通过使用id.xy作为索引,访问Result纹理对应位置的像素
  • id.x & id.y 这是一个位运算,表达式将id.x和id.y的值进行位与操作,所得结果为当前像素中R通道的值
  • (id.x & 15)/15.0(id.y & 15)/15.0 分别对线程的x和y坐标与15(二进制为00001111)进行按位与操作,然后除以15.0。这个操作将坐标值限制在一个更小的范围内(0到15),并映射到0到1的浮点数范围内,分别用于G和B通道的值
  • 0.0 这是RGBA中的Alpha(透明度)通道,这里设置为0.0表示完全透明。

不要太在意这段代码是如何计算的,只需要知道通过X和Y的值计算出了每个想的RGB值,最终结果为一个谢尔宾斯基三角形的样子
现在我们将这个函数内的代码改成

1
Result[id.xy] = float4(1,0,0,0);

这时候运行unity,我们会得到一张红色的贴图

现在我们可能注意到一个问题,在C#代码中,我们调用了两次 DispatchShader 方法,在Start中的参数是 (texResolution / 16, texResolution / 16),而在Update中的是(texResolution / 8, texResolution / 8),第一个调用时,生成的图片只占整个图片的四分之一,第二次调用时,生成的图片是全铺满的。这里就涉及到Compute Shader中 [numthreads(8,8,1)]ComputeShader.Dispatch参数的问题了

首先我们看[numthreads(8,8,1)]的参数,这个如下表所示,其中横向为X坐标,纵向为Y坐标
当GroupID为(0,0,0)时,我们所操作的图片中像素坐标为

0,7,0 1,7,0 2,7,0 3,7,0 4,7,0 5,7,0 6,7,0 7,7,0
0,6,0 1,6,0 2,6,0 3,6,0 4,6,0 5,6,0 6,6,0 7,6,0
0,5,0 1,5,0 2,5,0 3,5,0 4,5,0 5,5,0 6,5,0 7,5,0
0,4,0 1,4,0 2,4,0 3,4,0 4,4,0 5,4,0 6,4,0 7,4,0
0,3,0 1,3,0 2,3,0 3,3,0 4,3,0 5,3,0 6,3,0 7,3,0
0,2,0 1,2,0 2,2,0 3,2,0 4,2,0 5,2,0 6,2,0 7,2,0
0,1,0 1,1,0 2,1,0 3,1,0 4,1,0 5,1,0 6,1,0 7,1,0
0,0,0 1,0,0 2,0,0 3,0,0 4,0,0 5,0,0 6,0,0 7,0,0

这样,8x8个线程就在一张图的左下角第一个像素开始,生成了一个8x8像素的红色区域

接着GroupID为(1,0,0)时,我们所操作的图片中像素坐标变成了如下表格

8,7,0 9,7,0 10,7,0 11,7,0 12,7,0 13,7,0 14,7,0 15,7,0
8,6,0 9,6,0 10,6,0 11,6,0 12,6,0 13,6,0 14,6,0 15,6,0
8,5,0 9,5,0 10,5,0 11,5,0 12,5,0 13,5,0 14,5,0 15,5,0
8,4,0 9,4,0 10,4,0 11,4,0 12,4,0 13,4,0 14,4,0 15,4,0
8,3,0 9,3,0 10,3,0 11,3,0 12,3,0 13,3,0 14,3,0 15,3,0
8,2,0 9,2,0 10,2,0 11,2,0 12,2,0 13,2,0 14,2,0 15,2,0
8,1,0 9,1,0 10,1,0 11,1,0 12,1,0 13,1,0 14,1,0 15,1,0
8,0,0 9,0,0 10,0,0 11,0,0 12,0,0 13,0,0 14,0,0 15,0,0

也就是绘制了从左下角开始横向8到15像素,纵向0到7像素的一个红色区域

当GroupID为(0,1,0)时,我们所操作的图片中像素坐标变成了如下表格

0,15,0 1,15,0 2,15,0 3,15,0 4,15,0 5,15,0 6,15,0 7,15,0
0,14,0 1,14,0 2,14,0 3,14,0 4,14,0 5,14,0 6,14,0 7,14,0
0,13,0 1,13,0 2,13,0 3,13,0 4,13,0 5,13,0 6,13,0 7,13,0
0,12,0 1,12,0 2,12,0 3,12,0 4,12,0 5,12,0 6,12,0 7,12,0
0,11,0 1,11,0 2,11,0 3,11,0 4,11,0 5,11,0 6,11,0 7,11,0
0,10,0 1,10,0 2,10,0 3,10,0 4,10,0 5,10,0 6,10,0 7,10,0
0,9,0 1,9,0 2,9,0 3,9,0 4,9,0 5,9,0 6,9,0 7,9,0
0,8,0 1,8,0 2,8,0 3,8,0 4,8,0 5,8,0 6,8,0 7,8,0

这样就绘制了从左下角开始横向0到7像素,纵向8到15像素的一个红色区域

[numthreads(8,8,1)] 中的线程块是并行的,而 ComputeShader.Dispatch 中的X,Y,Z参数,分别表示了我们在这3个维度上分别要调用多少次这样的线程块

现在返回我们刚才生成的图片

这里只有从左下角开始的四分之一,也就是 (texResolution / 16, texResolution / 16),和 [numthreads(8,8,1)],我们在代码中给出的图片尺寸为256x256,线程块单边的绘制长度为8x16,也就是生成图片的面积占总面积的四分之一了。而我们在Update中的方法参数是 (texResolution / 8, texResolution / 8),线程块单边的绘制长度为8x32,正好绘制满整个图片

在本章中,我们着重讲解了 numthreadsSV_DispatchThreadID 的相关概念,这部分概念非常重要,在后续的计算中会频繁用到
在下一章中,我们会创建多个kernel,分别使用它们在图片上画出不同的视觉效果


相关链接

谢尔宾斯基三角形 扩展阅读

numthreads

其他扩展阅读