Unity Compute Shader - 2 脚本回顾
在上一章中,我们使用Compute Shader生成了一个图片,并且让这张图片显示在了Quad上,这张图看起来很奇怪,这种图形的名字是谢尔宾斯基三角形
Compute Shader所用的语言是HLSL(High Level Shader Language),其语法和C比较相似。
我们打开之前创建的FirstComputeShader
脚本,内容如下
1 | // Each #kernel tells which function to compile; you can have many kernels |
首先看第一行 #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 | void KernelFunction(uint3 groupId : SV_GroupID, |
接着是变量部分,这里只有一个变量 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,正好绘制满整个图片
在本章中,我们着重讲解了 numthreads
和 SV_DispatchThreadID
的相关概念,这部分概念非常重要,在后续的计算中会频繁用到
在下一章中,我们会创建多个kernel,分别使用它们在图片上画出不同的视觉效果