2019年4月15日 星期一

Checkerboard Rendering In Unity

Checkerboard Rendering

就稱為棋盤格渲染吧!一種向上採樣(up sampling)的構圖方式。
概念是使用目標解析度的一半(四分之一)進行交錯渲染,最後再合成完整的frame。
例如我想跑3840*2160的解析度,我就在第N-1 Frame畫一張1920*1080的,再在N Frame畫一張1920*1080的,最後再進行後製合併,以最小的畫質損失達到節省GPU時間的目的。

PS4 Pro之所以能支援4K,也是因為用了這個技術。
Frostbite、Ubisoft等等大廠也有實作它。

Overview

圖1. Full Resolution Render

回顧一下三角形的光柵化,光柵化規則為如果三角形有通過像素格的中心點,則被視為通過然後進入pixel shader(fragment shader)處理,反之則捨棄,上圖的三角形都順利通過測試,所以出來的影像是黃綠相間的。


圖2. Half Resolution Render

那麼解析度減半之後呢?Sorry,因為損失了一半的資訊,這個時候通過紅色取樣點的三角形只剩下一半的數量,若使用這種資訊,是沒有辦法合成目標畫質的。


圖3. 2x MSAA Coverage

解決的方式,即是利用2x MSAA來解決!
2x MSAA的取樣模式剛好是左上右下,一個像素有兩個取樣點的模式,恰好可以利用來補足解析度砍半的像素資訊。 (MSAA的取樣模式可以參考這篇)



圖3. 2x MSAA插幀

啟動2x MSAA後,本來的像素點以兩個取樣點的方式來篩三角形,這次所有的三角形得以順利通過光柵化測試,還原幾乎1:1的像素資訊。

Modification

圖4. Forward Rendering修改

接下來就是如何把方法融入體系了,FW Rendering裡面很單純,直接將它畫在只有一半解析度&開啟2X MSAA的Render Target就可以了,然後再進行合成。
這邊的流程有分CB後製與CB重構後的後製,有的時候我們只需要在CB的解析度處理後製特效就好,有時候則是希望完整的frame出來後才上後製特效,看應用而定。


圖5. Deferred Rendering修改

那麼Deferred Rendering又如何呢?
當然就是所有的GBuffer都也要減半解析度,然後開啟2x MSAA,畫上東西之後,需要建立一張兩倍長度同樣高度的SRT(Shade Resolve Target),左半邊儲存Sample位置0的資訊,右半邊儲存Sample位置1的資訊。

為何會這麼複雜?因為GBuffer合成階段其實也是一個後製pass的過程,這個時候已經沒有場景物件的光柵化了(只剩一個全螢幕Quad),沒有辦法再把GBuffer存成2x MSAA Target,所以原文使用兩倍長度的圖去存資料。

搞定之後,forward物件的部分(透明物件無法存在GBuffer必定要使用FW)會畫到另外一張CFB(Checkerboard Forward Buffer)上,這張圖是2x MSAA然後減半解析度的。

最後一樣跑CB重建,合成目標解析度的影像。
不過這次,原文就沒有提到CB Post Process了,因為這次似乎是打算在CB Reconstruct一次完成SRT+CFB合併以及最終影像合成。

其實再把流程拆細,例如Sample0的SRT+CFB以及Sample1的SRT+CFB先個別完成,理論上就可以在這步去做後製特效了,然後再進行CB Reconstruct,不過當然整個流程會再複雜一點~

Shading in Motion

靜態畫面,不用太特殊的處理。
可惜遊戲有99.99999999%的機率會有東西在動。
這個時候只要把Motion Vector考慮進來就可以了,在CB重建這一步時去加上motion vector位移。
但是如果位移造成了像素資料遺失,還是需要利用當前的frame來進行補值。

Implementation In Unity

在Unity實作中我將以Forward Rendering為基礎去寫,Deferred Rendering由於Unity把GBuffer藏在底層,基本上只有在Shader拿得到GBuffer,沒辦法讓我們在CPU建立MSAA Target,所以暫時先不管Deferred Rendering了。

要改的話只能自己寫Scriptable Rendering Pipeline自製Deferred Rendering,不過要做到完全一樣必須一直測試有沒有漏功能,會是個大工程XD。


                 
        mainCam = GetComponent();
        mainCam.renderingPath = RenderingPath.Forward;
        mainCam.depthTextureMode |= DepthTextureMode.MotionVectors | DepthTextureMode.Depth;

        for (int i = 0; i < 2; i++)
        {
            quarterRT[i] = new RenderTexture(Screen.width / 2, Screen.height / 2, 16, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
            quarterRT[i].antiAliasing = 2;
            quarterRT[i].mipMapBias = -0.5f;
            quarterRT[i].bindTextureMS = true;
            quarterRT[i].name = "Quarter Frame " + i;
        }

        motionRT = new RenderTexture(Screen.width / 2, Screen.height / 2, 0, RenderTextureFormat.RGHalf);
        motionRT.name = "Motion Frame";
  
碼1. 建立RenderTexture

初始準備很簡單,建立長寬半減的四分之一RenderTexture,重點是bindTextureMS要設定為True,這樣才有辦法在shader中使用Texture2DMS,讓我們自己使用多重取樣。
然後Camera的DepthTextureMode記得要打開Motion Vector。


                 
    void Update()
    {
        frameCnt = (frameCnt + 1) % 2;
        mainCam.targetTexture = quarterRT[frameCnt];

        Rect camRect = mainCam.pixelRect;
        camRect.x = (frameCnt & 1) == 0 ? 0.25f : 0.75f;
        mainCam.pixelRect = camRect;
    }

    void OnPostRender()
    {
        if (blitMotion)
        {
            Graphics.Blit(null, motionRT, blitMotion);
        }
    }

碼2. 交錯Rendering以及取得Motion Vector

接著的步驟也很單純,有一個frame counter去看目前是偶數frame還是單數frame,給予不同的viewport位移讓兩張小的RenderTexture儲存交錯渲染的資料,因為最後合成時我會用另外一個camera的OnRenderImage,所以這邊要利用Blit去取得Motion Vector的值。


                 
 sampler2D_half _CameraMotionVectorsTexture;

 float4 frag (v2f i) : SV_Target
 {
  return float4(tex2D(_CameraMotionVectorsTexture,i.uv).rg,0,1);
 }
碼3. 轉移Motion Vector的Shader Code

Fairly simple,Unity已經幫我們計算好Motion Vector了,只要直接回傳就行。




                 
 Texture2DMS<float4,2> _CbrFrame0;
 Texture2DMS<float4,2> _CbrFrame1;
 Texture2D _CbrMotion;

 uint _FrameCnt;
 const float Epsilon = 1.401298E-45;

 #define Up 0
 #define Down 1
 #define Left 2
 #define Right 3

 float4 readFromQuadrant(int2 pixel, int quadrant)
 {
  [branch]
  if (0 == quadrant)
   return _CbrFrame0.Load(pixel, 1);
  else if (1 == quadrant)
   return _CbrFrame1.Load(pixel + int2(1, 0), 1);
  else if (2 == quadrant)
   return _CbrFrame1.Load(pixel, 0);
  else //( 3 == quadrant )
   return _CbrFrame0.Load(pixel, 0);
 }

 float4 colorFromCardinalOffsets(uint2 qtr_res_pixel, int2 offsets[4], int quadrants[2])
 {
  float4 color[4];

  float2 w;

  color[Up] = readFromQuadrant(qtr_res_pixel + offsets[Up], quadrants[0]);
  color[Down] = readFromQuadrant(qtr_res_pixel + offsets[Down], quadrants[0]);
  color[Left] = readFromQuadrant(qtr_res_pixel + offsets[Left], quadrants[1]);
  color[Right] = readFromQuadrant(qtr_res_pixel + offsets[Right], quadrants[1]);

  return float4((color[Up].rgb + color[Down].rgb + color[Left].rgb + color[Right].rgb) * 0.25f, 1);
 }

 void getCardinalOffsets(int quadrant, out int2 offsets[4], out int quadrants[2])
 {
  if (quadrant == 0)
  {
   offsets[Up] = -int2(0, 1);
   offsets[Down] = 0;
   offsets[Left] = -int2(1, 0);
   offsets[Right] = 0;

   quadrants[0] = 2;
   quadrants[1] = 1;
  }
  else if (quadrant == 1)
  {
   offsets[Up] = -int2(0, 1);
   offsets[Down] = 0;
   offsets[Left] = 0;
   offsets[Right] = +int2(1, 0);

   quadrants[0] = 3;
   quadrants[1] = 0;
  }
  else if (quadrant == 2)
  {
   offsets[Up] = 0;
   offsets[Down] = +int2(0, 1);
   offsets[Left] = -int2(1, 0);
   offsets[Right] = 0;

   quadrants[0] = 0;
   quadrants[1] = 3;
  }
  else // ( quadrant == 3 )
  {
   offsets[Up] = 0;
   offsets[Down] = +int2(0, 1);
   offsets[Left] = 0;
   offsets[Right] = +int2(1, 0);

   quadrants[0] = 1;
   quadrants[1] = 2;
  }
 }

 float4 GetComposeColor(uint2 samplePos)
 {
  float2 vel = _CbrMotion.Load(uint3(samplePos*0.5f, 0)).rg;

  uint quadrant = (samplePos.x & 0x1) + (samplePos.y & 0x1) * 2;
  uint2 qtrPixel = floor(samplePos.xy * 0.5f);

  uint frameQuadrants[2];
  const uint _FrameLookup[2][2] =
  {
   { 0, 3 },
   { 1, 2 }
  };
  frameQuadrants[0] = _FrameLookup[_FrameCnt][0];
  frameQuadrants[1] = _FrameLookup[_FrameCnt][1];

  [branch]
  if (frameQuadrants[0] == quadrant || frameQuadrants[1] == quadrant)
  {
   // match current frame, sample frame N
   return readFromQuadrant(qtrPixel, quadrant);
  }
  else
  {
   // mismatch current frame, sample frame N-1
   uint2 prevSamplePos = samplePos.xy + float2(0.5f, 0.5f) - vel;
   uint quadrantPrev = (prevSamplePos.x & 0x1) + (prevSamplePos.y & 0x1) * 2;
   int2 prevCenterPixel = floor(prevSamplePos.xy * 0.5f);

   // check missing pixel
   bool missingPixel = false;

   [branch]
   // quad check
   if (frameQuadrants[0] == quadrantPrev || frameQuadrants[1] == quadrantPrev)
    missingPixel = true;
   else if (abs(vel.x) > Epsilon || abs(vel.y) > Epsilon)
    missingPixel = true;

   [branch]
   if (missingPixel)
   {
    int2 cardinal_offsets[4];
    int cardinal_quadrants[2];
    getCardinalOffsets(quadrant, cardinal_offsets, cardinal_quadrants);

    return colorFromCardinalOffsets(qtrPixel, cardinal_offsets, cardinal_quadrants);
   }

   return readFromQuadrant(prevCenterPixel, quadrantPrev);
  }
 }

 float4 frag (v2f i) : SV_Target
 {
  uint2 samplePos = i.vertex.xy;
  samplePos.y = _ScreenParams.y - samplePos.y;
  return GetComposeColor(samplePos);
 }

最後一個複雜的部份,合成frame的時候,將剛剛兩張長寬減半的圖用SetTexture丟進來。
這邊必須宣告Texture2DMS<float4,2>,代表將它以2X Render Target對待。
取樣時不再是使用Sample,而是使用Texture2DMS.Load(uint3())來取值。
uint3座標的xy為螢幕座標(不是0~1那種uv,是0~畫面長度寬度-1那種),z值代表要取樣哪個點,以2x msaa來講可以使用0(右下像素)或1(左上像素)。

進入GetComposeColor後,先簡單判別要求的像素是哪一格。
uint quadrant = (samplePos.x & 0x1) + (samplePos.y & 0x1) * 2;
例如(0,0) (1,0) (0,1) (1,1)計算後會是0 1 2 3。
0 3 代表偶數格,1 2 代表奇數格。
_FrameLookup存放偶數格與奇數格時會有的值。

隨後進入第一個判定,如果像素跟lookup對上,代表目前的像素是跟目前的render frame有對到的,直接取值作為這個像素的顏色即可。

readFromQuadrant()函式中,根據四個不同的quadrant做處理,如果是偶數像素(0 or 3),根據位置去取值即可,0是(0,0),所以load 1這個位置(左上),3是(1,1),所以load 0(右下)。
如果是奇數frame,一樣根據數值取左上右下點。

那如果目前像素不是由當前的frame處理的呢?這個時候就要往N-1 Frame去拿資料了。
由於N-1 Frame到N可能有變動,所以取樣時的sample pos就會把motion vector算進去。
這時用兩個條件來判斷是不是有遺失像素,第一個是計算出N-1 Frame的座標後,去看quadrant是不是跟目前的framelookup重疊到了;第二個是如果有motion變化,直接視為像素有遺失。

最後,如果沒有像素遺失,直接用N-1 Frame的座標正常readFromQuadrant(),有的話則需要用N Frame的座標來做內插補值。

Result

目標解析度訂為3840x2160,測試GPU為GTX1070 Ti(操一下啊)。
雖然截圖不是3840x2160,但畫面確實紮紮實實的畫在4k render target上。
比較一下前後差距。

圖6. 沒有CBR vs 有開CBR

除了Bloom的Blur半徑有差一些以外(畢竟是絕對半徑,結果會根據解析度而變),大部分的內容都有補回來。

Performance


圖7. 效能差距

效能差距相當明顯,4k下GPU花了23ms左右,而透過CBR後則是降低到了8.8ms。
得到的效能差距是相當大的!

Summary

如果有GPU效能瓶頸,利用CBR技術可以在最小的畫質損失下得到很高的效能收益。

參考資料: https://software.intel.com/en-us/articles/checkerboard-rendering-for-real-time-upscaling-on-intel-integrated-graphics

Github:
https://github.com/SquallLiu99/Checkerboard-Rendering-Deferred