Unity中ComputeShader的基础介绍与使用 agile Posted on Oct 2 2021 优秀博文 > 本文由 [简悦 SimpRead](http://ksria.com/simpread/) 转码, 原文地址 [zhuanlan.zhihu.com](https://zhuanlan.zhihu.com/p/368307575) 前言 -- ComputeShader 是如今比较流行的一种技术,例如之前的《天刀手游》,还有最近大火的《永劫无间》,在分享技术的时候都有提到它。 本着不学习就可能失业的压力,就来学一下,虽然好像已经晚了好几年了 =。=。 Unity 官方对 ComputeShader 的介绍如下: [undefined](https://docs.unity3d.com/Manual/class-ComputeShader.html) ComputeShader 和其他 Shader 一样是运行在 GPU 上的,但是它是**独立于渲染管线之外**的。我们可以利用它实现**大量且并行**的 GPGPU 算法,用来加速我们的游戏。 在 Unity 中,我们在 Project 中右键,即可创建出一个 ComputeShader 文件:  生成的文件属于一种 Asset 文件,并且都是以 **.compute 作为文件后缀**的。 我们来看下里面的默认内容: ``` #pragma kernel CSMain RWTexture2D<float4> Result; [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) { Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0); } ``` 本文的主要目的就是让和我一样的**萌新**能够看懂这区区几行代码的含义,学好了基础才能够看更牛逼的代码嘛。如果看完还看不懂,那就是我写的不好了! 语言 -- Unity 使用的是 DirectX 11 的 HLSL 语言,会被自动编译到所对应的平台。 kernel ------ 然后我们来看看第一行: ``` #pragma kernel CSMain ``` CSMain 其实就是一个函数,在代码后面可以看到,而 kernel 是内核的意思,这一行即把一个名为 CSMain 的函数声明为内核,或者称之为核函数。这个核函数就是最终会在 GPU 中被执行。 一个 ComputeShader 中**至少要有一个 kernel 才能够被唤起**。声明方法即为: > #pragma kernel functionName 我们也可用它在一个 ComputeShader 里声明多个内核,此外我们还可以再该指令后面定义一些预处理的宏命令,如下: > #pragma kernel KernelOne SOME_DEFINE DEFINE_WITH_VALUE=1337 > #pragma kernel KernelTwo OTHER_DEFINE 我们不能把注释写在该命令后面,而应该**换行写注释**,例如下面写法会造成编译的报错: > #pragma kernel functionName // 一些注释 RWTexture2D ----------- 接着我们再来看看第二行: ``` RWTexture2D<float4> Result; ``` 看着像是声明了一个和纹理有关的变量,具体来看一下这些关键字的含义。 RWTexture2D 中,RW 其实是 **Read** 和 **Write** 的意思,Texture2D 就是二维纹理,因此它的意思就是**一个可以被 compute shader 读写的二维纹理**。 如果我们只想读不想写,那么可以使用 Texture2D 的类型。 我们知道纹理是由一个个像素组成的,每个像素都有它的下标,因此我们就可以通过像素的下标来访问它们,例如:Result[uint2(0,0)]。 同样的每个像素会有它的一个对应值,也就是我们要读取或者要写入的值。这个值的类型就被写在了 <> 当中,通常对应的是一个 rgba 的值,因此是 float4 类型。通常情况下,我们会在 ComputeShader 中处理好纹理,然后在 FragmentShader 中来对处理后的纹理进行采样。 这样我们就大致理解这行代码的意思了,声明了一个名为 Result 的可读写二维纹理,其中每个像素的值为 float4。 在 Compute Shader 中可读写的类型除了 **RWTexture** 以外还有 **RWBuffer** 和 **RWStructuredBuffer**,后面会介绍。 [RWTexture2D - Win32 apps](https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-object-rwtexture2d) numthreads ---------- 然后是下面一句(很重要!): ``` [numthreads(8,8,1)] ``` 又是 num,又是 thread 的,肯定和线程数量有关。没错,它就是定义**一个线程组(Thread Group)中可以被执行的线程(Thread)总数量**,格式如下: > numthreads(tX, tY, tZ) > 注:X,Y,Z 前加个 t 方便和后续 Group 的 X,Y,Z 进行区分 其中 tX*tY*tZ 的值即线程的总数量,例如 numthreads(4, 4, 1) 和 numthreads(16, 1, 1) 都代表着有 16 个线程。那么为什么不直接使用 numthreads(num) 这种形式定义,而非要分成 tX,tY,tZ 这种三维的形式呢?看到后面自然就懂其中的奥秘了。 **每个核函数前面我们都需要定义 numthreads**,否则编译会报错。 其中 tX,tY,tZ 三个值也并不是也可随便乱填的,比如来一刀 tX=99999 暴击一下,这是不行的。它们在不同的版本里有如下的约束: <table data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal"><tbody><tr><th>Compute Shader 版本</th><th>tZ 的最大取值</th><th>最大线程数量(tX*tY*tZ)</th></tr><tr><td>cs_4_x</td><td>1</td><td>768</td></tr><tr><td>cs_5_0</td><td>64</td><td>1024</td></tr></tbody></table> 在 Direct11 中,可以通过 **ID3D11DeviceContext::Dispatch(gX,gY,gZ)** 方法创建 gX*gY*gZ 个**线程组**,一个线程组里又会包含多个线程(数量即 numthreads 定义)。**注意顺序,先 numthreads 定义好每个核函数对应线程组里线程的数量(tX*tY*tZ),再用 Dispatch 定义用多少线程组 (gX*gY*gZ) 来处理这个核函数**。其中**每个线程组内的线程都是并行的**,不同线程组的线程可能同时执行,也可能不同时执行。一般一个 GPU 同时执行的线程数,在 1000-10000 之间。 接着我们用一张示意图来看看线程与线程组的结构,如下图:  上半部分代表的是线程组结构,下半部分代表的是单个线程组里的线程结构。因为他们都是由 (X,Y,Z) 来定义数量的,因此就像一个三维数组,下标都是从 0 开始。我们可以把它们看做是表格一样:**有 Z 个一样的表格,每个表格有 X 列和 Y 行**。例如线程组中的 (2,1,0),就是第 1 个表格的第 2 行第 3 列对应的线程组,下半部分的线程也是同理。 搞清楚结构,我们就可以很好的理解下面这些与单个线程有关的参数含义: <table data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal"><tbody><tr><th>参数</th><th>值类型</th><th>含义</th><th>计算公式</th></tr><tr><td>SV_GroupID</td><td>int3</td><td>当前线程所在的线程组的 ID,取值范围为 (0,0,0) 到(gX-1,gY-1,gZ-1)。</td><td>无</td></tr><tr><td>SV_GroupThreadID</td><td>int3</td><td>当前线程在所在线程组内的 ID,取值范围为 (0,0,0) 到(tX-1,tY-1,tZ-1)。</td><td>无</td></tr><tr><td>SV_DispatchThreadID</td><td>int3</td><td>当前线程在所有线程组中的所有线程里的 ID,取值范围为 (0,0,0) 到(gX*tX-1, gY*tY-1, gZ*tZ-1)。</td><td>假设该线程的 SV_GroupID=(a, b, c),SV_GroupThreadID=(i, j, k) 那么 SV_DispatchThreadID=(a*tX+i, b*tY+j, c*tZ+k)</td></tr><tr><td>SV_GroupIndex</td><td>int</td><td>当前线程在所在线程组内的下标,取值范围为 0 到 tX*tY*tZ-1。</td><td>假设该线程的 SV_GroupThreadID=(i, j, k) 那么 SV_GroupIndex=k*tX*tY+j*tX+i</td></tr></tbody></table> 这里需要注意的是,不管是 group 还是 thread,它们的**顺序都是先 X 再 Y 最后 Z**,用表格的理解就是先行 (X) 再列 (Y) 然后下一个表(Z),例如我们 tX=5,tY=6 那么第 1 个 thread 的 SV_GroupThreadID=(0,0,0),第 2 个的 SV_GroupThreadID=(1,0,0),第 6 个的 SV_GroupThreadID=(0,1,0),第 30 个的 SV_GroupThreadID=(4,5,0),第 31 个的 SV_GroupThreadID=(0,0,1)。group 同理,搞清顺序后,SV_GroupIndex 的计算公式就很好理解了。 再举个例子,比如 SV_GroupID 为 (0,0,0) 和(1,0,0)的两个 group,它们内部的第 1 个 thread 的 SV_GroupThreadID 都为 (0,0,0) 且 SV_GroupIndex 都为 0,但是前者的 SV_DispatchThreadID=(0,0,0)而后者的 SV_DispatchThreadID=(tX,0,0)。 好好理解下,它们在核函数里非常的重要。 [numthreads - Win32 apps](https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-attributes-numthreads) 核函数 --- ``` void CSMain (uint3 id : SV_DispatchThreadID) { Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0); } ``` 最后就是我们声明的核函数了,其中参数 SV_DispatchThreadID 的含义上面已经介绍过了,除了这个参数以外,我们前面提到的几个参数都可以被传入到核函数当中,根据实际需求做取舍即可,完整如下: ``` void KernelFunction(uint3 groupId : SV_GroupID, uint3 groupThreadId : SV_GroupThreadID, uint3 dispatchThreadId : SV_DispatchThreadID, uint groupIndex : SV_GroupIndex) { } ``` 而函数内执行的代码就是为我们 Texture 中下标为 id.xy 的像素赋值一个颜色,这里也就是最牛逼的地方。 举个例子,以往我们想要给一个 x*y 分辨率的 Texture 每个像素进行赋值,单线程的情况下,我们的代码往往如下: ``` for (int i = 0; i < x; i++) for (int j = 0; j < y; j++) Result[uint2(x, y)] = float4(a, b, c, d); ``` 两个循环,像素一个个的慢慢赋值。那么如果我们要每帧给很多张 2048*2048 的图片进行操作,可想而知会卡死你。 如果使用多线程,为了避免不同的线程对同一个像素进行操作,我们往往使用分段操作的方法,如下,四个线程进行处理: ``` void Thread1() { for (int i = 0; i < x/4; i++) for (int j = 0; j < y/4; j++) Result[uint2(x, y)] = float4(a, b, c, d); } void Thread2() { for (int i = x/4; i < x/2; i++) for (int j = y/4; j < y/2; j++) Result[uint2(x, y)] = float4(a, b, c, d); } void Thread3() { for (int i = x/2; i < x/4*3; i++) for (int j = x/2; j < y/4*3; j++) Result[uint2(x, y)] = float4(a, b, c, d); } void Thread4() { for (int i = x/4*3; i < x; i++) for (int j = y/4*3; j < y; j++) Result[uint2(x, y)] = float4(a, b, c, d); } ``` 这么写不是很蠢么,如果有更多的线程,分成更多段,不就一堆重复的代码。但是如果我们能知道每个线程的开始和结束下标,不就可以把这些代码统一起来了么,如下: ``` void Thread(int start, int end) { for (int i = start; i < end; i++) for (int j = start; j < end; j++) Result[uint2(x, y)] = float4(a, b, c, d); } ``` 那我要是可以开出很多很多的线程是不是就可以一个线程处理一个像素了? ``` void Thread(int x, int y) { Result[uint2(x, y)] = float4(a, b, c, d); } ``` 用 CPU 我们做不到这样,但是用 GPU,用 ComputeShader 我们就可以,实际上,前面默认的 ComputeShader 的代码里,核函数的内容就是这样的。 接下来我们来看看 compute shader 的妙处,**看 id.xy 的值**。id 的类型为 SV_DispatchThreadID,我们先来回忆下 SV_DispatchThreadID 的计算公式: > 假设该线程的 SV_GroupID=(a, b, c),SV_GroupThreadID=(i, j, k) 那么 SV_DispatchThreadID=(a*tX+i, b*tY+j, c*tZ+k) 首先前面我们使用了 [numthreads(8,8,1)],即 tX=8,tY=8,tZ=1 ,且 i 和 j 的取值范围为 0 到 7,而 k=0。那么我们线程组(0,0,0) 中所有线程的 SV_DispatchThreadID.xy 也就是 id.xy 的取值范围即为 (0,0) 到 (7, 7),线程组 (1,0,0) 中它的取值范围为 (8,0) 到 (15, 7),...,线程组 (0,1,0) 中它的取值范围为 (0,8) 到 (7, 15),...,线程组 (a,b,0) 中它的取值范围为 (a*8, b*8, 0) 到(a*8+7, b*8+7, 0)。 我们用示意图来看下,假设下图每个网格里包含了 64 个像素:  也就是说我们每个线程组会有 64 个线程同步处理 64 个像素,并且不同的线程组里的线程不会重复处理同一个像素,若要处理分辨率为 1024*1024 的图,我们只需要 dispatch(1024/8, 1024/8, 1) 个线程组。 这样就实现了成百上千个线程同时处理一个像素了,若用 CPU 的方式这是不可能的。是不是很妙? 而且我们可以发现 numthreads 中设置的值是很值得推敲的,例如我们有 4*4 的矩阵要处理,那么设置 numthreads(4,4,1),那么每个线程的 SV_GroupThreadID.xy 的值不正好可以和矩阵中每项的下标对应上么。 那么我们在 Unity 中怎么调用核函数,又怎么 dispatch 线程组以及使用的 RWTexture 又怎么来呢?这里就要回到我们 C# 的部分了。 C# 部分 ----- 以往的 vertex&fragment shader 我们都是给它关联到 material 上来使用的,但是 compute shader 不一样,它是**由 c# 来驱动**的。 先新建一个 monobehaviour 脚本,Unity 为我们提供了一个 **ComputeShader** 的类型用来引用我们前面生成的 .compute 文件: ``` public ComputeShader computeShader; ```  此外我们再关联一个 Material,因为 ComputeShader 处理后的纹理,依旧要经过 FragmentShader 采样后来显示。 ``` public Material material; ``` 这个 material 我们使用一个 unlit shader,并且纹理不用设置,如下:  然后关联到我们的脚本上,并且随便建个 Cube 也关联上这 material。 接着我们可以将 Unity 中的 **RenderTexture** 赋值到 ComputeShader 中的 RWTexture2D 上,但是需要注意因为我们是多线程处理像素,并且这个处理过程是**无序**的,因此我们要将 RenderTexture 的 **enableRandomWrite** 属性设置为 true,代码如下: ``` RenderTexture mRenderTexture = new RenderTexture(256, 256, 16); mRenderTexture.enableRandomWrite = true; mRenderTexture.Create(); ``` 我们创建了一个分辨率为 256*256 的 RenderTexture,首先我们要把它赋值给我们的 material,这样我们的 Cube 就会显示出它。然后要把它赋值给我们 computeshader 中的 Result 变量,代码如下: ``` material.mainTexture = mRenderTexture; computeShader.SetTexture(kernelIndex, "Result", mRenderTexture); ``` 这里有一个 kernelIndex 变量,即核函数下标,我们可以利用 FindKernel 来找到我们声明的核函数的下标: ``` int kernelIndex = computeShader.FindKernel("CSMain"); ``` 这样在我们 fragment shader 采样的时候,采样的就是 compute shader 处理过后的纹理: ``` fixed4 frag (v2f i) : SV_Target { // _MainTex 就是被处理后的 RenderTexture fixed4 col = tex2D(_MainTex, i.uv); return col; } ``` 最后就是开线程组和调用我们的核函数了,在 ComputeShader 中,Dispatch 方法为我们一步到位: ``` computeShader.Dispatch(kernelIndex, 256 / 8, 256 / 8, 1); ``` 为什么是 256/8,前面已经解释过了。来看看效果:  上图就是我们 Unity 默认生成的 ComputeShader 代码所能带来的效果,我们也可试下用它处理 2048*2048 的 Texture,也是非常快的。 接下来我们再来看看粒子效果的例子: 首先一个粒子通常拥有颜色和位置两个属性,并且我们肯定是要在 Compute Shader 里去处理这两个属性的,那么我们就可以在 Compute Shader 创建一个 struct 来存储: ``` struct ParticleData { float3 pos; float4 color; }; ``` 接着,这个粒子肯定是很多很多的,我们就需要一个像 List 一样的东西来存储它们,在 Compute Shader 中为我们提供了 RWStructuredBuffer 类型。 RWStructuredBuffer ------------------ 它是一个可读写的 buffer,并且我们可以指定 buffer 中的数据类型为我们**自定义**的 struct 类型,不用再局限于 int,float 这类的基本类型。 因此我们可以这么定义我们的粒子数据: ``` RWStructuredBuffer<ParticleData> ParticleBuffer; ``` [RWStructuredBuffer - Win32 apps](https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-object-rwstructuredbuffer) 为了有动效,我们可以再添加一个时间相关值,我们可以根据时间来修改粒子的位置和颜色: ``` float Time; ``` 接着就是怎么在核函数里修改我们的粒子信息了,要修改某个粒子,我们肯定要知道粒子在 buffer 中的下标,并且这个下标在不同的线程中不能重复,否则就可能导致多个线程修改同一个粒子了。 根据前面的介绍,我们知道一个线程组中 SV_GroupIndex 是唯一的,但是在不同线程组中并不是,例如每个线程组内有 1000 个线程,那么 SV_GroupID 都是 0 到 999。那么我们可以根据 SV_GroupID 把它叠加上去,例如 SV_GroupID=(0,0,0)时是 0-999,SV_GroupID=(1,0,0)是 1000-1999 等等,为了方便我们的线程组就可以是 (X,1,1) 格式。然后我们就可以根据 Time 和 Index 随便的摆布下粒子,Compute Shader 完整代码: ``` #pragma kernel UpdateParticle struct ParticleData { float3 pos; float4 color; }; RWStructuredBuffer<ParticleData> ParticleBuffer; float Time; [numthreads(10, 10, 10)] void UpdateParticle(uint3 gid : SV_GroupID, uint index : SV_GroupIndex) { int pindex = gid.x * 1000 + index; float x = sin(index); float y = sin(index * 1.2f); float3 forward = float3(x, y, -sqrt(1 - x * x - y * y)); ParticleBuffer[pindex].color = float4(forward.x, forward.y, cos(index) * 0.5f + 0.5, 1); if (Time > gid.x) ParticleBuffer[pindex].pos += forward * 0.005f; } ``` 接下来我们要在 C# 里给粒子初始化并且传递给 Compute Shader。我们要传递粒子数据,也就是说要给前面的 RWStructuredBuffer<ParticleData> 赋值,Unity 为我们提供了 **ComputeBuffer 类来与 RWStructuredBuffer 或 StructuredBuffer 相对应**。 ComputeBuffer ------------- 在 ComputeShader 中经常需要将我们一些自定义的 Struct 数据读写到内存缓冲区,ComputeBuffer 就是为这种情况而生的。我们可以在 c# 里创建并填充它,然后传递到 compute shader 或者其他 shader 中使用。 通常我们用下面方法来创建它: ``` ComputeBuffer buffer = new ComputeBuffer(int count, int stride) ``` 其中 count 代表我们 buffer 中元素的数量,而 stride 指的是每个元素占用的空间(字节),例如我们传递 10 个 float 的类型,那么 count=10,stride=4。需要注意的是 **ComputeBuffer 中的 stride 大小必须和 RWStructuredBuffer 中每个元素的大小一致**。 声明完成后我们可以使用 SetData 方法来填充,参数为自定义的 struct 数组: ``` buffer.SetData(T[]); ``` 最后我们可以使用 ComputeShader 类中的 SetBuffer 方法来把它传递到 Compute Shader 中: ``` public void SetBuffer(int kernelIndex, string name, ComputeBuffer buffer) ``` 记得用完后把它 Release() 掉。 [undefined](https://docs.unity3d.com/ScriptReference/ComputeBuffer.html) 在 C# 中我们定义一个一样的 Struct,这样才能保证和 Compute Shader 中的大小一致: ``` public struct ParticleData { public Vector3 pos;//等价于float3 public Color color;//等价于float4 } ``` 然后我们在 Start 方法中声明我们的 ComputeBuffer,并且找到我们的核函数: ``` void Start() { //struct中一共7个float,size=28 mParticleDataBuffer = new ComputeBuffer(mParticleCount, 28); ParticleData[] particleDatas = new ParticleData[mParticleCount]; mParticleDataBuffer.SetData(particleDatas); kernelId = computeShader.FindKernel("UpdateParticle"); } ``` 由于我们想要我们的粒子是运动的,即每帧要修改粒子的信息。因此我们在 Update 方法里去传递 Buffer 和 Dispatch: ``` void Update() { computeShader.SetBuffer(kernelId, "ParticleBuffer", mParticleDataBuffer); computeShader.SetFloat("Time", Time.time); computeShader.Dispatch(kernelId,mParticleCount/1000,1,1); } ``` 到这里我们的粒子位置和颜色的操作都已经完成了,但是这些数据并不能在 Unity 里显示出粒子,我们还需要 Vertex&FragmentShader 的帮忙,我们新建一个 UnlitShader,修改下里面的代码如下: ``` Shader "Unlit/ParticleShader" { SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct v2f { float4 col : COLOR0; float4 vertex : SV_POSITION; }; struct particleData { float3 pos; float4 color; }; StructuredBuffer<particleData> _particleDataBuffer; v2f vert (uint id : SV_VertexID) { v2f o; o.vertex = UnityObjectToClipPos(float4(_particleDataBuffer[id].pos, 0)); o.col = _particleDataBuffer[id].color; return o; } fixed4 frag (v2f i) : SV_Target { return i.col; } ENDCG } } } ``` 前面我们说了 ComputeBuffer 也可以传递到普通的 Shader 中,因此我们在 Shader 中也创建一个结构一样的 Struct,然后利用 StructuredBuffer<T> 来接收。 **SV_VertexID:**在 VertexShader 中用它来作为传递进来的参数,代表顶点的下标。我们有多少个粒子即有多少个顶点。顶点数据使用我们在 Compute Shader 中处理过的 buffer。 最后我们在 C# 中关联一个带有上面 shader 的 material,然后将粒子数据传递过去,最终绘制出来。完整代码如下: ``` public class ParticleEffect : MonoBehaviour { public ComputeShader computeShader; public Material material; ComputeBuffer mParticleDataBuffer; const int mParticleCount = 20000; int kernelId; struct ParticleData { public Vector3 pos; public Color color; } void Start() { //struct中一共7个float,size=28 mParticleDataBuffer = new ComputeBuffer(mParticleCount, 28); ParticleData[] particleDatas = new ParticleData[mParticleCount]; mParticleDataBuffer.SetData(particleDatas); kernelId = computeShader.FindKernel("UpdateParticle"); } void Update() { computeShader.SetBuffer(kernelId, "ParticleBuffer", mParticleDataBuffer); computeShader.SetFloat("Time", Time.time); computeShader.Dispatch(kernelId,mParticleCount/1000,1,1); material.SetBuffer("_particleDataBuffer", mParticleDataBuffer); } void OnRenderObject() { material.SetPass(0); Graphics.DrawProceduralNow(MeshTopology.Points, mParticleCount); } void OnDestroy() { mParticleDataBuffer.Release(); mParticleDataBuffer = null; } } ``` **material.SetBuffer**:传递 ComputeBuffer 到我们的 shader 当中。 **OnRenderObject**:该方法里我们可以自定义绘制几何。 **DrawProceduralNow**:我们可以用该方法绘制几何,第一个参数是拓扑结构,第二个参数数顶点数。 [undefined](https://docs.unity3d.com/ScriptReference/Graphics.DrawProceduralNow.html) 最终得到的效果如下:  Demo: [undefined](https://github.com/luckyWjr/ComputeShaderDemo/tree/master/Assets/Particle) ComputeBufferType ----------------- 在例子中,我们 new 一个 ComputeBuffer 的时候并没有使用到 [ComputeBufferType](https://docs.unity3d.com/ScriptReference/ComputeBufferType.html) 的参数,默认使用了 ComputeBufferType.Default。实际上我们的 ComputeBuffer 可以有多种不同的类型对应 [HLSL 中不同的 Buffer](https://docs.microsoft.com/en-us/windows/win32/direct3d11/direct3d-11-advanced-stages-cs-resources),来在不同的场景下使用,一共有如下几种类型: <table data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal"><tbody><tr><td>Default</td><td>ComputeBuffer 的默认类型,对应 HLSL shader 中的 StructuredBuffer 或 RWStructuredBuffer,常用于自定义 Struct 的 Buffer 传递。</td></tr><tr><td>Raw</td><td>Byte Address Buffer,把里面的内容(byte)做偏移,可用于寻址。它对应 HLSL shader 中的 ByteAddressBuffer 或 RWByteAddressBuffer,用于着色器访问的底层 DX11 格式为无类型的 R32。</td></tr><tr><td>Append</td><td>Append and Consume Buffer,允许我们像处理 Stack 一样处理 Buffer,例如动态添加和删除元素。它对应 HLSL shader 中的 AppendStructuredBuffer 或 ConsumeStructuredBuffer。</td></tr><tr><td>Counter</td><td>用作计数器,可以为 RWStructuredBuffer 添加一个计数器,然后在 ComputeShader 中使用 IncrementCounter 或 DecrementCounter 方法来增加或减少计数器的值。由于 Metal 和 Vulkan 平台没有原生的计数器,因此我们需要一个额外的小 buffer 用来做计数器。</td></tr><tr><td>Constant</td><td>constant buffer (uniform buffer),该 buffer 可以被当做 Shader.SetConstantBuffer 和 Material.SetConstantBuffer 中的参数。如果想要绑定一个 structured buffer 那么还需要添加 ComputeBufferType.Structured,但是在有些平台(例如 DX11)不支持一个 buffer 即是 constant 又是 structured 的。</td></tr><tr><td>Structured</td><td>如果没有使用其他的 ComputeBufferType 那么等价于 Default。</td></tr><tr><td>IndirectArguments</td><td>被用作 Graphics.DrawProceduralIndirect,ComputeShader.DispatchIndirect 或 Graphics.DrawMeshInstancedIndirect 这些方法的参数。buffer 大小至少要 12 字节,DX11 底层 UAV 为 R32_UINT,SRV 为无类型的 R32。</td></tr></tbody></table> 举个例子,在做 GPU 剔除的时候经常会使用到 Append 的 Buffer(例如后面介绍的用 ComputeShader 实现视椎剔除),C# 中的声明如下: ``` var buffer = new ComputeBuffer(count, sizeof(float), ComputeBufferType.Append); ``` 注:Default,Append,Counter,Structured 对应的 Buffer 每个元素的大小,也就是 stride 的值应该是 4 的倍数且小于 2048。 上述 ComputeBuffer 可以对应 ComputeShader 中的 AppendStructuredBuffer,然后我们可以在 ComputeShader 里使用 Append 方法为 Buffer 添加元素,例如: ``` AppendStructuredBuffer<float> result; [numthreads(640, 1, 1)] void ViewPortCulling(uint3 id : SV_DispatchThreadID) { if(满足一些自定义条件) result.Append(value); } ``` 那么我们的 buffer 中到底有多少个元素呢?计数器可以帮助我们得到这个结果。 在 C# 中,我们可以先使用 ComputeBuffer.SetCounterValue 方法来初始化计数器的值,例如: ``` buffer.SetCounterValue(0);//计数器值为0 ``` 随着 AppendStructuredBuffer.Append 方法,我们计数器的值会自动的 ++。当 ComputeShader 处理完成后,我们可以使用 ComputeBuffer.CopyCount 方法来获取计数器的值,如下: ``` public static void CopyCount(ComputeBuffer src, ComputeBuffer dst, int dstOffsetBytes); ``` Append,consume 或者 counter 的 buffer 会维护一个计数器来存储 buffer 中的元素数量,该方法可以把 src 中的计数器的值拷贝到 dst 中,dstOffsetBytes 为在 dst 中的偏移。在 DX11 平台 dst 的类型必须为 Raw 或者 IndirectArguments,而在其他平台可以是任意类型。 因此获取 buffer 中元素数量的代码如下: ``` uint[] countBufferData = new uint[1] { 0 }; var countBuffer = new ComputeBuffer(1, sizeof(uint), ComputeBufferType.IndirectArguments); ComputeBuffer.CopyCount(buffer, countBuffer, 0); countBuffer.GetData(countBufferData); //buffer中的元素数量即为:countBufferData[0] ``` 从上面两个最基础的例子中,我们可以看出,Compute Shader 中的数据都是由 C# 传递过来的,也就是说**数据要从 CPU 传递到 GPU**。并且在 Compute Shader 处理结束后**又要从 GPU 传回 CPU**。这样的话可能会有点延迟,而且它们之间的传输速率也是一个瓶颈。 但是如果我们有大量的计算需求的话,不要犹豫,请使用 ComputeShader,觉得能够对性能进行很大的提升。 UAV(Unordered Access view) -------------------------- Unordered 是无序的意思,Access 即访问,view 代表的是 “data in the required format”,应该可以理解为数据所需要的格式吧。 什么意思呢?我们的 Compute Shader 是多线程并行的,因此我们的数据必然需要能够支持被无序的访问。例如,如果纹理只能被 (0,0),(1,0),(2,0),...,Buffer 只能被 [0],[1],[2],... 这样有序访问,那么想要用多线程来修改它们明显不行,因此提出了一个概念,即 UAV,**可无序访问的数据格式**。 前面我们提到了 RWTexture,RWStructuredBuffer 这些类型都属于 UAV 的数据类型,并且它们**支持在读取的同时写入**。它们只能在 Fragment Shader 和 Compute Shader 中被使用(绑定)。 如果我们的 RenderTexture 不设置 enableRandomWrite,或者我们传递一个 Texture 给 RWTexture,那么运行时就会报错: > the texture wasn't created with the UAV usage flag set! 不能被读写的数据类型,例如 Texure2D,我们称之为 **SRV(Shader resource view)**。 [Direct3D 12 术语表 - Win32 apps](https://docs.microsoft.com/zh-cn/windows/win32/direct3d12/directx-12-glossary) Wrap / WaveFront ---------------- 前面我们说了使用 numthreads 可以定义每个线程组内线程的数量,那么我们使用 numthreads(1,1,1) 真的每个线程组只有一个线程嘛?NO! 这个问题要从硬件说起,我们 GPU 的模式是 **SIMT**(single-instruction multiple-thread,单指令多线程)。在 NVIDIA 的显卡中,一个 **SM(streaming multiprocessor)** 可调度多个 wrap,而每个 wrap 里会有 32 个线程。我们可以简单的理解为一个指令最少也会调度 32 个并行的线程。而在 AMD 的显卡中这个数量为 64,称之为 wavefront。 也就是说如果是 NVIDIA 的显卡,如果我们使用 numthreads(1,1,1),那么线程组依旧会有 32 个线程,但是多出来的 31 个线程完全就处于没有使用的状态,造成浪费。因此我们在使用 numthreads 时,最好将线程组的数量定义为 64 的倍数,这样两种显卡都可以顾及到。 [undefined](https://www.cvg.ethz.ch/teaching/2011spring/gpgpu/GPU-Optimization.pdf) 移动端支持问题 ------- 我们可以运行时调用 **SystemInfo.supportsComputeShaders** 来判断当前的机型是否支持 ComputeShader。其中 OpenGL ES 从 3.1 版本才开始支持 ComputeShader,而使用 Vulkan 的 Android 平台以及使用 Metal 的 IOS 平台都支持 ComputeShader。 然而有些 Android 手机即使支持 ComputeShader,但是对 RWStructuredBuffer 的支持并不友好。例如在某些 OpenGL ES 3.1 的手机上,只支持 Fragment Shader 内访问 StructuredBuffer。 在普通的 shader 中要支持 ComputeShader,shader model 最低要求为 4.5,即: ``` #pragma target 4.5 ``` 利用 ComputeShader 实现视椎剔除 ----------------------- [王江荣:Unity 中使用 ComputeShader 做视锥剔除(View Frustum Culling)](https://zhuanlan.zhihu.com/p/376801370) 利用 Compute Shader 实现 Hi-z 遮挡剔除 ------------------------------ [王江荣:【Unity】使用 Compute Shader 实现 Hi-z 遮挡剔除(Occlusion Culling)](https://zhuanlan.zhihu.com/p/396979267) Shader.PropertyToID ------------------- 在 Compute Shader 中定义的变量依旧可以通过 Shader.PropertyToID("name") 的方式来获得唯一 id。这样当我们要频繁利用 ComputeShader.SetBuffer 对一些相同变量进行赋值的时候,就可以把这些 id 事先缓存起来,避免造成 GC。 ``` int grassMatrixBufferId; void Start() { grassMatrixBufferId = Shader.PropertyToID("grassMatrixBuffer"); } void Update() { compute.SetBuffer(kernel, grassMatrixBufferId, grassMatrixBuffer); // dont use it //compute.SetBuffer(kernel, "grassMatrixBuffer", grassMatrixBuffer); } ``` 全局变量或常量? -------- 假如我们要实现一个需求,在 ComputeShader 中判断某个顶点是否在一个固定大小的包围盒内,那么按照以往 C# 的写法,我们可能如下定义包围盒大小: ``` #pragma kernel CSMain float3 boxSize1 = float3(1.0f, 1.0f, 1.0f); // 方法1 const float3 boxSize2 = float3(2.0f, 2.0f, 2.0f); // 方法2 static float3 boxSize3 = float3(3.0f, 3.0f, 3.0f); // 方法3 [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) { // 做判断 } ``` 经过测试,其中方法 1 和方法 2 的定义,在 CSMain 里读取到的值都为 float3(0.0f,0.0f,0.0f) ,只有方法 3 才是最开始定义的值。 Shader variants and keywords ---------------------------- ComputeShader 同样支持 [shader 变体](https://docs.unity3d.com/2020.3/Documentation/Manual/SL-MultipleProgramVariants.html),用法和普通的 shader 变体基本相似,示例如下: ``` #pragma kernel CSMain #pragma multi_compile __ COLOR_WHITE COLOR_BLACK RWTexture2D<float4> Result; [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) { #if defined(COLOR_WHITE) Result[id.xy] = float4(1.0, 1.0, 1.0, 1.0); #elif defined(COLOR_BLACK) Result[id.xy] = float4(0.0, 0.0, 0.0, 1.0); #else Result[id.xy] = float4(id.x & id.y, (id.x & 15) / 15.0, (id.y & 15) / 15.0, 0.0); #endif } ``` 然后我们就可以在 C# 端启用或禁用某个变体了: * #pragma multi_compile 声明的全局变体可以使用 Shader.EnableKeyword/Shader.DisableKeyword 或者 ComputeShader.EnableKeyword/ComputeShader.DisableKeyword * #pragma multi_compile_local 声明的局部变体可以使用 ComputeShader.EnableKeyword/ComputeShader.DisableKeyword 示例如下: ``` public class DrawParticle : MonoBehaviour { public ComputeShader computeShader; void Start() { ...... computeShader.EnableKeyword("COLOR_WHITE"); } } ``` 还在学习和完善 ing~ 【Unity】使用Compute Shader实现Hi-z遮挡剔除(Occlusion Culling) 纹理映射(Texture mapping)