跳轉到內容

Cg 程式設計/Unity/計算影像效果

來自華夏公益教科書,開放的書籍,開放的世界
應用於影片影像的後處理效果。

本教程介紹了在 Unity 中為相機檢視的影像後處理建立最小計算著色器的基本步驟。如果您不熟悉 Unity 中的影像效果,您應該先閱讀“最小影像效果”部分。請注意,計算著色器在 macOS 上不受支援。

Unity 中的計算著色器

[編輯 | 編輯原始碼]

從某些方面來說,計算著色器類似於片段著色器,有時將它們視為“改進”的片段著色器是有幫助的,因為計算著色器解決了片段著色器的一些問題

  • 片段著色器是圖形管線的一部分,這使得將它們用於其他用途(尤其是GPGPU 程式設計(通用圖形處理單元上的通用計算))變得很麻煩。
  • 片段著色器是為極度並行問題而設計的,該問題是對三角形(和其他幾何基元)的片段進行光柵化。因此,它們不適合不那麼極度並行的問題,例如,當著色器必須在它們之間共享或通訊資料或需要寫入記憶體中的任意位置時。
  • 執行片段著色器的圖形硬體提供了更高階並行程式設計的功能,但認為在片段著色器中提供這些功能是不明智的;因此,認為需要不同的應用程式程式設計介面 (API)。

歷史上,解決片段著色器這些缺點的第一種方法是引入全新的 API,例如 CUDAOpenCL 等。雖然其中一些 API 仍然非常流行用於 GPGPU 程式設計,但由於幾個原因,它們在圖形任務(例如影像處理)方面並不那麼流行。(一個原因是使用兩個 API(計算和圖形)對同一硬體的開銷;另一個原因是在計算 API 和圖形 API 之間通訊資料的困難。)

由於單獨的計算 API 存在問題,計算著色器在圖形 API(特別是 Direct3D 11、OpenGL 4.3 和 OpenGL ES 3.1)中作為另一類著色器被引入。這也是Unity 支援的內容。

在本教程中,我們研究如何在 Unity 中使用計算著色器進行影像處理,以介紹計算著色器的基本概念,以及將計算著色器用於影像處理的具體問題,這是一個重要的應用領域。後續教程將討論計算著色器的更多高階功能以及影像處理以外的應用。

建立計算著色器

[編輯 | 編輯原始碼]

在 Unity 中建立計算著色器並不複雜,與建立任何著色器非常相似:在“專案視窗”中,單擊“建立”,然後選擇“著色器 > 計算著色器”。一個名為“NewComputeShader”的新檔案應該出現在專案視窗中。雙擊它開啟它(或右鍵單擊並選擇“開啟”)。一個使用預設著色器的文字編輯器將出現在DirectX 11 HLSL 中。(DirectX 11 HLSL 與 Cg 不同,但它共享許多常見的語法特徵。)

以下計算著色器可用於使用使用者指定的顏色為影像著色。您可以將其複製並貼上到著色器檔案中

#pragma kernel TintMain

float4 Color;

Texture2D<float4> Source;
RWTexture2D<float4> Destination;

[numthreads(8,8,1)]
void TintMain (uint3 groupID : SV_GroupID, 
      // ID of thread group; range depends on Dispatch call
   uint3 groupThreadID : SV_GroupThreadID, 
      // ID of thread in a thread group; range depends on numthreads
   uint groupIndex : SV_GroupIndex, 
      // flattened/linearized GroupThreadID between 0 and 
      // numthreads.x * numthreads.y * numthreadz.z - 1 
   uint3 id : SV_DispatchThreadID) 
      // = GroupID * numthreads + GroupThreadID
{
   Destination[id.xy] = Source[id.xy] * Color;
}

讓我們逐行了解此著色器:#pragma kernel TintMain(特定於 Unity)行定義了計算著色器函式;這與片段著色器中的#pragma fragment ... 非常相似。

float4 Color; 行定義了一個在指令碼中設定的統一變數,如下所述。這就像片段著色器中的統一變數一樣。在這種情況下,Color 用於為影像著色。

Texture2D<float4> Source; 行定義了一個具有四個浮點分量的 2D 紋理,這樣計算著色器可以讀取它(無需插值)。在片段著色器中,您將使用sampler2D Source; 來取樣 2D 紋理(具有插值)。(請注意,HLSL 使用單獨的紋理物件和取樣器物件;請參閱Unity 的手冊,瞭解如何為給定紋理物件定義取樣器物件,如果您想使用函式SampleLevel() 在計算著色器中使用插值取樣 2D 紋理,則需要這樣做。)

RWTexture2D<float4> Destination; 指定了一個讀寫 2D 紋理,計算著色器可以從中讀取和寫入。這對應於 Unity 中的渲染紋理。計算著色器可以寫入RWTexture2D 中的任何位置,而片段著色器通常只能寫入其片段的位置。但是請注意,計算著色器的多個執行緒(即計算著色器函式的呼叫)可能會以未定義的順序寫入RWTexture2D 中的同一位置,從而導致未定義的結果,除非採取特殊措施來避免這些問題。在本教程中,我們透過讓每個執行緒僅寫入RWTexture2D 中它自己的唯一位置來避免任何這些問題。

下一行是[numthreads(8,8,1)]。這是計算著色器的專用行,它定義了執行緒組的尺寸。執行緒組是一組並行執行的計算著色器函式呼叫,因此它們的執行可以同步,即可以指定屏障(使用諸如GroupMemoryBarrierWithGroupSync() 之類的函式),所有執行緒都必須到達屏障,然後任何執行緒才能繼續執行。執行緒組的另一個特點是,一個執行緒組中的所有執行緒都可以共享一些特別快的(“groupshared”)記憶體,而不同組中的執行緒可能共享的記憶體通常比較慢。

執行緒按執行緒組的 3D 陣列組織,每個執行緒組本身也是一個 3D 陣列,其三個維度由numthreads 的三個引數指定。對於影像處理任務,第三個 (z) 維度通常為 1,如我們的示例[numthreads(8,8,1)] 中所示。維度 (8,8,1) 指定每個執行緒組包含 8 × 8 × 1 = 64 個執行緒。(有關說明,請參閱Microsoft 對 numthreads 的文件。)這些數字存在一定的平臺特定限制,例如,對於 Direct3D 11,x 和 y 維度必須小於或等於 1024,z 維度必須小於或等於 64,三個維度的乘積(即執行緒組的大小)必須小於或等於 1024。另一方面,為了獲得最佳效率,執行緒組應該具有約 32 的最小大小(取決於硬體)。

如下所述,計算著色器在指令碼中使用函式ComputeShader.Dispatch(int kernelIndex, int threadGroupsX, int threadGroupsY, int threadGroupsZ) 呼叫,其中kernelIndex 指定計算著色器函式,其他引數指定執行緒組 3D 陣列的尺寸。對於我們的[numthreads(8,8,1)] 示例,每個組中有 64 個執行緒,因此執行緒總數將為64 * threadGroupsX * threadGroupsY * threadGroupsZ

程式碼的其餘部分指定了計算著色器函式void TintMain()。通常,對於計算著色器函式來說,瞭解它是為 3D 執行緒陣列中的哪個位置呼叫的是很重要的。瞭解執行緒組線上程組 3D 陣列中的位置以及執行緒線上程組中的位置也很重要。HLSL 提供了以下語義來提供此資訊

  • SV_GroupID:一個uint3 向量,它指定執行緒組的 3D ID;ID 的每個座標從 0 開始,到(但不包括)ComputeShader.Dispatch() 呼叫中指定的維度為止。
  • SV_GroupThreadID:一個uint3 向量,它指定執行緒組內執行緒的 3D ID;ID 的每個座標從 0 開始,到(但不包括)numthreads 行中指定的維度為止。
  • SV_GroupIndex:一個uint,它指定 0 到 numthreads.x * numthreads.y * numthreadz.z - 1 之間的扁平化/線性化SV_GroupThreadID
  • SV_DispatchThreadID:一個uint3 向量,它指定執行緒在整個執行緒組陣列中的 3D ID。它等於SV_GroupID * numthreads + SV_GroupThreadID

計算著色器函式可以接收這些值中的任何值,如示例所示:void TintMain (uint3 groupID : SV_GroupID, uint3 groupThreadID : SV_GroupThreadID, uint groupIndex : SV_GroupIndex, uint3 id : SV_DispatchThreadID)

特定的函式TintMain 實際上只使用了帶有SV_DispatchThreadID 語義的變數id。函式呼叫按 2D 陣列組織,該陣列的尺寸至少與DestinationSource 紋理的尺寸相同;因此,id.xid.y 可用於訪問這些紋素Destination[id.xy]Source[id.xy]。基本操作只是將Source 紋理的顏色乘以Color,然後將其寫入Destination 渲染紋理

Destination[id.xy] = Source[id.xy] * Color;

將計算著色器應用於相機檢視

[編輯 | 編輯原始碼]

為了將計算著色器應用於相機檢視的所有畫素,我們需要定義函式 `OnRenderImage(RenderTexture source, RenderTexture destination)` 並在計算著色器中使用這些渲染紋理。然而,存在一些問題,特別是在較新的 Unity 版本中,我們需要將源畫素複製到臨時紋理中,才能在計算著色器中使用它們。此外,如果 Unity 直接渲染到幀緩衝區,`destination` 會被設定為 `null`,我們就沒有渲染紋理可用於計算著色器。另外,我們需要在建立渲染紋理之前啟用它以進行隨機寫入訪問,而我們無法使用在 `OnRenderImage()` 中獲得的渲染紋理來執行此操作。我們可以透過建立一個與 `source` 渲染紋理尺寸相同的臨時渲染紋理,並讓計算著色器寫入該臨時渲染紋理,來處理這些情況(以及 `source` 和 `destination` 渲染紋理尺寸不同的情況)。然後可以將結果複製到 `destination` 渲染紋理中,如果 `destination` 為 `null`,則將結果複製到幀緩衝區。

以下 C# 指令碼使用臨時渲染紋理 `tempDestination` 來實現此想法。

using System;
using UnityEngine;

[RequireComponent(typeof(Camera))]
[ExecuteInEditMode]

public class tintComputeScript : MonoBehaviour {

   public ComputeShader shader;
   public Color color = new Color(1.0f, 1.0f, 1.0f, 1.0f);
   
   private RenderTexture tempSource = null;
      // we need this intermediate render texture to access the data   
   private RenderTexture tempDestination = null;  
      // we need this intermediate render texture for two reasons:
      // 1. destination of OnRenderImage might be null 
      // 2. we cannot set enableRandomWrite on destination
   private int handleTintMain;

   void Start() 
   {
      if (null == shader) 
      {
         Debug.Log("Shader missing.");
         enabled = false;
         return;
      }
      
      handleTintMain = shader.FindKernel("TintMain");
      
      if (handleTintMain < 0)
      {
         Debug.Log("Initialization failed.");
         enabled = false;
         return;
      }  
   }

   void OnDestroy() 
   {
      if (null != tempSource)
      {
         tempSource.Release();
         tempSource = null;
      }
      if (null != tempDestination) {
         tempDestination.Release();
         tempDestination = null;
      }
   }

   void OnRenderImage(RenderTexture source, RenderTexture destination)
   {      
      if (null == shader || handleTintMain < 0 || null == source) 
      {
         Graphics.Blit(source, destination); // just copy
         return;
      }

      // do we need to create a new temporary source texture?
      if (null == tempSource || source.width != tempSource.width
         || source.height != tempSource.height)
      {
         if (null != tempSource)
         {
            tempSource.Release();
         }
         tempSource = new RenderTexture(source.width, source.height,
           source.depth);
         tempSource.Create();
      }

      // copy source pixels
      Graphics.Blit(source, tempSource);
      
      // do we need to create a new temporary destination render texture?
      if (null == tempDestination || source.width != tempDestination.width 
         || source.height != tempDestination.height) 
      {
         if (null != tempDestination)
         {
            tempDestination.Release();
         }
         tempDestination = new RenderTexture(source.width, source.height, 
            source.depth);
         tempDestination.enableRandomWrite = true;
         tempDestination.Create();
      }

      // call the compute shader
      shader.SetTexture(handleTintMain, "Source", tempSource); 
      shader.SetTexture(handleTintMain, "Destination", tempDestination);
      shader.SetVector("Color", (Vector4)color);
      shader.Dispatch(handleTintMain, (tempDestination.width + 7) / 8, 
         (tempDestination.height + 7) / 8, 1);
      
      // copy the result
      Graphics.Blit(tempDestination, destination);
   }
}

該指令碼應儲存為 "tintComputeScript.cs"。為了使用它,必須將其附加到相機,並且公共變數 `shader` 必須設定為計算著色器,例如上面定義的計算著色器。

指令碼的 `Start()` 函式只執行一些錯誤檢查,使用 `shader.FindKernel("TintMain")` 獲取計算著色器函式的編號,並將其寫入 `handleTintMain` 以便在 `Update()` 函式中使用。

`OnDestroy()` 函式釋放臨時渲染紋理,因為垃圾收集器不會自動釋放渲染紋理所需的硬體資源。

`Update()` 函式執行一些錯誤檢查,然後(如果需要)在 `tempSource` 和 `tempDestination` 中建立新的渲染紋理,並將畫素複製到 `tempSource`,之後使用函式 `SetTexture()`、`SetVector()` 和 `SetInt()` 設定計算著色器的所有統一變數,然後使用對 `Dispatch()` 的呼叫呼叫計算著色器函式。在本例中,我們使用 `(tempDestination.width + 7) / 8` 乘以 `(tempDestination.height + 7) / 8` 執行緒組(這兩個數字都隱式向下取整)。我們在兩個維度上除以 8,因為我們指定了執行緒組的數量,每個執行緒組的大小為 8 乘以 8,如計算著色器中的 `[numthreads(8,8,1)]` 所指定。需要加上 7 以確保如果渲染紋理的尺寸不能被 8 整除,我們不會少一個。在排程計算著色器之後,結果使用對 `Graphics.Blit()` 的呼叫從 `tempDestination` 複製到 `OnRenderImage()` 的實際 `destination`。

與用於影像效果的片段著色器比較

[edit | edit source]

這個計算著色器和 C# 指令碼實現了與“最小影像效果”部分中的片段著色器相同的效果。顯然,使用計算著色器實現影像效果比使用片段著色器需要更多的程式碼。但是,您應該記住兩點:1) 額外程式碼的原因主要是 Unity 的 `OnRenderImage()` 函式和 `Graphics.Blit()` 函式的設計初衷是為了與片段著色器順利協作,而在定義這些函式時沒有考慮計算著色器,2) 計算著色器能夠完成片段著色器無法完成的事情,例如,寫入目標渲染紋理中的任意位置、線上程之間共享資料、同步執行緒的執行等。其他教程中將討論這些功能中的一部分。

總結

[edit | edit source]

恭喜您,您已經學習了有關 Unity 中的計算著色器的基礎知識,以及如何將它們用於影像效果。您已經看到了一些內容,包括:

  • 如何為影像效果建立計算著色器。
  • 如何在 C# 指令碼中設定計算著色器的統一變數。
  • 如何使用 `ComputeShader.Dispatch()` 函式呼叫計算著色器函式。

進一步閱讀

[edit | edit source]

如果您想了解更多

< Cg 程式設計/Unity

除非另有說明,否則本頁上的所有示例原始碼均歸屬於公共領域。
華夏公益教科書