前言

实时渲染中,间接光照才是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预积分求解比较简单,因为它涉及的未知数只有入射向量,而高光部分会涉及到入射向量和出射向量,求解会非常困难。公式如下:

1737296258632.png

为了方便求解镜面反射部分,UE的做法是将该公式分为两部分,分别求解:

  1. 预积分环境贴图
  2. 剩下的GF部分

1737296460266.png

预积分环境贴图

这一部分有点类似黎曼积分,不同之处在于漫反射光是低频信息,而高光就不同的了,它会受roughness影响,可以清晰可以模糊,为此需要根据roughness预计算环境贴图,并生成多张mipmaps,每个mipmap level对应一个阶段的roughness

1737296729446.png

由于GI Diffuse的计算只需考虑normal即可,而Specular就不一样了,还需考虑view dir(出射向量)、light dir(入射向量)。为了解决这个问题,UE做出了一些舍弃,它们假设出射向量和采样向量(N)来自同一方向,即两者相等。这样可以解决多个参数的问题,但得到的效果在某些情况下不再那么物理了——如下图所示,入射光线的掠射角得到的效果会有些模糊

1737297810616.png

如何获得入射向量呢?再GI Diffuse中可以围绕半球做均匀采样,但Specular就不行了,高光可以是在某一区域内,而不是整个半球,且这个区域受roughness影响,roughness越大区域也越大。为此这里需要用到GGX重要性采样,它可以计算得到指定roughness下,指定范围内的入射向量

1737299878700.png

在计算GGX重要性采样前,还需了解Hammersley低差异序列,这是一种差异性较低的随机序列,使用这种序列得到的随机效果会更加好

1737300058776.png

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);
}

得到的函数曲线如下:

1737301007593.png

最后UE将roughness划分为三个区域,做不同的采样:

  1. 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;
    }
    
  2. 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);
    }
    
  3. 剩下的走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了,把剩下的内容预计算即可:

1737301758130.png

计算过程如下(借用大佬的推导过程):

1737301812481.png

这部分还有更省性能的方法——使命召唤:黑色行动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;
}

效果图如下:

1737302080009.png

最终效果

1737302100321.png

加上直接光照:

1737302139252.png

接下来

后续关于更深入的预计算GI会暂时搁置一段时间,会优先写shadow、roughness很低时生成圆形高光、GPU Instance、Cluster Based Lighting等等

Reference

Cube mapping

漫反射辐照度

游戏引擎编程实践(5)- PBR基于图像的光照 (IBL)实现

镜面反射 IBL

UE4 Mobile IBL烘焙实现分析


他们曾如此骄傲的活过,贯彻始终