Table of contents generated with markdown-toc
基于OpenGL图形API,借助GLFW和GLAD开源库,使用C/C++语言编程开发的一个简易天体模拟系统,三维动态还原太阳、地球,月球的天体运动规律。
-
模型方面:
- 用三维球状模型(不低于30面)还原太阳、地球和月亮等天体。
- 不同的天体使用不同的材质和纹理。
-
动画方面:
- 以太阳为中心,模拟太阳自转、地球绕太阳公转,地球自转、月球绕地球公转、月球自转的天体运动。
- 天体的运动模仿真实运动轨迹(有所调整)。
-
交互控制:
- 实现通过按键切换不同的观察视角。
- 实现第一人称漫游视角。用键盘控制移动,用鼠标控制视野方向和缩放。
- 实现第三人称俯视视角。用鼠标控制观察视角的旋转和缩放。
-
渲染方面:
- 实现Phong式光照模型。
- 使用立方体贴图技术实现静态天空盒。
GLFW是一个针对OpenGL的C语言库,替代了较为老旧的GLUT库,它提供了一些渲染物体所需的最低限度的接口,允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入。本项目就是使用GLFW提供的API来实现项目窗口的设置,初始化及管理的。下面介绍构建GLFW开发环境的方法。
- 从GLFW官网上下载GLFW的源码,准备用来编译。
- 下载并安装CMake,在CMake中选中GLFW的源代码根目录,新建一个build文件夹用于存放生成编译后的文件。
- 选择将源文件项目生成为适用于Visual Studio的版本,指定好源文件路径和目标文件路径后,点击Configure按钮让CMake读取配置文件和源代码。使用默认配置,再次点击Configure保存设置。保存之后,点击Generate生成。
- 在项目文件外新建一个OpenGLSetting的文件夹,并创建includes和lib子文件夹,用于存放所有的第三方库的引用和.lib后缀的动态库。在VS中选中当前项目,进入“属性”,在“引用目录”和“库目录”中分别添加对应上述新建的两个文件夹的路径;进入“C++—>常规”,将includes路径添加到附加包含目录中;最后,在“链接器—>输入—>附加依赖项中添加glfw3.”
由于OpenGL只是一个标准规范,只提供了函数接口而没有实现。具体每个函数的实现方式一般由不同的驱动开发商针对特定显卡来完成。因此我们在使用OpenGL的提供的各种函数前,需要首先确定具体使用哪种版本的函数实现,频繁地寻找正确版本地函数会使整个项目的开发变得很麻烦,同时程序的源代码会变得冗长。而开源的GLAD库就帮我们提前确定好每个OpenGL函数合适的实现版本,所以在正式开发前还需要配置好GLAD。下面介绍具体的配置方法。
- 从GLAD在线服务中下载GLAD对应版本的GLAD库。在在线服务网站中选择对应的OpenGL版本(3.3),设置Profile为Core,选中Generate a loader,最后点Generate生成。
- 将生成的文件中的glad和KHR复制到前面创建的includes文件夹中,并在主程序中使用以下代码引用该glad库。
#include<glad/glad.h> |
---|
gl函数库是OpenGL的核心库,里面封装了OpenGL中最基本的3D函数。下面整理了部分在该项目中使用到的核心gl函数及具体的作用。
- glGenVertexArrays
void glGenVertexArrays(GLsizei n, GLuint *arrays); |
---|
函数功能:用来创建VAO,并生成对象ID。第一个参数指定需要创建的VAO数量。
- glGenBuffers
void glGenBuffers(GLsizei n, GLuint * buffers); |
---|
函数功能:用来创建VBO,并生成对象ID。第一个参数指定需要创建的VBO数量。
- glBindVertexArray
void glBindVertexArray(GLuint array); |
---|
函数功能:用来绑定VAO,使后续对于顶点属性的操作,顶点指针的配置以及相应的VBO都存储在当前绑定的VAO中。
- glBindBuffer
void glBindBuffer(GLenum target, GLuint buffer); |
---|
函数功能:将顶点缓冲对象绑定到GL_ARRAY_BUFFER上,使得后续所有对于顶点缓冲的操作的目标都为当前绑定的VBO。
- glBufferData
void glBufferData(GLenum target, GLsizeiptr size, const void * data, GLenum usage); |
---|
函数功能:将定义好的顶点数据data复制到缓冲的内存中
- glVertexAttribPointer
void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void * pointer); |
---|
函数功能:定义OpenGL解析VBO中顶点数据的方式,包括每种顶点的起始位置,偏移步长,数据量大小,是否要标准化等。
- glGenTextures
void glGenTextures( GLsizei n, GLuint *textures); |
---|
函数功能:用来创建纹理对象,并生成对象ID。第一个参数指定需要创建的纹理数量。
- glBindTexture
void glBindTexture(GLenum target, GLuint texture); |
---|
函数功能:将纹理对象texture绑定到OpenGL中。
- glTexParameteri
void glTexParameteri(GLenum target, GLenum pname, GLint param); |
---|
函数功能:设置纹理的环绕方式和过滤方式。Target指定设置的目标,pname指定目标的某个纹理轴/指定缩放,param指定环绕方式或过滤方式。
- glDrawArrays
void glDrawArrays(GLenum mode, GLint first, GLsizei count); |
---|
函数功能:使用当前激活的着色器程序、定义好的顶点属性,VBO来绘制图元。
- glDrawElements
void glDrawElements(GLenum mode, GLsizei count, GLenum type, const GLvoid *indices ); |
---|
函数功能:使用绑定在OpenGL中的EBO索引缓冲对象来按照索引绘制图元。
- glViewport
void glViewport(GLint x, GLint y, GLsizei width, GLsizei height ); |
---|
函数功能:定义屏幕空间的坐标原点位置和大小。用于视口变换阶段将模型从NDC空间中变换到屏幕空间。
- glShaderSource
void glShaderSource(GLuint shader, GLsizei count, const GLchar **string, const GLint *length); |
---|
函数功能:将指定的shader源代码加载为OpenGL可识别的着色器对象。
- glAttachShader
void glAttachShader(GLuint program, GLuint shader); |
---|
函数功能:将同一渲染管线中的着色器关联到目标着色器程序。
- glUseProgram
void glUseProgram(GLuint program); |
---|
函数功能:激活目标着色器程序。同时在每次设置Uniform值之前都要调用该函数激活着色器程序。
- glUniform
void glUniform1f(GLint location, GLfloat v0); void glUniform2f(GLint location, GLfloat v0, GLfloat v1); …… |
---|
函数功能:添加不同的后缀来设置不同类型的uniform值。
对于该项目,需要用到的模型和纹理就是太阳的模型和纹理,地球的模型和纹理以及月亮的模型和纹理。由于太阳、地球及月球都可以近似成均匀的球状模型,因此它们可以共用一份网格数据。因此首先使用Blender建模软件构建了一个大约由15000个三角形面组成的球体作为三个主要天体的网格数据。
随后在网上收集到了太阳,地球和月球的表面纹理贴图,作为三个天体各自表面的漫反射贴图。由于实际在宇宙中观察天体时几乎没有反射现象,因此不需要而外使用镜面反射贴图来还原反射的效果。
最后,在Blender中将设计好的模型以.obj的格式导出。导出后每种天体模型文件夹内包含三个文件,分别是.obj格式的模型数据文件,.mtl格式的材质文件以及.jpg格式的贴图文件。下面详细介绍在OpenGL中借助Assimp库导入天体模型数据的步骤。
-
构建Assimp:
Assimp是一个常用的模型导入库,使用该库可以将模型文件加载至Assimp的通用数据结构中存储和管理。在Assimp官方下载对应版本的源代码,使用CMake仿照前面配置glfw时的步骤编译源文件成可用的预编译库。 -
导入模型:
在主程序文件中包含Assimp对应的头文件,如下:
#include <assimp/Importer.hpp> #include <assimp/scene.h> #include <assimp/postprocess.h> |
---|
使用LoadModel函数导入模型到Assimp中的scene数据结构中,并检查scene中的根节点的导入情况。若一切正常,定义递归函数processeNode来递归处理所有的子节点。
- 将Assimp对象转为Mesh对象:
首先获取模型中的所有顶点数据。使用mesh->mNumVertices遍历网格中的所有顶点,包括顶点的xyz坐标,法线的xyz坐标和纹理坐标。
然后获取模型中的所有网格索引。使用mesh->mFaces遍历每个网格的面数组,即一个图元。由于设置了aiProcess_Triangulate,所以一定是三角形图元。
最后获取模型相关的材质数据。使用scene->mMaterials获取网格中位于mMaterialIndex的材质。在获取到aiMaterial对象后,使用loadMaterialTextures函数分别加载漫反射贴图和镜面贴图。
对于加载过的纹理使用一个数组textures_loaded来进行全局储存,并在加载其他纹理时判断与其中已有的纹理进行比较,若该纹理已加载过则跳过以提高效率。
对于每个天体模型表面贴图从文件到OpenGL 系统的导入,借助一个支持多种流行格式的图像加载库stb_image.h来实现。在需要导入纹理的源文件中如下引用即可。
#define STB_IMAGE_IMPLEMENTATION #include "stb_image.h" |
---|
借助该库中stbi_load函数就可以从指定的文件路径将图片加载到程序的内存中,同时获取到这张图片的宽高以及颜色通道的个数。在该项目中,通过自定义的TextureFromFile函数实现在读取模型的过程中,读取模型对应的纹理贴图。而要使用该纹理,需要进行下面两个步骤:
-
**生成纹理:
** 使用gl库中的glGenTextures函数为目标纹理分配一个纹理的ID,随后使用glBindTexture函数将该ID绑定为我们想要使用的形式,即2D纹理GL_TEXTURE_2D。随后使用glTexImage2D函数将导入的图片数据与刚才定义的纹理ID绑定起来。为了防止相机离贴了纹理的模型距离的不同导致纹理失真,还需使用glGenerateMipmap函数生成多级渐远纹理。
由于在后续纹理采样的时候,纹理的大小往往不能和要映射的网格大小完全匹配,因此需要使用glTexParameteri函数设置纹理的环绕方式和过滤方式。最后使用stbi_image_free函数释放资源。
-
应用纹理:
要使用纹理就需要纹理映射的技术。这部分由OpenGL系统完成,又因为其中的纹理坐标已经通过模型导入时得到了,因此剩下需要完成的工作主要是在着色器中对纹理进行采样。
首先在顶点着色器中,需要增加一个新的输入aTexCoords来接收纹理的坐标,但不需要进行额外的操作,因此直接定义同样类型的TexCoords输出到片元着色器中。
在片元着色器,需要使用sampler2D来定义一个采样器负责管理待采样的纹理,并在最终计算片元颜色时使用texture函数来根据TexCoords对sampler2D的纹理进行采样,完成纹理映射的工作。
除此之外,在C++部分调用绘制指令前,需要使用glActiveTexture激活纹理单元(对应在片元着色器中创建的采样器),最后使用glBindTexture将要采样的纹理ID与激活的纹理单元绑定。
太阳、地球和月球三者均为天体,有着各自的运动规律或运动轨迹。它们由共同之处,都会发生自转,且地球和月球都会公转。但它们也有不同之处,如大小不同,自转的速度、旋转轴不同,公转的速度、对象不同等等。
因此,本项目中通过自定义的Planet类对这些有所异同的星体对象进行管理。Planet类的成员具体如下:
对于每一种天体,首先在main函数中实例化一个Planet对象,初始化时构造函数中补充该天体的部分参数,包括天体的名字,模型文件的路径,大小,自转速度等等。
随后再主渲染循环中,每一帧都要更新模型的模型变换矩阵,观察矩阵和投影变换矩阵。特别的,对于会发生自转或公转的天体,需要调用Rotate或RotateAround函数,并将上一帧的时长dt作为参数来实现每一帧天体的旋转或位移。更新完mvp矩阵后,在绘制前调用SetMVP函数,并传入对应的着色器作为参数来将该模型的mvp矩阵提交到着色器中。
最后,调用Draw函数绘制天体。
所有的天体都不是完全静止的,因此在每一帧绘制前,都需要更新模型的模型变换矩阵(以下简称m矩阵),观察矩阵(以下简称v矩阵)和投影变换矩阵(以下简称p矩阵),并将最新的矩阵数据更新到显存中交给着色器使用。
在C++中,通过定义的Shader类来实现着色器中各种数据类型的全局变量的更新,特别的,对于4x4的mvp矩阵,使用内部封装了glUniformMatrix4fv函数的setMat4函数来实现指定全局变量的更新。
-
模型变换矩阵
m矩阵负责模型的位移,缩放和旋转的实现。而在计算m矩阵是,要注意将正常线性变换的顺序反过来,即先平移,再叠加缩放,最后叠加旋转。具体的,平移使用glm数学库中的glm::translate函数实现,缩放使用glm::scale函数实现,绕指定旋转轴的旋转使用glm::rotate函数实现。最后使用setMat4更新到着色器中。
-
观察矩阵
v矩阵负责相机观察视角变换的实现。由于相机的视角实时都有可能发生变换,因此也需要在每一帧动态更新它。具体的,使用自定义的camera相机类中封装了glm::lookAt函数的GetViewMatrix函数获取。
-
投影变换矩阵
p矩阵负责投影变换的实现,具体通过glm::perspective函数来设置视锥体的大小,由于相机视野缩放时是通过改变视锥体大小来实现的,因此p矩阵也需要在每帧动态更新。
在C++中更新并设置好mvp矩阵到着色器中后,在顶点着色器中,还需要使用如下代码来进行mvp变换。
gl_Position = projection * view * model * vec4(aPos, 1.0); |
---|
同样是因为矩阵乘法的不可逆性,mvp矩阵需要按p,v,m的顺序来写。gl_Position即为进行mvp变换后顶点的齐次坐标。
下面针对每种不同的天体,介绍对应的设计和实现方案。
太阳是太阳系的中点,也是该项目日地月天体模拟的中心。而如果只限于太阳系的话,可以忽略太阳的位移,认为太阳是始终在固定位置自转的天体。因此,在该项目中将太阳的位置固定世界坐标的原点,即(0,0,0)的位置。
- 太阳自转:
同时,太阳会有缓慢的自转现象,因此在初始化时设置一较小的自转速度,同时设置自转旋转轴为(0,1,0),即绕y轴旋转。最后在每一帧调用Rotate函数即可更新太阳的模型变换矩阵。
在真实的宇宙中,地球相比太阳要小的多,地球的体积大约是太阳的130万分之一,因此按照真实的比例尺进行还原显然会严重影响程序的性能和观赏性。因此在该项目中设置地球的直径约为太阳的0.25倍,以达到较好的视觉效果。
除此之外,地球会绕太阳公转,同时也会自转。在该项目中为了方便观察和计算,将地球绕太阳的运动简化为了匀速圆周运动,同时把世界坐标系中的xz平面作为地球的公转平面。并设置了较为合理的公转半径和公转速度。
- 地球公转:
对于地球的公转后每一帧的位置,项目中采用的是实时计算地球模型x,y,z位置坐标的方式,通过每一帧更新rotateAngle_r公转角度,并运用数学三角函数的知识求出x,y,z坐标的具体值,再用最新的坐标更新模型矩阵。具体计算方法如下:
newPos.x = _targetPos.x + disToTar * glm::cos(glm::radians(rotateAngle_r)); newPos.y = position.y; newPos.z = _targetPos.z + disToTar * glm::sin(glm::radians(rotateAngle_r)); |
---|
- 地球自转:
地球的自转平面与公转平面不同。地球的公转平面在地球上的横切线为赤道,而地球自转平面在地球上的横切线为黄道,黄道与赤道存在一个23°26'的黄赤交角。因此地球的自转旋转轴并不是简单的y轴,而是一个与世界坐标系中y轴始终保持23°26'夹角的向量。又因为该向量在世界坐标系中的值会随着地球位置变换而变化,因此需要使用UpdataRotateAxis_s来每帧更新地球的自转轴。自转轴的具体计算方法如下:
newAxis.x = 0.397f * glm::cos(glm::radians(rotateAngle_r)); newAxis.y = 0.918f; newAxis.z = 0.397f * glm::sin(glm::radians(rotateAngle_r)); rotateAxis_s = glm::normalize(newAxis); |
---|
在真实的宇宙中,同样月球要比地球小的多,月球的体积大约是地球的1/49。因此在该项目中设置月球的直径约为太阳的0.08倍,地球的0.32倍,以达到较好的视觉效果。
除此之外,月球会绕地球公转,同时也会自转。在该项目中同样将月球绕地球的运动简化为了匀速圆周运动。
- 月球公转:
月球的公转平面同样不是xz平面,月球的公转平面在地球上的横切线为白道。白赤交角是一个动态的角度,最小为18.50度,最大为28.50度。实际上这两个极值的变化周期为18.61年。同样为了简化计算,项目中白赤交角取固定的中间值23.5度。
因此在具体计算月球的位置时,在考虑xz平面上的公转角度rotateAngle_r的同时,还需要考虑在xy平面上的黄赤交角。在程序中,用线性插值并结合一定的逻辑计算出月球每一帧在xy平面上的夹角alpha,最终位置的具体计算方法如下:
newPos.x = _targetPos.x + disToTar * glm::cos(glm::radians(rotateAngle_r)) * glm::cos(glm::radians(alpha)); newPos.y = glm::sin(glm::radians(alpha)); newPos.z = _targetPos.z + disToTar * glm::sin(glm::radians(rotateAngle_r)) * glm::cos(glm::radians(alpha)); |
---|
- 月球自转:
月球自转的特殊之处在于潮汐锁定的现象,即月球一直是同一面朝着地球的。这就导致了月球的自转周期和公转周期是几乎一致的。在项目中实际实现的过程中,曾尝试通过同步自转角度和公转角度来模拟潮汐锁定的现象,但没有达到理想的效果。因此只能通过调整合适的自转速度来尽量使月球一直是同一面朝着地球的。
为了每帧检测键盘的输入,需要使用GLFW中的glfwGetKey函数来捕捉按键的状态。而为了响应按键的输入,需要通过注册回调函数来实现。在该项目中具体注册了两个回调函数:
void processInput(GLFWwindow* window); static void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods); |
---|
processInput回调函数处理如ESC退出程序和第一人称相机模式下W,S,A,D的控制相机位置的移动。
而key_callback回调函数是专门为了实现当按下按键后,直到松开按键只会触发一次的情况。在本项目中用来实现按下Tab键来切换相机的模式。如果仍然使用processInput来处理会由于帧数太高出现闪烁的问题。
为了每帧检测鼠标的输入,需要注册mouse_callback函数来监听鼠标移动事件,并通过glfwSetCursorPosCallback设置该回调函数。
对于每一帧鼠标的位移,通过将上一帧鼠标的位置与当前帧鼠标位置的差来获得鼠标在x和y方向上的位移量xoffset和yoffset。再乘上表示鼠标灵敏度的常量sensitivity来得到最终两帧的偏移情况。
将偏移情况转为xz平面的偏航角yaw和xy平面的俯仰角pitch。最后通过这两个角度来计算相机最新的方向向量,实现相机视角的转向。
对于相机的实现,第一人称漫游视角和第三人称俯视视角有有所异同,因此使用统一的Camera类来进行管理和实现。
首先需要为不同按键的输入定义对应的方向,这里定义了枚举类型的Camera_Movement来对W,S,A,D按键输入进行控制
enum Camera_Movement { FORWARD, BACKWARD, LEFT, RIGHT }; |
---|
随后定义类成员函数ProcessKeyboard来对不同方向的输入进行检测。
除此之外,鼠标带来的偏航角yaw和俯仰角pitch的变换也是在Camera类中通过定义的ProcessMouseMovement函数处理。函数中对于不同类型的相机的具体实现进行了区分。这里后续改进时可以尝试用C++的多态来优化实现。
对于视野的缩放,定义ProcessMouseScroll函数检测鼠标滚轮的滑动,带动Zoom参数的变化,最后在每帧更新p矩阵时将最新的Zoom作为参数传入。
当Camera类中的键盘鼠标位移量都被更新记录后,还要将具体的变化给到实际表示相机的参数Front,Up和Right。这里通过定义updateCameraVectors函数对这三个变量进行更新。
最后,定义供外部调用的GetViewMatrix函数来返回最新的v矩阵,函数内部封装了glm::lookAt函数。
对于第一人称漫游视角,具体的需求为通过键盘的W,S,A,D按键输入来控制相机朝当前的朝向Front进行前后左右的平移,并通过鼠标的滑动带动相机朝向的变换。
因此,对于按键的控制实现,具体是在ProcessKeyboard函数中,当检测到W或S前后方向的输入时,直接对相机的Position按相机朝向Front进行加减,同理对于A或D左右方向的输入,直接对相机的Position按Right方向进行加减。
而对于鼠标的转向的实现,具体是在updateCameraVectors函数中在更新表示相机的参数Front时,使用下面的算法:
front.x = cos(glm::radians(Yaw)) * cos(glm::radians(Pitch)); front.y = sin(glm::radians(Pitch)); front.z = sin(glm::radians(Yaw)) * cos(glm::radians(Pitch)); Front = glm::normalize(front); |
---|
随后通过向量的叉乘得到另外两个向量
Right = glm::normalize(glm::cross(Front, WorldUp)); Up = glm::normalize(glm::cross(Right, Front)); |
---|
最后的观察矩阵中相机的朝向为Front的方向,所以按如下方式返回:
return glm::lookAt(Position, Position + Front, Up); |
---|
对于第三人称俯视视角,具体的需求为锁定相机的朝向为世界坐标的中心,也就是太阳的位置,通过鼠标的左右滑动实现视角的旋转,上下滑动实现视角的高低调节。
因此,对于鼠标调整视角的实现,具体是先提前计算好相机距离固定观察点的距离distance,随后在updateCameraVectors直接用Yaw和Pitch更新相机的Position。而相机的Front则可通过Position与观察点坐标做差得到,具体如下:
newPos.x = distance * cos(glm::radians(Yaw)) * cos(glm::radians(Pitch)); newPos.y = distance * sin(glm::radians(Pitch)); newPos.z = distance * sin(glm::radians(Yaw)) * cos(glm::radians(Pitch)); Position = newPos; Front = glm::normalize(glm::vec3(0.0f, 0.0f, 0.0f) - Position); |
---|
另外两个向量的更新与第一人称视角相同,但最后的观察矩阵中相机的朝向(观察点)需设定为固定的目标点,如下:
return glm::lookAt(Position, glm::vec3(0.0f, 0.0f, 0.0f), Up); |
---|
- Phong光照模型:
Phong光照模型认为物体表面反射的光线由三部分组成:环境光(Ambient),漫反射(Diffuse)和高光反射(Specular),完整的光照计算公式如下:
但由于实际在宇宙中观察天体时几乎没有反射现象,因此在该项目中对Phong式光照模型进行了简化,忽略了反射项的计算,仅包含漫反射项和环境光项。
- 点光源实现:
对于Phong光照模型中点光源的实现,在C++中提交变量到显存等相关介绍与上文重复就不再赘述,特别之处主要是在片元着色器中,首先定义受光照的模型材质结构Material,其中就包含了漫反射贴图的采样器。随后定义点光源的结构体,具体如下:
struct PointLight { vec3 position; //点光源的位置 vec3 ambient; //环境光照强度 vec3 specular; //镜面反射强度 vec3 diffuse; //漫反射强度 //衰弱系数 float constant; float linear; float quadratic; }; |
---|
其中specular虽然保留,但实际计算中并未使用。
随后定义CalcPointLight函数用于计算点光源,这里需要提前在顶点着色器中将法线和片元在世界坐标中的位置传入片元着色器,并在此作为计算点光源光照的参数。具体完整的实现如下,高光反射代码保留但已注释。
//计算点光源 vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir) { // 计算光线方向 vec3 lightDir = normalize(light.position - fragPos); // 漫反射着色 // 计算漫反射强度 float diff = max(dot(normal, lightDir), 0.0); // 镜面光着色 //vec3 reflectDir = reflect(-lightDir, normal); //float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess); // 衰减计算 // 计算片元与光源的距离 float distance = length(light.position - fragPos); float attenuation = 1.0 / (light.constant + light.linear * distance + //计算衰弱因子 light.quadratic * (distance * distance)); // 合并结果 // 使用texture函数对漫反射贴图采样,环境光使用同样的贴图 vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords)); vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords)); //vec3 specular = light.specular * spec * material.specular; return (ambient + diffuse ) * attenuation; //返回衰弱后的光照结果 } |
---|
最后在片元着色器的main函数中将点光源的返回结果累加到最终FragColor的输出上,完成光照的计算。
为了使场景的画面更加丰富,又不会明显增加显卡渲染的负担,因此采用立方体贴图的技术实现了星空天空盒的效果。
首先在网上收集到了一份星空天空盒纹理,共6张贴图,随后在C++中使用自定义的loadCubemap函数对6张贴图进行类似于前面2.3.1.2纹理导入部分的方法将6张贴图按指定的顺序导入为纹理。
相较于一般单张的2D纹理,立方体纹理在使用glBindTexture绑定时需要设置为GL_TEXTURE_CUBE_MAP类型。
同时,由于天空盒总是在最后被绘制,而且应该在场景的所有物体后面。因此在天空盒的顶点着色器中,需要将顶点mvp后的齐次坐标中的z坐标设置为w的值,使得在透视除法后z为1,即深度值最大的位置。而相应的在主渲染中调用天空盒的绘制前,需要使用glDepthFunc(GL_LEQUAL)修改深度测试的规则,确保天空盒的深度值在小于或等于深度缓冲时通过测试,而非一般情况下小于深度缓冲值才通过。
除此之外,因为我们希望天空盒不会随着相机的移动而移动,因为它是作为一个无限远的背景存在的。因此在向天空盒的着色器更新v矩阵前,需要将矩阵的位移移除掉,保证其不会随着相机而移动,具体代码如下:
view = glm::mat4(glm::mat3(myCamera.GetViewMatrix())); //移除位移 skyBoxShader.setMat4("view", view); |
---|
最后在片元着色器中,使用samplerCube类型的采样器来管理立方体纹理,并同样使用texture函数对立方体纹理进行采样即可。
CPU:Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz
GPU:GeForce GTX 1060 3G
内存大小:16.0 GB
操作系统:64位操作系统
编译器:Visual Studio 2022 (x64 Debug模式)
第一人称漫游视角下通过键盘鼠标控制移动到地球和月球旁边,近距离观察的效果。
第三人称俯视视角下通过鼠标控制旋转和缩放,全局观察天体模拟的效果。
图2:第三人称——远距离观察整体三个天体的运动
图3:第三人称——近距离观察整体三个天体的运动(角度1)
图4:第三人称——近距离观察整体三个天体的运动(角度2)
Phong式光照模型的实现,使得地球和月球的侧面有逐渐从亮到暗的渐变效果。
图5:Phong式光照模型——右侧地球和月球在太阳照射下的渐变
图6:星空背景展示(角度1)
图7:星空背景展示(角度2)
图8:星空背景展示(角度3)
整个项目基本按照初计划完成了。但由于实际开发中遇到的问题以及时间上的限制,仍有部分可以实现的功能未能很好实现,有些遗憾。总体来说对于模型与纹理和交互控制方面完成的较为完善。而天体运动方面未能实现真实的天体物理学公式来仿真,而渲染方面太阳光的效果做的一般,导致模型在场景中有些突兀。
在课后有时间的情况下,我计划对项目进行继续的完善和改进,目前主要有以下计划:
- 天体的数量不限于太阳,地球和月亮,尝试增加太阳系中其它的星体。
- 天体的运动使用天体物理学的公式来还原。
- 使用实例化技术加载小行星,并可以通过鼠标拖动来实现交互。
- 对太阳光实现HDR和泛光的效果。