目录
由下图可以看到阴影的重要性:
投影阴影
一种很适合在地平面上绘制阴影,又相对不需要太大计算代价的方法,叫作投影阴影(projective shadows)
。
给定一个位于(xL, yL, zL)的点光源、一个需要渲染的物体以及一个投射阴影的平面,可以通过生成一个变换矩阵,将物体上的点(xw, yw, zw)变换为相应阴影在平面上的点(xs, 0, zs)。之后将其生成的“阴影多边形”绘制出来,通常使用暗色物体与地平面纹理混合作为其纹理,如下图所示。
使用投影阴影进行投射的优点是它的高效和易于实现。但是,它仅适用于平坦表面——这种方法无法投射阴影于曲面或其他物体。即使如此,它仍然适用于有室外场景并对性能要求较高的应用,很多游戏中的场景都属于这类。
阴影体
先找到被物体阴影覆盖的阴影体,之后减少视体与阴影体相交部分中的多边形的颜色强度。如下图展示了阴影体中的立方体,因此,立方体绘制时会更暗。
阴影体
的优点在于其高度准确,比起其他方法来更不容易产生伪影。但是,计算出阴影体以及每个多边形是否在其中这件事,即使对于现代 GPU 来说,计算代价也很大。几何着色器可以用于计算阴影体, 模板缓冲区可以用于判断像素是否在阴影体内。有些显卡对于特定的阴影体操作优化提供了硬件支持。
阴影贴图
深度贴图(Depth Map)
也称阴影贴图
是用于投射阴影的实用且流行的方法,虽然并不总是像阴影体一样准确(且通常伴随着讨厌的伪影),但实现起来更简单,可以在各种情况下使用,并享有强大的硬件支持。
阴影贴图基于一个非常简明的想法: 光线无法“看到”的任何东西都在阴影中。也就是说,如果对象 1 阻挡光线到达对象 2,等同于光线不能“看到”对象 2。
这个想法的强大之处在于我们已经有了方法来确定物体是否可以被“看到”——使用 Z-buffer算法。因此,计算阴影的策略是,暂时将相机移动到光的位置,应用 Z-buffer 算法,然后使用生成的深度信息来计算。
因此,渲染场景需要两轮:
- 第 1 轮从光源的角度渲染场景( 但实际上没有将其绘制到屏幕上);
- 第 2 轮从相机的角度渲染场景。
第 1 轮的目的是从光源的角度生成深度缓冲区。完成第 1 轮之后,我们需要保留深度缓冲区并使用它来帮助我们在第 2 轮生成阴影。第 2 轮实际绘制场景。
我们的策略可以更加精练:
- (第 1 轮)从光源的位置渲染场景。对于每个像素,深度缓冲区包含光源与最近的对象之间的距离。
- 将深度缓冲区复制到单独的“阴影缓冲区”。
- (第 2 轮)正常渲染场景。对于每个像素,在阴影缓冲区中查找相应的位置。如果相机到渲染点的距离大于从阴影缓冲区检索到的值, 则在该像素处绘制的对象离光源的距离比当前离光源最近的对象离光源更远,因此该像素处于阴影中。
当发现像素处于阴影中时,我们需要使其更暗。 一种简单而有效的方法是仅渲染环境光,忽略漫反射和镜面反射分量。
上述方法通常称为“阴影缓冲区”。而:
(a) 将深度缓冲区复制到纹理中的过程称为“阴影贴图
”;
(b) 当纹理对象用于储存阴影深度信息时,我们称其为阴影纹理
。
OpenGL 通过 sampler2DShadow
类型支持阴影纹理。这样,我们就可以利用片段着色器中纹理单元和采样器变量(即“纹理贴图”)的硬件支持功能,在第 2 轮快速执行深度查找。我们现在修改的策略是:
- (第 1 轮)与之前相同;
- 将深度缓冲区的内容复制到纹理对象;
- (第 2 轮)与之前相同,不过阴影缓冲区变为阴影纹理。
阴影贴图(第 1 轮)——从光源位置“绘制”物体
在第 1 步中,我们首先将相机移动到光源的位置,然后渲染场景。我们的目标不是在显示器上实际绘制场景,而是完成足够的渲染过程以正确填充深度缓冲区。因此,没有必要为像素生成颜色,我们第 1 轮将仅使用顶点着色器,而片段着色器不执行任何操作。
在我们的场景中,我们通常会将相机从光源指向原点。
第 1 轮中有几个需要处理的重要细节:
- 配置缓冲区和阴影纹理。
- 禁用颜色输出。
- 从光源处为视野中的物体构建一个 LookAt 矩阵。
- 启用 GLSL 第 1 轮着色器程序,该程序仅包含下面代码的简单顶点着色器,准备接收 MVP 矩阵。在这种情况下, MVP 矩阵将包括对象的模型矩阵 M、前一步中计算的 LookAt 矩阵(作为观察矩阵 V),以及透视矩阵 P。我们将该 MVP 矩阵称为“shadowMVP”,因为它是基于光而不是相机的观察点。由于实际上没有显示来自光源的视图,因此第 1 轮着色器程序的片段着色器不会执行任何操作。
- 为每个对象创建 shadowMVP 矩阵,并调用 glDrawArrays()。第 1 轮中不需要包含纹理或光照,因为对象不会渲染到屏幕上。
// 顶点着色器
#version 430
layout(location = 0) int vec3 vertPos;
uniform mat4 shadowMVP;
void main() {
gl_Position = shadowMVP * vec4(vertPos, 1.0);
}
// 片段着色器
void main() {}
阴影贴图(中间步骤)——将深度缓冲区复制到纹理
OpenGL 提供了两种将 Z 缓冲区深度数据放入纹理单元的方法:
方法 1:生成空阴影纹理,然后使用命令 glCopyTexImage2D()
将活动的深度缓冲区复制到阴影纹理中。
方法 2:在第 1 轮中构建一个“自定义帧缓冲区
”(而不是使用默认的 Z 缓冲区),并使用命令 glFrameBufferTexture()
将阴影纹理附加到它上面。使用这种方法时,无须将 Z 缓冲区“复制”到纹理中,因为缓冲区已经附加了纹理,深度信息由 OpenGL 自动放入纹理中。我们将在实现中使用这种方法。
阴影贴图(第 2 轮)——渲染带阴影的场景
第 2 轮的一个重要特征是它使用了两个 MVP 矩阵:一个是将对象坐标转换为屏幕坐标的标准 MVP 矩阵;另一个是在第 1 轮中生成的 shadowMVP 矩阵,用于从光源的角度进行渲染——我们将在第 2 轮中用它从阴影纹理中查找深度信息。
在第 2 轮中,从纹理贴图尝试查找像素时,情况比较复杂。 OpenGL 相机使用[−1,+1]坐标空间,而纹理贴图使用[0,1]空间。常见的解决方案是构建一个额外的变换矩阵,通常称为 B,将用于从相机空间到纹理空间的转换[或“偏离”( bias),该矩阵因此而得名]。 B 代表的变换过程很简单——先缩放为 1/2,再平移 1/2。
B
=
[
0.5
0
0
0.5
0
0.5
0
0.5
0
0
0.5
0.5
0
0
0
1
]
B=\begin{bmatrix} 0.5&0 &0 &0.5 \\ 0&0.5 &0 &0.5 \\ 0&0 &0.5 &0.5 \\ 0&0 &0 &1 \end{bmatrix}
B=
0.500000.500000.500.50.50.51
之后将 B 和 shadowMVP 相乘得到 shadowMVP2,以备在第 2 轮中使用。
点P在阴影里,那么点P∈[-1, 1],则两边同时乘以½并加上½:(P×½ + ½)∈[-1×½ + ½, 1×½ + ½]==[0, 1]。
以下是第 2 轮处理的详细信息摘要:
- 构建变换矩阵 B,用于从光照空间转换到纹理空间(更合适在 init()中进行)。
- 启用阴影纹理以进行查找。
- 启用颜色输出。
- 启用 GLSL 第 2 轮渲染程序,包含顶点着色器和片段着色器。
- 根据相机位置(正常)为正在绘制的对象构建 MVP 矩阵。
- 构建 shadowMVP2 矩阵(包含矩阵 B,如前所述)——着色器将用它查找阴影纹理中的像素坐标。
- 将生成的变换矩阵发送到着色器统一变量。
- 像往常一样启用包含顶点、法向量和纹理坐标(如果使用)的缓冲区。
- 调用 glDrawArrays()。
除了渲染任务外,顶点和片段着色器还需要额外承担一些任务:
- 顶点着色器将顶点位置从相机空间转换为光照空间,并将结果坐标发送到顶点属性中的片段着色器,以便对它们进行插值。 这样片段着色器可以从阴影纹理中检索正确的值。
- 片段着色器调用 textureProj()函数,该函数返回 0 或 1,指示像素是否处于阴影中。如果像素处于阴影中,则着色器通过剔除其漫反射和镜面反射分量来输出更暗的像素。
textureProj()
函数用于从阴影纹理中查找值,它类似于 texture(),但 textureProj() 函数使用 vec3 来索引纹理,而不是通常的 vec2。由于像素坐标是 vec4,因此需要将其投影到 2D 纹理空间上,以便在阴影纹理中查找深度值。正如我们将看到的, textureProj()实现了这些功能。
经过插值后的( 3D)光照空间坐标(x, y, z)在片段着色器中使用情况为:z 分量表示从光到像素的距离(经偏离矩阵B变换后范围在[0,1]);(x, y)分量用于检索存储在(2D)阴影纹理中的深度信息。将该检索的值(到最靠近光的物体的距离)与 z 进行比较,将产生“二元”结果,告诉我们我们正在渲染的像素是否比最接近光的物体离光更远(即像素是否处于阴影中)。
使用前面介绍过的 glFrameBufferTexture()并启用深度测试,然后在片段着色器中使用 sampler2DShadow 和 textureProj(),所渲染的结果将完全满足我们的需求,即 textureProj()将输出 0.0 或 1.0,具体取决于深度比较的结果。
齐次纹理坐标(homogeneous texture coordinates)
的概念对大多数人来说比较陌生,纹理坐标一般是二维的,如果是体纹理,其纹理坐标也只是三维的。齐次纹理坐标的出现是为了和三维顶点的齐次坐标相对应, 因为本质上,投影纹理坐标是通过三维顶点的齐次坐标计算得到的。
在OpenGL中,当坐标点(x, y, z)经过顶点着色器处理后,会被变换到裁剪空间。这个过程中,z分量的值会发生变化。在裁剪空间中,z分量的值代表的是该点在相机/光照视野内的深度信息。
然后,所有在裁剪空间内的坐标都会经过一个叫做透视除法的过程,这个过程会将每个坐标的x,y,z分量分别除以其齐次坐标w分量。这个过程将4D裁剪空间坐标变换为3D标准化设备坐标。这样,每个顶点的x,y,z坐标都会在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。
因此,到达片段着色器后,z分量的值实际上代表了该点在标准化设备坐标系中的深度信息。这个深度信息会被用于后续的深度测试,以确定哪些片段应该被绘制到屏幕上,哪些应该被丢弃。
在深度缓冲区中包含深度值介于 0.0 和 1.0 之间,从观察者看到其内容与场景中的所有对象的 z 值进行了比较。这些视图空间中的 z 值可以在投影平头截体的近平面和远平面之间的任何值。
片元在光空间的 -1 到 1 的范围,假设光空间中的某顶点为 projCoords,因为来自深度贴图的深度在 0 到 1 的范围,我们也打算使用 projCoords 从深度贴图中去采样,所以我们将 NDC 坐标变换为 0 到 1 的范围:为了和深度贴图的深度相比较,z 分量需要变换到[0,1];为了作为从深度贴图中采样的坐标,xy分量也需要变换到[0,1]。所以整个 projCoords 向量都需要变换到[0,1]范围。
projCoords = projCoords * 0.5 + 0.5;
有了这些投影坐标,我们就能从深度贴图中采样得到 0 到 1 的结果,从第一个渲染阶段的 projCoords 坐标直接对应于变换过的NDC坐标。我们将得到光的位置视野下最近的深度:
float closestDepth = texture(shadowMap, projCoords.xy).r;
为了得到片元的当前深度,我们简单获取投影向量的 z 坐标,它等于来自光的透视视角的片元的深度。
float currentDepth = projCoords.z;
实际的对比就是简单检查 currentDepth 是否高于 closetDepth,如果是,那么片元就在阴影中。
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
阴影贴图示例
// 大部分代码与之前相同。高亮部分代码是新加入的,用以实现阴影
// 实现光照所需的大部分引用需要在代码开始引入,与之前相同,因此不在这里重复
// 在这里定义渲染程序所用的变量、缓冲区、着色器源代码等
...
ImportedModel pyramid("pyr.obj"); // 定义四棱锥
Torus myTorus(0.6f, 0.4f, 48); // 定义环面
int numPyramidVertices, numTorusVertices, numTorusIndices;
...
// 环面、四棱锥、相机和光源的位置
glm::vec3 torusLoc(1.6f, 0.0f, -0.3f);
glm::vec3 pyrLoc(-1.0f, 0.1f, 0.3f);
glm::vec3 cameraLoc(0.0f, 0.2f, 6.0f);
glm::vec3 lightLoc(-3.8f, 2.2f, 1.1f);
// 场景中所使用白光的属性(全局光和位置光)
float globalAmbient[4] = { 0.7f, 0.7f, 0.7f, 1.0f };
float lightAmbient[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
float lightDiffuse[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
float lightSpecular[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
// 四棱锥的黄金材质
float* goldMatAmb = Utils::goldAmbient();
float* goldMatDif = Utils::goldDiffuse();
float* goldMatSpe = Utils::goldSpecular();
float goldMatShi = Utils::goldShininess();
// 环面的青铜材质
float* bronzeMatAmb = Utils::bronzeAmbient();
float* bronzeMatDif = Utils::bronzeDiffuse();
float* bronzeMatSpe = Utils::bronzeSpecular();
float bronzeMatShi = Utils::bronzeShininess();
// 在 display()中将光照传入着色器的变量
float curAmb[4], curDif[4], curSpe[4], matAmb[4], matDif[4], matSpe[4];
float curShi, matShi;
// 阴影相关变量
int screenSizeX, screenSizeY;
GLuint shadowTex, shadowBuffer;
glm::mat4 lightVmatrix;
glm::mat4 lightPmatrix;
glm::mat4 shadowMVP1;
glm::mat4 shadowMVP2;
glm::mat4 b;
// 这里定义类型为 mat4 的光源观察矩阵与相机观察矩阵的矩阵变换(mMat、 vMat 等)
// 其他在 display()中所使用的变量也在此定义
...
int main(void) {
// 与前例相同,无改动
}
// init()函数依然执行调用以编译着色器并初始化物体
// 同时它也调用 setupShadowBuffers()函数以初始化阴影贴图相关缓冲区
// 最后,它构造矩阵 B 以进行从光照空间到纹理空间的转换
void init(GLFWwindow* window) {
renderingProgram1 = Utils::createShaderProgram("./vert1Shader.glsl", "./frag1Shader.glsl");
renderingProgram2 = Utils::createShaderProgram("./vert2Shader.glsl", "./frag2Shader.glsl");
setupVertices();
setupShadowBuffers();
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 setupShadowBuffers(GLFWwindow* window) {
glfwGetFramebufferSize(window, &width, &height);
screenSizeX = width;
screenSizeY = height;
// 创建自定义帧缓冲区
glGenFramebuffers(1, &shadowBuffer);
// 创建阴影纹理并让它存储深度信息。这些步骤与第5章(纹理贴图)程序中的相似
glGenTextures(1, &shadowTex);
glBindTexture(GL_TEXTURE_2D, shadowTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32,
screenSizeX, screenSizeY, 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);
}
void setupVertices(void) {
// 与之前的例子相同,这个函数用来创建 VAO 和 VBO
// 之后将环面及四棱锥的顶点与法向量读入缓冲区
}
// display()函数分别管理第 1 轮需要使用的自定义帧缓冲区
// 以及第 2 轮需要使用的阴影纹理初始化过程。阴影相关新功能已突出显示
void display(GLFWwindow* window, double currentTime) {
glClear(GL_COLOR_BUFFER_BIT);
glClear(GL_DEPTH_BUFFER_BIT);
// 从光源视角初始化视觉矩阵以及透视矩阵,以便在第 1 轮中使用
lightVmatrix = glm::lookAt(currentLightPos, 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);
// 关闭绘制颜色(不写入颜色缓冲区),同时开启深度计算
glDrawBuffer(GL_NONE);
glEnable(GL_DEPTH_TEST);
passOne();
// 在屏幕上显示,并使用纹理上存储的深度缓冲区数据,并重新开启绘制。
glBindFramebuffer(GL_FRAMEBUFFER, 0);// 第2个参数设为0以中断framebuffer对象到目标的现有绑定
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, shadowTex);
glDrawBuffer(GL_FRONT);// 重新开启绘制颜色
passTwo();
}
// 接下来是第 1 轮和第 2 轮的代码
// 这些代码和之前的大体相同
// 与阴影相关的新增代码已突出显示
void passOne(void) {
// renderingProgram1 包含第 1 轮中的顶点着色器和片段着色器
glUseProgram(renderingProgram1);
...
// 接下来的代码段通过从光源角度渲染环面获得深度缓冲区
mMat = glm::translate(glm::mat4(1.0f), torusLoc);
// 轻微旋转以便查看
mMat = glm::rotate(mMat, toRadians(25.0f), glm::vec3(1.0f, 0.0f, 0.0f));
// 我们从光源角度绘制,因此使用光源的矩阵 P、 V
shadowMVP1 = lightPmatrix * lightVmatrix * mMat;
sLoc = glGetUniformLocation(renderingProgram1, "shadowMVP");
glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP1));
// 在第 1 轮中我们只需要环面的顶点缓冲区,而不需要它的纹理或法向量
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
glClear(GL_DEPTH_BUFFER_BIT);
glEnable(GL_CULL_FACE);
glFrontFace(GL_CCW);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo[4]); // vbo[4] 包含环面索引
glDrawElements(GL_TRIANGLES, numTorusIndices, GL_UNSIGNED_INT, 0);
// 不清除 GL_DEPTH_BUFFER_BIT 绘制被遮挡的物体!!!!!!!!!!!!!!!!!!!!
// 对四棱锥做同样的处理(但!不清除 GL_DEPTH_BUFFER_BIT!)
// 四棱锥没有索引,因此我们使用 glDrawArrays()而非 glDrawElements()
...
glDrawArrays(GL_TRIANGLES, 0, numPyramidVertices);
}
/* passOne() 对应的着色器代码 Begin */
// 顶点着色器
#version 430
layout(location = 0) in vec3 vertPos;
uniform mat4 shadowMVP;
void main(void) {
gl_Position = shadowMVP * vec4(vertPos,1.0);
}
// 片段着色器
#version 430
void main(void) {}
/* passOne() 对应的着色器代码 End */
void passTwo(void) {
glUseProgram(renderingProgram2); // 第 2 轮顶点着色器和片段着色器
// 绘制环面,这次我们需要加入光照、材质、法向量等
// 同时我们需要为相机空间以及光照空间都提供 MVP 变换
mvLoc = glGetUniformLocation(renderingProgram2, "mv_matrix");
projLoc = glGetUniformLocation(renderingProgram2, "proj_matrix");
nLoc = glGetUniformLocation(renderingProgram2, "norm_matrix");
sLoc = glGetUniformLocation(renderingProgram2, "shadowMVP");
// 环面是黄铜材质
curAmb[0] = bronzeMatAmb[0]; curAmb[1] = bronzeMatAmb[1]; curAmb[2] = bronzeMatAmb[2];
curDif[0] = bronzeMatDif[0]; curDif[1] = bronzeMatDif[1]; curDif[2] = bronzeMatDif[2];
curSpe[0] = bronzeMatSpe[0]; curSpe[1] = bronzeMatSpe[1]; curSpe[2] = bronzeMatSpe[2];
curShi = bronzeMatShi;
vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraLoc.x, -cameraLoc.y, -cameraLoc.z));
currentLightPos = glm::vec3(lightLoc);
installLights(renderingProgram2, vMat);
mMat = glm::translate(glm::mat4(1.0f), torusLoc);
// 轻微旋转以便查看
mMat = glm::rotate(mMat, toRadians(25.0f), glm::vec3(1.0f, 0.0f, 0.0f));
// 构建相机视角环面的 MV 矩阵
mvMat = vMat * mMat;
invTrMat = glm::transpose(glm::inverse(mvMat));
// 构建光源视角环面的 MV 矩阵
shadowMVP2 = b * lightPmatrix * lightVmatrix * mMat;
// 将 MV 以及 PROJ 矩阵传入相应的统一变量
glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));
glUniformMatrix4fv(nLoc, 1, GL_FALSE, glm::value_ptr(invTrMat));
glUniformMatrix4fv(sLoc, 1, GL_FALSE, glm::value_ptr(shadowMVP2));
// 初始化环面顶点和法向量缓冲区
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]); // 环面顶点
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vbo[2]); // 环面法向量
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(1);
glClear(GL_DEPTH_BUFFER_BIT);
glEnable(GL_CULL_FACE);
glFrontFace(GL_CCW);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo[4]); // vbo[4]包含环面索引
glDrawElements(GL_TRIANGLES, numTorusIndices, GL_UNSIGNED_INT, 0);
...
// 对黄金四棱锥重复同样步骤
}
/* passTwo() 对应的着色器代码 */
// 顶点着色器
#version 430
layout(location = 0) in vec3 vertPos;
layout(location = 1) in vec3 vertNormal;
out vec3 vNormal, vLightDir, vHalfVec;
out vec4 shadow_coord;
struct PositionalLight {
vec4 ambient, diffuse, specular;
vec3 position;
};
uniform PositionalLight light;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
uniform mat4 shadowMVP;
void main(void) {
//get a vertex vector in light space
vec3 vVertPos = (mv_matrix * vec4(vertPos,1.0)).xyz;
//get a vector from the vertex to the light and output it to the rasterizer for interpolation
vLightDir = light.position - vVertPos;
//get a vertex normal vector in eye space and output it to the rasterizer for interpolation
vNormal = (norm_matrix * vec4(vertNormal,1.0)).xyz;
// calculate the half vector (L+V)
vHalfVec = (vLightDir - vVertPos).xyz;
shadow_coord = shadowMVP * vec4(vertPos,1.0);
gl_Position = proj_matrix * mv_matrix * vec4(vertPos,1.0);
}
// 片段着色器
#version 430
in vec3 vNormal, vLightDir, vHalfVec;
in vec4 shadow_coord;
out vec4 fragColor;
struct PositionalLight {
vec4 ambient, diffuse, specular;
vec3 position;// unused!
};
struct Material {
vec4 ambient, diffuse, specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
layout(binding = 0) uniform sampler2DShadow shadowTex;
void main(void) {
vec3 L = normalize(vLightDir);
vec3 N = normalize(vNormal);
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);
}
}
运行结果如下图,[有光照无阴影的场景(左)]-(仿佛光线直接穿过了金字塔) 和 [有光照有阴影的场景(右)]-(光线照射到圆环被金字塔遮挡):
- 如果是自身的阴暗面,就正常的光照模型就处理了,正常的渲染。
- 如果是模型像素被其他模型遮挡,就得专门算法针对处理了。
所以,关键就是要判断本模型是否被其他模型所遮挡,如被遮挡,就忽略遮挡部位漫反射和镜面反射分量;否则,就使用标准光照模型。
为什么在 passOne() 中绘制被遮挡的圆环时为会要调用 glEnable(GL_DEPTH_TEST) 且不能调用 glClear(GL_DEPTH_BUFFER_BIT) ?
在OpenGL中处理阴影时,我们通常使用一种称为阴影映射(Shadow Mapping)的技术。这个技术包含两个步骤:
- 首先,我们从光源的视角渲染场景,并将深度值存储到一个纹理中,这个纹理被称为
深度贴图(Depth Map)
或阴影贴图。 - 然后,我们从摄像机的视角渲染场景,并使用深度贴图来判断每个片段是否在阴影中。
在离屏渲染的过程中,我们通常会在绘制第一个物体时清除GL_DEPTH_BUFFER_BIT,以确保深度缓冲区中没有任何先前的深度信息。然后,我们会绘制第一个物体,并将其深度信息写入深度缓冲区。
当我们开始绘制第二个物体时,我们不会清除GL_DEPTH_BUFFER_BIT。这是因为我们需要保留第一个物体的深度信息,以便我们可以比较第二个物体的深度信息和第一个物体的深度信息。如果第二个物体的深度值大于第一个物体的深度值(也就是说,第二个物体在第一个物体的后面),那么第二个物体就会被认为是在阴影中。
因此,我们在绘制第二个物体时不清除GL_DEPTH_BUFFER_BIT,是为了保留第一个物体的深度信息,以便我们可以正确地计算阴影。
深度测试是一种技术,它允许我们确定哪些像素应该被绘制到屏幕上,哪些像素应该被丢弃。在离屏渲染过程中,我们通常会启用深度测试,以确保正确地绘制被遮挡的物体。
构建LookAt矩阵,用光源坐标替换负相机坐标,从而完成“光源视角”。
对于本例:
glm::vec3 origin(0.0f, 0.0f, 0.0f);
glm::vec3 up(0.0f, 1.0f, 0.0f);
lightVmatrix = glm::lookAt(lightLoc, origin, up);
shadowMVP1 = lightPmatrix * lightVmatrix * mMat;
void glFrameBufferTexture(GLenum target, GLenum attachment, GLuint texture, GLint level)
将纹理对象的一个级别作为帧缓冲对象的逻辑缓冲区进行附加。
target - 指定帧缓冲区绑定的目标,适用于除glNamedFramebufferTexture之外的所有命令。
attachment - 指定帧缓冲区的附着点,如常量GL_DEPTH_ATTACHMENT。
texture - 指定要附加的现有纹理对象的名称,如一个GLuint类型的纹理ID。
level - 指定要附加的纹理对象的mipmap级别,默认为0。
命令将纹理对象的选定mipmap级别或图像附加为指定帧缓冲区对象的逻辑缓冲区之一。
纹理不能附加到默认的绘制和读取帧缓冲区,因此它们不是该命令的有效目标。“将纹理对象的一个级别作为帧缓冲对象的逻辑缓冲区进行附加”这句话的理解:
将纹理的一个特定层(或称为“级别”)与帧缓冲关联起来,以便在渲染过程中使用这个纹理层作为颜色、深度或模板缓冲区。
为了更通俗地解释,我们可以将其拆分为几个部分:
- 纹理对象:在OpenGL中,纹理是用于存储图像数据的对象。纹理对象可以包含多个级别的数据,这些级别通常对应于不同的分辨率或细节级别(例如,低分辨率、中分辨率和高分辨率)。
- 级别:指的是mipmap的级别,mipmap是一种纹理贴图技术,用于创建和使用不同分辨率的纹理图像,以优化渲染速度和质量。
- 帧缓冲对象:帧缓冲是用于渲染的最终目的地。它包含了多个缓冲区,如颜色缓冲区、深度缓冲区和模板缓冲区等。当我们在屏幕上看到渲染结果时,实际上是看到了帧缓冲中的内容。
- 附加逻辑缓冲区:这里所说的“附加”是将某个纹理级别与帧缓冲的某个缓冲区关联起来。例如,你可以将一个纹理的某个级别附加到帧缓冲的颜色缓冲区,这样在渲染过程中,该纹理级别就会被用作颜色数据。
在OpenGL中,帧缓冲对象(Framebuffer Object,FBO)
允许我们把渲染的结果存储到一个非屏幕的地方,比如一个纹理对象。这个过程就像是设置一个“目标”,而不是直接将图像渲染到屏幕上。帧缓冲对象是OpenGL中用于离屏渲染
的一种技术,它包含颜色缓冲区、深度缓冲区和模板缓冲区等。而“逻辑缓冲区”则是帧缓冲对象的一部分,可以是颜色、深度或模板缓冲区。
因此,这句话的意思是,你可以将一个纹理对象的特定级别附加到帧缓冲对象的逻辑缓冲区,这样在进行渲染时,OpenGL就会使用这个纹理对象的特定级别,而不是默认的颜色缓冲区。
当我们将纹理对象的一个级别(如纹理的mipmap层级)作为帧缓冲对象的逻辑缓冲区进行附加时,这意味着我们实际上是把纹理的一部分空间分配给了帧缓冲对象来存储渲染结果。这个纹理不再仅仅是一个普通的纹理,它变成了一个可以接收和存储图形数据的“缓冲区”。
通俗来说,这就像是我们在画一幅画,但不是直接画在画布上,而是先画在一张透明薄膜上(这就是纹理对象),然后再把这张薄膜贴到画布的某个位置(这就是将纹理对象附加到帧缓冲对象的过程)。这样,我们就可以对这张薄膜进行各种操作,比如添加特效、修改颜色等,而不会直接影响到画布上的其他部分。这种技术在很多高级图形效果中都非常有用,比如后期处理、渲染到纹理、阴影映射等。
简单来说,这句话就是在描述如何将一个纹理的不同层与帧缓冲的不同部分关联起来,以便在渲染时能够灵活地使用这些数据。
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对象到目标的现有绑定。
void glDrawBuffer(GLenum buf)
指定要绘制到的颜色缓冲区。
buf - 将颜色写入帧缓冲区时,它们将写入指定的颜色缓冲区。以下值之一可用于默认帧缓冲区:
GL_NONE——不写入颜色缓冲区。
GL_FRONT——只写入左前和右前颜色缓冲区。如果没有右前颜色缓冲区,则只写入左前颜色缓冲区。
GL_FRONT_LEFT——只写入左前颜色缓冲区。
GL_BACK——只有后左和后右颜色缓冲区会被写入。如果没有后右颜色缓冲区,只有后左颜色缓冲区会被写入。
…
对于单缓冲上下文,初始值为GL_FRONT,对于双缓冲上下文,初始值为GL_BACK。
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()函数中指定了二维纹理图像,这个纹理图像有众多参数,其中就有width和height,它们用于设置纹理图像的宽度和高度。在示例中使用的是初始窗口的宽高。但之后手动改变窗口大小,却没有重新设置这个纹理图像的宽高,就导致阴影贴图出现错误。所以解决办法是:将glTexImage2D()函数放在window_size_callback()函数体中;这样一来,无论怎样拖动改变窗口尺寸,glViewPort()调整了视口尺寸,而这个尺寸刚好为纹理图像的尺寸。
阴影贴图的伪影
好消息是我们的金字塔现在在环面上投下阴影!坏消息则是,这种成功伴随着严重的伪影。有许多波浪线覆盖在场景中的表面。这是阴影贴图的常见副作用,称为阴影痤疮(shadow acne)
或错误的【自阴影】。
阴影痤疮是由深度测试期间的舍入误差引起的。在阴影纹理中查找深度信息时计算的纹理坐标通常与实际坐标不完全匹配。因此,从阴影纹理中查找到的深度值可能并非当前渲染中像素的深度,而是相邻像素的深度。如果相邻像素在更远位置,则当前像素会被错误地显示为阴影。
幸运的是,阴影痤疮很容易修复。由于阴影痤疮通常发生在没有阴影的表面上,这里有个简单的技巧,在第 1 轮(passOne())中将每个像素的深度值稍微加大一点点,之后在第 2 轮(passTwo())将它们移回原位。通常, 这么做足以补偿各类舍入误差。 简单地在 display() 函数中调用 glPolygonOffset() 即可。
在OpenGL中,glPolygonOffset
函数是一种用于处理深度缓冲区的技术。此函数就像是一个调节器,它可以微调物体的深度值。glPolygonOffset 函数的工作原理是在执行深度测试之前和值写入深度缓冲区之前添加一个偏移量。这个偏移量会使得物体的深度值稍微增大一点,从而使得物体的表面在深度测试时优先于其阴影。在进行深度测试时,这个函数可以让物体的表面看起来总是在其阴影之前,就好像它们被“提前”了一样((projCoords.z > texture(shadowTex, projCoords.xy).r) ? “阴影中” : “光直接照射到”;)。这样可以避免在渲染时出现物体表面自阴影的情况。简单来说,glPolygonOffset就是用来帮助我们更好地控制物体表面和阴影的显示顺序的工具。这样,当我们在渲染物体的阴影时,物体的表面就不会被自己的阴影所遮挡,从而消除了自阴影伪影。
glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(2.0f, 4.0f);
passOne();
glDisable(GL_POLYGON_OFFSET_FILL);// 移回原位,与实际计算阴影有稍微差别也看不出来
void glPolygonOffset(GLfloat factor, GLfloat units)
设置用于计算[深度值]的比例和单位。
此函数对于渲染隐藏线图像(hidden-line images)、将贴图应用于【曲面】以及渲染【具有亮显边的实体】非常有用。
factor - 指定用于为每个多边形创建可变深度偏移的比例因子。初始值为0。
units - 乘以特定于实现的值以创建恒定的深度偏移。初始值为0。
虽然修复阴影痤疮很容易,但有时修复会导致新的伪影产生。在第 1 轮之前移动对象的“技巧”有时会导致在对象阴影中出现间隙。这种伪影通常被称为“Peter Panning
”,因为有时它会导致静止物体的阴影与物体底部分离(从而使物体的阴影部分与阴影的其余部分分离,让人想起 J. M. Barrie 笔下的角色 Peter Pan)。修复此伪影需要调整 glPolygonOffset() 的参数。如果参数太小,就会出现阴影痤疮;如果参数太大,则会出现 Peter Panning。
在OpenGL中,"Peter Panning"伪影的产生主要与阴影映射和深度贴图的处理有关。当我们按照光线投射产生深度贴图进行阴影映射时,由于深度贴图的分辨率有限,可能会导致多个像素点采样同一个深度值,这就可能产生阴影失真。为了解决这个问题,一种常见的方法是添加偏移(bias),也就是给像素的z值加上一个偏移量。然而,这种方法可能会导致"Peter Panning"伪影,即阴影看起来像是悬浮在物体表面之上。这是因为偏移量过大可能会导致原本应该在阴影中的部分被错误地判断为不在阴影中。
在实现阴影贴图时可能会发生许多其他伪影。如重复阴影
,因为在第 1 轮(存入阴影缓冲区时)渲染的场景区域与第 2 轮中渲染的场景区域不同(来自不同的观察位置)。这种差异可能导致在第 2 轮中渲染的场景中,某些区域尝试使用范围[0…1]之外的特征坐标来访问阴影纹理。回想一下,在这种情况下默认行为是 GL_REPEAT,因此,这可能导致错误的重复阴影。
一种可能的解决方案是将以下代码行添加到 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)
,包括阴影边缘的柔和区域。
最简单也最常见的一种方法叫作百分比邻近滤波(Percentage Closer Filtering, PCF)
。在 PCF 中,我们对单个点周围的几个位置的阴影纹理进行采样,以估计附近位置在阴影中的百分比。根据附近位置在阴影中的数量,对正在渲染的像素的光照分量进行修改。整个计算可以在片段着色器中完成。PCF 还可用于减少锯齿线伪影。
以黄色突出显示的特定像素不在阴影里面,在高亮像素的 9 个像素邻域中, 3 个像素处于阴影中而 6 个像素处于阴影外。因此,渲染像素的颜色可以被计算为【该像素处的总环境光分量加上漫反射和镜面反射分量的(6/9=66.7%)】,即 Iambient+(Idiffuse+Ispecular)*66.7%,这样会使像素一定程度(但不是完全)变亮。
在 PCF 的实现中,不对被渲染像素邻域内的每个像素进行采样。这有两个原因:
(a)我们想在片段着色器中执行此计算,但片段着色器无法访问其他像素;
(b)要获得足够宽的半影效果(例如, 10~20 像素宽)需要为每个被渲染的像素采样数百个附近的像素。
PCF 用以下方式解决了这些问题:
- 首先,我们不试图访问附近的像素,而是在阴影贴图中对附近的纹元进行采样。 片段着色器可以执行此操作,因为它虽然无法访问附近像素,但可以访问整个阴影贴图。
- 其次,为了获得足够宽的半影效果,我们采取对附近一定数量的阴影贴图纹元进行采样的方法,每个被采样的纹元都与所渲染像素的纹元有一定距离。
柔和阴影的准确度或平滑度受被采样像素附近纹元的数量影响。在性能和质量之间需要权衡——采样点越多,效果越好,但计算开销也越大。场景的复杂性和给定应用所需的帧率对于可实现阴影的质量有着相应的限制。每个像素采样 64 个点(如下图)通常是不切实际的。
一种用于实现 PCF 的常见算法是对每个像素附近的 4 个纹元进行采样, 其中样本通过像素对应纹元的特定偏移量选择。 对于每个像素, 我们都需要改变偏移量,并用新的偏移量确定采样的 4 个纹元。使用交错的方式改变偏移量的方法被称为抖动
,它旨在使柔和阴影的边界不会由于采样点不足而看起来“结块”。
一种常见的方法是假设有 4 种不同偏移模式,每次取其中一种——我们可以通过计算像素的 glFragCoord mod 2 值来选择当前像素的偏移模式。之前有提到, glFragCoord 是 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 取模的值。算法: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 的不同组合(也可以根据需要进行缩放)。更具体地说,计算 glFragCoord mod 2 的 4 种结果对应的偏移模式采样点。
假设正在渲染的像素位于 glFragCoord =(48,13)。首先我们确定像素在阴影贴图中的 4 个采样点。 为此, 我们计算 vec2(48,13) mod 2, 结果等于(0,1)。因此我们选择(0,1)所对应的偏移,在上图中以绿色显示,并且在阴影贴图对相应的点进行采样(假设没有指定偏移的缩放量),得到:
(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)
shadow_coord 是阴影贴图中与正在渲染的像素相对应的纹元的位置——在上图中显示为白色圆圈。
如下图,模式(0,0),(0,1),(1,0),(1,1)对应的不同颜色的4个采样点。对每个模式都有4个采样点(左上点、左下点、右上点、右下点)。
接下来,对我们选取的这 4 个采样点分别调用 textureProj(),它在每种模式下都返回 0.0 或 1.0,具体结果取决于该采样点是否在阴影中。将得到的 4 个结果相加并除以 4.0,就可以确定阴影中采样点的百分比。然后将此百分比用作系数,以确定渲染当前像素时要应用的漫反射和镜面反射分量。
尽管采样尺寸很小——每个像素只有 4 个采样点,但这种抖动方法通常可以产生好得惊人的柔和阴影。虽然不如 64 点采样效果好,但渲染速度要快得多。
柔和阴影计算可以完全在片段着色器中完成,程序代码如下:
// 片段着色器
#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 语言中一样,必须在调用它们之前(或“上方”)定义函数,否则必须提供前向声明。在该示例中则不需要前向声明,因为函数定义在调用代码上方。