OpenGL学习(九)阴影映射(shadowMapping)

本文介绍了OpenGL中的阴影映射技术,通过从光源方向进行渲染得到深度纹理,从而实现更逼真的光照效果。作者详细讲解了阴影映射的原理、封装Camera类、帧缓冲的使用以及如何查找和解决阴影映射的常见问题。文章提供了完整的C++代码和着色器实现,包括shadow、debug和正常渲染的着色器,帮助读者理解和应用阴影映射。
摘要由CSDN通过智能技术生成

写在前面

今天是 2021 年的元旦。不管 2020 怎么说,都翻篇了,需要看向一个新的前方。新年快乐!

在这里插入图片描述


书接上文:OpenGL学习(八)phong光照模型

上次我们讲到通过 phong 光照模型进行简单光照特效的绘制。今天我们使用阴影映射技术,来进一步绘制阴影,使得场景的光照效果更加逼真。

注:
事实上,这是我第 n 次写这个内容了。。。

第一次是:从零开始编写minecraft光影包(1)基础阴影绘制

第二次是:深大计算机图形学大作业之虚拟场景建模

第 3 到 n-1 次是帮别人改阴影映射的 bug

第 n 次则是这篇博客。

已经,品鉴的足够多次了。。。

另:
为何如侠客行一般反复品鉴阴影映射,却推迟到现在才出博客?
因为期末了在补别的科目的 ddl,元旦滚回家好不容易有点时间,才来更新一下
我爬我是菜鸡 Orz

阴影映射原理简介

阴影映射是一种渲染阴影的算法。最早在 1978 年被提出。因为其能以较小的代价模拟真实世界的阴影,比如将阴影投影到任何平面上。因此阴影映射是现代计算机游戏中,最常用的绘制阴影的方法,没有之一。


阴影映射的水非常深,有非常多的技术可以用来优化这一过程,比如 PCF,穹式投影,硬件深度比较,基于法线的偏移,peter panning… 但是我们今天只讨论最简单的阴影映射。

阴影映射的原理十分简单,就是通过从光源方向进行一次渲染,得到深度图,在正常的渲染中,通过该深度图来判断当前点是否在阴影之中。

步骤如下:

  1. 获取当前像素 p 到光源的距离 currentDepth
  2. 通过深度图,获取距离光源最近的点的深度 closestDepth
  3. 如果 closestDepth < currentDepth 说明 p 被遮挡,p 在阴影中

图解如下:
在这里插入图片描述

更加细节的代码实现:

  1. 从光源方向进行一次渲染,得到深度纹理
  2. 将当前点坐标,从世界坐标系下,变换到光源坐标系下(通过 view,projection 变换和透视除法)
  3. 根据变换后的坐标,取深度纹理中的数据,即 closestDepth
  4. 比较 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);

函数则可以将某个纹理对象,作为某个附件,添加到当前帧缓冲中。

阴影映射

我们正式开始进行阴影映射代码的编写!

这一部分的代码分为三个步骤,分别是:

  1. 变量准备
  2. 创建帧缓冲与深度纹理
  3. 从光源方向进行场景绘制
  4. 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 变量:

  1. 从光源方向渲染得到的深度纹理
  2. 转换到光源坐标系的 v,p 变换矩阵,通过 shadowCamera 对象获取

才能完成阴影映射的整个过程。

注:
这里其实可以将 v 和 p 矩阵直接乘起来,这样我们只需要传递一个矩阵即可
下文使用的就是这种思路,我们传递一个 shadowVP 矩阵即可

// 正常滴渲染
glUseProgram(program);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
  • 18
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值