AO系列总结
AO系列总结
该系列文章将总结目前行业中流行的三大AO算法
SSAO,HBAO,GTAO。我将介绍他们的原理和实现。其中 SSAO因为有 《LearnOpenGL》可以参考。所以放到最后,先从 HBAO开始,到 GTAO。最后再介绍 SSAO。
AO指的是 Ambient Occlusion,指的是在几何体图形变化剧烈的地方(角落,物体接缝处等)相较于平坦连续的表面,显得更暗。这是因为光线在这些地方传播时受遮蔽影响,导致能量衰减,最终进入眼的光线能量较弱,所以显得更暗。
该系列文章将会介绍上面三个算法。

前置知识
在介绍这些算法之前,先介绍一些前置知识。即根据深度图反算出世界空间的坐标和法线。
根据深度计算世界坐标
在我们的示例中,使用的是Reversed Z的深度图,靠近相机的位置深度接近1,远离相机的趋近于0。并且使用Unity URP框架来实现我们的代码。

现在我们需要采样这张贴图,并计算出世界空间坐标。
那么第一步就是采样这张深度图,顶点着色器如下,直接绘制屏幕面片。
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv = v.uv;
return o;
}
在片段着色器中,直接采样这张图:
TEXTURE2D(_CameraDepthAttachment);
SAMPLER(sampler_CameraDepthAttachment);
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
return float4(depth,depth,depth, 1.0);
}
然后由UV和深度,构造NDC坐标,并根据UNITY_UV_STARTS_AT_TOP来判断是否需要将 Y轴翻转。
TEXTURE2D(_CameraDepthAttachment);
SAMPLER(sampler_CameraDepthAttachment);
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
float4 ndc = float4(i.uv.x *2-1, i.uv.y*2-1,depth,1);
#if UNITY_UV_STARTS_AT_TOP
ndc.y *= -1;
#endif
return float4(ndc.xyz, 1.0); //返回显示灰度颜色
// float3 normal = normalize(cross(ddy(worldPos.xyz),ddx(worldPos.xyz)));
}
接着乘以投影矩阵和视图矩阵的逆矩阵,即可得到世界坐标。输出时可以把depth写入A通道,如果需要传递给其他步骤使用时,可以方便获得深度。
TEXTURE2D(_CameraDepthAttachment);
SAMPLER(sampler_CameraDepthAttachment);
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
// float3 worldPos = ComputeWorldSpacePosition(i.uv, depth, UNITY_MATRIX_I_VP);
float4 ndc = float4(i.uv.x *2-1, i.uv.y*2-1,depth,1);
#if UNITY_UV_STARTS_AT_TOP
ndc.y *= -1;
#endif
float4 worldPos = mul(UNITY_MATRIX_I_VP, ndc);
worldPos /=worldPos.w;
return float4(worldPos.xyz, depth); //返回显示世界坐标
}
在注释中可以看到,URP已经有封装好的函数,ComputeWorldSpacePosition。直接调用也可以获得世界坐标。内部实现和我们上面的写法无异。

根据深度重建法线
根据深度重新计算法线的原理,是计算梯度方向的叉乘来获得的,该算法得到的是近似结果。
我们的目标是要重建某个像素的世界空间坐标,那么就需要计算两个方向切线方向,并计算它们的叉积来获得法线。我们可以简单地用右边的像素减去当前像素该像素的世界坐标,作为该像素在X反向上的切线,用上方的像素减去当前像素的世界坐标,作为 Y方向的切线。然后计算叉积,得到法线。
在着色器中,ddx和 ddy就是做这个事情的。然而,这样的计算方式不是特别准确,但是看起来够用了。
TEXTURE2D(_CameraDepthAttachment);
SAMPLER(sampler_CameraDepthAttachment);
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
float3 worldPos = ComputeWorldSpacePosition(i.uv, depth, UNITY_MATRIX_I_VP);
float3 normal = normalize(cross(ddy(worldPos.xyz),ddx(worldPos.xyz)));
return float4(normal, 1.0); //返回显示法线
}

HBAO
我们从HBAO开始。HBAO原文叫做:Image-Space Horizon-Based Ambient Occlusion。算法的核心原理是针对深度图上每一个像素,在随机方向上Ray March,然后根据采样的值来计算AO值。
算法第一步是随机选取方向,并往该方向去采样深度图。随机方法有很多,可以用噪声图或噪声函数来实现随机。

然后在March方向上每一步采样一个深度,获得一个高度图。下图中的S_0,S_1,S_2,S_3为四个采样点,每个采样点都可以计算出一个 Horizon Angle。这是在视图空间的。

这里还需要考虑表面法线\vec{n}与采样方向的夹角t(\theta),计算AO时要减去sin(t(\theta))。

但是,我在参考很多实现源码的时候,都把公式转换成了\vec{n}\cdot \vec{h},\vec{h}是采样点S相对于点p的方向向量。
按原文演讲,得到下面的效果。在曲面上或有一些横条状的纹理,类似Shadow Map的摩尔纹。

解决方法也是增加一个Bias,称为 Angle Bias。每一次采样时都减去这个值。

在我参考的大多数文章和给出的源码中,都是在View空间计算的,所以可以看到由uv反推视空间坐标和法线的方法。而我自己在世界空间实现,感觉效果还可以。

这里我没有进行滤波操作,所以会有噪点(或者叫噪线,如下图)。对于该算法最后需要进行滤波降噪,而此类方法有很多,这里就不介绍了。

在Unity中实现,Shader代码主体如下:
Shader "Custom/AOShader"
{
..............
TEXTURE2D(_CameraDepthAttachment);
SAMPLER(sampler_CameraDepthAttachment);
float4 _CameraDepthAttachment_TexelSize;
float _RadiusPixel;
float _MaxRadiusPixel;
float _Radius;
float _AngleBias;
#define DIRECTION 8
#define STEPS 6
#define PI 3.141592653f
float2 AngleToDir(float angle)
{
return float2(cos(angle), sin(angle));
}
float FallOff(float dist, float radius)
{
return 1 - dist / radius;
}
float SimpleAO(float3 worldPos, float3 stepWorldPos, float3 normal, float bias)
{
float3 h = stepWorldPos - worldPos;
float dist = sqrt(dot(h,h));
float sinBlock = dot(normal, h)/dist;
float diff = max(sinBlock - bias,0);
return diff*saturate(FallOff(dist*dist, _Radius*_Radius));
}
//value-noise https://thebookofshaders.com/11/
float random(float2 uv) {
return frac(sin(dot(uv.xy, float2(12.9898, 78.233))) * 43758.5453123);
}
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
float3 positionWS = ComputeWorldSpacePosition(i.uv, depth, UNITY_MATRIX_I_VP);
float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);
float stepSize = min((_RadiusPixel / linearEyeDepth), _MaxRadiusPixel) / (STEPS + 1.0);
if(stepSize<1)
{
return float4(1,1,1,1);
}
float3 normalWS = normalize(cross(ddy(positionWS.xyz),ddx(positionWS.xyz)));
float delta = 2.0f * PI / DIRECTION;
float2 uv = i.uv;
float rnd = random(uv * 10);
float ao = 0;
UNITY_UNROLL
for(int i = 0;i<DIRECTION;i++)
{
float angle = delta*(float(i) + rnd);
float2 dir = AngleToDir(angle);
float rayPixel = 1; // Contant
UNITY_UNROLL
for(int j = 0;j<STEPS;j++)
{
float2 stepUV = round(rayPixel*dir)*_CameraDepthAttachment_TexelSize.xy + uv;
float stepDepth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, stepUV).r; //采样深度
float3 stepWorldPos = ComputeWorldSpacePosition(stepUV, stepDepth, UNITY_MATRIX_I_VP);
ao += SimpleAO(positionWS, stepWorldPos, normalWS, _AngleBias);
rayPixel += stepSize;
}
}
ao /= STEPS * DIRECTION;
ao = saturate(1 - ao);
return float4(ao,ao,ao, 1.0f); //返回显示灰度颜色
}
ENDHLSL
}
}
}
GTAO
GTAO全称Ground Truth Ambient Occlusion,是在HBAO基础上的改进。算法流程相对更复杂一些。下面先介绍理论,再来实现它。实现GTAO的方式可以在普通的Shader中实现,也就是后处理的方式,也可以在Compute Shader中实现,后者要相对复杂一些,HDRP就是在Compute Shader中实现的。
GTAO的计算也是在视图空间中进行的。通俗地说,计算过程就是:在每一个像素上的半球面上构造若干个半圆切片(Slice),然后沿着两个方向进行Ray March的过程,计算最大的水平角(Horizonal Angle)。根据两个最大水平角计算 AO值。

最大水平角的计算过程,就是往Slice的两个方向,不断采样深度,每一步计算取最大值。

最大水平角可能落在法线半球空间的背面,所以要把它限制在半球平面之内。如下图,n是投影法线与视图方向的夹角,这里h_1是负值,所以是减法。同理可以推导出h_2的公式。这一步是将最大水平角限制在法线半球空间的内(下图投影法线\vec{n}与\vec{h_1}'所在虚线平面构成的半球空间)。

得到每个切面上的两个最大水平角后,就可以计算上面的积分,计算当前切面上的AO值。解出来的公式如下:
这样就可以解出最终的AO值。然而还有一些概念没有介绍,例如投影法线和 Multi-Bounce
投影法线指的是该像素点在视图空间的法线,投影在Slice上的法线,即上图的\vec{n}。计算方法是使用斯密特正交化:

在这个过程中,对切面的选取,Ray March时对深度图的采样,加一点噪声可以得到丰富自然一点的效果。
Multi-Bounce是模拟环境光在物体表面反射,将周围的物体照亮,所以我们通过SSAO计算得到的AO值是偏小的,这就是因为光在物体表现多次反弹,实际表面应该更亮一点。多次反弹后的照亮效果,和物体表面的反照率相关,反照率越高,就能将周围照的更亮。相关函数可以表示如下:
我们使用由光线追踪得到的AO系数作为参照,尝试用多项式拟合函数曲线,得到大致的拟合曲线为:
float3 AOMultiBounce( float3 BaseColor, float AO )
{
float3 a = 2.0404 * BaseColor - 0.3324;
float3 b = -4.7951 * BaseColor + 0.6417;
float3 c = 2.7552 * BaseColor + 0.6903;
return max( AO, ( ( AO * a + b ) * AO + c ) * AO );
}
实现
经过上面的理论介绍,要实现还是比较困难。实际上在我参考的很多代码中,还涉及到Ray March步长的计算,水平角的权值(FallOff)的计算等等细节问题。这些我会在这一小节中逐步介绍。
float3 ComputeViewSpacePosition(float2 uv, float depth)
{
// return ComputeViewSpacePosition(uv, depth, UNITY_MATRIX_I_P);
float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);
return float3((uv*_UVToViewPos.xy + _UVToViewPos.zw)*linearEyeDepth, linearEyeDepth);
}
float3 GetNormalFromPosition(float3 position)
{
return normalize(cross(ddy(position),ddx(position)));
}
float4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, i.uv).r; //采样深度
float3 positionVS = ComputeViewSpacePosition(i.uv, depth);
float3 normalVS = GetNormalFromPosition(positionVS);
float3 vdir = normalize(-positionVS);
float2 uv = i.uv;
float ao = 0;
....
}
算法首先采样深度图,并反算出视图空间的坐标点,视图空间的法线和视图方向(上面图片中展示的\vec{v})。然后定义 uv和 ao的初始值。
接着计算Ray March步长,这里使用的和 HBAO一致,定义两个输入变量 _AORadiusPixel和 _AOMaxRadiusInPixels,STEPS是常数,表示 Ray March的次数。
float4 frag (v2f i) : SV_Target
{
....
float stepLength = min((_AORadiusPixel / positionVS.z),_AOMaxRadiusInPixels) / (STEPS + 1.0);
....
}
接着计算一个随机的偏移,这里是计算噪声。
float GetOffset(uint2 positionSS)
{
// Spatial offset
float offset = 0.25 * ((positionSS.y - positionSS.x) & 0x3);
return frac(offset);
}
float4 frag (v2f i) : SV_Target
{
....
float offset = GetOffset(uv);
....
}
然后是计算Slice,Slice是在UV空间内,以当前像素点为中心,将角度进行均分,每个角度可以定义一个Slice,这里可以再加一点噪声,总的角度是\pi,即半个圆。
float4 frag (v2f i) : SV_Target
{
....
float delta = PI / DIRECTION;
UNITY_UNROLL
for(int i = 0;i<DIRECTION;i++)
{
float angle = (float(i) + offset)*delta;
float2 dir = AngleToDir(angle);
float2 negDir = -dir + 1e-30;
}
....
}
算法的核心之一,是计算最大水平角,这里可以把它封装成一个函数来使用。
float4 frag (v2f i) : SV_Target
{
....
float delta = PI / DIRECTION;
UNITY_UNROLL
for(int i = 0;i<DIRECTION;i++)
{
....
float2 maxHorizons = float2(0,0);
maxHorizons.x = HorizonLoop(positionVS, vdir, uv, dir, offset, stepLength);
maxHorizons.y = HorizonLoop(positionVS, vdir, uv, negDir, offset, stepLength);
....
}
....
}
HorizonLoop定义如下,其中的 Fall Off函数使用和 HBAO一样的权值函数。下面就是 Ray March的过程,用前面计算的步长和方向。
float FallOff(float distSq, float invRadiusSq)
{
return saturate(1 - distSq * invRadiusSq);
}
float UpdateHorizon(float maxH, float candidateH, float distSq)
{
float weight = FallOff(distSq, 1.0f/(_AORadius*_AORadius));
return (candidateH > maxH)?lerp(maxH, candidateH, weight):lerp(maxH,candidateH, 0.03f);
}
float HorizonLoop(float3 positionVS, float3 vdir, float2 uv, float2 dir, float offset, float stepLength)
{
float maxHorizon = -1.0f;
float t = offset*stepLength + stepLength;
for(int i = 0;i<STEPS;i++)
{
float2 sampleUV = uv + t*dir*_CameraDepthAttachment_TexelSize.xy;
float sampleDepth = SAMPLE_TEXTURE2D(_CameraDepthAttachment, sampler_CameraDepthAttachment, sampleUV.xy).r;
float3 samplePosVS = ComputeViewSpacePosition(sampleUV, sampleDepth);
float3 deltaPos = samplePosVS - positionVS;
float deltaLenSq = dot(deltaPos,deltaPos);
float currentHorizon = dot(deltaPos, vdir)/sqrt(deltaLenSq);
maxHorizon = UpdateHorizon(maxHorizon, currentHorizon, deltaLenSq);
t += stepLength;
}
return maxHorizon;
}
最后计算投影法线,和一些角度,代入公式即可计算最终的AO值。
float4 frag (v2f i) : SV_Target
{
....
float delta = PI / DIRECTION;
UNITY_UNROLL
for(int i = 0;i<DIRECTION;i++)
{
....
float3 sliceN = normalize(cross(float3(dir.xy, 0), vdir));
float3 projN = normalVS - sliceN*dot(normalVS,sliceN);
float projNLen = length(projN);
float cosN = dot(projN/(projNLen + 0.0001f), vdir);
float3 T = cross(vdir, sliceN);
float N = -sign(dot(projN,T))*acos(cosN);
float h0 = -acos(maxHorizons.x);
float h1 = acos(maxHorizons.y);
h0 = N + max(h0 - N, -PI*0.5f);
h1 = N + min(h1 - N, PI*0.5f);
float val0 = (cosN + 2 * h0 * sin(N) - cos(2 * h0 - N)) / 4;
float val1 = (cosN + 2 * h1 * sin(N) - cos(2 * h1 - N)) / 4;
ao += val0+val1;
....
}
....
}
离开循环后,将AO值除以 Slice数量,即 DIRECTION,然后就可以输出 AO结果。这里我直接代入 Multi-Bounce计算多次弹射的结果,实际使用中,是在使用 AO和场景颜色才有能采样出 BaseColor。这里我只是想看看效果,所以就定义了一个白色。
下图是渲染结果,还是没有降噪,所以图中有噪点和噪线。

SSAO
对于SSAO,我这里就不详细介绍了。《Learn OpenGL》中已经给出了实现。
SSAO的实现原理是在像素点法线半球空间内进行随机采样,计算采样点的深度,如果深度大于该像素深度则认为是遮蔽的,否则是不遮蔽的。最后 AO值就是未被遮蔽数量的百分比。
