五、高级光照:“阴影”(“阴影映射”和“点阴影”)
5.3.1 阴影映射
阴影还是比较不好实现的,因为当前实时渲染领域还没找到一种完美的阴影算法。目前有几种近似阴影技术,但它们都有自己的弱点和不足,这点我们必须要考虑到。 视频游戏中较多使用的一种技术是阴影贴图(shadow mapping),效果不错,而且相对容易实现。阴影贴图并不难以理解,性能也不会太低,而且非常容易扩展成更高级的算法(比如 Omnidirectional Shadow Maps和 Cascaded Shadow Maps)。
阴影映射
对于阴影映射的原理,本教程与GAMES101中讲解的别无二致,而且后者更为详细,参考GAMES101内容即可
深度贴图
在OpenGL中的实现
const unsigned int SHADOW_WIDTH = 1024 , SHADOW_HEIGHT = 1024 ;
unsigned int depthMapFBO;
glGenFramebuffers ( 1 , & depthMapFBO) ;
unsigned int depthMap;
glGenTextures ( 1 , & depthMap) ;
glBindTexture ( GL_TEXTURE_2D, depthMap) ;
glTexImage2D ( GL_TEXTURE_2D, 0 , GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0 , GL_DEPTH_COMPONENT, 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_REPEAT) ;
glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT) ;
glBindFramebuffer ( GL_FRAMEBUFFER, depthMapFBO) ;
glFramebufferTexture2D ( GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0 ) ;
glDrawBuffer ( GL_NONE) ;
glReadBuffer ( GL_NONE) ;
glBindFramebuffer ( GL_FRAMEBUFFER, 0 ) ;
生成深度贴图不太复杂。因为我们只关心深度值,我们要把纹理格式指定为GL_DEPTH_COMPONENT。我们还要把纹理的高宽设置为1024:这是深度贴图的分辨率。 把我们把生成的深度纹理作为帧缓冲的深度缓冲 我们需要的只是在从光的透视图下渲染场景的时候深度信息,所以颜色缓冲没有用。 然而,不包含颜色缓冲的帧缓冲对象是不完整的,所以我们需要显式告诉OpenGL我们不适用任何颜色数据进行渲染。 我们通过将调用glDrawBuffer和glReadBuffer把读和绘制缓冲设置为GL_NONE来做这件事。 合理配置将深度值渲染到纹理的帧缓冲后,我们就可以开始第一步了:生成深度贴图。两个步骤的完整的渲染阶段,看起来有点像这样:
simpleDepthShader. use ( ) ;
simpleDepthShader. setMat4 ( "lightSpaceMatrix" , lightSpaceMatrix) ;
glViewport ( 0 , 0 , SHADOW_WIDTH, SHADOW_HEIGHT) ;
glBindFramebuffer ( GL_FRAMEBUFFER, depthMapFBO) ;
glClear ( GL_DEPTH_BUFFER_BIT) ;
glActiveTexture ( GL_TEXTURE0) ;
glBindTexture ( GL_TEXTURE_2D, woodTexture) ;
renderScene ( simpleDepthShader) ;
glBindFramebuffer ( GL_FRAMEBUFFER, 0 ) ;
glViewport ( 0 , 0 , SCR_WIDTH, SCR_HEIGHT) ;
glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) ;
debugDepthQuad. use ( ) ;
debugDepthQuad. setFloat ( "near_plane" , near_plane) ;
debugDepthQuad. setFloat ( "far_plane" , far_plane) ;
glActiveTexture ( GL_TEXTURE0) ;
glBindTexture ( GL_TEXTURE_2D, depthMap) ;
renderQuad ( ) ;
这段代码隐去了一些细节,但它表达了阴影映射的基本思路。这里一定要记得调用glViewport。 因为阴影贴图经常和我们原来渲染的场景(通常是窗口分辨率)有着不同的分辨率,我们需要改变视口(viewport)的参数以适应阴影贴图的尺寸。 如果我们忘了更新视口参数,最后的深度贴图要么太小要么就不完整。
光源空间的变换
glm:: mat4 lightProjection, lightView;
glm:: mat4 lightSpaceMatrix;
float near_plane = 1.0f , far_plane = 7.5f ;
lightProjection = glm:: ortho ( - 10.0f , 10.0f , - 10.0f , 10.0f , near_plane, far_plane) ;
lightView = glm:: lookAt ( lightPos, glm:: vec3 ( 0.0f ) , glm:: vec3 ( 0.0 , 1.0 , 0.0 ) ) ;
lightSpaceMatrix = lightProjection * lightView;
这个lightSpaceMatrix正是前面我们称为T的那个变换矩阵。有了lightSpaceMatrix只要给shader提供光空间的投影和视图矩阵,我们就能像往常那样渲染场景了。 然而,我们只关心深度值,并非所有片段计算都在我们的着色器中进行。为了提升性能,我们将使用一个与之不同但更为简单的着色器来渲染出深度贴图。
渲染至深度贴图
当我们以光的透视图进行场景渲染的时候,我们会用一个比较简单的着色器,这个着色器除了把顶点变换到光空间以外,不会做得更多了#version 330 core
layout ( location = 0 ) in vec3 position;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main ( )
{
gl_Position = lightSpaceMatrix * model * vec4 ( position, 1.0f ) ;
}
由于我们没有颜色缓冲,最后的片段不需要任处理,所以我们可以简单地使用一个空片段着色器。 渲染深度缓冲现在成了:simpleDepthShader. Use ( ) ;
glUniformMatrix4fv ( lightSpaceMatrixLocation, 1 , GL_FALSE, glm:: value_ptr ( lightSpaceMatrix) ) ;
glViewport ( 0 , 0 , SHADOW_WIDTH, SHADOW_HEIGHT) ;
glBindFramebuffer ( GL_FRAMEBUFFER, depthMapFBO) ;
glClear ( GL_DEPTH_BUFFER_BIT) ;
RenderScene ( simpleDepthShader) ;
glBindFramebuffer ( GL_FRAMEBUFFER, 0 ) ;
这里的RenderScene函数的参数是一个着色器程序(shader program),它调用所有相关的绘制函数,并在需要的地方设置相应的模型矩阵。 将深度贴图渲染到四边形上的片段着色器:#version 330 core
out vec4 color;
in vec2 TexCoords;
uniform sampler2D depthMap;
void main ( )
{
float depthValue = texture ( depthMap, TexCoords) . r;
color = vec4 ( vec3 ( depthValue) , 1.0 ) ;
}
要注意的是当用透视投影矩阵取代正交投影矩阵来显示深度时,有一些轻微的改动,因为使用透视投影时,深度是非线性的。
渲染阴影
正确地生成深度贴图以后我们就可以开始生成阴影了。 本部分不贴出全部代码,需要配合原教程代码观看。 这段代码在片段着色器中执行,用来检验一个片段是否在阴影之中 ,不过我们在顶点着色器中进行光空间的变换。 顶点着色器 的新的地方是FragPosLightSpace 这个输出向量。我们用同一个lightSpaceMatrix,把世界空间顶点位置转换为光空间。 顶点着色器传递一个普通的经变换的世界空间顶点位置vs_out.FragPos 和一个光空间的vs_out.FragPosLightSpace 给片段着色器。 片段着色器大部分是从高级光照教程中复制过来,只不过加上了个阴影计算。 我们声明一个shadowCalculation函数,用它计算阴影。 片段着色器的最后,我们我们把diffuse和specular乘以(1-阴影元素),这表示这个片段有多大成分不在阴影中。 这个片段着色器还需要两个额外输入,一个是光空间的片段位置 和第一个渲染阶段得到的深度贴图 ShadowCalculation函数:float ShadowCalculation ( vec4 fragPosLightSpace)
{
vec3 projCoords = fragPosLightSpace. xyz / fragPosLightSpace. w;
projCoords = projCoords * 0.5 + 0.5 ;
float closestDepth = texture ( shadowMap, projCoords. xy) . r;
float currentDepth = projCoords. z;
float shadow = currentDepth > closestDepth ? 1.0 : 0.0 ;
return shadow;
}
改进阴影贴图
阴影失真
- 对于目前的实现,放大看会发现明显的线条样式
- 我们可以看到地板四边形渲染出很大一块交替黑线。这种阴影贴图的不真实感叫做阴影失真(Shadow Acne),下图解释了成因:
因为阴影贴图受限于分辨率,在距离光源比较远的情况下,多个片段可能从深度贴图的同一个值中去采样。图片每个斜坡代表深度贴图一个单独的纹理像素。你可以看到,多个片段从同一个深度值进行采样。 我们可以用一个叫做阴影偏移(shadow bias)的技巧来解决这个问题,我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。 一个0.005的偏移就能帮到很大的忙,但是有些表面坡度很大,仍然会产生阴影失真。有一个更加可靠的办法能够根据表面朝向光线的角度更改偏移量:使用点乘 ShadowCalculation函数:float ShadowCalculation ( vec4 fragPosLightSpace)
{
vec3 projCoords = fragPosLightSpace. xyz / fragPosLightSpace. w;
projCoords = projCoords * 0.5 + 0.5 ;
float closestDepth = texture ( shadowMap, projCoords. xy) . r;
float currentDepth = projCoords. z;
vec3 normal = normalize ( fs_in. Normal) ;
vec3 lightDir = normalize ( lightPos - fs_in. FragPos) ;
float bias = max ( 0.05 * ( 1.0 - dot ( normal, lightDir) ) , 0.005 ) ;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0 ;
return shadow;
}
这里我们有一个偏移量的最大值0.05,和一个最小值0.005,它们是基于表面法线和光照方向的。这样像地板这样的表面几乎与光源垂直,得到的偏移就很小,而比如立方体的侧面这种表面得到的偏移就更大。
悬浮
使用阴影偏移的一个缺点是你对物体的实际深度应用了平移。偏移有可能足够大,以至于可以看出阴影相对实际物体位置的偏移。 这个阴影失真叫做悬浮(Peter Panning),因为物体看起来轻轻悬浮在表面之上。 我们可以使用一个叫技巧解决大部分的Peter panning问题:当渲染深度贴图时候使用正面剔除(front face culling)。 因为我们只需要深度贴图的深度值,对于实体物体无论我们用它们的正面还是背面都没问题。使用背面深度不会有错误,因为阴影在物体内部有错误我们也看不见。 这十分有效地解决了peter panning的问题,但只对内部不会对外开口的实体物体有效。
采样过多
还有一个视觉差异,就是光的视锥不可见的区域一律被认为是处于阴影中,不管它真的处于阴影之中。 出现这个状况是因为超出光的视锥的投影坐标比1.0大,这样采样的深度纹理就会超出他默认的0到1的范围。 根据纹理环绕方式,我们将会得到不正确的深度结果,它不是基于真实的来自光源的深度值。
PCF
阴影现在已经附着到场景中了,不过这仍不是我们想要的。如果你放大看阴影,阴影映射对分辨率的依赖很快变得很明显。 因为深度贴图有一个固定的分辨率,多个片段对应于一个纹理像素。结果就是多个片段会从深度贴图的同一个深度值进行采样,这几个片段便得到的是同一个阴影,这就会产生锯齿边。 你可以通过增加深度贴图的分辨率的方式来降低锯齿块,也可以尝试尽可能的让光的视锥接近场景。 实际上类似MSAA的思想
正交 vs 投影
5.3.2 点阴影
效果不错,但它只适合定向光,因为阴影只是在单一定向光源下生成的。所以它也叫定向阴影映射,深度(阴影)贴图生成自定向光的视角。 这个技术叫做点光阴影 ,过去的名字是万向阴影贴图(omnidirectional shadow maps)技术 。 对于深度贴图,我们需要从一个点光源的所有渲染场景,普通2D深度贴图不能工作; 如果我们使用立方体贴图会怎样?因为立方体贴图可以储存6个面的环境数据,它可以将整个场景渲染到立方体贴图的每个面上,把它们当作点光源四周的深度值来采样。 生成后的深度立方体贴图被传递到光照像素着色器,它会用一个方向向量来采样立方体贴图,从而得到当前的fragment的深度(从光的透视图)。
生成深度立方体贴图
const unsigned int SHADOW_WIDTH = 1024 , SHADOW_HEIGHT = 1024 ;
unsigned int depthMapFBO;
glGenFramebuffers ( 1 , & depthMapFBO) ;
unsigned int depthCubemap;
glGenTextures ( 1 , & depthCubemap) ;
glBindTexture ( GL_TEXTURE_CUBE_MAP, depthCubemap) ;
for ( unsigned int i = 0 ; i < 6 ; ++ i)
glTexImage2D ( GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0 , GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0 , GL_DEPTH_COMPONENT, GL_FLOAT, NULL ) ;
glTexParameteri ( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST) ;
glTexParameteri ( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST) ;
glTexParameteri ( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) ;
glTexParameteri ( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) ;
glTexParameteri ( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE) ;
glBindFramebuffer ( GL_FRAMEBUFFER, depthMapFBO) ;
glFramebufferTexture ( GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0 ) ;
glDrawBuffer ( GL_NONE) ;
glReadBuffer ( GL_NONE) ;
glBindFramebuffer ( GL_FRAMEBUFFER, 0 ) ;
万向阴影贴图有两个渲染阶段:首先我们生成深度贴图,然后我们正常使用深度贴图渲染,在场景中创建阴影。 帧缓冲对象和立方体贴图的处理看起是这样的:
glViewport ( 0 , 0 , SHADOW_WIDTH, SHADOW_HEIGHT) ;
glBindFramebuffer ( GL_FRAMEBUFFER, depthMapFBO) ;
glClear ( GL_DEPTH_BUFFER_BIT) ;
ConfigureShaderAndMatrices ( ) ;
RenderScene ( ) ;
glBindFramebuffer ( GL_FRAMEBUFFER, 0 ) ;
glViewport ( 0 , 0 , SCR_WIDTH, SCR_HEIGHT) ;
glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) ;
ConfigureShaderAndMatrices ( ) ;
glBindTexture ( GL_TEXTURE_CUBE_MAP, depthCubemap) ;
RenderScene ( ) ;
这个过程和默认的阴影映射一样,尽管这次我们渲染和使用的是一个立方体贴图深度纹理,而不是2D深度纹理。 在我们实际开始从光的视角的所有方向渲染场景之前,我们先得计算出合适的变换矩阵。
光空间的变换
float near_plane = 1.0f ;
float far_plane = 25.0f ;
glm:: mat4 shadowProj = glm:: perspective ( glm:: radians ( 90.0f ) , ( float ) SHADOW_WIDTH / ( float ) SHADOW_HEIGHT, near_plane, far_plane) ;
std:: vector< glm:: mat4> shadowTransforms;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( 1.0f , 0.0f , 0.0f ) , glm:: vec3 ( 0.0f , - 1.0f , 0.0f ) ) ) ;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( - 1.0f , 0.0f , 0.0f ) , glm:: vec3 ( 0.0f , - 1.0f , 0.0f ) ) ) ;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( 0.0f , 1.0f , 0.0f ) , glm:: vec3 ( 0.0f , 0.0f , 1.0f ) ) ) ;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( 0.0f , - 1.0f , 0.0f ) , glm:: vec3 ( 0.0f , 0.0f , - 1.0f ) ) ) ;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( 0.0f , 0.0f , 1.0f ) , glm:: vec3 ( 0.0f , - 1.0f , 0.0f ) ) ) ;
shadowTransforms. push_back ( shadowProj * glm:: lookAt ( lightPos, lightPos + glm:: vec3 ( 0.0f , 0.0f , - 1.0f ) , glm:: vec3 ( 0.0f , - 1.0f , 0.0f ) ) ) ;
因为投影矩阵在每个方向上并不会改变,我们可以在6个变换矩阵中重复使用。我们要为每个方向提供一个不同的视图矩阵。 用glm::lookAt创建6个观察方向,每个都按顺序注视着立方体贴图的的一个方向:右、左、上、下、近、远。
深度着色器
为了把值渲染到深度立方体贴图,我们将需要3个着色器:顶点和像素着色器,以及一个它们之间的几何着色器。 几何着色器是负责将所有世界空间的顶点变换到6个不同的光空间的着色器。因此顶点着色器简单地将顶点变换到世界空间,然后直接发送到几何着色器。 紧接着几何着色器以3个三角形的顶点作为输入,它还有一个光空间变换矩阵的uniform数组。 几何着色器接下来会负责将顶点变换到光空间;这里它开始变得有趣了 。 几何着色器有一个内建变量叫做gl_Layer,它指定发散出基本图形送到立方体贴图的哪个面。 当不管它时,几何着色器就会像往常一样把它的基本图形发送到输送管道的下一阶段,但当我们更新这个变量就能控制每个基本图形将渲染到立方体贴图的哪一个面。 当然这只有当我们有了一个附加到激活的帧缓冲的立方体贴图纹理才有效。 几何着色器 相对简单。
我们输入一个三角形,输出总共6个三角形(6*3顶点,所以总共18个顶点)。 在main函数中,我们遍历立方体贴图的6个面,我们每个面指定为一个输出面,把这个面的interger(整数)存到gl_Layer。 然后,我们通过把面的光空间变换矩阵乘以FragPos,将每个世界空间顶点变换到相关的光空间,生成每个三角形。 注意,我们还要将最后的FragPos变量发送给像素着色器,我们需要计算一个深度值。 像素着色器
这次我们将计算自己的深度,这个深度就是每个fragment位置和光源位置之间的线性距离。 计算自己的深度值使得之后的阴影计算更加直观。 像素着色器将来自几何着色器的FragPos、光的位置向量和视锥的远平面值作为输入。 这里我们把fragment和光源之间的距离,映射到0到1的范围,把它写入为fragment的深度值。 使用这些着色器渲染场景,立方体贴图附加的帧缓冲对象激活以后,你会得到一个完全填充的深度立方体贴图 ,以便于进行第二阶段的阴影计算。
万向阴影贴图
所有事情都做好了,是时候来渲染万向阴影(Omnidirectional Shadow)了。 这个过程和定向阴影映射教程相似,尽管这次我们绑定的深度贴图是一个立方体贴图,而不是2D纹理,并且将光的投影的远平面发送给了着色器。 具体过程略
显示立方体贴图深度缓冲
FragColor = vec4 ( vec3 ( closestDepth / far_plane) , 1.0 ) ;
PCF
可以和前面教程同样的那个简单的PCF过滤器,并加入第三个维度。
然而,samples设置为4.0,每个fragment我们会得到总共64个样本,这太多了! 大多数这些采样都是多余的,与其在原始方向向量附近处采样,不如在采样方向向量的垂直方向进行采样更有意义。 有个技巧可以使用,用一个偏移量方向数组,它们差不多都是分开的,每一个指向完全不同的方向,剔除彼此接近的那些子方向。