投影阴影
一种很适合在地平面上绘制阴影,又相对不需要太大计算代价的方法,叫作投影阴影(projective shadows)。
给定一个位于(xL, yL, zL)的点光源、一个需要渲染的物体以及一个投射阴影的平面,可以通过生成一个变换矩阵,将物体上的点(xw, yw, zw)变换为相应阴影在平面上的点(xs, 0, zs)。之后将其生成的“阴影多边形”绘制出来,通常使用暗色物体与地平面纹理混合作为其纹理,如下图所示。

使用投影阴影进行投射的优点是它的高效和易于实现。但是,它仅适用于平坦表面——这种方法无法投射阴影于曲面或其他物体。即使如此,它仍然适用于有室外场景并对性能要求较高的应用,很多游戏中的场景都属于这类。
阴影体
先找到被物体阴影覆盖的阴影体,之后减少视体与阴影体相交部分中的多边形的颜色强度。如下图展示了阴影体中的立方体,因此,立方体绘制时会更暗。

阴影体的优点在于其高度准确,比起其他方法来更不容易产生伪影。但是,计算出阴影体以及每个多边形是否在其中这件事,即使对于现代 GPU 来说,计算代价也很大。几何着色器可以用于计算阴影体, 模板缓冲区可以用于判断像素是否在阴影体内。有些显卡对于特定的阴影体操作优化提供了硬件支持。
阴影贴图
阴影贴图是用于投射阴影最实用也最流行的方法之一。虽然它并不总是像阴影体一样准确(且通常伴随着讨厌的伪影),但阴影贴图实现起来更简单,可以在各种情况下使用,并享有强大的硬件支持。
阴影贴图基于一个非常简明的想法: 光线无法看到的任何东西都在阴影中。也就是说,如果对象#1 阻挡光到达对象#2,等同于光不能“看到”对象#2。
这个想法的强大之处在于我们已经有了方法来确定物体是否可以被“看到”——使用Z缓冲区的隐藏面消除算法(HSR),如Blog“✠OpenGL-2-图像管线”-像素操作(隐藏面消除、Z-Buffer算法) 所述。因此,计算阴影的策略是,暂时将摄像机移动到光的位置,应用 Z 缓冲区 HSR 算法,然后使用生成的深度信息来计算阴影。
因此,渲染场景需要两轮:第 1 轮从灯光的角度渲染场景(但实际上没有将其绘制到屏幕上),第 2 轮从摄像机的角度渲染场景。第 1 轮的目的是从光的角度生成 Z 缓冲区。完成第 1 轮之后,我们需要保留 Z 缓冲区并使用它来帮助我们在第 2 轮生成阴影。第 2 轮实际绘制场景。
- (第 1 轮)从灯光的位置渲染场景。然后,对于每个像素,深度缓冲区包含光与最近的对象之间的距离。
- (中间步骤)将深度缓冲区复制到单独的“阴影缓冲区”。
- (第 2 轮)正常渲染场景。对于每个像素,在阴影缓冲区中查找相应的位置。如果灯光到渲染点的距离大于从阴影缓冲区检索到的值, 则在该像素处绘制的对象离光线的距离,比离光线最近的对象更远,因此该像素处于阴影中。
当发现像素处于阴影中时,我们需要使其更暗。一种简单而有效的方法是仅渲染其环境光,忽略其漫反射和镜面反射分量。
上述方法通常被称为“阴影缓冲区”。而当我们在第二步中,将深度缓冲区复制到纹理中,则称为“阴影贴图”。当纹理对象用于储存阴影深度信息时,我们称其为阴影纹理,OpenGL 通过 sampler2DShadow 类型支持阴影纹理。这样,我们就可以利用片段着色器中纹理单元和采样器变量(即“纹理贴图”)的硬件支持功能,在第 2 轮快速执行深度查找。我们现在修改的策略是:
- (第 1 轮)与之前相同;
- (中间步骤)将深度缓冲区的内容复制进纹理对象;
- (第 2 轮)与之前相同,不过阴影缓冲区变为阴影纹理。
阴影贴图(第1轮)——从光源位置“绘制”物体
在第一步中,我们首先将相机移动到灯光的位置然后渲染场景。我们的目标不是在显示器上实际绘制场景,而是完成足够的渲染过程以正确填充深度缓冲区。因此,没有必要为像素生成颜色,我们的第一遍将仅使用顶点着色器,但片段着色器不执行任何操作。
当然,移动相机需要构建适当的观察矩阵。根据场景的内容,我们需要在光源处依合适的方向来看场景。通常,我们希望此方向朝向最终在第 2 轮中呈现的区域。这个方向通常依场景而定——在我们的场景中,我们通常会将相机从光源指向原点。
第 1 轮中有几个需要处理的重要细节。
- 配置缓冲区和阴影纹理。
- 禁用颜色输出。
- 从光源到视野中的物体构建一个 LookAt 矩阵。
- 启用 GLSL 第 1 轮着色器程序,该程序仅包含下面代码的简单顶点着色器,准备接收 MVP 矩阵。在这种情况下, MVP 矩阵将包括对象的模型矩阵 M、前一步中计算的 LookAt 矩阵(作为观察矩阵 V)(LookAt矩阵描述如Blog:“✠OpenGL-3-数学基础-变换矩阵”),以及透视矩阵 P。我们将该 MVP 矩阵称为“shadowMVP”,因为它是基于光而不是相机的观察点。
// 顶点着色器
#version 430
layout(location = 0) int vec3 vertPos;
uniform mat4 shadowMVP;
void main() {
gl_Position = shadowMVP * vec4(vertPos, 1.0);
}
// 片段着色器
void main() {
}
由于实际上没有显示来自光源的视图,因此第 1 轮着色器程序的片段着色器不会执行任何操作。
- 为每个对象创建 shadowMVP 矩阵,并调用 glDrawArrays()。第 1 轮中不需要包含纹理或光照,因为对象不会渲染到屏幕上。

阴影贴图(中间步骤)——将Z缓冲区复制到纹理
OpenGL 提供了两种将 Z 缓冲区深度数据放入纹理单元的方法。
第一种方法是生成空阴影纹理,然后使用命令 glCopyTexImage2D()将活动深度缓冲区复制到阴影纹理中。
第二种方法是在第 1 轮中构建一个“自定义帧缓冲区”(而不是使用默认的 Z 缓冲区),并使用命令 glFrameBufferTexture()将阴影纹理附加到它上面。 OpenGL 在 3.0 版中引入该命令,以进一步支持阴影纹理。使用这种方法时,无须将 Z 缓冲区“复制”到纹理中,因为缓冲区已经附加了纹理,深度信息由 OpenGL 自动放入纹理中。我们将在实现中使用这种方法。
多级渐进纹理 MIPMAP?有什么优缺点?
为了加快渲染速度和减少图像锯齿, 贴图被处理成由一系列被预先计算和优化过的图片组成的文件,这样的贴图被称为 MIP map 或者mipmap。
多级渐进纹理由一组分辨率逐渐降低的纹理序列组成,每一级纹理宽度和高度都是上一级纹理宽度和高度的一半。宽和高不一定相等,也就是说,这些纹理不一定都是正方形。
优点:提高渲染速度,减少图像锯齿
缺点:会增加额外的内存消耗。
void glFrameBufferTexture(GLenum target, GLenum attachment, GLuint texture, GLint level)
attach a level of a texture object as a logical buffer of a framebuffer object
将纹理对象的级别附加为帧缓冲区对象的逻辑缓冲区。
target:Specifies the target to which the framebuffer is bound for all commands except glNamedFramebufferTexture.
指定帧缓冲区绑定到的目标(所有命令中除了glNamedFramebufferTexture都可行),如常量GL_FRAMEBUFFER。
attachment:指定帧缓冲区的附着点,如常量GL_DEPTH_ATTACHMENT。
texture:指定要附加的现有纹理对象的名称,如一个GLuint类型的纹理ID。
level:指定要附加的纹理对象的mipmap级别,默认为0。
命令将纹理对象的选定mipmap级别或图像附加为指定帧缓冲区对象的逻辑缓冲区之一。
纹理不能附加到默认的绘制和读取帧缓冲区,因此它们不是该命令的有效目标。
void glDrawBuffer(GLenum buf)
指定要绘制到的颜色缓冲区。
buf:将颜色写入帧缓冲区时,它们将写入指定的颜色缓冲区。以下值之一可用于默认帧缓冲区:
GL_NONE——不写入颜色缓冲区。
GL_FRONT——只写入左前和右前颜色缓冲区。如果没有右前颜色缓冲区,则只写入左前颜色缓冲区。
GL_FRONT_LEFT——只写入左前颜色缓冲区。
GL_BACK_RIGHT——只写入右后颜色缓冲区。
…
对于单缓冲上下文,初始值为GL_FRONT,对于双缓冲上下文,初始值为GL_BACK。
void glBindFramebuffer(GLenum target, GLuint framebuffer)
将帧缓冲区绑定到帧缓冲区目标。
target:指定绑定操作的帧缓冲区目标。
framebuffer:指定要绑定的帧缓冲区对象的名称。
glBindFramebuffer将名为framebuffer的framebuffer对象绑定到target指定的framebuffer目标。目标必须是GL_DRAW_FRAMEBUFFER, GL_READ_FRAMEBUFFER 或 GL_FRAMEBUFFER。
如果帧缓冲区对象绑定到GL_DRAW_FRAMEBUFFER 或 GL_READ_FRAMEBUFFER,则它将分别成为渲染或回读操作的目标,直到将其删除或将另一个帧缓冲区绑定到相应的绑定点。
调用glBindFramebuffer并将target设置为GL_FRAMEBUFFER会将framebuffer绑定到read和draw framebuffer目标。framebuffer是以前从调用glGenFramebuffers返回的framebuffer对象的名称,或为零以中断framebuffer对象到目标的现有绑定。
阴影贴图(第2轮)——渲染带阴影的场景
从纹理贴图尝试查找像素时,情况比较复杂。 OpenGL 相机使用[−1…+ 1]坐标空间,而纹理贴图使用[0…1]空间。常见的解决方案是构建一个额外的矩阵变换,通常称为 B(“偏离”, biases),它将用于从 [相机空间] 到 [纹理空间] 的转换。
得到 B 的过程很简单——先缩放为 1/2,再平移 1/2:[−1, 1] * ½ ∈[-½, ½] + ½ ∈[0, 1]。矩阵 B 如下:
B = [ 0.5 0 0 0.5 0 0.5 0 0.5 0 0 0.5 0.5 0 0 0 1 ] B= \left[ \begin{matrix} 0.5& 0& 0& 0.5 \\ 0& 0.5& 0& 0.5 \\ 0& 0& 0.5& 0.5 \\ 0& 0& 0& 1 \end{matrix} \right] B=⎣⎢⎢⎡0.500000.500000.500.50.50.51⎦⎥⎥⎤
之后将 B 合并入 shadowMVP 矩阵以备在第 2 轮中使用,如下:
s h a d o w M V P = [ B ] [ s h a d o w M V P ( p a s s 1 ) ] shadowMVP = [B][shadowMVP_{(pass1)}] shadowMVP=[B][shadowMVP(pass1)]
用偏离矩阵 B 变换后的顶点来判断[原顶点]是否在阴影里,可信度呢?

用数学方法可以很快证明一些东西:
①对于一维情况:
ⓐ假设阴影所在区间是[-1,1],证明:如果点P在阴影里,那么点(P×½ + ½)∈[0,1] 且 在阴影里。
证明:P在阴影里,即P∈[-1,1],那么(P×½ + ½)∈[0, 1]∈[-1,1],得证!
从以上证明可知,如果一个点在阴影区间[-1,1]里,那么将这个点乘½再加½得到的值阴影区间[0,1]。
ⓑ假设阴影所在区间是[-1,1],证明:如果点P不在阴影里,那么点(P×½ + ½)∉[0,1]。
证明:P不在阴影里,即P<-1或P>1,那么(P×½ + ½)<0或(P×½ + ½)>1,满足(P×½ + ½)∉[0,1],得证。
从以上证明可知,如果一个点不在阴影区,则将这个点乘½再加½得到的值不在[0,1]区间,而[0,1]区间正是纹理坐标空间范围,(P×½ + ½)∉[0,1],就说明变换后的点不在纹理坐标空间,即不在阴影纹理里(不在阴影里)。
②对于二维情况:
假设阴影所在区间是s,t∈[-1,1],证明:如果点P在阴影里,那么点(P×½ + ½)∈[0,1] 且 在阴影里。
证明:P在阴影里,即Ps,Pt∈[-1,1],那么对于水平方向s∈[-1,1],Ps∈[-1,1]的情况根据①ⓐ的证明,可知:点(Ps×½ + ½)∈[0,1] 且 在阴影里。同理,对于垂直方向t∈[-1,1],Pt∈[-1,1]的情况也根据①ⓐ的证明,可知:点(Pt×½ + ½)∈[0,1] 且 在阴影里。
综上,只要点P在s,t∈[-1,1]这个二维区间,就只可能在水平或垂直方向进行缩放(½)现平移(+½)的变换,而水平和垂直方向已分别得以证明,所以,点(P×½ + ½)∈[0,1] 且 在阴影里。
同理,对于反证,参考①ⓑ,轻松得到证明。
对于二维情况,其实就是一维情况的拓展;同理,对于三维情况就是对一、二维情况的拓展,也就是多了个坐标分量而已,原理是一样的;这里就不再证明了。
回答问题:用偏离矩阵B将顶点P从[-1,1]区间变换到[0,1]区间后,P 变为 P’,它们之间是一一对应关系(线性变换),P’∈[0,1] 满足纹理坐标范围,且如果判断出 P’ 在阴影里,那么原来的点 P 也一定在阴影里。故,顶点乘偏离矩阵B,是完成逻辑功能所必须的,没什么其他任何副影响!
假设我们使用阴影纹理附加到我们的自定义帧缓冲区的方法, OpenGL 提供了一些相对简单的工具,用于确定绘制对象时,像素是否处于阴影中。
- 构建变换矩阵 B,用于从光照空间转换到纹理空间[更合适在 init()中进行]。
- 启用阴影纹理以进行查找。
- 启用颜色输出。
- 启用 GLSL 第 2 轮渲染程序,包含顶点着色器和片段着色器。
- 根据相机位置(正常)为正在绘制的对象构建 MVP 矩阵。
- 构建 shadowMVP2 矩阵(包含 B 矩阵)——着色器将需要用它查找阴影纹理中的像素坐标。
- 将生成的矩阵变换发送到着色器统一变量。
- 像往常一样启用包含顶点、法向量和纹理坐标(如果使用)的缓冲区。
- 调用 glDrawArrays()。
除了渲染任务外,顶点和片段着色器还需要额外承担一些任务。
- 顶点着色器将顶点位置从相机空间转换为光照空间, 并将结果坐标发送到顶点属性中的片段着色器,以便对它们进行插值。这样片段着色器可以从阴影纹理中检索正确的值。
- 片段着色器调用
textureProj()函数, 该函数返回 0 或 1, 指示像素是否处于阴影中。如果它在阴影中,则着色器通过剔除其漫反射和镜面反射分量来输出更暗的像素。
float textureProj(sampler2DShadow sampler, vec4 P, [float bias])
使用投影执行纹理查找。它专门用于投影纹理访问的。
sampler:指定将从中检索texel的纹理绑定到的采样器。
P:指定纹理采样的纹理坐标。
[bias]:指定要在详细等级计算期间应用的可选偏移。
返回值:在阴影中返回0,不在阴影纹理中返回1。
OpenGL Reference:
P - Specifies the texture coordinates at which texture will be sampled.
The texture coordinates consumed from P, not including the last component of P, are divided by the last component of P.
即,vec4类型点P的使用是:除了最后一个分量自身外,其他分量都要除以最后一个分量。
也即,使用纹理查找时,它的纹理坐标各分量会除以最后一个分量,【然后才】访问纹理。
结果产生的在阴影形式中(in the shadow forms)的P的第三个分量用作Dref(深度信息)。
计算完这些值后,纹理查找将按texture()函数中的方式进行。
vec4类型纹理坐标——齐次纹理坐标
textureProj()函数用于从阴影纹理中查找值,它类似于我们之前看到的texture(),其区别是除了 textureProj() 函数使用 vec4 来索引纹理而不是通常的 vec2。
由于像素坐标是vec4,因此需要将其投影到 2D 纹理空间上,以便在阴影纹理贴图中【查找深度值】。
齐次纹理坐标(homogeneous texture coordinates)的概念对大多数人来说比较陌生,纹理坐标一般是二维的,如果是体纹理,其纹理坐标也只是三维的。齐次纹理坐标的出现是为了和三维顶点的齐次坐标相对应, 因为本质上,投影纹理坐标是通过三维顶点的齐次坐标计算得到的。
一般的纹理坐标是vec2类型的,因为它没有深度信息。而vec4类型的纹理坐标的第三个分量表示的是深度信息,所以除了一般的(s,t)纹理坐标外,还多了个深度信息分量。textureProj()函数用于从阴影纹理中查找值,函数的第二个参数是vec4类型的纹理坐标,所以从阴影纹理中查找的信息有常规的纹理坐标(s,t),还有深度值。
// 顶点着色器
#version 430
...
out vec4 shadow_coord;
uniform mat4 shadowMVP2;
layout(location = 0) in vec3 vertPos;
void main() {
...
shadow_coord = shadowMVP2 * vec4(vertPos, 1.0);
gl_Position = proj_matrix * mv_matrix * vec4(vertPos, 1.0);
}
从代码中可以看到,以前的shadow_coord是直接将从C++程序传递过来的vec2类型纹理坐标经光栅化后送到片段着色器处理了;而现在的shadow_coord是将vec3类型的模型顶点经MVP矩阵变换到光照空间中适合纹理坐标范围的顶点值,并且转换成了vec4类型。经转换后,shadow_coord中前三个分量分别将作为从阴影纹理单元查找纹理的水平坐标值、垂直坐标值 和 深度值。其中的第三个分量(深度值)直接决定判断点是否在阴影里的依据,如果第三个分量(深度值)小于等于阴影纹理单元中缓存的深度值,则说明这个像素比深度缓冲区中的像素更接近灯光位置,则不在阴影里;反之,则在阴影里。
渲染的像素和阴影纹理中的值的深度比较
首先,从顶点着色器开始,在模型空间中使用顶点坐标,我们将其与shadowMVP2 相乘以生成阴影纹理坐标,这些坐标对应于投影到光照空间中的顶点坐标,是之前从光源的视角生成的。经过插值后的3D光照空间坐标 (x, y, z) 在片段着色器中使用如下:z 分量表示从光到像素的距离, (x, y) 分量用于检索存储在2D阴影纹理中的深度信息。将该检索的值(到最靠近光的物体的距离)与 z 进行比较。该比较产生“二元”结果,告诉我们我们正在渲染的像素是否比【最接近光的物体】离光更远(即像素是否处于阴影中)。
假设光源位置以视觉空间坐标表示。
如果我们在 OpenGL 中使用前面介绍过的 glFrameBufferTexture()并启用深度测试,然后使用片段着色器的 sampler2DShadow 和 textureProj(),所渲染的结果将完全满足我们的需求。即 textureProj()将输出 0.0 或 1.0,具体取决于深度比较。基于此值,当像素离光源比离光源最近的物体更远时,我们可以在片段着色器中忽略漫反射和镜面反射分量,从而有效地创建阴影。


假设场景的原点位于图的中心在金字塔和环面之间(左图)。在第 1 轮中,我们将[相机]放在光源的位置并指向(0, 0, 0)。然后我们用红色绘制对象,它会产生如右图所示的输出。即,下图(右)是场景在【光源视角】下所渲染的场景。

如下图,[有光照无阴影的场景(左)]-(仿佛光线直接穿过了金字塔) 和 [有光照有阴影的场景(右)]-(光线照射到圆环被金字塔遮挡):

如果是自身的阴暗面,就正常的光照模型就处理了,正常的渲染。
如果是模型像素被其他模型遮挡,就得专门算法针对处理了。
所以,关键就是要判断本模型是否被其他模型所遮挡,如被遮挡,就忽略遮挡部位漫反射和镜面反射分量;否则,就使用标准光照模型。
// 片段着色器
#version 430
...
in vec4 shadow_coord;
layout (binding = 0) uniform sampler2DShadow shTex;
void main() {
...
float notInShadow = textureProj(shTex, shadow_coord);
fragColor = globalAmbient * materail.ambient + light.ambient * material.ambient;
if (notInShadow == 1.0) {
fragColor += light.diffuse * material.diffuse * max(dob(L, N), 0.0) + light.specular * material.specular * pow

最低0.47元/天 解锁文章
5460

被折叠的 条评论
为什么被折叠?



