[toc] 之前有记录过 GPU 硬件相关的知识,现在学习一下 ComputeShader.
# ComputeShader 线程组概念
首先,编写 ComputeShader 时需要我们指定核函数,即 #pragma kernel 名字
,核函数的构造一般如下:
1 2 3 4 5 6 7 8 9 #pragma kernel Circles [numthreads(8,8,1) ] void Circles (uint3 id : SV_DispatchThreadID ){ int2 center = (texResolution >> 1 ); int radius = 80 ; drawCircle(center,radius); }
如上所示,kernel 函数需要一个分配线程组的声明 [numthreads]
,其中三维便是一个线程组内的线程数量,如上述函数一个线程组内的线程数量是 (8*8*1) = 64 个,即一次计算会有 64 个线程同时进行。
而每次进行并发计算时,还会存在一个名叫线程 Id 的标识,如上述函数中的入参 id
就是所谓的线程 Id, 它也是 xyz
三维的一个下标标识符
x: 线程组的列数
y: 线程组的行数
z: 线程组的深度
每次进入核函数进行运算时线程 Id 就是通过上述三个变量确定的,以及对应的线程组。 如上图,实际的 DispatchId
和 ThreadId
的映射公式如下:线程组大小 [10,8,3]
1. SV_GroupThreadID : (7,5,0) 目标组内 Id 2. SV_GroupID : (2,1,0) 目标组 3. SV_DispatchThreadID : ([(2,1,0)*(10,8,3)] + (7,5,0)) = (27,13,0)
对应的线程 ID
4. SV_GroupIndex : 0*10*8+5*10+7=57
组内一维下的坐标
附: 3.2 可以简单理解为:目标线程 ID = (SV_GroupID * 线程组大小) + 组内 Id
# 举个栗子:使用线程组填充图片
由上面的讨论,我们可以知道,对于每个核函数而言,存在一个下标索引 ID, 同时线程组内核的数量也对应着并发的数量,而图片是二维的,如果我们需要使每个算子计算每个图片上的像素,则只需要让线程 Id 和像素坐标匹配上就可以了。比如我们有一个 256*256 的图片,我们的核函数可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #pragma kernel CSMain RWTexture2D<float4> Result; int kernelCount;int x,y;[numthreads (8 ,8 ,1 )] void CSMain (uint3 id : SV_DispatchThreadID) { float AS = kernelCount *1.0 ; float res = (id.x * id.y) /(AS*AS); Result[id.xy] = float4 (x/16.0 ,y/16.0 ,1 , 1.0 ); }
即每个线程组是 8x8x1 大小的,深度始终保持为 1, 就可以让三维降低为二维。 相应的,我们需要在 Dispatch 时根据核函数的大小来确定我们分配的组的 Id (不能大于图片的大小) 比如在图片大小为 256*256
线程并发度为 8*8*1 的条件下 每个 ThreadGroup 可以在 texture 上绘制的范围就是 8*8
GroupId 变化时:
X 每 + 1, 横向扩展 8 像素
Y 每 + 1, 纵向扩展 8 像素 即 X/Y 的 ID 上限为 256/8 验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 using System.Collections;using System.Collections.Generic;using UnityEngine;public class AssignTexture : MonoBehaviour { public ComputeShader shader; public int texResolution = 256 ; private Renderer rend; private RenderTexture outputTexture; private int kernelHandle; private int X, Y; void Start () { outputTexture = new RenderTexture( texResolution, texResolution,0 ); outputTexture.enableRandomWrite = true ; outputTexture.Create(); X = 1 ; Y = 1 ; rend = GetComponent<Renderer>(); rend.enabled = true ; InitShader(); } private void InitShader () { kernelHandle = shader.FindKernel("CSMain" ); shader.SetTexture(kernelHandle,"Result" ,outputTexture); shader.SetInt("kernelCount" ,(int )texResolution); rend.material.SetTexture("_MainTex" ,outputTexture); DispatchShader(); } private void DispatchShader () { shader.SetInt("x" ,(int )X); shader.SetInt("y" ,(int )Y); shader.Dispatch(kernelHandle,X,Y,1 ); } void Update () { if (Input.GetKeyUp(KeyCode.X)) { X++; DispatchShader(); } if (Input.GetKeyUp(KeyCode.Y)) { Y++; DispatchShader(); } if (Input.GetKeyUp(KeyCode.Z)) { X--; if (X <= 0 ) X = 1 ; DispatchShader(); } if (Input.GetKeyUp(KeyCode.T)) { Y--; if (Y <= 0 ) Y = 1 ; DispatchShader(); } } }
结果: 每个线程组实际上画的区域是这么一点 (8*8):
# 并发画圆算法 (Bresenhaman 画圆算法)
考虑一个有趣的事情,既然我们可以使用 ComputeShader 在图上画东西了,那么很自然也可以画圆,比如一个最暴力的方法就是并发整个图的像素,然后判断每个像素是否在圆的边界上,且需要着色
但是这样就失去了使用 ComputeShader 的并发优势,且无效计算很多,所以最好的情况是我们可以并发的将圆周画出来,如何实现呢? 如上图,我们可以适当将圆划分为 N 等分弧,
每次并发 N 个线程,填充对应区域的一个点,并发次数越多,对应区域填充的点越多
或者并发 M 次,每个线程填充 N 个区域
当一个弧画完时,一个圆就画完了 这里我们使用 Bresenham 画圆算法,伪代码如下 (我翻了好几个文章,发现他们最终得到的关于 d 的公式都不太一样,但是都能画出圆 XD, 下面只是其中一个我觉得写的比较好的伪代码):
上述代码看起来很简单,实现起来也很简单,根据伪代码实现的核函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 void plot1 (int x,int y,int2 center) { Result[uint2 (center.x + x,center.y + y)] = circleColor; } void plot8 (int x,int y,int2 center) { plot1 (x,y,center); plot1 (y,x,center); plot1 (x,-y,center); plot1 (y,-x,center); plot1 (-x,-y,center); plot1 (-y,-x,center); plot1 (-x,y,center); plot1 (-y,x,center); } void drawCircle (int2 center,int radius) { int x = 0 ; int y = radius; int d = 1 - radius; while (x<y) { if (d < 0 ) { d += 4 *x + 6 ; }else { d += 4 *(x-y) + 10 ; y--; } plot8 (x,y,center); x++; } } [numthreads (1 ,1 ,1 )] void Circles (uint3 id : SV_DispatchThreadID) { int2 center = (texResolution >> 1 ); int radius = 80 ; drawCircle (center,radius); }
但是这段代码隐藏了一个巨大的细节,就是 d
到底是什么,
# Bresenham 画圆算法解析
如上图:对于每一个 x
对应的 y
, 和上一个圆周上像素的关系有两种可能
1. y 轴和上一个像素一样
在上一个像素的右下角
y 与 x 的关系可以用一个公式表达 那么,我们在累加 X 的时候如何确定下一个 Y 点是在右侧还是右下角呢?
显而易见,如果像素单位为 1, (y(x+1)-y(x) < 0.5)
的时候,选择右侧,大于的时候,选择右下角 所以我们提出一个误差值 d
的概念:
1. 像素 y,X+1 距离圆周上的边的距离
2. y-1,X+1 距离圆周上的边的距离
误差值 d
= 条件 1 + 条件 2, 得到一个递归式 再借助每次下一个只有 y 或者 y-1 这连个像素可以选择的条件知:
1. di<0 时,选择右侧像素,此时 Y_(i+1) = Yi, 代入原式得:
di>0 时,选择右下角像素,此时 Y_(i+1) = Yi - 1, 代入原式得:
即
从 0 开始到 XY 时,刚好是 1/8 个圆弧,同时根据 8 方向的加加减减可以直接得到其他 7 个圆弧上的坐标 然后我发现另外一个公式也可以画出类似的结果:
# 最终效果
总结:根据下一个像素的两种可能,以及其到圆边上的距离,选择其中小的那个作为最终像素选择位置,来得出一个递归式 di
, 简化 di
来避免浮点运算