DX12 SSAO入门到HBAO入坟

前言

  • 什么是SSAO

    SSAO 的全称是 Screen Space Ambient Occlusion(屏幕空间环境光遮蔽)。这是一种在渲染中增加阴影感的算法,可以让物体交界处、缝隙、褶皱显得更真实,增加画面的层次感深度感

  • 为什么需要SSAO

    1. 因为目前实时渲染主要使用shadow cast 和 shadow receive,这种算法因为bias的原因,不能保证某些地方的阴影计算是完全正确且覆盖的;而SSAO正好可以弥补这一算法的不足
    2. 由于实时渲染无法像离线渲染一样,可以让光线多次反弹,从而得到屋子的角落会比空旷的地面暗一些,导致场景看起来像“塑料”一样,物体仿佛悬浮在地面上,缺乏体积感。SSAO正好可以高性能的解决这个问题
  • 如何实现SSAO
    1. 深度采样:计算屏幕每个像素点的depth
    2. 半径测试:在每个像素周围的一定空间内随机采样若干个点
    3. 遮蔽判断:如果采样点被周围的物体挡住了,说明这个像素处于阴影中
    4. 模糊处理:为了节省性能,采样通常比较稀疏,会有噪点,所以最后会加一层模糊处理,让阴影看起来更柔和
  • SSAO优缺点
    • 优点
    1. 性能开销低:与屏幕分辨率有关,不像光追和场景复杂度有关
    2. 不是预制菜:可以实时运行
    3. 可快速嵌入后效
    • 缺点:
    1. SSAO是一种屏幕空间技术,这种技术的弊端是对于屏幕外的物体无法加入计算中
    2. 光晕:物体边缘可能会出现不自然的黑色光圈
    3. 旋转视角时,阴影可能会有轻微变化

重建view space

对于后处理而言,重建position VS基本是必备的,这里采用untiy 的思路

float3 ComputeWorldSpacePosition(float2 screenUV, float rawDepth, Matrix invViewProjMatrix)
{
    float4 positionCS = ComputeClipSpacePosition(screenUV, rawDepth);
    float4 positionWS = mul(positionCS, invViewProjMatrix);
    return positionWS.xyz / positionWS.w;
}

因为采用的是延迟管线,所以normal VS从GBuffer中获得即可

为什么不采用world space?

  • 浮点数精度

    因为浮点数有着越接近0精度越大的特性,而world space的坐标数值可能非常大,但view space的坐标原点都是camera,所以view space下计算精度会更高

半径测试

SSAO需要在每个像素周围的一定空间内随机采样若干个点,用于后续的遮蔽判断。因此,一个好的随机算法十分重要,这里使用到的是基于均匀分布的切线空间随机旋转向量,它为每一个像素提供随机的旋转向量

算法流程:

  1. 空间分布:需要将空间分布在物体朝外的半球区域,以免采样到物体内部,造成错误阴影
    std::random_device rd;   //保证每次设备启动时随机数种子都不同
    std::default_random_engine generator(rd());  
    std::uniform_real_distribution<float> minusOneToOneDistribution(-1.f, 1.f);
    std::uniform_real_distribution<float> zeroToOneDistribution(0.f, 1.f);
    
    auto randomVec = Vector4(minusOneToOneDistribution(generator),
                                        minusOneToOneDistribution(generator),
                                        zeroToOneDistribution(generator), 1.f);
    

    因为需要在半球多次采样,我们肯定希望尽量覆盖全部区域,所以每次生成向量时,需要乘上i / maxSampleCount

    auto scale = (float)i / maxSampleCount;
    randomVec = randomVec * scale;
    
  2. 密度分布:使用二次函数再次计算新的采样点

    为什么需要这一步呢?虽然说是均匀分布,但最终效果还是希望采样点(阴影)更多地集中在原点附近,越向外越少

    scale = MathHelper::Lerp(0.01f, 1.f, scale * scale); // 二次函数分布
    
  3. 构建TBN:将原本所有沿着view space Z 轴正方向的采样点,变换至沿着表面法线的采样点。也就是说,变换所有采样点的轴

    现在我们知道normal VS,但并不知道其他的两轴中的任一一轴,因此需要生成一个向量,使用施密特正交化来构建TBN。且这个向量最好是随机向量,因为SSAO是需要多次采样的,这样可以消除重复感。另一点,由于TBN变换后是以z轴为基准,说明旋转(TBN变换)只在xy平面上转,那么这个随机向量的z分量应为0

    // 强制让随机向量与法线垂直,得到切线
    const float3 tangent = normalize(randomVector - normalVS * dot(randomVector, normalVS));
    const float3 bitTangent = cross(normalVS, tangent);
    const float3x3 TBN = float3x3(tangent, bitTangent, normalVS);
    

    这里唯一难点就是第一句,dot(randomVector, normalVS)得到randomVector在normalVS方向上的投影(又表示两个向量在方向上的相似性),随后乘上normalVS得到随机向量在normalVS上的向量分量,最后相减

    这里可能有个疑问,normalVS不是z轴吗,而randomVector的z分量为0,那dot(randomVector, normalVS)铁为0啊。这没有问题,但大多数时候物体的法线朝向的xy分量不可能刚好为0

    随机向量可以通过采样Blue noise texture得到,为了性能Blue noise texture分辨率可能比较小,此时便不能直接uv去采样,这样会将小小的Blue noise texture拉伸到和屏幕一样大,可能相邻的一部分区域都采样的相同值(比如左上角300x300都采样的Blue noise texture的同一像素)

    为了解决这一问题,需要使用Tiling的方式进行采样,这样即使还是会有些重复,但不会出现相邻的采样点都采样同一像素值。具体方式是用Screen resolution / Blue noise texture resolution,采样时uv应是Screen resolution / Blue noise texture resolution * uv。如此原本采样的范围是[0, 1],现在变为[0, 480](假设屏幕 1920,纹理 4),最后使用pointWrapSampler即可使用Tiling 采样

  4. 计算遮挡

    为了计算遮挡我们需要知道采样点的深度和当前采样点的z

    float3 randomVec = mul(g_AOSampleKernelArray[sampleIndex].xyz, TBN) * g_AORadius;
    
    float4 randomPosVS = float4(randomVec, 0.f) + float4(inputParam.PositionVS, 1.f);
    float4 randomPosCS = mul(randomPosVS, projMatrix);
    float2 randomPosUV = randomPosCS.xy / randomPosCS.w * 0.5f * float2(1.f, -1.f) + 0.5f;
    
    float randomDepth = SampleTexture2D(OpaqueDepthIndex, randomPosUV, ClampPointSampler);
    float randomEyeDepth = LinearEyeDepth(randomDepth, g_ZBufferParams);
    float randomZ = randomPosVS.z;
    
  • 遮挡判断:如果采样点的z大于采样点的深度,说明产生了遮蔽

    float range = step(randomEyeDepth, randomZ);

  • bias优化:由于是深度比较,和阴影类似,也需要bias来避免自遮挡

    float range = step(randomEyeDepth, randomZ + g_AOBias);

  • 范围检测:由于算法设定了一个半径值,只在半径值内的采样点才生效;因此还需要做范围检测,可以解决AO的漏光远景遮挡近景问题(如远处的物体不应给近处的物体提供AO)

    float rangeCheck = smoothstep(0.f, 1.f, g_AORadius / abs(randomEyeDepth - inputParam.PositionVS.z));

  1. 当前效果

模糊处理

上图可以看出当前的AO还比较硬,类似深度比较的阴影,AO也需要做软化处理,那么就需要用到模糊

  1. 模糊选型

    应该使用哪种模糊呢?其实我们真正的目的是,在模糊AO的同时,保留轮廓,否则当模糊强度稍微拉高,整个AO都很糊,看着很没有质感。因此选择双边滤波

  2. 算法

    其中,p = (p_x, p_y)是中心位置;q = (q_x, q_y)表示滤波计算时的目标像素;\sigma表示高斯分布的半径(决定以p为中心涵盖了多少临近像素需要参与滤波计算)

    这个公式可以简化:W = \sum G_{space}(dist) * G_{range}(range)。其中G_{space}(dist)表示滤波像素的高斯权重,可以提前预制一组高斯权重,而不是实时计算;G_{range}(range)表示范围权重,当中心像素和当前滤波像素的距离越近时,范围权重越大;越远时,范围权重越小

    对于AO算法,肯定不能在计算范围权重时,以两pixel的距离来计算,而是需要用到depth、normal

    由于高斯具有可分离性,可以将2d模糊拆分成两个1d模糊,如此可以将时间复杂度从N^2降至N(对于不分离的模糊糊,采样必定是(2R + 1) * (2R + 1),而可分离的水平竖直采样都是(R + 1) * (R + 1)

  3. 效果

HI-Z

目前SSAO的算法都是在原生分辨率上计算,这有几个缺点:一是带宽和计算量大;二是降低缓存命中率

  • 为什么降低缓存命中率

    想象一下,如果一个连续的AO区域非常大,那么肯定需要采样离中心像素很远的像素,对于原生分辨率这已经降低了效率;而且,因为相邻线程访问的内存地址跨度大,导致缓存命中率也不高

  • 算法

    1. HIZ对象:在做GPU Culling时,HI-Z对象时计算区域的min depth;而在AO中,要么计算2x2中的最小值,要么点采样取左上角pixel

    2. 层数计算

    • 如何确定最终层数呢?

      因为depth render texture的宽高很有可能不是2^n,那以哪条边来计算层数呢?mipmap是一个不断除2直至为1的过程,为此必须确保宽高最终都为1,所以应该以更长的边计算层数

    • 公式:floor(log_2(max(width, height))) + 1

    #include "private\ShadingCommon.hlsl"
    
    #define GROUP_SIZE 8
    
    cbuffer PassConstant : register(b0, perPassSpace)
    {
      UINT g_TargetTexIndex;
      UINT g_SourceTexIndex;
      float4 g_TargetSize;
      float4 g_SourceSize;
    }
    
    [numthreads(GROUP_SIZE, GROUP_SIZE, 1)]
    void TwoTwoMinHIZ(uint3 dispatchThreadID : SV_DispatchThreadID)
    {
      if (dispatchThreadID.x >= UINT(g_TargetSize.x) || dispatchThreadID.y >= UINT(g_TargetSize.y))
      {
          return;
      }
    
      Texture2D<float> srcTex = ResourceDescriptorHeap[g_SourceTexIndex];
      RWTexture2D<float> o = ResourceDescriptorHeap[g_TargetTexIndex];
    
      float2 screenUV = (float2(dispatchThreadID.xy) + 0.5f) * g_SourceSize.zw;
      UINT2 destCoord = dispatchThreadID.xy;
      UINT2 srcCoord = dispatchThreadID.xy * 2;
      uint2 maxCoord = (uint2)g_SourceSize.xy - 1;
    
      float depthArray[4];
      depthArray[0] = srcTex[min(srcCoord + UINT2(0, 0), maxCoord)].r;
      depthArray[1] = srcTex[min(srcCoord + UINT2(0, 1), maxCoord)].r;
      depthArray[2] = srcTex[min(srcCoord + UINT2(1, 0), maxCoord)].r;
      depthArray[3] = srcTex[min(srcCoord + UINT2(1, 1), maxCoord)].r;
    
      float minDepth = FLT_MAX;
      [unroll(4)]
      for (UINT i = 0; i < 4; i ++)
      {
          minDepth = min(minDepth, depthArray[i]);
      }
    
      o[destCoord] = minDepth;
    }
    
  • 最终效果

    因为计算得到的是原生非线性的深度,值非常大,图片全白,为了更方便的debug,所以需要在debug shader中将这一深度转换到01线性深度

    这里展示几个mipmap 下的深度图

    mipmap2:

    mipmap 5:

    mipmap7:


    mipmap 10:

  • 如何判断hi z是否正确呢?因为是在2x2中计算最小值,那么hiz之间的变化肯定是连续的,不会突然大变,且观察上面这些图中的白色部分,这一部分深度是最远的(显白色),mipmap从0到最高层,这一部分肯定是不断保持白色,直至不断被四周的黑色吞没

  • 融入AO

    我们需要计算撒点处到中心点的距离,基于这个距离来自动选择对应的层数

    float2 offsetPixel = abs(randomPosUV - screenUV) * g_TargetSize.xy;
    // 取宽高的最大值作为跨度参考,log2 得到层级
    // 保证采样点覆盖的区域与 Mip 代表的区域一致
    float mipmapLevel = clamp(log2(max(offsetPixel.x, offsetPixel.y)), 0.f, (float)g_HIZMaxMipmap);
    

    效果:

  • 修复条纹

    • 问题:当AO Radius非常大时,会造成上图的条纹

    • 原因

    1. 原本的采样区域被拉开的很大(稀疏化
    2. offsetPixel也会显著增加,那么mipmap之间的切换不平滑
    • 解决方案

    这种问题跟ray marching很类似,可以加入jitter

    float jitter = randomVector.y * 0.5;    // blue noise
    float mipmapLevel = clamp(log2(max(offsetPixel.x, offsetPixel.y)) + jitter, 0.f, (float)g_HIZMaxMipmap);
    

    可以看到有一定的效果但不多,因为这种方法不能解决mipmap切换不平滑的问题,它只能将条纹转化为噪声

    为了解决这一问题,需要通过三线性插值,也就是lerp mipmap

    float SampleHiZTrilinear(float2 uv, float mipmapLevel)
    {
        // 获取相邻的两个整数层级
        uint mip0 = (uint)floor(mipmapLevel);
        uint mip1 = min(mip0 + 1, g_HIZMaxMipmap);
    
        // 计算两层之间的混合因子
        float t = frac(mipmapLevel);
    
        // 采样低层级(更精细)
        float depth0 = SampleTexture2D_LOD(g_HIZTextureIndex, uv, ClampLinearSampler, (float)mip0);
    
        // 采样高层级(更粗糙)
        float depth1 = SampleTexture2D_LOD(g_HIZTextureIndex, uv, ClampLinearSampler, (float)mip1);
    
        // 在两层深度之间进行线性插值
        return lerp(depth0, depth1, t);
    }
    
    float randomDepth = SampleHiZTrilinear(randomPosUV, mipmapLevel);
    

    可以看到问题解决了

Sobol Sequence

  • 问题:之间在计算随机向量时,都是用c++直接在生成随机数再传递给gpu用于生成随机的向量,使用这种方法计算的AO肯定会产生许多噪点,例如:

  • 解决:为了解决这个问题,需要用到低差异序列Sobol Sqeuence

  • 算法:

    std::vector<Vector4> AOPass::GenerateSSAOSampleKernel()
    {
      std::vector<Vector4> o{};
      UINT maxSampleCount = 16;
      maxSampleCount = MathHelper::Min(UserData::GetInstance().aoParameter.SampleCount, maxSampleCount);
      o.resize(maxSampleCount);
    
      SobolSequenceGenerator sobol(2);
      uint32_t* C = sobol.rightmostZeroBit(maxSampleCount);
    
      for (UINT32 i = 0; i < maxSampleCount; ++i)
      {
          auto point = sobol.nextPoint(C[i]);
          float u1 = static_cast<float>(point[0]);
          float u2 = static_cast<float>(point[1]);
    
          // 核心:使用余弦加权映射到半球 (Cosine-weighted Hemisphere)
          float phi = 2.0f * 3.14159265f * u1;
          float cosTheta = sqrt(1.0f - u2);
          float sinTheta = sqrt(u2);
    
          Vector4 v;
          v.x = cos(phi) * sinTheta;
          v.y = sin(phi) * sinTheta;
          v.z = cosTheta; // Z轴始终为正,保证在半球内
          v.w = 0.0f;
    
          // 距离缩放(靠近原点的点权重更高,增强接触阴影)
          float scale = (float)i / (float)maxSampleCount;
          scale = 0.1f + 0.9f * (scale * scale); // Lerp(0.1, 1.0, scale^2)
    
          o[i] = v * scale;
      }
    
      return o;
    }
    
  • 效果

自适应半径

  • 问题

    目前半径是一个固定值,这会产生一个问题——如果半径大,纹路会被抹平;如果半径小,大场景将没有阴影

  • 解决

    同时计算一个大半径的AO和一个小半径的AO,并加权平均

  • 算法

    先设定一个大的radius:g_AORadius和一个小的radius:g_AORadius * 0.2f,它们会影响offsetPixel的大小

    小radius的offsetPixel较小,需要的mipmap level低,保证AO锐利;大radius的offsetPixel较大,需要的mipmap level高,保证AO较为模糊

    同时需要保证,遮挡判定和半径大小有关,这意味着rangeCheck需要加入半径缩放系数(即上面提到的0.2f)

    最后,因为阴影往往是细节处更难做,所以加权平均时,小radius的权重更大

    float AO_Small = 0.0f;
    float AO_Large = 0.0f;
    [unroll(16)]
    for (UINT sampleIndex = 0; sampleIndex < g_AOSampleCount; ++sampleIndex)
    {
      float3 randomVec = mul(g_AOSampleKernelArray[sampleIndex], TBN);
    
      float3 vecLarge = randomVec * g_AORadius;
      AO_Large += ComputeSingleAO(vecLarge, inputParam, normalVS, 1.0, randomVector * 0.5f);
    
      float3 vecSmall = randomVec * (g_AORadius * 0.2f);
      AO_Small += ComputeSingleAO(vecSmall, inputParam, normalVS, 0.2, randomVector * 0.5f);
    }
    
    AO_Large /= (float)g_AOSampleCount;
    AO_Small /= (float)g_AOSampleCount;
    
    float combinedAO = saturate(AO_Large * 0.4f + AO_Small * 0.6f);
    

    效果

    可以看到远处的阴影细节降低了,更宏大模糊

HBAO

  • 问题

    从上图可以看出,SSAO的AO效果其实不是特别明显,这是因为SSAO是随机撒点再计算点是否遮挡,这种方式并不物理——不能完全正确地计算遮挡,也就不能做到该黑的地方很黑。存在的问题如下:

    1. SSAO假设深度图中每个pixel向后无限厚:这就导致一个非常薄的薄片靠近墙体,它的阴影范围会很大
    2. 光晕:假设一个前景物体离背景很远,这种情况前景物体不应该与背景产生阴影,但SSAO会将这种情况算为遮挡(即使可以用距离阈值来减轻,但不能从根本上解决)
  • 解决

    为了解决以上两个问题,不能简单地根据深度来判断遮挡,而是需要引入ray marching一步一步地向某个方向步进,如此便解决了SSAO不靠谱的深度判断的问题

    HBAO便是这一问题的解决者

  • 算法

    步进并不难,难的是如何精准地计算遮挡,这也正是HBAO的算法精髓

    • 流程
    1. 随机生成切线角t:根据pixel的normal和随机向量,计算切线角t

    2. 步进

    3. 寻找最大仰角:对于步进到的每一个点 ,在深度图上计算它与起始点 P 构成的向量的仰角。记录并更新该方向上的最大仰角 h(即地平线角度)

      具体来说,每次步进后的坐标点,需要转化到uv,在深度图上采样对应uv的深度,并使用该深度重建view空间坐标,该坐标和起始点相减得到的值才是新向量

    4. 计算遮挡贡献:将最大仰角转化为一个 0 到 1 的遮挡值

    • 遮挡项

      • 公式:W = sin(h) - sin(t)
      • 物理意义:若与切线平行,则没有遮挡,W = 0;若与切线垂直,则完全遮挡,W = 1
    • 距离衰减

      为了更加物理,即遮挡应随到遮挡物距离的不断上升而下降,需要计算距离衰减

      • 公式:f(d) = max(0, 1 - \frac{d^2}{R^2})
        • d:起始点到最大仰角点的距离
        • R:预设的AO半径
      • 物理意义:随着d的增大,遮挡值会不断降低;随着d的降低,遮挡值会不断增大
    • 累加平均

      将每次步进后的遮挡贡献相加并平均

      • 切线角有什么用?

    避免把像素“自身的表面”误认为是遮挡物:比如,在斜坡向上步进肯定会得到一个很高的仰角,便会造成自遮挡

    • 为什么需要找最大仰角?

    计算是否遮挡:找到最大仰角,和切线角比较,若大于切线角,那肯定存在遮挡

    计算遮挡贡献

透视矫正

  • 为什么?

    为什么HBAO需要透视矫正,而SSAO不用呢?因为SSAO生成随机变量后,会不断进行矩阵变化直至uv空间,矩阵本身已经算透视矫正了;而HBAO需要在view space步进,从uv space到view space,本身就会受到透视畸变的影响

  • 如何矫正

    需要考虑fov 和 屏幕比例两种因素

    • 如何矫正uv space到view space

    因为uv space每个pixel都是一个大小相同的正方形,但在view space 受视锥体的影响,上下左右移动pixel的距离是不等,所以需要矫正,使得view space中上下左右移动pixel的距离是相同的

    众所周知,project Matrix的[0][0]与[1][1]分别表示\frac{1}{tan(fovX/2)}\frac{1}{tan(fovY/2)},再根据屏幕宽高,以及view space的深度,不难实现以下矫正

    float2 projScale = float2(projMatrix[0][0], projMatrix[1][1]);
    float ratio = g_TargetSize.x * g_TargetSize.w;
    
    float3 fovFix = float3(projScale.x, ratio * projScale.x, 1);
    float2 fovFixXY = fovFix.xy * (1.f / max(inputParam.PositionVS.z, 1e-4));   //抵消透视投影近大远小的影响
    

采样空间

与SSAO不同的是,HBAO的随机向量只需要xy分量,不需要xyz分量。这是因为SSAO是在一个3d空间撒点,而HBAO只需要确定2d空间。这就好像,我们站在某个位置,向某个方向望去(切线角),我们并不关心x轴,只关心yz轴

随机数选择

SSAO可以使用Sobol Sequence,但HBAO更偏向Poisson Disk Distribution

这是因为Sobol Sequence呈现网格状形状,而Poisson Disk Distribution中任意两个点的距离都大于某个最小值 r,没有明显的聚集,且是各向同性,另外Sobol Sequence在样本较少的时候,效果并不会很好

算法实现

  • 生成随机2d向量并随机旋转
    float3 randomVector = SampleTexture2D(BlueNoiseTexIndex, inputParam.ScreenUV * g_noiseScale, WarpPointSampler).xyz;
    float2 randomVec = randomVector.xy * 2.f - 1.f;
    float2x2 rotationMatrix = float2x2(
          randomVec.x, randomVec.y,
          -randomVec.y, randomVec.x
          );
    
    float2 unrotatedRandomDir = g_AOSampleKernelArray[i].xy;
    float2 randomDirUV = mul(unrotatedRandomDir, rotationMatrix);
    

    与SSAO不同,这里不能使用sobol sequence,否则会造成需要噪点

  • 计算切线角

    float tanAngleSin = dot(tangentVS, normalVS) + sin(g_AOBias);
    

    切线面是bit tangent,而dot(tangentVS, normalVS)等同于sin(切线面),用这种方式可以避免坐标系不同的问题

  • 步进

    [unroll(_AO_MAX_SAMPLE_STEP_COUNT)]
    for (UINT step = 0; step < g_AOSampleStepCount; ++step) 
    {
    float stepDistance = ((float)step + jitter) / (float)g_AOSampleStepCount; // 步进距离
    
    float2 offsetUV = randomDirUV * stepDistance * actualAORadius * projScale.x * 0.5f / max(
                                    inputParam.PositionVS.z, 1.0f);
    float2 vSampleUV = inputParam.ScreenUV + offsetUV;
    
    if (any(vSampleUV < 0) || any(vSampleUV > 1))
                  continue;
    
      float MipLevel = ComputeMipLevel(actualAORadius, inputParam.PositionVS.z, stepDistance, jitter);
      float localRawDepth = SampleHiZTrilinear(vSampleUV, MipLevel);
      float3 localPosVS = ComputeViewSpacePosition(vSampleUV, localRawDepth, projMatrix_I);
    }
    

    步进时使用jitter是一个经典做法,优化采样次数不足带来的噪点

    randomDirUV * stepDistance * actualAORadius * projScale.x * 0.5f / max(
    inputParam.PositionVS.z, 1.0f)
    ,将radius从world space转到uv space

  • 计算仰角

    // 计算仰角向量
    float3 v = localPosVS - inputParam.PositionVS;
    float distSq = dot(v, v);
    float dist = sqrt(distSq);
    
    // 距离衰减
    float invRadiusSq = 1.0 / (actualAORadius * actualAORadius + 1e-5);
    float falloff = saturate(1.0 - distSq * invRadiusSq);
    
    float3 V_norm = v / (dist + 1e-6);
    float currentSinH = dot(V_norm, normalVS);
    
    // 如果当前高度角超过已知最大角,更新遮挡
    if (currentSinH > maxSinH)
    {
    maxSinH = lerp(maxSinH, currentSinH, falloff);
    }
    

最终效果

优化

由于需要控制步进次数,最多6次,这里运用自适应半径的收益就不高了。收益高的方案有:

  • 采用UE的思路,使用一张全分辨率 AO + 1/2分辨率 AO + 1/4分辨率 AO,从低到高每次AO计算后与上一个AO混合

  • 无需额外pass的2x2 模糊

  • 时间滤波

    由于AO是和向量有关,且单帧无法支撑多大32次采样的消耗,那么不如每帧采样4次,并在多帧中旋转向量,再多帧混合,达到一帧拥有4*n帧的效果

耗时和效果:


pix gpu timer测试结果如下,分别是hi normal depth + Calc AO + TAA

Calc AO + TAA没啥问题,但hi normal depth由于采样次数比较多,造成频繁的barrier,解决方案有两个:

  1. 如果不想大改,用pixel shader性能会好一些
  2. 有一种可以一次dispatch完成所有hi normal depth的计算方法,这个留到后续介绍

Reference

双边滤波 - Bilateral Filter

基于屏幕空间的实时全局光照(Real-time Global Illumination Based On Screen Space)

UE4 AO


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