前言与问题

上篇介绍的HBAO思路其实是UE HBAO的方案,通过升降采样混合来实现大范围阴影,这种做法会存在几个问题:

  1. 升降采样会导致画面模糊,多了两张图的开销
  2. 由于在全分辨率计算,为了AO范围更大,半径会设置的较大,那么采样范围便增大了,导致GPU的纹理缓存命中率降低
  3. 升降采样混合与TAA导致闪烁的出现

为了解决以上几个问题,HBAO+由此诞生

什么是HBAO+

HBAO+与HBAO最大的不同,在于限定随机区域,HBAO本质还是暴力随机,即使使用blue noise,依然会导致噪点的出现;而HBAO+使用两两垂直4个向量采样的方式,这种做法可以限定随机区域,不至于在一个大的区域里随机,将一个大的区域拆分成四个小区域,在四个小区域里撒点,如此与HBAO相比得到的噪点会大大减少

算法流程

流程如下:

  • 构建四个两两垂直的向量
  • 将向量变换到uv space
  • 和HBAO一样,向量步进,记录最大仰角
float randomAngle = randomVector.x * TWO_PI;

const int NUM_DIRECTIONS = 4;
// world space to uv space
float pixelRadius = radius * projScale.x / max(inputParam.LinearEyeDepth, 1.f);
pixelRadius *= 0.5f * g_TargetSize.y;

float2 texelSize = g_TargetSize.zw;
float stepPixel = pixelRadius / (NUM_STEPS + 1);

[unroll(4)]
for (UINT dir = 0; dir < NUM_DIRECTIONS; dir ++)
{
    float angle = float(dir) / float(NUM_DIRECTIONS) * TWO_PI + randomAngle;
    float2 dirUV;
    sincos(angle, dirUV.y, dirUV.x);

    float2 deltaUV = dirUV * texelSize * stepPixel;

    float2 currentUV = inputParam.ScreenUV + deltaUV;

    //... 步进
}
  • 效果:

    下图是为TAA jitter 6次步进后的效果

优化

总的来说,HBAO+可选的优化方案如下:

  1. 去交错:HBAO+在优化方案与HBAO也有很大的不同,首当其冲的就是去交错渲染,这个算法类似Tile-Based,都是将一张整图划分为许多张小图
  2. 交叉双边滤波:普通的模糊不太适合AO算法,因为模糊会导致AO的细节丢失,尤其是边缘会随着模糊强度的增大而变大,需要一种模糊算法,在保留边缘的同时模糊中心,自然而然地想到了双边滤波,但简单的双边滤波并不能满足这一需求,还需要考虑采样点的normal 和 depth,来计算连续性;甚至当模糊半径过大时,因为高斯权重的原因,越往外权重低,那么半径大的就不再1个radius的增大,而是2个radius
  3. 自适应采样质量:根据上图所示,我们只想在黑色部分且越黑的部分提供高质量的AO,越白的部分提供低的

交叉双边滤波

计算深度权重

计算两个采样点的深度差,基于深度差计算深度权重,需要考虑近处权重小,远处权重大,深度差越大,权重应该越小

近处权重小,远处权重大:除以scene depth

深度差越大,权重应该越小:这和exp(-x)很类似

float depthDiff = abs(centerEyeDepth - sampleEyeDepth);
float depthScale = 10.0f / max(1.0f, centerEyeDepth);
float depthWeight = exp(-depthDiff * depthScale);

这里10是一个经验值

计算法线权重

计算两个法线的差异度,基于差异度计算法线权重

float normalDiff = saturate(dot(centerNormal, sampleNormal));
float normalWeight = pow(max(normalDiff, 0.1f), g_Sharpness);

实现

其余部分与双边滤波无异,采用可分离的思路来优化,将时间复杂度从N^2降至2N。不难写出:

float2 sampleUV;
float sampleAO;
float sampleEyeDepth;
float3 sampleNormal;
float LOD = 0;
float sampleRadius = 1.f;

sampleUV = centerUV + direction * stride * distance * texelSize * sampleRadius;
sampleAO = SampleTexture2D(g_SourceTexIndex,
                           sampleUV,
                           ClampPointSampler).r;
float rawDepth = SampleTexture2D(OpaqueDepthIndex, sampleUV, ClampPointSampler).r;
sampleEyeDepth = LinearEyeDepth(rawDepth, g_ZBufferParams);
sampleNormal = SampleNormalWS(sampleUV, ClampPointSampler);

float weight = ComputeWeight(sampleEyeDepth,
                                 sampleNormal,
                                 centerEyeDepth,
                                 centerNormal,
                                 distance,
                                 blurFalloff);
totalAO += sampleAO * weight;
totalWeight += weight;

Deinterleave

  • 问题:双边滤波可以模糊噪点,降低采样次数,但不能解决HBAO+本身的问题——GPU L2缓存的命中率
    • GPU L2是存储纹理、常量的地方,由于L2的缓存不大(只有几MB),不可能加载贴图的所有pixel。如下面代码所示,在HBAO+ 循环里会对depth texture反复采样,由于步进和采样半径,随着步进增大与采样半径增大,上一次采样的pixel可能离下一次采样的pixel很远,这就导致L2每次采样都需要加载pixel,很显然利用率非常低。甚至HBAO+还需要在四个区域里随机点,进一步降低了pixel
    for each pixel p:
        float occlusion = 0;
        for each direction d:
            float maxHorizon = 0;
            for step = 1 to numSteps:
                samplePos = p + d * step * randomScale;
                sampleDepth = texture2D(depthTex, samplePos).r;
    
    • SIMD:GPU的工作方式类似SIMD,一个线程组会执行相同的指令,再根据上面提到的问题,整个线程组采样的区域不够集中,非常稀疏,导致L2频繁的更换
  • 解决:需要一种方法让一个线程组内的线程访问的区域尽量集中

    仔细想想,步进的时候,先采样一个pixel,反复步进n个pixel后采样,这是否意味着,如果把整个屏幕,按n*n的区域划分(这里称为块),所有n*n的块中索引为[0,0]的pixel都存储在一张小图里,索引为[0,1]的pixel都存储在另一张小图里······

    这样每次步进n个像素后采样的pixel都在同一张图内,一个线程组内所有线程采样的区域不就在一张图里了嘛

  • 算法

    听起来很简单,实现起来可不简单,分为以下几步:

    1. 将一个整图拆分为多个小图,分为depth和AO

      整图应该拆分为多少张图?

      GPU在采样时是按2x2的块采样,GPU会检测这四个pixel对应的信息,看这些信息在L2中是否存在,如果不存在,会将数据放入Cache Line加载到L2

      Cache Line十分重要,它一次性可以加载64字节,而R32FLOAT大小4字节,这意味着Cache Line一次性可以加载16个格式为R32FLOAT的pixel

      再者,由于AO效果一般都要求范围较大,因此将小图分成16张是不错的选择

    2. 每个小图去交错,保存depth

    3. 在全屏下采样小图depth,得到小图AO

    4. 多张AO小图还原整图

    流程如下图所示:

  • 实现

    • 创建16张小图,每张小图的分辨率为全分辨率的1/4,将这些小图放在一个数组里传给GPU,GPU一个dispath计算所有的小图
    UINT2 pixelOffset = id.xy % 4;
    UINT layerIndex = pixelOffset.x + pixelOffset.y * 4;
    
    UINT2 writePos = id.xy / 4;
    
    // sample Depth
    o[writePos] = depth;
    

    以4x4为一个块,每个块的同一索引值都放在同一小图里

    • 在小图中计算AO

    重点在还原全分辨率的screen uv

    UINT layerIndex = id.z;
    
    uint offsetX = layerIndex % 4;
    uint offsetY = layerIndex / 4;
    
    float2 fullScreenUV = (float2(id.xy * 4 + uint2(offsetX, offsetY)) + 0.5f) *
                              g_FullScreenSize.zw;
    

    UINT layerIndex = id.z;这是因为,dispatch(x,y,16),可以并行执行16张AO图的计算

    后续步进需要求全分辨率下步进后的sample uv,虽然小图的分辨率是全分辨率的1/4,但uv都在[0,1],用以下代码即可还原

    float2 sampleFullUV = fullUV + (currentUV - localUV);
    
    • 16张小图拼成大图

    其实是第一步的逆计算,在全分辨率下计算,还原AO

    UINT2 pixelOffset = id.xy % 4;
    UINT layerIndex = pixelOffset.x + pixelOffset.y * 4;
    UINT2 readPos = id.xy / 4;
    
    // load ao
    o[id.xy] = ao;
    
  • 性能

    耗时如下:

    下图展示了L2命中率与线程组的占用率,可以看到数据很不错,都在60到70

Importance采样

  • 问题:目前每个pixel都以相同的采样次数采样,却不管这个点对AO的贡献高不高,对于平坦区域如此做法会导致大量不必要的浪费

  • 解决:提前计算一张importance图,图中记录每个pixel对AO的重要性,重要性高的pixel在后续继续AO时,提高采样次数,重要性低的pixel则降低采样次数

  • 算法

    • importance计算依据

    • 既然AO出现在深度变化大的区域,可以用深度变化作为依据吗?

      可以但不多,这种方法会存在一定问题。一般AO的区域都会调的比较大,这一区域一定是大于深度变化的区域,笔者在测试时这种方法时,发现会造成大量的截断,如下图所示,与地面的接触区域,正常情况不用importance,AO区域大概是下图的两倍

    • 为了得到更准确的importance,可以计算AO,这样得到的效果一定是准确的!

    • 为了避免在importance中计算AO带来的巨大开销,这一步一定要降采样

基于importance的交叉双边滤波

  • 问题:目前的交叉滤波和AO计算一样,每个pixel的采样半径都相同,利用importance也可以做到和计算AO一样的效果
  • 解决:针对平坦区域(importance低)提高采样半径,针对深度变化大的区域(importance高)降低采样半径

CheckBoard+TAA

  • 问题:去交错是四张图同时计算,相当于计算全分辨率,性能压力还是大,需要一种方法每帧只计算一半的图
  • 解决:利用CheckBoard渲染,第一帧渲染白棋(其中两个图),第二帧渲染黑棋(另外两张图)并合并第一帧结果(得到一个完整的AO),后续反复这个过程;在第三帧渲染黑棋时,提取第二帧的黑棋部分做时间滤波,将滤波后的黑棋与第二帧的白棋合并,后续反复这个过程

HIZ+去交错

  • 问题:全分辨率下的HIZ压力和范围还是大了,可以进一步缩小

  • 解决:对HIZ也去交错


    当然法线也可以去交错,毕竟后续在AO计算、滤波、上采样都会用到

个人方案

  • 算法说明:为了最大程度的提高L2的命中率,我将HIZ+Base AO(用于后续计算importance)+ Calc AO都进行了去交错。为什么不对blur也去交错呢,因为在 Calc AO阶段加入随机旋转用于后续配合TAA,如果在blur去交错后再上采样会导致后续TAA的闪烁异常明显

  • 重点说明:

    • HIZ

    • 问题:在AO这种与遮挡关系有极强关联的算法中,对HIZ进行简单的min、average都是不太好的,很可能出现断层和伪影

      • average:对于物体的边缘,基于2x2块 hiz,会将前景的depth 和 后景的depth average,得出一个既不在前景也不在后景的depth。最终导致伪影的出现
      • min:强制保留前景深度,把像素拉近。导致不该出现AO的地方出现了AO
    • 解决:需要一个基于遮挡关系智能average的算法

      以最近depth为基准,计算2x2块中其他pixel与它的距离,根据距离作为权重,离最近depth越远,权重越低,可以使用ao radius作为最大距离来计算权重

      float MipSmartAverage(float4 depths, float effectRadius)
      {
        float closest = min(min(depths.x, depths.y), min(depths.z, depths.w));
        float falloffCalcMulSq = -1.0f / (effectRadius * effectRadius);
      
        float4 dists = depths - closest.xxxx;
        float4 weights = saturate(dists * dists * falloffCalcMulSq + 1.0);
      
        return dot(weights, depths) / dot(weights, float4(1, 1, 1, 1));
      }
      
    • hiz mipmap level

    • 问题:如何正确计算hiz mipmap level至关重要,因为它直接关系到了L2的命中率

    • 解决:需要知道hiz mipmap level与什么直接挂钩,第一点想到的是depth,这正确但不对。因为在AO计算下与r半径直接相关,但在view space下r又显得太小,所以真正需要的是在screen space下,r占据了屏幕夺少像素。占据的屏幕像素越少说明离相机越近,mipmap level应该较低;占据的屏幕像素越多说明离相机越远,mipmap level应该较高

    • 算法

      // g_NDCToViewMul:cameraTanHalfFOV.x * 2.f, -cameraTanHalfFOV.y * 2.f
      // 屏幕上 1 个像素对应的 View Space 物理尺寸
      const float2 pixelDirRBViewspaceSizeAtCenterZ = inputParam.PositionVS.z * g_NDCToViewMul * g_DeinterleavedAOSize.zw;
      
      // 将 AO 半径从 View Space 转换回 Screen Space
      float pixLookupRadiusMod = (0.85f * radius) / pixelDirRBViewspaceSizeAtCenterZ.x;
      
      float mipLevel = max(0.0f, log2(pixLookupRadiusMod) - 4.3f);
      
      • 相机在 View Space 中 Z=1 处的总宽度是 2 \times \tan(\frac{FOV}{2})

    • Base AO

    • 问题:因为Base AO需要用于后续计算importance,且importance只是记录重要性不需要高频信息,所以需要一个AO算法在采样次数低的情况下没有大量噪点又能覆盖大面积AO区域

    • 解决:参考AMD CACAO的Base AO算法,这个算法类似SSAO使用撒点的形式计算AO,不同的是:

      1. 它并不是随机撒点,而是对称采集一对预计算的样本,从而减少噪点
      2. SSAO因为简单的参考depth从而导致不正确的AO出现, CACAO的Base AO参考HBAO的思想。利用采样点与表面法线的夹角,确定采样点是否在物体前方,并确定采样点是否在物体指定radius内,若都满足,才会产生遮蔽
      3. 输出AO区域权重,用于后续计算importance

    • Importance

    • 问题:importance的质量与后续计算AO的质量有着千丝万缕的关联,一个不好的importance图很可能会导致AO计算出现斑块,过度不自然

    • 解决:在计算importance的情况下,添加模糊,保证中心权重高的区域能向外扩散一些

    • Calc AO

    • 问题:在进行HBAO+的计算时,不能根据importance自适应步进次数(步进也相当于在积分,本来积分次数就较少,1-2次的步进差距都有可能导致两块区域的效果天差地别)

    • 解决:那如何善用importance呢?

      1. 自适应采样次数:虽然不能自适应步进次数,但采样次数是可以的,带来的巨变不大可以接受。但因为采样次数是可变的很可能出现线程等待的情况
      2. 固定采样次数+importance混合:先计算一个固定的采样次数的AO,基于importance混合AO,权重大的区域AO效果强,权重小的区域AO效果弱,可以达到渐变的效果
      3. 基于importance混合不同分辨率下的AO结果:UE5的AO是直接混合了三种分辨率的结果,可以将这个思路融入。基于importance,小的区域计算W/4;中的计算W/2;高的计算全分辨率W
    • 上采样

    • 问题:简单的反去交错(类似point sampler)会导致棋盘格的出现,由此需要进行手动linear sampler,但像AO这种在意边缘信息的,linear sampler会导致边缘信息模糊

    • 解决:使用先前Calc AO计算得到的深度信息权重,在手动linear sampler时加入深度信息权重

    • Blur

    根据importance动态模糊AO,importance低的模糊强度拉满,importance高的模糊强度变低保留AO边缘

    因为Blur需要用到深度信息,这一信息通常是depth与normal共同计算得到的,而双边滤波一个循环中会多次采样depth normal,造成极大的浪费,所以需要一种方法能一次性计算完深度信息,这种方法是在Calc AOy一次性计算深度信息,保存到g通道传递给Blur

  • 效果与性能
    效果如下:

    性能如下:

    模糊和HIZ阶段都还可以用CS优化,目前在还原算法的情况下且采样次数高到32次,差不多0.7-0.8ms性能是不错的

    以下是L2命中率,可以看到除了前面因为是计算HIZ,其他大部分时间L2的命中率都非常高,甚至大部分稳在90以上

    Warp占用率60 左右,说明出现了一些线程等待的情况,这点需要优化shader逻辑

    总体来说性能是很不错的

Reference

SIGGRAPH2016

HBAOPlus

AMD FidelityFX™ CACAO


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