目录
写在前面
今天是 2021 年的元旦。不管 2020 怎么说,都翻篇了,需要看向一个新的前方。新年快乐!
书接上文:OpenGL学习(八)phong光照模型
上次我们讲到通过 phong 光照模型进行简单光照特效的绘制。今天我们使用阴影映射技术,来进一步绘制阴影,使得场景的光照效果更加逼真。
注:
事实上,这是我第 n 次写这个内容了。。。第一次是:从零开始编写minecraft光影包(1)基础阴影绘制
第二次是:深大计算机图形学大作业之虚拟场景建模
第 3 到 n-1 次是帮别人改阴影映射的 bug
第 n 次则是这篇博客。
已经,品鉴的足够多次了。。。
另:
为何如侠客行一般反复品鉴阴影映射,却推迟到现在才出博客?
因为期末了在补别的科目的 ddl,元旦滚回家好不容易有点时间,才来更新一下
我爬我是菜鸡 Orz
阴影映射原理简介
阴影映射是一种渲染阴影的算法。最早在 1978 年被提出。因为其能以较小的代价模拟真实世界的阴影,比如将阴影投影到任何平面上。因此阴影映射是现代计算机游戏中,最常用的绘制阴影的方法,没有之一。
![](https://i-blog.csdnimg.cn/blog_migrate/24d711e33ab463fa2dade8400034c88e.png#pic_center)
阴影映射的水非常深,有非常多的技术可以用来优化这一过程,比如 PCF,穹式投影,硬件深度比较,基于法线的偏移,peter panning… 但是我们今天只讨论最简单的阴影映射。
阴影映射的原理十分简单,就是通过从光源方向进行一次渲染,得到深度图,在正常的渲染中,通过该深度图来判断当前点是否在阴影之中。
步骤如下:
- 获取当前像素 p 到光源的距离 currentDepth
- 通过深度图,获取距离光源最近的点的深度 closestDepth
- 如果 closestDepth < currentDepth 说明 p 被遮挡,p 在阴影中
图解如下:
更加细节的代码实现:
- 从光源方向进行一次渲染,得到深度纹理
- 将当前点坐标,从世界坐标系下,变换到光源坐标系下(通过 view,projection 变换和透视除法)
- 根据变换后的坐标,取深度纹理中的数据,即 closestDepth
- 比较 closestDepth 和 currentDepth,判断是否在阴影中
思路还就内个思路。难点在于我们如何通过从光源方向的渲染,来获取深度图。这才是最容易出 bug 的地方。
封装 Camera 类
我们总共要进行两次渲染,第一次渲染从光源方向进行,进而完成深度图的绘制,第二次渲染从正常的相机视角进行,进行场景的绘制。
这意味着我们要重复进行两次相机矩阵的计算,并且使用两套全局变量,对相机进行管理:
...
// 相机参数
glm::vec3 cameraPosition(0, 0, 0); // 相机位置
glm::vec3 cameraDirection(0, 0, -1); // 相机视线方向
glm::vec3 cameraUp(0, 1, 0); // 世界空间下竖直向上向量
float pitch = 0.0f;
float roll = 0.0f;
float yaw = 0.0f;
// 视界体参数
float left = -1, right = 1, bottom = -1, top = 1, zNear = 0.01, zFar = 100.0;
...
// 计算欧拉角以确定相机朝向
cameraDirection.x = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraDirection.y = sin(glm::radians(pitch));
cameraDirection.z = -cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 相机看向z轴负方向
// 传视图矩阵
glm::mat4 view = glm::lookAt(cameraPosition, cameraPosition + cameraDirection, cameraUp);
GLuint vlocation = glGetUniformLocation(program, "view");
glUniformMatrix4fv(vlocation, 1, GL_FALSE, glm::value_ptr(view));
// 传投影矩阵
glm::mat4 projection = glm::perspective(glm::radians(70.0f), (GLfloat)windowWidth / (GLfloat)windowHeight, zNear, zFar);
GLuint plocation = glGetUniformLocation(program, "projection");
glUniformMatrix4fv(plocation, 1, GL_FALSE, glm::value_ptr(projection));
这是非常麻烦的,于是我们需要封装一个 Camera 类,帮助我们管理相机相关的操作。回想一个相机需要那些属性?
-
首先我们需要相机的位置和朝向,以确定相机的模视变换矩阵
-
我们还需要一组欧拉角来以 FPS 相机的形式,确定相机的朝向
-
随后我们需要指定投影的相关参数,比如 left, right, bottom, top, zNear, zFar, fovy, aspect,并且根据这些参数,定制一个投影矩阵。
需求很明确了,我们可以给出 Camera 类的结构:
注:
因为懒得开头文件,和上次的 Mesh,Model 类一样,我们直接写在 .cpp 里面了
class Camera
{
public:
// 相机参数
glm::vec3 position = glm::vec3(0, 0, 0); // 位置
glm::vec3 direction = glm::vec3(0, 0, -1); // 视线方向
glm::vec3 up = glm::vec3(0, 1, 0); // 上向量,固定(0,1,0)不变
float pitch = 0.0f, roll = 0.0f, yaw = 0.0f; // 欧拉角
float fovy = 70.0f, aspect = 1.0, zNear = 0.01, zFar = 100; // 透视投影参数
float left = -1.0, right = 1.0,top = 1.0,bottom = -1.0; // 正交投影参数
Camera() {
}
// 视图变换矩阵
glm::mat4 getViewMatrix(bool useEulerAngle = true)
{
if (useEulerAngle) // 使用欧拉角更新相机朝向
{
direction.x = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
direction.y = sin(glm::radians(pitch));
direction.z = -cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 相机看向z轴负方向
}
return glm::lookAt(position, position + direction, up);
}
// 投影矩阵
glm::mat4 getProjectionMatrix(bool usePerspective = true)
{
if (usePerspective) // 透视投影
{
return glm::perspective(glm::radians(fovy), aspect, zNear, zFar);
}
return glm::ortho(left, right, bottom, top, zNear, zFar);
}
};
其中 getViewMatrix 用于返回视图变换矩阵,传入的参数是 “是否使用欧拉角更新相机朝向”,而 getProjectionMatrix 用于返回投影矩阵,传入的参数决定是否使用透视投影。
接着我们创建相机对象:
// 相机
Camera camera; // 正常渲染
Camera shadowCamera; // 从光源方向渲染
于是我们非常简单的就可以进行相机矩阵的计算:
// 传视图矩阵
glUniformMatrix4fv(glGetUniformLocation(program, "view"), 1, GL_FALSE, glm::value_ptr(camera.getViewMatrix()));
// 传投影矩阵
glUniformMatrix4fv(glGetUniformLocation(program, "projection"), 1, GL_FALSE, glm::value_ptr(camera.getProjectionMatrix()));
在鼠标的回调函数中,我们直接更新相机的参数(欧拉角)以决定相机朝向即可:
// 鼠标运动函数
void mouse(int x, int y)
{
// 调整旋转
camera.yaw += 35 * (x - float(windowWidth) / 2.0) / windowWidth;
camera.yaw = glm::mod(camera.yaw + 180.0f, 360.0f) - 180.0f; // 取模范围 -180 ~ 180
camera.pitch += -35 * (y - float(windowHeight) / 2.0) / windowHeight;
camera.pitch = glm::clamp(camera.pitch, -89.0f, 89.0f);
glutWarpPointer(windowWidth / 2.0, windowHeight / 2.0);
glutPostRedisplay(); // 重绘
}
而键盘回调函数则是同理,我们更新 camera.position
即可。详细代码见下文
帧缓冲
帧缓冲是一个抽象的屏幕。在之前的 OpenGL 学习中,我们的绘制命令总是直接绘制到屏幕上面。
阴影映射需要我们先从光源方向(对应 shadowCamera 对象)对场景进行绘制。而我们需要显示的内容则是需要由正常的相机来完成(对应 camera 对象),这就发生了冲突。
通俗点说,我们无法利用一张画纸,画两张画。于是便需要引入帧缓冲的概念。
帧缓冲是一个抽象的屏幕,或者说一种抽象的管理显存的方式。一个帧缓冲是由一系列 “画纸” 组成的,这些画纸叫做 “附件” (attachment) 。这些附件通常都是纹理。因为在 OpenGL 中,纹理既可以被当作被写入的对象,也可以被当作读的对象。
其中一个帧缓冲可以有多个颜色附件和一个深度附件,但是默认最终只有 0 号帧缓冲会被输出到屏幕。
帧缓冲的概念非常重要。在之后的博客,我们会手把手的实现延迟渲染管线。还要和它打交道。
在着色器中使用:
gl_FragData[x]
即可向当前 draw call 对应的帧缓冲的第 x 号颜色附件进行写入。
同时我们也可以通过
uniform sampler2D tex;
...
color.rgb = texture2D(tex, texcoord.st).rgb;
来从附件(也就是纹理)中读取被其他着色器绘制好的数据。
使用如下的代码可以创建并绑定一块帧缓冲,一旦帧缓冲绑定之后,任何的 draw call 都会输出到当前的帧缓冲。
GLuint frameBufferObject;
...
glGenFramebuffers(1, &frameBufferObject);
而通过
glFramebufferTexture2D(GL_FRAMEBUFFER, 附件类型, GL_TEXTURE_2D, 纹理对象, 0);
函数则可以将某个纹理对象,作为某个附件,添加到当前帧缓冲中。
阴影映射
我们正式开始进行阴影映射代码的编写!
这一部分的代码分为三个步骤,分别是:
- 变量准备
- 创建帧缓冲与深度纹理
- 从光源方向进行场景绘制
- debug 绘制
准备工作
因为要从光源方向进行场景的渲染,我们需要两个相机:
// 相机
Camera camera; // 正常渲染
Camera shadowCamera; // 从光源方向渲染
...
// 正交投影参数配置 -- 视界体范围 -- 调整到场景一般大小即可
shadowCamera.left = -20;
shadowCamera.right = 20;
shadowCamera.bottom = -20;
shadowCamera.top = 20;
shadowCamera.position = glm::vec3(0, 4, 15);
此外,我们需要三组着色器,其中 shadow 和 debug 着色器分别负责从光源方向的渲染,和输出深度纹理以 debug。
// 着色器程序对象
GLuint program;
GLuint debugProgram; // 调试用
GLuint shadowProgram; // 绘制阴影的着色器程序对象
...
// 生成着色器程序对象
program = getShaderProgram("shaders/fshader.fsh", "shaders/vshader.vsh");
shadowProgram = getShaderProgram("shaders/shadow.fsh", "shaders/shadow.vsh");
debugProgram = getShaderProgram("shaders/debug.fsh", "shaders/debug.vsh");
注:
其实 shadow 着色器仅仅是顶点着色器在工作。
因为不用输出颜色,片元着色器不用写任何代码,除了 void main{}
最后我们需要帧缓冲和其深度纹理附件:
// 光源与阴影参数
int shadowMapResolution = 1024; // 阴影贴图分辨率
GLuint shadowMapFBO; // 从光源方向进行渲染的帧缓冲
GLuint shadowTexture; // 阴影纹理
创建帧缓冲与深度纹理附件
在了解到阴影映射的算法原理之后,我们需要从光源方向进行一次绘制。我们需要额外的一块绘制区域(帧缓冲),同时我们将深度信息输出到深度缓存。
在 init 部分应该有如下的代码:
// 创建shadow帧缓冲
glGenFramebuffers(1, &shadowMapFBO);
// 创建阴影纹理
glGenTextures(1, &shadowTexture);
glBindTexture(GL_TEXTURE_2D, shadowTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, shadowMapResolution, shadowMapResolution, 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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将阴影纹理绑定到 shadowMapFBO 帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, shadowMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadowTexture, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
从光源方向进行渲染
我们创建了对应的帧缓冲,接下来我们在 display 中 bind 该缓冲并且调用 draw call 即可进行绘制:
// 从光源方向进行渲染
glUseProgram(shadowProgram);
glBindFramebuffer(GL_FRAMEBUFFER, shadowMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glViewport(0, 0, shadowMapResolution, shadowMapResolution);
// 光源看向世界坐标原点
shadowCamera.direction = glm::normalize(glm::vec3(0, 0, 0) - shadowCamera.position);
// 传视图矩阵
glUniformMatrix4fv(glGetUniformLocation(shadowProgram, "view"), 1, GL_FALSE, glm::value_ptr(shadowCamera.getViewMatrix(false)));
// 传投影矩阵
glUniformMatrix4fv(glGetUniformLocation(shadowProgram, "projection"), 1, GL_FALSE, glm::value_ptr(shadowCamera.getProjectionMatrix(false)));
// 从光源方向进行绘制
for (auto m : models)
{
m.draw(shadowProgram);
}
而我们的 shadow 系列着色器也是十分简单。我们首先在顶点着色器完成 mvp 变换,而片元着色器不用输出任何像素。下面是顶点着色器 shadow.vsh 代码:
#version 330 core
layout (location = 0) in vec3 vPosition;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection * view * model * vec4(vPosition, 1.0);
}
片段着色器 shadow.fsh :
#version 330 core
void main()
{
// gl_FragDepth = gl_FragCoord.z;
}
正常地渲染场景
这部分的代码和往常无异。只是除了相机 camera 对象的 v,p 变换矩阵,光源的位置信息等 uniform 变量以外,我们需要额外传入至少两个 uniform 变量:
- 从光源方向渲染得到的深度纹理
- 转换到光源坐标系的 v,p 变换矩阵,通过 shadowCamera 对象获取
才能完成阴影映射的整个过程。
注:
这里其实可以将 v 和 p 矩阵直接乘起来,这样我们只需要传递一个矩阵即可
下文使用的就是这种思路,我们传递一个 shadowVP 矩阵即可
// 正常滴渲染
glUseProgram(program);
glBindFramebuffer(GL_FRAMEBUFFER, 0);