2019年11月27日 星期三

[札記] Rendering手筋 - 透明物件深度 & 將Unity GBuffer用好用滿

兩個小主題紀錄一下。

透明物件深度

 

●圖1. 透明物件也輸出深度

●圖2. 輸出深度的同時也不影響畫面


由於截圖畫質,所以會有banding現象請忽視。

在一般的情況下,我們不太會去輸出透明物件深度值,但隨著品質要求越來越高,總是會需要一些資訊到後製處理階段去。

不像Cutout物件,可以直接用Opaque的方式再用clip或discard pixel的方式來將深度值裁切掉;如果直接輸出透明物件的深度值,整片還是會輸出到Depth Buffer,造成RGB透掉的部分也有深度值的奇怪現象。

也就是說,最理想的是做到per-pixel depth output
在D3D10之後,多了一個SV_Depth,可以讓我們在pixel shader輸出深度值。

float4 fragExample(v2f i, out float oDepth : SV_Depth) : SV_Target
{
      oDepth = i.pos.z;
      return float4(1,0,0,1);
}

這就是一般pipeline在做的事了,將光柵化後的z座標輸出到depth buffer,用SV_Depth就相當於這件事,那現在有了釣竿,我們就知道怎麼釣魚了。

sampler2D _CameraDepthTexture;
float4 fragExample(v2f i, out float oDepth : SV_Depth) : SV_Target
{
      // oDepth = i.pos.z * i.alpha; // 不好,沒考慮到背景

      float2 screenUV = i.screenUV.xy / i.screenUV.w;   // 來自vertex shader的ComputeScreenPos
      float sceneDepth = tex2D(_CameraDepthTexture, screenUV).r;
      oDepth = lerp(sceneDepth, i.pos.z, i.alpha);

      return float4(1,0,0,1);
}


輸出深度值時,考量到最終的Alpha(透明度)就行了,當然直接乘上去是會有問題的,透掉的部分是有可能還存在物件的,所以這邊跟當前的scene depth做lerp,不需要使用linear01depth之類的,因為我們要的就是raw depth。


但很可惜,還差了一步,由於SV_Depth無視順序,強制寫值,並且會打破early-z culling,做不了Greater Equal的比較(沒reversed-z的話是Less Equal),雖然大多數的時候透明物件都是從後面排到前面,但仍有交錯的可能,所以,再做個最終改寫。

struct v2f
{
    centroid float4 pos : SV_POSITION;
};

sampler2D _CameraDepthTexture;
float4 fragExample(v2f i, out float oDepth : SV_DepthGreaterEqual) : SV_Target
{
      // oDepth = i.pos.z * i.alpha; // 不好,沒考慮到背景

      float2 screenUV = i.screenUV.xy / i.screenUV.w;   // 來自vertex shader的ComputeScreenPos
      float sceneDepth = tex2D(_CameraDepthTexture, screenUV).r;
      oDepth = lerp(sceneDepth, i.pos.z, i.alpha);

      return float4(1,0,0,1);
}

如此一來,物件就會像正常Rendering似的考量到深度值的大小來決定要不要寫入深度值,從而達成圖1流暢的深度值!

而centroid關鍵字是使用SV_DepthGreaterEqual,它的作用我不贅述,有興趣可參考此連結

有了透明物件的深度資訊就可以發展出更多應用。

將Unity GBuffer用好用滿 

根據官方文件,大家都知道Unity的GBuffer格式:
  • RT0, ARGB32 format: Diffuse color (RGB), occlusion (A).
  • RT1, ARGB32 format: Specular color
    (RGB), roughness (A).
  • RT2, ARGB2101010 format: World space normal (RGB), unused (A).
  • RT3, ARGB2101010 (non-HDR) or ARGBHalf (HDR) format: Emission + lighting + lightmaps
    + reflection probes
    buffer.
  • Depth+Stencil buffer
    .
如果有燒shadowmask,會還有一個RT4(shadowmask)存在。
今天的重點在於未使用的channel,RT2跟RT3的channel均未使用,我們是否可以最大化利用它?

會想利用的起因是有時會收到一些特殊需求,例如說指定物件不要接收陰影、指定物件不要有specular highlight (shader設定只對forward有用,似乎是BUG),這些光改standard shader達不到,因為它隱含實作在Internal-DeferredShading.shader裡面,我們必須把資料帶過來這個shader,然後再做處理。

從RT2&RT3的格式ARGB2101010我們可以知道,A是一個2-bit數值,因此排列組合上共有0 1 2 3四種數值可以利用:

half4 normalBuffer = tex2D(_CameraGBufferTexture2, i.uv);

float atten = lerp(atten, 1, (normalBuffer.a == 1));  // 數值為1時忽略陰影
float highlight = lerp(highlight, 0, (normalBuffer.a == 2)); // 數值為2時忽略高光

這樣根據通道數值來決定動作的方式就有了,但是這邊卻有個陷阱。
ARGB2101010格式對應D3D底層為DXGI_FORMAT_R10G10B10A2_UNORM,這種格式會將數值正規化為[0~1]之間,所以絕對不會有什麼1~3的數值的,當然如果使用DXGI_FORMAT_R10G10B10A2_UINT格式,就可以直接儲存了,但是Rendering不會用UINT。

因此,我們需要修改code:
half4 normalBuffer = tex2D(_CameraGBufferTexture2, i.uv);

float atten = lerp(atten, 1, (normalBuffer.a == 1/3.0f));  // 數值為1時忽略陰影
float highlight = lerp(highlight, 0, (normalBuffer.a == 2/3.0f)); // 數值為2時忽略高光

一個2-bit UNORM格式的數值,將會以0.0f, 1/3, 2/3, 1.0f來做區分。
以此類推,3-bit UNORM就會是0.0f, 1/4, 2/4, 3/4,1.0f來區隔。
然後在shader內指定這些數字,就可以做出功能區隔了,GBuffer的價值在這邊算是用到滿了。


範例


完整測試專案連結

在2017.4.3以上開啟即可,相當簡單的測試,不過並沒有直接預覽深度值的功能,需使用FrameDebugger或者是RenderDoc (圖1的瀏覽器) 之類的工具。

至於GBuffer利用的部分,改了specular highlight的部分,這部分Unity雖然有判斷keyword但是實測根本沒用,所以用GBuffer帶數值的方式決定要不要開起highlight,修改後,對Deferred Rendering的Opaque物件也能正常開關specular highlight了。

2019年11月17日 星期日

[Unity] 快速刷出邊緣光

●圖1. 一台車加上刷光特效

首先感謝 https://free3d.com/3d-models/vehicles
這個網站,上面真的有蠻多素材可以測試
找素材的時間我都寫完10份演算法了XD

目標

簡單來說,目的就是丟一個模型進去,計算完邊緣之後將結果存下來。
這種邊緣的計算跟抗鋸齒要的不太一樣,抗鋸齒可以即時處理,但是想要這種刷光是需要前置輔助的。

如果不自動計算,美術就只能一碗滷肉飯一瓶礦泉水一筆一畫畫在想要的位置上,實在很沒效率。



●圖2. 這個要美術畫大概會起笑吧

演算法

演算法很單純,邊緣查找,不外乎就是把所有頂點一個一個打斷鼻樑抓進來,然後兩兩比較他們的法線的內積


這個公式大家再熟悉不過了,當a跟b都是單位向量的時候角度與內積值會呈反比。

所以工作相當的單純:
  1. 先給一個頂點的距離門檻值,篩掉距離太遠的點 (邊緣之所以叫邊緣就是他們很接近)
  2. 計算兩兩點內積值,若有多重個點,加總內積值以及次數
  3. 平均內積值,輸出到貼圖上面 (想加粗可以改成min,想變細可以改抓max,看需求)

實作方式

從邊緣計算的方式我們可以知道,點數越大的模型,出來的效果越好
因為如果面數太低,點與點之間的距離必定會加大,變成距離門檻值要拉很高才算得出東西,這樣不論怎麼算都會是粗線條。

因此,範例我抓了一個96000點的模型來使用,不過問題來了,這個計算量會是相當龐大的。
96000*95999 =  9215904000
92億次的計算????市面上絕對沒有CPU撐得住

如果修改演算法,先根據距離門檻值把點做分群再算呢?
可能值得一試,但先分群反而還多了前處理的時間,根據切法不同有時候可能還不會真正省到時間

所以,維持簡單的算法,丟到GPU上面算吧!
  • 方法一、 Geometry Shader

    Geometry Shader允許我們輸入一個三角形,一次針對一整個三角形來處理,其中更有一種輸入型別 triangleadj,可以抓取鄰居三角形! (距離門檻值都省了)


    illustration of the various primitive types for a geometry shader object 

    本來的input是0 1 2,丟入triangleadj就會變成去處理024 012 045 234
    這樣邊緣資訊就唾手可得了,不過,這個方法卻有個致命的缺點....
    就是Unity不支援,掰掰洗洗睡


  • 方法二、Compute Shader

    另一個方法,自然就是用Compute Shader加速了!它的特性是多重thread平行化計算,對於這種大量但是規則單一的動作是最為拿手的了!








    在計算時,我的頂點以16383一組為單位,去呼叫compute shader,一次使用1024個threadgroup處理,一個threadgroup又有1024個threads在執行,所以96000*96000次計算,實際上被我打散成 16 * 94 = 1504次!
    如此一來,計算上就是節省為 96000 / 16383 * 1504次,八千多次即可完成
    這實在是非常多的節省~

    當然要用compute shader只得一個缺點: 需要D3D11
    不過現在應該沒有顯卡不支援,若果一個專案用不起支援D3D11的顯卡就趕快逃吧


     

計算結果的優化

最後結果的輸出,絕對不是直接用模型的uv來寫值到貼圖上,要知道compute shader可不像fragment shader一樣,會自動幫我們把pixel之間做線性內插,如果直接輸出根本無法使用:



因此,這邊的輸出並不是最終輸出,還要再做一次smooth的動作,輸出的方式為:


首先創一張足夠容納模型點數的像素圖 (96000開根號 = 310 * 310大小的圖)
只是將它作為一張查找表來使用 (變成256*256只是我輸出一份來預覽,實際上是310*310)

↑哥存的是查找貼圖,不是渲染貼圖


然後,再把這張查找表丟到另外一個Shader做處理:
↑最重要的部分

首先,我們必須把數值取出來,所以這邊利用SV_VertexID系統關鍵字,來取得vertex id,然後做了跟我剛剛在compute shader做的轉換,利用vertex -> fragment shader會幫我們內插數值的特性來優化結果!

其實這個結果就相當於把數值儲存在model裡面使用,但是我不想在即時渲染的時候還是使用高面數版本的模型,我希望能夠低面數存取貼圖的情況

所以,這一小段第二個重點,就是clip-to-texture space的小轉換了
一般情況下,我們做頂點轉換的時候,最終都會投影到[-1,1]這個稱為NDC (Normalized Device Coordinate) Space的空間,所以這邊就是利用模型的uv,假設uv絕不重疊且位於[0,1]之間,透過簡單的轉換轉成[-1,1]就可以了

如此一來,圖2的結果就會展現出來


總結

輸入一個高面數模型 (假設normal正確,uv不重疊並介於0~1之間)
透過compute shader快速取得邊緣值
再透過mapping-to-texture的方式輸出結果
最後就可以使用低面數的模型來繪製出刷光的結果!
生成出來的圖亦可以再做模糊或其它後製處理來平滑細節