目录

  • 投影阴影
  • 阴影体
  • 阴影贴图
    • 阴影贴图(第1轮)——从光源位置“绘制”物体
    • 阴影贴图(中间步骤)——将Z缓冲区复制到纹理
    • 阴影贴图(第2轮)——渲染带阴影的场景
    • 用偏离矩阵 B 变换后的顶点来判断[原顶点]是否在阴影里,可信度呢?
    • 渲染的像素和阴影纹理中的值的深度比较
  • 理解[纹理(映射)单元]
  • 背面剔除与阴影的区别
  • 阴影贴图示例及分析
    • 探讨第1轮与第2轮操作区别——构建和使用带深度的帧缓冲
  • 阴影贴图的伪影(阴影痤疮)
    • 深度偏移——glPolygonOffset()函数
    • 视口变化对阴影的影响
    • 阴影间隙、重复阴影、阴影条、锯齿状阴影边缘
  • 柔和阴影
    • 生成柔和阴影——百分比邻近滤波(PCF)
    • 柔和阴影/PCF程序
    • 理解PCF程序代码
    • 理解lookup()参数列表的指定
    • 生成64采样的高分辨率柔和阴影
  • 补充
投影阴影

一种很适合在地平面上绘制阴影,又相对不需要太大计算代价的方法,叫作投影阴影(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.5000.500.500.5000.50.50001]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.5000​00.500​000.50​0.50.50.51​⎦⎥⎥⎤​
之后将 B 合并入 shadowMVP 矩阵以备在第 2 轮中使用,如下:
shadowMVP=[B][shadowMVP(pass1)]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(max(dot(H, N), 0.0), material.shininess * 3.0);}
}
理解[纹理(映射)单元]

纹理映射单元(Texture mapping unit, TMU)是现代图形处理器(GPU)的部件。它是一个独立的物理处理器,TMU能够旋转,调整大小和扭曲位图图像——执行纹理采样,以作为纹理放置在给定3D模型的任意平面上。此过程称为纹理制图映射。纹理(映射)单元的出现是由于采样和将平面图像(作为纹理贴图)转换为需要在3D空间中的正确角度和远景的计算需求。计算操作是一个大的矩阵乘法。

目前显卡的渲染流程是通过顶点单元构建模型骨架,纹理映射单元处理纹理贴图,像素单元处理光影特效,光栅单元负责3D转2D光栅化输出。

纹理在进入GPU时,会为其分配新的纹理单元,并上传纹理到该纹理单元。纹理单元是着色器的一部分,并与渲染输出单元(ROP)分离。例如,在AMD的赛普拉斯GPU中,每个着色器(其中有20个)有四个纹理映射单元,为GPU提供80个纹理映射单元。这是由芯片设计师完成的,它们将着色器和它们将要使用的纹理引擎紧密结合在一起。

首先就要知道什么是sampler纹理采样器。在GLSL中,我们经常会在片段着色器源码文件中使用sampler这个修饰符,它会和一个texture一一对应,从而获取到我们需要的纹理。它的功能就是辅助texture()函数获取纹理的纹素,而texture()函数有两个参数,一个是纹理坐标,另外一个就是sampler修饰的纹理;由此可见,sampler是通过某些方法获取纹理的修饰符。

通常情况下,一个纹理的位置,我们通常称其为纹理单元,一个纹理的默认纹理单元是0,通常情况下,这个默认的纹理单元0是激活的。纹理单元的主要目的是让我们可以在着色器中可以使用多于1个的纹理。

激活了相应的纹理单元位置(glActiveTexture(GL_TEXTUREN);)以后,我们可以通过把纹理单元赋值给纹理采样器,从而将[纹理采样器]获取到的纹理赋值到纹理单元。纹理和纹理单元也就要一一对应,就需要进行绑定。绑定纹理到对应激活的纹理单元的方法是:glBindTexture(GL_TEXTURE_2D, texure);第一个参数是纹理的维度,第二个就是纹理。每次使用着色器前先绑定。把纹理绑定到纹理单元,再把纹理单元指定着色器。

OpenGL保证了16个纹理单元供我们选择使用,即GL_TEXTURE0~GL_TEXTURE15。正常情况下可用纹理单元是32个。

背面剔除与阴影的区别

这个区别其实很明显,“背面剔除”是在由眼睛视线方向的一条射线穿过物体,产生多于一个点的交点,在所有交点中,取离屏幕最近的点展示,其他的点不去渲染。
而阴影是可以看到的,光照模型本身就会产生阴暗面,这是由光照算法决定的。阴影是一个物体遮挡另一物体所产生的一种暗面效果。

如上图,坐标点P和Q在同一指向相机的射线上,试想如果点P是一个孔,那么就可以直接看到点Q了,所以点P的存在遮挡了点Q。在相机空间中,背景剔除就是点P的深度值小于点Q的深度值,点Q被剔除。注意地面也就是一个模型,所以这个阴影是球体遮光,并利用阴影贴图产生的,而不是直接由ADS光照产生的。
点M在球体的阴暗面,点N在投影阴影上面,这是相机可以直接看到的。

阴影贴图示例及分析

main.cpp

void installLights(int renderingProgram, glm::mat4 vMatrix) {// 将灯光位置转换到【相机空间】// glm::vec3 lightLoc(-3.8f, 2.2f, 1.1f);transformed = glm::vec3(vMatrix * glm::vec4(lightLoc, 1.0));lightPos[0] = transformed.x; lightPos[1] = transformed.y; lightPos[2] = transformed.z;posLoc = glGetUniformLocation(renderingProgram, "light.position");glProgramUniform3fv(renderingProgram, posLoc, 1, lightPos);
}void setupVertices() {                                                                                                                                                                                                                                                   glGenVertexArrays(1, vao);glBindVertexArray(vao[0]);glGenBuffers(numVBOs, vbo);glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);// 圆环顶点glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);// 金字塔顶点glBindBuffer(GL_ARRAY_BUFFER, vbo[2]);// 圆环法向量glBindBuffer(GL_ARRAY_BUFFER, vbo[3]);// 金字塔法向量//...glBufferData...glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo[4]);// 索引(用索引绘制圆环,金字塔用导入模型完成)glBufferData(GL_ELEMENT_ARRAY_BUFFER, ind.size() * 4, &ind[0], GL_STATIC_DRAW);
}void setupShadowBuffers(GLFWwindow* window) {glfwGetFramebufferSize(window, &width, &height);scSizeX = width;scSizeY = height;// ############生成【帧缓冲区】对象###########################################glGenFramebuffers(1, &shadowBuffer);// ############生成【纹理对象】并【绑定】纹理对象###########################################glGenTextures(1, &shadowTex);glBindTexture(GL_TEXTURE_2D, shadowTex);// ############指定二维纹理图像###########################################// 将图像文件数据复制到纹理对象中。// 由于本例没有使用真实材质而是用光照模型中的铜/黄金材质,所以data参数为0。glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32,scSizeX, scSizeY, 0, GL_DEPTH_COMPONENT, GL_FLOAT, 0);// 设置多级渐远纹理贴图等glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);// 可能减少阴影边界伪影glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}void init(GLFWwindow* window) {renderingProgram1 = Util::createShaderProgram("./vert1Shader.glsl", "./frag1Shader.glsl");renderingProgram2 = Util::createShaderProgram("./vert2Shader.glsl", "./frag2Shader.glsl");glfwGetFramebufferSize(window, &width, &height);aspect = (float)width / (float)height;pMat = glm::perspective(toRadians(60.0f), aspect, 0.1f, 1000.0f);setupVertices();setupShadowBuffers(window);b = glm::mat4(// 从相机空间到纹理空间的转换矩阵0.5f, 0.0f, 0.0f, 0.0f,0.0f, 0.5f, 0.0f, 0.0f,0.0f, 0.0f, 0.5f, 0.0f,0.5f, 0.5f, 0.5f, 1.0f);
}//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
void display(GLFWwindow* window, double currentTime) {glClear(GL_DEPTH_BUFFER_BIT);glClear(GL_COLOR_BUFFER_BIT);// ############根据灯光位置,构建【LookAt矩阵】和【透视矩阵】,配置好[光照空间]###########################################// 函数 glm::lookAt(eye, lookingAt, up)// glm::vec3 lightLoc(-3.8f, 2.2f, 1.1f);// glm::vec3 origin(0.0f, 0.0f, 0.0f);// glm::vec3 up(0.0f, 1.0f, 0.0f);lightVmatrix = glm::lookAt(lightLoc, origin, up);lightPmatrix = glm::perspective(toRadians(60.0f), aspect, 0.1f, 1000.0f);// ############绑定【帧缓冲区】###########################################glBindFramebuffer(GL_FRAMEBUFFER, shadowBuffer);// ############将【纹理深度】信息,附加到【帧缓冲区】###########################################glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, shadowTex, 0);// ############GL_NONE:不写入颜色缓冲区,即【禁用颜色输出】###########################################glDrawBuffer(GL_NONE);// important!glEnable(GL_DEPTH_TEST);glEnable(GL_POLYGON_OFFSET_FILL);// 开启面的深度偏移glPolygonOffset(2.0f, 4.0f);// (factor, units)设置用于计算[深度值]的比例和单位,用于减少阴影伪影passOne();// 阴影贴图(第1轮),使用第1个程序对象glDisable(GL_POLYGON_OFFSET_FILL);// 移回原位,与实际计算阴影有稍微差别也看不出来// ############用“0”来【中断】帧缓冲区对象与目标的现有绑定###########################################glBindFramebuffer(GL_FRAMEBUFFER, 0);// ############激活#0【纹理单元】,并【绑定】纹理单元###########################################glActiveTexture(GL_TEXTURE0);glBindTexture(GL_TEXTURE_2D, shadowTex);// ############指定绘制要写入到前缓冲(左前和右前)颜色缓冲区###########################################glDrawBuffer(GL_FRONT);// 由于OpenGL状态机,不调用则会沿用GL_NONE参数的调用状态。passTwo();// 阴影贴图(第2轮),使用第2个程序对象
}//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
void passOne() {glUseProgram(renderingProgram1);// glm::vec3 torusLoc(1.6f, 0.0f, -0.3f);mMat = glm::translate(glm::mat4(1.0f), torusLoc);mMat = glm::rotate(mMat, toRadians(25.0f), glm::vec3(1.0f, 0.0f, 0.0f));// ############将圆环转换到[光照空间],判断表面是否有像素在阴影里###########################################shadowMVP1 = lightPmatrix * lightVmatrix * mMat;sLoc = glGetUniformLocation(renderingProgram1, "shadowMVP");glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP1));glClear(GL_DEPTH_BUFFER_BIT);glEnable(GL_CULL_FACE);glFrontFace(GL_CCW);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);// >>>>>>>>>>>>>>>>>>>在[光照空间]绘制圆环<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<glDrawElements(GL_TRIANGLES, numTorusIndices, GL_UNSIGNED_INT, 0);// glm::vec3 pyrLoc(-1.0f, 0.1f, 0.3f);mMat = glm::translate(glm::mat4(1.0f), pyrLoc);mMat = glm::rotate(mMat, toRadians(30.0f), glm::vec3(1.0f, 0.0f, 0.0f));mMat = glm::rotate(mMat, toRadians(40.0f), glm::vec3(0.0f, 1.0f, 0.0f));// ############将金字塔转换到[光照空间],判断表面是否有像素在阴影里###########################################shadowMVP1 = lightPmatrix * lightVmatrix * mMat;glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP1));glEnable(GL_CULL_FACE);glFrontFace(GL_CCW);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);// >>>>>>>>>>>>>>>>>>>在[光照空间]绘制金字塔<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<glDrawArrays(GL_TRIANGLES, 0, numPyramidVertices);
}//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
void passTwo() {glUseProgram(renderingProgram2);sLoc = glGetUniformLocation(renderingProgram2, "shadowMVP");// glm::vec3 cameraLoc(0.0f, 0.2f, 6.0f);vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraLoc.x, -cameraLoc.y, -cameraLoc.z));// glm::vec3 torusLoc(1.6f, 0.0f, -0.3f);mMat = glm::translate(glm::mat4(1.0f), torusLoc);mMat = glm::rotate(mMat, toRadians(25.0f), glm::vec3(1.0f, 0.0f, 0.0f));// ############将灯光位置转换到[相机空间]###########################################installLights(renderingProgram2, vMat);mvMat = vMat * mMat;// ############将圆环转换到[光照空间],并转换顶点坐标到[0,1]范围###########################################shadowMVP2 = b * lightPmatrix * lightVmatrix * mMat;glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP2));glClear(GL_DEPTH_BUFFER_BIT);// 这里清除深度缓冲区,但不影响附加到帧缓冲区中的深度信息glEnable(GL_CULL_FACE);glFrontFace(GL_CCW);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);// >>>>>>>>>>>>>>>>>>>在[相机空间]绘制圆环<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<glDrawElements(GL_TRIANGLES, numTorusIndices, GL_UNSIGNED_INT, 0);// glm::vec3 pyrLoc(-1.0f, 0.1f, 0.3f);mMat = glm::translate(glm::mat4(1.0f), pyrLoc);mMat = glm::rotate(mMat, toRadians(30.0f), glm::vec3(1.0f, 0.0f, 0.0f));mMat = glm::rotate(mMat, toRadians(40.0f), glm::vec3(0.0f, 1.0f, 0.0f));// ############将灯光位置转换到[相机空间]###########################################installLights(renderingProgram2, vMat);mvMat = vMat * mMat;// ############将金字塔转换到[光照空间],并转换顶点坐标到[0,1]范围###########################################shadowMVP2 = b * lightPmatrix * lightVmatrix * mMat;glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP2));glEnable(GL_CULL_FACE);glFrontFace(GL_CCW);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);// >>>>>>>>>>>>>>>>>>>在[相机空间]绘制金字塔<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<glDrawArrays(GL_TRIANGLES, 0, numPyramidVertices);
}int main(void) {...
}

第 1 轮中不需要包含纹理或光照,因为对象不会渲染到屏幕上。
vert1Shader.glsl

#version 430
layout(location = 0) in vec3 vertPos;
uniform mat4 shadowMVP;
void main(void) {gl_Position = shadowMVP * vec4(vertPos, 1.0);// 在[光照空间]的顶点位置
}

frag1Shader.glsl

#version 430
void main(void) {}

vert2Shader.glsl

#version 430
layout (location = 0) in vec3 vertPos;
layout (location = 1) in vec3 vertNormal;out vec3 vNormal, vLightDir, vVertPos, vHalfVec;
out vec4 shadow_coord;struct PositionalLight {vec4 ambient, diffuse, specular;vec3 position;
};uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
uniform mat4 shadowMVP;
uniform PositionalLight light;
void main(void) {// 输出在[相机空间]中顶点位置vVertPos = (mv_matrix * vec4(vertPos, 1.0)).xyz;// 输出[相机空间]中的光照向量vLightDir = light.position - vVertPos;// 输出[相机空间]中顶点的法向量vNormal = (norm_matrix * vec4(vertNormal, 1.0)).xyz;// 计算[相机空间]中角平分线向量(L+V)vHalfVec = (vLightDir - vVertPos).xyz;// 将顶点坐标转换到【光照空间】,其中还包括了将顶点转换到符合纹理坐标的范围[0,1]shadow_coord = shadowMVP * vec4(vertPos, 1.0);// 在[相机空间]中的顶点坐标gl_Position = proj_matrix * mv_matrix * vec4(vertPos, 1.0);
}

frag2Shader.glsl

#version 430
layout(binding = 0) uniform sampler2DShadow shadowTex;// 阴影纹理单元(包含深度和位置信息)
in vec3 vNormal, vLightDir, vVertPos, vHalfVec;
in vec4 shadow_coord;
out vec4 fragColor;struct PositionalLight {vec4 ambient, diffuse, specular;vec3 position;
};
struct Material {vec4 ambient, diffuse, specular;float shininess;
};uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
void main(void) {vec3 L = normalize(vLightDir);vec3 N = normalize(vNormal);vec3 V = normalize(-vVertPos);vec3 H = normalize(vHalfVec);float inShadow = textureProj(shadowTex, shadow_coord);fragColor = globalAmbient * material.ambient+ light.ambient * material.ambient;if (inShadow != 0.0) {fragColor += light.diffuse * material.diffuse * max(dot(L, N), 0.0)+ light.specular * material.specular* pow(max(dot(H, N), 0.0), material.shininess * 3.0);}
}

函数glGenTextures、glTexImage2D相关解释,见Blog:✠OpenGL-5-纹理贴图(用C++/OpenGL实现纹理贴图(不利用SOIL2库))

void glDrawBuffer(GLenum buf)
指定要绘制到的颜色缓冲区。——你需要渲染的场景最终每一个像素都要写入该缓冲区,然后由它在渲染到屏幕上显示。
对于默认帧缓冲区,参数指定最多四个要绘制的颜色缓冲区。可以一次性启用多个缓冲区。
可指定的参数值为:GL_NONE, GL_FRONT_LEFT, GL_FRONT_RIGHT, GL_BACK_LEFT, GL_BACK_RIGHT, GL_FRONT, GL_BACK, GL_LEFT, GL_RIGHT, GL_FRONT_AND_BACK。
对于单缓冲上下文,初始值为GL_FRONT,对于双缓冲上下文,初始值为GL_BACK。
如果指定为GL_FRONT,OpenGL就会在前颜色缓冲区中进行渲染;
如果指定为GL_BACK,那么渲染将在后颜色缓冲区中进行。

GL_NONE:不写入颜色缓冲区。
GL_FRONT_LEFT:只写入左前颜色缓冲区。
GL_FRONT_RIGHT:只写入右前颜色缓冲区。
GL_BACK_LEFT:只写入左后颜色缓冲区。
GL_BACK_RIGHT:只写入右后颜色缓冲区。
GL_FRONT:只写入左前和右前颜色缓冲区。如果没有右前颜色缓冲区,则只写入左前颜色缓冲区。
GL_BACK:只写入左后和右后颜色缓冲区。如果没有右后颜色缓冲区,则只写入左后颜色缓冲区。
GL_LEFT:只写入左前和左后颜色缓冲区。如果没有左后颜色缓冲区,则只写入左前颜色缓冲区。
GL_RIGHT:只写入右前和右后颜色缓冲区。如果没有右后颜色缓冲区,则只写入右前颜色缓冲区。
GL_FRONT_AND_BACK:所有正面和背面颜色缓冲区(左前、右前、左后、右后)都被写入。如果没有背面颜色缓冲区,则只写入左前和右前颜色缓冲区。如果没有右颜色缓冲区,则只写入左前和左后颜色缓冲区。如果没有右或后颜色缓冲区,则只写入左前颜色缓冲区。

如果为绘图选择了多个颜色缓冲区,则会对每个颜色缓冲区分别计算和应用混合或逻辑操作,并在每个缓冲区中产生不同的结果。
单视上下文只包括左缓冲区,立体上下文包括左缓冲区和右缓冲区。同样,单缓冲上下文只包括前缓冲区,而双缓冲上下文同时包括前缓冲区和后缓冲区。在GL初始化时上下文被选择。

程序中第1轮是从光源位置绘制物体,中间步骤将Z缓冲区复制到纹理,第2轮是渲染带阴影的场景。

把光源当相机,在帧缓冲区附加上纹理,深度信息由OpenGL自动放入阴影纹理中。然后,在正常的相机空间中,拿刚才的深度信息去判断物体间的光线遮挡情况,使用生成的深度信息来计算阴影。在float inShadow = textureProj(shadowTex, shadow_coord); 中shadowTex是sampler2DShadow类型,这是在第一阶段完成的阴影纹理映射单元,它是在【光照空间】完成的,所以同样要通过【光照空间】中的纹理坐标shadow_coord采样,事实上也确实是在顶点着色器中shadow_coord=(B * P光源 * V光源 * M) * vec4(vertPos,1.0); 使得shadow_coord是在光照空间中的坐标,从而通过它来利用采样器变量进行采样:float inShadow = textureProj(shadowTex, shadow_coord);

MVP矩阵中的V指的是LookAt矩阵而非相机视图矩阵。MVP光源=光源透视矩阵P×光源LookAt矩阵V×模型矩阵M

构建LookAt矩阵,用光源坐标替换负相机坐标,从而完成“光源视角”。

对于本例:
glm::vec3 lightLoc(-3.8f, 2.2f, 1.1f);
glm::vec3 origin(0.0f, 0.0f, 0.0f);
glm::vec3 up(0.0f, 1.0f, 0.0f);
lightVmatrix = glm::lookAt(lightLoc, origin, up);
lightPmatrix = glm::perspective(toRadians(60.0f), aspect, 0.1f, 1000.0f);

探讨第1轮与第2轮操作区别——构建和使用带深度的帧缓冲

display()中第1轮操作之前:
初始化光源位置,创建LookAt矩阵和透视矩阵,绑定帧缓冲区并将带深度的纹理附加到帧缓冲区,禁用颜色输出,启用深度测试,启用GL_POLYGON_OFFSET_FILL(在执行深度测试和将值写入深度缓冲区之前添加偏移量)。

第1轮操作:
使用vert1Shader.glsl和frag1Shader.glsl创建程序对象,创建光照MVP矩阵shadowMVPone并关联到顶点属性,绘制两模型。输出的顶点位置gl_Position=shadowMVP×vec4(vertPos,1.0); 这是在光照空间的顶点位置,坐标值位于[-1, 1]区间。因为是要模拟光照条件下物体的遮挡情况,所以LookAt矩阵模拟放置一个发光的光源位置以方便得到遮挡关系。因为无论在相机空间还是光照空间,这种相对坐标空间和遮挡关系是永远不会变的。这时就在开启深度测试并绘制模型时就得到了已经附加到的帧缓冲区中带有深度的纹理。

第1轮与第2轮之间的操作:
禁用GL_POLYGON_OFFSET_FILL,即第1轮操作是要启用的,而第2轮要实际绘制模型,是需要禁用的;
中断[帧缓冲区]对象的当前绑定,即第1轮操作是绑定了帧缓冲区的,从而可以将深度信息缓存到帧,而第2轮操作是要中断包含深度信息的帧缓冲,从而可以保存之前的帧缓冲中的深度信息并拿来使用(OpenGL状态机机制,使得不这样做,就会沿用上次的状态);
激活#0纹理单元并绑定到纹理单元,即第1轮并没有使用纹理单元;
启用颜色输出的模式为GL_FRONT,即第1轮绘制是禁用颜色输出的,所以实际上并没有绘制到光栅上,但建立了带深度信息的帧缓冲,而第2轮操作是肯定要颜色输出的,毕竟是要真实地显现物体位置关系和光照及阴影的。

第2轮操作:
使用vert2Shader.glsl和frag2Shader.glsl创建程序对象,初始化材质,初始化负相机位置矩阵、初始化光源位置、安装光源、构建MV视图矩阵,构建可以转换到纹理坐标空间的光照空间转换矩阵shadowMVPtwo。显然对于第1轮操作仅仅只是要得在光照空间中的带深度的帧缓冲,所以第1轮操作就不应该有材质、相机、光源(是模拟光源的LookAt矩阵,所以没有光源信息)、MV、shadowMVPtwo(在第2轮操作中才要用以在片段着色器中给textureProj()提供纹理坐标参数)。
第2轮操作中由于之前已主动中断帧缓冲区对象,并且没有再次进行绑定操作,所以这一轮的帧缓冲是没有带深度的纹理信息的,当然也不能有,在vert2Shader.glsl中有:shadow_coord=shadowMVPtwo×vec4(vertPos,1.0); 即计算得到光照空间中的模型坐标矩阵,在frag2Shader.glsl中有:float inShadow=textureProj(shadowTex,shadow_coord); textureProj()函数通过采样器变量利用纹理单元,通过从顶点着色器传递来的的经变换到符合纹理坐标范围并带有深度信息的vec4类型的变量shadow_coord,判断出某个像素是否在其他物体的阴影里面。我们知道shadow_coord是在GLSL代码中的光照空间中模型的规格化坐标,而shadowTex是模型在C++代码中创建的纹理对象,并在每次执行display()时都在第1轮操作前,绑定帧缓冲区并将带深度的纹理附加到帧缓冲区。在第1轮结束后,如果在光照空间中物体A的局部Apart在物体B的阴影下,那么在相机空间也一定是Apart在物体B的阴影下,因为光源与所有物体之间的相对位置是恒定的。

阴影贴图的伪影(阴影痤疮)

好消息是我们的金字塔现在在环面上投下阴影!坏消息则是,这种成功伴随着严重的伪影。有许多波浪线覆盖在场景中的表面。这是阴影贴图的常见副作用,称为阴影痤疮(shadow acne)错误的【自阴影】

阴影痤疮是由深度测试期间的舍入误差引起的。在阴影纹理中查找深度信息时计算的纹理坐标通常与实际坐标不完全匹配。因此,从阴影纹理中查找到的深度值可能并非当前渲染中像素的深度,而是相邻像素的深度。如果相邻像素在更远位置,则当前像素会被错误地显示为阴影。

幸运的是,阴影痤疮很容易修复。由于阴影痤疮通常发生在没有阴影的表面上,这里有个简单的技巧,在第 1 轮中将每个像素的深度值稍微加大一点点,之后在第 2 轮将它们移回原位。通常, 这么做足以补偿各类舍入误差。 简单地在 display()函数中调用glPolygonOffset()即可。

glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(2.0f, 4.0f);
passOne();
glDisable(GL_POLYGON_OFFSET_FILL);// 移回原位,与实际计算阴影有稍微差别也看不出来
深度偏移——glPolygonOffset()函数

void glPolygonMode(GLenum face, GLenum mode);
选择多边形光栅化模式
face - 指定mode应用于的多边形。正面和背面的多边形必须是GL_FRONT_AND_BACK。
mode - 指定多边形将如何栅格化。可接受的值为GL_POINT、GL_LINE和GL_FILL。对于正面和背面多边形,初始值均为GL_FILL。
glPolygonMode控制用于光栅化的多边形的解释。face描述应用于哪个多边形的mode:正面和背面多边形(GL_FRONT_AND_BACK)。多边形模式仅影响多边形的最终光栅化。特别是,在应用这些模式之前,多边形的顶点会被照亮,多边形会被剪裁并可能被剔除。

void glPolygonOffset(GLfloat factor, GLfloat units)
设置用于计算[深度值]的比例和单位。
此函数对于渲染隐藏线图像(hidden-line images)、将贴图应用于【曲面】以及渲染【具有亮显边的实体】非常有用
factor - 指定用于为每个多边形创建可变深度偏移的比例因子。初始值为0。
units - 乘以特定于实现的值以创建恒定的深度偏移。初始值为0。

启用GL_POLYGON_OFFSET_FILL(面), GL_POLYGON_OFFSET_LINE(线), 或 GL_POLYGON_OFFSET_POINT(点)时(通过glEnable()函数),每个片段的深度值将在从相应顶点的深度值插值后偏移。

这个函数主要是用于绘制hidden-line图片(比如墙后面的人,用线框表示),或者表面上的贴花,或者描边。


开启深度测试后OpenGL就不会再去绘制模型被遮挡的部分,这样实现的显示画面更为真实,但是由于深度缓冲区精度的限制(一般是每个像素采用16bit或者24bit来表示[深度值]),对于深度相差非常小的情况(例如在同一平面上进行两次绘制),OpenGL就不能正确判定两者的深度值,此时深度测试(GL_DEPTH_TEST)就出错了,显示出来的现象时交错闪烁的前后两个画面,这种情况称为z-fighting。

如果要高亮显示实体对象的边,可以尝试使用多边形模式GL_FILL绘制对象,然后使用多边形模式GL_LINE以不同的颜色再次绘制一遍。但是,由于直线和填充多边形的光栅化方式不完全相同,因此为直线上的像素生成的深度值通常与多边形边的深度值不同,即使在相同的两个顶点之间也是如此。高亮线可能会淡入或淡出重合多边形,这有时被称为“缝合(stitching)”,在视觉上令人不快。

使用“多边形偏移(polygon offset)”可以消除视觉上的不愉快,它会添加一个适当的偏移,以强制分离重合的z值,从而将多边形边与其高亮显示线清晰地分开。(“Stencil Test” in Chapter 10中描述的模板缓冲区也可用于消除缝合。然而,多边形偏移几乎总是比模板化快。)多边形偏移也可用于将贴图应用于曲面,通过删除隐藏线渲染图像。除了直线和填充多边形,这种技术还可以用于点。

下图演示了深度坡度(Depth Slopes)

OpenGL为你计算多边形的深度坡度,但了解深度坡度是什么很重要,以便为因子选择合理的值。深度坡度是在遍历多边形时,z(深度)值的变化除以x或y坐标的变化。深度值以窗口坐标为单位,固定在[0,1]范围内。要估计多边形的最大深度坡度(偏移方程式中的DZ),请使用以下公式:
DZ=max{∣∂V∂s∣,∣∂V∂t∣}(∂y∂x:y对x的偏导)DZ = max\{|\frac{∂V}{∂s}|,|\frac{∂V}{∂t}|\} (\frac{∂y}{∂x}:y对x的偏导) DZ=max{∣∂s∂V​∣,∣∂t∂V​∣}(∂x∂y​:y对x的偏导)

应对伪影的一种办法是使用glPolygonOffset给当前绘制对象设置一个深度偏移。
使用glPolygonOffset之前需要用glEnable(GL_POLYGON_OFFSET_FILL)开启深度偏移功能。
在执行深度测试和将值写入深度缓冲区之前添加偏移量。也就是说,在片段上添加偏移量,然后再进行深度测试且更新深度缓冲区。

通过函数void glPolygonOffset(GLfloat factor, GLfloat units)设置偏移量。
这个偏移量是偏移方程式 offset = DZ×factor + r×units 计算出来的:
DZ - 相对于多边形屏幕区域的深度变化的度量,是多边形的最大深度坡度。
r - 保证在窗口坐标深度值中产生可解析偏移的最小值,它是一个特定于实现的常量。
设原来某个像素的深度值为Dpixel,则添加偏移量后,这个像素的深度值变为Dpixel+offset。
无论怎样offset,depth值都会在[0, 1]区间。

要在没有视觉瑕疵的情况下实现高亮显示实体对象的良好渲染,可以向实体对象添加[正偏移](将其推开(push it away from you))或向线框添加[负偏移](将其拉向你(pull it towards you))。最大的问题是:“多少偏移量就足够了?”不幸的是,所需的偏移取决于各种因素,包括每个多边形的深度坡度和线框中线条的宽度。

对于平行于近剪裁平面和远剪裁平面的多边形,深度坡度为零(DZ=0)。对于场景中深度坡度接近零的多边形,只需要一个小的、恒定的偏移。要创建一个小的、恒定的偏移,可以将factor=0.0和units=1.0传递给glPolygonOffset()。

对于与剪裁平面成较大角度的多边形,深度坡度可能显著大于零,并且可能需要更大的偏移。factor的较小非零值,例如0.75或1.0,可能足以生成不同的深度值,并消除令人不快的视觉伪影。

注意这句话:“The offset is added before the depth test is performed and before the value is written into the depth buffer.”,即:“在执行深度测试和将值写入深度缓冲区【之前】添加偏移量。”
a.深度测试是不可编程阶段,是OpenGL自动完成的。但只有通过glEnable(GL_DEPTH_TEST);启用深度测试后,才进行深度比较并更新深度缓冲区,才能完成隐藏面消除。(见Blog:✠OpenGL-2-图像管线——深度测试)
b.深度值是片段离近平面的距离,取值为[Znear,Zfar]=[0,1]。
假设某个片段的深度值为Dpixel,经深度偏移后变为Dpixel+offset,深度值增加offset,体现在这个片段远离近平面offset距离。然后,手动启用深度测试,自动执行进行Z-Buffer算法,执行深度比较,生成深度缓冲区。

偏移量设置正数表示当前的深度更深一些,显示的时候会被前景覆盖;设为负数表示深度较浅,会被绘制到屏幕上去。
一个大于 0 的 offset 会把模型推到离你(观察者/摄像机)更远的位置,相应的一个小于 0 的 offset 会把模型拉近。

示例:(解决实体与亮边的显示关系)

glEnable(GL_LIGHTING);
glEnable(GL_LIGHT0);
glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(1.0, 1.0);
glCallList(list);
glDisable(GL_POLYGON_OFFSET_FILL);glDisable(GL_LIGHTING);
glDisable(GL_LIGHT0);
glColor3f(1.0, 1.0, 1.0);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
glCallList(list);
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);

以上代码,其中显示列表(可能绘制实体对象)首先使用照明、默认的GL_FILL多边形模式和多边形偏移(factor=1.0,units=1.0)进行渲染。这些值可确保偏移量足以容纳场景中的所有多边形,而不考虑深度坡度。(这些值实际上可能比所需的最小值稍微大一点,但过多的偏移比过少的偏移更不明显。)然后,为高亮显示第一个对象的边,禁用照明、禁用偏移,将第二个对象渲染为未点亮的线框。
分析:原本绘制的两个对象在线框处是重叠的;现在两次绘制之间,把第一个对象弄个深度偏移,使得绘制第二个对象时,就与第一个对象之间有个极微小的分离,从而消除相邻像素舍入误差导致的伪影。

原例中,在第1轮操作中(光照空间下),设置glPolygonOffset(2.0f, 4.0f); 即factor=2.0f、units=4.0f。功能就是将每个像素的深度值稍微加大一点,将每个像素稍微远离光源,那么启用深度测试生成的深度缓冲区中的每个像素深度值当然就会稍微变大了。在第2轮操作中(相机空间下),调用 glDisable(GL_POLYGON_OFFSET_FILL); 禁用深度偏移功能。这样两轮操作之间就有个深度偏移差存在。在第2轮用到了第1轮产生的深度缓冲区buf_round1,利用这个深度缓冲区来计算判断出阴影区域,原本(不进行深度偏移)如果第2轮像素在阴影里,则Dpixel_round2>Dbuf_round1;由于第1轮设置了所有深度值都有个正偏移量,导致生成的Dbuf_round1变大,就导致在第2轮稍微有些原本在阴影区的像素现在不处于阴影区了,阴影区稍微变小;但这么做有什么好处呢?这就要看本例中这么做的目的了,本例中是为了消除阴影痤疮,而阴影痤疮是错误的自阴影,是光照射到的面上产生的不应该有的自阴影现象。前文有提到:“阴影痤疮是由深度测试期间的舍入误差引起的。在阴影纹理中查找深度信息时计算的纹理坐标通常与实际坐标不完全匹配。因此,从阴影纹理中查找到的深度值可能并非当前渲染中像素的深度,而是相邻像素的深度。如果相邻像素在更远位置,则当前像素会被错误地显示为阴影。” 而我们已经知道,Dbuf_round1变大,原本在阴影区的像素就稍微有些不处于阴影区了,这样一来,光照射到的面上的自阴影自然就没了,因为这个自阴影的由于深度偏移差值导致阴影阈值变小,这不会影响原本并没有阴影的片段,并且将自阴影给消失掉;而毕竟自阴影只是极小的舍入误差产生的,所以只需深度稍微有个偏移量,就足以补偿各类舍入误差,与实际计算阴影有稍微差别也看不出来。

假设原例中的深度偏移是个负值,会怎样呢?会导致生成的Dbuf_round1变小,就导致在第2轮稍微有些原本不在阴影区的像素现在处于阴影区了,阴影区稍微变大,这不会影响原本有阴影的片段,所以阴影痤疮不会消失,但因为产生更多阴影,情况就可能更严重了。如下图:

视口变化对阴影的影响


示例代码保持不变。窗口高度固定不动。
随着窗口逐渐变宽,导致透视参数aspect的值逐渐变大,呈现效果如下图:

在不改变视口尺寸的情况下,如果固定增大aspect值,则随着设定的aspect值的变大,除了整体显示的变形,对阴影没影响。
如果注释掉window_size_callback()函数体中的glViewport(0, 0, newWidth, newHeight);则不会出现上述任何问题(只不过是随着窗口的大小变化,物体不会居中显示且会变形拉伸而已,但阴影的状态正常。)
也就是说,以上的错误阴影是由于glViewport()这个函数直接引起的。那么我们再仔细看看这个函数的作用。

void glViewport(GLint x, GLint y, GLsizei width, GLsizei height)
x,y - 指定视口矩形的左下角(以像素为单位)。初始值为(0,0)。
width,height - 指定视口的宽度和高度。当GL上下文第一次附着到窗口时,宽度和高度将设置为该窗口的尺寸。
功能:设置视口(viewport)————指定x和y从标准化设备坐标(normalized device coordinates)到窗口坐标(window coordinates)的仿射变换。
标准化设备坐标NDC:是一个x、y在[-1.0…1.0],z在[0…1]的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。
设(Xnd, Ynd)为标准化设备坐标。然后窗口坐标(Xw,Yw)计算如下:
Xw=(Xnd + 1) × width / 2 + x
Yw=(Ynd + 1) × height / 2 + y
视口的宽度和高度会自动钳制到一个范围,该范围取决于实现。

由于Xnd、Ynd∊[-1, 1],所以Xnd+1、Ynd+1∊[0, 2],x,y取初始值(0,0),则Xw∊[0, width]、Yw∊[0, height]。这样就完成了(Xnd,Ynd)到(Xw,Yw)的“仿射”。

如果不调用glViewport(0, 0, newWidth, newHeight);,即不从标准化设备坐标(NDC)仿射变换到窗口坐标,则阴影没问题。
但图中阴影效果也实在猜不着啊。其实,问题出在glTexImage2D()函数上。

void glTexImage2D( GLenum target, 指定目标纹理。GLint level, 指定详细级别编号。级别0是基本图像级别。第n级是第n幅mipmap约简图像。GLint internalformat, 指定纹理中颜色组件的数量。如GL_RED、GL_R16、GL_COMPRESSED_RGBA等等。GLsizei width, 指定纹理图像的宽度。所有的实现都支持至少1024纹素宽的纹理图像。GLsizei height, 指定纹理图像的高度,或纹理数组中的层数等。GLint border, 这个参数必须是0。GLenum format, 指定像素数据的格式。GLenum type, 指定像素数据的数据类型。const void * data 指定一个指向内存中的图像数据的指针。);作用:指定二维纹理图像。将图像文件(data)数据复制到纹理对象中。

我们在init()中调用setupShadowBuffers()函数,而setupShadowBuffers()函数中指定了二维纹理图像,这个纹理图像有众多参数,其中就有widthheight,它们用于设置纹理图像的宽度和高度。在示例中使用的是初始窗口的宽高。但之后手动改变窗口大小,却没有重新设置这个纹理图像的宽高,就导致阴影贴图出现错误。所以解决办法是:将glTexImage2D()函数放在window_size_callback()函数体中;这样一来,无论怎样拖动改变窗口尺寸,glViewPort()调整了视口尺寸,而这个尺寸刚好为纹理图像的尺寸,上图中阴影错误就都不再出现了。
注意:当窗口的尺寸改变时,也需要产生一个的相应的深度纹理尺寸。

阴影间隙、重复阴影、阴影条、锯齿状阴影边缘

虽然修复阴影痤疮很容易,但有时修复会引起新的伪影。在第 1 轮之前移动对象的“技巧”有时会导致在对象阴影中出现间隙。这种伪影通常被称为“Peter Panning”,因为有时它会导致静止物体的阴影与物体底部分离的问题(从而使物体的阴影部分与阴影的其余部分分离)。修复此伪影需要调整glPolygonOffset()的参数。如果它们太小,就会出现阴影痤疮;如果太大,则会出现 Peter Panning。

在实现阴影贴图时可能会发生许多其他伪影。如重复阴影,因为在第 1 轮(存入阴影缓冲区时)渲染的场景区域与第 2 轮中渲染的场景区域不同(来自不同的观察位置)。这种差异可能导致在第 2 轮中渲染的场景中,某些区域尝试使用范围[0…1]之外的特征坐标来访问阴影纹理。回想一下,在这种情况下默认行为是 GL_REPEAT,因此,这可能导致错误的重复阴影。

详见:✠OpenGL-5-纹理贴图——环绕和平铺

一种可能的解决方案是将以下代码行添加到 setupShadowBuffers(), 将纹理换行模式设置为“夹紧到边缘”。

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

这样纹理边缘以外的值会被限制为边缘处的值(而非重复)。注意,这种方法自身也有可能造成伪影,即当阴影纹理的边缘处存在阴影时,截取边缘可能产生延伸到场景边缘的“阴影条”。

另一种常见错误是锯齿状阴影边缘。当投射的阴影明显大于阴影缓冲区可以准确表示的阴影时,就有可能出问题。通常,这取决于场景中物体和灯光的位置。尤其当光源在距离物体较远时,更容易发生。

消除锯齿状阴影边缘就没有处理之前的伪影那么简单了。一种技术是在第 1 轮期间将光位置移动到更接近场景的位置,然后在第 2 轮放回原始位置。另一种常用的有效方法则是我们将在下面讨论的“柔和阴影”方法之一。

柔和阴影

目前我们所展示的阴影生成方法都仅限于生成硬阴影,即带锐边的阴影。但是,现实世界中出现的大多数阴影都是柔和阴影。它们的边缘都会发生不同程度的模糊。

对于柔和阴影,要注意:

  • 离物体越远的阴影越“柔和”,离物体越近的阴影越“硬”。
  • 距离物体越近的阴影显得越暗。

光源本身的维度会导致柔和阴影。 如下图所示, 光源上各处会投射出略微不同的阴影。各种阴影不同的区域称为半影(penumbra),包括阴影边缘的柔和区域。

生成柔和阴影——百分比邻近滤波(PCF)

最简单也最常见的一种方法叫作百分比邻近滤波(Percentage Closer Filtering, PCF)。在 PCF 中,我们对单个点周围的几个位置的阴影纹理进行采样,以估计附近位置在阴影中的百分比。根据附近位置在阴影中的数量,对正在渲染的像素的光照分量进行修改。整个计算可以在片段着色器中完成。PCF 还可用于减少锯齿线伪影。

以黄色突出显示的特定像素不在阴影里面,在高亮像素的 9 个像素邻域中, 3 个像素处于阴影中而 6 个像素处于阴影外。因此,渲染像素的颜色可以被计算为【该像素处的总环境光分量加上漫反射和镜面反射分量的(6/9=66.7%)】,即 Iambient+(Idiffuse+Ispecular)*66.7%,这样会使像素一定程度(但不是完全)变亮。

如果颜色值为总环境光(全局环境光+材质环境光),则就是在遮挡阴影区里面的物体表面颜色;
如果采样颜色是总环境光+漫反射光+镜面反射光,则就是不在遮挡阴影区里面的物体表面颜色。

根据光特性和物体表面材质及光射线角度等在被遮挡的不同阴影区会产生不同的颜色效果,然后就用此效果,然后由于是进行采样,所以这个阴影颜色应该应该大于0%小于100%,这个阴影颜色是总环境光基础上叠加采样百分比的漫反射和镜面反射之和。

如果采样百分比为100%,则此像素本身就不处于遮挡阴影里面;颜色就等于物体表面颜色。
如果采样百分比为0%,则此像素本身就处在遮挡阴影里面;无需任何改变。
所以百分比越大,阴影越接近未遮挡时的颜色,阴影越“柔和”,以至于没有;百分比越小阴影越“硬”,以至于就是原本的阴影(最暗)。这样才满足柔和阴影的设定。

在整个网格中重复此过程将会产生上图所示的像素颜色。注意,对于那些邻域完全位于阴影中(或阴影外)的像素,生成的颜色与标准阴影贴图相同。

与上例不同的是,在 PCF 的实现中,不是对渲染像素临近区域内的每个像素进行采样。这有两个原因:
(a)我们想在片段着色器中执行此计算,但片段着色器无法访问其他像素;
(b)获得足够宽的半影效果(例如,10~20像素宽)将需要为每个被渲染的像素采样数百个附近的像素。

PCF 解决了以下两个问题:
首先,我们不试图访问附近的像素,而是在阴影贴图中对附近的纹素进行采样。片段着色器可以执行此操作,因为虽然它无法访问附近像素的值,但它可以访问整个阴影贴图。
其次,为了获得足够宽的半影效果,需要对附近一定数量的阴影贴图纹素进行采样,每个采样的纹素都距离所渲染像素的纹素一定距离。

柔和阴影的准确度或平滑度取决于所采样附近纹素的数量。因此,在性能和质量之间需要权衡——采样点越多, 效果越好, 但计算开销也越大。场景的复杂性和给定应用所需的帧率对于阴影可实现的质量有着相应的限制。每像素采样 64 个点(如下图所示效果)通常是不切实际的。

一种用于实现 PCF 的常见算法是对每个像素附近的 4 个纹素进行采样, 其中样本通过指定从像素对应纹素的偏移量选择。对于每个像素,我们都需要改变偏移量,并用新的偏移量确定采样的 4 个纹素。用交错方式改变偏移量的方法被称为抖动,它旨在使得柔和阴影边界不会由于采样点不足看起来“结块”。

一种常见的方法是假设有 4 种不同偏移模式,每次取其中一种——我们可以通过计算像素的 gl_FragCoord 对 2 取模来选择当前像素的偏移模式。内置输出变量 gl_FragCoord 是 vec2 类型,包含像素位置的 x、y 坐标。因此, mod 计算的结果有 4 种可能的值:(0,0)、(0,1)、(1,0)或(1,1)。我们使用 glFragCoord mod 2 的结果来从纹素空间(即阴影贴图)4 种不同偏移模式中选择一种。

genType mod(genType x, float y)
计算一个参数对另一个参数的模值。
返回 x 对 y 取模的值。算法:x - y * floor(x/y)

如果x是vec2类型,假设x=(m,n)、y=2,则mod(x,y)=(m%2,n%2),可能值有4种:(0,0)、(0,1)、(1,0)、(1,1)。

偏移模式通常在 x 和 y 方向上指定,具有−1.5,−0.5,+0.5 和+1.5 的不同组合;
如果要进行缩放,设缩放因子是S,则偏移模式在 以sx*S 和 sy*S为原点,指定组合为-1.5*S,-0.5*S,+0.5*S和+1.5*S。
更具体来说,由 gl_FragCoord mod 2 计算得到的【每种情况】的 4 种【常用】偏移模式是:

其中(Sx, Sy)是与正在渲染的像素相对应的阴影贴图中的位置,那么Sx、Sy∈[0,1],在代码示例中标识为 shadow_coord,它是阴影贴图中与正在渲染的像素相对应的纹素的位置——在下图中显示为白色圆圈。

从上面可以很明显4种模式的变换关系:
模式(0,0) → Y轴方向减1 → 模式(0,1)
模式(0,0) → X轴方向加1 → 模式(1,0)
模式(0,0) → X轴方向加1,Y轴方向减1 → 模式(1,1)

每4个采样点刚好构成一个边长为2的正方形。
所有采样点坐标绝对值|x|、|y|,最大为1.5,最小为0.5。
4种模式下,白圆○分别位于形成的正方形的:右下角、右上角、左下角、左上角。

所以只记住第一种模式的左上角点位置(-1.5,1.5),然后根据模式变换关系及采样点规律,其他的就很容易可以推出来了。

对于偏移模式(0,0),设缩放因子为S,则只要满足如下式子:
vec2 o = mod(gl_FragCoord.xy, 2.0) * S; (这时 o 有4种可能取值: (0,0)、(0,S)、(S,0)、(S,S))
(Xoffset,Yoffset)分别取下列的值:
(-1.5S + o.x, 1.5S - o.y)
(-1.5S + o.x, -0.5S - o.y)
( 0.5S + o.x, 1.5S - o.y)
( 0.5S + o.x, -0.5S - o.y)
则,o 的4种可能取值刚好与4种模式对应。
证明:
➀o=(0,0),4个采样点偏移量分别为(-1.5S,1.5S)、(-1.5S,-0.5S)、(0.5S,1.5S)、(0.5S,-0.5S),满足模式一。
➁o=(0,S),4个采样点偏移量分别为(-1.5S,0.5S)、(-1.5S,-1.5S)、(0.5S,0.5S)、(0.5S,-1.5S),满足模式二。
➂o=(S,0),4个采样点偏移量分别为(-0.5S,1.5S)、(-0.5S,-0.5S)、(1.5S,1.5S)、(1.5S,-0.5S),满足模式三。
➃o=(S,S),4个采样点偏移量分别为(-0.5S,0.5S)、(-0.5S,-1.5S)、(1.5S,0.5S)、(1.5S,-1.5S),满足模式四。

对于偏移模式(0,1),(Xoffset,Yoffset)分别取下列的值:
(-1.5S + o.x, 0.5S + o.y)
(-1.5S + o.x, -1.5S + o.y)
( 0.5S + o.x, 0.5S + o.y)
( 0.5S + o.x, -1.5S + o.y)
则,o 的4种可能取值刚好与4种模式对应。
证明:
➀o=(0,0),4个采样点偏移量分别为(-1.5S,0.5S)、(-1.5S,-1.5S)、(0.5S,0.5S)、(0.5S,-1.5S),满足模式二。
➁o=(0,S),4个采样点偏移量分别为(-1.5S,1.5S)、(-1.5S,-0.5S)、(0.5S,1.5S)、(0.5S,-0.5S),满足模式一。
➂o=(S,0),4个采样点偏移量分别为(-0.5S,0.5S)、(-0.5S,-1.5S)、(1.5S,0.5S)、(1.5S,-1.5S),满足模式四。
➃o=(S,S),4个采样点偏移量分别为(-0.5S,1.5S)、(-0.5S,-0.5S)、(1.5S,1.5S)、(1.5S,-0.5S),满足模式三。

对于其他偏移模式(1,0)和(1,1)是一样的道理。只是要注意是统一 +o.x 还是 -o.x、是统一 +o.y 还是 -o.y。

总之,是可以通过指定的某一种模式,可刚好凑齐4种模式。对其中任何一种偏移模式,都可获取采样的4个点。

假设正在渲染的像素位于 gl_FragCoord=(48,13)。首先我们确定像素在阴影贴图的 4 个采样点。 为此, 我们将计算 vec2(48,13) mod 2=(0,1)。因此我们选择(0,1)所对应的偏移,在上图中以绿色显示,并且在阴影贴图对相应的点进行采样(假设没有指定偏移的缩放量),得到第2种偏移模式如下:

(shadow_coord.x–1.5, shadow_coord.y+0.5)
(shadow_coord.x–1.5, shadow_coord.y–1.5)
(shadow_coord.x+0.5, shadow_coord.y+0.5)
(shadow_coord.x+0.5, shadow_coord.y–1.5)

即根据 vec2 mod 2 的值,一次采样4个点,分布xy坐标系的是左上、左下、右上、右下角的点。

接下来, 对我们选取的这 4 个点分别调用 textureProj(), 在每种情况下都返回 0.0 或 1.0,具体取决于该采样点是否在阴影中。将 4 个结果(0或1)相加并除以 4.0,就可以确定阴影中采样点的百分比。然后将此百分比用作乘数,确定渲染当前像素时要应用的漫反射和镜面反射分量。

尽管采样尺寸很小——每个像素只有 4 个样本——这种抖动方法通常可以产生好得惊人的柔和阴影。虽然它不如之前图示的 64 点采样版本好,但渲染速度要快得多。

柔和阴影/PCF程序

柔和阴影计算可以完全在片段着色器中完成。

#version 430
// 所有变量定义未改动
...
layout (binding = 0) uniform sampler2DShadow shadowTex;// 同之前的代码。阴影纹理单元(包含深度和位置信息)
in vec4 shadow_coord;// 同之前的代码。它是阴影贴图中与正在渲染的当前像素相对应的值
// 在阴影纹理中以指定偏移量(ox, oy)进行查找;ox,oy单位是像素。
// 偏移量需要乘以 1/windowsize,这里我们简单地假设窗口大小为1000×1000像素,将乘数硬编码为 0.001。
float lookup(float ox, float oy) {float t = textureProj(shadowTex, shadow_coord + vec4(ox * 0.001 * shadow_coord.w, oy * 0.001 * shadow_coord.w, -0.01,// 是用于消除阴影痤疮的偏移量0.0));return t;// 0:在阴影里;1:不在阴影里
}
void main() {float shadowFactor = 0.0;// ... vec3 L、N、V、H// 生成一个4采样抖动的柔和阴影// S:缩放因子,可调整的阴影扩散量,用于调整阴影边缘的“柔和”区域的大小。// S的选值取决于场景;对于环面/金字塔示例,它的值为 2.5;对于海豚场景,它的值为 8。float S= 2.5;// 根据glFragCoord mod 2 生成4采样模式中的一个,本例硬编码为偏移模式(0,0)vec2 o = mod(floor(gl_FragCoord.xy), 2.0) * S;// =(0,0)或(0,S)或(S,0)或(S,S)shadowFactor += lookup(-1.5*S + o.x,  1.5*S - o.y);shadowFactor += lookup(-1.5*S + o.x, -0.5*S - o.y);shadowFactor += lookup( 0.5*S + o.x,  1.5*S - o.y);shadowFactor += lookup( 0.5*S + o.x, -0.5*S - o.y);shadowFactor = shadowFactor / 4.0; // 采样点的百分比,是4个采样点的平均值vec4 shadowColor = globalAmbient * material.ambient + light.ambient * material.ambient;// 阴影颜色(总环境光颜色)// 被“点亮”的像素颜色(漫反射叠加上镜面反射的颜色)vec4 lightedColor = light.diffuse * material.diffuse * max(dot(L, N), 0.0) + light.specular * material.specular *pow(max(dot(H, N), 0.0), material.shininess * 3.0);fragColor = vec4(shadowColor.xyz + shadowFactor * lightedColor.xyz, 1.0);
}

注意:
GLSL 函数的例子(除了“ main”)。与在 C 语言中一样,必须在调用它们之前(或“上方”)定义函数,否则必须提供前向声明。在该示例中则不需要前向声明,因为函数定义在调用代码上方。

运行效果如下图:

理解PCF程序代码

我们知道textureProj()函数用于通过投影矩阵从阴影纹理中查找值。它的第二个参数是vec4类型,是指定的纹理采样的纹理坐标。
官方解释:“The texture coordinates consumed from P, not including the last component of P, are divided by the last component of P. ”
即:使用纹理查找期间,它的纹理坐标各分量(除了最后一个分量)会除以最后一个分量。
—————————————————————————————————————
分析:shadow_coord+vec4(ox×0.001×shadow_coord.w, oy×0.001×shadow_coord.w, -0.01, 0.0)

我们简单地假设窗口大小为 1000像素×1000像素。
1 / windowsize:1单位长度/1000像素,即每个像素占用多少个OpenGL环境的横/纵向单位长度。
ox × 0.001:每个像素占用0.001个横向单位长度,偏移ox个像素占用的ox × 0.001个单位长度。偏移量的单位是:像素。

vec4中第1、2个分量,偏移量为什么要乘以shadow_coord.w?就是因为“使用纹理查找时,它的纹理坐标各分量(除了最后一个分量)会除以最后一个分量。”,也就是说textureProj()函数在执行过程中会除以vec4类型参数的第四个分量。如果没有偏移量,则通过shadow_coord查找纹理过程中,shadow_coord会变成(shadow_coord.x/shadow_coord.w, shadow_coord.y/shadow_coord.w, shadow_coord.z/shadow_coord.w, shadow_coord.w),所以我们在加上偏移量时,偏移量必须要有shadow_coord.w这个因子,否则我们原本希望的偏移量ox×0.001就变成了ox×0.001/shadow_coord.w,而这不是我们想要的偏移量,程序运行就呈现出不可预测的效果。

vec4中第3个分量(-0.01)是用于消除阴影痤疮的偏移量;注意,这个分量也是要除以shadow_coord.w的,但代码中并没有给它乘以shadow_coord.w。不过无论如何,既然是减法,那么shadow_coord的z分量最终值肯定是变小的。我们首先得搞清楚,是谁和谁在作比较。在第1轮生成具有深度的纹理,这个纹理被复制到阴影纹理单元,从而可以通过采样器变量进行查找采样。而我们是在第2轮修改了shadow_coord.z的值,这个值是要去和深度纹理中的深度值去比较的;而具有深度的纹理是直接拿来用的,也不可能修改(通过第1轮将深度信息附加到帧缓冲区完成的);假设未修改shadow_coord.z时,shadow_coord.z>深度纹理中的深度值,则对应像素在阴影里;现在shadow_coord.z变小,则原本在阴影里的像素可能现在不在阴影里了,即阴影区变小了,这样就可以补偿舍入误差,让阴影痤疮消失。所以vec4中第3个分量得是一个负数,一个合适的较小负数。从实际测试中会发现,如果vec4中第3个分量(-0.01)改成-0.09就没有任何阴影了。

vec4中第4个分量(0.0),当然必须设置为0,否则就影响到原shadow_coord的w分量了;只有设置为0才不会影响shadow_coord的w分量。我们只是在x、y方向偏移采样4个点,w分量的事情,系统在透视分割时会作处理,所以我们肯定是不能影响这个分量的。

理解lookup()参数列表的指定

如下图,模式(0,0)是蓝色4个采样点,模式(0,1)是绿色4个采样点,模式(1,0)是黄色4个采样点,模式(1,1)是红色4个采样点。对每个模式都有4个采样点(左上点、左下点、右上点、右下点)。

对比由前面给出的列表,用采样模式(0,0)时:
shadowFactor += lookup(-1.5×S+ o.x, 1.5×S- o.y);(1左上点)
shadowFactor += lookup(-1.5×S+ o.x, -0.5×S- o.y);(2左下点)
shadowFactor += lookup( 0.5×S+ o.x, 1.5×S- o.y);(3右上点)
shadowFactor += lookup( 0.5×S+ o.x, -0.5×S- o.y);(4右下点)
因为 o = mod(gl_FragCoord.xy, 2.0) * S<=> S×[0|1]xy; 对于(1左上点)、(2左下点)、(3右上点)、(4右下点),lookup参数列表为:
(-1.5×S+ [0|1]x×S, 1.5×S- [0|1]x×S)
=S×(-1.5 + [0|1]x, 1.5 - [0|1]y)
=S×(-1.5, 1.5)
(-1.5×S+ [0|1]x×S, -0.5×S- [0|1]_y×S)
=S×(-1.5 + [0|1]x, -0.5 - [0|1]y)
=S×(-1.5, -0.5)
(0.5×S+ [0|1]_x×S, 1.5×S- [0|1]_y×S)
=S×(0.5 + [0|1]x, 1.5 - [0|1]y)
=S×(0.5, 1.5)
(0.5×S+ [0|1]_x×S, -0.5×S- [0|1]_y×S)
=S×(0.5 + [0|1]x, -0.5 - [0|1]y)
=S×(0.5, -0.5)
有模式(0,0)下的4个采样与最左侧栏完全相符,就是多了一个因子S。

要满足可以提取公因子S,o也得乘以这个公因子S!

对于其他三种模式,只要按上述列表中给出对应系数的值,就可以得到对应模式下的四个采样点。
下图是在模式(0,0)下,S=2时的情况,图中蓝色采样点变到相应的粉色采样点,S越大,越朝紫色箭头方向扩大:

从上图中可以看出,S这个因子越大,同样取4个采样点的情况下,它取样的半径越大,所以S是可调整的阴影扩散量,用于调整阴影边缘的“柔和”区域的大小。
S的选值取决于场景,对于环面/金字塔示例,它的值为 2.5;对于海豚场景,它的值为8。

通过偏移量来查找纹素:
lookup参数中ox=A×S+ o.x,oy=B×S- o.y,其中x方向偏移量是【加】o.x个像素,而y方向偏移量是【减】o.y个像素。
lookup(ox, oy),即是查找偏移量是(ox, oy)处的纹素。这样就是在点shadow_coord附近偏移(ox, oy)定位到纹理中采样点,从而判断这个采样点是否被阴影遮挡。

如果现在就是要在上述基础上产生“硬”阴影,怎么处理?
只需要这样即可:shadowFactor = lookup(0.0, 0.0);

生成64采样的高分辨率柔和阴影
float S= 2.5;
float endp = S * 3.5;
for (float m = -endp; m <= endp; m += S) {for (float n = -endp; n <= endp; n += S) {shadowFactor += lookup(m, n);}
}
shadowFactor = shadowFactor / 64.0;

-endp =< m <= endp,即m={-3.5,-2.5,-1.5,-0.5,+0.5,+1.5,+2.5,+3.5},刚好水平8个点;两层for循环,则是8×8=64个采取点。

在 64 采样代码中, S 比例因子用作嵌套循环中的步长,其采样距离正被渲染的像素的不同距离处的点。例如,当使用代码中的 S 值(2.5)时,程序将沿着每个轴在两个方向上以 1.25、 3.75、 6.25 和 8.75 的距离选择采样点——然后根据窗口大小进行缩放(如前所述)并用作纹理坐标采样阴影纹理。在这么多采样的情况下,通常不需要使用抖动来获得更好的结果。

64=8×8,要想x轴方向有8列点,y轴方向也有8行点,那么形成的点阵列长和高都为7×S(一段长度有两个点),由于用嵌套循环完成,并且中心点在原点,则最左边点的x值和最下边点的y值都为-3.5×S,最右边点x值和最上边点y值都为3.5×S。
当S=2.5时,点阵如下图:

补充

我们仅给出了 3D 图形中阴影世界的最基本介绍。在更复杂的场景中,即便使用本章提供的基础阴影贴图方法,也可能需要进行进一步的研究。例如,当场景中的某些对象拥有纹理的情况下,添加阴影时必须确保片段着色器正确区分阴影纹理和其他纹理。一种简单的方法是将它们绑定到不同的纹理单元,例如:

layout (binding = 0) uniform sampler2DShadow shTex;
layout (binding = 1) uniform sampler2D otherTexture;

然后, C++ / OpenGL 应用程序可以通过它们的绑定值来引用两个采样器。

当场景使用多个灯光时,则需要多个阴影纹理——每个光源需要一个阴影纹理。此外,每个光源都需要单独执行第 1 轮渲染,并在第 2 轮渲染中合并结果。

尽管我们在阴影贴图的每个阶段都使用了透视投影,但值得注意的是,当光源是远距离光源和定向光源而非我们使用的位置光时,正射投影(而非透视投影)通常才是首选。

✠OpenGL-8-阴影相关推荐

  1. OpenGL 点光源阴影Point Shadows

    OpenGL点光源阴影Point Shadows 点光源阴影Point Shadows简介 生成深度立方体贴图 光空间的变换 深度着色器 万向阴影贴图 显示立方体贴图深度缓冲 PCF 点光源阴影Poi ...

  2. openGL实现阴影映射(Shadow Mapping)

    openGL系列文章目录 文章目录 openGL系列文章目录 前言 阴影映射 阴影映射原理 二.使用步骤 显示效果 源码下载 参考 前言 阴影是光线被阻挡的结果:当一个光源的光线由于其他物体的阻挡不能 ...

  3. OpenGL Deferred Shading延迟阴影实例

    OpenGL 延迟阴影实例 先上图,再解答. 按下2键 按下5键 完整主要的源代码 源代码剖析 先上图,再解答. 按下2键 按下5键 完整

  4. The OpenGL® Shading Language, Version 4.60.7 翻译第一章

    The OpenGL® Shading Language, Version 4.60.7 翻译第一章 Chapter 1. Introduction This document specifies o ...

  5. Windows OpenGL 图像色调

    目录 一.OpenGL 图像色调调节 1.原始图片 2.效果演示 二.OpenGL 图像色调调节源码下载 三.猜你喜欢 零基础 OpenGL ES 学习路线推荐 : OpenGL ES 学习目录 &g ...

  6. Windows OpenGL 图像绿幕抠图

    目录 一.OpenGL 图像绿幕抠图 1.原始图片 2.效果演示 二.OpenGL 图像绿幕抠图源码下载 三.猜你喜欢 零基础 OpenGL ES 学习路线推荐 : OpenGL ES 学习目录  & ...

  7. OpenGL完整教程专栏完整目录

    OpenGL完整教程专栏完整目录 专栏说明如下 专栏目录 专栏说明如下 内容:OpenGL完整教程 数量:314篇博文(2023年2月15日截止) 更新时间至:2023年2月15日(后续加上去的博文, ...

  8. OpenGL 图像白平衡色温

    目录 一.OpenGL 图像白平衡色温 1.IOS Object-C 版本 1.Windows OpenGL ES 版本 2.Windows OpenGL 版本 二.OpenGL 图像白平衡色温 GL ...

  9. Windows OpenGL 图像白平衡色温

    目录 一.OpenGL 图像白平衡色温 1.原始图片 2.效果演示 二.OpenGL 图像白平衡色温源码下载 三.猜你喜欢 零基础 OpenGL ES 学习路线推荐 : OpenGL ES 学习目录 ...

  10. OpenGL 图像绿幕抠图

    目录 一.OpenGL 图像绿幕抠图 1.IOS Object-C 版本 1.Windows OpenGL ES 版本 2.Windows OpenGL 版本 二.OpenGL 图像绿幕抠图 GLSL ...

最新文章

  1. 怎么讲d 盘里的软件弄到桌面_教大家电脑怎么把e盘文件移到d位置
  2. python利器app-有了这个神器,轻松用 Python 写 APP !
  3. mysql-5.7.17-winx64的安装配置
  4. 微信支付8·8无现金日超1亿人次、近70万家门店参与
  5. linux 系统怎么安装vnc,在Linux系统中安装和使用VNC
  6. 超级终端软件哪个好_同城配送软件哪个好?如何选择配送软件?
  7. 树莓派进阶之路 (029) - 语音识别模块 LD3320(原创)
  8. 【Linux】Linux内核的整体架构简介
  9. Meta最快明年推出智能手表 挑战下一代Apple Watch
  10. ERP实施--常见问题
  11. python职业发展规划书范文_职业生涯规划书范文 3篇
  12. USACO stamps
  13. 计算机重装系统怎么链接打印机,如何连接打印机,详细教您电脑如何与打印机连接...
  14. WordStream:谷歌广告各指标分析
  15. 现有的数字版权保护大全
  16. mac 类似matlab,10款Mac上科研人员必备的科研工具推荐和下载 | 玩转苹果
  17. GM8913型DC平衡双向控制器LVTTL转FPD-LINK
  18. Scala入门_开发环境安装
  19. 形态学重建之孔洞填充
  20. 如何用 Python 和 API 收集与分析网络数据?

热门文章

  1. springboot+shiro前后端分离过程中跨域问题、sessionId问题、302鉴权失败问题
  2. 2.4万余门课程在线学,22家平台面向全国高校开放
  3. 制造业ERP系统是什么?制造业ERP软件系统有哪些功能
  4. 大话西游之GUI编程—(—)DOS时代
  5. 26 欧拉积分疑惑解析
  6. OSC源创会(西安)图文总结
  7. jenkins+maven+docker java项目编译、打包、构建镜像、上传私有仓库、web容器部署
  8. oracle rownum left join,SQL常用指令集(Oracle)
  9. 金美信消费金融AB面:大赚之下的超高利率“怪圈”
  10. bugku web18 秋名山车神