在写shader的时候,我们通常会创建一个unlit shader或者standard surface shader,但是,至少在我的工作中,从来没有创建过compute shader,这个东西是干嘛用的呢?带着这个疑问,我们今天来一探究竟。
谈起compute shader,我们要先了解一个概念,叫GPGPU(General-purpose computing on graphics processing units)。根据Wikipedia的介绍
它是利用处理图形任务的图形处理器来计算原本由中央处理器处理的通用计算任务。这些通用计算任务通常与图形处理没有任何关系。
那么,专门为图形任务所设计的图形处理器是如何怎么能处理通用计算任务的呢?这不是在抢CPU的饭碗么?的确,早期的显卡是没有这种功能的。在很久很久以前,那时老黄还没成立NVIDIA,显卡市场还是Voodoo称霸的时候,显卡里有两种单元,一种专门处理顶点,叫做vertex unit,另一种专门处理像素,称作pixel unit。然而,随着渲染场景越来越复杂(想想游戏史,是不是游戏看起来越来越逼真了?),这种模式不利于负载均衡,而且这时候人们也希望显卡能做一些通用计算的需求,图形渲染不再是唯一的需求,所以GPGPU在这时开始浮出水面。到了2006年底及2007年初,老黄拿出了他的GeForce 8800 GTX,AMD也拿出了Radeon HD 2800,unified shaders的时代来临了。什么叫unified shaders?之前专门处理顶点的vertex unit我们需要为它写vertex shader,专门处理像素的pixel unit我们需要为它写pixel shader,到了unified shaders时代,不管vertex shader也好,pixel shader也好,显卡都会用一种unit来处理,这时,GPGPU变成了可能。
现在回到compute shader,它是一种运行在显卡上却不在普通渲染管线上的程序,利用它可以做大型并行的GPGPU算法,以此来获得比CPU快很多倍的计算能力。不过在获得这种能力之前,我们先来看看如何使用这种shader。
首先是C#脚本,用于掌控全局
public Texture inputTex;
public ComputeShader computeShader;
public RawImage image;
void Start(){
RenderTexture t = new RenderTexture(inputTex.width,inputTex.height,24);
t.enableRandomWrite = true;
t.Create();
image.texture = t;
image.SetNativeSize();
int kernel = computeShader.FindKernel("Gray");
computeShader.SetTexture(kernel,"inputTexture",inputTex);
computeShader.SetTexture(kernel,"outputTexture",t);
computeShader.Dispatch(kernel,inputTex.width / 8, inputTex.height / 8,1);
}
其次是compute shader,所有的计算都放在这里
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel Gray
Texture2D inputTexture;
// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
RWTexture2D outputTexture;
[numthreads(8,8,1)]
void Gray (uint3 id : SV_DispatchThreadID)
{
float r = inputTexture[id.xy].r;
float g = inputTexture[id.xy].g;
float b = inputTexture[id.xy].b;
float res = r * 0.299 + g * 0.587 + b * 0.114;
outputTexture[id.xy] = float4(res,res,res,1);
}
这里我实现的功能是将一张彩色图转成灰阶图,虽然体现不出GPU的强大并行计算能力,但能起到如何使用compute shader的作用O(∩_∩)O~
首先我们为要输出的灰阶图准备一个地方,名叫t
,t的属性enableRandomWrite
必须为true,这样才能将数据写入。然后通过FindKernel
方法找kernel,里面传入的string就是compute shader中#pragma kernel Gray定义的名字Gray,当然你想起什么名字就起什么名字,可以随便改的,不过相应的void Gray (uint3 id : SV_DispatchThreadID)
这个地方的名字要一致。然后用SetTexture
方法将数据设置好,由于inputTexture
是读取数据用的,所以对应在compute shader里面的变量类型是只读型的Texture2D,而outputTexture
的输出数据用的,所以用读写型的RWTexture2D。有朋友可能要问了,RWTexture2D这个怎么没见过啊。原来,unity中的compute shader是遵循DirectX 11语法的,所以这个RWTexture2D是HLSL里的类型,详见微软文档https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-object-rwtexture2d。
最后到了调用Dispatch
方法的时候,也意味着整段程序都设置完毕,要开始工作了。从文档里可以看出需要传四个参数进去,分别是kernelIndex、threadGroupsX、threadGroupsY、threadGroupsZ,这些是什么意思呢?
kernelIndex很好解释,就是刚刚FindKernel
方法返回的值。其余的参数,从浅层次上讲,就是要让threadGroupsX * numthreads.x = 图片宽,threadGroupsY * numthreads.y = 图片高,threadGroupsZ大部分时间下都是1。这里的numthreads就是compute shader里的[numthreads(8,8,1)]
。
另外有个限制条件是在shader model 5的平台下numthreads.x *numthreads.y * numthreads.z <= 1024,numthreads.z <= 64,(在shader model 4.5的平台下这个数字是768,numthreads.z <=1,再往下的shader model则不支持compute shader了)。
还有要注意的是由于架构问题,一个线程组里有几个线程需要结合硬件,NVIDIA的架构下最好是32的倍数个线程,AMD架构下最好为64的倍数个线程。
更进一步,我们来看微软文档里的一张图。
threadGroupsX、threadGroupsY、threadGroupsZ代表着你要开多少组线程,每个线程组里面有多少个线程是由numthreads里的参数决定的。拿我写的这段代码举例,Dispatch
的时候开了128 * 128 * 1组的线程组,每组线程组里面有8 * 8 * 1个线程,128 * 8 = 1024,这里我用的图片的长和宽都是1024,即每个线程都在处理图片上的某一个像素。void Gray (uint3 id : SV_DispatchThreadID)
这边的id即为每个线程的index。那如果numthreads设为[numthreads(64,4,1)]
,那么Dispatch
的时候可以设为Dispatch(kernel,inputTex.width / 64, inputTex.height / 4,1);
。
文档图片2中还提到了SV_GroupThreadID
,SV_GroupID
,SV_GroupIndex
,这些也是用来索引线程的,具体关系看彥霖大佬的图就明白了。
Group ID 一看就懂 :
Group Thread ID 一看就懂 :
Group Index 一看就懂 :
接下来我们来看如何用compute shader进行简单计算任务,而不是处理贴图。代码如下
CS脚本
public ComputeShader csBuffer;
ComputeBuffer buffer;
struct MyInt{
public int val;
public int index;
};
void Start()
{
CSFib();
}
public void CSFib(){
MyInt[] total = new MyInt[32];
buffer = new ComputeBuffer(32,8);
int kernel = csBuffer.FindKernel("Fibonacci");
csBuffer.SetBuffer(kernel,"buffer",buffer);
csBuffer.Dispatch(kernel,1,1,1);
buffer.GetData(total);
for (int i = 0; i < total.Length; i++)
{
Debug.Log(total[i].val);
}
}
private void OnDestroy() {
buffer.Release();
}
compute shader
#pragma kernel Fibonacci
struct MyInt{
int val;
int index;
};
RWStructuredBuffer buffer;
int Fib(int n){
int a = 0;
int b = 1;
int res = 0;
for(int i=0;i
菲波那切数列我想大家都应该知道吧?那么用GPU来算菲波那切数列就是以上代码在实现的内容了。为了让大家看看在compute shader中如何使用自定义结构体,我舍弃了int类型而使用了自定义结构体MyInt,其中val存菲波那切数列中每一项的值,index存的是值所对应的索引。
这里多出来了一个ComputeBuffer类型的buffer,用于存储计算得到的值,可以看到后面要用
GetData
方法把数值从GPU里面拿出来。在new这个ComputeBuffer的时候我们需要传入两个参数,从文档上来看第一个是count,我需要输出32个菲波那切数列,就填32;第二个是stride,代表每个元素的长度,由于自定义结构体MyInt有两个int类型的属性,所以这里的stride为8。其余内容通过上面的讲解,我想应该不难理解了。
当然,compute shader能做的远远不止这些,来看看大佬们把compute shader玩出了什么花样。
还有很多很多应用在这就不一一列举了,以及,以上的我都无力实现,这么菜让大家见笑了(⊙o⊙)…
项目地址
参考
Introduction to compute shaders
【风宇冲】Shader:二十八ComputeShaders
Unity 使用 GPGPU 計算,使用 ComputeShader 將圖片轉成灰階圖
numthreads
Unity 3D : ComputeShader 全面詳解
Compute Shader次世代优化方案