前言

在之前我们了解了如何使用烘焙贴图的方式计算IBL的diffuse、specular,但这种方式存在些许问题:

  • 纯静态

  • 显存与带宽开销大、ALU

    HDR cubemap 贴图一般是很大的,会占用几十mb

    采样Cubemap带来的开销

本文将细细讲解解决这一问题的球谐函数,它究竟是什么,为什么需要它,该如何实现它,它有什么优缺点

球谐函数的what、why

如果去网上搜索关于球谐函数的讲解,会发现大部分都是往你脸上丢非常复杂的公式,在这就被劝退了。学习,是一个先理解本质与作用,再考虑如何实现的过程,这里不会先看公式,而是说明球谐函数的本质是什么

  • 球谐函数的本质

    球谐函数其实就是在球面上的、由多个sin、cos函数组成的基函数集合(基函数即sin、cos)

    而其中由多个sin、cos函数组成的基函数集合也就是网上所说的傅里叶变换

    那么球谐函数可以理解为,三维空间中二维平面上的傅里叶变换

  • 为什么需要球谐函数

    用于拟合特定曲线(形状)

    为什么球谐函数可以拟合任一曲线呢?这一特性正是sin、cos支持的,只要sin、cos够多,可以拟合任何曲线,初音未来都没问题

    对于IBL的diffuse信息,不难发现是低频信息,如果可以用球谐函数来拟合这些低频信息的形状,那不就可以省去烘焙了嘛。没错,球谐函数正是做这件事的

如何在IBL中运用球谐函数

  • 工业界使用的公式IBL Diffuse如下:

    • 在工业界并不会直接用sin、cos拟合,因为GPU端sin、cos三角函数指令开销是比较高的,更不必说会积分,所以工业界的方案是将三角函数代替为normal的x、y、z,并配合一些炮轰出的magic number
    void EvaluateSHBasis(float3 dir, out float Y[9])
    {
        float x = dir.x;
        float y = dir.y;
        float z = dir.z;
    
        // L0
        Y[0] = 0.282095f;
    
        // L1
        Y[1] = 0.488603f * y;
        Y[2] = 0.488603f * z;
        Y[3] = 0.488603f * x;
    
        // L2
        Y[4] = 1.092548f * (x * y);
        Y[5] = 1.092548f * (y * z);
        Y[6] = 0.315392f * (3.0f * z * z - 1.0f);
        Y[7] = 1.092548f * (x * z);
        Y[8] = 0.546274f * (x * x - y * y);
    }
    
    • 本来球面的立体角以公式中的sinθdθdΦ是可以表示的,但实际计算中模型不是球面,而是立方体,因此实际计算中会采用一般曲面积分公式来表示立体角——dΩ = \frac{dA·cosθ}{r^2},否则会导致某些像素被重复采样了好几次,而某些边角的高频像素可能被彻底漏掉
    // 映射到[-1, 1]
    float u_coord = (samplePos.x + 0.5f) * g_SkyboxSize.z * 2.0f - 1.0f;
    float v_coord = (samplePos.y + 0.5f) * g_SkyboxSize.w * 2.0f - 1.0f;
    
    // 每次在特定的面计算像素坐标,那么xyz中有一个abs一定为1
    // 假设现在是以z = 1平面,采样点坐标为[u, v, 1],原点到采样点的距离平方r^2 = x^2 + y^2 + 1
    float distSq = 1.0f + u_coord * u_coord + v_coord * v_coord;
    
    // θ是V与V夹角,N = (0,0,1),V = (u, v, 1),根据点积:N⋅V=∣N∣∣V∣cosθ => cosθ = 1 / r
    // 因为归一化了, dA = 2 * 2 = 4
    float solidAngle = 4.0f / (sqrt(distSq) * distSq);
    
    • 为了更好的性能,Lambert 漫反射的公式会除以PI,因此工业界直接对E(n)的系数都除以PI
    finalSH[0] *= 0.28209479f;
    finalSH[1] *= 0.32573501f;
    finalSH[2] *= 0.32573501f;
    finalSH[3] *= 0.32573501f;
    finalSH[4] *= 0.27313711f;
    finalSH[5] *= 0.27313711f;
    finalSH[7] *= 0.27313711f;
    finalSH[6] *= 0.07884789f;
    finalSH[8] *= 0.13656855f;
    
    • 在最后我们会加权平均(积分的总立体角可能不会等于4PI),并乘上球体的总立体角表面积4PI

优化

不难发现,如果skybox是动态的,需要每帧或多帧分摊实时计算SH系数,如何优化SH系数的计算过程将非常重要

如果在compte shader里一次性循环skybox的六个面、宽、高,相当于使用一个线程完成了所有计算(所有线程跑相同计算),性能是非常差的

  • 可以想到的方案是将三个循环分摊给所有线程——Dispatch(宽,高,面)
    m_pCommand->Dispatch(CeilDivide(skyboxTex.GetWidth(), threadGroupSize.x),
                                   CeilDivide(skyboxTex.GetHeight(), threadGroupSize.y),
                                   6);
    

但这会有一个问题:每64线程为一组计算SH、weight,需要GroupMemoryBarrierWithGroupSync同步,也就是说需要用到LDS,将每组每个线程得到的SH、weight累加到LDS。但这不是最重要的,最重要的是,LDS的64个数据,如何充分利用到线程?如果是int类型,可以使用InterlockedAdd(),在同步后立即计算,但很不幸这里是float类型,且InterlockedAdd会打破线程的并行,退化为串行,且每个线程都需要跑到全局显存,效率非常低

  • 一种可行的方案:每一个线程组的第0号线程,统一求和64个数据
    if (localIdx == 0)
    {
      float4 totalSH[9] = (float4[9])0;
      float totalWeight = 0.f;
    
      for (int k = 0; k < 64; ++k)
      {
          for (int j = 0; j < 9; ++j)
          {
              totalSH[j] += g_SHCoefficients[j][k];
          }
          totalWeight += g_SHWeights[k];
      }
    
      const UINT saveIndex = groupID.x +
                             groupID.y * g_SHCoefficientsTempCount.x +
                             groupID.z * g_SHCoefficientsTempCount.x * g_SHCoefficientsTempCount.y;
      SHCoefficientData groupResult;
      for (int i = 0; i < 9; ++i)
      {
          groupResult.SHCoefficients[i] = totalSH[i];
      }
      groupResult.TotalWeight = totalWeight;
      groupResult._Padding = float3(0, 0, 0);
    
      Elysia_Save_Temp_SHCoefficient_Data(saveIndex, groupResult);
    }
    

    这种方案不会因为锁等待派对,不会每个线程都去全局显存读取,效率会提升不少

  • 但每组依然会有63个线程无所事事,尤其暴力循环,不如来个二分法,每次折半叠加——如64个线程,每次取一半的线程(32个),前一半的线程叠加后一半的线程,继续折半,直至1,时间复杂度从O(N)降到了O(logN)

    for (UINT stride = 32; stride >= 1; stride >>= 1)
    {
      if (localIdx < stride)
      {
          for (UINT i = 0; i < 9; ++i)
          {
              g_SHCoefficients[i][localIdx] += g_SHCoefficients[i][localIdx + stride];
          }
          g_SHWeights[localIdx] += g_SHWeights[localIdx + stride];
          GroupMemoryBarrierWithGroupSync();
      }
    }
    

最终效果如下:


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