淺談多執行緒程式設計與Unity的C# Job System
前陣子把bilibili上的Games104刷完,其中一堂有講到多執行緒與Job system,決定把一些重點記錄下來然後順便介紹一下Unity中可以實作平行處理的C# job system。
多執行緒程式設計(Multithread Programming) - 將我們的程式任務分配到多個thread去執行,來更有效率的利用硬體還並達成加速的效果,
那為何會需要Job System? 像我用的C#本身有提供Thread.Start還有像是TaskFactory.StartNew這類的API可以使用,Job System的multithreading跟我們直接呼叫這些API有哪些差異? 在討論差異之前我們要先了解寫多執行緒程式時的優缺點。
寫多執行緒程式的優勢 將Main thread上計算的壓力分擔到其他thread上,解掉CPU Bound。 對遊戲的客戶端來說,Main thread就是專案內所有code都在搶資源的戰場,但最終玩家也會期待main thread可以在16.66毫秒(60fps)內消化完所有的計算。
對近代CPU來說,單一核心的clock speed成長已經趨緩,多核心的CPU是市場的趨勢。
寫多執行緒程式時常見的問題 相較於單一執行緒,這些是寫多執行緒時常見的問題 :
Race Condition
Deadlock
Context Switch
Debug & Other
Race Condtion 當不同thread同時對一份資料做讀取&寫入時,就會產生race condtion,以下圖為例,假設原本有一份Data內的值是2,兩個thread - A與B同時對這個值做+5的動作,雖然我們身為旁觀者可以很直覺的預期結果應該是2+5(A)+5(B) = 12,但事實上Thread A跟B在讀這筆資料的時候有可能都還是2,最後寫入的時候卻把另一個thread算出的結果覆蓋掉了,輸出的結果變成7。
當程式中有race condtion時,整支程式就會變得非常不穩定而且還難以debug,因為往往是執行時的時間差造成預期外的結果。
Deadlock 為了避免race condition,常見的作法就是用lock。當某個thread要對某個資料做讀寫時,設定一個lock,其他thread如果想要更改這份資料,必須等待原本lock這份資料的thread做unlock的動作。但這又衍生出另一個問題 - 如果沒有unlock呢? 如果thread跑一跑出現exception直接死了呢? 這種情況就是deadlock,其他thread會一直等待下去。過沒多久程式就會垮了。
Context Switch CPU的core數是有限的,程式內new出來的thread數有可能超過當前可執行的core數,這種時候CPU的core必須暫停當前執行的thread task,轉而執行更高priority的thread task﹐而CPU的core做thread的切換的動作即是context switch。
執行Context switch的時候,在切換到新的thread之前,要先將當前thread的狀態記錄下來,之後才能回頭過來繼續執行。而切換到新的thread之後,由於新的thread內所需的資料有可能不再當前的cpu cache內,CPU又必須重新在從RAM內把需要的資料抓出來。
以上的這些操作對於效能來說都是昂貴的操作,可以慢至1萬~100萬nano seconds( 從L1 cache讀取資料只需要1 nanosecond ),所以在寫遊戲的程式時,我們希望能夠盡量避免出現context switch。
讓Shadergraph也能使用DrawMeshInstancedIndirect
在渲染數量超過上萬個物件時,一般會使用叫做Instanced Indirect的API,在Unity內則是透過呼叫Graphics.DrawMeshInstancedIndirect這個方法,不論shader是用CG還是在SRP的HLSL寫都可以透過這個API來高效率的渲染出大量的物件。
但如果每次想用instanced indirect時都重寫一個custom shader,不免有點曠日費時,尤其像是HDRP管線這種光照計算更加複雜的情況,寫custom shader的成本就很高,於是我把歪腦筋動到shadergraph上,畢竟shadergraph拉出來的shader比較能無痛在各SRP間切換,修改效果的成本也更低。接著稍微查一下資料就會發現shadergraph並不支援DrawMeshInstancedIndirect這一個方法,不過好險是有辦法讓它支援的。讓它支援的方式藏在Particle System GPU Instancing的實作中。
首先,shadergraph內新增一個custom function node,設為string模式。內容如下 :
這個node的用途就是讓這支shader能跑#pragma那段。 關鍵就是prcedural:SetupFunc(或任何你想取的funtion名) 這一段,當執行
#pragma instancing_option procedural:SetupFunc 後,unity除了執行SetupFunc,還會把instancedID相關的MACRO都跑一遍,也就讓這支shader變成支援procedural instancing的shader,在這之後就可以透過unity_InstanceID來取從C# script內傳進GPU的structure buffer內的data。
接著,再新增一個custom function node,這次用file模式,這個node必須呼叫任何一個include file內的function(比如說下方的Dummy),shadergraph內才會真的存有上方指定的procedural function的程式碼。
include file的內容,可以參考這部分(點我展開)
vertInstacingSetup裡面直接修改了unity_objectToWorld矩陣,讓instanced mesh的object position可以根據unity_InstanceID去取buffer內對應的data作移動、旋轉、縮放,
而檔案最下方的InstancingColor則是再新增一個custom function node來呼叫,function內部一樣根據unity_InstanceID取buffer內對應的color。
最後拉出一個每個instanced的旋轉速度不一樣、顏色隨機的shadergraph來作測試,
C#的部分大部分沿用unity範例,在HDRP下Demo :
參考資料 https://twitter.com/Cyanilux/status/1396848736022802435?s=20
https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstancedIndirect.html
https://github.com/Unity-Technologies/Graphics/blob/4b98cece5623e02f03c9ff311bca0f749ba4fd94/Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl
Verlet Integration在Unity內的簡易實作
Verlet integration(韋爾萊積分法)是一種針對牛頓第二運動定力的積分法,常用於程式中的布料模擬以及計算繩索、Ragdoll之類的效果。
最初發現Verlet integration的契機,是之前在做Ragdoll研究的時候,碰巧翻到一篇Hitman(刺客任務)開發者以前發表過的一Advanced Character Physics,文中提到他必須想辦法讓遊戲內被殺死的NPC做出夠有說服力的Ragdoll效果(玩家要可以互動、拖拉…等等),又必須讓Ragdoll計算的效能足夠輕量化,把身體每個部位都當成Rigidbody想必是不可行的(當時是西元2000年),於是Verlet integration就是他們用來計算ragdoll motion的方法。
Unity URP內自訂義光照的Shader
之前心血來潮做的風格化渲染Shader,已經上架一段時間,順便發一篇筆記來幫助自己整理一下腦袋。
關於Unity內自訂光照的shader在中文的網路資源中較多停留在Built-in render pipeline時代,SRP家族的Universal Render Pipeline則比較少,不過受惠於SRP開源的性質,trace一下原始碼可以很快就搞懂整個lit shader的渲染流程。
這篇文章我會寫一個自訂光照的shader,包括cast/receive陰影與支援多個光源,會順便爬一下com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl 的程式碼,從而幫助我們了解Unity的lit/simple lit shader在做Forward rendering時的一些細節(Deffered rendering之後再講)。
自訂義光照的Shader Unity官方文件有提供基礎的Unlit shader的範例,但要製作一個有完整的shader我們還需要在shader內補上其他帶有不同pass tag的pass。 這份程式碼是一個簡單的template。(太長,貼link就好)
將這個shader掛上材質球後,assign給場景上的mesh renderer,如下圖。
在shader code的最下方可以看到好幾個use pass,原因是渲染管線會針對不同的LightMode tag在不同的時機抓出這些pass進行渲染 :
ShadowCaster會在管線渲染shadow map的時候,將這個物件的深度從光源視角畫進shadow map內,唯有補上這個pass,才能讓物體cast出陰影。 —
Depth Only則是從當前相機視角將物件深度畫進depth texture內。URP的depth prepass也會使用到。
Meta Pass用於光照烘培,Unity在build專案的過程會自動把這個pass抽掉。
Universal2D用於URP的2D renderer以及2d lighting,不過我沒有深入研究過。
這裡用use pass是偷懶,SRP batcher會因為其他shader的cbuffer不一致失效,實際操作時應該要自己寫對應各自pass tag的pass,不過code會很長。
這些pass都補上之後,一個物件在專案場景渲染中必要的元素(深度、寫入陰影貼圖、烘培),就只差物體本身的顏色&光照了,Template最上方,LightMode為UniversalForward的pass就是主要需要自己寫的部分。
在Unity實現風格化渲染的Shader
風格化渲染,也稱為NPR或者Toon shading,可能是在除了Unity內建的standard shader外,Unity的開發者們最常使用的一種渲染風格。前幾個月自己寫了一個風格化渲染的shader,下文將我寫的shader內重點feature大致分為下列幾個項目。
基於漸變貼圖的漫反射光照(Ramp lighting)
風格化的Specular Highlight
邊緣光Rim Lighting)
描邊效果(Outline)
頭髮上的Specular Highlight
半色調效果(Halftone Overlay)
素描效果(Hatching Overlay)
自訂陰影顏色(Custom Shadow Color)
自訂材質編輯器
其他
基於漸變貼圖的漫反射光照(Ramp lighting) 在諸多常見的光照模型中,用於計算漫反射的Lambert模型還有Half-Lambert模型是最常用到的,透過dot(normal, lightDirection)[之後就稱NdotL]的計算結果我們可以讓物體表面的顏色呈現出一個基本的漫反射光照。
{:.blog-post-md-img-half}
但在做風格化渲染時,我們希望對於光照在物體表面的顏色可以有更多客製的能力,基於漸變貼圖的漫反射光照就是一個解決的方法,原理是不僅僅是將NdotL的值作為光照在物體表面的強度,而是將NdotL當作uv值去採樣一張漸變貼圖(Ramp texture),透過這個步驟我們可以讓原本漫反射光照的結果可以完全由貼圖上的顏色漸層去控制。
{:.blog-post-md-img-15}{:.blog-post-md-img-70}