Compute Shader 簡介

貓冬發表於2021-04-24

原文在我的部落格:Compute Shader 簡介
注:[^1] 類似的標記是腳註,對應的連結在文章底部。
這麼久沒寫文章,圖靈社群的 markdown 格式仍然這麼獨特...

做遊戲的時候,我們經常要面對各種優化問題。DOTS 技術棧的出現提供了一種 CPU 端的多執行緒方案,那麼我們是否也能將一些計算轉到 GPU 上面,從而平衡好對 CPU 和 GPU 的使用呢?對我而言,以前使用 GPU 無非是通過寫 vert/frag shader、做好渲染相關的設定等操作,但實際上我們還能使用 GPU 的計算能力來幫我們解決問題。Compute Shader 就是我們跟 GPU 請求計算的一種手段。

本文將從並行架構開始,依次講解一個最簡單的 Compute Shader的編寫、執行緒與執行緒組的概念、GPU 結構和其計算流水線,並講解一個鳥群 Flocking 的例項,最後介紹 Compute Shader 的應用。全文較長,讀者可以通過目錄挑想看的看。

Compute Shader 也和傳統著色器的寫法十分不一樣,寫傳統 Shader 寫怕了的同學請放心~

介紹

當今的 GPU 已經針對單址或連續地址的大量記憶體處理(亦稱為流式操作,streaming operation)進行了優化,這與 CPU 面向記憶體隨機訪問的設計理念則剛好背道而馳。再者,考慮到要對頂點與畫素分別進行單獨的處理,因此 GPU 現已經採用了大規模並行處理架構。例如,NVIDIA 公司開發的 “Fermi” 架構最多可支援 16 個流式多處理器(streaming multiprocessor, SM),而每個流式處理器又均含有 32 個 CUDA 核心,也就是共 512 個 CUDA 核心。

CUDA 與 OpenCL 其實就是通過訪問 GPU 來編寫通用計算程式的兩組不同的 API。

CPU compare GPU

現代的 CPU 有 4-8 個 Core,每個 Core 可以同時執行 4-8 個浮點操作,因此我們假設 CPU 有 64 個浮點執行單元,然而 GPU 卻可以有上千個這樣的執行單元。僅僅只是比較 GPU 和 CPU 的 Core 數量是不公平的,因為它們的職能不同,組織形式也不同。

顯然,圖形的繪製優勢完全得益於 GPU 架構,因為這架構就是專為繪圖而精心設計的。但是,一些非圖形應用程式同樣可以從 GPU 並行架構所提供的強大計算能力中受益。我們將 GPU 用於非圖形應用程式的情況稱為通用 GPU 程式設計(通用 GPU 程式設計。General Purpose GPU programming, GPGPU programming)。當然,並不是所有的演算法都適合由 GPU 來執行,只有資料並行演算法(data-parallel algorithm) 才能發揮出 GPU 並行架構的優勢。也就是說,僅當擁有大量待執行相同操作的資料時,才最適宜採用並行處理。[^1]

粒子系統是一個例子,我們可簡化粒子之間的關係模型,使它們彼此毫無關聯,不會相互影響,以此使每個粒子的物理特徵都可以分別獨立地計算出來。

對於 GPGPU 程式設計而言,使用者通常需要將計算結果返回 CPU 供其訪問。這就需將資料由視訊記憶體複製到系統記憶體,雖說這個過程的速度較慢(見下圖),但是 GPU 在運算時所縮短的時間相比卻是微不足道的。 針對圖形處理任務來說,我們一般將運算結果作為渲染流水線的輸入,所以無須再由 GPU 向 CPU 傳輸資料。例如,我們可以用計算著色器(Compute Shader)對紋理進行模糊處理(blur),再將著色器資源檢視(shader resource view,DirectX 的概念),與模糊處理後的紋理相繫結,以作為著色器的輸入。

CPU 與 GPU 的資料傳輸

計算著色器雖然是一種可程式設計的著色器,但 Direct3D 並沒有將它直接歸為渲染流水線中的一部分。雖然如此,但位於流水線之外的計算著色器卻可以讀寫 GPU 資源。從本質上來說,計算著色器能夠使我們訪問 GPU 來實現資料並行演算法,而不必渲染出任何圖形。正如前文所說,這一點即為 GPGPU 程式設計中極為實用的功能。另外,計算著色器還能實現許多圖形特效——因此對於圖形程式設計師來說,它也是極具使用價值的。前面提到,由於計算著色器是 Direct3D 的組成部分,也可以讀寫 Direct3D 資源,由此我們就可以將其輸出的資料直接繫結到渲染流水線上。

計算著色器並非渲染流水線的組成部分,但是卻可以讀寫GPU 資源。而且計算著色器也可以參與圖形的渲染或單獨用於 GPGPU 程式設計

最簡單的 Compute Shader

現在我們來看看一個最簡單的 Compute Shader 的結構。

Unity 右鍵 → Create → Shader → Compute Shader 就可以建立一個最簡單的 Compute Shader。

Compute Shader 副檔名為 .compute,它們是以 DirectX 11 樣式 HLSL 語言編寫的。

#pragma kernel CSMain

RWTexture2D<float4> Result;

[numthreads(8,8,1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    // 為了演示,我把模板中下面這行改了
    Result[id.xy] = float4(0, 1, 1, 1.0);
}

第 1 行:一個計算著色器資原始檔必須包含至少一個可以呼叫的 compute kernel,實際上這個 kernel 對應的就是一個函式,該函式由 #pragma 指示,名字要和函式名一致。一個 Shader 中可以有多個核心,只需定義多個 #pragma kernel functionName 和對應的函式即可,C# 指令碼可以通過 kernel 的名字來找到對應要執行的函式( shader.FindKernel(functionName))。

第 3 行: RWTexture2D 是一種可供 Compute Shader 讀寫的紋理,C# 指令碼可以通過 SetTexture() 設定一個可讀寫的 RenderTexture 供 Compute Shader 修改畫素顏色。其中 RW 代表可讀寫。

第 5 行:numthreads 設定執行緒組中的執行緒數。組中的執行緒可以被設定為 1D、2D 或 3D 的網格佈局。執行緒組和執行緒的概念下文會提到。

第 6 行:CSMain 為函式名,需要和 pragma 定義的 kernel 名一一對應。一個函式體代表一個執行緒要執行的語句,傳進來的 SV_DispatchThreadID 是三維的執行緒 id,下文會提到。

第 9 行:根據當前執行緒 id 索引到可讀寫紋理對應的畫素,並設定顏色。

C# 指令碼這邊

private void InitShader()
{
    _image = GetComponent<Image>();
    _kernelIndex = computeShader.FindKernel("CSMain");
    int width = 1024, height = 1024;
    _rt = new RenderTexture(width, height, 0) {enableRandomWrite = true};
    _rt.Create();

    _image.material.SetTexture("_MainTex", _rt);
    computeShader.SetTexture(_kernelIndex, "Result", _rt);
    computeShader.Dispatch(_kernelIndex, width / 8, height / 8, 1);
}

第 4 行:一個 Compute Shader 可能有多個 Kernel,這裡根據名字找到需要的 KernelIndex,這樣指令碼才知道要把資料送給哪一個函式運算。

第 6、7 行:建立一個支援隨機讀寫的 RenderTexture

第 10 行:為 Compute Shader 設定要讀寫的紋理。

第 11 行:設定好要執行的執行緒組的數量,並開始執行 Compute Shader。執行緒組數量的設定下文會提到。

將 Compute Shader 在 Inspector 賦值給指令碼,然後將指令碼掛在一個有 Image 元件的 GameObject 下,就能看到藍色的圖片。

enter image description here

到現在我們應該大概明白了:

  • kernel 函式裡面執行的是一個執行緒的要執行的邏輯。
  • 我們需要設定執行緒組的數量(Dispatch)、和執行緒組內執行緒的數量(numthreads)。
  • 我們可以為 Compute Shader 設定紋理等可讀寫資源。

那麼什麼是執行緒組和執行緒呢?我們又該如何設定數量?

如何劃分工作:執行緒與執行緒組

在 GPU 程式設計的過程中,根據程式具體的執行需求,可將 執行緒 劃分為由 執行緒組(thread group) 構成 的網格(grid)。

numthreadDispatch 的三維 Grid 的設定方式只是方便邏輯上的劃分,硬體執行的時候還會把所有執行緒當成一維的。因此 numthread(8, 8, 1)numthread(64, 1, 1) 只是對我們來說索引執行緒的方式不一樣而已,除外沒區別。

執行緒組構成的 3D 網格

下圖是 Dispatch(5,3,2)numthreads(10,8,3) 時的情況。

注意下圖 Y 軸是 DirectX 的方向,向下遞增,而 Compute Shader 中 Y 軸是相反的,向上遞增,這裡參考網格內的結構和執行緒組與執行緒的關係即可。

enter image description here

上圖中還顯示了 SV_DispatchThreadID 是如何計算的。

不難看出,我們能夠根據需求定義出不同的執行緒組佈局。例如,可以定義一個具有 X 個執行緒的單行 執行緒組 [numthreads(X, 1, 1)] 或內含 Y 個執行緒的單列執行緒組 [numthreads(1, Y, 1)]

還可以通過將維度 z 設為 1 來定義規模為 $X × Y$ 的 2D 執行緒組,形如 [numthreads(X, Y, 1)]。我們應結合所遇到的具體問題來選擇適當的執行緒組佈局。

例如當我們處理 2D 影像時,需要讓每一個執行緒單獨處理一個畫素,就可以定義 2D 的執行緒組。假設我們 numthreads 設定為 (8, 8, 1),那麼一個執行緒組就有 $8×8$ 個執行緒,能處理 $8×8$ 的畫素塊(內含 64 個畫素點)。

那麼如果我們要處理一個 $texResolution × texResolution$ 解析度的紋理,那麼需要多少個執行緒組呢?

x 和 y 方向都需要 $texResolution / 8$ 個執行緒組。

enter image description here

可以通過執行緒組來劃分要處理哪些畫素塊($8×8$)

enter image description here

enter image description here

numthreads 有最大執行緒限制,具體查閱不同平臺的文件:numthreads

前面介紹瞭如何設定執行緒組和執行緒的數量,現在介紹執行緒組和執行緒在硬體的執行形式。

執行緒組的 GPU 之旅

Fermi 架構

Ampere 架構

我們知道 GPU 會有上千個“核心”,用 NVIDIA 的說法就是 CUDA Core。

  • SP:最基本的處理單元,streaming processor,也稱為 CUDA core。最後具體的指令和任務都是在 SP 上處理的。GPU 進行平行計算,也就是很多個 SP 同時做處理。我們所說的幾百核心的 GPU 值指的都是 SP 的數量;
  • SM:多個 SP 加上其他的一些資源組成一個 streaming multiprocessor。也叫 GPU 大核,其他資源如:warp scheduler,register,shared memory 等。SM 可以看做 GPU 的心臟(對比 CPU 核心),register 和 shared memory 是 SM 的稀缺資源。CUDA 將這些資源分配給所有駐留在 SM 中的 threads。因此,這些有限的資源就使每個 SM 中 active warps 有非常嚴格的限制,也就限制了並行能力。

這些核心被組織在流式多處理器(streaming multiprocessor, SM)中,一個執行緒組執行於一個多處理器(SM)之上。每一個核心同一時間可以執行一個執行緒。

流式多處理器(streaming multiprocessor, SM)是 Nvidia 的說法,AMD 對應的單元則是 Compute Unit。

因此,對於擁有 16 個 SM 的 GPU 來說,我們至少應將任務分解為 16 個執行緒組,來讓每個多處理器都充分地運轉起來。但是,要獲得更佳的效能,我們還應當令每個多處理器至少擁有兩個執行緒組,使它能夠切換到不同的執行緒組進行處理,以連續不停地工作(執行緒組在執行的過程中可能會發生停頓,例如,著色器在繼續執行下一個指令之前會等待紋理的處理結果,此時即可切換至另一個執行緒組)。

SM 會將它從 Gigathread 引擎(NVIDIA 技術,專門管理整個流水線)那收到的大執行緒塊,拆分成許多更小的堆,每個堆包含 32 個執行緒,這樣的堆也被稱為:warp (AMD 則稱為 wavefront)。多處理器會以 SIMD32 的方式(即 32 個執行緒同時執行相同的指令序列)來處理 warp,每個 CUDA 核心都可處理一個執行緒。

“Fermi” 架構中的每個多處理器都具有 32 個 CUDA 核心。

每一個執行緒組都會被劃分到一個 Compute Unit 來計算,執行緒組中的執行緒由 Compute Unit 中的 SIMD 部分來執行。 如果我們定義 numthreads(8, 2, 4),那麼每個執行緒組就有 $8×2×4=64$ 個執行緒,這一整個執行緒組會被分成兩個 warp,排程到單個 SIMD 單元計算。

enter image description here

單個 SM 處理逐個 warp,當一個 warp 暫時需要等待資料的時候,就可以先換其他 warp 繼續執行。

如何設定好執行緒組的大小

我們應當總是將執行緒組的大小設定為 warp 尺寸的整數倍。讓 SM 同時容納多個 warp,能夠以防一些情況。例如有時候為了等待某些資料就緒,你不得不停下來。比如說,我們需要通過法線紋理貼圖來計演算法線光照,即使該法線紋理已經在 Cache 中了,訪問該資源仍然會有所耗時,而如果它不在 Cache 中,那就更加耗時了。用專業術語講就是 Memory Stall(記憶體延遲)。與其什麼事情也不做,不如將當前的 Warp 換成其它已經準備就緒的 Warp 繼續執行。[^2]

enter image description here

上圖來自:DirectCompute Lecture Series 210: GPU Optimizations and Performance

NVIDIA 在 Maxwell 更改了 SM 的組織方式,即 SMM——全新的 SM 架構。每個 SM 分為四個獨立的處理塊,每個處理塊具備自己的指令緩衝區、排程器以及 32 個 CUDA 核心。因此 Maxwell 中可以同時執行 4 個以上的 Warp,實際上,在 GTC2013 大會上的一個 CUDA 優化視訊裡講到,在常用 case 中推薦使用 30 個以上的有效 Warp,這樣才能確保 Pipeline 的滿載利用率。 —— Guohui Wang

NVIDIA 公司生產的圖形硬體所用的 warp 單位共有 32 個執行緒。而 ATI 公司採用的 “wavefront” 單位則具有 64 個執行緒,且建議為其分配的執行緒組大小應總為 wavefront 尺寸的整數倍。另外,值得一提的是,不管是 warp 還是 wavefront,它們的大小在未來幾代中都有可能發生改變。

總之,每個 SM 的操作度是 warp,但是每個 SM 可以同時處理多個 warp。然後因為有記憶體等待(memory stall)的問題,同一個 thread block 有可能需要等待記憶體才做,因此可以使用多個執行緒組交叉執行。warp 對我們是不可見和不可程式設計的,我們可程式設計的只有執行緒組。[^3]

GPU Compute Unit

接下來我們看一下 GPU 內部的結構,這裡的內容來自 Compute Shaders: Optimize your engine using compute / Lou Kramer, AMDLou Kramer 以 AMD 的 GCN 架構為例,介紹了 GPU 大體的結構。

這裡 GCN 就是一個 Compute Unit,Vega 64 顯示卡有 64 個 Compute Unit。

enter image description here

GCN 有 4 個 SIMD-16 單元(即 16 個執行緒同時執行相同的指令序列)。

enter image description here

執行緒間交流

多個執行緒組間的交流

上面提到,執行緒並不能訪問其他組中的共享記憶體。如果執行緒組需要互相交流,那麼就需要 L2 cache 來支援。但是 L2 cache 效能肯定會有折扣,因此我們要保證組間的交流盡可能少。

enter image description here

單個執行緒組內的交流

如果單個執行緒組內執行緒需要互相交流,則需要 Local Data Share (LDS) 來完成。

enter image description here

LDS 會被其他著色階段(shader stage)使用,例如畫素著色器就需要 LDS 來插值。但是 Compute Shader 的用途和傳統著色器不一樣,不是必須要 LDS,因此我們可以隨意地使用 LDS。

groupshared float data[8][15];

[numthreads(8,8,1)]
void main(ivec3 index : SV_GroupThreadID)
{
    data[index.x][index.y] = 0.0;
    GroupMemoryBarrierWithGroupSync();
    data[index.y][index.x] += index.x;
…
}

需要組內共享的變數前加 groupshared ,同時為了保證其他執行緒也能讀到資料,我們也需要通過 Barrier 來保證他們讀的時候 LDS 裡面有需要的資料。

LDS 比 L1 cache 還快!

Vector Register 和 Scalar Register

如果有些變數是執行緒獨立的,我們稱之為 “non-uniform” 變數。(如果一個執行緒組內有 64 個執行緒,就要存 64 份資料)

如果有些變數是執行緒間共享的的,我們稱之為 “uniform” 變數,例如執行緒組 id 是組內每個執行緒都一樣的。(每個執行緒組內只存 1 份資料)

“non-uniform” 變數會被儲存到 Vector Register(VGPR, vector general purpose register)中。

“uniform” 變數會被儲存到 Scalar Register(SGPR, scalar general purpose register)中。

enter image description here

如果用了過多 “non-uniform” 變數導致 Vector Register 裝不下,就會導致分配給 SIMD 的執行緒組數量降低。

與傳統著色器執行流程的異同

Vert-Frag Shader

  1. 首先 Command Processor 會收集並處理所有命令,傳送到 GPU,並告知下一步要做什麼。
  2. Draw() 命令傳送後,Command Processor 告知 Graphics Processor 要做的事情。

    我們可以將 Graphics Processor 看作是輸入裝配器(Input Assembler)的硬體對應的部分。

  3. 然後類似於頂點著色器這些就會被送到 Compute Unit 去計算,處理完會到 Rasterizer (光柵器),並返回處理好的畫素到 Compute Unit 執行畫素著色(Pixel shader)。

  4. 最後才會輸出到 RenderTarget 。

下圖中,AMD 顯示卡架構中的 Compute Unit 相當於 nVIDIA GPUs 中的流式多處理器(streaming multiprocessor, SM)。

enter image description here

Compute Shader

  1. 首先 Command Processor 仍會收集並處理所有命令,傳送到 GPU。
  2. 我們不需要傳資料到 Graphics Processor,因為這不是一個 Graphics Command,而是直接傳到 Compute Unit。
  3. Compute Unit 開始處理 Compute Shader,輸入可以有 constants 和 resources(對應 DirectX 的 Resource 可以繫結到渲染管線的資源,例如頂點陣列等),輸出可以有 writable resources(UAV, Unordered Access View 能被著色器寫入的資源檢視)。

總結

因此,如果我們用了 Compute Shader,可以不通過渲染管線,跳過 Render Output,使用更少硬體資源,利用 GPU 來完成一些渲染不相關的工作。

enter image description here

此外,Compute Shader 的流水線需要的資訊也更少

enter image description here

Boids 示例

講完了理論,這裡來看看我們在 Unity 中使用 Compute Shader 來做一個鳥群(Boids)的 demo。

群落演算法可以參考:Boids (Flocks, Herds, and Schools: a Distributed Behavioral Model)

程式碼示例地址:Latias94/FlockingComputeShaderCompare

群落演算法簡單來講,就是模擬生物群落的自組織特性的移動。

Craig Reynolds 在 1986 年對諸如魚群和鳥群的運動進行了建模,提出了三點特徵來描述群落中個體的位置和速度:

  1. 排斥(separation):每個個體會避免離得太近。離得太近需要施加反方向的力使其分開。
  2. 對齊(Alignment):每個個體的方向會傾向於附近群落的平均方向。
  3. 凝聚(Cohesion):每個個體會傾向於移動到附近群落的平均位置。

在這個示例中,我們可以將每一隻鳥的位置和方向用一個執行緒來計算,Compute Shader 負責遍歷這隻鳥的周圍鳥的資訊,計算出這隻鳥的平均方向和位置。C# 指令碼則負責每一幀傳入凝聚(Cohesion)的位置、經過的時間,再從 Compute Shader 獲取每一隻鳥的位置和朝向,設定到每一隻鳥的 Transform 上。

設定資料

文章開頭的例子中,指令碼給 Shader 設定了 RWTexture2D<float4> ,讓 Compute Shader 能直接在 Render Tecture 設定顏色。

對於其他型別的資料,我們首先要定義一個結構(Struct),再通過 ComputeBuffer 與 Compute Shader 交流資料。

// FlockingGPU.cs
struct Boid
{
    public Vector3 position;
    public Vector3 direction;
};
public class FlockingGPU : MonoBehaviour
{
    public ComputeShader shader;
    private Boid[] _boidsArray;
    private GameObject[] _boids;
    private ComputeBuffer _boidsBuffer;
    // ...
    void Start()
    {
        _kernelHandle = shader.FindKernel("CSMain");

        uint x;
        // 獲取 Compute Shader 中定義的 numthreads
        shader.GetKernelThreadGroupSizes(_kernelHandle, out x, out _, out _);
        _groupSizeX = Mathf.CeilToInt(boidsCount / (float) x);
        // 塞滿每個執行緒組,免得 Compute Shader 中有執行緒讀不到資料,造成讀取資料越界
        _numOfBoids = _groupSizeX * (int) x;

        InitBoids();
        InitShader();
    }

    private void InitBoids()
    {
        // 初始化 _Boids GameObject[]、_boidsArray Boid[]
    }

    void InitShader()
    {   // 定義大小,鳥的數量和每個鳥結構的大小,一個 Vector3 就是 3 * sizeof(float)
        // 10000 只鳥,每隻佔6 * 4 bytes,總共也就佔 0.234mib GPU 視訊記憶體 
        _boidsBuffer = new ComputeBuffer(_numOfBoids, 6 * sizeof(float));
        _boidsBuffer.SetData(_boidsArray); // 設定結構陣列到 Compute Buffer 中
        // 設定 buffer 到 Compute Shader,同時設定要呼叫的計算的函式 Kernel
        shader.SetBuffer(_kernelHandle, "boidsBuffer", _boidsBuffer);
        shader.SetFloat("boidSpeed", boidSpeed); // 設定其他常量
        shader.SetVector("flockPosition", target.transform.position);
        shader.SetFloat("neighbourDistance", neighbourDistance);
        shader.SetInt("boidsCount", boidsCount);
    }
    // ...
    void OnDestroy()
    {
        if (_boidsBuffer != null)
        {    // 用完主動釋放 buffer
            _boidsBuffer.Dispose();
        }
    }
}

獲取資料

在開頭最簡單的 Compute Shader 一節中,我介紹了需要 Dispatch 去執行 Compute Shader 的 Kernel。

下面的 Update,設定了每一幀會變的引數,Dispatch 之後,再通過 GetData 阻塞等待 Compute Shader kernel 的計算結果,最後對每一個 Boid 結構賦值。

// FlockingGPU.cs
public class FlockingGPU : MonoBehaviour
{
    // ...
    void Update()
    {   // 設定每一幀會變的變數
        shader.SetFloat("deltaTime", Time.deltaTime);
        shader.SetVector("flockPosition", target.transform.position);
        shader.Dispatch(_kernelHandle, _groupSizeX, 1, 1); // 呼叫 Compute Shader Kernel 來計算
        // 阻塞等待 Compute Shader 計算結果從 GPU 傳回來
        _boidsBuffer.GetData(_boidsArray);
        for (int i = 0; i < _boidsArray.Length; i++)
        {   // 設定鳥的 position 和 rotation
            _boids[i].transform.localPosition = _boidsArray[i].position;
            if (!_boidsArray[i].direction.Equals(Vector3.zero))
            {
                _boids[i].transform.rotation = Quaternion.LookRotation(_boidsArray[i].direction);
            }
        }
    }
}

在 Compute Shader 中,也要定義一個 Boid 結構和相對應的 RWStructuredBuffer<Boid> 來用指令碼傳來的 Compute Buffer。Shader 主要就是對一隻鳥遍歷一定範圍內的鳥群的資訊,計算出結果返回給指令碼。

// SimpleFlocking.compute
#pragma kernel CSMain
#define GROUP_SIZE 256

struct Boid
{   // Compute Shader 也定義好相關的結構
    float3 position;
    float3 direction;
};

RWStructuredBuffer<Boid> boidsBuffer; // 允許讀寫的資料 buffer
float deltaTime;
float3 flockPosition;

[numthreads(GROUP_SIZE,1,1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    Boid boid = boidsBuffer[id.x];
    // ...
    for (int i = 0; i < boidsCount; i++)
    {
        if (i == id.x)
            continue;
        Boid tempBoid = boidsBuffer[i];
        // 通過周圍的鳥的資訊,計算經過三個特性後,這一隻鳥的方向和位置。
        // ...
    }
    // ...
    boid.direction = lerp(direction, normalize(boid.direction), 0.94);
    boid.position += boid.direction * boidSpeed * deltaTime;
    // 設定資料到 Buffer,等待 CPU 讀取
    boidsBuffer[id.x] = boid;
}

Dispatch 之後 GetData 是阻塞的,如果想非同步地獲取資料,Unity 2019 新引入一個 API:AsyncGPUReadbackRequest ,可以讓我們先傳送一個獲取資料的請求,再每一幀去查詢資料是否計算完。也有同學用了測出第一次呼叫耗時較多等問題,具體可以參考:Compute Shader 功能測試(二)

下面是 100 只鳥的結果:

enter image description here

通過 Compute Shader,我們可以通過 Compute Shader 在 GPU 直接計算好需要計算的東西(例如位置、mesh 頂點等),並與傳統著色器共享一個 ComputeBuffer ,直接在 GPU 渲染,這樣就省去渲染時 CPU 再次傳資料給 GPU 的耗時。我們也可以將 Compute Shader 計算後的資料返回給 CPU 再做額外的計算。總而言之,Compute Shader 十分靈活。

CPU 端計算 vs GPU 端計算

假設我們在 CPU 端不用任何 DOTS,直接在每個 Update 中 for 每個鳥計算朝向和位置,這樣效能是非常差的。

下圖是把計算都放到 C# Update 中的 Profile:

enter image description here

如果放到 Compute Shader 計算,每個 Update 更新資料,這樣 CPU 消耗小了很多。

enter image description here

感興趣的朋友可以對比下 FlockingCPU.cs 和 FlockingGPU.cs 的程式碼,會發現兩者的程式碼其實十分相似,只不過前者把 for loop 放到指令碼,後者放到了 Compute Shader 中而已,因此如果大家覺得有一些地方十分適合平行計算,就可以考慮把這部分計算放到 GPU 計算。

Profile Compute Shader

我們可以通過 Profiler 來看 GPU 利用情況,通常這個皮膚是隱藏的,需要手動開啟。

也可以通過 RenderDoc 來看,這裡不展示。

enter image description here

優化:DrawMeshInstanced

前面我們用 Instantiate 來初始化鳥群,其實我們也能通過 GPU instancing 來優化,用 Graphics.DrawMeshInstanced 來例項化 prefab。這個優化未包含在 Github 例子中,這裡提供思路。

enter image description here

這麼做的話,位置和旋轉都要在傳統 shader 中計算成變換矩陣應用在頂點上,因此為了防止 Compute Shader 資料傳回 CPU 再傳到 GPU 的傳統 shader 的開銷,需要兩個 Shader 共享一個 StructuredBuffer

這樣如果要給模型加動畫的話,還得提前烘焙動畫,將每一幀動畫的頂點和當前幀數提前傳到 vertex shader(or surface shader) 裡做插值,這樣做的話還能根據鳥的速度去控制動畫的速率。

應用

  • 遮擋剔除(Occlusion Culling
  • 環境光遮蔽(Ambient Occlusion
  • 程式化生成:
    • terrain heightmap evaluation with noise, erosion, and voxel algorithms
  • AI 尋路
    • Compute Shader 做尋路有點不太好的就是往往遊戲(CPU)需要知道計算結果,因此還要考慮 GPU 返回結果給 CPU 的延時。可以考慮做 CPU 端並行的方案,例如用 Job System。
  • GPU 光線追蹤
  • 影像處理,例如模糊化等。
  • 其他你想放到 GPU,但是傳統著色器幹不了的並行的解決方案。

原神

Unity線上技術大會-遊戲專場|從手機走向主機 -《原神》主機版渲染技術分享

enter image description here

解壓預烘焙的 Shadow Texture

在離線製作的時候,對於烘焙好的 shadow texture 做一個壓縮,儘量地去保持精度,執行的時候解壓的速度也非常快,用 Compute Shader 去解壓的情況,1K×1K 的 shadow texture,解壓只需要 0.05 毫秒。

enter image description here

做模糊處理

在進行模糊處理的時候,每個畫素需要採取周邊多個畫素的數值進行混合,可以看到,如果使用傳統的 PS,每個畫素都會需要多次貼圖取樣,且這些取樣結果實際上是可以在相鄰其他畫素的計算中進行重用的,因此為了進一步提升計算效能,《原神》這裡的做法是將模糊處理放到 Compute Shader 中來完成。

具體的做法是,將相鄰畫素的取樣結果儲存在 區域性儲存空間(Local Data Share) 中,之後再模糊的時候取用,一次性完成四個畫素的模糊計算,並將結果輸出。^4

天涯明月刀

《天涯明月刀》手遊引擎技術負責人:如何應用GPU Driven優化渲染效果?| TGDC 2020

enter image description here

做遮擋剔除(Occlusion Culling)時,CPU 只能做到 Object Level,而 GPU 可以通過切分 Mesh 做進一步的剔除。

enter image description here

知乎上也有人嘗試了實現:Unity實現GPUDriven地形

Clay Book

基於3D SDF 體渲染的黏土遊戲:Claybook Game

演講:DD2018: Sebastian Aaltonen - GPU based clay simulation and ray tracing tech in Claybook

動圖:https://gfycat.com/gaseousterriblechupacabra

Jelly in the sky

Finished my compute shader based game 這帖子的哥們寫了六千多行 HLSL 程式碼做了一個完全在 GPU 執行的基於物理模擬的遊戲。

Steam:Jelly in the sky on Steam

動圖:https://gfycat.com/validsolidcanine

開源專案

缺點

雖然 Unity 幫我們做了跨平臺的工作,但是我們仍然需要面對一些平臺差異。

本小節內容大部分來自 Compute Shader : Optimize your game using compute

  • 難 Debug
  • 陣列越界,DX 上會返回 0,其它平臺會出錯。
  • 變數名與關鍵字/內建庫函式重名,DX 無影響,其他平臺會出錯。
  • 如果 SBuffer 內結構的視訊記憶體佈局與記憶體佈局不一致,DX 可能會轉換,其他平臺會出錯。
  • 未初始化的 SBuffer 或 Texture,在某些平臺上會全部是 0,但是另外一些可能是任意值,甚至是NaN。
  • Metal 不支援對紋理的原子操作,不支援對 SBuffer 呼叫 GetDimensions
  • ES 3.1 在一個 CS 裡至少支援 4 個 SBuffer(所以,我們需要將相關聯的資料定義為 struct)。
  • ES 從 3.1 開始支援 CS,也就是說,在手機上的支援率並不是很高。部分號稱支援 es 3.1+ 的Android 手機只支援在片元著色器內訪問 StructuredBuffer。
    • 使用 SystemInfo.supportsComputeShaders 來判斷支不支援[^5]

最後

我相信 Compute Shader 這個詞不少讀者應該都會在其他地方見過,但是大都覺得這個技術離我們還很遠。我身邊的朋友問了問也沒怎麼了解過,更不要說在專案上用了,這也是這篇文章誕生的原因之一。

開始碎碎念,去年的年終總結也沒寫,今年到現在就憋出一篇文章,十分不應該。其實也是自己沒什麼好分享的,自己還需要多學習。當然也很高興通過部落格認識到不同朋友,這是我寫作的動力,謝謝你們。

參考

[^1]: 《DirectX 12 3D 遊戲開發實戰》第13章 計算著色器
[^2]: Render Hell —— 史上最通俗易懂的GPU入門教程(二)
[^3]: 知乎 - “問個CUDA並行上的小白問題,既然SM只能同時處理一個WARP,那是不是有的SP處於閒置?”的評論
[^5]: ComputeShader 手機相容性報告