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的取樣模式剛好是左上右下,一個像素有兩個取樣點的模式,恰好可以利用來補足解析度砍半的像素資訊。 (MSAA的取樣模式可以參考這篇)
圖3. 2x MSAA插幀
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