计算机图形学课程设计

 

设计内容

设计一个交互式场景建模与绘制效果演示工具软件,实现三维物体的多边形网格建模与真实感渲染。

其中具体要求如下:

  • 包含三个以上物体,组成一个有意义的三维场景
  • 可任意选择不同图像用于不同对象的纹理映射
  • 可选择定义对象的不同凹凸纹理映射
  • 选择设置2个点光源,光源位置、方向和光照强度可分别改变

技术思路

首先,设计要求提到要设计一个可交互的演示工具,因此具有可视化的界面是必不可少的。同时为了实现软件可在多平台运行,我们此次选用Qt作为本次软件开发的主要平台。同时设计要求需要对三维物体的多边形网格进行建模和渲染,因此使用OpenGL作为渲染的底层API。其中Qt还对OpenGL的API进行了一定的高级封装,使得使用opengl变为更加简单。

OpenGL有两种渲染管线,分别为固定管线和可编程管线。其中固定渲染管线是只可配置的管线,实现的渲染效果较可编程渲染管线要少。而可编程渲染管线将很多部分从可配置改为了可编程,使得渲染效果可通过编程的方式实现,自由度也大大提高。现在较新的渲染API几乎都已经淘汰固定渲染管线(如OpenGL 3.0+, Direct3D 10+, OpenGL ES 2+, WebGL)。因此我们计划使用OpenGL的可编程渲染管线来实现对模型的渲染。

OpenGL渲染流程

OpenGl图形绘制管线主要可分为三个阶段:应用程序阶段、几何阶段、光栅阶段。

  • 应用程序阶段

    主要和cpu、内存进行交互,进行的是场景物体的建立,模型的碰撞检测、视锥的裁剪等算法。同时也会将几何体数据信息通过数据总线传送到图形硬件。

  • 几何阶段

    顶点着色器在这个阶段运行,其中顶点坐标转换、光照、裁剪、投影、屏幕映射等均在GPU中运行。该阶段最后会得到经过变换和投影后的顶点坐标、颜色、纹理坐标等,会将这些数据作为光栅化阶段的输入进一步进行处理。

  • 光栅阶段

    光栅阶段首先进行光栅化,决定那些像素被集合图元覆盖的过程。之后会对顶点坐标、颜色、纹理坐标等进行着色器的上色过程。

xuanran

渲染器的代码实现

OpenGL显示窗口

首先,在Qt Creator中创建了一个以MainWindow为基类的工程。Qt Creator会自动创建mainwindow.cpp、mainwindow.h和mainwindow.ui三个文件,其中mainwindow.ui是窗口绘制的可视化配置文件,可以在设计窗口中方便的配置程序的布局等。

我们可以先在mainwindow.ui中设置好窗口的大小,再拖入一个OpenGL Widget控件,放置至合适位置,并设置好大小,作为显示的窗口。 之后创建一个新类,命名为LoadEngine,类继承自QOpenGLWidgetQOpenGLFunctions_3_3_Core。 所有的渲染工作将在LoadEngine这个类中进行。 再转到mainwindow.ui文件中,可以把刚才放进去的QOpenGLWidget进行提升类操作。这里新建提升类的名称为LoadEngine,并勾选上全局包含,点击添加。这样QOpenGLWidget这个窗口就与类LoadEngine进行了绑定,这个窗口就能显示出在OpenGL中绘制的模型。

着色器

OpenGl图形渲染管线可以划分为两个主要部分,第一个部分是将3D的坐标转换为2D坐标,另一部分是将2D坐标转变未实际的有颜色的像素。其中,着色器就是快速处理坐标转换和颜色计算的程序。

着色器渲染流程

在图形渲染管线抽象流程中,顶点着色器,几何着色器和片段着色器是可以由开发者进行编程自定义的。

shader

  • 顶点数据

    顶点数据一般是由一系列顶点的集合。其中一个顶点是一个3D坐标数据的集合,其中一个顶点一般包含一个点的三维坐标和顶点的颜色、法向量、纹理坐标等顶点属性组成。

  • 顶点着色器

    顶点着色器把一个单独的顶点作为输入,对顶点的坐标进行一系列的坐标转换操作,同时顶点着色器允许对顶点属性进行一些基本处理。

  • 图元装配

    图元装配是将顶点着色器输出的所有顶点作为输入,并所有的点装配成指定的图元形状。

  • 几何着色器

    图元装配的结果会传递给几何着色器,几何着色器会将一系列的顶点集合作为输入,同时可以通过产生新顶点构造出新的图元来生成其他形状。

  • 光栅化阶段

    几何着色器的输出会传入光栅化阶段,这里会将图元映射未屏幕上相应的像素,生成供片段着色器使用的片段。

  • 片段着色器

    片段着色器是计算像素的最终颜色,是OpenGL高级效果产生的地方。片段着色器包含3D场景的光照、阴影、光的颜色等数据,这些数据用来计算最终每个像素的颜色。

  • 测试与混合

    最后所有像素的颜色确定后,最终对象传入Alpha测试和混合阶段。这个阶段检测片段的深度值,判断物体的前后深度和透明度,并对物体进行混合。

着色器代码实现

首先我们在Qt Creator中创建一个Qt Resource File用于管理工程中需要包含的文件。之后在Resource文件目录下新建GLSL模板下的Fragment Shader文件和Vertex Shader文件,并命名为loadengine.fragloadengine.vert。这两个文件内分别将放置我们的片段着色器程序和顶点着色器程序。

之后新建一个名为Shader的类,其中需要继承自QOpenGLFunctions_3_3_Core。 类内需要包含一个构造函数Shader()和析构函数\textasciitildeShader()。同时还需要添加initShader()paintShader()函数分别用于初始化和绘制Shader类的功能。 Shader类中需要一个着色器程序QOpenGLShaderProgram,一个模型Model变量和三个QVector3D向量分别用于表示模型的缩放,位置和朝向。

具体Shader类结构如下:

class Shader : protected QOpenGLFunctions_3_3_Core
{
public:
    Shader();
    ~Shader();
    void initShader(QString file,QString model);
    void paintShader(Camera *cam,int width,int height,std::vector<Light> light);
  
    QOpenGLShaderProgram *program;
    Model *object_model;
    QVector3D model_scale,model_position,model_row;
};

initShader()函数中,我们可以将编写好的顶点着色器和片段着色器程序添加到着色器程序program中,再将顶点着色器与片段着色器链接起来。同时我们可以提前给着色器指定UniformValue,指定模型的变换矩阵。

paintShader()函数中,我们将摄像机类导入进来,通过对摄像机的视角和位置进行计算,得出模型的投影矩阵。再将光源导入进来,设置好每个光源的位置,光的颜色和光的强度和衰减,最后就可以调用模型类Model进行绘制。

坐标系和摄像机

在实际的渲染过程中,模型的坐标需要经过几个坐标系的变换,才能转换为以摄像机或屏幕为坐标系的坐标。 为了从一个坐标系转换到另一个坐标系,我们需要用到模型(Model)、观察(View)、投影(Projection)三个矩阵。如图所示,模型的顶点起始于局部空间,这里成为局部坐标,之后转换为世界坐标,观察坐标,裁剪坐标,最后转为屏幕坐标而结束。

space

局部空间

局部空间是指模型所在的坐标空间,一个模型在建立的时候每个顶点的坐标是由建立时的坐标空间确定的,因此模型的所有顶点坐标在导入的时候都是处于局部空间内的。

世界空间

当物体导入程序中,其可能全都放置在世界坐标系中的原点。因此为每一个模型定义一个位置,从而能在一个更大的世界中确定其位置关系。 局部空间的坐标转换到世界空间中的变换矩阵是由模型矩阵实现的。模型矩阵可以通过对模型的平移,旋转和缩放实现使模型放置在世界空间中的任何位置方向和大小。

观察空间

观察空间是一般成为摄像机的空间。观察空间是世界空间转换到摄像机视野前方坐标的结果。观察空间就是摄像机的视角观察到的空间。这一系列的位移和旋转变换能使世界空间转换到观察空间。

裁剪空间

在模型转换到观察空间后,并不是没一个顶点都能落在观察者的可视范围内。 因此需要对可视范围外的顶点裁剪掉,裁剪剩下的坐标就将变为屏幕上可见的片段。 为了将顶点坐标从观察空间转换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix)。 其指定一个范围的坐标,投影矩阵会将这个指定范围内的坐标变换为标准化设备坐标的范围(-1.0,1.0)。 若图元一部分超出了裁剪体积,OpenGL会自动重构这个图元为一个或多个三角形,使其能够适应这个裁剪空间。

由投影矩阵创建的观察箱被成为平截头体(Frustum)。将观察坐标变换为裁剪空间坐标的投影矩阵可以为两种不同的形式,每种都定义了不同的平截头体。

  • 正射投影

    正射投影定义了一种类似立方体的平截头体。如图所示,正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中我们更希望顶点不会被透视所干扰。某些如 Blender 等进行三维建模的软件有时在建模时也会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。 zhengshe

  • 透视投影

    透视投影相比正射投影增加了透视的效果。如图一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点。透视投影中视野(Field of View)表示裁剪空间观察的视角大小,其通常设置为45.0f。 toushi

摄像机

在前文观察空间,是讨论以摄像机的视角作为场景原点时场景中所有顶点坐标:观察矩阵把所有世界坐标变换为相对于摄像机位置与方向的观察坐标。由此我们定义一个摄像机类Camera,其包含了一个FPV摄像机的所有参量和变换。

class Camera
{
public:
    // Camera Attributes
    QVector3D position;
    QVector3D front;
    QVector3D up;
    // Euler Angles
    float yaw;
    float pitch;
    // Camera options
    float movementSpeed;
    float mouseSensitivity;
    float fov;
};    
  • 获取观察矩阵

    获取观察矩阵需要摄像机的位置,目标和上向量。在Qt中可以很方便的使用lookAt()函数计算得到。

QMatrix4x4 view;
view.lookAt(position, position + front, up);
  • 摄像机移动

    在Qt中通过QKeyEvent库可以方便的订阅键盘按键的事件。摄像机移动有四个方向,分别为向前,向后,向左和向右,可以通过下列代码实现。

switch (direction) {
    case FORWARD:
    {
        QVector3D dir(velocity * front.x(), 0.0f, velocity * front.z());
        position += dir;
    }
        break;
    case BACKWARD:
    {
        QVector3D dir(velocity * front.x(), 0.0f, velocity * front.z());
        position -= dir;
    }
        break;
    case LEFT:
    {
        QVector3D dir(velocity * front.x(), 0.0f, velocity * front.z());
        QMatrix4x4 transform;
        transform.rotate(90.0f, 0.0f, 1.0f, 0.0f);
        position += transform * dir;
    }
        break;
    case RIGHT:
    {
        QVector3D dir(velocity * front.x(), 0.0f, velocity * front.z());
        QMatrix4x4 transform;
        transform.rotate(-90.0f, 0.0f, 1.0f, 0.0f);
        position += transform * dir;        
    }
        break;
}    
  • 摄像机旋转

    在Qt中通过QMouseEvent库可以方便的订阅鼠标的动作事件。而鼠标的两个方向的移动可以作为摄像机的两个方向的旋转。这里通过设置pitch的角度小于±89°从而防止出现万向节死锁的情况出现。

void rotate(float xoffset, float yoffset)
{
    xoffset *= mouseSensitivity;
    yoffset *= mouseSensitivity;
    yaw   += xoffset;
    pitch += yoffset;

    if (pitch > 89.0f)
        pitch = 89.0f;
    if (pitch < -89.0f)
        pitch = -89.0f;

    front.setX(cosf(qDegreesToRadians(yaw)) * cosf(qDegreesToRadians(pitch)));
    front.setY(sinf(qDegreesToRadians(pitch)));
    front.setZ(sinf(qDegreesToRadians(yaw)) * cosf(qDegreesToRadians(pitch)));
    front.normalize();
}
  • 视角缩放

    在Qt中通过QWheelEvent库可以方便的订阅鼠标滚轮的动作事件。这里设置鼠标滚轮朝上滚为放大视角。

void zoomIn()
{
    const float sensitivity = 5.0f;
    fov -= sensitivity;
    if (fov <= 1.0f)
        fov = 1.0f;
}
void zoomOut()
{
    const float sensitivity = 5.0f;
    fov += sensitivity;
    if (fov >= 150.0f)
        fov = 150.0f;
}

光照

现实世界的光照是极其复杂的,而且会受到多种因素的影响。我们在渲染绘制时对光照进行简化,对实际情况进行近似。根据冯氏光照模型(Phong Lighting Model),光照主要由三个分量组成:环境光,漫反射和镜面反射光照。

  • 环境光照

    即使在黑暗的环境下,通常也会有一些光亮(如月光,远处的光等等),物体一般不会是完全黑暗的,为了模拟这种情况,我们始终给物体一个较暗的颜色。

  • 漫反射光照

    模拟光源对物体的方向性影响。它是冯氏光照模型中视觉上最显著的分量,物体的一部分越是正对着光源,其就越亮。

  • 镜面反射光照

    模拟有光泽物体上出现的亮点。镜面光照的颜色相比与物体的颜色更倾向于光的颜色。

环境光照

我们使用一个简化的全局照明模型用于环境光照。我们使用一个较小的常亮颜色,添加到物体的片段着色器的最终颜色中,这样就算没有直接的光源,也能看起来存在一些发散的光。

void main()
{
    float ambient = 0.1;
    vec3 ambient = ambient * lightColor;
    ...
    vec3 result = ambient * objectColor;
    FragColor = vec4(result, 1.0);
}

漫反射光照

计算漫反射光照,我们就需要知道光线是以什么角度接触到这个片段的。为了测量光线和片段的角度,我们使用法向量与光线的点乘即可计算得出。

由于需要知道每个顶点的法向量,所有我们更新顶点着色器,增加一组法向量的属性,同时输出法向量。

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
...
out vec3 Pos;
out vec3 Normal;
void main()
{
    Pos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal;
    gl_Position = projection * view * vec4(Pos, 1.0);
}

有了每个顶点的法向量和光源的位置,就可以在片段着色器中计算相应的漫反射光照。

out vec4 FragColor;
in vec3 Pos;
in vec3 Normal;
void main()
{
    ...
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - Pos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
    vec3 result = (ambient + diffuse) * objectColor;
    FragColor = vec4(result, 1.0);
}

镜面反射光照

镜面反射光照与漫反射光照一样,都是依据光的方向和物体的法向量来决定的,但是它还依赖观察的方向。为了得到观察这的世界空间向量,我们使用摄像机的坐标位置传给片段着色器。

uniform vec3 viewPos;
void main()
{
    ...
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);    
    vec3 specular =  spec * vec3(texture(texture_specular1, TexCoord));
    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);    
}

点光源

点光源是处于现实世界中一个位置的光源,其朝着所有方向发光,但是光强会随着距离的增加而逐渐衰减,类似与灯泡。 在上面的方法中,我们使用的是简化的点光源,其对所有方向的任何距离的光强都是一致的。而在现实中,一般点光源所能照亮的范围是有限的,所以我们需要给点光源加上衰减系数。

下面这个公式可以根据片段距离光线的距离计算光强的衰减值: \(F_{att} = \frac{1.0}{K_c+K_l*d+K_q*d^2}\)

其中$d$代表片段距光源的距离,我们定义3个系数分别为常数项$K_c$、一次项$K_l$和二次项$K_q$。

  • 常数项系数$K_c$一般保持为1.0,其作用是保证分母不会比1小,否则在某些距离上它反而会增加光照强度。
  • 一次项$K_l$与距离相乘,以线性的方式减少强度
  • 二次项$K_q$与距离的平方相乘,使光源以二次递减的方式减少强度。

由于增加了光源的衰减系数,所以我们的片段着色器需要更改:

struct PointLight {
    vec3 position;
    float constant;
    float linear;
    float quadratic;
}; 
uniform PointLight pointLights;
void main()
{
    ...
    float distance = length(light.position - Pos);
    float attenuation = 1.0 / (light.constant + light.linear * \
                       distance +light.quadratic * (distance * distance));
    ambient *= attenuation;
    diffuse *= attenuation;
    specular *= attenuation;
    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);
}

模型

既然是三维对象模型的渲染演示软件,模型就需要能够从外部进行导入了。通常模型文件可以通过Blender,3DS Max,Maya或一些三维建模软件中建立。而一般模型文件格式有obj、fbx、dae、3ds、blend等众多格式。若想从这些众多格式的文件中导入模型,需要针对每一种文件格式写一个导入器。因此本项目采用Assimp库来处理模型文件导入的问题。

Assimp模型加载库

Assimp模型加载库(Open Asset Import Library)能够导入很多不同模型文件的格式,其会将所有的模型数据加载进Assimp通用的数据结构中,当Assimp库加载完模型后,我们就能从其数据结构中提取所需要的数据了。

首先,我们需要构建编译Assimp库。编译完Assimp库后安装至计算机共享库中,这样Qt Creator就能通过添加系统库的方式链接到Assimp库。

模型加载实现

一个网格至少需要一系列的顶点,每个顶点包含一个位置向量,一个法向量和一个纹理坐标向量。一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射和镜面反射贴图)。

因此我们先定义一个顶点类,将所有需要的向量存到Vertex的结构体中,可以用其来索引每个顶点的属性。

//顶点类
struct Vertex
{
    QVector3D Position; //位置
    QVector3D Normal;   //法向量
    QVector2D TexCoords;//纹理坐标
};

我们再将纹理数据放入Texture的结构体中,其中存储了纹理的id及其类型和指向纹理的指针。

//纹理
struct Texture
{
    QString type;
    QString filename;
    QSharedPointer<QOpenGLTexture> texture;
};

自此,可以定义一个网格类Mesh的结构了。

class Mesh : protected QOpenGLFunctions_3_3_Core
{ 
public:
    Mesh() = default;
    ~Mesh();
    //构造
    Mesh(const std::vector<Vertex> &vertices, 
           const std::vector<GLuint> &indices,
		     const std::vector<Texture> &textures);
    //画网格模型
    void draw(QOpenGLShaderProgram *program);
            
    //顶点
	std::vector<Vertex> vertices;
	//索引
	std::vector<GLuint> indices;
	//纹理
	std::vector<Texture> textures;
	//是否进行过setupMesh初始化操作
	bool isinitialized;
	GLuint VAO;
	GLuint VBO;
    GLuint EBO;  
private:
	// 初始化所有缓冲区对象/数组
	void setupMesh(QOpenGLShaderProgram *program);
};  

在构造函数中,我们将所有的数据输入Mesh类,在setupMesh()函数中创建并初始化缓冲,之后吧数据加载到缓冲区中,最后掉用draw()函数来绘制网格。 在调用draw()函数绘制网格时,我们先将一些uniform的全局变量设置好,类似于链接采样器到纹理单元。

最后,我们需要建立一个Model类用于模型文件的导入和存储模型的一个或多个Mesh类。

class Model
{
public:
    Model() = default;
    explicit Model(const QString& path);
    ~Model();
    //加载模型
    bool loadModel(const QString &path);
    void processNode(aiNode *node, const aiScene *scene);
    Mesh *processMesh(aiMesh *mesh, const aiScene *scene);
    
    std::vector<Texture> loadMaterialTextures(aiMaterial *material, aiTextureType type, const QString &typeName);

    void draw(QOpenGLShaderProgram *program);
    //目录
    QString directory;
    std::vector<Mesh *> meshes;
    std::vector<Texture> textures_loaded;
};    

Model类中包含了一个或多个Mesh对象并放入了C++标准化容器Vector中。构造函数需要一个文件路径,通过loadModel来加载文件,其中会调用Assimp库来导入模型文件。我们还将存储文件路径的目录,用于加载纹理时调用。

界面和交互

最后,交互式三维对象建模与渲染演示工具需要一个交互的界面。再重新打开mainwindow.ui,设置好mainwindow的窗体大小等参数,并在左侧控件中选择并拖入需要的控件即可。

而对于类似doubleSpinBox这类需要显示模型参数并更改的控件,需要添加控件与模型参数之间的链接。右键选择的控件对象,使用转到槽里的valueChanged(double)函数,即可在自动创建的函数中设置模型的参数。最后在控件的属性里设置好最大最小值,步长,默认值等属性即可。

最终效果

如图所示,窗口的左侧是模型显示界面,右侧是模型属性和光源属性的设置界面。同时还支持使用WASD来移动FPV摄像机的位置,鼠标点击拖动能移动摄像机的视角,鼠标滚轮能缩放摄像机的视野。

ui

程序默认加载的是两个人物的模型和一个球体模型,且同时两个点光源。人物模型都支持漫反射纹理和镜面反射纹理叠加,从而体现出更加逼真的高光现象。

我也刚刚接触Qt设计和OpenGL的编程开发,项目中一定有很多不完善甚至错误的地方。从什么都不懂到完成这个项目才用一个多点的星期。期间在LearnOpenGL CN的教程从头开始学,代码也借鉴和使用了部分教程里的内容。这个项目的代码放在这里