UE4 Mobile IBL烘焙實現分析
Split Sum
Split Sum是UE4 IBL的基礎,其思想是把環境反射由單個蒙特卡洛積分分成兩個獨立分部
:入射光貢獻部分和BRDF積分部分
。渲染方程到Split Sum的推導如下
渲染方程的蒙特卡羅積分
UE4 蒙特卡羅積分的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層級中即可。
Split Sum的侷限性
除Karis自己提到的
Split Sum不能模擬拉伸的高光
(因為在儲存IBL的時候假定了 V = N)外,我的理解裡,還有其它的一些侷限,如果我理解有誤,歡迎指正:
Split Sum的假設是各向同性的,所以不能模擬各向異性
Split Sum近似在數學上並不正確,嚴格來說它是對原有積分的過高估計,所以它會顯得更亮,反射範圍也更大(最終結果會顯得反射高亮、擴散)
Split Sum IBL可編碼儲存的有效Roughness值域有限,線性插值多個Roughness出來的高光並不符合GGX分佈。要達到PBR材質中L8編碼的Roughness同等精度是不可能任務。
反射球Cubemap構建流程
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
=
NoL
/
PI
;
float
SolidAngleSample
=
1。0
/
(
NumSamples
*
);
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
=
D_GGX
(
Pow4
(
Roughness
),
NoH
)
*
0。25
;
float
SolidAngleSample
=
1。0
/
(
NumSamples
*
);
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
;
Prefilered Cubemap針對移動端使用RGBM編碼到RGBA8紋理
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函式的公式和影象如下