# 计算机图形学实验 1: 三维图形显示与交互
# 实验要求
编写一个 3D 图形应用,基于鼠标和键盘进行交互,实现如下功能:
- 从
.obj
或.ply
文件读取一个三维模型,能够使用鼠标对模型进行平移、放缩和旋转操作。其中旋转操作要方便、直观、符合三维操作习惯。随着课程的进行,支持正投影和透视投影两种观察方式切换,支持基于光照的真实感和预定义物体颜色的两种绘制方式。 - 能够对模型进行 instance 操作,将模型通过变换形成多个实例。
- 基于上述功能,顺序定义同一 3D 物体的四个不同实例 (具有不同位置和姿态),编程实现此物体沿着由四个不同实例确定的三次 Bézier 曲线移动,同时物体姿态由初始姿态 (第一个实例的姿态) 到最终姿态 (第四个物体的姿态) 进行光滑旋转。可控制物体整个运动过程所需时间长短。
- 编程实现从物体多个实例中用鼠标选中其中一个物体,然后对其进行各种变换操作。
- 编写实验报告,简要说明每个步骤的实现要点。
# 设计思路与实现要点
# 设计思路
首先解释设计要点。对于关键内容的实现,其包括如下部分:
- 模型读取
- 模型渲染
- 键鼠操作捕获
- 模型的实例定义
- 实现 Bézier 曲线移动
# 模型的渲染
模型的渲染进行依照模型渲染管线实现,主要借助着色器进行。
# 着色器的设计
在 OpenGL 中,至少需要包含两种着色器:顶点着色器 (vertex shader) 和片元着色器 (fragment shader). 前者控制顶点的变换和颜色,后者控制片元的颜色以及纹理的位置。
# 顶点着色器
顶点着色器的代码如下:
#version 330 core | |
layout (location = 0) in vec3 aPos; | |
layout (location = 1) in vec2 aTexCoord; | |
out vec2 TexCoord; | |
uniform mat4 model; | |
uniform mat4 view; | |
uniform mat4 projection; | |
void main() { | |
gl_Position = projection * view * model * vec4(aPos, 1.0f); | |
TexCoord = vec2(aTexCoord.x, aTexCoord.y); | |
} |
该着色器需要传入 model
, view
, projection
三个矩阵,分别对应模型变换、视角变换和投影变换。通过在顶点着色器中将上述三矩阵依次相乘,可以实现顶点从局部坐标到屏幕坐标的转换。因此,对于实验要求中的正投影和透视投影的转换,可以通过修改投影矩阵的形式完成。
# 片元着色器
片元着色器的代码如下:
#version 330 core | |
out vec4 FragColor; | |
in vec2 TexCoord; | |
uniform sampler2D texture_diffuse1; | |
void main() { | |
FragColor = texture(texture_diffuse1, TexCoords); | |
} |
片元着色器用来实现片元的着色功能,即纹理加载和颜色填充。完成这一功能,需要传入各图元在纹理图片中对应的坐标 (如果有纹理). 其输出 FragColor
是片元的颜色信息。
# 模型的读取
# 库选取
模型的读取采用 assimp
库实现,其可以读取多种三维模型格式,并输出为统一的对象 aiScene
供程序使用。此外,如果需要加载纹理,还需要借助 stb_image
库实现。
# 代码思路和细节
模型的读取需要一些主要的类和方法,包括顶点、纹理、网格和模型四个主要的结构体 / 类。
顶点结构体需要包括顶点的位置、顶点处的法线方向,以及顶点的纹理坐标。纹理结构体需要包括纹理编号、类型和路径。直接采用结构体进行定义,不需要加设额外的方法。
网格类需要包括顶点、面片索引号和纹理索引号信息 (这也是根据 obj
文件的存储格式进行设计的)。其基本代码结构如下:
class Mesh { | |
public: | |
// 网格数据信息 | |
vector<Vertex> vertices; | |
vector<unsigned int> indices; | |
vector<Texture> textures; | |
unsigned int VAO; | |
// 网格的构建 | |
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures); | |
// 网格绘制,需要传入一个着色器,并根据着色器绘制 | |
void Draw(Shader& shader); | |
private: | |
// 渲染用数据 | |
unsigned int VBO, EBO; | |
// 进行 VAO, VBO, EBO 的初始化和绑定 | |
void setupMesh(); | |
}; |
模型类需要包括模型的基本数据,如网格、纹理和模型路径。此外,模型的基本方法包括模型的导入,以及借助 assimp
库的数据信息转换方法。
class Model { | |
public: | |
// 模型数据 | |
vector<Texture> textures_loaded; | |
vector<Mesh> meshes; | |
string directory; | |
// constructor, expects a filepath to a 3D model. | |
Model(string const& path, bool gamma = false); | |
// draws the model, and thus all its meshes | |
void Draw(Shader& shader); | |
private: | |
// 从路径加载模型,并转化为 aiScene 等信息 | |
void loadModel(string const& path); | |
// 将 aiScene 和 aiNode 中的数据信息转化为网格的顶点信息 | |
void processNode(aiNode* node, const aiScene* scene) | |
// 网格信息转换 | |
Mesh processMesh(aiMesh* mesh, const aiScene* scene) | |
// 纹理检查和纹理信息转换 | |
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName); | |
}; |
# 键鼠操作的捕获
键盘和鼠标操作的捕获是该实验的另一个重点。原生 glut
不支持鼠标滚轮的操作,因此采用 glfw
库完成。
# 基本键位安排
在我的程序中,采用的主要按键包括:
按键 | 功能 |
---|---|
鼠标移动 | 摄像机视角移动 (上下左右) |
鼠标滚轮 | 摄像机前后移动 |
键盘 W , A , S , D , Q , E | 模型前后左右上下移动 |
键盘 P | 切换投影模式 |
键盘数字键 1 ~ 0 | 实例选择 (最多 9 个) |
键盘 O | 删除实例 |
# 代码实现细节
主要说说键鼠操作的简单代码调用函数。
鼠标光标移动的回调函数为 glfwSetCursorPosCallback(GLFWwindow* ,GLFWcursorposfun)
, 此处传入的第一个参数是活动窗口,第二个参数是一个函数指针。该函数指针需要具有特定的输入参数,包括窗口和光标横纵坐标。上述回调函数将光标和窗口参数回传给函数指针,进而实现鼠标光标位置的获取。这样,可以根据鼠标的位置改变设计摄像机的视角变化。鼠标滚轮的回调函数类似。
键盘操作更为简单,借助 glfwGetKey(GLFWwindow* window, int key)
即可获取对应的键是否被按下,借此即可实现基本的键盘操作。
# 模型的实例操作
模型的实例操作包括实例的定义、选择、复制和删除等。其关键在于如何设计才能实现简单,且可以完成实验要求。如果采用鼠标选择的方式进行实现,其需要将鼠标位置与模型在屏幕空间中的位置建立映射关系,且在多个模型出现重叠时难以计算,需要考虑深度信息。因此,我采用键盘绑定实例的方式进行实例的选择,即每个数字键对应一个实例,按下不同的数字键则切换为不同的实例。其具体操作如下:
按键规则 | 对应效果 |
---|---|
单次按下数字键 | 切换为对应的实例 |
1 + C → 2 | 复制实例 1 到实例 2 |
1 → O | 删除实例 1 |
# Bézier 曲线移动
注意到,根据运动学的基本原理,模型中心沿曲线的移动和模型自身的转动是两个不相关的运动。我们可以将一个整体的移动分解成上述两个运动,也可以将上述两个运动合成为一个运动。因此,待实现的 Bézier 曲线移动主要包括两个部分:
- 模型整体沿 Bézier 曲线的移动
- 模型自身的转动
# 模型沿 Bézier 曲线的移动
模型整体沿 Bézier 曲线的移动是以模型上一点为基准的,该点为模型空间中的原点. 注意到,Bézier 曲线的参数方程的形式为
因此,当确定模型移动前后四个点的坐标后,即可实现模型整体的移动。其代码也容易完成:
glm::vec3 Bezier(glm::vec3 p1, glm::vec3 p2, glm::vec3 p3, glm::vec3 p4, float t) { | |
glm::vec3 p; | |
float a1 = pow((1 - t), 3); | |
float a2 = pow((1 - t), 2) * 3 * t; | |
float a3 = 3 * t * t * (1 - t); | |
float a4 = t * t * t; | |
p = a1 * p1 + a2 * p2 + a3 * p3 + a4 * p4; | |
p = a1 * p1 + a2 * p2 + a3 * p3 + a4 * p4; | |
return p; | |
} |
其中, glm::vec3
是一个三维向量,用来表示点在世界坐标系中的的位置信息。
# 模型自身的转动
模型自身的转动不需要移动模型的位置,只需要考虑模型本身的朝向。在数学实现上,我们只需将其初始位置和末位置进行一个 slerp 插值即可。即若设模型的初始方向为 , 末方向为 , 则我们可以构造一个旋转插值如下:
其中, 表示两个方向向量 之间的夹角。
将其实现到代码中,就得到了如下的代码段:
glm::vec3 smoothRotate(glm::vec3 v1, glm::vec3 v2, float t) { | |
float omega = glm::dot(v1, v2)/sqrt((glm::dot(v1, v1)) * (glm::dot(v2, v2))); | |
return (sin((1-t)*omega)*v1 + sin(t*omega)*v2)/sin(omega); | |
} |
# 两运动的复合
将上述两运动进行复合,只需要位置坐标与方向向量相加即可。
# 效果
<img src="../../images/ 计算机图形学 / Exp1/1.png" style="zoom:50%;" />
<img src="../../images/ 计算机图形学 / Exp1/2.png" style="zoom:50%;" />
assimp
库的贴图还没有完全弄妥当,因此模型显示只能是纯色色块。
# 总结和收获
本次实验的过程是曲折的,收获是巨大的,但暴露的代码问题也是较多的。
在收获上,首先是熟悉了图形学渲染管线的整体架构,以及基于 OpenGL 的渲染设计方案。具体说来,包括模型网格类、模型类、键鼠控制头文件等的基本函数。此外,对于一些具体的工程问题也更加熟练了。包括依靠 CMake
配置头文件和相应的链接库,构建并复用头文件等。
再来说说代码中存在的问题。在这个工程的完成过程中,代码重构的次数较多,这一方面是因为不熟悉各个部件的功能和相互调用关系造成的,另一方面也是对如何规划头文件设置等没有深入的理解。毕竟,把所有代码放到一个 main
文件不利于阅读和修改。
此外,另一个问题是文档的阅读问题。一方面,有些采用的 OpenGL
库较老,文档既难以寻找也与现在的代码开发环境及操作系统等不完全适配。另一方面,对于一些未知的函数或方法,我仍然安逸于使用中文博客,而不愿阅读英文原版文档。
除此之外,感觉最大的不足是对图形学相关的代码不够熟练。但我认为这不是最严重的问题,如果在某个方向深耕下去,这反而是会逐渐熟练的。