用OpenGL搭建并渲染一个场景

Posted by LvKouKou on December 7, 2023

计算机图形学期末大作业:用OpenGL搭建并渲染一个场景

选题

在本次大作业中,我们小组选择的是用OpenGL搭建并渲染一个场景

场景描述

在广东的某个角落,有着一片神奇的海域。在这片海域上,我们决定搭建三个遗世独立的漂浮小岛,每个小岛都拥有独特的景观和魅力。

第一个小岛是一个带有雪景的街景,这是广东难得一见的奇景。当人们踏足小岛时,他们会被一片银装素裹的景象所震撼。纯净洁白的雪花轻轻飘落,落在屋顶、街道和树木上,仿佛将整个小岛化为了一个童话世界。人们可以在这里畅快地堆雪人,打雪仗,或者只是欣赏着美丽的雪景散步。

第二个小岛是粉色的蛋糕甜品岛,令人垂涎欲滴。当人们踏足这座小岛时,他们会被芬芳的甜味所包围。岛上到处都是各种各样精致的蛋糕,小岛中央耸立着一座高大的蛋糕塔,它由五彩斑斓的糖果砌成,美轮美奂。人们可以在这里尽情地品尝美味的甜品。

第三个小岛是一片未开发的原生态区域,拥有壮丽的山脉和清澈的河流。当人们踏足这片小岛时,他们会被大自然的美景所震撼。郁郁葱葱的森林覆盖了整个小岛,山峦起伏,宛如一幅画卷。河流从山上奔流而下,流过岛屿的每个角落,清澈见底。岛上还有一些野生动物栖息,人们可以远足探险,欣赏野生花草和鸟类,或者在河边搭起帐篷,感受大自然的宁静与美好。

这三个小岛通过一座彩虹桥连接在一起,形成了一个完美的整体。当人们踏上这座绚丽多彩的桥梁时,他们不仅可以欣赏到雪景小岛、蛋糕甜品岛和原生态小岛的独特景观,还能感受到它们带来的不同氛围和体验。这座彩虹桥像一道奇迹般的连接,将人们带入一个充满惊喜和幸福的世界。在这个奇幻的漂浮小岛群中,人们找到了属于自己的梦幻天堂。无论是雪景街景、粉色蛋糕甜品岛还是未开发的原生态小岛,每个小岛都散发着独特的魅力,让人们流连忘返。这个小岛群成为了一个融合了多种美好元素的理想之地,给予人们无尽的惊喜和快乐。

在这里,他们可以逃离尘嚣,放松身心,享受到独一无二的旅程。

效果展示

最终实现结果如下:

操作方式简述:

  • 自由移动功能:WSAD键实现前后左右,空格和CTRL实现上升下降
  • 视角移动功能:鼠标移动实现视角转向
  • 画面缩放功能:鼠标滚轮实现画面缩放
  • 摄像机参数重置功能:R键实现摄像机回到原点
  • 控制光照角度功能:JKNM键来改变光照的角度,H键恢复默认角度
  • 切换天空盒贴图:B键切换天空盒贴图
  • 控制骨骼动画模型状态:Z键切换模型状态为运动/静止
  • 切换骨骼动画模型:V键切换不同骨骼动画模型

(我们在模型中隐藏了一个小彩蛋,可以召唤出奥特曼)

运行时渲染时间大约为3分钟,请耐心等待。


总览和动态骨骼(以及两个不同的天空盒):

Two Photos Side by Side
image-20231207171232677 20231207_161241

房间的家具和街道:

Two Photos Side by Side
image-20231207171518790 image-20231207171632778

两个其sr他小岛以及两个天空盒:

Two Photos Side by Side
image-20231207171841763 image-20231207171945550

展示视频:


接下来将介绍我们是如何一步步完成本次项目的。


模型的建立

我们选择使用Blender搭建3D场景,调整模型位置和大小时,为了使得模型可以与地板紧密相连,需要先在右上角选项按钮选择仅影响原点,按下G移动原点到物体最下方,然后取消仅影响原点。

image-20231206155145579

然后选择中间的“磁铁”吸附将其吸附到最近面,这样每个物体就可以吸附到地板上,然后移动位置即可。初始模型如图:

image-20231206155634662

街景:

image-20231206160305386

为了增加细节,我们还在房子内加入了家具:

image-20231206155741733

image-20231206155818253

纹理映射

现在模型建立好了,但是仍是一个黑白的世界,为了给世界添加色彩,我们要进行纹理映射,将其变成彩色的时间,我们采用图像纹理映射,首先在Blender中进入UV编辑,按下“U”选择智能UV映射即可,然后将找到的图像素材分别添加到房子。

image-20231206160058368

在布局页面找到材质,选择图像纹理,然后添加图片,例如:

image-20231206160656381

添加纹理后效果:

image-20231206160618016

街景:

image-20231206160759916

家具:

image-20231206161414246

蛋糕甜品岛:

image-20231206160837311

原生态自然小岛:

image-20231206160920745

增加雪景时,可以使用Blender的官方插件RealSnow,到编辑->偏好设置->插件 搜索 snow,选择即可,然后选择物体点击AddSnow,选择覆盖大小即可

image-20231206161011318

增加雪景后街道效果如下:

image-20231206161134481

最后导出为obj文件即可用assimp库加载,要注意的是导出为.obj的同时还会有.mtl文件,这是纹理映射的文件。

代码框架的搭建

本次作业的代码是在前几次作业用到的LearnOpenGL文档提供的基础框架上添加补充实现的

  • 用到的第三方库有glad、glfw、assimp、glm,代码分别存放在/code/include里的同名文件夹中,其静态库放在/code/include/lib中。
  • 我们自己实现的头文件以及着色器等代码则放在/code/include/OpenGL中。
  • 代码中用于渲染的模型文件及其纹理图片放在/code/finalmodel中
  • 天空盒图片文件放在/code/skybox中

  • 动态骨骼的动画文件放在/code/dae中
  • 主文件main.cpp放在/code中

加载静态模型文件

​ 在本项目中,实现了用Assimp库加载obj文件。当使用Assimp导入一个模型的时候,它通常会将整个模型加载进一个场景(Scene)对象,它会包含导入的模型/场景中的所有数据。Assimp会将场景载入为一系列的节点(Node),每个节点包含了场景对象中所储存数据的索引。我们将创建我们自己的model和mesh类来加载并储存导入后的模型。

​ 通过使用Assimp库,我们可以加载不同的模型到程序中,但是载入后它们都被储存为Assimp的数据结构。我们最终仍要将这些数据转换为OpenGL能够理解的格式,这样才能渲染这个物体,于是我们定义一个我们自己的网格类来实现这个效果。mesh.h中为实现网格类的代码实现。在构造器中,我们将所有必须的数据赋予了网格,我们在setupMesh函数中初始化缓冲,并最终使用Draw函数来绘制并渲染网格,这便是这份代码的整体实现思路。

​ 我们使用Assimp来加载模型后会将它转换至多个网格对象,前文代码只实现了一个网格的绘制,于是我们创建另一个类用来包含多个网格,方便管理一个或者多个物体的模型。这便是model类。model类包含了一个mesh对象的vector,在构造器中,我们给它一个文件路径,它会直接通过loadModel来加载文件,并且调用Draw函数来绘制这个模型。在loadModel函数中具体实现了使用assimp库中的ReadFile函数来读取obj文件并将其转换为assimp库自己的数据结构。在processMesh函数中实现了将assmip库自己的数据结构转换为我们的mesh对象。处理网格的过程主要有三部分:获取所有的顶点数据,获取它们的网格索引,并获取相关的材质数据。处理后的数据将会储存在三个vector当中,我们会利用它们构建一个mesh对象,并返回它到函数的调用者那里。

​ 关于我们是如何将assmip库自己的数据结构转换为我们的mesh对象的,assimp的接口定义了每个网格都有一个面数组,每个面代表了一个图元,在我们的例子中它总是三角形。一个面包含了多个索引,它们定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制,所以如果我们遍历了所有的面,并储存了面的索引到indices这个vector中就可以了。

​ 以上实现了如何渲染出模型,但此时模型还没有纹理,是一片黑的,于是我们还要实现渲染纹理的逻辑。和节点一样,一个网格只包含了一个指向材质对象的索引。如果想要获取网格真正的材质,我们还需要索引场景的mMaterials数组。我们使用一个叫做loadMaterialTextures的工具函数来从材质中获取纹理。这个函数将会返回一个Texture结构体的vector,我们将在模型的textures vector的尾部之后存储它。这就是使用Assimp导入模型的全部了。

​ 实现代码放在code文件夹内


​ 为了更好地理解模型是怎么储存的,我们来分析模型文件的组成,一个模型最重要的两个文件为.obj和.mtl文件,前者储存了模型顶点信息,后者储存了纹理信息。

​ obj文件中有如下几种条目信息:

  • o:表示对象名称(Object Name),后面跟着一个字符串,用于给模型中的对象命名。一个Obj文件可以包含多个对象,每个对象可以包含多个面片。例如:o Cube 表示将接下来的面片定义为名称为 “Cube” 的对象。
  • mtllib xx.mtl:表示该obj文件使用xx.mtl中的纹理来渲染
  • v:表示顶点(Vertex),后面跟着三个浮点数,分别代表顶点在三维空间中的X、Y、Z坐标。例如:v 0.0 1.0 2.0 表示一个位于 (0.0, 1.0, 2.0) 坐标的顶点。
  • vn:表示法线向量(Vertex Normal),后面跟着三个浮点数,分别代表顶点的法线方向在三维空间中的X、Y、Z分量。法线向量通常用于渲染物体的光照效果。例如:vn 0.0 1.0 0.0 表示一个指向上方的法线向量。
  • vt:表示纹理坐标(Texture Coordinate),后面跟着两个浮点数,分别代表纹理坐标的U、V分量。纹理坐标通常用于给模型贴上纹理图片。例如:vt 0.5 0.2 表示一个纹理坐标为 (0.5, 0.2) 的点。
  • s:表示光滑组(Smoothing Group),后面跟着一个整数值。它用于定义一组共享相同法线的面片,以实现平滑的表面效果。当数字为0时,表示取消光滑组。例如:s 1 表示将接下来的面片设置为光滑组 1。
  • f:表示面片(Face),后面跟着多个顶点索引,用于定义一个面片。每个顶点索引由顶点索引、纹理坐标索引和法线索引组成,它们之间使用斜线(/)分隔。例如:f 1/1/1 2/2/2 3/3/3 表示一个由三个顶点组成的面片,每个顶点包含一个顶点索引、一个纹理坐标索引和一个法线索引。

mtl文件通常与obj文件一起使用,用于定义3D模型的材质属性。下面是对每个参数的解释:

  • newmtl xx:定义了一个名为”xx”的新材质。
  • Ns :指定了材质的光泽度。这个值通常在0到1000范围内,数值越高,表面的高光反射越强烈。
  • Ka :定义了材质的环境颜色(Ambient Color)。RGB值都为0则表示没有环境光照射。
  • Kd:定义了材质的漫反射颜色(Diffuse Color)。
  • Ks :定义了材质的镜面反射颜色(Specular Color)。
  • Ni :指定了材质的折射率(Index of Refraction),1.0表示无折射。
  • d :指定了材质的透明度(Dissolve)。1.0表示完全不透明。
  • illum:指定了光照模型。0表示禁用光照,该材质不受场景中的光照影响。1表示基础光照模型,只考虑漫反射光照。2表示带高光的光照模型,同时考虑漫反射和镜面反射光照。3表示反射光照模型,包括漫反射、镜面反射和环境光照。

​ 另外还有三个 map_开头的参数用于指定纹理贴图的文件路径。具体来说:

  • map_Kd 1.png:指定了漫反射纹理贴图的文件路径,这里是”1.png”。
  • map_Bump:指定了凹凸贴图(Bump Map)的文件路径。
  • map_Ks :指定了镜面反射贴图的文件路径。

交互式摄像机的实现

​ 当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量,在之前的作业中已实现并详细解释过,这里不再赘述。我们的摄像机类代码存储在camera.h中

​ 首先使用glfwSetCursorPosCallbackglfwSetScrollCallback来注册回调函数,实现获取鼠标键盘输入信息,在processInput函数中来对摄像机的参数进行实时的变更。例如当W键被按下,表达式glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS即为True,跳转到我们设定的函数。

​ 其次在每次渲染循环中都记录一个时间参数,用两次循环的参数来计算出时间差deltaTime,将这个时间差乘上移动速度,即可得到按下按键对摄像机的影响程度,比如按下1秒的W能使摄像机前进多少。

​ 我们的交互式摄像机通过main loop中的processInput(window)函数实现了多个功能,(由于在main loop中processInput(window)识别按键的速度太快,切换会出现跳跃的现象,故在切换功能时采用key_callback绑定函数,实现按键一次切换一次的功能),如下所示:

  • 自由移动功能:WSAD键实现前后左右,空格和CTRL实现上升下降

    当我们按下WASD键的任意一个,摄像机的位置都会相应更新。如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。如果我们希望向左右移动,我们使用叉乘来创建一个右向量,并沿着它相应移动就可以了。这样就创建了使用摄像机时熟悉的横移效果。

    只实现前后左右逻辑的摄像机似乎可控度还不够高,于是我实现了上升下降两种逻辑,灵感来源于Minecraft。空格代表上升,CTRL代表下降,实现原理只要把摄像机坐标的纵轴加上或减去一个量即可。

  • 视角移动功能:鼠标移动实现视角转向

    在camera类中,存放着三个欧拉角,分别为俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll)。

    俯仰角是描述我们如何往上或往下看的角,偏航角表示我们往左和往右看的程度。滚转角代表我们如何翻滚摄像机,在本项目中不需要修改。每个欧拉角都有一个值来表示,把三个角结合起来我们就能够表示摄像机的观察方向了,那么我们读取鼠标的移动幅度,并将其修改到Pitch或Yaw中,就可以实现视角随着鼠标移动。

  • 画面缩放功能:鼠标滚轮实现画面缩放

    首先读取鼠标滚轮输入,记录进一个变量Zoom内,用这个量修改projection矩阵中的fov参数实现

  • 摄像机参数重置功能:R键实现摄像机回到原点

    记录下初始时摄像机的参数(例如Pitch,Yaw,摄像机坐标等等参数),在按下R键后调用reset()函数,来将摄像机的值恢复为默认值。

  • 控制光照角度功能:JKNM键来改变光照的角度,H恢复默认角度

    通过修改旋转矩阵来旋转光源位置向量,逻辑和上述功能大同小异

  • 切换天空盒贴图:B键切换天空盒贴图

    使用processInput(window)逻辑会导致切换时天空盒闪烁,于是使用key_callback实现。按键B会触发立方体贴图重新从容器中加载下一组天空盒,同时更新当前天空盒索引id。

  • 控制骨骼动画模型状态:Z键切换模型状态为运动/静止

    骨骼动画动态效果是通过在main loop中加入UpdateAnimation(deltaTime)实现动作更新的。按键Z会触发对bool变量movingflag的修改,True时main loop才会调用UpdateAnimation(deltaTime),否则不调用并显示暂停帧的静态模型效果。

  • 切换骨骼动画模型:V键切换不同骨骼动画模型

    项目中动态模型容器共有4套舞蹈动作的骨骼动画模型可供加载。按键V会触发对animateModel、dancingAnimation和animator的重新加载,加载对象是容器中的下一个模型,同时更新当前骨骼动画模型索引id。注意,按键后重新加载渲染需要等待几秒。

加载骨骼动画模型文件

在我们的项目中,实现了用Assimp库加载动态骨骼动画dae文件。

加载动态骨骼动画模型是在加载静态模型的基础上实现的。写在前面:我们在实现动态骨骼的时候,新加入了assimp_glm_helpers.hanimdata.hmodel_animation.hanimator.hbone.hanimation.h这几个新文件,下面会一一介绍其作用和实现思路。

首先我们需要了解骨骼动画是由什么构成的。经过学习了解到,动画模型是由蒙皮、骨骼和关键帧三个组件构成的。其中蒙皮的作用是为动画模型添加外观,在静态模型中已经通过网格mesh实现;骨骼可以理解为是将模型结构化为一具骨架,每一部分就是一块骨头,骨头之间由关节相互连接相互影响(父子关系),以骨骼移动逻辑实现蒙皮移动的动态效果,即实现骨骼动画效果;关键帧是通过在动画相邻两个帧之间插值,以实现姿势的平滑过渡。

  • assimp_glm_helpers.h:实现将Matrix、Vector、Quaternion转成GLM格式的功能;

  • animdata.h:BoneInfo结构体存储骨骼信息;

  • model_animation.h:在model.h的基础上添加了骨量提取支持,并重新将类名命名为Model2,具体来说就是添加了SetVertexBoneDataToDefaultSetVertexBoneDataExtractBoneWeightForVertices三个函数。在processMesh过程中,原先model.h只实现了提取蒙皮的操作,在这里我们首先调用SetVertexBoneDataToDefault将骨骼数据设为默认值,最后调用ExtractBoneWeightForVertices处理骨骼,如果是新的骨头则加入BoneInfo,如果是已存在的骨骼则进一步提取其子节点的骨骼数据,调用SetVertexBoneData更新骨骼数据;

  • animator.h:Animator是骨骼动画制作的顶层类,类方法CalculateBoneTransform会为我们计算所需的最终骨骼转换矩阵,最重要的骨骼动画更新类方法UpdateAnimation会调用其更新骨骼动画;

  • Bone.h:Bone类读取所有关键帧数据的单个骨骼数据,根据当前动画时间在关键帧之间进行插值,即平移、缩放和旋转。

  • animation.h:Animation类读取存储AssimpNodeData和Bone类的参与动画的所有骨骼及其关键帧数据,存储动画的持续时间、控制帧之间插值的速度。

实现动画功能的类关系视图如下:

1701936136438

骨骼动画模型的加载可以通过以下代码实现:

1
2
3
animateModel = Model2(animPath);//添加模型dae文件路径
dancingAnimation = Animation(animPath, &animateModel);
animator = Animator(&dancingAnimation);

接着就是渲染模型,与静态模型的渲染过程大体一致,不赘述。

骨骼动画效果如下:

20231207_161241

天空盒背景的实现

天空盒背景是通过立方体贴图实现的。所谓立方体贴图,就是一个由6个2D纹理组成的纹理,6个2D纹理分别对应立方体的6个面。将场景置于一个大的立方体内,6个面的纹理相互吻合形成全景世界,给予观察者一种处在比实际大得多的环境当中,这就是天空盒的效果。事先在网上搜集到多种天空盒素材,其中场景有海域、草原、火山、星球等等,资源放在/skybox文件夹中。

首先加载天空盒,即是加载立方体贴图,主要实现函数是loadCubmap。先创建一个纹理,然后将其绑定到纹理目标GL_TEXTURE_CUBE_MAP中;随后将6张图像按顺序载入生成该纹理,即遍历每个面调用一次glTexImage2D。OpenGL中提供了6个特殊的纹理目标供立方体贴图使用:

  1. GL_TEXTURE_CUBE_MAP_POSITIVE_X
  2. GL_TEXTURE_CUBE_MAP_NEGATIVE_X
  3. GL_TEXTURE_CUBE_MAP_POSITIVE_Y
  4. GL_TEXTURE_CUBE_MAP_NEGATIVE_Y
  5. GL_TEXTURE_CUBE_MAP_POSITIVE_Z
  6. GL_TEXTURE_CUBE_MAP_NEGATIVE_Z
  7. 分别代表立方体的“右左上下后前”6个面,并且可以用int类型线性增值来遍历。最后设定立方体贴图的环绕和过滤方式分别为GL_CLAMP_TO_EDGEGL_LINEAR,完成loadCubmap函数。

接着显示天空盒。我们创建了一组新的skyboxVAOskyboxVBO和一组立方体顶点,加载了一组新的顶点着色器skybox.vs和片段着色器skybox.fs。然后在main loop中渲染天空盒,只需绑定立方体贴图纹理即可。注意一在渲染过程中要先glDepthMask(GL_FALSE)禁止深度写入,渲染完再glDepthMask(GL_TRUE),以达到天空盒永远置于场景背后的效果;注意二view用glm::mat4(glm::mat3(camera.GetViewMatrix()))初始化,移除了位移变换但保留旋转变换,以达到观察者移动摄像机时与天空盒相对距离不变,依旧感觉自己位于天空盒中心,但可以旋转观察各个视角的场景真实效果。

从天空盒素材容器中挑选海域、草原两组来加载渲染,天空盒效果如下:

1701930347400

1701930462953

实现Blinn-Phong光照明模型

 首先实现Phong光照模型,然后在此光照模型的基础上改进。

Phong光照模型

现实世界的光照是极其复杂的,而且会受到诸多因素的影响,这是我们有限的计算能力所无法模拟的。因此OpenGL的光照使用的是简化的模型,对现实的情况进行近似,这样处理起来会更容易一些,而且看起来也差不多一样。这些光照模型都是基于我们对光的物理特性的理解。其中一个模型被称为Phong光照模型(Phong Lighting Model)。Phong光照模型的主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。

  • 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
  • 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是Phong光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
  • 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
  • 最后,我们可以将环境光、漫反射光和镜面光的颜色和强度加权合成,得到最终的光照效果。通常,我们可以使用Phong模型中的以下公式来计算每个片元的光照:
1
2
3
4
color = ambient + diffuse + specular
ambient = ka * La                // 环境光
diffuse = kd * Ld * max(0, dot(N, L))   // 漫反射光
specular = ks * Ls * pow(max(0, dot(R, V)), shininess)  // 镜面光

其中,La、Ld、Ls分别表示环境光、漫反射光和镜面光的颜色,ka、kd、ks分别表示它们的强度,N表示表面法线,L表示光线方向,R表示反射光线方向,V表示视线方向,shininess表示镜面光的反光度。

Blinn-Phong光照模型

Phong光照不仅对真实光照有很好的近似,而且性能也很高。但是它的镜面反射会在一些情况下出现问题,特别是物体反光度很低时,会导致大片(粗糙的)高光区域。

出现这个问题的原因是观察向量和反射向量间的夹角不能大于90度。如果点积的结果为负数,镜面光分量会变为0.0。你可能会觉得,当光线与视线夹角大于90度时你应该不会接收到任何光才对,所以这不是什么问题。

然而,这种想法仅仅只适用于漫反射分量。当考虑漫反射光的时候,如果法线和光源夹角大于90度,光源会处于被照表面的下方,这个时候光照的漫反射分量的确是为0.0。但是,在考虑镜面高光时,我们测量的角度并不是光源与法线的夹角,而是视线与反射光线向量的夹角。

于是在Phong着色模型上加以拓展,引入了Blinn-Phong着色模型。Blinn-Phong模型与Phong模型非常相似,但是它对镜面光模型的处理上有一些不同,让我们能够解决之前提到的问题。Blinn-Phong模型不再依赖于反射向量,而是采用了所谓的半程向量(Halfway Vector),即光线与视线夹角一半方向上的一个单位向量。当半程向量与法线向量越接近时,镜面光分量就越大。

获取半程向量的方法很简单,只需要将光线的方向向量和观察向量加到一起,并将结果正规化(Normalize)就可以了:

1
2
3
vec3 lightDir   = normalize(lightPos - FragPos);
vec3 viewDir    = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);

接下来,镜面光分量的实际计算只不过是对表面法线和半程向量进行一次约束点乘(Clamped Dot Product),让点乘结果不为负,从而获取它们之间夹角的余弦值,之后我们对这个值取反光度次方:

1
2
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;

下面给出Blinn-Phong光照模型下的着色器

“advanced_lighting.vs”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
// declare an interface block; see 'Advanced GLSL' for what these are.
out VS_OUT { 
 vec3 FragPos; 
 vec3 Normal; 
 vec2 TexCoords;
} vs_out;

uniform mat4 projection;
uniform mat4 view;

void main()
{ 
 vs_out.FragPos = aPos; 
 vs_out.Normal = aNormal; 
 vs_out.TexCoords = aTexCoords; 
 gl_Position = projection * view * vec4(aPos, 1.0);
}

“advanced_lighting.fs”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#version 330 core
out vec4 FragColor;
in VS_OUT {
 vec3 FragPos;
 vec3 Normal;
 vec2 TexCoords;
} fs_in;
uniform sampler2D floorTexture;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform bool blinn;
void main(){
 vec3 color = texture(floorTexture, fs_in.TexCoords).rgb;
 // ambient
 vec3 ambient = 0.05 * color;
 // diffuse
 vec3 lightDir = normalize(lightPos - fs_in.FragPos);
 vec3 normal = normalize(fs_in.Normal);
 float diff = max(dot(lightDir, normal), 0.0);
 vec3 diffuse = diff * color;
 // specular
 vec3 viewDir = normalize(viewPos - fs_in.FragPos);
 vec3 reflectDir = reflect(-lightDir, normal);
 float spec = 0.0;
 if(blinn)
 {
  vec3 halfwayDir = normalize(lightDir + viewDir);
  spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
 } 
 else
 { 
  vec3 reflectDir = reflect(-lightDir, normal);
  spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
 } 
 vec3 specular = vec3(0.3) * spec; // assuming bright white light color
 FragColor = vec4(ambient + diffuse + specular, 1.0);
}

使用Shadow Map方法为物体构建一个阴影:阴影是光线被阻挡的结果;当一个光源的光线由于其他物体的阻挡不能够达到一个物体的表面的时候,那么这个物体就在阴影中了。阴影能够使场景看起来真实得多,并且可以让观察者获得物体之间的空间位置关系。

阴影映射(Shadow Mapping)背后的思路非常简单:我们以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的一定是在阴影之中了。具体的实现分为以下几步:

  • 深度贴图 第一步我们需要生成一张深度贴图(Depth Map)。深度贴图是从光的透视图里渲染的深度纹理,用它计算阴影。因为我们需要将场景的渲染结果储存到一个纹理中,我们将再次需要帧缓冲。 首先,我们要为渲染的深度贴图创建一个帧缓冲对象。然后,创建一个2D纹理,提供给帧缓冲的深度缓冲使用。生成深度贴图不太复杂。因为我们只关心深度值,我们要把纹理格式指定为GL_DEPTH_COMPONENT。我们还要把纹理的高宽设置为1024:这是深度贴图的分辨率。 我们需要的只是在从光的透视图下渲染场景的时候深度信息,所以颜色缓冲没有用。然而,不包含颜色缓冲的帧缓冲对象是不完整的,所以我们需要显式告诉OpenGL我们不适用任何颜色数据进行渲染。我们通过将调用glDrawBuffer和glReadBuffer把读和绘制缓冲设置为GL_NONE来做这件事。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// configure depth map FBO
// -----------------------
const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
// create depth texture
unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 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_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
float borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
// attach depth texture as FBO's depth buffer
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
  • 光源空间的变换 因为我们使用的是一个所有光线都平行的定向光。出于这个原因,我们将为光源使用正交投影矩阵,透视图将没有任何变形。 因为投影矩阵间接决定可视区域的范围,以及哪些东西不会被裁切,需要保证投影视锥(frustum)的大小,以包含打算在深度贴图中包含的物体。当物体和片段不在深度贴图中时,它们就不会产生阴影。 为了创建一个视图矩阵来变换每个物体,把它们变换到从光源视角可见的空间中,我们将使用glm::lookAt函数;这次从光源的位置看向场景中央。 二者相结合为我们提供了一个光空间的变换矩阵,它将每个世界空间坐标变换到光源处所见到的那个空间;这正是我们渲染深度贴图所需要的。最后合成一个lightSpaceMatrix,有了lightSpaceMatrix只要给shader提供光空间的投影和视图矩阵,我们就能像往常那样渲染场景了。
1
2
3
4
5
6
glm::mat4 lightProjection, lightView;
glm::mat4 lightSpaceMatrix;
float near_plane = 0.0001f, far_plane = 6000.5f;
lightProjection = glm::ortho(-180.0f, 180.0f, -100.0f, 100.0f, near_plane, far_plane);
lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0));
lightSpaceMatrix = lightProjection * lightView;
  • 渲染至深度贴图 当我们以光的透视图进行场景渲染的时候,我们会用一个比较简单的着色器,这个着色器除了把顶点变换到光空间以外,不会做得更多了。这个简单的着色器叫做depthShader,其顶点着色器内容如下:

“shadow_mapping_depth.fs”

1
2
3
4
5
6
7
8
9
#version 330 core
layout (location = 0) in vec3 position;

uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
    gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f);
}

由于我们没有颜色缓冲,最后的片段不需要任何处理,所以我们可以简单地使用一个空片段着色器。 渲染深度缓冲如下:

1
2
3
4
5
6
7
depthShader.Use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
  • 渲染阴影 正确地生成深度贴图以后我们就可以开始生成阴影了。这段代码在片段着色器中执行,用来检验一个片段是否在阴影之中,不过我们在顶点着色器中进行光空间的变换:

“shadow_mapping.vs”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;

out vec2 TexCoords;


out VS_OUT {
 vec3 FragPos;
 vec3 Normal;
 vec2 TexCoords;
 vec4 FragPosLightSpace;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform mat4 lightSpaceMatrix;


void main()
{
 gl_Position = projection * view * model * vec4(position, 1.0f);
 vs_out.FragPos = vec3(model * vec4(position, 1.0));
 vs_out.Normal = transpose(inverse(mat3(model))) * normal;
 vs_out.TexCoords = texCoords;
 vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}

片段着色器使用Blinn-Phong光照模型渲染场景。我们接着计算出一个shadow值,当fragment在阴影中时是1.0,在阴影外是0.0。然后,diffuse和specular颜色会乘以这个阴影元素。由于阴影不会是全黑的(由于散射),我们把ambient分量从乘法中剔除。

“shadow_mapping.fs”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#version 330 core
out vec4 FragColor;

in VS_OUT {
 vec3 FragPos;
 vec3 Normal;
 vec2 TexCoords;
 vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

float ShadowCalculation(vec4 fragPosLightSpace)
{
 [...]
}

void main()
{
 vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
 vec3 normal = normalize(fs_in.Normal);
 vec3 lightColor = vec3(1.0);
 // Ambient
 vec3 ambient = 0.15 * color;
 // Diffuse vec3 lightDir = normalize(lightPos - fs_in.FragPos);
 float diff = max(dot(lightDir, normal), 0.0);
 vec3 diffuse = diff * lightColor;
 // Specular
 vec3 viewDir = normalize(viewPos - fs_in.FragPos);
 vec3 reflectDir = reflect(-lightDir, normal);
 float spec = 0.0;
 vec3 halfwayDir = normalize(lightDir + viewDir);
 spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
 vec3 specular = spec * lightColor;
 // 计算阴影
 float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
 vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
 FragColor = vec4(lighting, 1.0f);

}

激活这个着色器,绑定合适的纹理,激活第二个渲染阶段默认的投影以及视图矩阵,就能得到阴影。