OpenGL核心之SSAO技术讲解一

2017年03月17日 18:00 0 点赞 0 评论 更新于 2025-11-21 21:16
 OpenGL核心之SSAO技术讲解一

在使用引擎开发产品时,我们常常将环境光当作游戏场景中的太阳光。环境光照是添加到场景总体光照中的一个固定光照常量,用于模拟光的散射(Scattering)。在现实世界中,光线会向任意方向散射,其强度也会不断变化。因此,间接被照亮的那部分场景的光照强度也应该是变化的,而不是像传统环境光那样一成不变。

其中一种模拟间接光照的方法叫做环境光遮蔽(Ambient Occlusion),其原理是通过将褶皱、孔洞以及非常靠近的墙面变暗,近似模拟出间接光照的效果。这些区域很大程度上被周围的几何体遮蔽,光线难以到达,所以看起来会更暗。你可以站起来观察一下房间的拐角或者衣物的褶皱,会发现这些地方确实显得更暗。

下图展示了使用和不使用SSAO时场景的差异。特别留意褶皱部分,你会发现环境光被大量遮蔽:

尽管这一效果不是非常显著,但启用SSAO的图像给人更真实的感觉,这些细微的遮蔽细节为整个场景增添了更强的深度感。

环境光遮蔽技术会带来较大的性能开销,因为它需要考虑周围的几何体。我们可以向空间中的每一点发射大量光线来确定其遮蔽量,但这种方法在实时运算中很快就会成为难题。2007年,Crytek公司发布了屏幕空间环境光遮蔽(Screen - Space Ambient Occlusion, SSAO)技术,并将其应用于他们的代表作《孤岛危机》中。该技术使用屏幕空间场景的深度,而非真实的几何体数据来确定遮蔽量。与真正的环境光遮蔽相比,这种方法不仅速度快,而且效果良好,使其成为近似实时环境光遮蔽的标准。

SSAO背后的原理较为简单:对于铺屏四边形(Screen - filled Quad)上的每个片段,我们会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子随后会用于减少或抵消片段的环境光照分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并与当前片段深度值进行对比得到的。高于片段深度值的样本数量即为我们所需的遮蔽因子。

在上图中,几何体内灰色的深度样本均高于片段深度值,这些样本会增加遮蔽因子。几何体内样本数量越多,片段获得的环境光照就越少。

显然,渲染效果的质量和精度与采样的样本数量直接相关。如果样本数量过低,渲染精度会急剧下降,会出现一种叫做波纹(Banding)的效果;如果样本数量过高,则会影响性能。我们可以通过在采样核心(Sample Kernel)的采样中引入随机性来减少样本数量。通过随机旋转采样核心,我们可以在有限的样本数量下获得高质量的结果。然而,这仍然会带来一些问题,因为随机性会引入明显的噪声图案,我们需要通过模糊结果来修复这一问题。下图展示了波纹效果以及随机性造成的影响:

可以看到,尽管在低样本数的情况下会出现明显的波纹效果,但引入随机性后,这些波纹效果就完全消失了。

Crytek公司开发的SSAO技术会产生一种特殊的视觉风格。由于使用的采样核心是一个球体,这会导致平整的墙面也显得灰蒙蒙的,因为核心中一半的样本会落在墙这个几何体上。下图展示了《孤岛危机》中的SSAO效果,清晰地呈现了这种灰蒙蒙的感觉:

因此,我们不使用球体的采样核心,而是使用一个沿着表面法向量的半球体采样核心。

通过在法向半球体(Normal - oriented Hemisphere)周围采样,我们不会考虑片段底部的几何体,从而消除了环境光遮蔽带来的灰蒙蒙的感觉,产生更真实的结果。

SSAO需要获取几何体的信息,因为我们需要确定一个片段的遮蔽因子。对于每个片段,我们需要以下数据:

  • 逐片段位置向量
  • 逐片段的法线向量
  • 线性深度纹理
  • 采样核心
  • 用于旋转采样核心的逐片段随机旋转矢量

通过使用逐片段观察空间位置,我们可以将采样半球核心对准片段的观察空间表面法线。对于每个核心样本,我们会采样线性深度纹理并比较结果。采样核心会根据旋转矢量稍微偏转,我们获得的遮蔽因子将用于限制最终的环境光照分量。

由于SSAO是一种屏幕空间技巧,我们在铺屏2D四边形上的每个片段计算这一效果。也就是说,我们没有场景中几何体的直接信息。我们能做的是将几何体数据渲染到屏幕空间纹理中,然后将此数据发送到SSAO着色器中,这样我们就能访问这些几何体数据了。如果你看过前面的教程,会发现这与延迟渲染非常相似。这意味着SSAO和延迟渲染能够完美兼容,因为我们已经将位置和法线向量存储到G缓冲中了。

由于我们已经有了逐片段位置和法线数据(在G缓冲中),我们只需更新几何着色器,使其包含片段的线性深度即可。回顾我们在深度测试那一节所学的知识,我们可以从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);
}

提取出来的线性深度是在观察空间中的,因此后续的运算也在观察空间中进行。要确保G缓冲中的位置和法线都在观察空间中(乘上观察矩阵也一样)。观察空间线性深度值会被保存在gPositionDepth颜色缓冲的alpha分量中,这样就无需再声明一个新的颜色缓冲纹理。

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);

这为我们提供了一个线性深度纹理,可用于为每个核心样本获取深度值。需要注意的是,我们将线性深度值存储为浮点数据,这样从0.1到50.0范围的深度值就不会被限制在[0.0, 1.0]之间。如果你不使用浮点值存储这些深度数据,要确保首先将值除以FAR进行标准化,再存储到gPositionDepth纹理中,并在后续的着色器中用类似的方法重建它们。同样需要注意的是GL_CLAMP_TO_EDGE的纹理封装方法,这保证了我们不会不小心采样到屏幕空间中纹理默认坐标区域之外的深度值。

接下来,我们需要真正的半球采样核心和一些随机旋转它的方法。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章