前言与问题
上篇介绍的HBAO思路其实是UE HBAO的方案,通过升降采样混合来实现大范围阴影,这种做法会存在几个问题:
- 升降采样会导致画面模糊,多了两张图的开销
- 由于在全分辨率计算,为了AO范围更大,半径会设置的较大,那么采样范围便增大了,导致GPU的纹理缓存命中率降低
- 升降采样混合与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+可选的优化方案如下:
- 去交错:HBAO+在优化方案与HBAO也有很大的不同,首当其冲的就是去交错渲染,这个算法类似Tile-Based,都是将一张整图划分为许多张小图
- 交叉双边滤波:普通的模糊不太适合AO算法,因为模糊会导致AO的细节丢失,尤其是边缘会随着模糊强度的增大而变大,需要一种模糊算法,在保留边缘的同时模糊中心,自然而然地想到了双边滤波,但简单的双边滤波并不能满足这一需求,还需要考虑采样点的normal 和 depth,来计算连续性;甚至当模糊半径过大时,因为高斯权重的原因,越往外权重低,那么半径大的就不再1个radius的增大,而是2个radius
- 自适应采样质量:根据上图所示,我们只想在黑色部分且越黑的部分提供高质量的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都在同一张图内,一个线程组内所有线程采样的区域不就在一张图里了嘛
-
算法
听起来很简单,实现起来可不简单,分为以下几步:
- 将一个整图拆分为多个小图,分为depth和AO
整图应该拆分为多少张图?
GPU在采样时是按2x2的块采样,GPU会检测这四个pixel对应的信息,看这些信息在L2中是否存在,如果不存在,会将数据放入Cache Line加载到L2
Cache Line十分重要,它一次性可以加载64字节,而R32FLOAT大小4字节,这意味着Cache Line一次性可以加载16个格式为R32FLOAT的pixel
再者,由于AO效果一般都要求范围较大,因此将小图分成16张是不错的选择
-
每个小图去交错,保存depth
-
在全屏下采样小图depth,得到小图AO
-
多张AO小图还原整图
流程如下图所示:

- 将一个整图拆分为多个小图,分为depth和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,不同的是:
- 它并不是随机撒点,而是对称采集一对预计算的样本,从而减少噪点
- SSAO因为简单的参考depth从而导致不正确的AO出现, CACAO的Base AO参考HBAO的思想。利用采样点与表面法线的夹角,确定采样点是否在物体前方,并确定采样点是否在物体指定radius内,若都满足,才会产生遮蔽
- 输出AO区域权重,用于后续计算importance

-
Importance
-
问题:importance的质量与后续计算AO的质量有着千丝万缕的关联,一个不好的importance图很可能会导致AO计算出现斑块,过度不自然
-
解决:在计算importance的情况下,添加模糊,保证中心权重高的区域能向外扩散一些
-
Calc AO
-
问题:在进行HBAO+的计算时,不能根据importance自适应步进次数(步进也相当于在积分,本来积分次数就较少,1-2次的步进差距都有可能导致两块区域的效果天差地别)
-
解决:那如何善用importance呢?
- 自适应采样次数:虽然不能自适应步进次数,但采样次数是可以的,带来的巨变不大可以接受。但因为采样次数是可变的很可能出现线程等待的情况
- 固定采样次数+importance混合:先计算一个固定的采样次数的AO,基于importance混合AO,权重大的区域AO效果强,权重小的区域AO效果弱,可以达到渐变的效果
- 基于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逻辑

总体来说性能是很不错的


















Comments | NOTHING