从计算着色器到四边形【翻译】
Table of Contents
// input texture
Texture2D<float3> _sourceTexture;
// a compute buffer that we can append to for output
AppendStructuredBuffer<int2> _brightPoints;
在上面的代码段中,_sourceTexture
是从外部传给GPU的纹理。和在fragment shader中访问纹理不一样的是,在片元着色器中通过采样器(sampler)获取纹理,纹理坐标是浮点数,而在compute shader中,“纹理”可以视为一个二维数组,可以通过整数索引值,直接访问这“二维数组”中的元素——纹素。
AppendStructedBuffer
AppendStructuredBuffer
可视为是原始缓冲区的“升级版”,即结构化缓冲区。原始缓冲区(raw buffer)只是一个字节数组,而结构化缓冲区意味着可以为缓冲区中的每个元素定义一个结构。可以用来AppendStructuredBuffer.Append()
方法将元素从缓冲区的末尾加入。重AppendStructuredBuffer.Append()
是一个线程安全的操作。即使有数千个线程同时尝试向缓冲区添加某些内容,也不会产生冲突。
// tell Unity that the function "FindBrights" is a compute kernel
#pragma kernel FindBrights
// define thread groups for the FindBrights kernel
[numthreads(32, 32, 1)]
pragma kernel 指令
#pragma kernel FindBrights
是Unity专用的代码。用来通知Unity函数FindBrights
是计算内核(compute kernel),这意味着它可以用作计算着色器,而不仅仅是一个普通函数。
numthreads指令
numthreads
指令的三个输入参数分别表示该着色器在工作组(Work Group)内的线程布局。具体来说,numthreads(x, y, z)
指令中xyz三个参数分别定义了在X、Y和Z三个维度上每个工作组中线程的数量。这三个参数决定了工作组内线程的总数量,例如numthreads(8, 8, 1)
就定义了一个工作组包含 8 x 8 x 1 = 64 个线程。工作组的总线程数直接影响计算的并行性,在实际调用时,通过Dispatch()
函数定义工作组的分布。如下代码:
ComputeShader.Dispatch(kernelIndex, numGroupsX, numGroupsY, numGroupsZ);
在上面的代码中,numGroupsX
、numGroupsY
和 numGroupsZ
定义了工作组的数量,配合 numthreads
定义每个工作组内的线程数量,共同决定了整个计算着色器执行的并行规模。
在Direct3D11中,1024是D3D11每一个工作组的最大线程总数(无论是 32x32、512x2 还是 32x16x2)
之所以工作组要在三个轴上分配线程,是为了适应不同类型的数据结构和计算任务。在Unity3D的Compute Shader中,数据通常是多维的,类似于图像、体积数据或3D模型等。不同的数据形状和维度要求计算任务能灵活地处理这些不同维度的数据。以下是具体原因:
-
适应多维数据结构:
- 一维数据(x 轴):对于一维数组或线性数据(比如粒子系统),只需要在x轴上分配线程。因此可用
numthreads(x, 1, 1)
来分配线程,表示在一维线性维度上执行操作。 - 二维数据(x 和 y 轴):二维数据常见于图像或纹理。对于每个像素的处理,通常需要在X和Y两个维度上分配线程。例如,一个图像的宽度和高度对应于 x 和 y 轴,
numthreads(x, y, 1)
可以让每个线程处理一个像素点。 - 三维数据(x, y, z 轴):体积数据(如3D纹理、体积渲染等)需要在三个维度上操作。例如,体积数据的宽度、高度和深度分别对应 x、y 和 z 轴,
numthreads(x, y, z)
就适合处理每个三维体素(volumetric pixel)。
- 一维数据(x 轴):对于一维数组或线性数据(比如粒子系统),只需要在x轴上分配线程。因此可用
-
提高并行处理效率 Compute Shader 是为并行处理设计的。通过分成X、Y和Z三个轴,可以让不同线程同时处理多个维度的数据,提高并行效率。例如,二维图像的每个像素可以被不同线程并行处理,三维数据的每个体素也可以被并行处理。
-
灵活应对不同的计算问题。分三个轴让你有更多的控制权。例如:
- X轴:可以处理数据的一维维度,如数组、行处理。
- Y轴:可以处理数据的二维维度,如矩阵、图像的每一列。
- Z轴:当你有3D数据或多个层次时,Z轴可以用来遍历每一个深度或层次。
例子1 二维图像处理
如果处理一个1024x1024的图像,可以这样定义一个工作组:
[numthreads(8, 8, 1)]
这意味着每个工作组将有8x8=64个线程,每个线程处理图像中的一个像素。然后,用以下的Dispatch
函数调度它:
// 128*8=1024
computeShader.Dispatch(kernelIndex, 128, 128, 1);
这会在X和Y维度上创建128x128=16384个工作组,总线程数为16384x64共1048576。整个图像中每个像素都会由一个线程去计算。
例子2 三维数据处理
如果处理一个128x128x128的3D体积数据,可能会这样定义一个工作组:
[numthreads(4, 4, 4)]
这定义了一个4x4x4=64个线程的工作组。然后可以这样调度:
computeShader.Dispatch(kernelIndex, 32, 32, 32);
这会在X、Y和Z维度上创建32x32x32个工作组,最终覆盖整个128x128x128的数据体积。
总结
三个轴允许你处理不同维度的数据——一维、二维或三维——并通过工作组和线程来分配工作负载,从而在GPU上实现高效的并行计算。这样做能够充分利用GPU的强大计算能力,适应不同的任务需求。
三个重要的计算着色器系统语义值
在计算着色器中,SV_DispatchThreadID
、SV_GroupThreadID
和 SV_GroupID
这三个系统语义值(system-value semantics)是用来为每个线程标记其在整个计算任务中的位置的。每个值都提供了线程在不同范围中的唯一标识,以帮助处理数据计算。以下表格是这些语义值的具体作用:
语义值 | 含义 | 作用范围 | 用途 |
---|---|---|---|
SV_DispatchThreadID |
表示当前线程在整个Dispatch 函数调用中的全局ID |
该ID在整个调度网格中唯一标识每个线程,使用三维坐标 (X, Y, Z) 来表示位置 | 通常用于访问全局数据结构,比如纹理或缓冲区等。在处理一个图像时,可以用 SV_DispatchThreadID 直接定位某个像素的位置 |
SV_GroupThreadID |
表示当前线程在所属的工作组内的局部ID | 该ID唯一标识每个工作组中的线程,范围通常从 (0, 0, 0) 到在numthreads 中定义的最大值。例如 (x-1, y-1, z-1)。 |
在局部工作组范围内的计算和同步操作,例如在同一个工作组内进行数据共享、同步或者对某些局部资源进行操作。 |
SV_GroupID |
表示当前工作组在整个调度网格中的ID | 该ID唯一标识调度网格中的每个工作组。每个工作组由多个线程组成,并且 SV_GroupID 标识的是这个工作组在调度网格中的位置(X, Y, Z)坐标 |
用于在全局范围上确定工作组的唯一位置,例如在调度多个工作组时, SV_GroupID 可以帮助定位某个工作组的全局位置。 |
举例说明的代码段
// 如果在C#层,Dispatch(kernelIndex, 10, 10, 1) 被调用,并且在计算着色器中定义了numthreads(8, 8, 1)
// 那么 SV_DispatchThreadID 将会表示**从0到79的X和Y维度上**所有线程的全局ID。
uint3 globalThreadID = SV_DispatchThreadID;
// 如果numthreads(8, 8, 1),那么 SV_GroupThreadID 的值范围将在 (0, 0, 0) 到 (7, 7, 0),表示工作组内线程的位置。
// SV_GroupThreadID就是当前正在执行本compute shader的那个线程的在其所属工作组内的局部ID
uint3 localThreadID = SV_GroupThreadID;
// 如果 Dispatch(kernelIndex, 10, 10, 1) 被调用,这意味着你有10x10个工作组。
// SV_GroupID 会从 (0, 0, 0) 到 (9, 9, 0),标识每个工作组的全局位置。
uint3 groupID = SV_GroupID;
简单总结
当调用 Dispatch(kernelIndex, numGroupsX, numGroupsY, numGroupsZ)
时:
SV_GroupID
用来确定哪个工作组在调度网格中的位置。表示工作组在整个调度网格中的位置。SV_GroupThreadID
用来确定工作组内部每个线程的局部位置。表示线程在当前工作组中的局部ID。SV_DispatchThreadID
是结合了SV_GroupID
和SV_GroupThreadID
之后的全局线程ID,用来唯一标识调度网格中所有线程的位置。表示线程在整个Dispatch
调用中的全局ID。
这些ID让你可以从全局到局部、再到线程级别控制数据的分配和计算流程。
// these three system-value semantics let us know which thread we're in.
void FindBrights (uint3 globalId : SV_DispatchThreadID, uint3 localId : SV_GroupThreadID, uint3 groupId : SV_GroupID)
{
// 如上文所述,SV_DispatchThreadID是全局级别的线程ID,因为这里定义了纹素数量和线程的数量相等,
// 所以线程的xy方向顶点索引ID可以直接用作纹素二维数组的索引值
float3 colour = _sourceTexture[globalId.xy];
if (CalculateLuminance(colour) > 1.43)
{
// add our position to _brightPoints if colour is bright enough
// 亮度值大于1.43的纹素点记录到struct buffer中
_brightPoints.Append(int2(globalId.xy));
}
}
上面的CalculateLuminance()
是一个简单的函数,用于从线性 sRGB 颜色中获取亮度值。这里唯一值得注意的是,在 HLSL(以及许多其他通常较旧的语言)中,函数必须在源代码中定义在调用它们的位置之前。
struct BrightPoint // 8 * 4 = 32 bytes
{
int2 middle; // midpoint in texels
float magnitude; // (luminance - luminanceThreshold) / luminanceThreshold
float3 colour; // colour of pixel that spawned it
float2 padding; // pad out to a multiple of float4
};
float CalculateLuminance(float3 colourLinear)
{
// magic numbers copied from wikipedia, the source of all the best magic numbers
// https://en.wikipedia.org/wiki/Relative_luminance
return colourLinear.x * 0.2126 + colourLinear.y * 0.7152 + colourLinear.z * 0.0722;
}