OGL(教程23)——阴影映射1

原文地址:http://ogldev.atspace.co.uk/www/tutorial23/tutorial23.html
项目的下载地址:git@gitee.com:yichichunshui/ShadowMap1.git

背景知识:
阴影的形成和光是密不可分的,因为你需要一个灯来投射阴影。产生阴影有很多方式,在接下来的两节中我们将会学习最基础、最简单的一种方式——阴影映射。

当到达了光栅化和阴影的时候,你会问自己——这个像素是否在阴影中。让我们换种方式提问——从光源到像素的路径上是否经过其他的物体?如果是,那么这个像素则在阴影中(加上阻碍的那个物体是不透明的)。如果不是——这个像素就不在阴影中。这种方式,此问题和之前的章节中的问题很类似——怎样确定看到近的物体,当一个物体和另外一个物体重叠时。如果我们把摄像机放在光源的起点,两个问题变为了一个。我们希望距离远的深度测试时失败(如果此像素比另外一个像素要远),那么此像素在阴影中。只有那些深度测试通过的像素,才在光源中。这个是光源直接照射,路径上没有其他物体。这既是阴影映射的实质。

所以看起来像深度测试能帮着我们决定一个像素是否在阴影中,但是存在一个问题。摄像机和光源并不总是处在同一个位置。深度测试通常是用来从摄像机的角度来解决可见性问题。所以当光源处在很远的位置,如何利用摄像机来解决阴影侦测。解决的方案是,渲染场景两次。第一次是从光源的位置。渲染的结果不会到达颜色缓冲,但是最近点的深度值会存储在应用程序创建的深度缓冲中(这个深度缓冲不是由GLUT自动创建的)。第二次绘制,是通常所谓的在摄像机位置渲染场景。我们创建的深度缓冲被绑定到片段着色器以备读取。对于每个像素我们都从深度缓冲中读取深度信息,我们同样计算从光源位置处的深度信息。某些情况下,这两个深度信息是相同的。这种情况下,此像素离光源最近,所以它的深度值写入了深度缓冲。如果是这样的话,那么此像素在光源中,将会被正常着色。如果深度值是不同的,那么说明从光源的位置来看,有其他的像素覆盖了此像素。在这种情况下,我们在颜色计算的时候添加影子系数,来模拟影子效果。看下图:
在这里插入图片描述

我们的场景由两个物体组成——平面和立方体。光源处在左上角的位置,照射着立方体。首先,我们从光源的位置渲染场景。注意点A、B和C。当渲染到B点的时候,它的深度值写入到了深度缓冲。这是因为在光源和B点之间没有其他的东西。所以此时B点就是距离光源最近的点。但是A和C点就需要进行比较,到底谁会写入深度缓冲呢。由于两个点在同一条直线上,所以光栅化之后,两个点在屏幕的同一个位置。深度测试的时候C点战胜了A点,C的深度值写入深度缓冲。

然后,从摄像机的角度渲染表面和立方体。在光照shader中做所有的事情之外,我们也要计算像素到光源的距离,然后和深度缓冲比较。当光栅化点B的时候,这两个值近乎相等(有些略微的不同是因为浮点数插值计算的时候精度问题)。因此,我们侦测B点不在阴影之内。当光栅化A点的时候,从深度缓冲去除的深度值远远小于A点到光源的距离。因此我们侦测A点在阴影之内,所以要加入阴影系数处理,以使它变得暗一些。

这就是阴影映射算法的原理(第一次从光源渲染场景叫做阴影映射)。我们将会分两个步骤学习它。本节我们将学习如何渲染到阴影映射。渲染某个东西,如深度、颜色等,到一个应用创建的贴图,这个过程叫做渲染到纹理。我们将会使用我们已经熟悉的一个简单的纹理映射技术来把这个阴影映射展示在屏幕上。这是一个很好的调试阴影是否正确的过程。下一节,我们将会看到如何使用阴影映射图来判断像素是否在阴影中。

本节的源代码中,包含一个四边形网格,用来展示阴影映射图。四边形由两个三角形组成,纹理坐标也被设置用来覆盖整个贴图空间。当四边形被渲染的时候,纹理坐标被光栅器插值,于是整个纹理会显示在屏幕上。

代码注释:

(shadow_map_fbo.h:50)

class ShadowMapFBO
{
    public:
        ShadowMapFBO();

        ~ShadowMapFBO();

        bool Init(unsigned int WindowWidth, unsigned int WindowHeight);

        void BindForWriting();

        void BindForReading(GLenum TextureUnit);

    private:
        GLuint m_fbo;
        GLuint m_shadowMap;
};

OpenGL中的3D渲染管线的最终的结果是一个叫做帧缓冲对象,FBO(framebuffer object)。
帧缓冲对象可以用于颜色缓冲、深度缓冲还有其他的额外的缓冲。当glutInitDisplayMode()被调用的时候,他会根据指定的参数创建默认的帧缓冲对象,这个帧缓冲是由操作系统管理的,而且不能被OpenGL删除。处理默认的帧缓冲外,应用程序可以创建自己的FBO对象。这些对象可以在应用程序控制下,来应用各种各样的技术。ShadowMapFBO 封装了FBO对象相关的简单接口,用来实现阴影映射。在这个类中,包含两个OpenGL的句柄。m_fbo代表事实上FBO。FBO对象包含了帧缓存的所有状态。一旦这个对象被创建,且被正确配置,我们就可以很容易的绑定到一个不同的对象。注意到只有默认的帧缓存可以被用来展示东西到屏幕。而由应用程序创建的帧缓冲只能用于离屏渲染,这个是中间的渲染通道,比如我们的阴影映射缓冲,在之后渲染通道中才能被绘制到屏幕上去。

就本身而言,帧缓冲对象只是一个占位符。为了让其能够有用,我们需要附加贴图到一个或者多个可以用的附着点上。其实贴图是实际的帧缓冲存储空间。OpenGL定义下面的附着点:

  1. COLOR_ATTACHMENTi——把贴图绑定到这个附着点,它用来接收片段着色器输出的颜色。i表面可以有多个贴图同时绑定到颜色附着点。片段着色器中存在这种机制,它能够同时渲染多个颜色缓冲。
  2. DEPTH_ATTACHMENT——贴图绑定到这个类型,将接收的是深度测试结果。
  3. STENCIL_ATTACHMENT——贴图绑定到这个类型,将会称当模板缓冲。模板缓冲可以限制光栅化区域,实现多种技术效果。
  4. DEPTH_STENCIL_ATTACHMENT——这个是深度和模板的结合,因为这两个经常会同时使用。

对于阴影映射技术,我们只需要深度缓冲。成员m_shadowMap是一个贴图的句柄,他将会被绑定到DEPTH_ATTACHMENT附着点。ShadowMapFBO同样提供了一些方法,他们将会在主渲染循环使用。我们将会在渲染阴影映射之前调用BindForWriting(),而在第二个渲染通道中,调用BindForReading()。

(shadow_map_fbo.cpp:43)

glGenFramebuffers(1, &m_fbo);

这里我们创建了FBO。和贴图和缓冲一样,我们指定了GLuints数组的地址和大小。此数组用句柄填充。

shadow_map_fbo.cpp:46)

glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

接着,我们创建了贴图,称当的是阴影映射图。通常,这个是标准的2D贴图,然后对其做具体的参数设置:

  1. 内部格式是GL_DEPTH_COMPONENT。这个和之前用到的函数不同,它的内部颜色类型是GL_RGB。GL_DEPTH_COMPONENT 意味着一个单精度的浮点数,它代表了标准化的深度。
  2. glTexImage2D 最后一个参数是空。这就意味着我们不提供任何数据初始化缓冲。这个意味着,我们想要每帧都包含深度信息值,每帧都有些不同。每当我们想开始新的帧的时候,我们会调用glClear()来清空缓冲。
  3. 我们告诉OpenGL,当贴图坐标超出边界的时候,限制在[0,1]区间。这种情况会在,从摄像机视角投射的窗口大于从光源角度投射的窗口时发生。为了避免奇怪的人造假象,比如wraparound效果(阴影在某处重复自身),我们采样clamp贴图坐标进行避免。
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);

我们已经创建了一个FBO,还有一个贴图对象,并且为贴图对象设置好参数以备阴影映射了。现在我们需要把贴图对象和FBO绑定。第一件事情,我们需要绑定FBO。这个会使当前和将来的操作都应用于FBO对象。这个函数接收FBO句柄和需要的目标。绑定的目标可以是GL_FRAMEBUFFER,GL_DRAW_FRAMEBUFFER ,GL_READ_FRAMEBUFFER。GL_READ_FRAMEBUFFER是在想从FBO中读取像素的时候使用,函数是glReadPixels。GL_DRAW_FRAMEBUFFER是当想渲染到FBO时使用。GL_FRAMEBUFFER 具备读写两个功能,建议使用这个类型初始化FBO对象。当我们会在真正开始渲染的时候使用GL_DRAW_FRAMEBUFFER。

(shadow_map_fbo.cpp:55)

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);

这里我们绑定阴影映射图到FBO的深度缓冲附着点。上面的FBO对应的附着目标是GL_FRAMEBUFFER,所以glFramebufferTexture2D的第一个参数是GL_FRAMEBUFFER。最后一个参数是揭示了没有mipmap层级被使用。mipmapping是贴图映射特性,它代表不同的分辨率,从高分辨率(其mipmap=0),到其他低分辨率(1-N)。这里我们仅仅使用高分辨率即可。第四个参数是阴影映射图的句柄,如果这里是0,那么当前的贴图就会和指定的附着点解耦(比如上面的深度缓冲)。

(shadow_map_fbo.cpp:58)

glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

由于我们不想渲染颜色缓冲,只是深度信息。我们只需要调用上面两句代码即可。默认情况,颜色缓冲目标是被置为GL_COLOR_ATTACHMENT0,但是我们FBO甚至连颜色缓冲都不要会包含。因此,最好告诉OpenGL我们真正的目的。有效的参数还可以是GL_NONE、GL_COLOR_ATTACHMENT0 到GL_COLOR_ATTACHMENTm ,这里m是GL_MAX_COLOR_ATTACHMENTS -1。这些参数只对FBO对象有效。如果默认的帧缓冲使用参数GL_NONE,GL_FRONT_LEFT,GL_FRONT_RIGHT,GL_BACK_LEFT ,GL_BACK_RIGHT。这些允许你绘制前后左右到缓冲。我们把读取缓冲设置为GL_NONE,记住我们不打算调用glReadPixel函数。这个主要是避免支持OpenGL3.x和OpenGL4.x的GPU会出现的问题。

(shadow_map_fbo.cpp:61)

GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);

if (Status != GL_FRAMEBUFFER_COMPLETE) {
    printf("FB error, status: 0x%x\n", Status);
    return false;
}

当配置FBO之后,最重要的事情是验证它的状态是否是OpenGL定义的完成状态。这就意味着没有错误被侦测到,并且帧缓冲能够被使用,上面的代码负责检测状态。

(shadow_map_fbo.cpp:72)

void ShadowMapFBO::BindForWriting()
{
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}

我们要渲染到阴影映射和渲染到默认缓冲之间做切换。在第二个渲染通道时,我们同样需要绑定阴影映射到输入。这个函数和下一个函数提供一个封装以便于做这件事情。上面的代码,简单的绑定FBO以被写入,这个在第一个渲染通道之前会被调用。

(shadow_map_fbo.cpp:78)

void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
    glActiveTexture(TextureUnit);
    glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}

上面的函数在第二次渲染绑定阴影映射图以备读取之前调用。注意到,我们绑定了贴图对象,而不是FBO本身。这个函数接收了贴图单元。贴图单元必须和shader中的sampler2D统一变量保持一致。很重要的一点是,当glActiveTexture 接收的参数是索引枚举的时候,如GL_TEXTURE0, GL_TEXTURE1等等。shader需要的仅仅是0和1,等等。

(shadow_map.vs)

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;

out vec2 TexCoordOut;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoordOut = TexCoord;
}

我们将会对两个渲染通道使用同样的shader。顶点着色器两个通道都会使用,但是片段着色器只有第二个通道使用。由于我们第一次渲染的时候,关闭了颜色缓冲,所以片段着色器将不会被使用。上面的顶点着色器很容易。它产生了裁剪空间坐标。第一次渲染,贴图坐标是冗余的,因为没有片段着色器。但是,但是这个不影响,因为要公用顶点着色器。正如你看到的,从shader的角度,这里是z通道还是真正的渲染通道没有区别。真正的区别在于,应用传递光源视角的WVP矩阵到第一个通道,而在第二个通道传递的是摄像机视角出的WVP矩阵。第一次渲染,z被距离光源最近的深度值填充。而第二次渲染是被距离摄像机最近的点的深度值填充。第二次渲染,我们需要纹理坐标,因为片段着色器需要从阴影映射图采样。

(shadow_map.fs)

#version 330

in vec2 TexCoordOut;
uniform sampler2D gShadowMap;

out vec4 FragColor;

void main()
{
    float Depth = texture(gShadowMap, TexCoordOut).x;
    Depth = 1.0 - (1.0 - Depth) * 25.0;
    FragColor = vec4(Depth);
}

这个是片段着色器,用来展示渲染通道的阴影映射图。2D纹理坐标用来从阴影映射图中采样。阴影映射图是通过GL_DEPTH_COMPONENT 类型创建的内部格式。这意味着基本纹理单元是单精度浮点数,而不是颜色。这就是为什么在采样的时候使用.x的原因。透视矩阵有一个知道的行为,就是它把距离摄像机更近的点映射到[0,1]范围。理论上z要高精度,因为距离摄像机越近,误差越明显。当展示深度缓冲的时候,我们有可能遇到的问题是,结果的图片不是很清晰。因此,在我们从阴影映射图采样深度之后,我们会对其进行缩放,缩放的系数是当前的点到最远的边(z=1.0)的距离,然后用1.0减去这个值。这样会提高最终图片的效果。我们使用新的深度值来创建颜色,颜色是四位向量,所以我们用这个值,填充RGBA四个通道,也就是上文的vec4(Depth)。这样我们会得到一张灰度变化的图(近处较黑、远处较白)。

现在,我们来看看怎样把两个片段的代码结合起来,创建应用程序:

(tutorial23.cpp:106)

virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_scale += 0.05f;

    ShadowMapPass();
    RenderPass();

    glutSwapBuffers();
}

主渲染函数变得很简单,因为大量的函数移动到其他的函数内了。比如,全局变量用来更新摄像机的位置和类成员方法,用来旋转物体。接着我们调用函数来渲染阴影映射,然后再展示结果。最终,调用glutSwapBuffer()展示结果到屏幕。

(tutorial23.cpp:117)

virtual void ShadowMapPass()
{
    m_shadowMapFBO.BindForWriting();

    glClear(GL_DEPTH_BUFFER_BIT);

    Pipeline p;
    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 5.0f);
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());

    m_pMesh->Render();

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

我们从shadow map通道开始,它首先是绑定FBO对象。从现在开始,所有的深度值,都会被写入到shadow map贴图,所有的颜色缓冲会被忽略掉。我们清空深度缓冲,在做其他事情之前。接着,创建管线类,用于渲染网格(本例使用坦克模型)。这里有一点需要注意,这里的摄像机更新位置是基于spot光的位置和朝向。渲染网格之后,调用glBindFramebuffer(GL_FRAMEBUFFER,0),还原默认的缓冲。

virtual void RenderPass()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pShadowMapTech->SetTextureUnit(0);
    m_shadowMapFBO.BindForReading(GL_TEXTURE0);

    Pipeline p;
    p.Scale(5.0f, 5.0f, 5.0f);
    p.WorldPos(0.0f, 0.0f, 10.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());
    m_pQuad->Render();
}

渲染通过从清除颜色和深度缓冲开始。这些缓冲属于默认的缓冲。我们告诉shader,使用纹理单元0,绑定阴影映射图来读取纹理单元0。从现在开始一切如常。我们缩放四边形,把它放在摄像机的前面然后渲染它。在光栅化阶段,阴影映射图被采样,然后展示。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值