屏幕空间环境光遮蔽(SSAO)

本文主要介绍在Unity中如何实现SSAO,项目代码已经开源到GitHub上,点击这里获取!

SSAO简介

在现实中,光线会以任意方向散射,它的强度是会一直改变的,所以间接被照到的那部分场景也应该有变化的强度,而不是一成不变的环境光。其中一种间接光照的模拟叫做环境光遮蔽(Ambient Occlusion),它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮蔽的,光线会很难流失,所以这些地方看起来会更暗一些。环境光遮蔽这一技术会带来很大的性能开销,因为它还需要考虑周围的几何体。我们可以对空间中每一点发射大量光线来确定其遮蔽量,但是这在实时运算中会很快变成大问题。
屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)技术解决了计算AO的问题。这一技术使用了屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽不但速度快,而且还能获得很好的效果,使得它成为近似实时环境光遮蔽的标准。

SSAO原理

上面那张图有点光线追踪的味道了(hh~😆)不过这样也许更好理解!
  1. 首先从观察点拍这个场景会得到一张相片,我们的SSAO就是在对这张相片做渲染,所以SSAO是基于屏幕空间的。只不过在拿到这张照片的同时,我们还拿到了这个场景的线性深度信息,观察空间的法线信息(其实有这两个信息就可以进行渲染了)等等。简单的理解的话有了这些信息,你指定一个片段就可以知道它的线性深度(这篇文章的”深度值精度”目录下有介绍),并且还可以知道在观察空间下的法线!
  2. 你拍的照片上那一个个的小方块叫做像素,对应到屏幕空间下它叫片段,两者几乎是等同的,只不过片段还要在流水线上经过一些处理之后才会变成像素。现在,我们指定一个片段,然后以这个片段为中心生成一个半圆,在这个半圆中生成一些随机的采样点,然后查询这些采样点的深度值是多少,和该片段的深度值比较如果比它大就加分,最后算得所有采样点的平均分就是该片段的遮蔽因子。
  3. 得到遮蔽因子之后把因子应用到之前的那张照片上(相当于加了层滤镜吧~~),就可以得到一张有AO的图啦!

  1. 如何生成这些采样点呢,整张照片去随意的生成肯定不行,那么最好的办法显然是依据我们的片段点,在片段点生成的半球中去随机的产生采样点,于是这就需要我们有一个被称为半球型的采样核心(Kernel)的东西去告诉我们如何在片段点周围随机的产生采样点,产生多少采样点。
  2. 显然生成的采样点应该是在三维空间下的,而我们的照片是个二维的平面,因此我们要得到指定的片段在三维空间下的坐标(以下我们把片段放到了观察空间中),然后再根据这个片段在三维空间下的坐标生成采样点。
6. 值得注意的是,显然我们要生成的半球应该尽可能的贴着物体的表面,否则不管怎样半球都会插到物体表面中,这样就会导致分数升高(相当于都有了个基数),这样整体看上去就会有点糊。那么在切线空间下生成采样点解决这个问题就简单多了,在切线空间下物体表面的法向量是一直垂直于物体表面的,然后切线和副切相是沿着物体表面的(uv状态下),这样只用保证沿法向方向的分量>0,那么生成的采样点一定是在贴着物体的半球内的。

SSAO的实现

从上面的分析我们知道了,其实SSAO说白了就是在对已经渲染出来的场景图加滤镜,基于这个特点我们非常自然的想到了在unity中有一个技术十分合适干这个事情——屏幕后处理。

生成采样核心

首先我们要生成上面所说的采样核心。每个核心中的样本将会被用来偏移观察空间片段位置从而采样周围的几何体,正如上面提到的如果没有变化采样核心,我们将需要大量的样本来获得真实的结果。通过引入一个随机的转动到采样核心中,我们可以很大程度上减少这一数量。

ScreenSpaceAOEffect.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void GenerateAOSampleKernel()
{
if (SampleKernelCount == sampleKernelList.Count)
return;
sampleKernelList.Clear();
for (int i = 0; i < SampleKernelCount; i++)
{
var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
vec.Normalize();
var scale = (float)i / SampleKernelCount;
scale = Mathf.Lerp(0.1f, 1.0f, scale * scale);
           //给向量乘一个scale是为了让生成的随机采样点更靠近片段点,这样得到的采样点更有意义
           vec *= scale;
sampleKernelList.Add(vec);
}
}

上面的代码中引入了一个变量scale,它的引入是为了让生成的随机采样点更靠近片段点。相关的数学描述如下:

曲线反应的函数是$$DISTANCE(i) = \sqrt{ {g(i).x}^2+{g(i).y}^2+{g(i).z}^2}$$ 其中$$ g(i) = \{0.1 + 0.9 * (\frac {i} {SampleKernelCount}) ^ 2\} * vec$$

其中记$$ scale = 0.1 + 0.9 * (\frac {i} {SampleKernelCount}) ^ 2$$

原来的vec的各个分量本身就是在0~1之间的浮点数,乘上一个二次函数上取到的平滑的0~1之间的浮点数,这样就使得采样点更接近片段点了,如上图所示效果确实是这样。

采样核心引入随机性

如果对于所有的片段点周围都用同一个采样核心,那显然是不合适的,但是给每个片段点都去创建一个采样核心那又不太现实,因此我们的方法是创建一个小的随机旋转向量纹理平铺在屏幕上,然后用它来扰动采样核心。

NoiseGenerator.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void GenerateNoiseImage(int x, int y)
{
int size = Mathf.Min(x, y);
Texture2D tex = new Texture2D(x, y, TextureFormat.RGB24, false);
Color[] pixel = new Color[x * y];


for (int i = 0; i < x; i++)
{
for (int j = 0; j < y; j++)
{
float sample1 = Random.Range(-1.0f, 1.0f) * 2.0f - 1.0f;
float sample2 = Random.Range(-1.0f, 1.0f) * 2.0f - 1.0f;
//由于采样核心是沿着正z方向在切线空间内旋转,我们设定z分量为0.0,从而围绕z轴旋转。
pixel[i * size + j] = new Color(sample1, sample2, 0);
}
}
tex.SetPixels(pixel);
//应用上面的SetPixels
tex.Apply();

File.WriteAllBytes(System.Environment.CurrentDirectory + "\\Assets\\" + texName + ".png", tex.EncodeToPNG());
EditorUtility.DisplayDialog("成功", "噪声图\"" + texName + "\"" + "已在Assets目录下生成!", "确定", "取消");
}

使用上面代码,我们创建了一个4 * 4 的纹理,注意由于之后我们要把这个纹理展开铺平到整个屏幕上(屏幕大小肯定不止4 * 4 ),于是我们要把纹理的环绕格式设置为GL_REPEAT,从而保证它合适地平铺在屏幕上,在unity中设置这个这很简单

找到生成到`Asset`目录下的图片,然后设置成上图即可。

实现SSAO着色器

上面的两个步骤主要是在c#代码中完成的,接下来我们就可以放心大胆的来编写shader了~~

SSAO着色器核心部分如下所示:

SSAO fragment shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//计算AO贴图
fixed4 frag_ao (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);

float linear01Depth;
float3 viewNormal;

float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
//采样获得深度值和法线值
DecodeDepthNormal(cdn, linear01Depth, viewNormal);

float3 viewPos = linear01Depth * i.viewRay;
viewNormal = normalize(viewNormal) * float3(1, 1, -1);

//铺平纹理
float2 noiseScale = float2(Height / 4.0,Width / 4.0);
//float2 noiseUV = i.uv * noiseScale;
float2 noiseUV = float2(i.uv.x * noiseScale.x,i.uv.y * noiseScale.y);
//采样噪声图
float3 randvec = tex2D(_NoiseTex,noiseUV).xyz;
//Gramm-Schimidt处理创建正交基
float3 tangent = normalize(randvec - viewNormal * dot(randvec,viewNormal));
float3 bitangent = cross(viewNormal,tangent);
float3x3 TBN = float3x3(tangent,bitangent,viewNormal);
int sampleCount = _SampleKernelCount;

float oc = 0.0;
for(int i = 0; i < sampleCount; i++)
{
//1.注意不要把矩阵乘反了,否则得到的结果很黑;CG语言构造矩阵是"行优先",OpenGL是"列优先",两者之间是转置的关系,所以请把learnOpenGL中的顺序反过来
//float3 randomVec = mul(TBN, _SampleKernelArray[i].xyz);
float3 randomVec = mul(_SampleKernelArray[i].xyz,TBN);

float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;

float randomDepth;
float3 randomNormal;
float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
DecodeDepthNormal(rcdn, randomDepth, randomNormal);

//1.range check & accumulate
float rangeCheck = smoothstep(0.0,1.0,_SampleKeneralRadius / abs(randomDepth - linear01Depth));
oc += (randomDepth >= linear01Depth ? 1.0 : 0.0) * rangeCheck;
}
//1.求分数平均值
oc = oc / sampleCount;
col.rgb = oc;
return col;
}

代码在此就不详述了,具体的思路可以参见LearnOpenGL,再结合我上面所说的这些,理解起来应该是很直观的。

模糊操作

由上图所示,由于随机性的引入,导致了场景中出现了这样不平整的颗粒效果,于是我们需要让他变得平滑!

如上图所示模糊之后显然颗粒就没了,效果好了很多!这里的模糊可以简单的把周围几个点的像素值拿来简单的做平均,当然也可以做更复杂的效果,比如这里我使用了双边滤波(Bilateral Filter)来做模糊操作,具体的可以在我的代码中查找,由于篇幅原因,在此我就不介绍了,推荐一篇博文

应用SSAO

应用它很简单,把之前SSAO的结果(你可以理解为是上面的黑白图,也可以是我们之前说过的”采样点的平均分”)和真实的场景乘起来即可。

composite fragment shader
1
2
3
4
5
6
7
8
//应用AO贴图
fixed4 frag_composite(v2f i) : SV_Target
{
fixed4 ori = tex2D(_MainTex, i.uv);
fixed4 ao = tex2D(_AOTex, i.uv);
ori.rgb *= ao.r;
return ori;
}

效果

效果见文章的gallery!


参考资料
LearnOpenGL

puppet_master先生的文章

评论