9-SSAO

在之前的项目中,环境光照是一个固定常量,它被用来模拟光的散射。在现实中,光线会以任意方向散射,且强度会发生变化,不应该是固定常量。

一种间接光照的模拟是 环境光遮蔽(Ambient Occlusion),它通过让墙壁褶皱、孔洞等容易被遮挡的地方变暗来近似模拟出间接光照,让画面更加真实:

SSAO

概念

2007年,Crytek公司发布了一款叫做 屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion,SSAO)的技术,这一技术使用屏幕空间场景的深度而不是真实的几何数据来确定遮蔽量。速度快,效果好,成为近似实时环境光遮蔽的标准。

遮蔽因子

对于屏幕四边形上的每一个片段,都会根据周边深度值去计算一个 遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少/抵消片段的环境光照分量。通过对片段周围进行球形核的采样,获得多个深度样本,再和当前片段深度值作比较,高于片段深度值样本的个数就是遮蔽因子。

上图中深色的深度样本就是高于片段深度值的样本,会增加遮蔽因子。遮蔽因子越多,该片段的环境光照分量就越少。

此外,渲染效果的质量还和样本数量有关系,如果样本数量太低,会得到一种叫做 波纹(Banding)的效果;样本数量太高则会影响性能。可以通过随机采样减少样本数目,但会引入噪声问题,需要通过模糊来修复:

优化

原先的SSAO的采样核是一个球体,这会导致至少一半的样本都会被计入遮蔽因子,让场景中灰蒙蒙的:

因此可以借助表面法向量,沿着表面法线的半球采样:

这样就消除了SSAO灰蒙蒙的感觉,让结果更真实了。

实现SSAO

样本缓冲

对于每一个片段,我们需要如下数据:

  • 位置向量;
  • 法线向量;
  • Albedo漫反射颜色;
  • 采样核;
  • 用来随机旋转采样核的向量;

大致流程如下:

可以发现也要用到延迟渲染,G-Buffer的片段着色器如下:

#version 330 core
layout (location = 0) out vec3 gPosition;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec3 gAlbedo;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

void main()
{
	// 存储位置信息
	gPosition = FragPos;
	// 存储法线信息
	gNormal = normalize(Normal);
	// 存储diffuse材质颜色 - albedo
	gAlbedo = vec3(0.95);
}

因为SSAO是屏幕空间算法,那么算法就该在观察空间内实现。因此,几何处理阶段的FragPosNormal都应该是观察空间的,这里需要注意。

法线对齐的半球采样

接下来需要在表面法线对齐的半球上随机采样。由于对每个表面法线的半球随机采样是不切实际的,我们将在切线空间生成采样核,法向量指向+z方向。

假设我们有一个单位半球,那么就能按下面的方式获取一个最大样本量为64的采样核:

// 初始化SSAO半球采样核
std::uniform_real_distribution<float> randomFloats(0.0, 1.0);	// [0.0, 1.0]的随机浮点数
std::default_random_engine generator;
for (unsigned int i = 0; i < 64; ++i)
{
	glm::vec3 sample(
		randomFloats(generator) * 2.0 - 1.0,
		randomFloats(generator) * 2.0 - 1.0,
		randomFloats(generator)
	);
	sample = glm::normalize(sample);
	sample *= randomFloats(generator);
	m_ssaoKernel.push_back(sample);
}

我们将x和y的范围设定为切线空间中的[-1, 1],让z的范围为[0, 1],这样就得到一个半球上的随机法向量。

现在所有的采样都随机分布在采样核中,但我们更希望采样分布在半球球心附近。可以通过加速插值函数实现它:

// 让样本更集中于半球球心
float scale = static_cast<float>(i) / 64.0f;
scale = myLerp(0.1f, 1.0f, scale * scale);
sample *= scale;
m_ssaoKernel.push_back(sample);

其中插值函数的定义如下:

static auto myLerp = [](float a, float b, float f)
{
	return a + f * (b - a);
};

这样就能让样本分布更加集中在球心了:

随机采样核旋转

接下来还要对采样核进行旋转,以获得大量不同的样本。可以给每个片段生成一个随机旋转向量,但这样太耗内存了。可以生成一个随机旋转向量的纹理,然后将其铺满屏幕。

我们创建一个4x4的随机向量数组,它们朝向切线空间正表面的的随机方向:

// 初始化SSAO半球采样核的随机旋转向量
for (unsigned int i = 0; i < 16; ++i)
{
	glm::vec3 noise(
		randomFloats(generator) * 2.0 - 1.0,
		randomFloats(generator) * 2.0 - 1.0,
		0.0f);
	m_ssaoNoise.push_back(noise);
}

接下来创建对应的纹理:

unsigned int noiseTexture; 
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, 4, 4, 0, GL_RGB, GL_FLOAT, &ssaoNoise[0]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);  

SSAO着色器

接下来就能实现SSAO了。SSAO着色器在一个2D屏幕四边形上运行,计算每一个片段的遮蔽因子。这需要再创建一个FBO:

unsigned int ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);  
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
  
unsigned int ssaoColorBuffer;
glGenTextures(1, &ssaoColorBuffer);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RED, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
  
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBuffer, 0);

如上述代码所示,SSAO的计算结果是一个简单的灰度值,我们只需一个通道存储就可以,也就是用GL_RED

SSAO的渲染流程如下:

// 几何处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
    [...]
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

// 使用G-Buffer去渲染SSAO材质
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
    glClear(GL_COLOR_BUFFER_BIT);    
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, gPosition);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, gNormal);
    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, noiseTexture);
    shaderSSAO.use();
    SendKernelSamplesToShader();
    shaderSSAO.setMat4("projection", projection);
    RenderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
  
// 光照计算阶段
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.use();
[...]
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
[...]
RenderQuad(); 

其中,shaderSSAO着色器将G-Buffer纹理、噪声纹理、法线对齐的半球采样核作为输入:

#version 330 core
out float FragColor;

in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D texNoise;

uniform vec3 samples[64];
uniform mat4 projection;
uniform float screenWidth;
uniform float screenHeight;

// 将噪声纹理平铺到屏幕上
vec2 noiseScale = vec2(screenWidth / 4.0, screenHeight / 4.0);

void main()
{
}

首先是noiseScale变量,我们想让噪声纹理均匀平铺到整个屏幕上,但由于TexCoords的范围是[0, 1],texNoise纹理将不会被平铺。所以要计算缩放大小,并在之后使用。

// 获取SSAO算法的输入
vec3 fragPos = texture(gPosition, TexCoords).xyz;
vec3 normal = normalize(texture(gNormal, TexCoords).rgb);
vec3 randomVec = normalize(texture(texNoise, TexCoords * noiseScale).xyz);

因为我们将噪声纹理设置为GL_REPEAT,随机值将会在整个屏幕上重复。和fragPosnormal向量一起,我们有足够的数据去创建一个TBN矩阵了,它可以将任何向量从切线空间转换到观察空间:

// 计算将切线空间向量转换到观察空间的TBN矩阵
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);

通过使用格拉姆施密特正交化,我们创建了一个正交基,每次都会根据randomVec的值稍微倾斜。没有必要创建一个恰好沿着几何体表面的TBN矩阵。

接下来将对采样核中的每个样本迭代,将样本从切线空间变换到观察空间,将它们添加到当前像素位置上,然后将采样位置深度值与当前像素深度值比较,进而得到遮蔽因子:

// 迭代采样核中的样本, 得到遮蔽因子
float occlusion = 0.0;
for (int i = 0; i < kernelSize; ++i)
{
	// 获取采样的位置
	vec3 samplePos = TBN * samples[i];	// 切线空间 -> 观察空间
	samplePos = fragPos + samplePos * sampleRadius;
	...
}

这里kernelSizesampleRadius都是可调节的变量,这里分别为64与0.5。对于每个迭代,我们先将样本变换到观察空间。然后将变换后的样本添加偏移。最后通过乘sampleRadius可以增加/减少SSAO的有效采样半径。

接下来我们想要将sample变换到屏幕空间,这样就能进行位置/深度值采样了。因为向量还在观察空间,我们将会用projection矩阵将它变换到裁剪空间:

// 将采样位置投影到采样材质上, 从而获取屏幕位置
vec4 offset = vec4(samplePos, 1.0);
offset = projection * offset;		// 观察空间 -> 裁剪空间
offset.xyz /= offset.w;				// 透视除法
offset.xyz = offset.xyz * 0.5 + 0.5;// 映射到[0, 1]上

将采样位置转换到裁剪空间后,我们将进行透视除法,结果是归一化后的设备坐标,然后将其变换到[0, 1]上方便采样深度:

// 获取采样点的深度
float sampleDepth = texture(gPosition, offset.xy).z;

使用offset的x和y在gPosition纹理上采样,然后接受该点在观察者视角上的深度值/z值,这是第一个没被遮挡的可见片段。接下来就能得到遮蔽因子了,通过比较深度值:

// 获得遮蔽因子
occlusion += (sampleDepth >= samplePos.z + sampleBias ? 1.0 : 0.0);

这里添加了一个小偏移值(0.025)。偏移值不是必要的,但它帮忙调节SSAO的效果,并会解决因场景复杂度可能出现的Acne问题。

还有一个小问题,当检测靠近边缘的片段时,它也会考虑在测试表面远后面表面的深度值,这些值会带来不正确的结果。可以通过添加范围检查来解决:

通过添加范围检查,可以确保我们只当被测深度值在取样半径内时才会影响遮蔽因子:

// 获得遮蔽因子
float rangeCheck = smoothstep(0.0, 1.0, sampleRadius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z + sampleBias ? 1.0 : 0.0) * rangeCheck;

这里使用GLSL的smoothstep()函数,可以平滑地把第三个参数插值到[第一个参数0, 第二个参数1]之间:

如果是用硬编码实现范围检查,就会使得结果很突兀,例如很显然且难看的边缘。

最后,需要标准化遮蔽因子。注意,这里我们用1减去它,以便能直接用标准化遮蔽因子去缩放环境光分量:

	}
// 标准化缩放因子
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;

SSAO的结果如下:

可见,环境遮蔽产生了非常强烈的深度感。仅仅通过环境遮蔽纹理我们就已经能清晰地看见模型一定躺在地板上而不是浮在空中。现在的效果仍然看起来不是很完美,由于重复的噪声纹理再图中清晰可见。为了创建一个光滑的环境遮蔽结果,我们需要模糊环境遮蔽纹理。

SSAO模糊

在SSAO阶段和光线处理阶段之间,还需要对SSAO纹理进行模糊,不然噪声影响观感。

首先创建一个模糊的FBO:

unsigned int ssaoBlurFBO, ssaoColorBufferBlur;
glGenFramebuffers(1, &ssaoBlurFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
glGenTextures(1, &ssaoColorBufferBlur);
glBindTexture(GL_TEXTURE_2D, ssaoColorBufferBlur);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RED, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBufferBlur, 0);

因为堆叠的随机向量材质给我们固定的随机量,我们可以用这个特性去创造一个简单的模糊着色器:

#version 330 core
out float FragColor;

in vec2 TexCoords;

uniform sampler2D ssaoInput;

void main()
{
	vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));	
	float result = 0.0;
	for (int x = -2; x < 2; ++x)
	{
		for (int y = -2; y < 2; ++y)
		{
			vec2 offset = vec2(float(x), float(y)) * texelSize;
			result += texture(ssaoInput, TexCoords + offset).r;
		}
	}
	FragColor = result / (4.0 * 4.0);
}

这里我们遍历SSAO每个纹素的左上两个至右下两个,然后进行均值采样。效果如下:

可见效果不错,接下来就能应用光照了。

光照处理阶段

光照处理阶段和延迟渲染的几乎一样,只需在环境光ambient相关的地方乘上SSAO的纹理值就行了:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D ssao;

struct Light {
    vec3 Position;
    vec3 Color;
    
    float Linear;
    float Quadratic;
};
uniform Light light;

void main()
{             
    // retrieve data from gbuffer
    vec3 FragPos = texture(gPosition, TexCoords).rgb;
    vec3 Normal = texture(gNormal, TexCoords).rgb;
    vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
    float AmbientOcclusion = texture(ssao, TexCoords).r;
    
    // then calculate lighting as usual
    vec3 ambient = vec3(0.3 * Diffuse * AmbientOcclusion);
    vec3 lighting  = ambient; 
    vec3 viewDir  = normalize(-FragPos); // viewpos is (0.0.0)
    // diffuse
    vec3 lightDir = normalize(light.Position - FragPos);
    vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
    // specular
    vec3 halfwayDir = normalize(lightDir + viewDir);  
    float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
    vec3 specular = light.Color * spec;
    // attenuation
    float distance = length(light.Position - FragPos);
    float attenuation = 1.0 / (1.0 + light.Linear * distance + light.Quadratic * distance * distance);
    diffuse *= attenuation;
    specular *= attenuation;
    lighting += diffuse + specular;

    FragColor = vec4(lighting, 1.0);
}

结果如下:

参考资料

  • SSAO - LearnOpenGL CN (learnopengl-cn.github.io)
  • LearnOpenGL - SSAO