[Shader筆記]最佳Compute Shader入門姿勢(゚∀゚)
GPU硬體架構與Compute Shader寫法筆記
前情提要:
會寫這篇文章主要是受到CatlikeCoding啟發,從很久以前就對Compute Shader深感興趣,但無奈中文圈一直很少有在Unity3D裡討論這一塊,也鮮少會有人針對全新手做全面性的介紹,所以想說乾脆自己整理一篇面向新手的Compute Shader,我本身不是什麼Unity 大神 Compute Shader也是我第一次學習,這篇文章的性質比較接近筆記,以及幫所有想要一起踏入這個領域的人降低門檻。畢竟網路上的資料真的非常分散,學習的過程非常艱辛,要馬直接挖keijiro 的源碼生吃要馬直接啃官方文檔….。
其實Catlikecoding已經寫得非常完整了,我這邊只是將既有的教學再加上個人的理解,以及自己的實作過程。如果對Compute Shader有興趣的話,建議一定要點進去看一下。
這篇文章真的超級長,但是也完整的紀錄了我學習Compute Shader入門的過程,這其中包括了一大堆的專有名詞,腳本運行邏輯、處理Compute Shader的思路…等等。所以建議大家還是全部看完,畢竟後面實作的部分如果沒有前面對硬體以及CUDA的理解可能會完全看不懂,最後希望大家會喜歡這篇文章。
在開始認識 Compute Shader 之前,我強烈建議至少要對Shader有簡單的理解,知道Shader是什麼、Shaderlab基礎的語法後再來閱讀本篇文章才會比較容易理解喔,還不太認識Shadere的話建議可以先參考我之前寫的Shader筆記再回來喔~
目錄:
1. GPU
2. 平行處理
3. CUDA
4. Compute Shader 架構
5. Compute Shader 實作與應用
6.文章參考
Compute Shader 介紹
什麼是Compute Shader ?
Compute Shader 從名字上可以看得出來他也是Shader一族的人,而既然是Shader那就是在GPU上運作的程序。與一般的Shader的不同地方在於,Compute Shader通常用來撰寫渲染以外的指令,利用GPU強大的效能以及平行處理的能力來加速CPU的運行效率。
Unity 中使用的 Compute shader 語法上非常接近於HLSL 語言(DirectX 11),也因此撰寫Compute Shader 除了要注意不同於CPU的撰寫方式以外(也就是平行演算法),還需要注意目標平台是否支援Compute Shader。
官方建議撰寫Compute Shader前最好有對GPU硬體架構的理解與平行算法的理解,但平行算法水太深了,本篇文章僅會提及些許的GPU硬體架構。
HLSL (wiki)
是由微軟擁有及開發的一種著色器語言,最初的開發是為了輔助 Direct3D 9 的著色器彙編語言,後成為 Direct3D 10 以來統一著色器模型所必須的語言。
為什麼要用Compute Shader ?
那說了那麼多,我們在什麼樣的情況下會需要使用到Compute Shader 呢?當你需要重複進行大量相同的計算時,且就算用演算法依然無法將龐大運算次數縮減的情況,舉例來說如果場景中有數以百萬計的粒子運動需要計算,使用Compute Shader 就會成為一個非常好的選擇。
Compute Shader 的缺點?
Compute Shader 並不是永遠的萬靈丹,由於CPU與GPU之間的通訊速度並不是那麼理想,因此如果使用Compute Shader 那麼第一個要注意的就是要極力地減少Compute Shader與Charp之間的資料傳遞。
其次如果有寫過Shader的人應該明白,在Shader中本來就要盡量if 、for 這類條件語句,讓Shader只負責處理計算的過程,詳細的技術細節可以參考這篇文章。
GPU 單元
既然Compute Shader 是在GPU上運作的指令,那他的運作原理當然也跟GPU的硬體架構息息相關,甚至直接關聯到他的語法與裡面要做的事。
一般來說一個GPU擁有多個「多處理器(multiprocessors)」而每個多處理器又有8個「流處理器(stream processors)」此外每個多處理器還有多個「暫存器(Register)」以及「共享記憶體(shared memoru)」、 「材質快取(texture cache)」 、「常數快取(constant cache)」。
p.s. 請記住上面提到的「多處理器」、「流處理器」、「共享記憶體」,等等的文章還會再提到這三個組件。
平行處理
理解平行處理最好的方式就是理解CPU和GPU運作方式,CPU就像是能力非常厲害的老師,GPU就像台下的學生,假設今天有個考試一共有1000題,底下剛好也有1000個學生,學生可以一人做一題,而台上的老師要一題一題做,可以想見他們之間計算題目的速度一定是差異巨大。而這就是平行處理的魅力,藉由大量的運算單元把原本龐大數量的問題在極短的時間內同時解決。
CUDA (Compute Unified Device Architecture)
為什麼要認識 CUDA ?
由於應用於Unity 內的 Compute Shader 其實就是基於 CUDA 架構設計的一種技術,在Compute Shader中你可以找到對應的CUDA架構跟GPU的硬體架構,因此Unity 中的 Compute Shader 與 CUDA息息相關,甚至可以說是父子關係也不為過XD
什麼是CUDA ?
CUDA 全名為 Compute Unified Deviced Architecture 中文稱作「統一計算架構」,由NVIDIA所提出,是一種將GPU應用於圖像渲染之外的一種整合技術,也是NVIDIA給GPGPU (也就是Unity中的Compute Shader) 的正式名稱
CUDA運作架構
在CUDA 的運作架構中一共可以分為兩個部分,一個是CPU端另一個是GPU端。CPU端除了本身要計算程式以外還需要發派程式給GPU執行因此又稱「host端」,而GPU端又稱作「device端」,在其上面運行的程式稱作「kernel」。
p.s. kernel 也要先記住,等等你會很常看到他
CUDA的執行流程
基於CUDA的運作架構,CPU與GPU之間並不共用記憶體,因此當host端想要派發給device端的時候,host端需要將要處理的資料(經由PCI-E線)傳給GPU在將運算完的結果從device端的記憶體複製並傳回CPU端。
CUDA 軟體架構
CUDA裡GPU上執行的程式可以用三階層架構來說明,大致上可以分為
Grid
|-Block
| |-Thread
1. Grid 格 :Grid 是最上層的架構,可以直接對應於一個kernel。
2. Block 區塊 :同一格(Grid)中有許多區塊,而每個區塊都執行相同程式。
3. Thread 執行緒:執行序是GPU上最基本的運算單元,每個執行緒都有自己的暫存器與執行緒內的記憶體。
既然執行緒是GPU最基礎的單元,不同執行緒之間當然會有資料交換的需求,因此根據上面的階層,CUDA架構下的記憶體也可以分為三個階層,分別為:
1. 執行緒內記憶體(Per-Thread memory )
2. 共享記憶體 (shared memoru) : 同一區塊中的執行緒可以共享的記憶體。
3. 全域記憶體 (global memory): 同一格內的執行緒能共用的記憶體。
GPU硬體架構與CUDA之間的對應關係
在CUDA架構中一個流處理器(stream processor)對應一個執行緒(Thread),而一個多處理器(Muti-processor)對應一個區塊(block)。
多處理器 (multiprocessor) — — — — — — — 區塊(Block)
|- 流處理器 ( stream processor) — — — — — 執行緒(Thread)
通常一個多處理器內僅有8個流處理器,但卻會遇到需要對應遠超過8的執行緒。為了解決這種軟體與硬體架構數量上不對等的問題,硬體會自動將同一區塊的執行緒給分組,這個分組就稱作「Warp」,每個Warp會有固定數量的執行緒,因此遇到超過的數量就會額外在調用一次warp,所以每一次額外的調用的執行緒一定是warp的倍數。
舉例來說:
如果一個block裡有128個執行緒,而你的顯卡每個warp有32個執行緒,總共就會有4個warp,分別處理你分配的指令。
p.s. 這個warp也很重要,等等實作的時候還會再看到他
Compute Shader 語法介紹(資源)
要在Unity裡面更加深入的實作Compute Shader除了原生的HLSL以外,當然也需要看一下Unity官方提供的API
如果是希望學習怎麼使用HLSL優化遊戲或做科學計算,目前我找到兩本書但因為都還沒看,因此這邊純分享不討論書本內容。
- GPGPU Programming For Games And Science:
- Real-Time 3D Rendering with DirectX and HLSL: A Practical Guide to Graphics:比較像是純粹的HLSL教學,這本書講了蠻詳細的HLSL渲染細節與實作
- Parallel Scientific Computation: A Structured Approach Using BSP
以上這幾本書無一例外都需要有堅實的數學背景,當然你也可以像我一樣邊看邊學,只是會看得有點緩慢就是了XD
Compute Shader 實作
實作內容說明:(Unity版本 :Unity 2020.1.1)
實作一個2d的方塊矩陣,矩陣中每一個方塊都會隨著perlin-noise的值擺動。
p.s. 大部分會看到這篇文章的人應該都是對Unity有一定程度的認識,因此以下實作過程僅會針對API以及撰寫時的邏輯進行講解,並不會講解太細節的Unity操作。
各腳本負責內容:
1.CSharp :
- 處理CSharp 與 Shader、Compute Shader之間的資料傳遞
- 使用GPU instancing 把物體繪製出來 (每一幀都要去呼叫)
2.Compute Shader
- 處理物體運動函數
- 計算Particle座標
3.Shader
- 客製化各種材質、光照…
- 讀取 Compute Shader 計算出來的結果
- 將Particle畫在正確的位置
Step1 創建腳本
新增一個CSharp腳本、Standard Surface Shader 、Compute Shader
Step2 編輯CSharp 腳本
接下來我們要處理Compute Shader的前置準備,這一part中會出現兩個角色,一是我們今天的目標Compute Shader,另一個則是Compute Buffer。
Compute Buffer
Compute Buffer是Compute Shader要傳到Shader中的資料,他從頭到尾都待在GPU的記憶體,這裡大家可能心裡會有個疑問,既然是ComputeShader中才要使用到的資料結構為什麼我們這裡需要先宣告?這是因為雖然
Compute Shader與Shader之間都處於device(GPU)端,但是彼此之間並不曉得實際的記憶體位置,中間必須依靠CSharp來指派記憶體位置兩者之間才能相互溝通,舉個例子:「郵差跟你家雖然都在同個區域,但郵差並不曉得你家在哪裡,因而必須仰賴第三方的訊息管道」而這三者之間的關係就剛好像CShrap(第三方訊息管道)、Shader(你家)、ComputeShader(郵差)。
我們需要先宣告好我們待會要用的Compute Buffer,宣告Compute Shader的建構子需要提前計算兩個參數,分別是數量與容量。你需要知道你總共要有多少組座標,這裏由於我希望創造一個方陣,因此粒子的總數會等於方陣的邊長相乘(resolution * resolution),再來你需要先計算好要宣告的資料佔用電腦多少容量,舉例來說一組三維空間的座標為三個浮點數值,因此容量大小為sizeof(float) * 3。
也就是我宣告這裡總共有(resolution * resolution)組資料,且每組資料為(sizeof(float)*3)
宣告 ComputeBuffer
實例化 ComputeBuffer : buffer = new ComputeBuffer(數量, 大小)
[SerializeField] ComputeShader computeShader = default;
[Range(10, 200)] public int resolution = 10;
ComputeBuffer computeBuffer;//實例化 Compute Buffer
//第一個參數是Buffer的數量,第二個參數是每個buffer佔用的記憶體大小void CreateComputeBuffer(){computeBuffer = new ComputeBuffer(resolution * resolution, 3 * sizeof(float));}//釋放ComputeBuffer
//GPU的記憶體很寶貴,沒用記得丟void DiscardComputeBuffer(){computeBuffer.Release();
computeBuffer = null;}
步驟三:編輯Compute Shader
- 宣告使用的kernel名稱
還記得前面提到的kernel嗎?(不記得回去翻CUDA架構),這邊等於是宣告我們要運算的函式名稱,這個kernel名稱非常重要,每個Kernel都會分配一組Buffer跟Texture,指定好Kernel名稱為們才找得到他計算完的資料,也才能分配ComputeBuffer給他。
pragma kernel function_name
#pragma kernel PerlinSea
2. 宣告這個kernel每個blcok(Thread group)裡面使用到多少thread
一個block是由很多個Thread所組成,因此又稱為Thread group。而每個Thread group 又會包含很多個warp,由於這裡的數量是根據warp一組的數量來訂的,而每一家的顯卡warp數量不盡相同,但通常都是32或64,因此不論你是幾乘幾都建議使用64個Thread (ex : [64, 1, 1] [8, 8, 1]…)或至少2的倍數,這裏我們要做的是一個方陣,因此定義數量為8 * 8 * 1。
numthreads
//宣告參數
RWStructuredBuffer<float3> _Positions;
uint _Resolution;
float _Amplitude;
float _Speed;
float _Time;
float _Step;//main thread
[numthreads(8,8,1)]
void PerlinSea (uint3 id : SV_DispatchThreadID){
//這邊先不寫
}
3. 撰寫Particle運動規則
GetUV
// 目標:將每個Thread 的id(三維的id) 都映射到方陣中// 參數解釋:
//id 三維的id從 SV_DispatchID取得,每個維度的範圍為0 ~ resolution-1
//_step : 從CSharp 傳遞過來的參數,用來表示每個Particle的Scale,因爲
// Particle的大小會隨著數量變多(也就是resolution)而變小,他的值為
// (2/resolution)// 計算說明:
// 關於這邊的計算最好的理解方式為,我們有一個0~1區間的值,如果想要將他映射到
// -1~1的話轉換公式就等於,new_x = [(x + 0.5) * 2 - 1.0]
// 舉例來說x = 1 帶入進去後 new_x 還是1, 但0帶入進去後會得到-1, 0.5->0float2 GetUV(uint3 id){ float2 uv = (id.xy + 0.5) * _Step - 1.0;
return uv;}
PerlinNoiseWave
//產生Perlin noise
//網路上大大提供的開源程式碼:/*https://forum.unity.com/threads/2d-3d-4d-optimised-perlin-noise-cg-hlsl-library-cginc.218372/
*/float GetPerlinNoise(float2 uv, float time){ return snoise(float2(uv.x,uv.y+(time * 0.5)));}//真正讓Compute Shader執行的計算float3 PerlinWaveFunction(float2 uv, float t){ float3 pos;
pos.x = uv.x;
pos.y = ((GetPerlinNoise(uv, t)+0.5) * 2 - 1) * _Amplitude;
pos.z = uv.y;
return pos;}
4. 在PerlinSea中執行上述程式
//main thread [numthreads(8,8,1)]
void PerlinSea (uint3 id : SV_DispatchThreadID){
// TODO: insert actual code here!
float2 uv = GetUV(id);
SetPosition(id, PerlinWaveFunction(uv, _Time));
}
步驟四 編輯Shader
1. 告訴GPU再進入render pipeline之前你還要再額外做些事情
instancing_options
//assumeuniformscaling : 使用統一的Scale來進行縮放
//procedural:function_name#pragma instancing_options assumeuniformscaling procedural:mappingPosition
mappingPosition
2. 這裡主要是處理每個particle要渲染在哪,不然一般就是直接使用Transform組件提供的位置來進行渲染
//在宣告一次我們要使用到的變數 _Step(大小) 、_Positions(座標)float _Step;
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float3> _Positions;
#endif //這邊是使用不包含旋轉的Transform Matrix
//詳細的數學原理可以參考這裡void mappingPosition(){
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
float3 position = _Positions[unity_InstanceID];
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 =
float4(position.x, position.y, position.z, 1.0);
unity_ObjectToWorld._m00_m11_m22 = _Step;
#endif
}
步驟五 再回到CShrap
會把溝通的工作放在最後一步講是因為我希望可以把建構與傳遞資訊分成兩個部分,這裡我們的工作是將CSharp與Shader、ComputeShader真正意義上的串接。
1.建立連接:
這裡的API是建立CSharp與Compute Shader中變數的連接,在Unity中每個Shader的變數都會被分配一個整數,用來給Unity追蹤用,建立完之後我們就可以自由的選擇要將什麼樣的資訊傳遞給Compute Shader或Shader,或甚至取得Buffer中的資料。
Shader.PropertyToID : 取得Shader/Compute Shader裡變數的ID
computeShader.SetInt : 傳遞整數值給Shader/Compute Shader
computeShader.SetFloat:傳遞浮點數值給Shader/Compute Shader
computeShader.SetBuffer:傳遞給Shader/Compute Shader
2. 執行程式與渲染:
Dispatch 就是用來呼叫Compute Shader執行Kernel程式的API,因此使用時除了要宣告要使用哪一個kernel進行計算,還要跟他說需要用多少的
Thread group,注意這裡Dispatch宣告的Thread group跟Compute Shader裡的numthreads是不同的東西。
Dispatch裡的宣告的數量是告訴GPU你需要多少Thread Group參與你的計算,而numthreads則是你一個Thread group裡有多少個Thread。
computeShader.Dispatch :執行指定kernel裡的程式碼
Graphics.DrawMeshInstancedProcedural :利用GPU Instancing 渲染物件群
//唯讀值,在 Unity 中,Shader的每一個属性的每個名稱(例如 _MainTex或
//_Color)都會分配一個整数,並且這個整數會從開始遊戲到結束遊戲始終保持相同的
//值。
static readonly int
resolutionId = Shader.PropertyToID("_Resolution"),
positionsId = Shader.PropertyToID("_Positions"),
amplitudeId = Shader.PropertyToID("_Amplitude"),
speedId = Shader.PropertyToID("_Speed"),
timeId = Shader.PropertyToID("_Time"),
stepId = Shader.PropertyToID("_Step");
//更新客製化的參數到Compute Shader與Shader,並且將每個Particle畫出來
void UpdateFunctionOnGPU(){
//Update argument of compute shader
int kernelId = computeShader.FindKernel("PerlinSea");
computeShader.SetInt(resolutionId, resolution);
computeShader.SetFloat(amplitudeId, amplitude);
computeShader.SetFloat(speedId, speed);
computeShader.SetFloat(timeId, Time.time);
computeShader.SetFloat(stepId, 2.0f/resolution);
computeShader.SetBuffer(kernelId, positionsId, computeBuffer);
//dipatch commpute shader
int groupNumber = Mathf.CeilToInt(resolution / 8);
computeShader.Dispatch(kernelId, groupNumber, groupNumber, 1);
//update argument of material
material.SetFloat(stepId, 2.0f/resolution);
material.SetBuffer(positionsId, computeBuffer);
//call gpu Instancing to draw the particles
var bounds = new Bounds(Vector3.zero, Vector3.one * (2f +
2f/resolution));
// first argument : mesh
// second argument : sub-mesh index
// third argument : material
// 4th : bound
// 5th : count of buffer Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds,
computeBuffer.count);
}
完整腳本
複習用問題
- Compute Shader 、Surface Shader 、c#之間的通訊方式
- 如何用Compute Shader 動態生成物件位置達到GPU控制物體移動的效果
快速Review
CSharp TODO List
- 計算要交換的資料格式有多大 (new ComputeBuffer())
- 建立與Compute Shader參數之間的連結(Shader.Shader.PropertyToID())
- 設定Compute Shader參數 (computeShader.setInt())
- 設定Shader 參數
- 規劃這次要使用到多少的Thread group (computeShader.Dispatch())
- 呼叫Graphics.DrawMeshInstancedProcedural來畫目標
Compute Shader TODO List
- 規劃每個Thread Group 有多少Thread (numthreads)
- 撰寫程式邏輯
Shader TODO List
- 打開GPU instancing的開關
- 宣告pragma instancing_options procedural:function_name
- 宣告要使用的參數
- 撰寫程式邏輯