2020年7月18日 星期六

Squall's Graphic - D3D12 Multi-Thread Forward Rendering




1. Squall's Multi-Thread Forward Rendering

總之只是另一個鬼畜練功的日常,程式內容尚不想公開觀看
這邊只做個紀錄,並說明一些我的概念
畫面上借用了Unity的維京村落素材,讓我建構出基本的測試場景

前言
簡單來說,我認為UnityD3D12/Vulkan這邊的造詣跟屎一樣
這兩個API主打多工執行繪圖指令,並有著較低的CPU Overhead
再加上自己有興趣,所以開了一個修羅場
也就是自製了SqMeshFilterSqMeshRendererSqLight等等我自己的Component
我的Rendering完完全全是在底層DLL完成,只有把結果貼回來時用到Unity內建OnRenderImgae,不使用SRP是因為SRP改不了component本身的性質(只能在一個thread存取)
也順便為之後的生涯規劃鋪路

Native DLL的開發
所幸,Unity也知道自己的程度,也有開放Native Graphic介面給開發者,這個介面能夠讓我拿到ID3D12Device*,夠了!

一切繪圖指令的根源都要靠這個介面~
Native這邊也能簡單用Console來輸出效能狀況

至於其他Debug訊息,例如會造成crash那種,由於是在DLL內執行看不到訊息,所以我搭配了DebugView,它能夠抓取幾乎所有的win32訊息,用outputdebugstring()之類的輸出可以被debugview看到,這樣Debug不成問題

而資源方面,Unity現在大致上都能提供Mesh/RenderTexture/TextureNativePtr
例如我呼叫 diffuse.GetNativeTexturePtr(),就可以拿到ID3D12Resource*,借用unityasset,這樣我可以更快進入核心演練~

最後一定要記得,Release Build DLL跑過了才算數,聽說過很多人Debug版正常但是Releasecrash的,那就是細節沒注意好

RenderThread的設計
例如圖1上顯示了我有2907draw call,我的流程是把call平均分擔到三個核心上的~
不像unity只依賴了一個render thread,這邊最大化的去利用CPU核心的優勢
Rendering時我的thread狀況如下:

 
2. Thread

主執行緒會去喚醒Render Thread,並等待渲染,而Render Thread又會再利用多個Worker Thread並行渲染,特地隔著一個Render Thread是因為有些工作必定只能單核執行(例如透明物件渲染一定要從後面到前面,不可打散)

另一個好處是可以達成Async Rendering,我可以不需要讓主執行緒去等待Render Thread,也就是圖12.4505 ms的渲染時間可以不用卡主執行緒(MIND BLOWING)
但為了穩定度我還是有讓主執行緒等待



 3. Thread內容

Thread內容基本上是圖3,頭的部分等待喚醒,尾的部分通知完成,利用核心當然很美好,但用不著讓他一直跑(不要busy-waiting就對了)Thread Handle都是beginthreadex來的

Render Pipeline總覽
接著正式進入RP的部分。


4. 渲染流程

打黑色星號的部分代表有利用到WorkerThread並行處理,不適合並行處理的工作就不需要。

5. WorkerThread的管理

每次指派工作,我會先把類型設定好,再做圖5的動作,其實就是去做ResetEvent() / SetEvent() / WaitForMultipleObjects()這三個動作,等到worker thread都確實做完了再繼續。
接著逐一說明流程在做啥~

Culling

6. Frustum Culling Test & DirectX Math
目前我只做了一般的Frustum Culling,透過<DirectXMath>函式庫,正確計算出Frustum就可以做Culling了。

另外一個推薦DirectX Math的原因是,它的實作是有根據硬體支援的指令集去選擇的,能夠達成更高速的計算,哪怕是像圖6那種vector相加,也有指令集優化。culling維京村落1127的物件下來0.5ms左右,不錯。

Frustum的生成是直接利用BoundingFrustum::CreateFromMatrix(),再使用unityview/proj matrix然後再計算invert viewfrustum轉成world space去比較。
要注意的是Unity提供的矩陣是GL-Baseview matrix某幾個元素要反轉(血淚測試)


Sorting
直接使用了C++Sort()函式,基於IntroSort()library,它是一種混合型的排序方法,它始於Quick Sort,遞迴次數過多會轉成Heap Sort,物件數量不多則會轉成Insertion Sort,三種排序法的優點都給它吸收了。

由於只需針對看得到的物件排序,最差複雜度nlogn已經算是相當好用。
我在計算每個物件的Z距離之後,Opaque從前排到後,透明從後排到前。

Begin Frame
Frame的一開始,沒甚麼好說的,就是把一些渲染用的bufferclear的動作。

Upload Work

上傳資料到GPU的工作,基本上所有用到的ConstantBuffer都少不了這一步。


7. 上傳GPU資料

以我的自製SystemConstant為例,用memcpy就能輕易上傳,但背後的原理需要知道一下。

首先buffer必須建立為D3D12_HEAP_TYPE_UPLOAD,表明了我就是要上傳。
然後利用ID3D12Resource->Map()這個函式讓我們可以複製資料。
有別於D3D11Map/Unmap patternD3D12是可以辦到資源創好,下一次Map(),直到release才下Unmap()!

這樣CPU-GPU之間的資源傳遞會更有效率,但是要注意的是需要避免CPU-GPU同時使用資源的問題。



8. Ring Buffer等待資源

不知道圖3後面Fence值意義嗎?就是用在圖8這裡的。
3我在渲染結束後,在GPU的時間線上下了一個Fence
在渲染之前,圖8這邊我會去檢查GPU的時間線進行到我設定的Fence了沒,超過了就代表OK,資源已經使用完畢,否則需要等待。

而我的流程準備了3FrameResource,意即一個ConstantBuffer都建立了三份,雖然多佔了一點空間,但是利用Ring BufferFrame讓傳輸效益提升,這也是微軟推薦的方式。

Profile的部分並沒有顯示這個時間,我覺得算在render time裡就行了,不過從圖1的結果看也只佔了0.5ms

PrePass Work
主要就只是Depth Pre Pass,深度目前只有用來實作陰影的接收。
透明類的就直接用cutout的方式去做,我並不打算加unity那種醜陋的抖動。


9. Depth Resolve
Depth畫完之後,我會馬上做MSAA Resolve,因為我要用它來接收陰影。
不用平均而是用max是因為平均會破壞掉Depth的性質。
Resolve之後,我在ResolvedDepth上也畫上了透明物件的陰影,理由後面再說。

Shadow Work

再來就是陰影,包含Shadow PassCollect Shadow
畫陰影很簡單,其實就是把Depth shader小改一下而已,把ViewProj矩陣換成燈光視角的去畫就行,畫的時候我並沒有施加bias,我認為在取值的時候加就足夠了也更有彈性。

Collect Shadow,則是screen-space的接收陰影,unity也有,但我的版本是可以一盞燈光只下一次draw callunity要每個cascade都下一次。


10. 陰影資料以及深度轉World Pos

Screen-space轉換深度為world position,再投影到shadow map上計算接收值,這樣的效率比起per-object接收還要好得多了。

裡面的SQ_MATRIX_INV_P對應到的是GL.GetGPUProjectionMatrix(camera.projectionMatrix, true).inverse

SQ_MATRIX_INV_V則是camera. worldToCameraMatrix.inverse,這邊絕對不要用cameraToWorldMatrix,數值完全不一樣()

只要能夠正確傳遞matrix,反轉回world pos很容易,不知為何有些複雜到還需要把深度轉到[0,1],算view ray什麼的,這裡簡單轉換


11. 某視角下的cascade、最後接收的陰影

柔和方面做了PCF3x34x45x5,未來再試試看PCSS
Opaque/Cutoff Rendering
Draw Call平均分擔到各個worker,乍聽之下Early-Z Culling的效益會減損(因為多執行緒不能保證渲染順序)但這邊我是將材質的的ZTest設置為Equal
有了Pre Pass Depth + 非常嚴格的完全等於,over draw其實降到最低了,而且至少每個worker畫的group還是從前排到後的。

至於Shader這邊,目前就單純的diffuse/specular/BRDF/簡單GI,之後有想到再加。



12. 部分燈光計算

燈光方面,打死我也不用Unity那種古老Multi-Pass的方式,太蠢了,明明可以將燈光資料整合在StructuredBuffer內的(9),我可以一次pass就處理完所有燈光(LWRP似乎也行),陰影方面就取樣使用剛才算好的Collect Shadow

燈光模型簡單使用FrenselBlinnPhong,但我沒有學unity使用disneydiffuse,覺得不是很必要。

GI的部分目前簡單加上ambient color,但不是直接整個加上去,那樣太平。
而是考慮到normal方向去增減ambient color (hemi sphere sky light),看起來會比較好些。

Skybox Pass
就是個skybox,沒甚麼需要注意的。

外行人可能認為skybox是先畫的(因為clear這個詞),但其實在opaque/cutoff rendering完成後才是最好的,因為有了深度資訊可以跳過很多pixel

Transparent Pass
不需要並行處理(也不能)pass,畢竟透明物件為了正確性要從後畫到前
效果跟Opaque/Cutoff不會差太多,不過還記得我前面說我有輸出透明深度嗎?
所以我的透明物件也是接收得到影子的


13. 透明物件接收陰影

缺點是多個透明物件覆蓋時,只會用最前面那個world pos接收,但仔細想想也不會差到哪,如果是前面有影子後面沒影子,正常,後面有影子前面的沒影子反正也會透過去的地方也能看到阿。

否則就要紮實的把陰影matrix丟進來,然後投影world pos到每個shadow map上面,per-object做的話很耗效能。

End Frame
總算到了End Frame,這裡很單純就是收尾,並且把MSAA TargetResolveSubresource()Resolve,貼回正常的畫面上。

雖然這邊說得簡單,但是RP這部分的程式佔了最大份量(

Shader管理方式

在底層,沒有什麼CGProgram,只有純粹的HLSL,這邊說一下我怎麼管理Shader


14. Shader Model 5.1Depth Shader

充分利用了5.1Dynamic Index,我們可以把貼圖放在一個Descriptor Table裡面,直接index到我們想要用的位置,所以在我的架構裡,貼圖都是用index去取的。
只要是連續的同類型資源,我們都能直接Dynamic Index到該資源的GPU位址。

這樣的好處是多張貼圖也只佔了一個shader欄位,而且這個Table是可以允許不同格式、大小的貼圖的!比起Texture2DArray更加有彈性的多了。

至於其他一些自製的pragma,我們都知道底層的shader compile是利用D3DCompileFromFile(),需要丟入shader entry point,以及自定義的define,我不想寫死!所以寫了一套簡單的Parser去解析hlsl內容。

l   sq_vertex: 遇到這個詞就抓後面的名字做為Vertex Shader進入點
l   sq_pixel: 後面的名字做為Pixel Shader進入點
l   sq_keyword: 相當於shader_feature,告訴系統有這個keyword存在
l   sq_srvStart/End: 其實就是準備抓Shader Resource,只是我用這個詞包起來,避免抓到local function裡面的定義
l   sq_cbuffer: 指定想要用的constant buffer,沒指定就是自動全抓
l   sq_srv: 指定想要用的shader resource,沒指定就是自動全抓

D3D12,必須為Shader建立RootSignature,告知系統Shader輸入的結構。
以圖9為例,我的parse會生成:
Root 0, ConstantBufferView, register 0
Root 1, ConstantBufferView, register 3
Root 2, DescriptorTable_TypeSRV, register 0
Root 3, DescriptorTable_TypeSampler, register 0

NVIDIA不建議共用Root signature,所以我將RS的結構降低到最小的set,真的有用到才編進來。

目前我的底層Shader都是runtime即時compile後使用,所以初始時會需要幾秒,下次考慮弄個cache系統吧!?也沒有辦法即時看到修改的變化。

材質的管理方式

當然,我也有nativematerial,但嚴格說來只負責取值+建立。
D3D12不像D3D11,有一堆pipeline函式像是PSSetShaderVSSetConstantBuffer等等,取而代之的是使用Pipeline State Object(PSO),一個材質對應一個PSO



15. 建立PSO

就是這樣,而在RP裡面要渲染時,呼叫ID3D12GraphicsCommandList::SetPipelineState()這個函式就能綁好我們需要的狀態,微軟也表示切換PSO的方式是比D3D11還要快速的,雖然建立起來更加複雜。

那參數的傳遞呢?14已經出現了我Material Constant的樣子,其實這邊我只針對了一種shader去傳,我還沒花時間去建立自己的material系統 (大坑),我簡單從unity script這邊整理好我要的參數送過來而已。



16. 參數傳遞
傳遞struct,我沒做甚麼特別的處理,內容都是基本型別,我直接送c# structc++這邊用void*去接就成了!

好處是我在upload constant的時候,我不需要再把MaterialConstant的內容在c++端宣告一次(太蠢了),這也是指標的偉大之處~

雖然沒有自製material系統,但至少參數傳遞方面是滿意的了,有空再想要不要做這個系統。

Profile效能
Profile的部分,我用了QueryPerformanceCounter高精度計時器來profile cpu的部分。

GPU部分,會比較複雜,需要在測量開始/結束時對GPUD3D12_QUERY_TYPE_TIMESTAMPquery,然後將秒數從gpu撈回來換算= =
所以GPU Profile總是會有峰值的,目前僅僅針對總和時間測量,之後考慮各種work的測量吧。

而目前是對editor profilebuild出來的執行檔可能表現又更好吧。

總結
算是完成個人第一階段的演練了,發揮了多核心渲染的優勢。

測試平台 i5 4690k / gtx 1070 ti
2907draw call / 2.4505ms (1 render thread, 3 worker threads)
              / 2.5157ms (1 render thread, 2 worker threads)
                          / 3.6105ms (1 render thread, 1 worker threads)

這個數據還算是滿意的,我甚至都還沒實作任何batching!
目前的場景大概開3核心就差不多了
GPU稍微高了一點,因為目前還沒做任何Batching,會要一直切換Vertex Buffer

下一次的演練,大方向的預計會購入RTX顯示卡,開始寫DirectX Raytracing API,來完成indirect specular(反射)的部分,以及加入point light spot light,跟兩者的shadow,之後會想試試forward plus優化這兩種燈光,shadow的部分則用光追處理,這兩種燈光實在不想用shadow mapping的方式。

再來就是一些細節修改吧,像是gpu profile更細,加入其他batching/culling方式等等。

由於身處整天把先蹲低再起跳掛在嘴邊,卻整天依賴第三方插件,沒有自己的核心技術,產品開發超過一年就該該叫的環境,想要變強真的只能靠自己修練了