Unreal 4.22渲染數據管線重構和動態Instancing

這是侑虎科技第581篇文章,感謝作者王文濤供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

作者主頁:https://www.zhihu.com/people/wang-wen-tao-70,作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!


動機和目的

之前寫過一篇文章,講的是UE4裏的HISM的工作方法。

UE4的HISM深入探究:https://zhuanlan.zhihu.com/p/58963258

爲了實現植被的遮擋剔除、Instance還有距離剔除的功能。HISM Component幾乎成爲了UE4裏最龐大的一個文件,然而實際上HISM對於很多遊戲場景裏的需求還是很難滿足,特別是HISM需要滿足靜態數據的前提,十分依賴美術的工作和參數調整,對於越來越大和高自由度的遊戲世界非常難受。

很多AAA級遊戲引擎都有動態合併Instance的功能,也就是動態判斷繪製的物體材質,Vertex Buffer(VB)等是否一樣,一樣的物體合併到一起繪製。但是對於4.21前的管線,動態物體和靜態物體沒法合併,然後運行時判斷是否合併Instance的效率太低。

爲了解決像動態Instancing、渲染提交效率等等的問題,EPIC終於在4.22痛下殺手改動這個陳舊龐大的渲染管線代碼。知乎上有很多講UE4渲染的文章,看過一些代碼的人應該也清楚,UE4.21之前的渲染數據是基於DrawingPolicy的框架,這樣的框架的好處是面向對象的設計,思路很直接,但是它的缺點卻是顯而易見的:

1、通過大量模板實現Shader的選擇和permutation,代碼難以閱讀,對於現代CPU體系來說性能堪憂(非Data-oriented 設計)。

2、分成Dynamic和Static兩個draw list,Instancing/Batch等優化,只能對static物體做手腳。

3、DrawPolicy直接決定渲染線程設置到GPU的數據,難以和GameThread數據解耦,冗餘數據很多,對性能也是大問題。

 

老的UE4一個Draw命令的數據流程

 

 

除此之外,一些新技術的需求,這種DrawingPolicy的架構也是難以勝任的:

1、DXR技術,Shader所需Constant等數據必須是全場景的,同時,Ray Tracing技術是不能簡單地針對可見性作剔除,光線會反彈,同樣需要全場景信息。

2、GPU Driven Culling的技術,CPU必須在不知道可進性的情況下,把場景數據發送到GPU的Buffer,由GPU進行剔除計算。

GPU-Driven Rendering Pipelines

3、Rendering Graph或者說可編程的渲染管線技術,需要能夠靈活的抽象出Render Pass等概念,而現在的DrawingPolicy代碼去寫一個簡單的自定義pass可能就需要好幾百行,太過沉重。

總結起來就是,我們需要一種更簡潔、更緊湊、更利於CPU訪問的數據結構去表示場景數據,包括Transform、Material(可以理解成Shader參數)、RenderState、頂點Buffer等等。而它們最好是一個數組,並不需要包括太多Game Thread的信息。而我們的Renderer代碼需要在渲染每一個Frame開始前cache全場景的數據。

UE4給出的解決方案叫做MeshDrawCommand

FMeshDrawCommand類緩存了一個DrawCall所需要的最少資源集合:

  • VertexBuffer和IndexBuffer
  • ShaderBindings(Constant,SRV,UAV等)
  • PipelineStateObject(VS/PS/HS/GS/CS 和 RenderState)

FMeshDrawCommand的好處有:

1、獨立的結構體,不用依賴於DrawPolicy那一大堆模板;

2、Data-oriented設計,在提交的時候就是一堆數組,效率高。

基於FMeshDrawCommand的新管線:

 

 


Caching MeshDrawCommand

構造好PrimitiveSceneProxy之後,就可以用FMeshPassProcessor的AddMeshBatch去創建一個或者多個MeshDrawCommand。

繼承自己的FMeshPassProcessor就可以創建一個新的自定義MeshPass,創建一個MeshDrawCommand的流程如下:

 

 

例如,陰影繪製裏的DepthPass:

 

 

注意原來UE4.21的Uniform那一套東西在MeshDrawCommand裏被替換成了ShaderBinding。我們不用一個個去調用setParameters接口,而統一用SetShaderBindings的API即可。

FMeshDrawShaderBindings是一個存放了若干個內存Buffer的數組,數組的大小和該Command用的Shader數量相同。Buffer裏面的內容就是構造好的RHIUniformBuffer的refrence,包括SRV、Constantant等等,使用MeshDrawShaderBindings的好處就是可以快速比較兩個DrawCommand的Uniforms是否數目一樣,內容一致,從而實現DrawCall Merge。

GetShaderBindings的例子:

FMeshMaterialShader::GetShaderBindings(Scene, FeatureLevel, PrimitiveSceneProxy, MaterialRenderProxy, Material, DrawRenderState, ShaderElementData, ShaderBindings);
ShaderBindings.Add(RenderOffsetParameter, ShaderElementData.RenderOffset);

FMeshDrawShaderBindings裏存放的是FRHIUniformBuffer結構體的指針,這是爲了在update uniform時,只用更新一個UniformBuffer的內容即可,而不是更新引用到這個UniformBuffer的所有MeshDrawCommand。

 

 

有了MeshDrawCommand,我們就可以只根據物體的動態或者靜態屬性區分每一幀需要更新的東西。對於場景大部分的靜態物體,只需要判斷Uniform是否與view相關去更新view即可,動態物體則一般需要每幀更新MeshBatch和MeshDrawCommand。


Dynamic Instancing和GPUScene

1、GPUScene

上面這些Data-oriented向的優化只是提高了繪製的效率,但是要真正實現Dynamic Instancing,我們需要的一個個GPUSceneBuffer來存儲PerInstance的場景數據。UE4用一個GPUTArray來表示PerInstance的數據,實際上它是個float4的數組,這樣的方法在幾年前Ubi的引擎裏就實現過:

 

Ubi的GPU Driven Pipeline

 

 

只不過Ubi做的更徹底,包括Mesh信息VB IB等都全部扔到GPU去,然後用MultiDrawInstanceIndirect去繪製。UE4的GPUScene Buffer則主要包括世界矩陣、Lightmap參數、包圍盒等PerInstance相關的Shader數據,4.23之後添加了一些CustomData可以用於你自定義的Instance算法。

 

 

UE4的GPUTArray代碼實現在ByteBuffer.cpp和ByteBuffer.usf中,在DirectX中用的是StructureBuffer來實現,OpenGL下則用的是TextureBuffer。

Update/Resize一個Buffer的代碼也很簡單。Map一段CPU數據到GPU然後執行一個ComputeShader更新場景裏已經綁定的Buffer:

SetShaderValue(RHICmdList, ShaderRHI, ComputeShader->NumScatters, NumScatters);
SetSRVParameter(RHICmdList, ShaderRHI, ComputeShader->ScatterBuffer, ScatterBuffer.SRV);
SetSRVParameter(RHICmdList, ShaderRHI, ComputeShader->UploadBuffer, UploadBuffer.SRV);
SetUAVParameter(RHICmdList, ShaderRHI, ComputeShader->DstBuffer, DstBuffer.UAV);
RHICmdList.DispatchComputeShader(FMath::DivideAndRoundUp<uint32>(NumScatters, FScatterCopyCS::ThreadGroupSize), 1, 1);
SetUAVParameter(RHICmdList, ShaderRHI, ComputeShader->DstBuffer, FUnorderedAccessViewRHIRef());

這裏UE4對GPU Buffer的代碼實現是相對比較簡單的,實際上對於動態更新的GPU數據可以用一個GPU RingBuffer去實現,可以節省每幀重新分配內存和map操作的時間:

 

GPU RingBuffer示意

 

 

2、MeshDrawCommand Merging

有了PerInstance的數據,我們還需要知道哪些MeshDrawCommand可以merge,MeshDrawCommand可以簡單的判斷,去比較是否相等從而merge。

 

 

注意因爲MeshDrawShaderBindings都是指針,所以比較只要簡單判斷command綁定的bindings是否相等即可。

Merge好的MeshDrawCommand存在場景裏的一個HashSet裏,如果兩個command能merge,簡單的把這個command的StateBucket+1即可:

    /** Instancing state buckets.  These are stored on the scene as they are precomputed at FPrimitiveSceneInfo::AddToScene time. */
    TSet<FMeshDrawCommandStateBucket, MeshDrawCommandKeyFuncs> CachedMeshDrawCommandStateBuckets;

判斷command是否能merge是比較費時的操作,因此只會在物體AddToScene的時候執行。此外,現在的MeshDrawCommand是逐個比較相等去判斷能否merge的,之後也可以直接用Hash值去merge來提高效率。

3、PrimitiveId Buffer

UE4是用DrawIndexedInstanced最終去繪製物體的,那麼我們需要按照某個物體的ID去索引GPUSceneBuffer中的內容,UE4的做法也很直接,在支持DynamicInstance的物體的VertexFactory中添加一個VertexStream作爲PrimitiveId:

if (GetType()->SupportsPrimitiveIdStream() && bCanUseGPUScene)
{
    Elements.Add(AccessStreamComponent(FVertexStreamComponent(&GPrimitiveIdDummy, 0, 0, sizeof(uint32), VET_UInt, EVertexStreamUsage::Instancing), 13));
    PrimitiveIdStreamIndex[Index] = Elements.Last().StreamIndex;
}

每個MeshDrawCommand在構造時去初始化這個VertexBuffer即可:

for (int32 DrawCommandIndex = 0; DrawCommandIndex < NumDrawCommands; DrawCommandIndex++)
{
const FVisibleMeshDrawCommand& VisibleMeshDrawCommand = VisibleMeshDrawCommands[DrawCommandIndex];
PrimitiveIds[PrimitiveIdIndex] = VisibleMeshDrawCommand.DrawPrimitiveId;

}

例如場景的Scene Buffer有一千個物體的數據,我們只要知道我們的MeshDrawCommand在這一千個index中的offset,然後對command裏的primitives ID依次加一即可。

最後是SubmitMeshDrawCommands:

RHICmdList.SetStreamSource(Stream.StreamIndex, ScenePrimitiveIdsBuffer, PrimitiveIdOffset);
RHICmdList.SetStreamSource(Stream.StreamIndex, Stream.VertexBuffer, Stream.Offset);
RHICmdList.DrawIndexedPrimitive(MeshDrawCommand.IndexBuffer,MeshDrawCommand.VertexParams.BaseVertexIndex,0,MeshDrawCommand.VertexParams.NumVertices,MeshDrawCommand.FirstIndex,MeshDrawCommand.NumPrimitives,MeshDrawCommand.NumInstances * InstanceFactor

4、新管線的優勢和發展方向

4.22的新管線在場景複雜度較高的情況下有非常好的減少CPU overhead的效果,收益主要來自於Data-Oriented設計帶來的cache miss的減少和動態Instancing實現。而且這個收益不僅限於PC端,據Epic的工程師說,在堡壘之夜上使用Depth Prepass和新的Dynamic Instancing管線,讓渲染效率提升了30%左右。

將更多的工作交給GPU來完成也是遊戲引擎發展的一個趨勢,下一篇計劃用4.22這些新輪子去實現一些新的東西。

參考文檔:

[1]GPU Driven pipeline
[2]GPU Dynamic resource management


文末,再次感謝王文濤的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

也歡迎大家來積極參與U Sparkle開發者計劃,簡稱“US”,代表你和我,代表UWA和開發者在一起!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章