本文同时发布在我的个人博客上:https://dragon_boy.gitee.io
我们在介绍光照的时候曾经接触过环境光光照,我们用一个常量来模拟,但实际上,光是向四面八方散射的,场景中的每个物体都会或多或少的受到影响。一种近似模拟这种真实的环境光的技术时环境光遮蔽,这个技术通过将接缝、空洞、很接近的表面变暗来尽量区模拟非直接光照带来的影响。上述这些区域被周围的几何体大范围的遮挡,光基本照不到,就会形成比较暗的区域。观察一下自己的四周就会法线的确有以下稍微暗一点的区域。
下面是一组使用环境光遮蔽和没有使用的对比:
上面图片中,使用了环境光遮蔽的图片会发现在某些区域变得较暗,给场景增加了更丰富的细节,让物体间的空间分布更为明显。
不过环境光遮蔽的技术计算开销很大,因为需要将周围的几何体都考虑在内。在2007年,crytek游戏开发商提出了屏幕空间环境光遮蔽(screen-space ambient occlusion)的技术,简称SSAO,并将这一技术运用到孤岛危机的游戏中。这个技术使用屏幕空间的一个屏幕深度缓冲来决定遮蔽的数量,而不使用场景中真实的几何数据。对比真实环境光遮蔽,这一技术大大降低了运算开销而且也能给出比较好的结果,非常适合用于模拟环境光遮蔽。
SSAO的原理很简单:对于绘制在屏幕平面上的每个片段,我们基于其周围的片段的深度值计算出一个遮蔽因数,这个遮蔽因数被用来减少或取消片段的环境光照组件的影响。这一遮蔽因数可以通过一个围绕片段的一个球形核心来进行多重深度采样,并比较当前中心片段的深度值和采样的深度值来得到。高于当前片段深度值的采样数量就代表遮蔽因数。
上图中,灰色的采样点将用来计算总的遮蔽因数,这样的采样点越多,片段所接收到的环境光就越少。
通过上面的原理陈述,我们可以明白决定环境光遮蔽效果的质量和精度的要素在于球形区域内采样点的数量,如果采样点数量太低,精度会非常低,最后结果就是一种条带感(banding);如果采样点数量太高,我们就增大了计算的开销。我们可以通过对球形核心增加一些随机度来减少采样的数量,通过随机旋转每个片段的采样核心我们也可以只使用很少数量的采样点来获取比较高质量的结果。当然,这样获得的结果也会因为随机而产生一些视觉上的问题,我们还需要对结果进行模糊处理。下面是一组对比:
可以看到,使用低采样数量会造成条带感,但通过随机旋转采样核心,还是可以得到比较高质量的效果。
Crytek的SSAO技术有自己独特的视觉效果,因为他们使用的采样核心是一个球,这样的话,扁平的墙面也会有一些灰色的感觉(球形核心中大致会有一半的采样点用于计算环境光遮蔽),下面是一个例子:
这里我们不想实现这种灰蒙蒙的感觉,所以我们不使用球形采样核心,而是使用沿平面法线方向的半球形采样核心:
这样的话就可以移除那种灰度感的环境光遮蔽效果,可以生成更为真实的效果。
采样缓冲
SSAO需要一些几何信息来决定一个片段的遮蔽因数,对每个片段,我们需要下面的数据:
- 每个片段的位置
- 每个片段的法线
- 每个片段的反照率颜色(即漫反射)
- 一个采样核心
-
一个随机旋转向量来旋转采样核心
使用每个片段的视图空间位置我们可以绕片段的视图空间法线来旋转半球形采样核心,使用这个核心来采样不同偏移的位置缓冲纹理。对每个片段的核心采样,我们将其深度和位置缓冲种的深度进行比较来决定遮蔽的程度。接着遮蔽因数用来限制最后的环境光组件。通过是对每个片段的采样核心使用旋转向量我们也可以减少采样次数。
因为SSAO是一个屏幕空间的技术,我们会在屏幕平面上的每个片段上计算效果,所以这也意味着我们没有场景中的几何信息,因此我们需要将几何信息渲染到屏幕空间纹理中,接着传到SSAO的着色器中,这样就可以使用屏幕空间每个片段的几何信息了。已经很明了了,我们可以使用上一章延后渲染的G缓冲来帮助我们实现这一点。
因为我们需要每个片段的位置和法线信息,在片元着色器的开头定义这几个颜色缓冲来存储信息:
#version 330 core
layout (location = 0) out vec4 gPosition;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;
in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;
void main()
{
// store the fragment position vector in the first gbuffer texture
gPosition = FragPos;
// also store the per-fragment normals into the gbuffer
gNormal = normalize(Normal);
// and the diffuse per-fragment color, ignore specular
gAlbedoSpec.rgb = vec3(0.95);
}
由于SSAO是屏幕空间技术,遮蔽是通过观察者视角计算的,所以在视图空间实现算法是很合理的。因此,FragPos和Normal这些在顶点着色器计算的数据需要转换到视图空间。
通过深度值来重建位置向量是可行的,这篇博客中介绍了这一方法。虽然需要在着色器中进行额外的计算,但可以节省大量内存(本来在G缓冲中存储位置就是很耗内存的)。不过例子简单的话就不用考虑这些。
设置纹理用来存储几何数据,这里只展示位置:
glGenTextures(1, &gPosition);
glBindTexture(GL_TEXTURE_2D, gPosition);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
注意我们使用了GL_RGBA16F,因为我们需要更高的精度。同时纹理映射方式为GL_CLAMP_TO_EDGE,这保证了我们不会过采样。
沿法线的半球
我们需要生成一个沿法线方向的半球形采样核心,这里我们将在切线空间生成这个半球(法线都指向+z轴),以免为每个平面都单独生成一个沿各自法线方向的半球。
假设我们有一个单位半球,我们可以像下面这样得到一个拥有最大采样数为64的采样核心:
std::uniform_real_distribution<float> randomFloats(0.0, 1.0); // random floats between [0.0, 1.0]
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
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);
ssaoKernel.push_back(sample);
}
我们将切线空间的x、y坐标限制在[-1,1]间,z坐标限制在[0,1]之间(即一个沿法线方向的半球)。
不过这里生成的64个采样点是随机分布的,我们希望采样点更倾向于接近核心的片段,我们可以在上面的循环中添加下面的代码:
float scale = (float)i / 64.0;
scale = lerp(0.1f, 1.0f, scale * scale);
sample *= scale;
ssaoKernel.push_back(sample);
}
lerp是线性插值,是这样定义的:
float lerp(float a, float b, float f)
{
return a + f * (b - a);
}
这样的采样点会更倾向于接近中心片段:
接下来为每个核心添加一些随机的旋转或噪声。
随机核心旋转
我们将每个核心的随机旋转存储在纹理中。
首先创建一个4*4阵列的随即旋转向量,绕着切线空间的法线:
std::vector<glm::vec3> ssaoNoise;
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);
ssaoNoise.push_back(noise);
}
接着创建一张4*4的纹理来存储这些向量,将映射方式这位GL_REPEAT来保证覆盖整个屏幕:
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着色器在2D屏幕平面上运行,为上面的每个片段计算遮蔽值。我们创建一个SSAO帧缓冲:
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_RGB, 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);
因为环境光遮蔽是一个单通道的灰度值,所以我们的颜色缓冲纹理只需要1个通道即可。我们使用GL_RED。
使用SSAO的渲染流程应该如下:
// 几何阶段:将所有要用的几何数据渲染到G缓冲的纹理中
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
[...]
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 使用G缓冲中的几何数据将遮蔽因数渲染到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缓冲纹理,随机旋转纹理以及半球形采样核心:
#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;
// tile noise texture over screen, based on screen dimensions divided by noise size
const vec2 noiseScale = vec2(1280.0/4.0, 720.0/4.0); // screen = 1280x720
void main()
{
[...]
我们这里设置随机纹理坐标的缩放noiseScale。
vec3 fragPos = texture(gPosition, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
由于我们使用了屏幕缩放等级,随机值会覆盖整个屏幕。接着使用fragPos和normal向量,我们可以创建TBN矩阵:
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
使用Gramm-Schmidt方法我们可以创建正交的TBN矩,同时使用randomVec进行偏移。注意因为我们使用的是随机向量来构造切线,所以没有必要百分百让矩阵完美地和平面贴合。
接着我们遍历每个核心采样,将采样从切线空间转化到视图空间,接着进行深度对比:
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
// 获取采样位置
vec3 sample = TBN * samples[i]; // 从切线空间转化到视图空间
sample = fragPos + sample * radius;
[...]
}
这里的kernelSize和radius是用来调整效果的参数,这里设置为64和0.5。我们将计算的采样位置进行调整后作为偏移添加到fragPos。
接着我们将smple移动到屏幕空间,这样我们就可以采样sample的深度值。我们使用projection将其转化到切割空间:
vec4 offset = vec4(sample, 1.0);
offset = projection * offset; // 从视图到切割
offset.xyz /= offset.w; // 透视除法
offset.xyz = offset.xyz * 0.5 + 0.5; // 范围到0.0 - 1.0
接着使用偏移来采样G缓冲中的位置的z值来作为采样的深度:
float sampleDepth = texture(gPosition, offset.xy).z;
接着我们将当前的采样深度值和存储的深度值进行比较,如果大一些的话,添加遮蔽因数的影响:
occlusion += (sampleDepth >= sample.z + bias ? 1.0 : 0.0);
添加bias可以帮助调整环境光遮蔽的效果,也可以解决痤疮问题。
这里还存在一个问题,当一个用来测试环境光遮蔽的片段靠近一个平面的边缘时,这个平面会被当做远在测试片段的平面之后,这些值会参与计算遮蔽因数,我们可以通过范围检查来解决这一问题。
我们用范围检查来确保某一片段的深度值在采样半径内,这样才会对遮蔽因数做贡献。我们修改上面着色器的代码:
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= sample.z + bias ? 1.0 : 0.0) * rangeCheck;
这里使用了GLSL自带的smoothstep方法,将第三个参数的值在前两个参数的范围内平滑插值,如果值小于或等于第一个参数则返回0,大于或等于第一个参数则返回1。如果深度值接近采样半径,将会得到非常平滑的值,下面是曲线:
如果不使用这种方法直接抛弃范围外的值的话,会让结果非常不自然。
最后的最后,我们使用这一遮蔽参数计算最终的颜色。我们将其除以采样数的大小来标准化因数,接着用1减去表明,因数越大越黑:
}
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;
最后对一个场景进行测试,结果如下:
最后一步,为了得到更好的结果,我们对环境光遮蔽纹理进行模糊处理。
环境光遮蔽模糊
在SSAO阶段和光照阶段,我们模糊处理SSAO阶段得到的环境光遮蔽纹理,我们使用另一个帧缓冲来存储模糊结果:
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_RGB, 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);
}
结果如下:
应用环境光遮蔽
接下来进入光照阶段来应用我们计算好的环境光遮蔽纹理。我们要做的非常简单,只需要采样环境光遮蔽纹理的值来影响环境光即可:
#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;
float Radius;
};
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;
// blinn-phong (in view-space)
vec3 ambient = vec3(0.3 * Diffuse * AmbientOcclusion); // here we add occlusion factor
vec3 lighting = ambient;
vec3 viewDir = normalize(-FragPos); // viewpos is (0.0.0) in view-space
// 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 dist = length(light.Position - FragPos);
float attenuation = 1.0 / (1.0 + light.Linear * dist + light.Quadratic * dist * dist);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;
FragColor = vec4(lighting, 1.0);
}
渲染结果如下:
这里给出原文代码参考:Code。
由于SSAO是高度自定义话的,我们可以有多种方式来调节效果,比如我们可以用幂运算调整遮蔽因数:
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = pow(occlusion, power);
最后,贴出原文地址供参考:https://learnopengl.com/Advanced-Lighting/SSAO