计算着色器(Compute Shaders)
原文 :?https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
1 將工作轉移到GPU(Moving Work to the GPU)
我們圖形的分辨率越高,CPU和GPU需要做的工作就越多,即計算位置和渲染方塊。點的數量等于分辨率的平方,所以雙倍的分辨率會顯著的增加工作負載。我們也許在分辨率100的時候能達到60FPS,但是我們又能推進多遠?并且如果我們抵達了一個瓶頸我們能否使用不同的方法越過瓶頸?
1.1 分辨率200(Resolution 200)
讓我們從將Graph?的最大分辨率從100提升到200開始,并且看一下我們能得到什么樣的性能。
[SerializeField, Range(10, 200)]int resolution = 10;?
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Graph with resolution set to 200.
?我們現在渲染了40 000 個點。我這里的平均分辨率,DRP降到了10FPS,URP降到了15FPS。
?
?Profiling a build at resolution 200, without VSync, DRP and URP.
?1.2 GPU 圖像(GPU Graph)
?排序、批處理,然后將40 000個點的變換矩陣發送給GPU消耗很多時間。單個矩陣包含16個float?數字,每個是4 字節,一個矩陣就是總共64 字節。40 000 個點就是2.56?百萬字節--差不多2.44M--每次花這些點時都要復制給GPU。這些事URP每幀要做兩次,一次是陰影,一次是常規幾何圖形。DRP至少要做三次,因為它的額外的深度通道,除此之外,除了主平行光之外的每盞燈都要再加一次。
通常情況下,最好將CPU和GPU間的通信和數據量降到最低。由于我們只需要點的位置來顯示他們,最好是這些數據只存在于GPU。這會消除許多數據轉換。但是CPU將不再計算位置,轉而交由CPU計算。幸運的是它很適合這個任務。
讓CPU計算位置需要一個不同的方法。我們將創建一個新的圖像,并保留目前的圖像作為對比。復制Graph?腳本并重命名為GPUGraph。移除pointPrefab?和?points?字段,并移除?Awake,?UpdateFunction, and?UpdateFunctionTransition?方法。
using UnityEngine;public class GPUGraph : MonoBehaviour {//[SerializeField]//Transform pointPrefab;[SerializeField, Range(10, 200)]int resolution = 10;[SerializeField]FunctionLibrary.FunctionName function;public enum TransitionMode { Cycle, Random }[SerializeField]TransitionMode transitionMode = TransitionMode.Cycle;[SerializeField, Min(0f)]float functionDuration = 1f, transitionDuration = 1f;//Transform[] points;float duration;bool transitioning;FunctionLibrary.FunctionName transitionFunction;//void Awake () { … }void Update () { … }void PickNextFunction () { … }//void UpdateFunction () { … }//void UpdateFunctionTransition () { … } }然后在Update 中移除這些函數的調用
void Update () {…//if (transitioning) {// UpdateFunctionTransition();//}//else {// UpdateFunction();//}}我們的GPUGraph?組件與Graph?內部不同,但有著相同的配置選項,除了預制件。它包含了功能之間轉換的邏輯,但除此之外不做任何事。創建一個游戲對象并添加此組件,resolution為200,Transition Mode 為 Cycle。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?GPU graph component, set to instantaneous transitions.
?1.3 計算緩沖區(Compute Buffer)
為了保存GPU上的位置,我們需要為他們申請空間。為此我們創建一個ComputeBuffer?對象。在GPUGraph?中添加一個位置緩沖區字段,并在Awake?函數中通過調用new?ComputeBuffer()創建對象。
ComputeBuffer positionsBuffer;void Awake () {positionsBuffer = new ComputeBuffer();}?我們需要傳遞給緩沖區元素的數量作為參數,也就是分辨率的平方,就像Graph.的位置數組一樣。
positionsBuffer = new ComputeBuffer(resolution * resolution);compute buffer 包含任意無類型數據。通過第二個參數,我們需要指定每個元素精確的尺寸。我們需要存儲3D位置向量,即包含3個float?數字,所以每個元素是3倍的4?bytes。
positionsBuffer = new ComputeBuffer(resolution * resolution, 3 * 4);現在我們得到了一個compute buffer,但是這些對象不會再熱重載時生存(not survive hot reloads),也就是說如果我們在play模式下修改代碼它就會消失。我們可以將其從Awake 函數替換到 OnEnable 函數,每次組件被激活時它都會被調用。
void OnEnable () {positionsBuffer = new ComputeBuffer(resolution * resolution, 3 * 4);}除此之外我們還需要添加?OnDisable?函數,當組件禁用時會被調用,同時在被銷毀和熱重載之前被調用。通過在其中調用Release?函數來釋放緩沖區。這樣聲明的GPU內存可以被立即釋放。
void OnDisable () {positionsBuffer.Release();}由于這之后我們不在使用這個對象實例,最好將這個字段設為null 。這使得Unity的垃圾回收機制下次運行時可以回收這個對象,如果我們的圖像在play模式時被禁用或銷毀。
void OnDisable () {positionsBuffer.Release();positionsBuffer = null;}1.4 計算著色器(Compute Shader)
為了在GPU上計算位置,我們必須為其寫一個腳本,也就是一個計算著色器,通過?Assets / Create / Shader / Compute Shader?創建。它將成為我們FunctionLibrary?類的GPU等價物,所以也將其命名為FunctionLibrary?。雖然它是作為一個著色器并使用HLSL 語法,但它作為通用程序運行,而不是常規的用作渲染東西的著色器。因此我將其放置在Scripts?文件夾。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Function library compute shader asset.
?打開這個文件并移除它默認的內容。一個計算著色器需要包含一個主函數作為核心,通過#pragma kernel?指令后面跟著一個名字來指定,就像我們表面著色器的#pragma surface。這條指令作為第一行也是當前唯一一行,使用名字FunctionKernel
#pragma kernel FunctionKernel在指令下面來定義函數。
#pragma kernel FunctionKernelvoid FunctionKernel () {}1.5 計算線程(Compute Threads)
當GPU被安排執行計算著色器時,它將它的任務劃分為多個組然后獨立的或平行的調度他們。每個組由若干線程組成,這些線程執行相同的計算但是有不同的輸入。通過為我們的核心函數添加numthreads 屬性,我們必須詳細說明每個組有多少個線程。最簡單的選項是所有三個參數都使用1,這使每個組只運行一個線程。
[numthreads(1, 1, 1)] void FunctionKernel () {}GPU 硬件包含始終執行特定數量的lockstep里的線程的計算單元。它們被稱為warps 或?wavefronts。如果一個組的線程數比warp 大小要小,一些線程就會空運轉,浪費時間。如果線程數量超出了,CPU會給每個組使用更多warps。通常,默認使用64個線程比較好,因為這和ADM GPU的warp大小匹配,如果是NVidia GPU則是32,所以后者每個組將使用兩個warp。在現實中硬件會更復雜并且會對線程組做更多,但是這和我們簡單的圖形無關。
numthreads?的三個參數可以被用來組織線程是1、 2 還是3維度的。例如,(64, 1, 1)是一維的,而(8, 8, 1) 數量相同但是提供了一個2D 的8X8 方格。由于我們基于2D uv坐標定義我們的點,我們使用后者。
[numthreads(8, 8, 1)]這里的線程是包含三個無符號整型的向量,我們可以通過給我們的函數添加uint3?參數來實現。
void FunctionKernel (uint3 id) {}我們必須明確的指明這個參數是作為線程標識。為此我們在參數名字后面添加SV_DispatchThreadID?關鍵字。
void FunctionKernel (uint3 id: SV_DispatchThreadID) {}1.6 UV坐標(UV Coordinates)
如果我們知道圖像的步長,我們可以將線程標識轉換為坐標。給計算著色器添加一個屬性命名為_Step?,就像我們給我們的表面著色器添加_Smoothness?
float _Step;[numthreads(8, 8, 1)] void FunctionKernel (uint3 id: SV_DispatchThreadID) {}然后創建一個GetUV?函數,將線程標識作為它的參數并返回float2型的UV坐標。我們可以使用Graph?中循環點時同樣的邏輯。
float _Step;float2 GetUV (uint3 id) {return (id.xy + 0.5) * _Step - 1.0; }1.7 設置位置(Setting Positions)
?為了存儲位置我們需要訪問位置緩沖區。在HLSL 中,一個計算緩沖區被看作是一個結構體緩沖區。因為我們需要對其進行讀寫,所以添加一個RWStructuredBuffer. 字段,命名為_Positions
RWStructuredBuffer _Positions;float _Step;在這個例子中我們需要列出緩沖區元素的類型。位置是float3?值,我們直接寫在RWStructuredBuffer?后面,并用尖括號括起來。
RWStructuredBuffer<float3> _Positions;為了存儲點的位置,我們需要基于線程標識為其分配一個索引。我們需要知道圖像的分辨率。所以添加一個_Resolution?屬性,類型是uint?以匹配標識的類型。
RWStructuredBuffer<float3> _Positions;uint _Resolution;float _Step;然后創建一個SetPosition?函數以設置點,傳遞它一個標識和要設置的點。我們將使用標識的x 部分加上y部分乘以分辨率作為索引。這樣我們就將2D數據按順序存儲到了1D數組中。
float2 GetUV (uint3 id) {return (id.xy + 0.5) * _Step - 1.0; }void SetPosition (uint3 id, float3 position) {_Positions[id.x + id.y * _Resolution] = position; }? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Position indices for 3×3 grid.
?需要注意的是我們組的每個計算都是8*8個點的網格。如果圖形的分辨率不是8的倍數,我們最后將得到一行和一列的一些點的計算是越界的。這表明那些點會在緩沖區之外或與有效的索引沖突,而這會破壞我們的數據。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Going out of bounds.
可以通過限制標識的X 和 Y 小于分辨率來避免非法的位置
void SetPosition (uint3 id, float3 position) {if (id.x < _Resolution && id.y < _Resolution) {_Positions[id.x + id.y * _Resolution] = position;} }1.8 Wave 功能(Wave Function)
我們現在可以通過FunctionKernel?獲得UV坐標,并通過我們創建的函數設置位置。先從設置位置為0開始
[numthreads(8, 8, 1)] void FunctionKernel (uint3 id: SV_DispatchThreadID) {float2 uv = GetUV(id);SetPosition(id, 0.0); }我們首先只支持Wave 功能,也就是我們庫里最簡單的功能。為了讓其動起來我們需要知道時間,所以添加一個_Time?屬性。
float _Step, _Time;然后從FunctionLibrary?類復制?Wave?函數,將其插到FunctionKernel 之上。為了將其轉換為 HLSL 函數,移除public?static?修飾符,用float3?替換Vector3?,用sin. 替換 Sin。
float3 Wave (float u, float v, float t) {float3 p;p.x = u;p.y = sin(PI * (u + v + t));p.z = v;return p; }最后還缺少定義是PI。我們將添加一個宏來定義它。添加#define PI?然后在其后面加上數字,我們使用3.14159265358979323846. 這比一個float?值要精確很多。
#define PI 3.14159265358979323846float3 Wave (float u, float v, float t) { … }現在我們用 Wave 函數來計算位置。
void FunctionKernel (uint3 id: SV_DispatchThreadID) {float2 uv = GetUV(id);SetPosition(id, Wave(uv.x, uv.y, _Time)); }1.9 分發一個計算著色器核心(Dispatching a Compute Shader Kernel)
我們現在有一個計算并存儲我們圖形點位置的核心函數,下一步將其在GPU上運行。為此GPUGraph?需要訪問計算著色器,添加一個序列化字段ComputeShader?然后和我們的資源關聯起來
[SerializeField]ComputeShader computeShader;? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Compute shader assigned.
?我們需要設置一些計算著色器的屬性。為此我們需要知道他們在Unity中的標識。它們都是整型,可以通過調用Shader.PropertyToID?并傳入一個字符串獲得。這些標識是按需聲明的,并在app或編輯器運行時保持不變,所以我們可以直接用靜態字段存儲它們。先從_Positions 屬性開始。
static int positionsId = Shader.PropertyToID("_Positions");我們永不會修改這些字段,所以我們可以為其添加readonly?修飾符。
static readonly int positionsId = Shader.PropertyToID("_Positions");存儲?_Resolution,?_Step, 和_Time? 的標識符
static readonly intpositionsId = Shader.PropertyToID("_Positions"),resolutionId = Shader.PropertyToID("_Resolution"),stepId = Shader.PropertyToID("_Step"),timeId = Shader.PropertyToID("_Time");接下來,創建一個UpdateFunctionOnGPU?函數計算步長并設置分辨率、步長和時間屬性。通過調用SetInt?和?SetFloat?來實現。
void UpdateFunctionOnGPU () {float step = 2f / resolution;computeShader.SetInt(resolutionId, resolution);computeShader.SetFloat(stepId, step);computeShader.SetFloat(timeId, Time.time);}我們還需要設置位置緩沖區,它并不復制任何數據而是將緩沖區鏈接到核心(kernel)。通過調用?SetBuffer實現,它和其他函數很像只是多了一個參數。它的第一個參數是核心函數的索引,因為一個計算著色器可以包含多個核心,緩沖區可以被鏈接到特定的一個。我們可以通過調用計算著色器上的FindKernel?來獲得核心索引,但我們只有一個核心,它的索引會總是0,所以我們可以直接使用這個值。
computeShader.SetFloat(timeId, Time.time);computeShader.SetBuffer(0, positionsId, positionsBuffer);設置完緩沖區后我們可以運行我們的核心,調用計算著色器上的有四個參數的Dispatch?函數。第一個是核心的索引,另外三個是運行的組的數量。所有維度都使用1,意思就是只有第一個有8x8位置的組被計算。
computeShader.SetBuffer(0, positionsId, positionsBuffer);computeShader.Dispatch(0, 1, 1, 1);因為我們的組是8X8的尺寸,我們需要X和Y等于分辨率除以8,向上取整。
int groups = Mathf.CeilToInt(resolution / 8f);computeShader.Dispatch(0, groups, groups, 1);最后在Update中調用UpdateFunctionOnGPU?來運行我們的核心。
void Update () {…UpdateFunctionOnGPU();}現在,在play模式下,我們已經每幀在計算所有圖像的位置,即使我們沒有注意到這點并且沒有對數據做任何事。?
2 程序化繪制(Procedural Drawing)
下一步是繪制這些點,且不必從CPU傳遞任何變換矩陣到GPU。因此著色器需要從緩沖區獲得正確的位置而不是標準矩陣。
2.1 繪制許多網格(Drawing Many Meshes)
因為那些點已經在GPU上存在,我們不需要在CPU端記錄它們。我們甚至不需要為他們建游戲對象。相反,我們需要通過一個命令通知GPU用特定的材質繪制特定的網格。為了能配置繪制些什么,在GPUGraph. 中添加序列化字段?Material?和?Mesh?。我們先用我們已有的Point Surface?材質來繪制DRP點。網格我們用默認的方塊。
[SerializeField]Material material;[SerializeField]Mesh mesh;? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Material and mesh configured.
?我們通過調用?Graphics.DrawMeshInstancedProcedural?來實現程序化繪制,并傳入網格,子網格索引和材質作為參數。子網格索引是為一個網格包含多個部分時準備的,我們例子沒有這種情況所以為0,。
void UpdateFunctionOnGPU () {…Graphics.DrawMeshInstancedProcedural(mesh, 0, material);}由于這種方式不使用游戲對象,Unity不知道在場景中哪里繪制。我們需要添加一個參數指定邊界。這是一個軸對其框,只是我們正在繪制內容的邊界。Unity使用這個來決定是否跳過這個繪制,因為它可能在攝像機視域之外。這被稱為視錐剔除。現在是評估一次整個圖形邊界而不是單個點。這對我們的圖形沒有影響,因為我們想要完整的看到它。
我們的圖形坐落于遠點,它的點仍應該在2以內。我們可以用Vector3.zero?和??Vector3.one乘以2來調用Bounds?構造函數以創建一個邊界值。
var bounds = new Bounds(Vector3.zero, Vector3.one * 2f);Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds);但是點也有尺寸,它的一半會在邊界之外。所以我們同樣需要增加邊界。
var bounds = new Bounds(Vector3.zero, Vector3.one * (2f + 2f / resolution));最后一個要傳遞給DrawMeshInstancedProcedural?的參數是要繪制多少實例。這需要和位置緩沖區的元素個數匹配,我們可以通過它的count?屬性獲得。
Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds, positionsBuffer.count);? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Overlapping unit cubes.
?進入play模式后,我們可以看到一個彩色的單位方塊坐落于原點。每個點渲染一次相同的方塊,但都具有單位變換矩陣,所以他們會重疊。現在性能比之前好很多,因為幾乎沒有數據要被拷貝到GPU,并且所有的點只用一個draw call 繪制。Unity也不需做任何裁剪。也不必根據它們的視空間深度來為他們排序,通常離攝像機最近的點最先被繪制。深度排序對不透明物體的渲染非常有效,因為這可以避免overdraw,但是我們的程序化繪制命令僅僅一個接一個的繪制那些點。然而,消除的CPU工作和數據傳輸,加上GPU全速渲染所有立方體的能力足以彌補這一點。
2.2 獲取位置(Retrieving the Positions)
為了獲取我們存在GPU的點的位置,我們需要創建一個新著色器,先重DRP開始。復制Point Surface?著色器并重命名為Point Surface GPU。同樣修改其菜單標簽。由于我們現在基于一個由計算著色器填充的結構緩沖區,提升著色器的目標級別為4.5。
Shader "Graph/Point Surface GPU" {Properties {_Smoothness ("Smoothness", Range(0,1)) = 0.5}SubShader {CGPROGRAM#pragma surface ConfigureSurface Standard fullforwardshadows#pragma target 4.5…ENDCG}FallBack "Diffuse" }程序化渲染的工作類似于GPU instancing,但是我們需要通過#pragma instancing_options?指令指定一個額外的選項。
#pragma surface ConfigureSurface Standard fullforwardshadows#pragma instancing_options procedural:ConfigureProcedural這表明表面著色器需要為每個點調用ConfigureProcedural?函數。它是一個沒有任何參數,返回空的函數。
void ConfigureProcedural () {}void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {surface.Albedo = saturate(input.worldPos * 0.5 + 0.5);surface.Smoothness = _Smoothness;}默認情況下,只有常規繪制通道(regular draw pass)會調用這個函數。為了在渲染陰影時也調用,我們需要指定一個陰影通道,通過在#pragma surface?指令后添加addshadow?
#pragma surface ConfigureSurface Standard fullforwardshadows addshadow現在添加一個和計算著色器一樣的位置緩沖區。因為這次我們只需要讀取它所以用StructuredBuffer替換RWStructuredBuffer
StructuredBuffer<float3> _Positions;void ConfigureProcedural () {}但是我們應該只對為程序化繪制專門編譯的著色器變體執行此操作。這是定義了UNITY_PROCEDURAL_INSTANCING_ENABLED?宏時的情況。我們可以通過#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)檢查是否定義。這是一個預處理指令,它使編譯器只在標簽被定義時才包含后面的代碼,直到遇到#endif?指令。
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)StructuredBuffer<float3> _Positions;#endif我們還要為ConfigureProcedural?做同樣的事。
void ConfigureProcedural () {#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)#endif}現在我們可以通過位置緩沖區的索引,也就是當前正在被繪制的實例的標識符,來得到點的位置。我們通過unity_InstanceID 得到標識符,他可以被全局訪問。
void ConfigureProcedural () {#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)float3 position = _Positions[unity_InstanceID];#endif}2.3 創建變換矩陣(Creating a Transformation Matrix)
我們有了位置之后,下一步就是創建一個object-to-world 變換矩陣。為簡單起見,我們將圖形放在世界坐標原點,沒有旋轉和縮放。調整GPU Graph?游戲對象的Transform?不會有任何效果,所以我們不會對他做任何操作。
我們只操作點的位置和縮放。位置被存儲在4 x 4 變換矩陣的最后一列,縮放存儲在矩陣的對角線。矩陣的最后一位是1,其他都是0。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Transformation matrix with position and scale.
?變換矩陣是為了將頂點從對象空間轉換到世界空間。它由unity_ObjectToWorld 提供。因為我們是程序化繪制,它是一個單位矩陣,所以我們需要重置他。先把它設為0
float3 position = _Positions[unity_InstanceID];unity_ObjectToWorld = 0.0;我們可以通過float4(position,?1.0). 構建一個列向量。我們可以通過unity_ObjectToWorld._m03_m13_m23_m33.將其設為矩陣的第四列。
unity_ObjectToWorld = 0.0;unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);然后添加一個float?_Step?屬性并將其賦值給unity_ObjectToWorld._m00_m11_m22. 這是用來縮放的。
float _Step;void ConfigureProcedural () {#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)float3 position = _Positions[unity_InstanceID];unity_ObjectToWorld = 0.0;unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);unity_ObjectToWorld._m00_m11_m22 = _Step;#endif}還有一個unity_WorldToObject?矩陣,用來變換法線向量。它是用來矯正方向向量的轉換,但是我們不需要所以我們可以忽略它。我們通過添加assumeuniformscaling 來通知我們的著色器。
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural現在為我們的著色器創建一個材質,勾選GPU instancing,并賦值給我們的GPU graph
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Using GPU material.
?我們還需要設置一下材質的屬性,就像我們之前設置計算著色器一樣。在UpdateFunctionOnGPU 中,繪制之前調用材質的SetBuffer?和?SetFloat?函數。此時我們不需要提供核心的索引。
material.SetBuffer(positionsId, positionsBuffer);material.SetFloat(stepId, step);var bounds = new Bounds(Vector3.zero, new Vector3(2f + 2f / resolution));Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds, positionsBuffer.count);? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?40,000 shadowed cubes, drawn with DRP.
?我們進入play模式時,我們再次看見了我們的圖像,但是現在有40 000個點被渲染并保持60FPS。如果我關閉VSync 它可以達到245FPS。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Profiling a DRP build with VSync.
?2.4 百萬(Going for a Million)
既然40 000個點表現的這么好,我們看一下是否可以處理百萬個點。但在這之前我們需要知道異步著色器編譯。這是Unity 編輯器的特色,而非構建(builds)的。編輯器只在需要時編譯著色器,這可以節省很多編譯時間,但也意味著著色器并不總是立即有效。當這種情發生時,會臨時使用一個統一的藍綠色著色器直到著色器被編譯完成。這通常還好,但是這個臨時著色器不支持程序化繪制。這會顯著的減緩繪制過程,如果要渲染百萬個點很可能會使Unity崩潰,甚至整個機器也可能崩潰。
我們可以通過設置關閉異步著色器編譯,但只有我們的Point Surface GPU?才有這個問題。幸運的人是我們可以通過添加#pragma editor_sync_compilation?指令通知Unity為某個著色器使用同步編譯。
#pragma surface ConfigureSurface Standard fullforwardshadows addshadow#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural#pragma editor_sync_compilation#pragma target 4.5現在我們可以將分辨率限制增加到1000了
[SerializeField, Range(10, 1000)]int resolution = 10;?
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Resolution set to 1,000.
?在小窗口中它看起來并不漂亮,因為點太小會出現摩爾紋,但它確實運行了。我這里渲染百萬個點是24FPS。在編輯器和構建中性能是一樣的。此時編輯器開支是無關緊要的,GPU才是瓶頸。并且,是否打開VSync 也并沒有明顯的不同。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Profiling a build rendering a million points, no VSync.
?當VSync 被關閉時,可以看出多數player loop時間花費在等待GPU完工。
注意我們現在是渲染一百萬個有陰影的點,對于DPR需要每幀渲染他們三次。關閉陰影和VSync 后幀率會上升到65FPS。
2.5 URP
為了看下URP的表現,我們需要復制我們的?Point URP?shader graph,重命名為Point URP GPU?。Shader graph 并不直接支持程序化繪制,但我們可以一些自定義代碼使其支持。為了簡單化和重用性我們創建一個HLSL文件資源。Unity沒有這個選項,所以復制一個表面著色器然后重命名為PointGPU。然后修改擴展名為hlsl.
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?PointGPU HLSL script asset.
?清空文件內容,讓后復制一下內容
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)StructuredBuffer<float3> _Positions; #endiffloat _Step;void ConfigureProcedural () {#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)float3 position = _Positions[unity_InstanceID];unity_ObjectToWorld = 0.0;unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);unity_ObjectToWorld._m00_m11_m22 = _Step;#endif }?我們現在可以通過?#include "PointGPU.hlsl"?指令在Point Surface GPU? 著色器中包含這個文件,原來的代碼可以被移除。
#include "PointGPU.hlsl"struct Input {float3 worldPos;};float _Smoothness;//#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)// StructuredBuffer<float3> _Positions;//#endif//float2 _Scale;//void ConfigureProcedural () { … }void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) { … }?我們將在shader graph 中使用一個Custom Function?節點來包含HLSL文件。那個節點會調用一個文件里的函數。我們給PointGPU?添加一個簡單的函數,僅僅傳入一個float3?值并將其返回。
給PointGPU?添加一個有兩個float3?參數的void?ShaderGraphFunction_float?函數,僅僅將輸入賦值給輸出。
void ShaderGraphFunction_float (float3 In, float3 Out) {Out = In; }這里假定了Out?參數是一個輸出參數,我們需要在其前面添加?out?
void ShaderGraphFunction_float (float3 In, out float3 Out) {Out = In; }函數名后面的_float?后綴是必要的的,因為它指定了函數的精度。Shader graph 提供了兩種精度,float?和?half. 后者是前者的一半。節點的精度可以被明確的選擇或設為繼承,繼承也是默認值。為了支持兩種精度,使用half 添加一個變體函數。
void ShaderGraphFunction_float (float3 In, out float3 Out) {Out = In; }void ShaderGraphFunction_half (half3 In, out half3 Out) {Out = In; }現在給Point URP GPU?添加一個Custom Function?節點。它的Type?默認是File?。將PointGPU?賦值給它的Source?屬性。Name?設為ShaderGraphFunction?,不要后綴。然后在Inputs?中添加?In?,Outputs?中添加Out?,都是Vector3. 類型
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Custom function via file.
添加一個Position?節點,設為對象空間,并將其與我們的自定義節點的輸入鏈接起來。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Object-space vertex position passed through our function.
現在對象空間的頂點位置就傳入了我們的函數,并且我們的代碼也被包含進了著色器。但是為了程序化渲染,我們還需要包含#pragma instancing_options?和#pragma editor_sync_compilation?。它們必須被直接注入生成的著色器源代碼,不能通過文件引用。所以添加一個?Custom Function?節點,輸入和輸出與之前一樣,但是Type?設為String. 。Name?設一個適當的值,比如InjectPragmas,然后將指令寫入Body?文本框。body就像一個函數代碼塊一樣,所以這里我們還需要將輸入賦值給輸出。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Custom function via string injecting pragmas.
?為了看得更清晰,下面是body的代碼
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural #pragma editor_sync_compilationOut = In;將頂點位置傳遞給這個節點。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Shader graph with pragmas
?創建一個材質,啟用instancing,使用Point URP GPU?著色器,將其賦值給我們的graph,然后進入play模式。我這里達到了36FPS,在開啟陰影的情況下,比DRP快了50%
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Profiling URP build.
2.6 可變的分辨率(Variable Resolution)
因為我們總是為緩沖區的每個位置繪制點,降低分辨率會使一些點固定在那里。這是因為計算著色器值更新符合圖形的點。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Stuck points after lowering resolution.
?計算著色器不能調整大小。我們可以每次改變分辨率時創建一個新的,但簡單的方法是總是申請一個最大分辨率的的緩沖區。這樣天生的就可以改變分辨率。
我們將最大分辨率設為一個常量,然后在resolution 字段的?Range?屬性里使用它
const int maxResolution = 1000;…[SerializeField, Range(10, maxResolution)]int resolution = 10;接下來,使用最大分辨率創建緩沖區。
void OnEnable () {positionsBuffer = new ComputeBuffer(maxResolution * maxResolution, 3 * 4);}最后,使用當前分辨率的平方替換緩沖區元素數
void UpdateFunctionOnGPU () {…Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds, resolution * resolution);}3 GPU函數庫(GPU Function Library)
現在我們基于GPU的方式是功能化的,讓我們將我們整個函數庫轉到我們的計算著色器。
3.1 所有函數(ll Functions)
我們可以像拷貝修改Wave 一樣拷貝其他函數。第二個是MultiWave。它與Wave唯一較大的不同是它包含了float?值。在HLSL中沒有 f 后綴,所以將其全部移除。為了指明他們是浮點數,我給他們加了一個點,比如2f 變成了 2.0
float3 MultiWave (float u, float v, float t) {float3 p;p.x = u;p.y = sin(PI * (u + 0.5 * t));p.y += 0.5 * sin(2.0 * PI * (v + t));p.y += sin(PI * (u + v + 0.25 * t));p.y *= 1.0 / 2.5;p.z = v;return p; }同樣修改其他函數,Sqrt?改為sqrt?,Cos?改為cos.
float3 Ripple (float u, float v, float t) {float d = sqrt(u * u + v * v);float3 p;p.x = u;p.y = sin(PI * (4.0 * d - t));p.y /= 1.0 + 10.0 * d;p.z = v;return p; }float3 Sphere (float u, float v, float t) {float r = 0.9 + 0.1 * sin(PI * (6.0 * u + 4.0 * v + t));float s = r * cos(0.5 * PI * v);float3 p;p.x = s * sin(PI * u);p.y = r * sin(0.5 * PI * v);p.z = s * cos(PI * u);return p; }float3 Torus (float u, float v, float t) {float r1 = 0.7 + 0.1 * sin(PI * (6.0 * u + 0.5 * t));float r2 = 0.15 + 0.05 * sin(PI * (8.0 * u + 4.0 * v + 2.0 * t));float s = r2 * cos(PI * v) + r1;float3 p;p.x = s * sin(PI * u);p.y = r2 * sin(PI * v);p.z = s * cos(PI * u);return p; }3.2 宏(Macros)
我們現在要為每個圖形函數創建一個獨立的核心函數,但那會有非常多的重復代碼。我們可以通過創建一個宏避免這些。在FunctionKernel?函數上面寫上#define KERNEL_FUNCTION?
#define KERNEL_FUNCTION[numthreads(8, 8, 1)]void FunctionKernel (uint3 id: SV_DispatchThreadID) { … }這些定義通常只適用于寫在它后面的,同一行的東西,但是我們可以通過給除了最后一行之外的每一行后面添加 \ 反斜杠來擴展到多行。
#define KERNEL_FUNCTION \[numthreads(8, 8, 1)] \void FunctionKernel (uint3 id: SV_DispatchThreadID) { \float2 uv = GetUV(id); \SetPosition(id, Wave(uv.x, uv.y, _Time)); \}現在,當我們寫KERNEL_FUNCTION?時,編譯器會用FunctionKernel函數代碼將其替換。為了讓其有函數功能,我們給宏添加一個參數。這就像函數的參數一樣,但是沒有類型并且圓括號緊跟著宏名字。給它一個?function?參數并用其代替Wave.
#define KERNEL_FUNCTION(function) \[numthreads(8, 8, 1)] \void FunctionKernel (uint3 id: SV_DispatchThreadID) { \float2 uv = GetUV(id); \SetPosition(id, function(uv.x, uv.y, _Time)); \}我們還需要修改核心函數的名字。我們使用function?作為前綴,后面跟著Kernel 。我們需要將function?標簽分離出,否則它不能被識別為著色器參數。為此我們使用?##?宏鏈接操作符將兩個字組合起來。
void function##Kernel (uint3 id: SV_DispatchThreadID) { \現在所有五個函數都可以用KERNEL_FUNCTION?來定義了。
#define KERNEL_FUNCTION(function) \…KERNEL_FUNCTION(Wave) KERNEL_FUNCTION(MultiWave) KERNEL_FUNCTION(Ripple) KERNEL_FUNCTION(Sphere) KERNEL_FUNCTION(Torus)我們還需要為每個函數替換我們的kernel指令
#pragma kernel WaveKernel #pragma kernel MultiWaveKernel #pragma kernel RippleKernel #pragma kernel SphereKernel #pragma kernel TorusKernel最后一步是在?GPUGraph.UpdateFunctionOnGPU?中使用當前函數作為kernel索引來替換之前的0
var kernelIndex = (int)function;computeShader.SetBuffer(kernelIndex, positionsId, positionsBuffer);int groups = Mathf.CeilToInt(resolution / 8f);computeShader.Dispatch(kernelIndex, groups, groups, 1);? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?All functions at resolution 1,000, with plane to show shadows.
計算著色器非常快所以每個函數的幀率都差不多。
3.3 變形(Morphing Functions)
支持從一個函數變形到另一個函數有點復雜,因為每個變形都需要有一個單獨的核心函數。先添加一個轉換進程屬性,后面的融合函數會用到。
float _Step, _Time, _TransitionProgress;復制那個核心宏,重命名為KERNEL_MOPH_FUNCTION, 給它添加兩個參數:functionA?和functionB.將函數名改為?functionA##To##functionB##Kernel?,然后使用lerp??來對計算的點線性插值。
#define KERNEL_MOPH_FUNCTION(functionA, functionB) \[numthreads(8, 8, 1)] \void functionA##To##functionB##Kernel (uint3 id: SV_DispatchThreadID) { \float2 uv = GetUV(id); \float3 position = lerp( \functionA(uv.x, uv.y, _Time), functionB(uv.x, uv.y, _Time), \_TransitionProgress \); \SetPosition(id, position); \}每個函數都可以轉換為其他,所以每個函數有四個轉換。為他們添加核心函數
KERNEL_FUNCTION(Wave) KERNEL_FUNCTION(MultiWave) KERNEL_FUNCTION(Ripple) KERNEL_FUNCTION(Sphere) KERNEL_FUNCTION(Torus)KERNEL_MOPH_FUNCTION(Wave, MultiWave); KERNEL_MOPH_FUNCTION(Wave, Ripple); KERNEL_MOPH_FUNCTION(Wave, Sphere); KERNEL_MOPH_FUNCTION(Wave, Torus);KERNEL_MOPH_FUNCTION(MultiWave, Wave); KERNEL_MOPH_FUNCTION(MultiWave, Ripple); KERNEL_MOPH_FUNCTION(MultiWave, Sphere); KERNEL_MOPH_FUNCTION(MultiWave, Torus);KERNEL_MOPH_FUNCTION(Ripple, Wave); KERNEL_MOPH_FUNCTION(Ripple, MultiWave); KERNEL_MOPH_FUNCTION(Ripple, Sphere); KERNEL_MOPH_FUNCTION(Ripple, Torus);KERNEL_MOPH_FUNCTION(Sphere, Wave); KERNEL_MOPH_FUNCTION(Sphere, MultiWave); KERNEL_MOPH_FUNCTION(Sphere, Ripple); KERNEL_MOPH_FUNCTION(Sphere, Torus);KERNEL_MOPH_FUNCTION(Torus, Wave); KERNEL_MOPH_FUNCTION(Torus, MultiWave); KERNEL_MOPH_FUNCTION(Torus, Ripple); KERNEL_MOPH_FUNCTION(Torus, Sphere);我們添加核心以使他們的索引等于functionB + functionA *?5,?
#pragma kernel WaveKernel #pragma kernel WaveToMultiWaveKernel #pragma kernel WaveToRippleKernel #pragma kernel WaveToSphereKernel #pragma kernel WaveToTorusKernel#pragma kernel MultiWaveToWaveKernel #pragma kernel MultiWaveKernel #pragma kernel MultiWaveToRippleKernel #pragma kernel MultiWaveToSphereKernel #pragma kernel MultiWaveToTorusKernel#pragma kernel RippleToWaveKernel #pragma kernel RippleToMultiWaveKernel #pragma kernel RippleKernel #pragma kernel RippleToSphereKernel #pragma kernel RippleToTorusKernel#pragma kernel SphereToWaveKernel #pragma kernel SphereToMultiWaveKernel #pragma kernel SphereToRippleKernel #pragma kernel SphereKernel #pragma kernel SphereToTorusKernel#pragma kernel TorusToWaveKernel #pragma kernel TorusToMultiWaveKernel #pragma kernel TorusToRippleKernel #pragma kernel TorusToSphereKernel #pragma kernel TorusKernel?回到GPUGraph, 添加轉換進程屬性的標識
static readonly int…timeId = Shader.PropertyToID("_Time"),transitionProgressId = Shader.PropertyToID("_TransitionProgress");如果正在轉換,在UpdateFunctionOnGPU 中使用它。
computeShader.SetFloat(timeId, Time.time);if (transitioning) {computeShader.SetFloat(transitionProgressId,Mathf.SmoothStep(0f, 1f, duration / transitionDuration));}為了選擇正確的索引,如果在轉換增加?transition function 乘以5,否則增加自身乘以5
var kernelIndex =(int)function + (int)(transitioning ? transitionFunction : function) * 5;? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??Continuous random morphing.
?添加的轉換對幀率沒有影響。很明顯渲染才是瓶頸,計算不是。
3.4 函數數量屬性(Function Count Property)
為了計算核心索引,GPUGraph?需要知道有多少個函數。我們可以給FunctionLibrary?添加GetFunctionCount?函數來得到個數。這樣做的好處是,如果我們添加或移除函數,只需要修改哪兩個FunctionLibrary?文件。
public static int GetFunctionCount () {return 5;}我們甚至可以移除常量,返回functions?數組的長度,更加減少了我們需要修改的代碼。
public static int GetFunctionCount () {return functions.Length;}將函數數量改為屬性也是一個好方法。
public static int FunctionCount {get {return functions.Length;}}這就定義了一個getter屬性。由于它唯一要做的就是返回一個值,我們可以將其簡化為?get?=> functions.Length;.
public static int FunctionCount {get => functions.Length;}因為沒有set?塊,我們可以將其更簡化為省略get. 這樣它就減為只有一行。
public static int FunctionCount => functions.Length;?GetFunction?和?GetNextFunctionName.也使用這種方法
public static Function GetFunction (FunctionName name) => functions[(int)name];public static FunctionName GetNextFunctionName (FunctionName name) =>(int)name < functions.Length - 1 ? name + 1 : 0;在GPUGraph.UpdateFunctionOnGPU.中使用新屬性替換常量
var kernelIndex =(int)function +(int)(transitioning ? transitionFunction : function) *FunctionLibrary.FunctionCount;3.5 更多細節 (More Details)
由于分辨率的增加,我們的圖像可以更加詳細。比如,我們可以雙倍Sphere.扭曲的頻率。
float3 Sphere (float u, float v, float t) {float r = 0.9 + 0.1 * sin(PI * (12.0 * u + 8.0 * v + t));… }? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??More detailed sphere.
? 同樣還有Torus. 的星星樣式。
float3 Torus (float u, float v, float t) {float r1 = 0.7 + 0.1 * sin(PI * (8.0 * u + 0.5 * t));float r2 = 0.15 + 0.05 * sin(PI * (16.0 * u + 8.0 * v + 3.0 * t));… }? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??More detailed torus.
總結
以上是生活随笔為你收集整理的计算着色器(Compute Shaders)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 禁用BAMBOOK S1的home键
- 下一篇: 干货!一分钟精通常用光纤知识