Unity中Compute Shader的基礎介紹與使用


前言

Compute Shader是如今比較流行的一種技術,例如之前的《天刀手游》,還有最近大火的《永劫無間》,在分享技術的時候都有提到它。

Unity官方對Compute Shader的介紹如下:https://docs.unity3d.com/Manual/class-ComputeShader.html

 

Compute Shader和其他Shader一樣是運行在GPU上的,但是它是獨立於渲染管線之外的。我們可以利用它實現大量且並行的GPGPU算法,用來加速我們的游戲。

在Unity中,我們在Project中右鍵,即可創建出一個Compute Shader文件:

 

生成的文件屬於一種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中被執行。

一個Compute Shader中至少要有一個kernel才能夠被喚起。聲明方法即為:

#pragma kernel functionName

 

我們也可用它在一個Compute Shader里聲明多個內核,此外我們還可以再該指令后面定義一些預處理的宏命令,如下:

#pragma kernel KernelOne SOME_DEFINE DEFINE_WITH_VALUE=1337
#pragma kernel KernelTwo OTHER_DEFINE

 

我們不能把注釋寫在該命令后面,而應該換行寫注釋,例如下面寫法會造成編譯的報錯:

#pragma kernel functionName // 一些注釋

 

RWTexture2D

 

接着我們再來看看第二行:

RWTexture2D<float4> Result;

看着像是聲明了一個和紋理有關的變量,具體來看一下這些關鍵字的含義。

RWTexture2D中,RW其實是ReadWrite的意思,Texture2D就是二維紋理,因此它的意思就是一個可以被Compute Shader讀寫的二維紋理

如果我們只想讀不想寫,那么可以使用Texture2D的類型。

我們知道紋理是由一個個像素組成的,每個像素都有它的下標,因此我們就可以通過像素的下標來訪問它們,例如:Result[uint2(0,0)]。

同樣的每個像素會有它的一個對應值,也就是我們要讀取或者要寫入的值。這個值的類型就被寫在了<>當中,通常對應的是一個RGBA的值,因此是float4類型。通常情況下,我們會在Compute Shader中處理好紋理,然后在FragmentShader中來對處理后的紋理進行采樣。

這樣我們就大致理解這行代碼的意思了,聲明了一個名為Result的可讀寫二維紋理,其中每個像素的值為float4。

在Compute Shader中可讀寫的類型除了RWTexture以外還有RWBufferRWStructuredBuffer,后面會介紹。

RWTexture2D - Win32 apps

 

numthreads

 

然后是下面一句(很重要!):

[numthreads(8,8,1)]

又是num,又是thread的,肯定和線程數量有關。沒錯,它就是定義一個線程組(Thread Group)中可以被執行的線程(Thread)總數量,格式如下:

numthreads(tX, tY, tZ)
注:X,Y,Z前加個t方便和后續Group的X,Y,Z進行區分

其中tXtYtZ的值即線程的總數量,例如numthreads(4, 4, 1)和numthreads(16, 1, 1)都代表着有16個線程。那么為什么不直接使用numthreads(num)這種形式定義,而非要分成tX、tY、tZ這種三維的形式呢?看到后面自然就懂其中的奧秘了。

每個核函數前面我們都需要定義numthreads,否則編譯會報錯。

其中tX,tY,tZ三個值也並不是可以隨便亂填的,比如來一刀tX=99999暴擊一下,這是不行的。它們在不同的版本里有如下的約束:

 

在Direct11中,可以通過ID3D11DeviceContext::Dispatch(gX,gY,gZ)方法創建gXgYgZ個線程組,一個線程組里又會包含多個線程(數量即numthreads定義)。

注意順序,先numthreads定義好每個核函數對應線程組里線程的數量(tXtYtZ),再用Dispatch定義用多少線程組(gXgYgZ)來處理這個核函數。其中每個線程組內的線程都是並行的,不同線程組的線程可能同時執行,也可能不同時執行。一般一個GPU同時執行的線程數,在1000-10000之間。

接着我們用一張示意圖來看看線程與線程組的結構,如下圖:

 

上半部分代表的是線程組結構,下半部分代表的是單個線程組里的線程結構。因為他們都是由(X,Y,Z)來定義數量的,因此就像一個三維數組,下標都是從0開始。我們可以把它們看做是表格一樣:有Z個一樣的表格,每個表格有X列和Y行。例如線程組中的(2,1,0),就是第1個表格的第2行第3列對應的線程組,下半部分的線程也是同理。

搞清楚結構,我們就可以很好的理解下面這些與單個線程有關的參數含義:

 

這里需要注意的是,不管是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

 

核函數

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,用Compute Shader我們就可以。實際上,前面默認的Compute Shader的代碼里,核函數的內容就是這樣的。

接下來我們來看看Compute Shader的妙處,看id.xy的值。id的類型為SV_DispatchThreadID,我們先來回憶下SV_DispatchThreadID的計算公式:
假設該線程的SV_GroupID=(a, b, c),SV_GroupThreadID=(i, j, k) 那么SV_DispatchThreadID=(atX+i, btY+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)中它的取值范圍為(a8, b8, 0)到(a8+7,b8+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為我們提供了一個Compute Shader的類型用來引用我們前面生成的 .compute 文件:

public ComputeShader computeShader;

  

 


在Inspector界面關聯.compute文件

 

此外我們再關聯一個Material,因為Compute Shader處理后的紋理,依舊要經過Fragment Shader采樣后來顯示。
public Material material;

這個Material我們使用一個Unlit Shader,並且紋理不用設置,如下:

 

然后關聯到我們的腳本上,並且隨便建個Cube也關聯上這Material。

接着我們可以將Unity中的RenderTexture賦值到Compute Shader中的RWTexture2D上,但是需要注意因為我們是多線程處理像素,並且這個處理過程是無序的,因此我們要將RenderTexture的enableRandomWrite屬性設置為true,代碼如下:

RenderTexture mRenderTexture = new RenderTexture(256, 256, 16);
mRenderTexture.enableRandomWrite = true;
mRenderTexture.Create();

我們創建了一個分辨率為256*256的RenderTexture,首先我們要把它賦值給我們的Material,這樣我們的Cube就會顯示出它。然后要把它賦值給我們Compute Shader中的Result變量,代碼如下:

material.mainTexture = mRenderTexture;
computeShader.SetTexture(kernelIndex, "Result", mRenderTexture);

這里有一個kernelIndex變量,即核函數下標,我們可以利用FindKernel來找到我們聲明的核函數的下標:

int kernelIndex = computeShader.FindKernel("CSMain");

這樣在我們FragmentShader采樣的時候,采樣的就是Compute Shader處理過后的紋理:

fixed4 frag (v2f i) : SV_Target
{
    // _MainTex 就是被處理后的 RenderTexture
    fixed4 col = tex2D(_MainTex, i.uv);
    return col;
}

最后就是開線程組和調用我們的核函數了,在Compute Shader中,Dispatch方法為我們一步到位:

computeShader.Dispatch(kernelIndex, 256 / 8, 256 / 8, 1);

為什么是256/8,前面已經解釋過了。來看看效果:

 

上圖就是我們Unity默認生成的Compute Shader代碼所能帶來的效果,我們也可試下用它處理2048*2048的Texture,也是非常快的。


接下來我們再來看看粒子效果的例子:

首先一個粒子通常擁有顏色和位置兩個屬性,並且我們肯定是要在Compute Shader里去處理這兩個屬性的,那么我們就可以在Compute Shader創建一個struct來存儲:

struct ParticleData {
float3 pos;
float4 color;
};

接着,這個粒子肯定是很多很多的,我們就需要一個像List一樣的東西來存儲它們,在ComputeShader中為我們提供了RWStructuredBuffer類型。

 

RWStructuredBuffer

 

它是一個可讀寫的Buffer,並且我們可以指定Buffer中的數據類型為我們自定義的struct類型,不用再局限於int、float這類的基本類型。

因此我們可以這么定義我們的粒子數據:

RWStructuredBuffer<ParticleData> ParticleBuffer;

RWStructuredBuffer - Win32 apps

為了有動效,我們可以再添加一個時間相關值,我們可以根據時間來修改粒子的位置和顏色:

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賦值,Unity為我們提供了ComputeBuffer類來與RWStructuredBuffer或StructuredBuffer相對應。

 

ComputeBuffer

 

在Compute Shader中經常需要將我們一些自定義的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[]);

最后我們可以使用Compute Shader類中的SetBuffer方法來把它傳遞到Compute Shader中:

public void SetBuffer(int kernelIndex, string name, ComputeBuffer buffer)

記得用完后把它Release()掉。

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來接收。

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:我們可以用該方法繪制幾何,第一個參數是拓撲結構,第二個參數數頂點數。

https://docs.unity3d.com/ScriptReference/Graphics.DrawProceduralNow.html

最終得到的效果如下:

 

Demo鏈接如下:
https://github.com/luckyWjr/ComputeShaderDemo/tree/master/Assets/Particle

 

ComputeBufferType

 

在例子中,我們new一個ComputeBuffer的時候並沒有使用到ComputeBufferType的參數,默認使用了ComputeBufferType.Default。實際上我們的ComputeBuffer可以有多種不同的類型對應HLSL中不同的Buffer,來在不同的場景下使用,一共有如下幾種類型:

 

舉個例子,在做GPU剔除的時候經常會使用到Append的Buffer(例如后面介紹的用Compute Shader實現視椎剔除),C#中的聲明如下:

var buffer = new ComputeBuffer(count, sizeof(float), ComputeBufferType.Append);

注:Default,Append,Counter,Structured對應的Buffer每個元素的大小,也就是stride的值應該是4的倍數且小於2048。

上述ComputeBuffer可以對應Compute Shader中的AppendStructuredBuffer,然后我們可以在Compute Shader里使用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方法,我們計數器的值會自動的++。當Compute Shader處理完成后,我們可以使用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。這樣可能會有點延遲,而且它們之間的傳輸速率也是一個瓶頸。

但是如果我們有大量的計算需求,不要猶豫,請使用Compute Shader,對性能能有很大的提升。

 

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的數據類型,並且它們支持在讀取的同時寫入。它們只能在FragmentShader和ComputeShader中被使用(綁定)。

如果我們的RenderTexture不設置enableRandomWrite,或者我們傳遞一個Texture給RWTexture,那么運行時就會報錯:
the texture wasn't created with the UAV usage flag set!

不能被讀寫的數據類型,例如Texure2D,我們稱之為SRV(Shader Resource View)

Direct3D 12 術語表 - Win32 apps

 

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的倍數,這樣兩種顯卡都可以顧及到。

https://www.cvg.ethz.ch/teaching/2011spring/gpgpu/GPU-Optimization.pdf

 

移動端支持問題

 

我們可以運行時調用SystemInfo.supportsComputeShaders來判斷當前的機型是否支持Compute Shader。其中OpenGL ES從3.1版本才開始支持Compute Shader,而使用Vulkan的Android平台以及使用Metal的IOS平台都支持Compute Shader。

然而有些Android手機即使支持Compute Shader,但是對RWStructuredBuffer的支持並不友好。例如在某些OpenGL ES 3.1的手機上,只支持Fragment Shader內訪問StructuredBuffer。

在普通的Shader中要支持Compute Shader,Shader Model最低要求為4.5,即:

#pragma target 4.5

  

利用Compute Shader實現視椎剔除
《Unity中使用Compute Shader做視錐剔除(View Frustum Culling)》

利用Compute Shader實現Hi-z遮擋剔除
《Unity中使用Compute Shader實現Hi-z遮擋剔除(Occlusion Culling)》

 

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);
}

  

全局變量或常量?

 

假如我們要實現一個需求,在Compute Shader中判斷某個頂點是否在一個固定大小的包圍盒內,那么按照以往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變體,用法和普通的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");
    }
}

  

這是侑虎科技第1027篇文章,感謝作者王江榮供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:793972859)

作者主頁:https://www.zhihu.com/people/luckywjr,再次感謝王江榮的分享,如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:793972859)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM