Froxelizer主要是用于filament光照效果的實現,生成光照渲染時所需的必要信息,幫助渲染過程中明確哪些區域受哪些光源所影響,是Filament中保證光照效果渲染效率的核心所在。這部分的源碼,可以結合filament官方文檔中Light Path部分進行理解。
光照效果渲染
優秀的光照效果渲染一般都是影響引擎渲染性能的重要影響點,引擎也可能會對場景中可以使用的光的數量施加嚴格的限制,以此來保證渲染性能。3D引擎通常有兩種主要的渲染方式:
- 前向渲染(forward rendering):像拍照一樣,一次性把所有物體的顏色、光照等信息算好再繪制到屏幕上。
- 延遲渲染(deferred rendering):先記錄每個像素的位置和材質信息,最后再統一計算光照效果,適合復雜場景。
一些現代3D引擎采用延遲渲染的方式,可以輕松支持數十、數百甚至數千個光源,但是往往需要很高的顯存帶寬。按照filament中默認的PBR材質模型,一個像素的渲染,可能就需要160到192 bits的G-Buffer占用,這就意味著非常高的顯存帶寬需求。
前向渲染過去一直難以處理多個光源。一種常見做法是 多次渲染場景(每個可見光源一次),然后將結果疊加。另一種方法是為每個物體分配固定的光源上限。但若場景中存在大片區域(如建筑、道路),這種方法會變得不切實際。
分格著色(Tiled Shading)可應用于前向和延遲渲染方法。這個想法是將屏幕分割成一個圖塊網格,對于每個圖塊,找到影響該圖塊中像素的燈光列表。這樣做的優點是可以減少延遲渲染中的過度繪制和前向渲染中大型對象的著色計算。但是因為深度不連續的問題(如物體邊緣),也可能導致大量無關的計算。
Filament的光照效果渲染以低帶寬、每個像素支持多個動態光源為限定條件,另外期望能夠輕松支持多重采樣抗鋸齒、透明度、多種材料模型等能力。
為了平衡光照渲染效果和渲染性能,Filament引入了一種稱之為“分簇著色(Clustered Shading)”的前向渲染的變體方式。分簇著色擴展了分格著色的概念,但在z軸上添加了分段。“分簇”操作是在視圖空間中完成的,方法是將視錐體分割成3D網格。這個過程,類似把一個2D圖片分割成塊,把3D空間分割成小立方塊,只是這個分割是在是視錐空間進行的。如下圖所示:
在Filament中,把分割的結果每塊稱之為Froxel(a voxel in frustum space,造詞小能手結合了frustum和voxel,來表示在視錐空間下的體素)。
在渲染一幀畫面之前,場景中的每個光源會被分配到所有它覆蓋的Froxel中。這樣,每個Froxel最終都會有一個它關聯的光源列表。在實際渲染時,我們只需要找到當前像素片段所屬的Froxel,就能直接獲取影響該像素的所有光源。
Froxel的深度劃分不是等距的,而是指數型增長的。因為人眼對靠近攝像機的物體更敏感(畫面中近處的像素更多),所以我們會把靠近攝像機的位置劃分得更細一些,而遠處劃分得更粗。這種指數型的Froxel深度劃分方式,能讓光源分配在最需要的地方更精確,提升渲染效率和呈現效果。
參考Froxelizer中的注釋,froxel緩沖區的條目數量由最大UBO大小決定(見getFroxelBufferByteCount()函數)。 另外,因為增加froxel數量會加重"記錄緩沖區"的壓力(這個緩沖區負責存儲每個froxel的光索引),filament中還設置記錄緩沖區的容量限制為min(16K[UBO], 64K[uint16])
條目。實際上,部分froxel并未被使用,因此我們能存儲更多數據。
渲染實現流程
同通用的圖像渲染流程一樣,Filament中對于光照效果的渲染,從大的使用步驟上來看,也是經過材質的構建、Shader的編譯、GPU Program的啟用、Program參數的傳遞以及最后的DrawCall。前文所提到的Froxelizer,主要的工作是計算出需要傳遞Program的和光照信息相關的參數。
材質的使用
有OpenGL、DX、Vulkan等渲染基礎的朋友都知道,需要使用這些圖形API進行圖像的渲染,一般都需要通過編寫頂點著色器和片元著色器,構建出GPU執行的渲染程序,然后再渲染前設置好相關的程序參數(Attribute變量、Uniform變量等),最后材質DrawCall。
而在常見的渲染引擎或者游戲引擎中,如nity、Unreal等,都會通過材質的概念,將渲染程序以及渲染管線中的一些數據、狀態等組織到一起,來進行渲染效果的實現。這樣既方便資源的擴展與復用,又能解耦設計效果和渲染邏輯。
Filament中也存在材質的概念,其實現在MaterialBuilder中,官方有專門的文檔來介紹Filament中的PBR材質,材質系統及自定義材質的方式,可優先參考官方文檔。Filament中的材質構建流程,簡單來說主要包括以下步驟:
- 編寫材質文件:filament中的材質使用自定義的文件結構,定義了包括材質參數、頂點著色器、片元著色器等等各種信息。
- 材質編譯:Filament提供了材質編譯器(filament/tools/matc),將材質文件編譯為二進制.
- 材質構建:使用
Material::Builder
進行材質構建,package加載編譯后的材質文件,constant設置常量,build方法構建出材質實例。 - 材質設置:構建出的材質對象,可以通過
setDefaultParameter
來設置默認材質實例的參數。如果一個材質對象,需要傳遞不同的參數,應用到不同地方,可通過createInstance
來創建不同的實例,然后修改其參數進行應用。 - 材質應用:參考Filament引擎(二) ——引擎的調用及接口層核心對象,將材質實例綁定給渲染實體。
Shader的構建
在filament中存在多種光照效果,關于光照的Shader模型如下:
/*** 支持的Shader模型*/
enum class Shading : uint8_t {UNLIT, //!< 不應用光照,可采用自發光方式LIT, //!< 默認的, 標準光照模型,模擬金屬與非金屬的剛體材質SUBSURFACE, //!< 次表面散射光照模型,模擬光線穿透材質內部的散射效果CLOTH, //!< 布料光照模型,模擬紡織物的纖維堆疊與散射特性SPECULAR_GLOSSINESS, //!< 鏡面光澤度光照模型,兼容傳統美術流程,通過鏡面反射與光澤度參數替代金屬度
};
以標準光照模型為例,matc中使用MaterialCompiler
進行材質編譯時,會先使用MaterialBuilder
來進行材質的構建。參考MaterialBuilder::build
函數實現,其內部會調用MaterialBuilder::generateShaders
函數,進一步調用到ShaderGenerator中的createSurfaceVertexProgram
和createSurfaceFragmentProgram
函數。這兩個函數中,在有光源時,都會通過調用generateSurfaceMaterialVariantDefines
來在Shader中增加對應的宏,方向光增加宏VARIANT_HAS_DIRECTIONAL_LIGHTING
。存在動態光源(有點光源及聚光燈光源),會增加宏VARIANT_HAS_DYNAMIC_LIGHTING
。
在ShaderGenerator::createSurfaceFragmentProgram
中,當在材質文件中指定了光照模型shadingModel: lit
時,會調用CodeGenerator::generateSurfaceLit
來將以下文件也寫入到片元著色器的stream中:
- surface_lighting.fs
- 啟用了陰影效果渲染時:surface_shadowing.fs
- surface_shading_model_standard.fs
- surface_brdf.fs
- surface_ambient_occlusion.fs
- surface_light_indirect.fs
- surface_shading_lit.fs
- 有方向光時:surface_light_directional.fs
- 有動態光時(即存在點光源或者聚光燈光源):surface_light_punctual.fs
后面CodeGenerator::generateSurfaceMain
會將surface_main.vs
和surface_main.fs
分別寫入到頂點著色器和片元著色器的stream中,這兩個文件中包含著shader的mian函數。經過各種方式拼接出來的stream,會通過GLSLPostProcessor::process
進行優化及轉換,變成指定的Shader語言,GLSL、MSL、WGSL、SPirv等。
在需要進行包含動態光源的光照效果渲染時,以Lit光照模型為例,Shader的主要調用流程為:surface_main.fs: main
-> surface_shading_lit.fs: evaluateMaterial
-> surface_shading_lit: evaluateLights
-> surface_light_punctual.fs: evaluatePunctualLights
。在這個渲染過程中,需要用到Froxelizer生成的Froxel信息以及在場景中增加的光源信息,我們還需要確認下這部分信息的傳遞流程。
Froxel信息的傳入
在Filament引擎(三) ——引擎渲染流程的分析中有提到,Filament會在每幀渲染前,view.prepare
的過程中,判斷存在點光源或者聚光燈光源時,進行Froxel化,計算出所有分簇被哪些光源所影響,并在Color Pass渲染之前,將這些信息提交到GPU中進行使用。
在filament中,UibGenerator
被用來進行GPU緩沖區數據的生成和管理,在ShaderGenerator::createSurfaceFragmentProgram
創建片元著色器時,如果存在動態光源,會通過CodeGenerator::generateUniforms
在片元著色器中生成綁定點為RECORD_BUFFER
和FROXEL_BUFFER
的Uniform變量,關聯對應的BufferInterfaceBlock
對象。這樣片元著色器中,就存在了后續用來接收Froxel數據的Uniform端點。
在FView構造函數中,Froxelizer中用來接收Froxel數據的Uniform Buffer Object對象mRecordsBuffer
和mFroxelsBuffer
,通過ColorPassDescriptorSet::init
函數,生成分別與RECORD_BUFFER
及FROXEL_BUFFER
綁定點關聯的描述信息,然后在FRenderer::renderJob
中,通過 fg.addTrivialSideEffectPass("Prepare View Uniforms", xxx)
發送任務到渲染線程,執行view.commitUniformsAndSamplers
來真正實現的UBO和綁定點的關聯。
在進行Color Pass的渲染之前,再通過fg.addTrivialSideEffectPass("Prepare Color Passes", xxx)
發送任務到渲染線程,等待Froxel化及數據壓縮完成,執行view.commitFroxels
來提交壓縮數據到GPU中對應的UBO中。這樣片元著色器在需要進行光照渲染時就可以使用到mRecordsBuffer
和mFroxelsBuffer
對應的數據。
光源信息的傳入
光照信息傳入到Shader中的流程,與Froxel信息的傳入類似。在ShaderGenerator::createSurfaceFragmentProgram
創建片元著色器時,如果存在動態光源,會通過CodeGenerator::generateUniforms
在片元著色器中生成綁定點位LIGHTS
的Uniform變量,關聯對應的BufferInterfaceBlock
對象,用于后續接收光照信息的傳入。ColorPassDescriptorSet::init
函數在生成與Froxel綁定點關聯的描述信息,也會生成與光照信息綁定點關聯的描述信息。
FView.prepare
時,會調用FView.prepareLighting
,當存在動態光源時,它會調用scene->prepareDynamicLights
來將LightSoa數據轉換成LightsUib數組,傳遞到GPU中,在evaluatePunctualLights
函數中同Froxel數據一起被使用。
Froxel準備過程
當FView進行prepare時,會進行是否存在動態光源的判斷,即用戶是否設置了點光源或者聚光燈。如果存在動態光源,就會進行Froxel的準備工作。在filament中一個場景下,動態光源的最大個數限制為256,在Froxelizer源碼實現中,緩存相關的信息也是按照最大256個光源來進行實現的。
映射信息
Froxel的準備工作,主要是為了生成緩存信息。在Froxelizer中,有幾個重要的對象:
- mFroxelShardedData:對象類型為
utils::Slice<FroxelThreadData>
,每個FroxelThreadData
實際上是數組LightGroupType[8192](LightGroupType實際上是一個uint32_t),切片大小為GROUP_COUNT(也就是8),實際占用256KiB的大小。數據在froxelizePointAndSpotLight
函數中被填充,它記錄的是所有Froxel哪些光源所影響。 - mFroxelBufferUser: 對象類型為
utils::Slice<FroxelEntry>
, FroxelEntry結構體中只有一個uint32_t,高16位記錄的是燈光信息的索引(相對mLightRecords),低8位記錄的是燈光的數量。切片Size是mFroxelBufferEntryCount
(不出意外就是8192),所以它的內存占用大概為32KiB。這部分數據會傳遞到GPU,用于著色器渲染時查詢受哪些光源的影響。通過FroxelEntry可以在mRecordBufferUser中,找到哪些燈光對當前Froxel生效。 - mLightRecords:對象類型為
utils::Slice<LightRecord>
, LightRecord結構體中只有一個256位的bitset,表示燈光索引,記錄每個Froxel用到了哪些燈光。切片Size為mFroxelBufferEntryCount
(8192),和所以它的內存占用大概也是256Kib。 - mRecordBufferUser: 對象類型為
utils::Slice<RecordBufferType>
,RecordBufferType是一個uint8_t,記錄的是“編碼”后的燈光信息。按照filament中的實現,低3位記錄了第幾個分組,高5位記錄了分組中的第幾個。切片Size為RECORD_BUFFER_ENTRY_COUNT
(16384),所以它的內存占用大概為16KiB。 數據也是在froxelizeAssignRecordsCompress
函數中被填充。
在Froxelizer.h文件頭部有一個圖示,結合后面的分析重繪一下大致如下:
主要實現
由外部設置FView傳入Viewport、投影矩陣等信息,Froxelizer會進行必要數值的計算,決定如何切分視錐。
在filament中Froxelizer::prepare過程中有幾個重要的數值計算的偽代碼如下:
// 記錄Froxel索引的緩沖區大小,決定了一共可以分割多少個froxel出來。
// maxUniformBufferSize為打包多個Uniform變量的結構塊的最大值(字節),OpenGL中通過`glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &maxUniformBufferSize)`獲取
mFroxelBufferByteCount = min(8192 * 4, maxUniformBufferSize/16*16);
// Forxel的索引數。FroxelEntry結構體內部存儲的是一個uint32_t,所以也就是4字節
mFroxelBufferEntryCount = getFroxelBufferByteCount()/sizeof(FroxelEntry);
// Z軸的切片固定位16段,上面有提到Froxel的深度劃分不是等距離的,而是按照指數增長的方式切分。切分段數固定位16段。
mFroxelSliceCount = 16;
// 最大索引數固定了,Z方向分段固定,所以XY平面分塊的最大值也就知道
mFroxelMaxXYPlaneCount = mFroxelBufferEntryCount / mFroxelSliceCount;
// XY平面分塊的尺寸計算。分塊的邏輯是按照Viewport的大小劃分出接近mFroxelMaxXYPlaneCount個正方形,且保證分塊的寬高是8的倍數,以優化Shader的性能。
mFroxelXYTiledDimension = maxRound8(ceil(width / sqrt(mFroxelMaxXYPlaneCount * width / height)), ceil(height / sqrt(mFroxelMaxXYPlaneCount * height / width)));
// 根據尺寸再計算出X和Y方向的切分,保證 mFroxelXCount * mFroxelYCount <= mFroxelMaxXYPlaneCount
mFroxelXCount = ceil(width / mFroxelXYTiledDimension);
mFroxelYCount = ceil(height / mFroxelXYTiledDimension);
根據計算的信息,去更新緩存數據的空間,緩存數據記錄在一個LinearAllocatorArena中。主要記錄的內容有:
- 用來表示Z軸切分點的
mFroxelSliceCount + 1
(17)個float,記為mDistancesZ
. - 用來表示視圖空間下X平面的
mFroxelXCount + 1
個float4,通過平面方程x * A + y * B + z * C + w = 0
表示一個平面,記為mPlanesX
,下面Y平面也類似。 - 用來表示視圖空間下Y平面的
mFroxelYCount + 1
個float4,記為mPlanesY
。 - 用來表示包圍球的
mFroxelXCount * mFroxelYCount * mFroxelSliceCount
個float4(其中,xyz表示包圍球中心,w表示球半徑),記為mBoundingSpheres
。
相機信息中一般包含相機的投影矩陣mProjection
,用來將視圖空間坐標轉換成投影空間(或者叫裁切空間)的坐標。在Froxel中,期望存儲的是視圖空間下的6個平面信息,進行Froxel分割時,已經算出x、y、z三個方向上的切分份數,已知viewport大小,filament中通過以下方式,計算出視圖空間下的X平面和Y平面信息。
// 投影矩陣的轉置矩陣,用來將投影空間轉換成視圖空間
const mat4f trProjection(transpose(mProjection));for (size_t i = 0, n = mFroxelCountX; i <= n; ++i) {float const x = (float(i) * froxelWidthInClipSpace) - 1.0f;// float4{ -1, 0, 0, x } 表示一個平面方程: -1 * x' + 0 * y' + 0 * z' = x,X平面垂直于x軸。這里構建的是投影空間下的平面。// 通過投影矩陣轉置后 與 X平面相乘,并進行歸一化,得到視圖空間下的X'平面方程float4 const p = trProjection * float4{ -1, 0, 0, x };planesX[i] = float4{ normalize(p.xyz), 0 };
}// 求視圖空間下的Y'平面方程
for (size_t i = 0, n = mFroxelCountY; i <= n; ++i) {float const y = (float(i) * froxelHeightInClipSpace) - 1.0f;float4 const p = trProjection * float4{ 0, 1, 0, -y };planesY[i] = float4{ normalize(p.xyz), 0 }; // p.w is guaranteed to be 0
}
然后更新mBoundingSpheres,包圍球的計算邏輯為:
- 每個Froxel因為是從視錐中截出來的,X、Y是等分的,Z是指數增長的,所以每個Froxel也是一個截錐體。
- 每個截錐體有6個面,XYZ三個面一定有一個交點,根據6個面求出截錐體的八個頂點。
- 八個頂點相加再除以8(乘0.125)就得到了球的中心。
- 計算八個點中離球中心最遠的距離,作為球的半徑。
最后,計算mParamsZ(float)的幾個屬性值,mParamsZ將被傳入Shader中的參數,這部分的內容,在官方文檔中8.4.2.4部分也有介紹,實際上zParamsZ的值大約為:
zParams[0] = 1.0f - Z_FAR / Z_NEAR;
zParams[1] = Z_FAR / Z_NEAR;
zParams[2] = (MAX_DEPTH_SLICES - 1) / log2(Z_SPECIAL_NEAR / Z_FAR);
zParams[3] = MAX_DEPTH_SLICES; // mFroxelSliceCount
Froxel化過程
參考Froxelizer::froxelizeLights方法。froxel化的過程主要有兩步:先將所有的燈光的處理分組打包成多個任務(CONFIG_MAX_LIGHT_COUNT
定義了最大燈光個數,LIGHT_PER_GROUP
定義了每組燈光個數),拋到JobSystem中進行執行,將Froxel化的結果寫到mFroxelShardedData中,然后再對Froxel化的結果mFroxelShardedData進行壓縮。
froxelizePointAndSpotLight
froxelizeLoop函數負責將燈光的處理進行分組打包,并拋到JobSystem中執行。froxelizeLoop中構建的process,會以分組數(源碼中固定為8)作為步長,以0-7分別作為offset,分成8個任務,對所有燈光信息進行遍歷,并調用froxelizePointAndSpotLight
來進行處理,填充mFroxelShardedData
數據。
froxelizePointAndSpotLight的處理過程,主要就是確認指定的燈光對那些Froxel有影響,記錄到mFroxelShardedData中,其實現步驟主要如下:
- 根據燈光的位置和半徑,構建出燈光的包圍盒。然后將相機的投影矩陣(裁切矩陣)參與計算,得到燈光包圍盒投影后,在投影平面的八個點。
- 計算包圍八個二維點的矩形框上兩個角的點p1(min(x), min(y))和 p2(max(x), max(y))。前面有說到,Froxel化在XY兩個方向上是進行等分的,用這兩個點,就能算出光照影響到的Froxel的XY兩個方向的Index方法。根據光源的中心位置和光源半徑,知道光源影響的近點和遠點,再結合Froxel在Z方向的指數增長的劃分方式,可以算出光照影響在Z方向的兩個Index。這6個Index(記作XMin、XMax、YMin、YMax、ZMin、ZMax),初步限定了光照影響Froxel的范圍。
- 在Z方向上,從ZMin到ZMax進行遍歷(記為z1),計算光照球和Z=z1的平面是否相交。相交的話(不包括相切),用相交形成的圓構建一個新的球,記為S1。如果z1和光照球中心所在的Z一致的話,不必計算新球,光照球就是S1。
- 計算出這個新球S1的球心,在投影平面上的點C1(x0,y0),和前面一樣,用這個投影點,可以很容易計算出它對應的Froxel在XY兩個方向上的Index。
- 在Y方向上,從YMin到YMax進行遍歷(記為y1),計算球S1與當前Y平面是否相交。相交的話,用相交形成的圓可以再構建一個新球,記為S2。
- 在X方向上,從XMin到XMax進行遍歷,計算球S2與當前X平面是否相交,找到與S2球相交的X平面區間 bx和ex。
- Froxel切分按照先X軸從小到大,再Y軸從小到大,最后Z軸從近到遠的順序進行Index計數。根據(bx, y1, z1) 計算得到X遍歷時的Froxel的最小Index,記為fi。
fi
到fi + ex - bx
區間的Froxel,如果該光源是點光源,就都會被該光源影響。如果是聚光燈,就需要計算聚光燈的圓錐體和Froxel的包圍球是否相交,相交的則是被該光源影響。- 被光源影響的Froxel,信息更新方式為:
froxelThread[fi++] |= LightGroupType(1) << bit
。bit是froxelizePointAndSpotLight
的入參,代表光源在分組中的Index。
Froxel化的過程,主要時出于計算效率的考慮,以分組的方式,將所有的光源對Froxel影響的計算,拆分到多個線程進行并行處理。在計算光源是否對Froxel有影響時,為了計算效率和準確性的雙重考慮,按照Z->Y->X三個方向逐步判斷,Z平面與光源球相交后,用相交的圓構建新球來圈定Z值固定時,光源對Y方向Froxel的影響。再以類似的方式,圈定Y值也固定時,光源對X方向Froxel的影響。通過這種方式,高效的確認所有被當前光源影響的Froxel,而不是對所有Froxel遍歷判斷是否被當前光源影響,以此提高Froxel化效率。
froxelizeAssignRecordsCompress
froxelizeAssignRecordsCompress顧名思義,主要用于將froxel的信息進行壓縮。在froxelizeLoop
中,為了提高Froxel化效率,將Froxel化過程拆成了8個分組,丟到8個線程中并行處理。最多256個燈光對于每個Froxel的影響,也被分別存儲在8個int32_t中。在froxelizeAssignRecordsCompress中,會對這些數據進行壓縮處理,降低對GPU的帶寬需求,同時也便于后續的計算優化。其主要實現過程如下:
- 將Froxel數據(燈光信息,
mFroxelShardedData
),從N組M位轉換成一個256位的bitset(轉換的結果存儲在mLightRecords
中),便于進行相鄰比較和壓縮。每一個froxel就對應著一個LightRecord(256位),記錄哪些燈光針對該Froxel生效。 mLightRecords
遍歷進行或運算,得到被使用的所有燈光信息,allLights(LightRecord::bitset類型)。- 遍歷allLights中被置為1的每一位用uint8_t來進行“編碼”:以32個燈光為一組,一共分8組。低3位(0-7),表示屬于哪一組,高5位(0-31),表示一組中的第幾個。編碼信息存儲在
mRecordBufferUser
中。 - 遍歷
mLightRecords
,對所有Froxel和影響它的光源信息,按照步驟3的方式,進行編碼。編碼信息同樣存儲到mRecordBufferUser
。
按照以上的方式,如果只有兩個燈光,都只對一半的Froxel生效,則只需要2 + 8192(Froxel總個數) * 0.5(一半的生效) * 2(燈光數)
個uint_8,再加上8192個uint32(mFroxelBufferUser
)記錄FroxelEntry,即可存儲所有的光源對所有Froxel的影響信息。而原始數據需要8192(Froxel總個數) * 256 (光源數) / 8
個uint_8。在實際的應用中,大量光源對大量Froxel都生效的情況畢竟是少數。
參考文檔:
- DOOM (2016) - Graphics Study
- Filament官方文檔
歡迎轉載,轉載請保留文章出處。求閑的博客[https://blog.csdn.net/junzia/article/details/149668651]