前言
实时渲染中,间接光照才是BxDF的灵魂,一个好的IBL算法可以极大提升光照质感,作为IBL的首篇,先来实现目前业界常用的预积分IBL方案
采样Cubemap
本篇主要谈及预积分IBL,会将大量积分提前计算,这时就需要确定采样cubemap的向量。而cubemap有六个面,因此确定cubemap 的采样向量是不简单的
我们知道cubemap有六个面,在确定采样向量时,需要基于每个面计算
具体每个面对应的轴方向如下:
在计算每个面的采样向量时,若是+x,则向量的x分量为1,若是-x,则向量的x分量为-1,随后再基于该方向,将该方向上的正方形平面抽象成一个uv平面,使用u、v来确定剩下的y、z分量
不难得出确定采样分量的代码如下:
// 根据像素坐标和面索引获取法线方向
float3 GetCubeFaceDirection(float2 uv, int face)
{
float3 dir = 0;
switch (face)
{
case 0: //+X
dir.x = 1.0;
dir.yz = uv.yx * -2.0 + 1.0;
break;
case 1: //-X
dir.x = -1.0;
dir.y = uv.y * -2.0f + 1.0f;
dir.z = uv.x * 2.0f - 1.0f;
break;
case 2: //+Y
dir.xz = uv * 2.0f - 1.0f;
dir.y = 1.0f;
break;
case 3: //-Y
dir.x = uv.x * 2.0f - 1.0f;
dir.z = uv.y * -2.0f + 1.0f;
dir.y = -1.0f;
break;
case 4: //+Z
dir.x = uv.x * 2.0f - 1.0f;
dir.y = uv.y * -2.0f + 1.0f;
dir.z = 1;
break;
case 5: //-Z
dir.xy = uv * -2.0f + 1.0f;
dir.z = -1;
break;
}
return normalize(dir);
}
黎曼积分
用于间接光照的漫反射部分会受到多个方向的光源影响,精准地采样一张贴图是无法准确的还原环境信息
用于漫反射的性质是来自四面八方的光照,因此对于一张环境贴图来说,可以进行卷积计算,将目前pixel的颜色和周围的pixel的颜色融合,从而达到GI漫反射的效果。实现这一目的可以有以下四种途径:
- 对环境贴图模糊
- 采样环境贴图的mipmap level
- 黎曼积分
- 球谐函数
本篇先介绍使用黎曼积分实现GI Diffuse
卷积非常适合实现GI Diffuse,因为它会将目前pixel的颜色和周围的pixel的颜色融合。具体的,为了得到更好的GI Diffuse效果,需要对半球上的方向进行离散采样并对其irradiance取平均值,来计算每个输出采样方向 的积分
环境贴图和积分后的GI Diffuse图分别如下所示:
随后在实时计算时,使用normal去采样这种GI Diffuse贴图即可
- 积分公式
在积分计算时,为了方便不再直接计算方位角,而是将其拆分为两个角度:航向角(球体旋转)[0, 2Π]、倾斜角(从半球顶部出发)[0, \frac{Π}{2}]
公式这里不推导和解析,直接给出,还是比较简单的
-
实现
[numthreads(8,8,1)] void PreIntegrateDiffusse (uint3 id : SV_DispatchThreadID) { float2 uv = ((float2) id.xy + 0.5f) * _ViewSize.zw; int index = id.y * _ViewSize.x + id.x; const float3 N = GetCubeFaceDirection(uv, _Face); // sample direction equal hemisphere's orientation float3 up = float3(0.f, 1.f, 0.f); const float3 right = normalize(cross(N, up)); up = normalize(cross(N, right)); float3x3 TBN = float3x3(right, up, N); const float delta = 0.01f; float3 irradiance = 0.f; int N1 = 0, N2 = 0; for(float phi = 0.f; phi < TWO_PI; phi += delta) { N2 = 0; for(float theta = 0.f; theta < HALF_PI; theta += delta) { // Convert from polar coordinate system to Cartesian coordinate system float3 sampleDirTS = float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); float3 sampleDirWS = mul(sampleDirTS, TBN); irradiance += _EnivronmentTex.SampleLevel(Smp_ClampU_ClampV_Linear, sampleDirWS, 0) * cos(theta) * sin(theta); N2++; } N1++; } irradiance = irradiance * PI / (N1 * N2); _RW_Output[index] = float4(irradiance, 1.f); }
- 最终效果
Split Sum
BRDF渲染方程部分的Diffuse预积分求解比较简单,因为它涉及的未知数只有入射向量,而高光部分会涉及到入射向量和出射向量,求解会非常困难。公式如下:
为了方便求解镜面反射部分,UE的做法是将该公式分为两部分,分别求解:
- 预积分环境贴图
- 剩下的GF部分
预积分环境贴图
这一部分有点类似黎曼积分,不同之处在于漫反射光是低频信息,而高光就不同的了,它会受roughness影响,可以清晰可以模糊,为此需要根据roughness预计算环境贴图,并生成多张mipmaps,每个mipmap level对应一个阶段的roughness
由于GI Diffuse的计算只需考虑normal即可,而Specular就不一样了,还需考虑view dir(出射向量)、light dir(入射向量)。为了解决这个问题,UE做出了一些舍弃,它们假设出射向量和采样向量(N)来自同一方向,即两者相等。这样可以解决多个参数的问题,但得到的效果在某些情况下不再那么物理了——如下图所示,入射光线的掠射角得到的效果会有些模糊
如何获得入射向量呢?再GI Diffuse中可以围绕半球做均匀采样,但Specular就不行了,高光可以是在某一区域内,而不是整个半球,且这个区域受roughness影响,roughness越大区域也越大。为此这里需要用到GGX重要性采样,它可以计算得到指定roughness下,指定范围内的入射向量
在计算GGX重要性采样前,还需了解Hammersley低差异序列,这是一种差异性较低的随机序列,使用这种序列得到的随机效果会更加好
float2 Hammersley(uint i, uint N) { // 0-1
uint bits = (i << 16u) | (i >> 16u);
bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
float rdi = float(bits) * 2.3283064365386963e-10;
return float2(float(i) / float(N), rdi);
}
随后便是计算GGX
// PDF = D * NoH / (4 * VoH)
float4 ImportanceSampleGGX( float2 E, float a2 )
{
float Phi = 2 * PI * E.x;
float CosTheta = sqrt( (1 - E.y) / ( 1 + (a2 - 1) * E.y ) );
float SinTheta = sqrt( 1 - CosTheta * CosTheta );
// 笛卡尔转极坐标
float3 H;
H.x = SinTheta * cos( Phi );
H.y = SinTheta * sin( Phi );
H.z = CosTheta;
// PDF
float d = ( CosTheta * a2 - CosTheta ) * CosTheta + 1;
float D = a2 / ( PI*d*d );
float PDF = D * CosTheta;
return float4( H, PDF );
}
还需要根据当前的mipmap level来确定roughness大小,UE的做法不是做线性映射,而是一个曲线函数,随着mipmap level的增加,roughness越来越大
#define REFLECTION_CAPTURE_ROUGHEST_MIP 1
#define REFLECTION_CAPTURE_ROUGHNESS_MIP_SCALE 1.2
float ComputeReflectionCaptureRoughnessFromMip(float Mip, half CubemapMaxMip)
{
float LevelFrom1x1 = CubemapMaxMip - Mip;
return exp2((REFLECTION_CAPTURE_ROUGHEST_MIP - LevelFrom1x1) / REFLECTION_CAPTURE_ROUGHNESS_MIP_SCALE);
}
得到的函数曲线如下:
最后UE将roughness划分为三个区域,做不同的采样:
- roughness特别小的,直接采样环境贴图
if (roughness <= 0.04) { radiance += _EnivronmentTex.SampleLevel(Smp_ClampU_ClampV_Linear, V, 0); //radiance = -min(-radiance, 0); _RW_Output[index] = float4(radiance, 1.0); return; }
- roughness特别大的,可以近似认为入射向量遍布整个半球
uint CubeSize = 1u << ((int)_MaxMipLevel - 1); const float SolidAngleTexel = 4.0f * PI / float(6.0f * CubeSize * CubeSize) * 2.0f; if (roughness > 0.99) { for (uint i = 0; i < sampleCount; i++) { float2 Xi = Hammersley(i, sampleCount); float3 L = CosineSampleHemisphere(Xi).xyz; float NoL = L.z; float PDF = NoL / PI; float solidAngleSample = rcp(sampleCount * PDF); float mip = 0.5f * log2(solidAngleSample / SolidAngleTexel); L = mul(L, tangentToWorld); radiance += _EnivronmentTex.SampleLevel(Smp_ClampU_ClampV_Linear, L, mip); } radiance /= sampleCount; } // PDF = NoL / PI float4 CosineSampleHemisphere( float2 E ) { float Phi = 2 * PI * E.x; float CosTheta = sqrt(E.y); float SinTheta = sqrt(1 - CosTheta * CosTheta); float3 H; H.x = SinTheta * cos(Phi); H.y = SinTheta * sin(Phi); H.z = CosTheta; float PDF = CosTheta * (1.0 / PI); return float4(H, PDF); }
- 剩下的走GGX路线
else { for (uint i = 0; i < sampleCount; i++) { float2 Xi = Hammersley(i, sampleCount); Xi.y *= 0.995; float4 H = ImportanceSampleGGX(Xi, pow4(roughness)); float3 L = reflect(-V, H); float NoL = L.z; if (NoL > 0.f) { float NoH = H.z; float HoV = V.z; float PDF = H.w; //float PDF = NDF_GGX(pow4(roughness), NoH) * 0.25f; float solidAngleSample = rcp(sampleCount * PDF); float mip = 0.5f * log2(solidAngleSample / SolidAngleTexel); L = mul(L, tangentToWorld); radiance += _EnivronmentTex.SampleLevel(Smp_ClampU_ClampV_Linear, L, mip) * NoL; weight += NoL; } } radiance /= max(weight, 0.001f); }
预计算BRDF
目前已经计算入射光、NDF了,把剩下的内容预计算即可:
计算过程如下(借用大佬的推导过程):
这部分还有更省性能的方法——使命召唤:黑色行动2采用近似函数逼近
half2 EnvBRDFApproxLazarov(half Roughness, half NoV)
{
// [ Lazarov 2013, "Getting More Physical in Call of Duty: Black Ops II" ]
// Adaptation to fit our G term.
const half4 c0 = { -1, -0.0275, -0.572, 0.022 };
const half4 c1 = { 1, 0.0425, 1.04, -0.04 };
half4 r = Roughness * c0 + c1;
half a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
half2 AB = half2(-1.04, 1.04) * a004 + r.zw;
return AB;
}
half3 EnvBRDFApprox( half3 SpecularColor, half Roughness, half NoV )
{
half2 AB = EnvBRDFApproxLazarov(Roughness, NoV);
// Anything less than 2% is physically impossible and is instead considered to be shadowing
// Note: this is needed for the 'specular' show flag to work, since it uses a SpecularColor of 0
float F90 = saturate( 50.0 * SpecularColor.g );
return SpecularColor * AB.x + F90 * AB.y;
}
效果图如下:
最终效果
加上直接光照:
接下来
后续关于更深入的预计算GI会暂时搁置一段时间,会优先写shadow、roughness很低时生成圆形高光、GPU Instance、Cluster Based Lighting等等
Comments | NOTHING