1. 程式人生 > >在Unity裡寫一個純手動的渲染管線(一)

在Unity裡寫一個純手動的渲染管線(一)

隨著Unity3D 2018的面世,Scriptable Rendering Pipeline,也就是可程式設計渲染管線這項新技術變得家喻戶曉。官方在推出這項技術的時候,著重強調了他的各種優點,而筆者總結了一下官方的解釋,認為SRP有以下三個優點:簡單,簡單和簡單。

這第一個簡單,筆者認為,SRP的誕生大大降低了萌新學習渲染管線的難度曲線斜率。如果使用OpenGL或DirectX,學習C/C++和底層API呼叫還是小事,可若要編寫完整的渲染管線,一些對老鳥來說都很有挑戰性的工作就赤裸裸的擺在了萌新們的面前,比如各種剔除,LOD,排序的實現並將其並行化,以利用盡可能多的CPU核心,同時還要注意一些十分繁瑣的事情,比如資料結構的時間複雜度,對CPU快取的友好程度等等。因此,從零開發一款遊戲引擎的渲染管線是極難的,這也是為什麼當前市場對於引擎工程師的要求是非常苛刻的。而SRP的出現對萌新們來說是一個福音,將繁瑣的過程進行高階封裝,在學習的過程中可以由淺入深,由零化整,不至於在某個或某些細節上卡太久。我們完全可以在瞭解了整個繪製過程之後,再考慮學習圖形API以瞭解每個Unity中的呼叫分別是如何實現的。

第二個簡單,筆者認為,SRP的誕生同時也降低了老司機們的工作難度。在當前市場人才稀缺的情況下,老司機們常需要作為“巨神阿特拉斯”扛起開發的整片天,一般來講工作壓力大且開發節奏飛快。本人雖不算老司機,但也有幸在國內某大廠的技術美術崗上“體驗”過一把一人當兩人用的工作節奏。本來在快樂的寫些小工具,做些測試,這時,對面的美術和策劃一拍即合,強行達成共識,並且用邪魅的眼神朝引擎組或TA組幾個可憐蛋們看了一眼,並且提出了一個一定要魔改渲染管線才可以實現的圖形效果需求。如果是Unreal Engine的專案,可能這幾周就得花時間在改原始碼編譯引擎上了,而如果是Unity的專案,怕是要用Graphics, GL, CommandBuffer這些觸碰底層的API強行給當前的管線加料,有時候甚至要把一些官方的實現繞開,而渲染管線這種底層功能又會涉及到一些與專案組其他同事的合作問題……唔。。不說能否實現,數星期之後老司機們的肝是否還健在這都是個問題。而SRP的出現則確實的解決了這個痛點,通過一些如Culling,DrawPass這樣的封裝,讓管線程式設計像搭積木一樣,三言兩語就實現完畢,把繁雜的,重複性高的活留給引擎,把關鍵的,決定性的和影響專案效果的把握在自己手裡,這是SRP的另一個設計目的。

第三個簡單,筆者認為,SRP的誕生降低了大規模次時代團隊開發的難度。和一些年輕的團隊靠幾個老司機撐起整支隊伍的的技術主力不同,許多國內外的成熟團隊,在開發大作時常需要幾十人甚至上百人的引擎組進行引擎的定製,研發。在這種時候,專案的解耦,分工就顯得尤為重要。然而,專案自己的框架由於時間和成本,一般設計比較倉促,通用性較差,很有可能造成重複造輪子的現象。SRP在設計時考慮到了這一點,並試圖通過這類封裝,提高框架通用性,降低溝通成本,進而縮小開發成本,提高開發容錯率。

既然SRP這麼好,我們為什麼要寫一個“純手動”的渲染管線呢?原因是,由於SRP的封裝實在太過高層,反而對於接觸渲染不久或接觸Unity不久的朋友容易產生誤會,因此我們這裡就不詳細解釋SRP的使用方法,我們會從一些Unity先前提供的一些API,來了解應該如何用最基礎的方法寫一個簡單的幾何學物體。等到對Unity的渲染API和整體的渲染管線過程有些瞭解以後,學習SRP時就能充分感受到其設計的善意。

首先,Unity中最基礎的繪製方法是Camera,Camera的每一次Render就是一整條管線,然而我們要拋開Camera,直接往畫面上懟,因此我們需要強行讓Camera不渲染任何物體:

這時螢幕會是黑色的,因為並沒有任何物體被渲染也沒有發生任何操作。

然後我們在攝像機上新增一個指令碼,並且寫入如下程式碼:

那麼這一段程式碼是非常簡單的,手動建立了一個RT,並且在攝像機繪製完,回撥OnPostRender方法時,將手動建立的RT執行Clear操作,使其變成一個灰色的貼圖,然後將這個RT繪製到camera的targetTexture上,如果targetTexture為null則會直接繪製到螢幕上。

接下來我們將試圖繪製一個純色的方塊在螢幕上,這同樣非常簡單:

其中少了一句 cam.TargetTexture=rt;

cubeTransform和cubeMesh是隨便在攝像機鏡頭前擺了一個Cube,Material則是一個使用Unlit/Color的簡單純色Shader:

可以看到,一個簡單粗暴的drawcall就已經被創造出來了,一個drawcall原則上分為Set Pass和Draw兩部分,Set Pass可以簡單的理解成,將需要用到的Shader Pass以及Material中的變數傳送到顯示卡,而Draw指令則是告訴顯示卡,是時候開始繪製了。一般在進行效能優化時所說的降低優化,其實主要目的是為了降低SetPass的數量,因為SetPass需要進行大量的拷貝,校驗工作,這個過程對於CPU的資源消耗是非常嚴重的,SetPass大概佔整個Drawcall的80%以上的時間,這也是為何我們平時儘可能使用少的材質,讓Unity來統計一幀中使用同一材質的物體,只進行一次SetPass就可以繪製多個物體,從而提高渲染效率。比如我們這裡可以嘗試使用同一個材質繪製多個方塊:

繪製出來的結果如下:

這樣就會繪製6個方塊,算上背景即7個batch,而Set Pass則只有2個,有興趣的朋友可以測試一下,通過這樣的合批方法,降低SetPass數量,可以大大降低效能消耗,這也是我們平時進行渲染優化時的重要理論依據。

灰色的屏實在醜陋的無法直視,天空盒子自然是必須的,我們準備新增一個天空盒子背景,天空盒子在實現上相當於一個一直停留在最遠處,即投影座標為1的位置的巨大“盒子”,然鵝我們需要做的也就是獲得每個畫素的方向,然後通過讀取Cubemap就可以實現所謂的盒子效果。

這裡我們將手動生成一個鋪滿全屏的Mesh,這裡是有些難以理解的,但是學過計算機圖形學基礎的朋友,應該會容易明白NDC座標在OpenGL的定義(Unity使用的是OpenGL標準),大致講解一下,即-1是左和下,1是右和上, 0是遠裁面,1是近裁面,那麼我們做的這個攤開在遠裁面的Mesh大致看起來就應該是這樣,注意,因為我們使用的是DX平臺,所以UV的y軸是倒過來的:

接下來就是要把4個遠裁面角傳入到Shader中:

最後在Shader裡使用這張已經鋪開的Mesh,並且讀取CubeMap:

到這裡,繪製順序大致如下,繪製了幾個不透明的純色方塊,然後在遠裁面繪製一個Skybox,效果如下:

可是,可是,我們沒有最重要的光照啊!如果只想要簡單計算一個Directional Light,那麼確實可以直接當做一個全域性變數傳入到Shader中,不過我們認為,應該實現一個健全一些的光照,順便多瞭解一點Render Target的知識,所以實現一個簡易的Deferred Shading想必是極好的。

Deferred Shading在計算光照上非常的方便,我們只需要在Shader中輸出4個貼圖,技術上一般稱之為GBuffer,而這4張貼圖將會作為貼圖變數傳入到一個後處理指令碼中,計算每個畫素點的光照,由於光照統一運算,不需要進行Per Object Light,大大降低了開發難度(我們暫時不Care效能問題~),當然咯,前提是得先有一個Deferred Pass Shader,我們可以直接從Unity提供的Builtin Standard Shader裡的Deferred Pass,官網可以直接下載到。

Deferred Shading的第一步在於MRT,即Multi Render Target。而Unity的SetRenderTarget早就支援了這個,首先我們要明白,每一個RenderTexture都有ColorBuffer和DepthBuffer,顯然前者就是一般我們在遊戲中使用的色彩,而後者則是一個16位的投影深度+8位的Stencil,這個在RenderTexture的文件中可以查到,GBuffer中應該儲存當前畫素的Albedo, Specular, AO,normal,Depth等。然後再在後處理指令碼中,繪製一個全屏的Mesh,從而對每一個畫素點進行光照運算,如果是點光源則應該繪製一個剔除掉前面只顯示背面球,然後在球所在的範圍內的畫素中,計算所有的畫素點。

首先,我們需要制定GBuffer,這裡為了更好的時候預設的Deferred Pass而不重複造輪子,我們果斷模仿一下Unity的GBuffer分佈:

在這段初始化程式碼裡,我們制定了總共三塊RT,第一塊是CameraTarget,也就是光照計算完以後最終的輸出結果,他也將會是最後繪製天空盒的RenderTarget。第二塊則是4個貼圖,也就是GBuffer,我們會在Shader中輸出這幾個值,第三個則是depthTexture,他將起到擔任depthbuffer和輸出最終深度的作用,因為需要每個畫素的世界座標,顯然深度圖是必不可少的。

底下的DrawMesh操作並無變化,但是SetRenderTarget上確實有顯著的變化,我們需要把這些RenderTexture作為Target命令顯示卡輸出,那麼接下來的程式碼就變成了這個樣子:

這段程式碼非常容易理解,把各個RenderTexture歸位,而Shader裡的MRT輸出則應該是這樣的:

SV_Target就對應我們剛才傳入的4個RenderBuffer,其他方面與普通Shader Pass並無區別,接下來,我們就需要將GBuffer傳入到負責光照的後處理材質中,然後輸出到CameraTarget了:

通過深度圖反推世界座標就需要viewProjection matrix的逆矩陣,所以我們使用API分別獲取Projection和View,然後計算逆矩陣並且傳入材質,隨後將GBuffer,光照資訊分別傳入材質中,最後直接將結果blit到camera target即可(由於我們暫不考慮效能,因此沒有使用stencil或Tiled做Light Culling,這些有機會日後再加)。在Deferred lighting這個shader中,我們將使用傳入的資料計算畫素點的光照,只需要對官方的builtin shader進行一些小改動就可以了,我這裡用的光照運算的Shader大概是這樣的:

Shader中沒什麼技術含量,套了一下Unity提供的BRDF公式就可以了,這些都在Editor/Data/CGIncludes這個資料夾中,只需要拿出來用就行了。注意,這裡邊有個UnityIndirect是暫時沒有使用的,我們不需要在意他會不會導致效能問題,Shader在編譯時,所有類似的,直接賦值的常量,一般是會直接被忽略掉的。

那麼最終輸出的結果如何呢(為了讓光照明顯一些,我把cube換成了sphere):

可以看到,結果非常完美,天空盒和基於物理的光照交相輝映。在本章節中,我們實現了從零開始,建立RenderTarget,繪製模型,光照和天空盒,而一個健全的渲染管線則還需要更多的操作,如陰影,間接光,剔除,排序甚至多執行緒併發等,我們會挑一些重要的,更快理解渲染管線的基本操作,在日後接觸SRP時,就可以充分享受SRP帶來的快樂,方便的管線程式設計體驗。

知乎@MaxwellGeng