
一、前言
在今年初Epic放出了UE5技術演示Demo之后,關於UE5的討論就一直未曾停止,相關技術討論主要圍繞兩個新的feature:全局照明技術Lumen和極高模型細節技術Nanite,已經有一些文章[1][2]比較詳細地介紹了Nanite技術。本文主要從UE5的RenderDoc分析和源碼出發,結合一些已有的技術資料,旨在能夠提供對Nanite直觀和總覽式的理解,並理清其算法原理和設計思想,不會涉及過多源碼級別的實現細節。
二、次世代模型渲染,我們需要什么?
要分析Nanite的技術要點,首先要從技術需求的角度出發。近十年來,3A類游戲的發展都逐漸趨向於兩個要點:互動式電影敘事和開放大世界。為了逼真的電影感cutscene,角色模型需要纖毫畢現;為了足夠靈活豐富的開放世界,地圖尺寸和物件數量呈指數級增長,這兩者都大幅度提升了場景精細度和復雜度的要求:場景物件數量既要多,每個模型又要足夠精細。
復雜場景繪制的瓶頸通常有兩個:
- 每次Draw Call帶來的CPU端驗證及CPU-GPU之間的通信開銷;
- 由於剔除不夠精確導致的Overdraw和由此帶來的GPU計算資源的浪費;
近年來渲染技術優化往往也都是圍繞這兩個難題,並形成了一些業內的技術共識。
針對CPU端驗證、狀態切換帶來的開銷,我們有了新一代的圖形API(Vulkan、DX12和Metal),旨在讓驅動在CPU端做更少的驗證工作;將不同任務通過不同的Queue派發給GPU(Compute/Graphics/DMA Queue);要求開發者自行處理CPU和GPU之間的同步;充分利用多核CPU的優勢多線程向GPU提交命令。得益於這些優化,新一代圖形API的Draw Call數量相較於上一代圖形API(DX11、OpenGL)提高了一個數量級[3]。
另一個優化方向是減少CPU和GPU之間的數據通訊,以及更加精確地剔除對最終畫面沒有貢獻的三角形。基於這個思路,誕生了GPU Driven Pipeline。關於GPU Driven Pipeline以及剔除的更多內容,可以讀一讀筆者的這篇文章[4]。
得益於GPU Driven Pipeline在游戲中越來越廣泛的應用,把模型的頂點數據進一步切分為更細粒度的Cluster(或者叫做Meshlet),讓每個Cluster的粒度能夠更好地適應Vertex Processing階段的Cache大小,並以Cluster為單位進行各類剔除(Frustum Culling、Occulsion Culling和Backface Culling)已經逐漸成為了復雜場景優化的最佳實踐,GPU廠商也逐漸認可了這一新的頂點處理流程。
但傳統的GPU Driven Pipeline依賴Compute Shader剔除,剔除后的數據需要存儲在GPU Buffer內,經由Execute Indirect這類API,把剔除后的Vertex/Index Buffer重新喂給GPU的Graphics Pipeline,無形中增加了一讀一寫的開銷。此外頂點數據也會被重復讀取(Compute Shader在剔除前讀取以及Graphics Pipeline在繪制時通過Vertex Attribute Fetch讀取)。
基於以上的原因,為了進一步提高頂點處理的靈活度,NVidia最先引入了Mesh Shader[5]的概念,希望能夠逐步去掉傳統頂點處理階段的一些固定單元(VAF,PD一類的硬件單元),並把這些事交由開發者通過可編程管線(Task Shader/Mesh Shader)處理。


Cluster示意圖

傳統的GPU Driven Pipeline,剔除依賴CS,剔除的數據通過VRAM向頂點處理管線傳遞

基於Mesh Shader的Pipeline,Cluster剔除成為了頂點處理階段的一部分,減少沒必要的Vertex Buffer Load/Store
三、這些就夠了嗎?
至此,模型數、三角形頂點數和面數的問題已經得到了極大的優化改善。但高精度的模型、像素級別的小三角形給渲染管線帶來了新的壓力:光柵化和重繪(Overdraw)的壓力。
軟光柵化是否有機會打敗硬光柵化?
要弄清楚這個問題,首先需要理解硬件光柵化究竟做了什么,以及它設想的一般應用場景是什么樣的,推薦感興趣的讀者讀一讀這篇文章[6]。簡單來說:傳統光柵化硬件設計之初,設想的輸入三角形大小是遠大於一個像素的。基於這樣的設想,硬件光柵化的過程通常是層次式的。
以N卡的光柵器為例,一個三角形通常會經歷兩個階段的光柵化:Coarse Raster和Fine Raster,前者以一個三角形作為輸入,以8x8像素為一個塊,將三角形光柵化為若干塊(你也可以理解成在尺寸為原始FrameBuffer 1/8*1/8大小的FrameBuffer上做了一次粗光柵化)。
在這個階段,借由低分辨率的Z-Buffer,被遮擋的塊會被整個剔除,N卡上稱之為Z Cull;在Coarse Raster之后,通過Z Cull的塊會被送到下一階段做Fine Raster,最終生成用於着色計算的像素。在Fine Raster階段,有我們熟悉的Early Z。由於Mip-Map采樣的計算需要,我們必須知道每個像素相鄰像素的信息,並利用采樣UV的差分作為Mip-Map采樣層級的計算依據。為此,Fine Raster最終輸出的並不是一個個像素,而是2x2的小像素塊(Pixel Quad)。
對於接近像素大小的三角形來說,硬件光柵化的浪費就很明顯了。首先,Coarse Raster階段幾乎是無用的,因為這些三角形通常都是小於8x8的,對於那些狹長的三角形,這種情況更糟糕,因為一個三角形往往橫跨多個塊,而Coarse Raster不但無法剔除這些塊,還會增加額外的計算負擔;另外,對於大三角形來說,基於Pixel Quad的Fine Raster階段只會在三角形邊緣生成少量無用的像素,相較於整個三角形的面積,這只是很少的一部分;但對於小三角形來說,Pixel Quad最壞會生成四倍於三角形面積的像素數,並且這些像素也包含在Pixel Shader的執行階段,使得WARP中有效的像素大大減少。

小三角形由於Pixel Quad造成的光柵化浪費
基於上述的原因,在像素級小三角形這一特定前提下,軟光柵化(基於Compute Shader)的確有機會打敗硬光柵化。這也正是Nanite的核心優化之一,這一優化使得UE5在小三角形光柵化的效率上提升了3倍[7]。
Deferred Material
重繪的問題長久以來都是圖形渲染的性能瓶頸,圍繞這一話題的優化也層出不窮。在移動端,有我們熟悉的Tile Based Rendering架構[8];在渲染管線的進化歷程中,也先后有人提出了Z-Prepass、Deferred Rendering、Tile Based Rendering以及Clustered Rendering,這些不同的渲染管線框架,實際上都是為了解決同一個問題:當光源超過一定數量、材質的復雜度提升后,如何盡量避免Shader中大量的渲染邏輯分支,以及減少無用的重繪。有關這個話題,可以讀一讀我的這篇文章[9]。
通常來說,延遲渲染管線都需要一組稱之為G-Buffer的Render Target,這些貼圖內存儲了一切光照計算需要的材質信息。當今的3A游戲中,材質種類往往復雜多變,需要存儲的G-Buffer信息也在逐年增加,以2009年的游戲《Kill Zone 2》為例,整個G-Buffer布局如下:

除去Lighting Buffer,實際上G-Buffer需要的貼圖數量為4張,共計16 Bytes/Pixel;而到了2016年,游戲《Uncharted 4》的G-Buffer布局如下:


G-Buffer的貼圖數量為8張,即32 Bytes/Pixel。也就是說,相同分辨率的情況下,由於材質復雜度和逼真度的提升,G-Buffer需要的帶寬足足提高了一倍,這還不考慮逐年提高的游戲分辨率的因素。
對於Overdraw較高的場景,G-Buffer的繪制產生的讀寫帶寬往往會成為性能瓶頸。於是學界提出了一種稱之為Visibility Buffer的新渲染管線[10][11]。基於Visibility Buffer的算法不再單獨產生臃腫的G-Buffer,而是以帶寬開銷更低的Visibility Buffer作為替代,Visibility Buffer通常需要這些信息:
(1)Instance ID,表示當前像素屬於哪個Instance(16~24 bits);
(2)Primitive ID,表示當前像素屬於Instance的哪個三角形(8~16 bits);
(3)Barycentric Coord,代表當前像素位於三角形內的位置,用重心坐標表示(16 bits);
(4)Depth Buffer,代表當前像素的深度(16~24 bits);
(5)Material ID,表示當前像素屬於哪個材質(8~16 bits);
以上,我們只需要存儲大約8~12 Bytes/Pixel即可表示場景中所有幾何體的材質信息,同時,我們需要維護一個全局的頂點數據和材質貼圖表,表中存儲了當前幀所有幾何體的頂點數據,以及材質參數和貼圖。
在光照着色階段,只需要根據Instance ID和Primitive ID從全局的Vertex Buffer中索引到相關三角形的信息;進一步地,根據該像素的重心坐標,對Vertex Buffer內的頂點信息(UV,Tangent Space等)進行插值得到逐像素信息;再進一步地,根據Material ID去索引相關的材質信息,執行貼圖采樣等操作,並輸入到光照計算環節最終完成着色,有時這類方法也被稱為Deferred Texturing。
下面是基於G-Buffer的渲染管線流程:

這是基於Visibility-Buffer的渲染管線流程:

直觀地看,Visibility Buffer減少了着色所需要信息的儲存帶寬(G-Buffer -> Visibility Buffer);此外,它將光照計算相關的幾何信息和貼圖信息讀取延遲到了着色階段,於是那些屏幕不可見的像素不必再讀取這些數據,而是只需要讀取頂點位置即可。基於這兩個原因,Visibility Buffer在分辨率較高的復雜場景下,帶寬開銷相比傳統G-Buffer大大降低。但同時維護全局的幾何和材質數據,增加了引擎設計的復雜度,同時也降低了材質系統的靈活度,有時候還需要借助Bindless Texture[12]等尚未全硬件平台支持的Graphics API,不利於兼容。
四、Nanite中的實現
羅馬絕非一日建成。任何成熟的學術和工程領域孕育出的技術突破都一定有前人的思考和實踐,這也是為什么我們花費了大量的篇幅去介紹相關技術背景。Nanite正是總結前人方案,結合現時硬件的算力,並從下一代游戲技術需求出發得到的優秀工程實踐。
它的核心思想可以簡單拆解為兩大部分:頂點處理的優化和像素處理的優化。其中頂點處理的優化主要是GPU Driven Pipeline的思想;像素處理的優化,是在Visibility Buffer思想的基礎上,結合軟光柵化完成的。借助UE5 Ancient Valley技術演示的RenderDoc抓幀和相關的源碼,我們可以一窺Nanite的技術真面目。整個算法流程如圖:

Instance Cull && Persistent Cull
當我們詳細地解釋了GPU Driven Pipeline的發展歷程以后,就不難理解Nanite的實現:每個Nanite Mesh在預處理階段,會被切成若干Cluster,每個Cluster包含128個三角形,整個Mesh以BVH(Bounding Volume Hierarchy)的形式組織成樹狀結構,每個葉節點代表一個Cluster。剔除分兩步,包含了視錐剔除和基於HZB的遮擋剔除。其中Instance Cull以Mesh為單位,通過Instance Cull的Mesh會將其BVH的根節點送到Persistent Cull階段進行層次式地剔除(若某個BVH節點被剔除,則不再處理其子節點)。
這就需要考慮一個問題:如何把Persistent Cull階段的剔除任務數量映射到Compute Shader的線程數量?最簡單的方法是給每棵BVH樹一個單獨的線程,也就是一個線程負責一個Nanite Mesh。但由於每個Mesh的復雜度不同,其BVH樹的節點數、深度差異很大,這樣的安排會導致每個線程的任務處理時長大不相同,線程間互相等待,最終導致並行性很差;那么能否給每個需要處理的BVH節點分配一個單獨的線程呢?這當然是最理想的情形,但實際上我們無法在剔除前預先知道會有多少個BVH節點被處理,因為整個剔除是層次式的、動態的。
Nanite解決這個問題的思路是:設置固定數量的線程,每個線程通過一個全局的FIFO任務隊列去取BVH節點進行剔除,若該節點通過了剔除,則把該節點的所有子節點也放進任務隊列尾部,然后繼續循環從全局隊列中取新的節點,直到整個隊列為空且不再產生新的節點。這其實是一個多線程並發的經典生產-消費者模式,不同的是,這里的每個線程既充當生產者,又充當消費者。通過這樣的模式,Nanite就保證了各個線程之間的處理時長大致相同。

整個剔除階段分為兩個Pass:Main Pass和Post Pass(可以通過控制台變量設置為只有Main Pass)。這兩個Pass的邏輯基本是一致的,區別僅僅在於Main Pass遮擋剔除使用的HZB是基於上一幀數據構造的,而Post Pass則是使用Main Pass結束后構建的當前幀的HZB,這樣是為了防止上一幀的HZB錯誤地剔除了某些可見的Mesh。
需要注意的是,Nanite並未使用Mesh Shader,究其原因,一方面是因為Mesh Shader的支持尚未普及;另一方面是由於Nanite使用軟光柵化,Mesh Shader的輸出仍要寫回GPU Buffer再用於軟光柵化輸入,因此相較於CS的方案並沒有太多帶寬的節省。
Rasterization
在剔除結束之后,每個Cluster會根據其屏幕空間的大小送至不同的光柵器,大三角形和非Nanite Mesh仍然基於硬件光柵化,小三角形基於Compute Shader寫成的軟光柵化。Nanite的Visibility Buffer為一張R32G32_UINT的貼圖(8 Bytes/Pixel),其中R通道的0~6 bit存儲Triangle ID,7~31 bit存儲Cluster ID,G通道存儲32 bit深度:

Cluster ID

Triangle ID

Depth
整個軟光柵化的邏輯比較簡單:基於掃描線算法,每個Cluster啟動一個單獨的Compute Shader,在Compute Shader初始階段計算並緩存所有Clip Space Vertex Positon到Shared Memory,而后CS中的每個線程讀取對應三角形的Index Buffer和變換后的Vertex Position,根據Vertex Position計算出三角形的邊,執行背面剔除和小三角形(小於一個像素)剔除,然后利用原子操作完成Z-Test,並將數據寫進Visibility Buffer。值得一提的是,為了保證整個軟光柵化邏輯的簡潔高效,Nanite Mesh不支持帶有骨骼動畫、材質中包含頂點變換或者Mask的模型。
Emit Targets
為了保證數據結構盡量緊湊,減少讀寫帶寬,所有軟光柵化需要的數據都存進了一張Visibility Buffer,但是為了與場景中基於硬件光柵化生成的像素混合,我們最終還是需要將Visibility Buffer中的額外信息寫入到統一的Depth/Stencil Buffer以及Motion Vector Buffer當中。這個階段通常由幾個全屏Pass組成:
(1)Emit Scene Depth/Stencil/Nanite Mask/Velocity Buffer,這一步根據最終場景需要的RenderTarget數據,最多輸出四個Buffer,其中Nanite Mask用0/1表示當前像素是普通Mesh還是Nanite Mesh(根據Visibility Buffer對應位置的Cluster ID得到),對於Nanite Mesh Pixel,將Visibility Buffer中的Depth由UINT轉為float寫入Scene Depth Buffer,並根據Nanite Mesh是否接受貼花,將貼花對應的Stencil Value寫入Scene Stencil Buffer,並根據上一幀位置計算當前像素的Motion Vector寫入Velocity Buffer,非Nanite Mesh則直接Discard跳過。

Nanite Mask

Velocity Buffer

Scene Depth/Stencil Buffer
(2)Emit Material Depth,這一步將生成一張Material ID Buffer,稍有不同的是,它並未存儲在一張UINT類型的貼圖,而是將UINT類型的Material ID轉為float存儲在一張格式為D32S8的Depth/Stencil Target上(稍后我們會解釋這么做的理由),理論上最多支持2^32種材質(實際上只有14 bits用於存儲Material ID),而Nanite Mask會被寫入Stencil Buffer中。

Material Depth Buffer
Classify Materials && Emit G-Buffer
我們已經詳細地介紹了Visibility Buffer的原理,在着色計算階段的一種實現是維護一個全局材質表,表中存儲材質參數以及相關貼圖的索引,根據每個像素的Material ID找到對應材質,解析材質信息,利用Virtual Texture或者Bindless Texture/Texture Array等技術方案獲取對應的貼圖數據。對於簡單的材質系統這是可行的,但是UE包含了一套極其復雜的材質系統,每種材質有不同的Shading Model,同種Shading Model下各個材質參數還可以通過材質編輯器進行復雜地連線計算,這種基於連連看動態生成材質Shader Code的模式顯然無法用上述方案實現。
為了保證每種材質的Shader Code仍然能基於材質編輯器動態生成,每種材質的PS Shader至少要執行一次,但我們只有屏幕空間的材質ID信息,於是不同於以往逐個物體繪制地同時運行其對應的材質Shader(Object Space),Nanite的材質Shader是在Screen Space執行的,以此將可見性計算和材質參數計算解耦,這也是Deferred Material名字的由來。但這又引發了新的性能問題:場景中的材質動輒成千上萬,每個材質都用一個全屏Pass去繪制,則重繪帶來的帶寬壓力勢必非常高,如何減少無意義的重繪就成為了新的挑戰。
為此,Nanite在Base Pass繪制階段並不是每種材質一個全屏Pass,而是將屏幕空間分成若干8x8的塊,比如屏幕大小為800x600,則每種材質繪制時生成100x75個塊,每塊對應屏幕位置。為了能夠整塊地剔除,在Emit Targets之后,Nanite會啟動一個CS用於統計每個塊內包含的Material ID的種類。由於Material ID對應的Depth值預先是經過排序的,所以這個CS會統計每個8x8的塊內Material Depth的最大最小值作為Material ID Range存儲在一張R32G32_UINT的貼圖中:

Material ID Range
有了這張圖之后,每種材質在其VS階段,都會根據自身塊的位置去采樣這張貼圖對應位置的Material ID Range,若當前材質的Material ID處於Range內,則繼續執行材質的PS;否則表示當前塊內沒有像素使用該材質,則整塊可以剔除,此時只需將VS的頂點位置設置為NaN,GPU就會將對應的三角形剔除。由於通常一個塊內的材質種類不會太多,這種方法可以有效地減少不必要的Overdraw。
實際上通過分塊分類減少材質分支,進而簡化渲染邏輯的思路也並非第一次被提出,比如《Uncharted 4》在實現他們的延遲光照時[13],由於材質包含多種Shading Model,為了避免每種Shading Model啟動一個單獨的全屏CS,他們也將屏幕分塊(16x16),並統計了塊內Shading Model的種類,根據塊內Shading Model的Range給每個塊單獨啟動一個CS,取Range內對應的Lighting Shader,以此避免多遍全屏Pass或者一個包含大量分支邏輯的Uber Shader,從而大幅度提高了延遲光照的性能。

Uncharted 4中分塊統計Shading Model Range
在完成了逐塊地剔除后,Material Depth Buffer就派上了用場。在Base Pass PS階段,Material Depth Buffer被設置為Depth/Stencil Target,同時Depth/Stencil Test被打開,Compare Function設置為Equal。只有當前像素的Material ID和待繪制的材質ID相同(Depth Test Pass)且該像素為Nanite Mesh(Stencil Test Pass)時才會真正執行PS,於是借助硬件的Early Z/Stencil我們完成了逐像素的材質ID剔除,整個繪制和剔除的原理見下圖:


紅色表示被剔除的區域
整個Base Pass分為兩部分,首先繪制非Nanite Mesh的G-Buffer,這部分仍然在Object Space執行,和UE4的邏輯一致;之后按照上述流程繪制Nanite Mesh的G-Buffer,其中材質需要的額外VS信息(UV,Normal,Vertex Color等)通過像素的Cluster ID和Triangle ID索引到相應的Vertex Position,並變換到Clip Space,根據Clip Space Vertex Position和當前像素的深度值求出當前像素的重心坐標以及Clip Space Position的梯度(DDX/DDY),將重心坐標和梯度代入各類Vertex Attributes中插值即可得到所有的Vertex Attributes及其梯度(梯度可用於計算采樣的Mip Map層級)。

至此,我們分析了Nanite的技術背景和完整實現邏輯。
參考
[1] 《A Macro View of Nanite》
[2] 《UE5 Nanite實現淺析》
[3] 《Vulkan API Overhead Test Added to 3DMark》
[4] 《剔除:從軟件到硬件》
[5] 《Mesh Shading: Towards Greater Efficiency of Geometry Processing》
[6] 《A Trip Through the Graphics Pipeline》
[7] 《Nanite | Inside Unreal》
[8] 《Tile-Based Rendering》
[9] 《游戲引擎中的渲染管線》
[10] 《The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading》
[11] 《Triangle Visibility Buffer》
[12] 《Bindless Texture》
[13] 《Deferred Lighting in Uncharted 4》
這是侑虎科技第983篇文章,感謝作者洛城供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:793972859)
作者主頁:https://www.zhihu.com/people/luo-cheng-11-75,目前就職於騰訊游戲研發效能部引擎中台部門,再次感謝洛城的分享,如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:793972859)