圖1. Squall's Multi-Thread Forward Rendering
總之只是另一個鬼畜練功的日常,程式內容尚不想公開觀看
這邊只做個紀錄,並說明一些我的概念
畫面上借用了Unity的維京村落素材,讓我建構出基本的測試場景
前言
簡單來說,我認為Unity在D3D12/Vulkan這邊的造詣跟屎一樣
這兩個API主打多工執行繪圖指令,並有著較低的CPU Overhead
再加上自己有興趣,所以開了一個修羅場
也就是自製了SqMeshFilter、SqMeshRenderer、SqLight等等我自己的Component
我的Rendering完完全全是在底層DLL完成,只有把結果貼回來時用到Unity內建OnRenderImgae,不使用SRP是因為SRP改不了component本身的性質(只能在一個thread存取)
也順便為之後的生涯規劃鋪路
Native DLL的開發
一切繪圖指令的根源都要靠這個介面~
在Native這邊也能簡單用Console來輸出效能狀況
至於其他Debug訊息,例如會造成crash那種,由於是在DLL內執行看不到訊息,所以我搭配了DebugView,它能夠抓取幾乎所有的win32訊息,用outputdebugstring()之類的輸出可以被debugview看到,這樣Debug不成問題
而資源方面,Unity現在大致上都能提供Mesh/RenderTexture/Texture的NativePtr
例如我呼叫 diffuse.GetNativeTexturePtr(),就可以拿到ID3D12Resource*,借用unity的asset,這樣我可以更快進入核心演練~
最後一定要記得,Release Build DLL跑過了才算數,聽說過很多人Debug版正常但是Release版crash的,那就是細節沒注意好
RenderThread的設計
例如圖1上顯示了我有2907個draw
call,我的流程是把call平均分擔到三個核心上的~
不像unity只依賴了一個render thread,這邊最大化的去利用CPU核心的優勢
而Rendering時我的thread狀況如下:
圖2. Thread
主執行緒會去喚醒Render Thread,並等待渲染,而Render Thread又會再利用多個Worker Thread並行渲染,特地隔著一個Render Thread是因為有些工作必定只能單核執行(例如透明物件渲染一定要從後面到前面,不可打散)
另一個好處是可以達成Async Rendering,我可以不需要讓主執行緒去等待Render Thread,也就是圖1那2.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(),再使用unity的view/proj matrix然後再計算invert view把frustum轉成world space去比較。
要注意的是Unity提供的矩陣是GL-Base,view matrix某幾個元素要反轉(血淚測試)。
Sorting
直接使用了C++的Sort()函式,基於IntroSort()的library,它是一種混合型的排序方法,它始於Quick Sort,遞迴次數過多會轉成Heap Sort,物件數量不多則會轉成Insertion Sort,三種排序法的優點都給它吸收了。
由於只需針對看得到的物件排序,最差複雜度nlogn已經算是相當好用。
我在計算每個物件的Z距離之後,Opaque從前排到後,透明從後排到前。
Begin Frame
Frame的一開始,沒甚麼好說的,就是把一些渲染用的buffer做clear的動作。
Upload Work
上傳資料到GPU的工作,基本上所有用到的ConstantBuffer都少不了這一步。
圖7. 上傳GPU資料
以我的自製SystemConstant為例,用memcpy就能輕易上傳,但背後的原理需要知道一下。
首先buffer必須建立為D3D12_HEAP_TYPE_UPLOAD,表明了我就是要上傳。
然後利用ID3D12Resource->Map()這個函式讓我們可以複製資料。
有別於D3D11的Map/Unmap pattern,D3D12是可以辦到資源創好,下一次Map(),直到release才下Unmap()!
這樣CPU-GPU之間的資源傳遞會更有效率,但是要注意的是需要避免CPU-GPU同時使用資源的問題。
圖8. Ring Buffer等待資源
不知道圖3後面Fence值意義嗎?就是用在圖8這裡的。
圖3我在渲染結束後,在GPU的時間線上下了一個Fence。
在渲染之前,圖8這邊我會去檢查GPU的時間線進行到我設定的Fence了沒,超過了就代表OK,資源已經使用完畢,否則需要等待。
而我的流程準備了3個FrameResource,意即一個ConstantBuffer都建立了三份,雖然多佔了一點空間,但是利用Ring Buffer偷Frame讓傳輸效益提升,這也是微軟推薦的方式。
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
Pass跟Collect Shadow。
畫陰影很簡單,其實就是把Depth
shader小改一下而已,把ViewProj矩陣換成燈光視角的去畫就行,畫的時候我並沒有施加bias,我認為在取值的時候加就足夠了也更有彈性。
而Collect
Shadow,則是screen-space的接收陰影,unity也有,但我的版本是可以一盞燈光只下一次draw call,unity要每個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、最後接收的陰影
柔和方面做了PCF3x3、4x4、5x5,未來再試試看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。
燈光模型簡單使用Frensel跟BlinnPhong,但我沒有學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 Target用ResolveSubresource()做Resolve,貼回正常的畫面上。
雖然這邊說得簡單,但是RP這部分的程式佔了最大份量(茶
Shader管理方式
在底層,沒有什麼CGProgram,只有純粹的HLSL,這邊說一下我怎麼管理Shader的
圖14. Shader Model 5.1的Depth Shader
只要是連續的同類型資源,我們都能直接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,沒指定就是自動全抓
以圖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系統吧!?也沒有辦法即時看到修改的變化。
材質的管理方式
當然,我也有native的material,但嚴格說來只負責取值+建立。
D3D12不像D3D11,有一堆pipeline函式像是PSSetShader、VSSetConstantBuffer等等,取而代之的是使用Pipeline State Object(PSO),一個材質對應一個PSO。
圖15. 建立PSO
就是這樣,而在RP裡面要渲染時,呼叫ID3D12GraphicsCommandList::SetPipelineState()這個函式就能綁好我們需要的狀態,微軟也表示切換PSO的方式是比D3D11還要快速的,雖然建立起來更加複雜。
那參數的傳遞呢?圖14已經出現了我Material Constant的樣子,其實這邊我只針對了一種shader去傳,我還沒花時間去建立自己的material系統 (大坑),我簡單從unity
script這邊整理好我要的參數送過來而已。
圖16. 參數傳遞
傳遞struct,我沒做甚麼特別的處理,內容都是基本型別,我直接送c# struct,c++這邊用void*去接就成了!
好處是我在upload constant的時候,我不需要再把MaterialConstant的內容在c++端宣告一次(太蠢了),這也是指標的偉大之處~
雖然沒有自製material系統,但至少參數傳遞方面是滿意的了,有空再想要不要做這個系統。
Profile效能
Profile的部分,我用了QueryPerformanceCounter高精度計時器來profile cpu的部分。
而GPU部分,會比較複雜,需要在測量開始/結束時對GPU下D3D12_QUERY_TYPE_TIMESTAMP的query,然後將秒數從gpu撈回來換算=
=
所以GPU Profile總是會有峰值的,目前僅僅針對總和時間測量,之後考慮各種work的測量吧。
而目前是對editor
profile,build出來的執行檔可能表現又更好吧。
總結
算是完成個人第一階段的演練了,發揮了多核心渲染的優勢。
測試平台 i5 4690k
/ gtx 1070 ti
2907個draw 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方式等等。
由於身處整天把先蹲低再起跳掛在嘴邊,卻整天依賴第三方插件,沒有自己的核心技術,產品開發超過一年就該該叫的環境,想要變強真的只能靠自己修練了