Split Sum是UE4 IBL的基礎,其思想是把環境反射由單個蒙特卡洛積分分成兩個獨立分部:入射光貢獻部分和BRDF積分部分。渲染方程到Split Sum的推導如下
理解Split Sum的關鍵在於如何理解分離後的Li對i的總和 。在渲染方程中,L表示的是反射方向上的入射光線 ,但在UE4的IBL的單個像素,表示的是Li在i上的和,是反射椎中所有入射光貢獻的總和。反射錐的示意如下
由於UE4 PBR模型中的法線分佈項決定,這個錐體的分佈模型也同樣是GGX模型,椎體大小則由物體的Roughness及視線(V)和表面法向(N)共同決定。UE4對此做了進一步簡化,假定V和N同向,從而使最終結果變為Roughness的單變數函數.
至此,UE4再使紋理Mipmap層級和Roughness一一對應,把確定的Roughness的入射光總和編碼到Cubemap對應的Mip層級中即可。
除Karis自己提到的Split Sum不能模擬拉伸的高光(因為在存儲IBL的時候假定了 V = N)外,我的理解裏,還有其它的一些侷限,如果我理解有誤,歡迎指正:
Split Sum的假設是各向同性的,所以不能模擬各向異性
Split Sum近似在數學上並不正確,嚴格來說它是對原有積分的過高估計,所以它會顯得更亮,反射範圍也更大(最終結果會顯得反射高亮、擴散)
Split Sum IBL可編碼存儲的有效Roughness值域有限,線性插值多個Roughness出來的高光並不符合GGX分佈。要達到PBR材質中L8編碼的Roughness同等精度是不可能任務。
UE4的反射球支持兩種光源來源:一種是直接以當前場景做為光源(CaptureScene),另一種是以HDR格式的經緯圖做為光源(SpecifiedCubemap)。其生成最終IBL Cubemap流程分為以下幾步
更新做為光源的Cubemap
更新光源Cubemap的實現部分在FScene::CaptureOrUploadReflectionCapture函數中。分別針對CaptureScene和SpecifiedCubemap進行處理。
if (CaptureComponent->ReflectionSourceType == EReflectionSourceType::CapturedScene) { bool const bCaptureStaticSceneOnly = CVarReflectionCaptureStaticSceneOnly.GetValueOnGameThread() != 0; CaptureSceneIntoScratchCubemap(this, CaptureComponent->GetComponentLocation() + CaptureComponent->CaptureOffset, ReflectionCaptureSize, false, bCaptureStaticSceneOnly, 0, false, false, FLinearColor()); } else if (CaptureComponent->ReflectionSourceType == EReflectionSourceType::SpecifiedCubemap) { UTextureCube* SourceTexture = CaptureComponent->Cubemap; float SourceCubemapRotation = CaptureComponent->SourceCubemapAngle * (PI / 180.f); ERHIFeatureLevel::Type InFeatureLevel = FeatureLevel; ENQUEUE_RENDER_COMMAND(CopyCubemapCommand)( [SourceTexture, ReflectionCaptureSize, SourceCubemapRotation, InFeatureLevel](FRHICommandList& RHICmdList) { CopyCubemapToScratchCubemap(RHICmdList, InFeatureLevel, SourceTexture, ReflectionCaptureSize, false, false, SourceCubemapRotation, FLinearColor()); }); }
對光源Cubemap進行Prefilter生成IBL用的Cubmap
Prefilter Cubemap的入口同樣在FScene::CaptureOrUploadReflectionCapture中,但其核心實現則在ComputeAverageBrightness和FilterReflectionEnvironment兩個函數裏。UE4生成IrrandianceMap和Prefilter Specular Cubemap的實現都是走的Render Pipeline。
用於計算IrrandianceMap的ComputeDiffuseIrradiance也是在FilterReflectionEnvironment中調用。雖然最終IrrandianceMap的輸出是一組球諧參數,但其渲染的Pass用的比生成Cubemap的Pass多得多。
Prefilter Cubemap使用了Mip層數 * 6 個Pass,逐mip逐cube face進行濾波。濾波的具體實現不在C++代碼中,而是在ReflectionEnvironmentShaders.usf的FilterPS實現。下面對FilterPS進行簡要分析。
一開始是計算Cubemap的坐標和TBN
float2 ScaledUVs = Input.UV * 2 - 1; float3 CubeCoordinates = GetCubemapVector(ScaledUVs);
float3 N = normalize(CubeCoordinates); float3x3 TangentToWorld = GetTangentBasis( N );
接著是計算當前Mipmap對應的Roughness
float Roughness = ComputeReflectionCaptureRoughnessFromMip( MipIndex, NumMips - 1 );
計算Mip對應Roughness並不是線性映射,而是指數函數,UE4的實現實現如下
#define REFLECTION_CAPTURE_ROUGHEST_MIP 1 #define REFLECTION_CAPTURE_ROUGHNESS_MIP_SCALE 1.2 float ComputeReflectionCaptureRoughnessFromMip(float Mip, half CubemapMaxMip) { float LevelFrom1x1 = CubemapMaxMip - 1 - Mip; return exp2( ( REFLECTION_CAPTURE_ROUGHEST_MIP - LevelFrom1x1 ) / REFLECTION_CAPTURE_ROUGHNESS_MIP_SCALE ); }
把1和1.2代入到上式中,x軸為mip層級,y軸為對應的roughness,這個函數的圖像是這樣的
這個圖像有兩重意思:一是mip層級小的部分給了roughness小的部分,即越光滑的材質,它對應的cube size會越大;二是mip越接近0,它對應的roughness增長越慢,圖上可以看到0~3共計4級mip層級,對應的不過是[0,0.2)之間的roughness,而4,5,6共3級mip對應了[0.2,1]之間的roughness。應用到IBL上,就是越光滑的材質會有越清晰的反射或者說編碼的精度傾向於給更光滑的表面。
再接下來,FilterPS開始針對Roughness實現不同的 ImportanceSample策略
對於幾乎完全鏡面的mip只採樣一次
if( Roughness < 0.01 ) { OutColor = SourceTexture.SampleLevel( SourceTextureSampler, CubeCoordinates, 0 ); return; }
對於幾乎完全粗糙的mip直接使用Cosine分佈在上半球進行採樣求解
if( Roughness > 0.99 ) { // Roughness=1, GGX is constant. Use cosine distribution instead LOOP for( uint i = 0; i < NumSamples; i++ ) { float2 E = Hammersley( i, NumSamples, 0 );
float3 L = CosineSampleHemisphere( E ).xyz;
float NoL = L.z;
float PDF = NoL / PI; float SolidAngleSample = 1.0 / ( NumSamples * PDF ); float Mip = 0.5 * log2( SolidAngleSample / SolidAngleTexel );
L = mul( L, TangentToWorld ); FilteredColor += SourceTexture.SampleLevel( SourceTextureSampler, L, Mip ); }
OutColor = FilteredColor / NumSamples; }
對於roughness在(0.01,0.99)之間的mip執行ggx prefilter
float Weight = 0;
LOOP for( uint i = 0; i < NumSamples; i++ ) { float2 E = Hammersley( i, NumSamples, 0 );
// 6x6 Offset rows. Forms uniform star pattern //uint2 Index = uint2( i % 6, i / 6 ); //float2 E = ( Index + 0.5 ) / 5.8; //E.x = frac( E.x + (Index.y & 1) * (0.5 / 6.0) ); E.y *= 0.995;
float3 H = ImportanceSampleGGX( E, Pow4(Roughness) ).xyz; float3 L = 2 * H.z * H - float3(0,0,1);
float NoL = L.z; float NoH = H.z;
if( NoL > 0 ) { //float TexelWeight = CubeTexelWeight( L ); //float SolidAngleTexel = SolidAngleAvgTexel * TexelWeight; //float PDF = D_GGX( Pow4(Roughness), NoH ) * NoH / (4 * VoH); float PDF = D_GGX( Pow4(Roughness), NoH ) * 0.25; float SolidAngleSample = 1.0 / ( NumSamples * PDF ); float Mip = 0.5 * log2( SolidAngleSample / SolidAngleTexel );
float ConeAngle = acos( 1 - SolidAngleSample / (2*PI) );
L = mul( L, TangentToWorld ); FilteredColor += SourceTexture.SampleLevel( SourceTextureSampler, L, Mip ) * NoL; Weight += NoL; } }
OutColor = FilteredColor / Weight;
RGBA8紋理的編碼是隻發生在移動端的,其具體實現在GenerateEncodedHDRData函數裏。
這一步是完整的運行在CPU上而不使用Render Pipeline的。GenerateEncodedHDRData函數使用大量篇幅處理跨邊閃爍的問題(簡單粗暴使用均值解決)了,對於非邊界部分的編碼則通過調用RGBMEncode實現。
RGBMEncode實現步驟有
先開方為敬,目的和光照圖一樣,給暗部更多精度
Color.R = FMath::Sqrt( Color.R ); Color.G = FMath::Sqrt( Color.G ); Color.B = FMath::Sqrt( Color.B );
接下來,按RGBM編碼的標準做法,是除max range(上面開過方,這兒能容納的亮度範圍實際上是256)
Color /= 16.0f; float MaxValue = FMath::Max(FMath::Max(Color.R, Color.G), FMath::Max(Color.B, DELTA));
再接下來,正常來說,RGBM後續的編碼直接使用Color.A = MaxValue ,Color.RGB /= Color.A 就結束了。但UE4的實現並不是這樣,UE的工程師們還處理了實際亮度範圍超過256的情況。他們在MaxValue大於0.75的時候,使用了一個簡單的反函數做了一次ToneMapping。完整的實現代碼是這樣的
if( MaxValue > 0.75f ) { // Fit to valid range by leveling off intensity float Tonemapped = ( MaxValue - 0.75 * 0.75 ) / ( MaxValue - 0.5 ); Color *= Tonemapped / MaxValue; MaxValue = Tonemapped; }
Encoded.A = FMath::Min( FMath::CeilToInt( MaxValue * 255.0f ), 255 ); Encoded.R = FMath::RoundToInt( ( Color.R * 255.0f / Encoded.A ) * 255.0f ); Encoded.G = FMath::RoundToInt( ( Color.G * 255.0f / Encoded.A ) * 255.0f ); Encoded.B = FMath::RoundToInt( ( Color.B * 255.0f / Encoded.A ) * 255.0f );
return Encoded;
這個ToneMapping函數的公式和圖像如下