DX12 SSAO入门到HBAO入坟
前言
- 什么是SSAO
SSAO 的全称是 Screen Space Ambient Occlusion(屏幕空间环境光遮蔽)。这是一种在渲染中增加阴影感的算法,可以让物体交界处、缝隙、褶皱显得更真实,增加画面的层次感和深度感
-
为什么需要SSAO
- 因为目前实时渲染主要使用shadow cast 和 shadow receive,这种算法因为bias的原因,不能保证某些地方的阴影计算是完全正确且覆盖的;而SSAO正好可以弥补这一算法的不足
- 由于实时渲染无法像离线渲染一样,可以让光线多次反弹,从而得到屋子的角落会比空旷的地面暗一些,导致场景看起来像“塑料”一样,物体仿佛悬浮在地面上,缺乏体积感。SSAO正好可以高性能的解决这个问题
- 如何实现SSAO
- 深度采样:计算屏幕每个像素点的depth
- 半径测试:在每个像素周围的一定空间内随机采样若干个点
- 遮蔽判断:如果采样点被周围的物体挡住了,说明这个像素处于阴影中
- 模糊处理:为了节省性能,采样通常比较稀疏,会有噪点,所以最后会加一层模糊处理,让阴影看起来更柔和
- SSAO优缺点
- 优点
- 性能开销低:与屏幕分辨率有关,不像光追和场景复杂度有关
- 不是预制菜:可以实时运行
- 可快速嵌入后效
- 缺点:
- SSAO是一种屏幕空间技术,这种技术的弊端是对于屏幕外的物体无法加入计算中
- 光晕:物体边缘可能会出现不自然的黑色光圈
- 旋转视角时,阴影可能会有轻微变化
重建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需要在每个像素周围的一定空间内随机采样若干个点,用于后续的遮蔽判断。因此,一个好的随机算法十分重要,这里使用到的是基于均匀分布的切线空间随机旋转向量,它为每一个像素提供随机的旋转向量
算法流程:
- 空间分布:需要将空间分布在物体朝外的半球区域,以免采样到物体内部,造成错误阴影
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; - 密度分布:使用二次函数再次计算新的采样点
为什么需要这一步呢?虽然说是均匀分布,但最终效果还是希望采样点(阴影)更多地集中在原点附近,越向外越少
scale = MathHelper::Lerp(0.01f, 1.f, scale * scale); // 二次函数分布 - 构建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 采样
-
计算遮挡
为了计算遮挡我们需要知道采样点的深度和当前采样点的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));
-
当前效果

模糊处理
上图可以看出当前的AO还比较硬,类似深度比较的阴影,AO也需要做软化处理,那么就需要用到模糊
- 模糊选型
应该使用哪种模糊呢?其实我们真正的目的是,在模糊AO的同时,保留轮廓,否则当模糊强度稍微拉高,整个AO都很糊,看着很没有质感。因此选择双边滤波
-
算法

其中,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))
-
效果

HI-Z
目前SSAO的算法都是在原生分辨率上计算,这有几个缺点:一是带宽和计算量大;二是降低缓存命中率;
- 为什么降低缓存命中率?
想象一下,如果一个连续的AO区域非常大,那么肯定需要采样离中心像素很远的像素,对于原生分辨率这已经降低了效率;而且,因为相邻线程访问的内存地址跨度大,导致缓存命中率也不高
-
算法
- HIZ对象:在做GPU Culling时,HI-Z对象时计算区域的min depth;而在AO中,要么计算2x2中的最小值,要么点采样取左上角pixel
-
层数计算:
-
如何确定最终层数呢?
因为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非常大时,会造成上图的条纹
-
原因
- 原本的采样区域被拉开的很大(稀疏化)
- 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是随机撒点再计算点是否遮挡,这种方式并不物理——不能完全正确地计算遮挡,也就不能做到该黑的地方很黑。存在的问题如下:
- SSAO假设深度图中每个pixel向后无限厚:这就导致一个非常薄的薄片靠近墙体,它的阴影范围会很大
- 光晕:假设一个前景物体离背景很远,这种情况前景物体不应该与背景产生阴影,但SSAO会将这种情况算为遮挡(即使可以用距离阈值来减轻,但不能从根本上解决)
- 解决
为了解决以上两个问题,不能简单地根据深度来判断遮挡,而是需要引入ray marching一步一步地向某个方向步进,如此便解决了SSAO不靠谱的深度判断的问题
HBAO便是这一问题的解决者
-
算法
步进并不难,难的是如何精准地计算遮挡,这也正是HBAO的算法精髓
- 流程
- 随机生成切线角t:根据pixel的normal和随机向量,计算切线角t
-
步进
-
寻找最大仰角:对于步进到的每一个点 ,在深度图上计算它与起始点 P 构成的向量的仰角。记录并更新该方向上的最大仰角 h(即地平线角度)
具体来说,每次步进后的坐标点,需要转化到uv,在深度图上采样对应uv的深度,并使用该深度重建view空间坐标,该坐标和起始点相减得到的值才是新向量
-
计算遮挡贡献:将最大仰角转化为一个 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的降低,遮挡值会不断增大
- 公式:f(d) = max(0, 1 - \frac{d^2}{R^2})
- 累加平均
将每次步进后的遮挡贡献相加并平均
- 切线角有什么用?
避免把像素“自身的表面”误认为是遮挡物:比如,在斜坡向上步进肯定会得到一个很高的仰角,便会造成自遮挡
- 为什么需要找最大仰角?
计算是否遮挡:找到最大仰角,和切线角比较,若大于切线角,那肯定存在遮挡
计算遮挡贡献
透视矫正
-
为什么?
为什么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,解决方案有两个:
- 如果不想大改,用pixel shader性能会好一些
- 有一种可以一次dispatch完成所有hi normal depth的计算方法,这个留到后续介绍
Reference
基于屏幕空间的实时全局光照(Real-time Global Illumination Based On Screen Space)

















Comments | NOTHING