- 教程地址:简介 - LearnOpenGL CN
SSAO
- 译文链接:SSAO - LearnOpenGL CN
- 原文链接:LearnOpenGL - SSAO
在学习本节之前,建议观看 GAMES202 关于 SSAO 的讲解:Lecture8 Real-time GLobal Illumination(screen space)
引言
我们在基础光照章节中简要提到过环境光照这个话题。环境光照(Ambient Lighting)是一个固定的光常量,我们将其添加到场景的整体光照中,以模拟光的散射。在现实中,光会以不同强度向各个方向散射,因此场景中间接照明的部分也应具有不同的光照强度。一种间接光照近似方法称为环境光遮蔽(Ambient Occlusion),它试图通过让褶边、孔洞和彼此靠近的表面变暗来近似表达间接光照。这些区域大多被周围的几何体所遮挡,逃逸出的光线较少,因此这些区域显得更暗。观察你房间的角落和褶边,你会发现那里的光线似乎稍微暗一些。
下面这幅图展示了在使用和不使用 SSAO 时场景的不同。特别注意对比褶皱部分,你会发现(环境)光被遮蔽了许多:
虽然不是一个非常明显的效果,但启用了环境光遮蔽的图像由于这些微小的遮蔽细节,显得更加真实,同时为整个场景增添了更强的深度感。
环境光遮蔽技术成本高昂,因为它们必须考虑周围的几何形状。可以为空间中的每个点发射大量光线来确定其被遮蔽的程度,但这对于实时解决方案来说计算上不可行。2007年,Crytek发布了一种名为 屏幕空间环境光遮蔽 (Screen-Space Ambient Occlusion, SSAO) 的技术,用于他们的游戏《Crysis》。该技术利用屏幕空间中场景的深度缓冲区来确定遮蔽量,而不是使用真实的几何数据。相比真正的环境光遮蔽,这种方法速度极快且效果良好,使其成为实时近似环境光遮蔽的标准。
SSAO 的基本原理很简单:对于充满屏幕的四边形上的每个片段,我们根据片段周围的深度值计算一个遮蔽因子(Occlusion Factor)。然后使用这个遮蔽因子来减少或消除片段的环境光照分量。遮蔽因子通过在围绕片段位置的球形采样核中获取多个深度样本,并将每个样本与当前片段的深度值进行比较来获得。样本中深度值高于片段深度值的数量就是遮蔽因子。
每个位于几何体内部的灰色深度样本都会增加遮蔽因子。我们在几何体中找到的样本(灰色)越多,片段最终接收到的环境光照就越少。
很明显,渲染效果的质量和精度与我们采样的样本数量有直接关系。如果样本数量太低,渲染的精度会急剧减少,我们会得到一种叫做 波纹(Banding) 的效果。如果样本数量过多,则会损失性能。我们可以通过在采样核(Sample Kernel)中引入一些随机性来样本的数量。通过在每个片段随机旋转采样核,我们可以用少量的样本获得高质量的结果。但这也有代价,随机性会引入明显的噪声图案,我们需要通过模糊结果来修复这一点。下图(由 John Chapman 提供)展示了波纹效果以及随机性对结果的影响:
如你所见,尽管我们在低样本数量的情况下得到了很明显的波纹效果,但引入随机性之后这些波纹效果就完全消失了。
Crytek 公司开发的 SSAO 技术会产生一种特殊的视觉风格。因为使用的采样核是一个球体,它导致平整的墙面也会显得灰蒙蒙的,核心中一半的样本都会在墙这个几何体上。下面这幅图展示了《Crysis》的 SSAO,它清晰地展示了这种灰蒙蒙的感觉:
出于这个原因,我们将不会使用球形的采样核,而是使用一个沿着表面法向量的半球形采样核。
通过在 法向半球体(Normal-oriented Hemisphere) 周围采样,我们不会将片段下方的几何体视为遮蔽因子的贡献因素。这消除了环境光遮蔽的灰色感,通常能产生更真实的结果。本章的技术基于法向半球法和 John Chapman 出色的 SSAO教程。
样本缓冲
SSAO需要几何信息,因为我们需要某种方法来确定片段的遮蔽因子。对于每个片段,我们需要以下数据:
- 每个片段的位置向量。
- 每个片段的法线向量。
- 每个片段的反射(albedo)颜色。
- 一个采样核。
- 每个片段用于旋转采样核的随机旋转向量。
使用每个片段的观察空间位置,我们可以将一个采样半球核对准片段观察空间的表面法线,并使用这个核加上一个偏移量来采样位置缓冲区纹理(得到核样本)。对于每个片段,我们将其深度与核样本的深度进行比较,以确定片段的遮蔽程度。得到的遮蔽因子随后用于限制最终的环境光照分量。通过对每个片段使用一个旋转向量(用于旋转半球采样核),我们可以显著减少所需样本的数量。
由于 SSAO 是一种屏幕空间技术,我们在充满屏幕的2D四边形上为每个片段计算其效果。这意味着我们没有场景的几何信息。我们可以做的是,将每个片段的几何数据渲染到屏幕空间纹理中,然后将这些纹理发送给SSAO着色器,以便访问每个片段的几何数据。如果你学习了前一章的内容,你会发现这很像延迟渲染器的G缓冲区设置。因此,SSAO 非常合适与延迟渲染结合使用,因为我们已经在G缓冲区中拥有了位置和法线向量。
在这个教程中,我们将会在一个简化版本的延迟渲染器(延迟着色法教程中)的基础上实现SSAO,所以如果你不知道什么是延迟着色法,请先读完那篇教程。
由于我们应该从场景物体中获取每个片段的位置和法线数据,几何阶段的片段着色器相当简单:
#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()
{ // 将片段位置向量存储到第一个G缓冲区纹理中gPosition = FragPos;// 将片段法线存储到G缓冲区中gNormal = normalize(Normal);// 存储片段漫反射颜色,忽略镜面反射gAlbedoSpec.rgb = vec3(0.95);
}
由于 SSAO 是一种屏幕空间技术,其遮蔽计算基于可见视图,在观察空间中实现该算法是合理的。因此,几何阶段顶点着色器提供的 FragPos
和 Normal
需转换到观察空间(乘以观察矩阵)。
通过一些小技巧来通过深度值重构实际位置值是可能的,Matt Pettineo 在他的博客里提到了这一技巧。这一技巧需要在着色器里进行一些计算,但是省了我们在 G 缓冲中存储位置数据,从而省了很多内存。为了示例的简单,我们将不会使用这些优化技巧,你可以自行探究。
gPosition
颜色缓冲区纹理的配置如下:
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);
这给了我们一个位置纹理,我们可以用它来获取每个核样本的深度值。注意,我们以浮点数据格式存储位置,这样位置值不会被限制在[0.0, 1.0]范围内,并且给了我们更高的精度。还需注意纹理包裹方式为 GL_CLAMP_TO_EDGE
,这确保我们不会意外地在超出纹理默认坐标区域的屏幕空间采样位置/深度值。
接下来,我们需要实际的半球采样核以及某种方法来随机旋转它。
法向半球
我们需要沿着表面法线方向生成大量的样本。正如本章开头简要讨论的那样,我们希望生成一组形成半球形状的样本。由于为每个表面法线方向生成采样核既困难也不合理,我们将在切线空间中生成一个采样核,其法线向量将指向正 z 方向。
假设我们有一个单位半球,我们可以通过以下方式获取最多包含64个样本值的采样核:
std::uniform_real_distribution<float> randomFloats(0.0, 1.0); // [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);
}
我们在切线空间中以 -1.0 到 1.0 为范围变换 x 和 y 方向,并以 0.0 到 1.0 为范围变换样本的z方向(如果z方向也在 -1.0 到 1.0 之间变化,我们将得到一个球形采样核)。由于采样核将沿着表面法线,最终的样本向量都将位于半球内。
目前,所有样本在采样核中是随机分布的,但我们更希望对靠近实际片段的遮蔽赋予更大的权重,也就是将更多的核样本分布在靠近原点的位置。可以使用插值函数来实现这一点:
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);
}
这就给了我们一个大部分样本靠近原点的核分布。
每个核样本都将用于偏移观察空间片段位置,以采样周围几何体。为了获得真实的结果,我们在观察空间中可能需要相当多的样本,这势必会对性能造成过大负担。然而,如果我们能对每个片段引入一些半随机旋转/噪声,就可以显著地减少所需样本的数量。
随机核旋转
通过为采样核引入一些随机性,我们可以大幅减少获得良好结果所需的样本数量。我们可以为场景的每个片段创建一个随机旋转向量,但这会迅速消耗内存。更合理的方法是创建一个小的随机旋转向量纹理,并在屏幕上平铺它。
我们创建一个 4x4 的朝向切线空间平面法线的随机旋转向量数组:
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);
}
由于采样核在切线空间中沿正 z 方向,我们将随机旋转向量的 z 分量设为 0.0,以便让采样核围绕 z 轴旋转。
然后,我们创建一个 4x4 的纹理来存储随机旋转向量;确保将其包裹方式设置为 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着色器
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_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);
由于环境光遮蔽结果是一个单一的灰度值,我们只需要纹理的红色分量,因此将颜色缓冲区的内部格式设置为 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;// 根据屏幕尺寸除以噪声纹理大小,在屏幕上平铺噪声纹理
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600void main()
{[...]
}
这里值得注意的是 noiseScale
变量。我们希望将噪声纹理平铺到整个屏幕上,但由于 TexCoords
在 0.0 到 1.0 之间变化,texNoise
纹理不会自动平铺。因此,我们通过将屏幕尺寸除以噪声纹理大小来计算所需的缩放比例。
vec3 fragPos = texture(gPosition, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
由于我们将 texNoise
的平铺参数设置为 GL_REPEAT
,随机值将在屏幕上重复出现。结合 fragPos
和 normal
向量,我们有足够的数据来创建一个TBN矩阵,将向量从切线空间转换到观察空间:
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
通过使用一个叫做格拉姆-施密特过程(Gramm-Schmidt Process),我们创建了一个正交基(Orthogonal Basis),每一次它都会根据 randomVec
的值稍微倾斜。注意因为我们使用了一个随机向量来构造切线向量,我们没必要有一个恰好沿着几何体表面的 TBN 矩阵,也就是不需要每个顶点的切线(和副切线)向量。
按作者前文思路,是对半球核进行旋转,那么应该去利用
randomVec
构造旋转矩阵去旋转normal
,然后利用三角形的边来计算 tangent,从而构建 TBN 矩阵。但从代码来看,是将这个randomVec
当作了 tangent,然后计算 TBN 矩阵。这里存在一个问题,randomVec
前文强调了 z=0,位于切线空间,但normal
是位于观察空间的,两者坐标空间不同,直接计算也会对半球核进行旋转。但这也导致了randomVec
的 z 分量是否为 0,结果看起来都差不多。
接下来我们遍历每个核样本,计算每个核样本观察空间的位置(先将样本位置偏移量转化到观察空间,然后加上当前片段的位置,此位置的 z 分量即为核样本的深度值)。再将这个位置转换到屏幕空间,利用屏幕空间位置的 xy 分量去采样位置纹理就可得到从摄像机向核样本看过去,看到的第一个未遮挡片段对应的观察空间坐标,然后取得坐标的 z 分量,就得到了第一个未遮挡片段的深度值,最后将两个深度值进行比较即可。我们将逐步讨论这个过程:
注意这里的深度值是线性深度,没有经过投影变换,与深度缓冲区存储的非线性深度不一样。
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{// 获取样本位置vec3 samplePos = TBN * samples[i]; // 从切线空间到观察空间samplePos = fragPos + samplePos * radius; [...]
}
这里 kernelSize
和 radius
是可调整效果的变量,在本例中分别设为 64 和 0.5。每次迭代,我们首先将样本位置偏移量转化到观察空间。然后将偏移量加上当前片段的位置(观察空间),并通过 radius
乘以偏移量以增加(或减少)SSAO的有效采样半径。
接下来,我们希望将样本位置转换到屏幕空间,仿佛直接将样本渲染到屏幕上的方式采样其位置/深度值。由于该向量当前在观察空间,我们首先使用投影矩阵将其转换为裁剪空间:
vec4 offset = vec4(samplePos, 1.0);
offset = projection * offset; // 从观察空间到裁剪空间
offset.xyz /= offset.w; // 透视除法
offset.xyz = offset.xyz * 0.5 + 0.5; // 转换到0.0 - 1.0范围
将变量转换为裁剪空间后,我们通过将其 xyz 分量除以 w 分量执行透视除法步骤。得到的标准化设备坐标随后被转换为[0.0, 1.0]范围,以便我们可以用它们采样位置纹理:
float sampleDepth = texture(gPosition, offset.xy).z;
我们使用 offset
向量的 x 和 y 分量采样位置纹理,以检索从观察者视角看到的样本位置的深度(即第一个未被遮挡的可见片段)。然后检查样本的当前深度值是否大于存储的深度值,如果是,则添加到最终贡献因子:
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0);
注意这是在观察空间下对深度值进行比较,由于摄像机朝向 -z 方向,所有深度值都为负数,越小(负)越远
注意,我们在这里为核样本的深度值添加了一个小的 bias(在本例中设为0.025)。bias 并非总是必要的,但它有助于视觉调整 SSAO 效果,并解决因场景复杂性可能出现的 acne 效应。
我们尚未完全完成,因为还有一个小问题需要考虑。每当在靠近表面边缘的片段上测试环境光遮蔽时,它还会考虑测试表面后面远处表面的深度值;这些值会(错误地)影响遮蔽因子。我们可以通过引入范围检查来解决这个问题,如下图(由 John Chapman 提供)所示:
我们引入一个范围检查,确保只有深度值在样本半径内的片段才会对遮蔽因子产生贡献。我们将最后一行改为:
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
这里我们使用了 GLSL 的 smoothstep
函数,它在其第一和第二参数的范围内平滑插值第三个参数的值,如果小于或等于第一个参数则返回 0.0,如果等于或大于第二个参数则返回 1.0。如果深度差异在 radius
范围内,其值会按照以下曲线在 0.0 和 1.0 之间平滑插值:
如果我们使用深度值在 radius
之外就突然移除遮蔽贡献的硬界限范围检测(Hard Cut-off Range Check)方法,我们将会在范围检测应用的地方看见一个明显的(很难看的)边缘。
最后一步,我们通过核的大小标准化遮蔽贡献并输出结果。注意我们用 1.0 减去遮蔽因子,这样就可以直接使用遮蔽因子来缩放环境光照分量。
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_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);
}
这里我们在 -2.0 到 2.0 之间遍历周围的 SSAO 纹理单元,采样 SSAO 纹理的次数与噪声纹理的尺寸相同。我们使用 textureSize
(返回给定纹理尺寸的vec2)计算单个纹理单元的精确大小来偏移每个纹理坐标。我们对获得的结果取平均值,就可以得到一个简单但有效的模糊效果:
貌似看不出来差别,建议查看中文译文链接里的纳米机器人模型展示,可以很清楚地看到模糊效果
这样,我们就得到了带有每个片段环境光遮蔽数据的纹理,好在光照阶段中使用。
应用环境光遮蔽
将遮蔽因子应用到光照方程中非常简单:我们只需将每个片段的环境光遮蔽因子乘以光照的环境分量即可。如果我们拿前一章的 Blinn-Phong 延迟光照着色器并稍作调整,就能得到以下片段着色器:
#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()
{ // 从G缓冲区检索数据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(在视图空间)vec3 ambient = vec3(0.3 * Diffuse * AmbientOcclusion); // 这里添加遮蔽因子vec3 lighting = ambient; vec3 viewDir = normalize(-FragPos); // 在视图空间中,视角位置为(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);
}
除了将计算的数据转换到视图空间外,我们真正改变的只是将场景的环境分量乘以 AmbientOcclusion
。在场景中摆放一个蓝色的点光源,我们会得到如下结果:
你可以在这里找到源代码,本次项目源代码:SSAO - GitCode
屏幕空间环境光遮蔽是一个可高度自定义的效果,其效果很大程度上依赖于我们根据场景的类型调整其参数。没有适用于所有场景的完美参数组合。有些场景只需要较小的半径,而其他场景则需要较大的半径和更多的样本数才能看起来更真实。当前演示使用了64个样本,这有点多;尝试使用较小的核大小,并努力获得良好的结果。
你可以调整的一些参数(通过uniform变量):核大小、半径、bias 和噪声核的大小。你还可以将最终的遮蔽值暴露为一个用户定义的幂,以增强其强度:
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = pow(occlusion, power);
多试试不同的场景和不同的参数,来体会 SSAO 的自定义性。
尽管 SSAO 是一个很微小的效果,可能甚至不是很容易注意到,但它在很大程度上增加了合适光照场景的真实性,它也绝对是一个在你工具箱中必备的技术。
附加资料
- SSAO教程:John Chapman 优秀的 SSAO 教程;本教程很大一部分代码和技巧都是基于他的文章
- 了解你的SSAO效果:关于提高 SSAO 特定效果的一篇很棒的文章
- 深度值重构SSAO:OGLDev 的一篇在 SSAO 之上的拓展教程,它讨论了通过仅仅深度值重构位置矢量,节省了存储开销巨大的位置矢量到G缓冲的过程