2020年6月23日 星期二

Scriptable Render Pipeline (1)


  • 前言

2018年,Unity 導入了 Scriptable Render Pipeline(之後簡稱 SRP),

讓開發者可以自訂專屬的 render pipeline ,大大的提升效能優化和特殊渲染需求的靈活性。

但使用自訂 render pipeline 也意味著之前 Unity 幫使用者處理的功能,

像是 culling、shadow map ... 等,都必須重新建置。

因此, Unity 也提供了兩個範本供開發者使用,包含:

  • Lightweight Render Pipeline(改名為Universal Render Pipeline),提供給低端設備使用
  • High Definition Render Pipeline,提供給高端設備使用

開發者可以根據需求直接修改範本。

雖然可以直接修改範本,但仍需要一些基礎知識才能有要利用。

因此本文將不使用 Unity 提供的範本,選擇從無開始建構一個完全客製的 render pipeline,

期望能在建構途中了解 SRP 的基礎原理,以方便之後能有效利用並修改 Unity 提供的範本。

客製化的 render pipeline 在 mobile 上的實行結果

  • 安裝與設定

本文使用的開發環境為 Unity 2019.3.2f1,

程式碼所使用的函數名稱或所屬的 namespace 可能會根據 Unity 版本不同而有變化,

在閱讀與實作上請注意。


首先,為了要能夠使用 SRP,需要從 Window\Package Manager 下載專用的 package



接著,產生一個 C# 檔案並任意取個名字(本文命名 DemoPipeline.cs),然後編輯內容如下

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class DemoPipeline : UnityEngine.Rendering.RenderPipeline
{
    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        
    }
}



DemoPipeline.cs 主要是描述 Render Pipeline 的運作內容,

需要繼承 UnityEngine.Rendering.RenderPipeline。

渲染時,會呼叫 Render 函式, 並根據 Render 函式實作內容,決定渲染流程。


接下來,為了能讓 Unity 使用客製的 render pipeline,

需要建立一個 RenderPipelineAsset 檔案,作為接口,

使 Unity 知道要如何取得 DemoPipeline.cs 內的實作內容。

為了達到這個目的,同樣地,

在專案下建立一個 C# 檔案並任意取個名字(本文命名為 DemoPipelineAsset.cs),

然後編輯內容如下

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "Demo Pipeline")]
public class DemoPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset
{
    protected override RenderPipeline CreatePipeline()
    {
        return new DemoPipeline();
    }
}

由於我在腳本上使用了 CreateAssetMenu 這個 Unity內建的 Attribute,

因此我可以在 Unity 編輯器內,點擊滑鼠右鍵產生 DemoPipelineAsset 的 Asset。



只要把這個 Asset,告知 Unity,Unity就知道要使用哪個 SRP 作為渲染流程。

告知 Unity 的方法有兩個

1. 在 Project Settings\Graphics 設定,將 Asset 用滑鼠拖曳到指定欄位


2. 在 Script 呼叫 GraphicsSettings.renderPipelineAsset [1]

public DemoPipelineAsset demoAsset;
GraphicsSettings.renderPipelineAsset = demoAsset;


如果按照上述說明並實作至此,會發現到完全沒有畫面。

這是正確的,因為我還沒有為 DemoPipeline::Render 這個函式製作任何內容。

因此接下來要做的事情就是實裝 Render 函數的內容。


首先,觀察到函數帶了兩個引數 context 和 cameras 

protected override void Render(ScriptableRenderContext context, Camera[] cameras)

context 是一個 native code,裡面封裝了所有渲染物件的資料

cameras 是一個 camera 陣列,Scene 裡面所有的 camera 都會被包裝到這個陣列裡面。

因為渲染是畫出 camera 所看到的內容,因此第一步我必須要針對所有的 camera 做處理。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    for(int i = 0; i < cameras.Length; ++i)
    {
        // To Do
    }
}

接著,畫出一個天空球,讓畫面不會太孤單

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    for(int i = 0; i < cameras.Length; ++i)
    {
        if (cameras[i].clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(cameras[i]);
    }
}

由於天空球是個渲染物件,因此他被封裝在 context 裡面,

我必須要透過呼叫專用的函式(DrawSkyBox),去渲染天空球。

但呼叫後並不會馬上被執行,這是因為我是在 CPU 呼叫指令,但執行是在 GPU,

因此,我必須要在設定好所有指令後,將指令送給GPU執行。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    for(int i = 0; i < cameras.Length; ++i)
    {
        if (cameras[i].clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(cameras[i]);
    }

    context.Submit();
}

透過呼叫 context.Submit(),將所有渲染指令送到 GPU。

執行到這裡,不論是 Game View 或 Scene View,畫面都會出現熟悉的背景天空。

但仔細一看會發現不論我怎麼轉動攝影機,畫面永遠維持不變。

這是因為我並沒有把攝影機的資料傳入(攝影機的面向、轉角 ... etc)。

既然如此,我知道我接下來要做的事是設定攝影機的資料,並把資料傳給 GPU 做運算。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    for(int i = 0; i < cameras.Length; ++i)
    {
        context.SetupCameraProperties(cameras[i]);
        if (cameras[i].clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(cameras[i]);
    }

    context.Submit();
}

context.SetupCameraProperties() 函數會自動幫我設定渲染所需攝影機的所有資料,

這樣,背景的天空就會隨著攝影機的角度而有不同的畫面。


到了這裡,看似已經完成一個背景天空渲染的基礎 SRP,

但熟悉 render pipeline 的人,應該會發現我沒有做一個做基礎的動作。

對,就是清除 render buffer。

在本文範例中,因為是全畫面渲染,因此清不清除 render buffer 都看不出問題來。

但如果畫面中不是一個全螢幕渲染,這時就會出現破綻。

因此,我必須在每個攝影機執行渲染動作前,

先下指令清除上一個 frame 留下了的 render buffer 資料。


但 render buffer 並不是一個渲染物件,因此沒有被封裝在 context 裡面。

對此,Unity 提供了 CommandBuffer 這個 class,讓開發者可以呼叫 context 不支援的指令。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    CommandBuffer cmdBuff = new CommandBuffer();
    for(int i = 0; i < cameras.Length; ++i)
    {
        ClearRenderBuffer(cmdBuff, context, cameras[i]);
        context.SetupCameraProperties(cameras[i]);
        if (cameras[i].clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(cameras[i]);
    }

    context.Submit();
    cmdBuff.Release();
}

void ClearRenderBuffer(CommandBuffer cmd, ScriptableRenderContext context, Camera cam)
{
    CameraClearFlags clearFlags = cam.clearFlags;

    bool isClearDepth = true;
    bool isClearColor = true;

    switch (clearFlags)
    {
        case CameraClearFlags.Depth:
            isClearColor = false;
            break;

        case CameraClearFlags.Nothing:
            isClearDepth = false;
            isClearColor = false;
            break;
    }

    cmd.ClearRenderTarget(isClearDepth, isClearColor, cam.backgroundColor);
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
}

透過設定 CommandBuffer,然後呼叫 context.ExecuteCommandBuffer,

如此 context 內部會複製 CommandBuffer 設定的指令,

當 context 資料傳送給 GPU執行時,這些被複製的指令也會一併被 GPU 執行。

執行的順序會依據呼叫 context 的任何函數的先後來決定。



  • 結論

本章節簡單的說明如何安裝並建立一個簡單的 SRP,

之後的章節會延續本章節的結果,一步一步的新增(修改)程式碼,

並簡單說明新增(修改)目的,直到能達成章節一開始所展示的客製 SRP 結果。


  • References

2020年6月8日 星期一

Dual Paraboloid Environment Mapping

  • 前言
在計算環境反射時,最常使用的就是 Cubemap。















圖片來源: Unity Manual

Cubemap 是由六張圖片對應六個方向所組成,

這對於 mobile 來說負擔太大,很容易造成效能瓶頸。

因此,本篇介紹一個較輕量的作法來實現環境反射,

此方法叫 Dual Paraboloid Environment Mapping。

  • 原理
先來看一個二元的數學方程式


這時透過觀察可以發現:當 x^2 + y^2 ≤ 1, 則 0 ≤ f(x,y)。

在上述的條件下,取 F(x,y) 上的任一點 P,

P 的座標可以記為 (x, y, f(x,y))。

在 P 點上,分別對 x 軸和 y 軸作微分,可以得到 P 點上的兩個切線向量。


接著,對 Tx 和 Ty 做外積,可以得到 P 點上的法向量 N = (x,y,1)。

這時取一條從原點到 P 點的向量 P',

計算 P' 相對於 N 的反射向量 R 。

的計算可以透過反射公式求得。


其中,上述所有向量都必須為單位向量。

因此,首先正規化向量 P' 和 N


得到兩個正規化向量後,帶入反射公式,求得反射向量 R


可以得知,反射向量為一個固定值(0,0,1)。


從上述的證明可以得到兩個重點
  1. 平面  f(x,y) 上任何一點的法向量 = (x,y,1),因為其 Z 軸為一個常數值,所以 N 可以視為一個二維變數。
  2. 從原點開始的任何一個向量 P',和平面 f(x,y) 接觸後的反射向量為一個固定值(0,0,1)。
這代表著我可以將一個三維向量 P' 在 Cubemap 取得的顏色資料 D

透過計算向量 P' 和平面  f(x,y) 交接處的法向量 N,

將顏色資料 D 儲存在一個2維陣列內。

而法向量 N 的計算就透過 P' 和固定反射向量 R 來求得。


但回頭看看平面方程式,為了方便說明我只顯示 X-Z 平面的形狀


這個平面只包含了上半部(+Z),

勢必要提供一個下半部(-Z)的二元方程式,才能完整獲得 Cubemap 所有範圍資料。


















圖片來源 : ScienceDirect - Environment Mapping

由於上半部和下半部個只需要一個2維陣列(i.e.一張2維貼圖)儲存 Cubemap 資料,

因此透過此方法,可以將一個 6張2維貼圖的資料,只用 2張2維貼圖儲存,

大大的減少資源需求。


  • Unity 實作

在實作範例中,我示範如何將一個使用 Cubemap 的 skybox,轉化成兩張 2維貼圖。

Dual Paraboloid map 一般用在即時的環境映射(事實上,我參考的文獻也是此種做法),

但一般 mobile 很少做這種計算,且兩者做法類似,

因此我在這裡示範 off-line 的 cubemap 轉換,若對 real-time 的環境映射有興趣,

可以參考我在文章最後列出的參考項目。


首先,在 Scene 裡面產生一個 Sphere 模型,
然後把攝影機設定在 Sphere 的原點為0,並設定 Clear Flags 為 Solid Color,顏色指定黑色
(在範例中,我是將畫面分成 +Z 和 -Z 兩個方向,所以攝影機的旋轉值全部設為 0)



接著,將 Sphere 掛上下面 Shader,並指定要轉換的 Cubemap


畫面變成


將輸出的畫面儲存。

接著把攝影機繞 Y 軸轉 180度,重複上述步驟,會得到下面結果




如此就完成了 Dual Paraboloid Environment mapping。

最後在附上 Dual Paraboloid Environment mapping 的 skybox shader。


  • 結論
本章節只討論 Dual Paraboloid Environment mapping 的其中一個應用,

就如同文章一開始所提到的, 一般常見的用法是用在 real-time 環境反射。

但兩者基本上做法是一樣,有興趣的人可以參考後面的參考連結,

修改上面提供的 shader 試試。


  • References



2020年6月1日 星期一

3D 渲染基礎


  • 前言
3D渲染簡單來說,其實是將 3D物件投影到 2D平面的一個技術。

不論使用的 Graphic API 是 OpenGL 也好, 還是 DirectX ,都是做相同的運算。

本章節將討論空間轉換的基礎知識,及 Unity3D 內的特殊處置,

作為光照計算以外特殊運用的基礎 (ex.水面倒影、戰爭迷霧 etc. )。

  • 坐標系
空間轉換的計算雖然說基本相同,但 OpenGL 和 DirectX 間仍有細微的差異,

最明顯的差異就是使用的坐標系不同。


OpenGL 使用右手座標系, X 向右遞增,Y向上遞增、Z向後遞增。

DirectX  使用左手座標系, X 向右遞增,Y向上遞增、Z向前遞增。

兩者之間的差別只在於 Z軸遞增方向。

Unity3D 在坐標軸的使用上比較特殊,在編輯器和 Script都是使用左手座標系,

但因為渲染底層是使用 OpenGL,所以在 Shader 上是使用右手座標軸。

  • 座標轉換
不管是使用哪個坐標系,渲染的最終目的都是要將 3D 空間轉換到 2D螢幕上。

通常在轉換上會經過三次處理,

分別為: model (world) transform、view transform 和 projection transform。

接下來會分別針對這 3種轉換一一說明。


  • model (world) transform
在使用 3D建模軟體(ex. 3DMax , Blender, etc..)時,會以原點為中心去建造一個模型,

並儲存成檔案輸出給所有遊戲編輯器使用。

這個檔案只包含了模型的基本形狀,並沒有包含在遊戲中出現的位置、角度或大小。

因此在渲染這個模型時,需要將這個模型放到遊戲場景中的指定位置,

並根據需求將其旋轉或縮放。

這個轉換就叫做 model (world) transform。


  • view transform
當模型轉換到遊戲場景中的指定位置之後,接下來需要判定但是否會出現在畫面中。

判定是否出現在畫面是根據模型和攝影機的相對位置。

模型離攝影機太遙遠或是在攝影機看不到的角落(ex. 攝影機背後),都不應該出現在螢幕上。

這種將 3D座標變換成與攝影機的相對座標的轉換就叫做 view transform。



  • projection transform
當決定了相對位置後,就要根據相對位置去做裁切,

標示那些模型需要被顯示,這樣的轉換就叫做 projection transform。



經過上述的3個轉換後,模型位置會落在一個叫做 clipping space 的三維空間內,

並設定在這個空間內的指定範圍才會被顯示到螢幕上。

圖片來源 : OpenGL view volume




  • Clipping Space
Clipping Space 定義了 X、Y軸的範圍再 [-1,1],超出這個範圍就不顯示。

要注意的是,根據 Unity Manual 的說明,

Z軸的範圍會根據目前使用的 Graphic API 而有不同。

當使用 DirectX 時, Z 軸範圍在 1~0 (1是最近,0是最遠)

當使用 OpenGL 時, Z軸範圍在 -1~1(-1最近,1是最遠)。

這個 Z軸遠近和上面提到的左(右)手坐標系描述完全相反。

目前並沒有找到詳細文件說明為何 Clipping Space 的Z軸方向和上述說明不符的原因,

推測可能是上述的轉換全部是在 Shader 內做處理,當 Shader 計算好後會將資料提交給 GPU,

GPU 再轉換到 Normalized Device Coordinate(標準化設備座標,簡稱 NDC) 顯示時,

又做了一次反向 [4]。


  • Unity3D 的座標轉換
在瞭解了基本的做邊轉換之後,來看看 Unity3D 的實際運作。

在前面的說明得知, Unity3D 同時使用了兩種坐標系。

因此 ,需要了解 Unity3D 是如何在這兩個坐標系做轉換,

才能方便我們客製屬於自己的空間轉換。


首先,在 Unity3D 編輯器建立一個空 Scene,

然後把攝影機的 position 和 rotation 都設為 (0,0,0)。

之後隨便放上一個物件(ex. Cube),接著開啟 Frame Debugger 來觀察。

結果發現:


Unity3D 的 View transform 將 Z 軸反轉了 (正值變負值)。

由於左手座標軸和右手座標軸的差別只在於 Z 軸方向,

因此可以得知 Unity3D 是在 View transform 的時候將左手座標系轉成右手座標系處理。


  • 結論
根據上列討論,整理成下列幾個重點

  1. Unity3D 同時使用左手座標系和右手座標系
  2. Unity3D 在 View transform 將左手座標系和右手座標系做轉換
  3. 座標轉換到 clipping space 後,Unity3D 會根據使用的 Graphic API 決定模型是否顯示。
    • Graphic API 使用 DirectX  顯示範圍在 (1,1,1) ~ (-1,-1, 0)
    • Graphic API 使用 OpenGL 顯示範圍在 (1,1,-1) ~ (-1,-1,1)
  4. 不論 Unity3D 的Graphic API 是使用哪種,渲染底層都是使用 OpenGL,因此坐標系都是要轉換成右手座標系處理。
  5. Clipping space 的模型前後判定和目前使用的 Graphic API 坐標系不一樣,需要注意前後方向的正負值。


  • References
1.Unity Manual
2.OpenGL view volume
3.opengl-tutorials
4.LearnOpenGL