你还记得我们的光照模型是如何推导出来的吗,回到 17 课我们先来看看我们之前的光照模型。首先是环境光,环境光是用于模拟一种普遍的感受——在光照充足或白天的情况下所有物体都是被点亮的,它的实现是为每个光源定义一个单精度浮点数(作为环境光强度),然后用这个浮点数乘上当前表面的纹理采样的颜色值。所有你可以只在场景中定义一个光源,其作用就相当于太阳,而且你可以通过调节这个光源的环境光强度来控制整个场景的大体亮度 —— 强度值越接近于 0,场景越暗,值越靠近于 1 场景越亮。
在之后的课程中我们实现了漫反射光和镜面反射光效果,这有助于提升场景的整体效果但是基本的环境光照还是一样的。在近几年中出现了一个新的技术 —— 环境光遮挡,在这个技术中并不是为每个像素设置一个特定的环境光强度值,而是计算每个像素在环境光光源中的曝光情况。一个位于房间中间的地板上的像素通常会比位于角落里的像素曝光度更高,这意味着房间角落的像素会比其他像素更加暗,这就是环境光遮挡的核心思想。所以为了实现这个算法我们需要找到一个区分 “ 位于角落的像素 ” 和 “ 位于开放空间的像素 ” 的方法,通过这个方法计算出来的结果应该是一个环境光遮挡因子,这个因子会在最后的光照计算阶段控制环境光的计算。下面是这个环境光因子的可视化表现。
你可以看到模型的边缘部分是最亮的,而那些处于更加角落的地方的像素我们认为其曝光度也较少,因而也更加暗。
目前对环境光遮挡的研究也不少,而且也开发出了很多算法对其进行不断的完善,在这一课中我们将学习这些算法中的一个分支 —— 屏幕空间环境光遮挡,即 SSAO,这个算法是由 Crytek 公司开发,并随着游戏《孤岛危机》 的发行而大受欢迎。现在很多游戏中都实现了 SSAO 并且在这个基础上做了很多改进,这一课中我们要学习的是 John Chapman 的 SSAO 教程中的一个简化版本的算法。
环境光遮挡十分耗费计算资源,Crytek 公司想出了一个折中的办法,为每个像素计算一次遮挡因子。这个算法名中的前缀“屏幕空间”,这意味着会依次遍历屏幕空间中的每一个像素,并提取每个像素在视图空间下的坐标信息,并在这个坐标点周围随机采样一些点,判断这些随机点位于几何体内部还是外部。如果许多点都是位于几何体内部,这就表示原始像素应该是位于角落中的因而接收到的光照也较少;如果大部分点都位于几何体之外,这就表示原始像素被 “高度曝光” ,因此接收到的光照也较多。例如下面这个图片:
在上面的图中有两个点 P0 和 P1,假设我们是从图像坐上角的某个地方进行观察。我们会在每个点周围随机采样一些点,并判断这些点是否位于几何体内部。对于 P0 来说它周围的随机点落在几何体内部的几率更大,而 P1 则相反。因此我们希望 P1 的环境光因子应该更大,这表示在最后的渲染结果中 P1 看起来会更加明亮。
接下来我们介绍更细节的部分。在我们的主渲染函数中的光照阶段之前,我们还需要添加一个环境光遮挡阶段(我们需要计算环境光因子)。环境光遮挡阶段会对屏幕上每个像素都进行一次计算。对于这每一个像素我们需要得到他再视图空间下的位置坐标,并在这个点附近生成一些随机点。最简单的办法就是将场景中所有几何体(当然只有离相机最近的像素才会在深度测试中保留下来)的视图空间坐标都保存在一个纹理中,因此在环境光遮挡阶段之前我们还需要有一个类似于延迟渲染中的几何阶段,这个几何阶段的作用就是将每个像素的视图坐标信息填充到一个类似于 GBuffer 的缓存中(这里我们不需要法线或者颜色信息)。这样在环境光遮挡阶段要得到像素的视图坐标信息就只需要进行一次采样操作即可。
所以现在我们在片元着色器中就可以得到当前像素在视图空间下的位置坐标,要在这个位置坐标周围生成随机点就十分简单了。我们会向着色器程序中传入一个随机向量数组(作为一致变量)并分别将这些向量与当前片元在视图空间下的坐标相加,这样就能得到一系列的随机点,之后我就要判断这些随机点是否位于几何体内部了。记住这些随机点都是不是真实存在的点,所以也没有与之对应的片元,对这些点的判断就和阴影纹理中的类似了,将随机点的 Z 分量值与场景中离相机最近的实际点的 Z 值进行比较,当然实际的点一定是位于从相机到虚拟点的射线之上。参考下面的图片:
P 点位于红色表面上,P 点周围的红色点(位于射线 R2 上)和绿色点(位于射线 R1 上)都是 P 点周围生成的随机点。绿点位于几何体之外,红点位于几何体之内(因此会增加 P 点被遮挡的程度)。圆圈表示随机点生成的范围(我们不希望随机点离原始顶点太远)。 R1 和 R2 是从相机(位于 0,0,0 点)分别到红点和绿点的两条射线。他们与几何体相交于一点。为了计算出环境光遮挡因子我们需要分别将红点和绿点的 Z 值与他们对应的几何体顶点(分别是射线 R1 和 R2 与几何体的交点)的 Z 值进行比较。对于红点和绿点的 Z 值我们可以直接得到(在视图空间下,毕竟我们直接生成了这些点的坐标),但是我们如何才能得到上面的交点的 Z 值呢?
当然有很多方法可以解决这个问题,但是因为我们已经将整个场景的视图空间坐标都渲染到了纹理中,所以最简单的办法就是从这个纹理中找到它就行了。为了达到这个目的我们需要另外一个纹理坐标,这样我们就可以为射线 R1、R2 与场景几何体的交点提取他们在视图空间中的坐标。
现在我们先对视图空间的位置坐标纹理的创建做一个快速的回顾。在将顶点坐标从模型的局部坐标系变换到视图坐标系之后,还需要乘上透视投影矩阵(实际上整个变换过程都是通过一个矩阵实现的)。这些都发生在顶点着色器中,在将它们传入到片元着色器时 GPU 会自动进行透视除法来完成整个投影过程。通过投影之后视图空间所有顶点都被投影到了近裁剪面上,而且所有位于视锥体中的顶点的 XYZ 分量都位于(-1,1)的范围之间。在片元着色器中将视图空间中的坐标信息写出到纹理中时(上面的计算只会作用于 gl_Position 中的存放的数据,而要写入到纹理中的数据则是被存放在另一个变量中),当前像素的 XY 分量会被变换到(0,1)的范围中,这个结果就是视图坐标数据被写入到纹理中时的纹理坐标。
你是否可以通过相似的步骤计算出红点和绿点的纹理坐标?当然他们的数学思想都是一样的,我们需要做的就是向着色器程序中传入透视投影矩阵并用这个矩阵将红点和绿点投影到近裁剪面上并手动执行投影除法。之后我们需要将结果变换到 (0,1)的范围中去,这样就能得到纹理坐标。之后我们就能通过采样操作从纹理中获得与射线的相交的顶点的 Z 值,并通过这个值来判断我们生成的随机点是否位于几何体内部。接下来让我们看看代码。
(tutorial45.cpp:156) |
virtual void RenderSceneCB() |
{ |
m_pGameCamera->OnRender(); |
m_pipeline.SetCamera(*m_pGameCamera); |
GeometryPass(); |
SSAOPass(); |
BlurPass(); |
LightingPass(); |
RenderFPS(); |
CalcFPS(); |
OgldevBackendSwapBuffers(); |
} |
(tutorial45.cpp:177) |
void GeometryPass() |
{ |
m_geomPassTech.Enable(); |
m_gBuffer.BindForWriting(); |
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); |
m_pipeline.Orient(m_mesh.GetOrientation()); |
m_geomPassTech.SetWVP(m_pipeline.GetWVPTrans()); |
m_geomPassTech.SetWVMatrix(m_pipeline.GetWVTrans()); |
m_mesh.Render(); |
} |
(geometry_pass.vs) |
#version 330 |
layout (location = 0) in vec3 Position; |
uniform mat4 gWVP; |
uniform mat4 gWV; |
out vec3 ViewPos; |
void main() |
{ |
gl_Position = gWVP * vec4(Position, 1.0); |
ViewPos = (gWV * vec4(Position, 1.0)).xyz; |
} |
(geometry_pass.fs) |
#version 330 |
in vec3 ViewPos; |
layout (location = 0) out vec3 PosOut; |
void main() |
{ |
PosOut = ViewPos; |
} |
这是几何阶段的顶点着色器和片元着色器,在顶点着色器中我们向往常一样计算出 gl_position 并通过另外一个变量将当前顶点在视图空间下的坐标传入到片元着色器中。记住这个变量不会进行透视除法,但是在光栅化阶段会对其进行插值。
在片元着色器中我们将插值之后的视图空间坐标写入到纹理中。
(tutorial45.cpp:192) |
void SSAOPass() |
{ |
m_SSAOTech.Enable(); |
m_SSAOTech.BindPositionBuffer(m_gBuffer); |
m_aoBuffer.BindForWriting(); |
glClear(GL_COLOR_BUFFER_BIT); |
m_quad.Render(); |
} |
(ssao.vs) |
#version 330 |
layout (location = 0) in vec3 Position; |
out vec2 TexCoord; |
void main() |
{ |
gl_Position = vec4(Position, 1.0); |
TexCoord = (Position.xy + vec2(1.0)) / 2.0; |
} |
(ssao.fs) |
#version 330 |
in vec2 TexCoord; |
out vec4 FragColor; |
uniform sampler2D gPositionMap; |
uniform float gSampleRad; |
uniform mat4 gProj; |
const int MAX_KERNEL_SIZE = 128; |
uniform vec3 gKernel[MAX_KERNEL_SIZE]; |
void main() |
{ |
vec3 Pos = texture(gPositionMap, TexCoord).xyz; |
float AO = 0.0; |
for (int i = 0 ; i < MAX_KERNEL_SIZE ; i++) { |
vec3 samplePos = Pos + gKernel[i]; // generate a random point |
vec4 offset = vec4(samplePos, 1.0); // make it a 4-vector |
offset = gProj * offset; // project on the near clipping plane |
offset.xy /= offset.w; // perform perspective divide |
offset.xy = offset.xy * 0.5 + vec2(0.5); // transform to (0,1) range |
float sampleDepth = texture(gPositionMap, offset.xy).b; |
if (abs(Pos.z - sampleDepth) < gSampleRad) { |
AO += step(sampleDepth,samplePos.z); |
} |
} |
AO = 1.0 - AO/128.0; |
FragColor = vec4(pow(AO, 2.0)); |
} |
之后我们要对随机点和随机点对应的真实点的深度值(Z 值)进行比较,GLSL 提供的 step(x,y) 函数在会在 y < x 的时候返回 0,反之则返回 1。这意味着越多的随机点落在几何体之后,那么局部变量 AO 的值也越大。我们计划是用像素的颜色值乘上这个结果,所以我们对 AO 变量进行了一个取反操作 'AO = 1.0 - AO/128.0'。最后的结果会被写入到一个缓存中,注意在写出之前我对 AO 值进行了平方运算,只是我们主观上觉得这样做的效果更好。
(tutorial45.cpp:205) |
void BlurPass() |
{ |
m_blurTech.Enable(); |
m_blurTech.BindInputBuffer(m_aoBuffer); |
m_blurBuffer.BindForWriting(); |
glClear(GL_COLOR_BUFFER_BIT); |
m_quad.Render(); |
} |
(blur.vs) |
#version 330 |
layout (location = 0) in vec3 Position; |
out vec2 TexCoord; |
void main() |
{ |
gl_Position = vec4(Position, 1.0); |
TexCoord = (Position.xy + vec2(1.0)) / 2.0; |
} |
(blur.fs) |
#version 330 |
in vec2 TexCoord; |
out vec4 FragColor; |
uniform sampler2D gColorMap; |
float Offsets[4] = float[]( -1.5, -0.5, 0.5, 1.5 ); |
void main() |
{ |
vec3 Color = vec3(0.0, 0.0, 0.0); |
for (int i = 0 ; i < 4 ; i++) { |
for (int j = 0 ; j < 4 ; j++) { |
vec2 tc = TexCoord; |
tc.x = TexCoord.x + Offsets[j] / textureSize(gColorMap, 0).x; |
tc.y = TexCoord.y + Offsets[i] / textureSize(gColorMap, 0).y; |
Color += texture(gColorMap, tc).xyz; |
} |
} |
Color /= 16.0; |
FragColor = vec4(Color, 1.0); |
} |
(tutorial45.cpp:219) |
void LightingPass() |
{ |
m_lightingTech.Enable(); |
m_lightingTech.SetShaderType(m_shaderType); |
m_lightingTech.BindAOBuffer(m_blurBuffer); |
glBindFramebuffer(GL_FRAMEBUFFER, 0); |
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); |
m_pipeline.Orient(m_mesh.GetOrientation()); |
m_lightingTech.SetWVP(m_pipeline.GetWVPTrans()); |
m_lightingTech.SetWorldMatrix(m_pipeline.GetWorldTrans()); |
m_mesh.Render(); |
} |
(lighting.fs) |
vec2 CalcScreenTexCoord() |
{ |
return gl_FragCoord.xy / gScreenSize; |
} |
vec4 CalcLightInternal(BaseLight Light, vec3 LightDirection, vec3 Normal) |
{ |
vec4 AmbientColor = vec4(Light.Color * Light.AmbientIntensity, 1.0f); |
if (gShaderType == SHADER_TYPE_SSAO) { |
AmbientColor *= texture(gAOMap, CalcScreenTexCoord()).r; |
} |
... |