譯:UE4是如何渲染一幀的(1)
原文鏈接:How Unreal Renders a Frame
作者:Kostas Anagnostou, Lead Graphics Programmer at Radiant Worlds
這幾天我在翻閱UE4的源碼。受到一些分析著名遊戲如何渲染一幀的文章啟發,我考慮對UE4也做些類似的工作,分析UE4是怎麼渲染一幀的。
由於UE4源碼公開,我們可以分析源碼來研究UE4的渲染器是如何工作的。不過UE4的渲染器十分複雜,並且根據場景的不同渲染流程有很多變化,所以有一個(渲染過程中的)清晰、底層的API調用看起來會更加方便一些(遇到搞不明白的地方再去看源碼)。
我製作了一個簡單的場景,場景包括一些動態的或靜態的模型,一些光源,體積霧,透明物體以及粒子效果。這些東西會覆蓋到UE4大部分材質和渲染方法。
我使用RenderDoc在編輯器中捕捉了一幀。一個實際遊戲里的渲染流程和這個可能不一樣,但通過捕捉到的數據我們可以粗略地窺見UE4是怎麼渲染一幀的。
聲明:以下分析基於GPU捕捉數據以及UE 4.17.1的渲染器代碼,(作者)本人先前沒有使用UE的經驗。如果我漏掉了什麼東西,請在評論中讓我知曉。
幸運的是UE4的draw call列表非常整潔,並且有良好的注釋,這會使我們分析起來更簡單。如果你的場景中缺了些材質或者你的渲染質量設置得較低,你捕捉到的draw call列表可能和我的不一樣。例如如果你的場景中沒有粒子效果,那麼ParticalSimulation這個 render pass就不會出現。
SlateUI這個pass包括了所有UE編輯器用於渲染UI的渲染調用,這一部分本文將會忽略,重點關注Scene下的所有render pass。
粒子模擬
UE4的一幀以ParticleSimulation pass開始。這一步在GPU上計算了場景里所有的粒子發射器(emitter)的粒子運動以及其他屬性,並將結果輸出到兩個渲染目標(rendertarget)上,一個格式為RGBA32_Float,保存位置,另一個為RGBA16_Float,保存速度以及其他一些和粒子時間/生存周期相關的數據。下圖展示了RGBA32_Float格式的渲染目標保存的數據,每一個像素代表了一個sprite的世界坐標。
我在場景中添加的粒子效果似乎有兩個emitter在GPU上模擬時不需要進行碰撞檢測,所以可以在每一幀較早的時候運行其對應的render pass。
Z-Prepass
接下來就是PrePass流程,這一步其實就是z-prepass,將所有不透明物體渲染到一個R24G8的深度緩衝中。
值得注意的是UE4使用reverse-Z來保存深度,意味著近裁面的深度值為1,遠裁面的深度值為0。這使得深度緩衝的精度更高,避免在遠處發生z-fighting的現象。從該pass的名字可以看出這一步是由「DBuffer」觸發的。DBuffer是UE4用來保存延遲貼花(deferred decal)的緩衝,這一步需要場景深度,所以會啟動Z-prepass。這個Z-buffer還會用在其他地方,例如遮擋檢測和屏幕空間反射,這些我們會在之後提及。
Draw call列表中的一些渲染pass似乎是空的,例如ResolveSceneDepth,這一步我猜測是用於那些需要在使用紋理前resolve渲染目標的平台(PC平台不需要);又比如ShadowFrustumQueries,這一步看起來是個傀儡標記,因為真正的陰影遮擋測試發生在寫一個渲染pass中。
遮擋檢測
BeginOcclusionTests負責一幀中所有遮擋測試。UE4默認使用硬體遮擋查詢(hardware occlusion queries)來進行遮擋測試。簡而言之分為3步:
- 將所有被標記為遮擋體的物體(例如一個較大的solid mesh)渲染進一個深度緩衝。
- 創建一個遮擋查詢(occlusion query),提交該查詢並渲染那些我們希望查詢遮擋情況的模型。這一步使用硬體深度測試(z-test)以及我們在第一步中創建的深度緩衝。遮擋查詢將返回通過深度測試的像素數量,如果結果是0就意味著該物體完全被solid mesh遮擋。由於為了深度測試而去把完整的模型渲染一遍的開銷很高,這一步我們渲染模型的包圍盒,而不是原模型,如果該包圍盒不可見(也就是沒通過深度測試),那麼該包圍盒所代表的模型肯定也不可見。
- 將查詢結果讀回CPU,根據被渲染像素的數量我們決定是否提交模型給GPU渲染(即便是有一小部分像素可見我們也可以不讀渲染這個模型)。
UE4根據具體情況決定使用哪一類遮擋查詢:
硬體遮擋查詢有諸多劣勢,例如有drawcall粒度上的問題,渲染器需要對每一個模型(或者一個模型批次)提交一個drawcall來進行遮擋查詢,這會使得每一幀的drawcall數量顯著上升;還有一個問題是硬體遮擋查詢需要將結果讀回到CPU,這就需要在CPU和GPU之間同步,並且要求CPU一直等待到GPU完成查詢處理的時刻。這對instanced物體並不友好,但在這裡我們先忽略這個問題。
對於CPU與GPU間的同步問題,UE4使用和其他引擎類似的方法:將CPU對數據的讀回操作延遲幾幀進行。這個方法大部分情況下可行,但在攝像機高速移動的時候可能會導致物體的突然出現(pop in)(實踐中這不是個大問題,因為物體在遮擋剔除時使用包圍盒來計算遮擋,這一步是保守,即便完全不可見的物體也可能被標記為可見)。額外的drawcall開銷依然存在,但這個問題也是可以解決的。UE4通過以下方法來減輕這個問題的影響:
- 首先所有物體會被渲染到深度緩衝。(也就是之前提到的這一過程)
- 對於所有需要遮擋測試的物體向GPU提交一個遮擋查詢請求。
- 在每一幀的最後,CPU從前一幀(或者更加前面的幀)讀回物體的可見性結果。如果物體是可見的就將物體標為在下一幀需要渲染。對於不可見的物體,將其加入一個「分組」的查詢中,該查詢會以批次提交最高8個物體的包圍盒組,測試這些物體在下一幀是否可見。
- 如果整個分組在下一幀變為可見,那麼再將整個組重新分離為獨立的遮擋查詢並提交。
如果相機和物體是靜止的(或者緩慢移動),這一優化會將必要的遮擋查詢數量減少8倍。唯一一個我注意到的奇怪地方是被遮擋物體的批次查詢組合方式似乎是隨機的,而不是基於物體在空間上的距離。
這一步對應於上圖中的IndividualQueries和GroupedQueries標記。GroupedQueries在這一幀是空的,因為UE4沒有在前一幀中找到任何需要這一操作的物體。
在整個遮擋剔除pass的最後,ShadowFrustumQueries提交所有針對本地光源(也就是點光源或者聚光燈)的包圍盒的遮擋查詢(無論光源是否投影都會提交,和這一步的名字所表達的意思不同),如果某個光源被完全遮擋住了那麼就沒必要去對該光源進行任何光照/投影計算。值得注意的是我們的實例場景中有4個點光源(每一幀每個光源都需要計算shadowmap),但是ShadowFrustumQueries這一步提交的查詢數量為3。我猜測這是因為其中一個光源的包圍盒和相機近裁面相交,因此UE4認為該光源必然可見。另一點值得一提,對於一個需要計算cubemap shadowmap的動態光源,UE4會提交一個球體來進行遮擋測試。
對於需要計算逐物體陰影的靜止動態光源(之後會有更詳細的介紹),UE4會提交一個視錐體來進行遮擋檢測:
最後對於PlanarReflectionQueries這一步,我估計是指用於計算平面反射(planar reflection)的遮擋剔除計算(方法是將相機變換到渲染平面之後/之下在重新繪製物體)。
Hi-Z緩衝的生成
接下來,UE4會創建一個Hi-Z緩衝(passes HZB SetupMipXX),存儲格式為16位浮點數(R16_Float)。這一步將Z-prepass階段得到的深度緩衝作為輸入創建一個深度值的mipmap鏈(mipmap chain)。這一步還會將深度重新採樣為解析度大小為2的冪次數的紋理,這樣用起來更方便。
之前提到,由於UE4使用reverse-Z,pixel shader在降採樣時使用最小值操作符(譯者註:也就是指每次降採樣時選取鄰域內深度值最小的像素輸出到下一個mipmap)。
陰影的渲染
接下來一步是陰影計算render pass(ShadowDepths)。
靜態(stationary)的平行光,兩個可移動(movable)的點光源以及一個靜止(static)的點光源。所有光源都會計算陰影。
對於靜態光源,渲染器會為靜態物體烘焙陰影,並為動態物體計算陰影。對於可移動的光源每一幀都需要為所有物體計算陰影(完全動態)。最終對於靜態物體其陰影會被烘焙入光照貼圖(lightmap),所以這些陰影在渲染中不會出現。
對於平行光我添加了分三個層級的級聯陰影(cascaded shadowmaps),以觀察UE4是怎麼處理這個功能的。UE4創建了一個3x1的格式為R16_TYPELESS的紋理(每行3個tile,每層陰影一個),每一幀清除一次(意味著每一幀所有層都要更新,而不會有隔幀更新之類的優化)。隨後,在Atlas0 render pass中所有物體會被渲染進對應的陰影tile中。
從上面的drawcall列表可看出只有Split0需要渲染一些物體,其他塊是空的。陰影在渲染時無需pixel shader,這能使得陰影的渲染速度翻倍。值得注意的是無論平行光是靜止的還是動態的,渲染器會將所有物體(包括靜態物體)都渲染到陰影貼圖中。
接下來是Atlas1 render pass,這一步將渲染所有靜態點光源的陰影。在我的場景中只有那塊岩石模型被標記為動態物體。對於靜態光源的動態物體,UE4使用逐物體陰影貼圖,保存在一個紋理圖集(texture atlas)中,意味著對於每一個光源,每一個物體都會渲染一個shadowmap。
最後,對於動態光源,UE4使用傳統的立方體陰影(cubemap shadowmap,在CubemapXX passes中),使用一個geometry shader來選擇要渲染到cubemap的哪個面上(以減少drwaw call)。在這一步只渲染動態物體,所有靜態物體會被緩存起來。CopyCachedShadowMap這一步會把陰影緩存複製進來,然後在此之上渲染動態物體的陰影深度。下圖是一個動態光源的立方體陰影緩存中一個面的內容(CopyCachedShadowMap這一步的輸出)
這是渲染了動態物體(石頭)後的結果:
靜態物體的陰影緩存不會再每一幀重新生成,因為渲染器知道(我們場景中的)這一光源沒有移動(儘管被標記為動態光源)。如果光源移動了,渲染器會在每一幀渲染動態物體前把所有靜態物體重新繪製入陰影緩存中(這一步我在另一個測試中證實):
唯一一個靜態光源(static light)完全沒有出現在drawcall列表中,意味著這個光源不會影響動態物體,只會通過光照貼圖去影響靜態物體。
在本文最後提個建議,如果在你的場景中有靜態光源(stationary light)請確保在編輯器中測試性能前烘焙光照(我不確定在standalone模式下運行時是否需要這樣),如果不烘焙的話UE4會將它當做動態光源並渲染立方體陰影,而不是逐物體陰影。
在下一篇中我們會繼續探索UE4的渲染流程,考察light grid生成,G-prepass和光照這些渲染步驟。
推薦閱讀:
※圖靈宇宙漫遊指南
※走樣與反走樣(Aliasing/Anti-Aliasing):Graphics Cases
※拓幻圖形學工程師教學手冊(第三講)|一字一字敲出OpenGL學習教程
※趁熱度來做個捏臉