# 计算机图形学实验 1: 三维图形显示与交互

# 实验要求

编写一个 3D 图形应用,基于鼠标和键盘进行交互,实现如下功能:

  1. .obj.ply 文件读取一个三维模型,能够使用鼠标对模型进行平移、放缩和旋转操作。其中旋转操作要方便、直观、符合三维操作习惯。随着课程的进行,支持正投影和透视投影两种观察方式切换,支持基于光照的真实感和预定义物体颜色的两种绘制方式。
  2. 能够对模型进行 instance 操作,将模型通过变换形成多个实例。
  3. 基于上述功能,顺序定义同一 3D 物体的四个不同实例 (具有不同位置和姿态),编程实现此物体沿着由四个不同实例确定的三次 Bézier 曲线移动,同时物体姿态由初始姿态 (第一个实例的姿态) 到最终姿态 (第四个物体的姿态) 进行光滑旋转。可控制物体整个运动过程所需时间长短。
  4. 编程实现从物体多个实例中用鼠标选中其中一个物体,然后对其进行各种变换操作。
  5. 编写实验报告,简要说明每个步骤的实现要点。

# 设计思路与实现要点

# 设计思路

首先解释设计要点。对于关键内容的实现,其包括如下部分:

  • 模型读取
  • 模型渲染
  • 键鼠操作捕获
  • 模型的实例定义
  • 实现 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 + C2复制实例 1 到实例 2
1O删除实例 1

# Bézier 曲线移动

注意到,根据运动学的基本原理,模型中心沿曲线的移动和模型自身的转动是两个不相关的运动。我们可以将一个整体的移动分解成上述两个运动,也可以将上述两个运动合成为一个运动。因此,待实现的 Bézier 曲线移动主要包括两个部分:

  • 模型整体沿 Bézier 曲线的移动
  • 模型自身的转动

# 模型沿 Bézier 曲线的移动

模型整体沿 Bézier 曲线的移动是以模型上一点为基准的,该点为模型空间中的原点(0,0,0)(0,0,0). 注意到,Bézier 曲线的参数方程的形式为

B(t)=(1t)3P0+3t(1t)2P1+3t2(1t)P2+t3P3,t[0,1]B(t) = (1-t)^3P_0 + 3t(1-t)^2P_1 + 3t^2(1-t)P_2 + t^3P_3, \quad t \in [0,1]

因此,当确定模型移动前后四个点的坐标后,即可实现模型整体的移动。其代码也容易完成:

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 插值即可。即若设模型的初始方向为 v1\vec v_1, 末方向为 v2\vec v_2, 则我们可以构造一个旋转插值如下:

slerp(v1,v2,t)=sin((1t)v1,v2)v1+sin(tv1,v2)v2sinv1,v2\mathrm{slerp}(v_1, v_2, t) = \frac{\sin\big((1-t)\langle\vec v_1, \vec v_2\rangle\big)\vec v_1 + \sin\big(t\langle\vec v_1, \vec v_2\rangle\big)\vec v_2}{\sin \langle\vec v_1, \vec v_2\rangle}

其中,v1,v2\langle\vec v_1, \vec v_2\rangle 表示两个方向向量 v1,v2\vec v_1, \vec v_2 之间的夹角。

将其实现到代码中,就得到了如下的代码段:

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 库较老,文档既难以寻找也与现在的代码开发环境及操作系统等不完全适配。另一方面,对于一些未知的函数或方法,我仍然安逸于使用中文博客,而不愿阅读英文原版文档。

除此之外,感觉最大的不足是对图形学相关的代码不够熟练。但我认为这不是最严重的问题,如果在某个方向深耕下去,这反而是会逐渐熟练的。