文章

AO系列总结

AO系列总结

  该系列文章将总结目前行业中流行的三大AO算法

   SSAOHBAOGTAO。我将介绍他们的原理和实现。其中 SSAO因为有 《LearnOpenGL》可以参考。所以放到最后,先从 HBAO开始,到 GTAO。最后再介绍 SSAO

   AO指的是 Ambient Occlusion,指的是在几何体图形变化剧烈的地方(角落,物体接缝处等)相较于平坦连续的表面,显得更暗。这是因为光线在这些地方传播时受遮蔽影响,导致能量衰减,最终进入眼的光线能量较弱,所以显得更暗。

  该系列文章将会介绍上面三个算法。

Ambient Occlusion - A Special Role in Game Design | Blender Render farm

前置知识

  在介绍这些算法之前,先介绍一些前置知识。即根据深度图反算出世界空间的坐标和法线。

根据深度计算世界坐标

  在我们的示例中,使用的是Reversed Z的深度图,靠近相机的位置深度接近1,远离相机的趋近于0。并且使用Unity URP框架来实现我们的代码。

image-20251120011858365.png

  现在我们需要采样这张贴图,并计算出世界空间坐标。

  那么第一步就是采样这张深度图,顶点着色器如下,直接绘制屏幕面片。

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。直接调用也可以获得世界坐标。内部实现和我们上面的写法无异。

image-20251120013354639.png

根据深度重建法线

  根据深度重新计算法线的原理,是计算梯度方向的叉乘来获得的,该算法得到的是近似结果。

  我们的目标是要重建某个像素的世界空间坐标,那么就需要计算两个方向切线方向,并计算它们的叉积来获得法线。我们可以简单地用右边的像素减去当前像素该像素的世界坐标,作为该像素在X反向上的切线,用上方的像素减去当前像素的世界坐标,作为 Y方向的切线。然后计算叉积,得到法线。
  在着色器中,ddxddy就是做这个事情的。然而,这样的计算方式不是特别准确,但是看起来够用了。

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); //返回显示法线
}

image-20251120014812865.png

HBAO

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

image-20251123202648403.png

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

image-20251123204629170.png

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

image-20251123205007379.png

  但是,我在参考很多实现源码的时候,都把公式转换成了\vec{n}\cdot \vec{h}\vec{h}是采样点S相对于点p的方向向量。

AO=\frac{\vec{n}\cdot \vec{h}}{|\vec{n}|\cdot|\vec{h}|}

  按原文演讲,得到下面的效果。在曲面上或有一些横条状的纹理,类似Shadow Map的摩尔纹。

image-20251123213437497.png

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

image-20251123213604695.png

AO=\frac{\vec{n}\cdot \vec{h}}{|\vec{n}|\cdot|\vec{h}|} - Bias

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

image-20251123214137771.png

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

image-20251123214620174.png

  在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值。

V_d=\frac{1}{\pi}\int_{\Omega}V(\omega_i)(n\cdot \omega_i)d\omega_i=\frac{1}{\pi}\int_0^{\pi}\int_{-\frac{\pi}{2}}^{\frac{\pi}{2}}V(\theta,\phi)(n\cdot\omega_i)|sin(\theta)|d\theta d\phi

image-20251128021159512.png

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

image-20251128021412725.png

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

image-20251128021722987.png

  得到每个切面上的两个最大水平角后,就可以计算上面的积分,计算当前切面上的AO值。解出来的公式如下:

V_d=\frac{1}{\pi}\int_0^{\pi}\int_{-\frac{\pi}{2}}^{\frac{\pi}{2}}V(\theta,\phi)(n\cdot\omega_i)|sin(\theta)|d\theta d\phi\\ =\frac{1}{4}(cos(n)-cos(2h_1-n)+2h_1sin(n))+\frac{1}{4}(cos(n)-cos(2h_2-n)+2h_2sin(n))

  这样就可以解出最终的AO值。然而还有一些概念没有介绍,例如投影法线和 Multi-Bounce

  投影法线指的是该像素点在视图空间的法线,投影在Slice上的法线,即上图的\vec{n}。计算方法是使用斯密特正交化:

image-20251128022724212.png

  在这个过程中,对切面的选取,Ray March时对深度图的采样,加一点噪声可以得到丰富自然一点的效果。
  Multi-Bounce是模拟环境光在物体表面反射,将周围的物体照亮,所以我们通过SSAO计算得到的AO值是偏小的,这就是因为光在物体表现多次反弹,实际表面应该更亮一点。多次反弹后的照亮效果,和物体表面的反照率相关,反照率越高,就能将周围照的更亮。相关函数可以表示如下:

V_d'=f(V_d,\rho)

  我们使用由光线追踪得到的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})。然后定义 uvao的初始值。

  接着计算Ray March步长,这里使用的和 HBAO一致,定义两个输入变量 _AORadiusPixel_AOMaxRadiusInPixelsSTEPS是常数,表示 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。这里我只是想看看效果,所以就定义了一个白色。
  下图是渲染结果,还是没有降噪,所以图中有噪点和噪线。

image-20251128025257901.png

SSAO

  对于SSAO,我这里就不详细介绍了。《Learn OpenGL》中已经给出了实现。

  SSAO的实现原理是在像素点法线半球空间内进行随机采样,计算采样点的深度,如果深度大于该像素深度则认为是遮蔽的,否则是不遮蔽的。最后 AO值就是未被遮蔽数量的百分比。

image-20251128030935359.png

许可协议:  CC BY 4.0