LearnOpenGL——SSAO学习笔记

SSAO

一、基本概念

环境光照是我们加入场景总体光照中的一个固定光照常量,它被用来模拟光的散射(Scattering)。散射应该是有强度的,所以被间接光照的部分也应该有变化的强度。环境光遮蔽(Ambient Occlusion)是一种模拟间接光照的办法。原理是将褶皱、孔洞和靠近墙面的地方变得更暗来模拟间接光照。
在这里插入图片描述
环境光遮蔽这一技术会带来很大的性能开销,因为它还需要考虑周围的几何体。随后Crytek公司发布了一个叫做屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)的技术。SSAO使用了屏幕空间的场景深度而不是真实的几何体数据,速度快效果好。

SSAO原理:对于屏幕四边形上的每一个像素,我们基于像素周围深度值计算一个遮蔽因子(Occlusion Factor),这个遮蔽因子是用来减少或者抵消片元的环境分量。遮蔽因子是通过用球型采样核采集片段周围多个深度样本,并将每个采样点与片元的深度值进行比较得到的。高于片元深度值的采样点的个数,就是遮蔽因子。
在这里插入图片描述

上图中,灰色的深度样本都是高于片元深度值的。采样点数量越多,片元最终的环境分量就越小。样本数量太低会影响渲染精度,太多又会影响性能,所以我们可以通过引入随机性到采样核心(Sample Kernel)的采样中从而减少样本的数目,但是也会有噪声,所以我们再次基础上继续引入模糊来修复。
因为我们的采样核心是球体,对于墙这种平面来说,会有一部分采样到墙后面去,会导致画面看起来灰蒙蒙的
在这里插入图片描述
所以我们不再使用球体,而是使用一个沿着表面法向量的半球体来采样核。法向半球体(Normal-oriented Hemisphere) 周围采样,我们将不会考虑到片段底部的几何体.它消除了环境光遮蔽灰蒙蒙的感觉,从而产生更真实的结果
在这里插入图片描述

二、样本缓冲

因为我们要确定每个片元的遮蔽因子,所以需要得到几何体信息。对于每个片元,我们需要知道:

  • 每个片元的位置向量
  • 每个片元的法线向量
  • 每个片元的漫反射颜色
  • 一个采样核
  • 每个片元的随机旋转向量,用于旋转采样核

基本步骤:

  • 通过在每个片元的观察空间位置,我们可以定义一个半球采样核,这个半球是围绕该片元在视图空间中的表面法线(surface normal)定向的。
  • 并用这个核在各个偏移量(偏移量加在每个样本点位置上,使得样本点不会完全集中在半球的中心,而是稍微散布开来)去采样位置缓冲纹理(位置缓冲纹理包含了场景中每个片元的世界空间位置信息)。
  • 对于每个片元的核样本,我们会比较它的深度值与位置缓冲区中的深度值来决定遮蔽因子
  • 然后通过遮蔽因子来限制最后的环境光照项。通过每个片元的旋转向量,我们也可以显著减少需要采集的样本数量。

在这里插入图片描述

SSAO是一项屏幕采样技术,由于我们没有物体的几何信息,我们就需要将每个片元的几何数据渲染成屏幕空间的纹理,然后将这些纹理发给SSAO着色器,这样就可以访问每个片元的几何数据。这就类似于延迟渲染的G-Buffer了

延续延迟渲染章节的G-Buffer 延迟渲染学习笔记,我们只需要更新一下几何着色器,让它包含片段的线性深度就行了,我们可以从gl_FragCoord.z中提取线性深度:

#version 330 core
layout (location = 0) out vec4 gPositionDepth;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

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

const float NEAR = 0.1; // 投影矩阵的近平面
const float FAR = 50.0f; // 投影矩阵的远平面
float LinearizeDepth(float depth)
{
    float z = depth * 2.0 - 1.0; // 回到NDC
    return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR));    
}

void main()
{    
    // 储存片段的位置矢量到第一个G缓冲纹理
    gPositionDepth.xyz = FragPos;
    // 储存线性深度到gPositionDepth的alpha分量
    gPositionDepth.a = LinearizeDepth(gl_FragCoord.z); 
    // 储存法线信息到G缓冲
    gNormal = normalize(Normal);
    // 和漫反射颜色
    gAlbedoSpec.rgb = vec3(0.95);
}

提取出来的线性深度是在观察空间中的,之后的运算也是在观察空间中,几何阶段顶点着色器提供的FragPos和Normal被转换到视图空间(同时乘以视图矩阵)。

gPositionDepth颜色缓冲如下:

glGenTextures(1, &gPositionDepth);
glBindTexture(GL_TEXTURE_2D, gPositionDepth);
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);

三、法向半球

我们需要沿着表面法线方向生成大量的样本。我们将在 切线空间(法线方向朝向z轴正向) 生成采样核心。

假设我们有一个单位半球,我们可以获得最大64个样本值的采样值

// 随机浮点数,范围0.0 - 1.0
std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); 

std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint 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);
    GLfloat scale = GLfloat(i) / 64.0; 
    scale = lerp(0.1f, 1.0f, scale * scale);
    sample *= scale;
    ssaoKernel.push_back(sample);  
}
  • uniform_real_distribution<GLfloat>default_random_engine generator 是创建一个均匀分布的随机浮点数生成器范围在[0.0-1.0];创建一个默认的随机数引擎,用于生成随机数。
  • for循环是生成64个随机向量作为采样核的一部分
  • sample是一个三维向量(半球范围),初始化时x和y分量时在[-1.0,1.0]范围的随机数(乘以2.0然后减去1.0是将[0.0, 1.0]范围内的随机数转换为[-1.0, 1.0]范围内的随机数),z分量是在[0.0, 1.0]范围内的随机数
  • sample *= randomFloats(generator); 缩放向量有助于在采样核中引入更多的变化,在SSAO中产生更多的噪点
  • 应用缩放因子 scale scale = lerp(0.1f, 1.0f, scale * scale):在生成采样向量时,可以通过scale来调整每个向量的权重,使得靠近中心的向量具有更高的权重,而靠近边缘的向量具有较低的权重。
GLfloat lerp(GLfloat a, GLfloat b, GLfloat f)
{
    return a + f * (b - a);
}

在这里插入图片描述
如果半球是完全严格按照法线方向生成,那么采样向量可能会过于规律,导致在某些方向上无法很好地捕捉到遮挡情况,或者在某些情况下产生明显的模式(如条纹或斑点)。所以我们为每个半球核引入一个随机转动

四、随机核心转动

通过引入一些随机性到采样核心上,我们可以大大减少获得不错结果所需的样本数量。我们可以创建一个小的随机旋转向量纹理平铺在屏幕上。

我们创建一个4×4朝向切线空间平面法线的随机旋转向量数组:由于采样核心是沿着正z方向在切线空间内旋转,我们设定z分量为0.0,从而围绕z轴旋转。

std::vector<glm::vec3> ssaoNoise;
for (GLuint 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纹理

GLuint noiseTexture; 
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 
	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的结果,还需要创建一个帧缓冲对象,然后将SSAO的结果作为颜色附件在帧缓冲上。

由于环境遮蔽的结果是一个灰度值,我们将只需要纹理的红色分量,所以我们将颜色缓冲的内部格式设置为GL_RED。

GLuint ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);  
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
GLuint 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);

完整的SSAO过程:

// 几何处理阶段: 渲染到G缓冲中
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
    [...]
glBindFramebuffer(GL_FRAMEBUFFER, 0); 

 // 使用G缓冲渲染SSAO纹理
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
shaderSSAO.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPositionDepth);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
SendKernelSamplesToShader();
glUniformMatrix4fv(projLocation, 1, GL_FALSE, 
	glm::value_ptr(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 gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;

uniform vec3 samples[64];
uniform mat4 projection;

// 屏幕的平铺噪声纹理会根据屏幕分辨率除以噪声大小的值来决定
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600

void main()
{
    vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
    vec3 normal = texture(gNormal, TexCoords).rgb;
    
    vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);

    float occlusion = 0.0;
    for(int i = 0; i < kernelSize; ++i)
    {
        // 获取样本位置
        vec3 sample = TBN * samples[i]; // 切线->观察空间
        sample = fragPos + sample * radius; 

        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的值域
        
        float sampleDepth = -texture(gPositionDepth, offset.xy).w;
        
        float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
        occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;   
    }
    occlusion = 1.0 - (occlusion / kernelSize);
    FragColor = occlusion;  
}

下面会对这段代码做个人向的解释

out float FragColor;
in vec2 TexCoords;
  • FragColor: 片段着色器的输出颜色(或者说输出值)。在这段代码中,它表示遮蔽值,作为浮点数输出。
    TexCoords: 输入的纹理坐标,表示当前片段在屏幕上的位置,用来从不同的纹理中采样数据。
uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;

uniform vec3 samples[64];
uniform mat4 projection;
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600
  • gPositionDepth:存储片元的世界坐标和深度的G-buffer纹理
  • gNormal:存储法线的G-buffer纹理
  • texNoise:存储随机噪声的纹理,用于创建随机的切线空间。
  • samples[64]:一个存储预计算采样向量的数组,这些向量用于在片段周围进行遮蔽计算。
    • 在 SSAO 的计算中,预定义的采样向量通常是在切线空间中生成的。这些向量表示了局部坐标系下,围绕当前像素的某些方向的偏移量。
  • projection: 投影矩阵,用于将样本点从观察空间转换到裁剪空间。
  • noiseScale: 噪声纹理的缩放因子。
    • 它决定了屏幕上噪声纹理如何重复,确保 SSAO 计算中的随机噪声效果在屏幕上分布均匀,而不是随着屏幕分辨率的变化而变化。
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
  • 选择 randomVec 而不是固定的向量来生成切线,保证了每个片元的切线方向是随机的,防止产生规律性的视觉伪影。去掉与法线平行的分量,剩下的部分自然就与法线垂直,从而形成切线。
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
    vec3 sample = TBN * samples[i]; // 切线->观察空间
    sample = fragPos + sample * radius; 

    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的值域
    float sampleDepth = -texture(gPositionDepth, offset.xy).w;
    float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
    occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;   
}
  • 将采样点从切线空间转换到观察空间,并以 fragPos 为中心进行偏移,得到实际的采样点位置。
  • 在 SSAO 中,我们需要比较采样点和当前像素的深度。深度信息通常是在屏幕空间的 G-buffer 中存储的,因此需要将 3D 空间中的采样点转换为屏幕空间的纹理坐标,才能进行比较。
  • 从 gPositionDepth 纹理中获取当前采样点的深度值,并与采样点的深度进行比较,判断该点是否被遮挡。如果被遮挡,则增加遮蔽值。
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;  
  • occlusion 中的值可能会累积到一个相对较大的数值,为了使这个值能够映射到 [0, 1] 的范围内,需要将它除以 kernelSize。
  • 计算得到的遮蔽值会被归一化,然后取反,以得到正确的环境光遮蔽值。
    在这里插入图片描述

六、环境遮蔽模糊

我们将ssao的结果模糊会得到更好的效果。所以我们再创建一个帧缓冲,来存储模糊结果。

GLuint 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
in vec2 TexCoords;
out float fragColor;

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);
}
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 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;
        }
    }
  • 这两个循环遍历了当前像素周围的一个4x4区域共16个采样点
  • offset为基于中心点像素的位移
  • 对一个像素附近的区域进行采样,并将这些采样的结果进行累加
fragColor = result / (4.0 * 4.0);
  • 取平均,输出像素颜色
    在这里插入图片描述

七、应用SSAO遮蔽因子

我们要做的就是逐片元地将环境遮蔽因子×环境分量上。

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D gPositionDepth;
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()
{             
    // 从G缓冲中提取数据
    vec3 FragPos = texture(gPositionDepth, TexCoords).rgb;
    vec3 Normal = texture(gNormal, TexCoords).rgb;
    vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
    float AmbientOcclusion = texture(ssao, TexCoords).r;

    // Blinn-Phong (观察空间中)
    vec3 ambient = vec3(0.3 * AmbientOcclusion); // 这里我们加上遮蔽因子
    vec3 lighting  = ambient; 
    vec3 viewDir  = normalize(-FragPos); // Viewpos 为 (0.0.0),在观察空间中
    // 漫反射
    vec3 lightDir = normalize(light.Position - FragPos);
    vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
    // 镜面
    vec3 halfwayDir = normalize(lightDir + viewDir);  
    float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
    vec3 specular = light.Color * spec;
    // 衰减
    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);
}

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值