前言

 

        这次讲解的是GPU如何来渲染我们的图像,了解GPU渲染管线以及相应的代码编写流程,并通过讲解上一节的代码来具体了解渲染的过程。
        由于篇幅和个人知识有限,有些地方存在错误和不足,还望大家提出建议并指正。

目录

一、GPU是怎么画画的(GPU渲染管线流程)

        1. 渲染流水线

        2. 应用程序阶段

        3. 几何阶段

         4. 光栅化阶段

二、怎么告诉GPU画什么,怎么画

        1. 窗口和视口

        2. EBO、VBO和VAO

        3. 着色器

三、代码是怎么实现的

        1. 初始化相关设置并创建窗口与视口

        2. 生成和绑定VBO和VAO

        3. 顶点着色器和片段着色器

        4. 渲染三角形

        5. 后续处理


一、GPU是怎么画画的(GPU渲染管线流程)

        1. 渲染流水线

        图像的渲染过程可概括为如下三个步骤,渲染管线的作用主要是:在给定虚拟相机、三维物体、光源、照明模式,以及纹理等诸多条件下生成或绘制一张二维图像

gpu 绘制字体 gpu画图_c++

        众所周知,CPU是一台计算机的核心,在计算机的图像中,渲染的起点同样是CPU而非GPU。渲染的时候,CPU会将需要绘制的数据信息发送给GPU,也就是告诉GPU:“嘿!老哥,帮我把这些东西画在屏幕上。”这就是渲染的第一个阶段,应用程序阶段。
        在接收到CPU老大哥发来的画画命令和数据后,GPU就开始画画了。GPU拿到的数据是最原始的数据,没有经过任何加工,所以在画到屏幕之前,GPU要对数据进行一系列的处理。
        首先,原始数据中一般都是三维坐标和二维坐标的数据,GPU需要将这些数据处理成能在屏幕上显示的屏幕坐标数据,也就是说GPU要处理这些图形应该画在屏幕的哪个位置。这个就是渲染管线中的几何阶段。
        然后知道了要在哪里画画了,接下来就是要开始上色了。根据几何阶段变换后的数据,计算出屏幕上每个像素的颜色值,然后输出到屏幕。这就是光栅化阶段。
        GPU完成的工作就是GPU的渲染流水线,也叫GPU渲染管线。接下来详细介绍每一个阶段的工作。

        2. 应用程序阶段

        该阶段是CPU将需要显示在屏幕上显示绘制出来的几何体,也就是会制图元,如点、线、矩形等输入到渲染管线的下一阶段(几何阶段)。数据包括图元的顶点数据、摄像机位置、光照纹理等。

        3. 几何阶段

        几何阶段的功能是将顶点数据进行屏幕映射。其中包括:

  • 将各个图元放入到世界坐标系中,即进行模型变换
  • 根据光照纹理等计算顶点处材质的着色效果
  • 根据摄像机的位置、取景范围进行观察变换和裁剪
  • 最后进行屏幕映射,即把三维模型转换到屏幕坐标系中

        几何阶段的步骤如下图所示

gpu 绘制字体 gpu画图_图形学_02

        其中,在顶点着色器中完成模型变换、视图变换、顶点着色,在几何、曲面细分着色器中完成顶点的增删和曲面的细分,在裁剪步骤中完成投影变换和裁剪,最终进行屏幕坐标映射。 

         4. 光栅化阶段

        光栅化阶段的功能是给每个像素正确配色,以便绘制整幅图形。由于输入的是三角形的顶点,所以根据三角形的表面差异遍历每个三角形,计算每个像素点的颜色值,再根据可见性等进行合并,得到最后的图形

gpu 绘制字体 gpu画图_gpu 绘制字体_03

        其中,三角形设置是将映射到屏幕后的三角形顶点连接成三角形网格,然后通过三角形遍历得到每个三角形对屏幕像素的覆盖情况,接着通过片元着色器计算出每个三角形覆盖的像素颜色值,最后的片元操作是对所有片元进行遮挡、透明、融合等处理,最后得到每个像素点的颜色值用于绘制。

二、怎么告诉GPU画什么,怎么画

        知道了GPU是怎么画画的,那如何让GPU画我们想要的东西呢?显然,让我们直接和GPU沟通是不实际的,我们需要设置一大堆的寄存器、显存等,效率低且存在出错的风险,且不通硬件的具体设置不尽相同。所以在图形编程中,我们需要借助一些诸如OpenGL、DirectX之类的工具来代替我们跟GPU打交道。这些工具提供的图形编程接口在不同的硬件上进行了抽象,提高编程效率的同时增加的兼容性。
        那么,如何使用这些工具来告诉GPU我们要画什么呢?作者学习使用的是OpenGL。
        首先,了解过图形渲染流程的我们知道,要绘制一个图形,我们至少需要顶点位置数据以及相应的着色器来着色。并且要看到我们画出来的东西,我们还需要一个窗口,并且设置视口大小,视口顾名思义就是用来观看的一个窗口,超过这个范围的东西我们是看不到的。通俗讲就是现实中,在我们实现范围内的东西我们可以看见,我们视线范围外(比如脑袋后面)的东西我们是看不见的。那么我们该如何创建窗口,定义视口、顶点和着色器呢?
        接下来介绍OpenGL中使用的方式,并在下一节使用代码实现。

        1. 窗口和视口

        窗口和视口是两个概念。窗口就是windos中最常见的东西了,比如你现在正在看这篇文章使用的浏览器,它就是一个窗口,窗口就是用来显示的,它可以移动、最大化、最小化,以像素为单位。而OpenGL中的视口是在窗口中可以用于绘图的一块区域,它可以大于、小于或等于窗口大小,一般我们将它的大小设置与窗口等大。在一个窗口中可以创建多个视口,比如不同视口用于显示一个物体的三视图。
        OpenGL中的视口坐标系是正规化的空间坐标系,也就是视口的X、Y、Z轴的范围都是[-1.0f, 1.0f],即视口的最左边为X轴的-1.0f,最右边是X轴的1.0f,Y轴是最上边是1.0f,下边是-1.0f,而Z轴是垂直于屏幕的方向,用于表示深度。

gpu 绘制字体 gpu画图_c语言_04

        2. EBO、VBO和VAO

        创建好窗口并定义好视口后,就该创建顶点了,那么该怎么创建并管理我们的顶点呢?这里介绍一下EBO、VBO和VAO的概念。
        EBO(Element Buffer Object, 也叫IBO:Index Buffer Object)索引缓冲区对象,它用来储存顶点的索引信息。那么它是用来干啥的呢?这边举一个例子,当我们要画一个四边形(即两个三角形面片),我们需要绘制两个三角形,需要六个顶点信息。如下图左图所示[V0,V1,V2]、[V3,V4,V5]为两个需要绘制的三角形面片的顶点集合。显然,V2和V3,以及V1和V4应该是两个完全相同的点,但是在存储时却存在两个不同的拷贝,这不免造成了空间的浪费,所以出现了右图使用索引的方式来确定三角形面片的方式。这种方式下相同的顶点可以通过索引的方式重复使用,所以每个顶点在储存时只需要储存一次即可。如下图右图所示,绿色区域即是四个顶点的索引,通过索引的重复使用[0,1,2]、[1,2,3]来定义两个三角形面片。
        由于索引信息大小远远小于顶点信息,所以顶点较多且重复使用较多的情况下,使用索引的方式能大大减少对储存空间的占用。

gpu 绘制字体 gpu画图_c++_05

        VBO(Vertex Buffer Object)顶点缓冲区对象。它主要用来储存顶点的各种信息。使用VBO的好处是将模型的顶点数据存入VBO后,数据不再是由CPU读取内存后送入GPU,而是GPU中直接从显存中读取,从而提高效率。
        VAO(Vertex Array Object)顶点数组对象。它主要作用是来管理VBO,它是一个保存了所有顶点数据属性的状态结合 ,储存了顶点数据的格式以及顶点所需的VBO对象的引用。即它是一个VBO引用的集合。VAO的出现是因为VBO在每次绘制时需要绑定顶点信息,当数据量很大时,这种绑定将会很麻烦,而VAO是将多个VBO保存在一个VAO对象中,这样每次绘制模型是要绑定VBO即可。
         这三者的简单关系如下,由于篇幅问题,这里不探讨中复杂的关系,要是有人想了解的话之后单独写一篇讲解。(其实我还在学)

gpu 绘制字体 gpu画图_c++_06

        3. 着色器

        有了顶点之后,接下来就是告诉GPU该怎么给我们要的模型上色了。这里就需要用到上面流程中频繁出现的东西——着色器了,而使用着色器需要用到着色器语言,着色器语言编写的代码在程序编译时不编译,只是以文本的方式存在,传到GPU后由显卡驱动进行翻译。由于我们使用的是OpenGL,所以我们使用OpenGL的着色器语言GLSL(OpenGL Shading Language)。GLSL和C语言的语法较为相似,具体将在之后学习过程再整理讲解。
        在我们使用的OpenGL版本中,有以下四个着色器:

  • 顶点着色器(vertex shader)
  • 几何着色器(geometry shader)
  • 片元着色器(fragment shader)
  • 曲面细分着色器(tessellation shader)

        由于着色器语言在CPU不编译,所以OpenGL中使用着色器一般分为以下几步:

  1. 创建着色器对象
  2. 源码关联到着色器对象
  3. 编译着色器
  4. 创建一个程序对象
  5. 将着色器对象关联到程序对象

三、代码是怎么实现的

        知道了如何叫GPU画画,接下来就开始用代码来实现吧。这里使用上一节的三角形绘制代码进行讲解。

        1. 初始化相关设置并创建窗口与视口

        该步骤分为四个部分:初始化GLFW、创建窗口、初始化GLAD、创建视口。

        首先初始化GLFW

//初始化GLFW
    if (GLFW_FALSE == glfwInit())
        return -1;

    //主次版本号
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);

        首先直接使用glfw自带的初始化函数glfwInit()来初始化glfw。接着使用glfwWindowHint()函数来配置相应字段。
        首先是主次版本号GLFW_CONTEXT_VERSION_MAJORGLFW_CONTEXT_VERSION_MINOR这里使用的是3.3版本的OpenGL,所以主次版本都设置为3。接下两句分别是设置使用核心模式以及不允许改变窗口大小。

        接下来创建窗口。

//创建窗口
    GLFWwindow* window = glfwCreateWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "First", nullptr, nullptr);
    if (nullptr == window)
    {
        std::cout << "Failed create window" << std::endl;
        return -1;
    }
    //设置上下文
    glfwMakeContextCurrent(window);

        使用glfwCreateWindow()函数来创建窗口,设置其宽度、高度以及标题。后面两个参数表示是否使用全屏模式和使用共享上下文窗口,这里不使用,直接赋值空指针即可。然后将窗口的上下文设置为当前进程的主上下文。上下文是指OpenGL的状态,OpenGL其实是一个大的状态机,我们设置完后表示使用当前的上下文,也就是当前的OpenGL状态来渲染图形。

        接下来初始化GLAD。

//初始化GLAD
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to load glad" << std::endl;
        return -1;
    }

        由于GLAD是用来管理OpenGL的函数指针的,所以在调用任何OpenGL函数之前,必须初始化GLAD。这里要特别提醒一下,在包含glad和glfw头文件时,glad一定要在glfw之前被include。

#include "glad/glad.h"
#include <GLFW/glfw3.h>
#include <iostream>

         最后是创建视口,调用gl函数进行创建。这里创建的大小和窗口大小一样。

//创建视口
    glViewport(0, 0, SCREEN_HEIGHT, SCREEN_HEIGHT);

        2. 生成和绑定VBO和VAO

        初始化完成后,就要开始设置顶点了,我们画的是三角形,所以只需要设置3个顶点。然后进VBO和VAO的生成和绑定,这里没有用到EBO是因为绘制的图形非常简单,而且没有重复使用的顶点。

//生成和绑定VAO、VBO
    //三角形顶点
    const float triangle[] = {
        -0.5f,  -0.5f,  0.0f,
        0.5f,   -0.5f,  0.0f,
        0.0f,   0.5f,   0.0f
    };

    //VAO
    GLuint vertex_array_object;
    glGenVertexArrays(1, &vertex_array_object);
    glBindVertexArray(vertex_array_object);
    //VBO
    GLuint vertext_buffer_object;
    glGenBuffers(1, &vertext_buffer_object);
    glBindBuffer(GL_ARRAY_BUFFER, vertext_buffer_object);

    glBufferData(GL_ARRAY_BUFFER, sizeof(triangle), triangle, GL_STATIC_DRAW);


    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    //解绑VAO、VBO
    glBindVertexArray(0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);

         VAO和VBO都需要先使用相应的glGen函数先生成,在用相应的glBind函数进行绑定。然后使用glBufferData()函数将顶点数据绑定至当前默认的缓冲中。
        将数据发送到GPU后,需要告诉GPU如何解释这些顶点数据。使用glVertexAttribPointer()来告诉GPU如何来解释顶点数据。
        这个函数的第一个参数表示顶点着色器的位置值,将在后面用到。
        第二个3表示顶点数据是一个3分量的向量。
        第三个参数表示顶点的类型,这里用GL_FLOAT。
        第四个参数表示是否希望我们的数据被标准化,也就是重映射成-1到1之间,这里设置为GL_FALSE,不需要标准化。
        第五个参数是步长,它表示连续顶点属性之间的间隔,我们的下一个顶点数据是在3个float值之后,所以我们设置为3 * sizeof(float)。
        最后一个参数是数据的偏移量,我们的位置属性是在数组的开头,所以这里设置为0,并进行强制类型转换。
        然后使用glEnableVertexAttribArray()函数来开启这个通道。

        在属性指针设置完后,将VAO和VBO进行解绑,原因是防止之后的绑定对当前的数据进行修改,同时代码更加规范。

        3. 顶点着色器和片段着色器

        上一步完成后,数据已经在GPU上了,接下来就是创建着色器来处理数据。按照之前的步骤来创建着色器。

        首先是着色器代码,使用GLSL编写

//着色器代码
    const char* vertex_shader_source =
        "#version 330 core\n"
        "layout(location = 0) in vec3 aPos;\n"
        "void main()\n"
        "{\n"
        "   gl_Position = vec4(aPos, 1.0);\n"
        "}\n\0";

    const char* fragment_shader_source = 
        "#version 330 core\n"
        "out vec4 FragColor;\n"
        "void main()\n"
        "{\n"
        "   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);"
        "}\n\0";

         有了着色器源码后,需要进行生成和编译着色器。

//编译顶点着色器
    int vertex_shader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertex_shader, 1, &vertex_shader_source, NULL);
    glCompileShader(vertex_shader);

    int success;
    char info_log[512];

    glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertex_shader, 512, NULL, info_log);
        std::cout << info_log << std::endl;
    }

    //编译片段着色器
    int fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragment_shader, 1, &fragment_shader_source, NULL);
    glCompileShader(fragment_shader);

    glGetShaderiv(fragment_shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragment_shader, 512, NULL, info_log);
        std::cout << info_log << std::endl;
    }

         最后,将顶点着色器和片段着色器链接到着色器程序中,在将顶点着色器和片段着色器删除,因为已经编译连接到着色器程序中了,后续的渲染只需要用到着色器程序即可。

int shader_program = glCreateProgram();
    glAttachShader(shader_program, vertex_shader);
    glAttachShader(shader_program, fragment_shader);
    glLinkProgram(shader_program);

    glGetShaderiv(fragment_shader, GL_LINK_STATUS, &success);
    if (!success)
    {
        std::cout << "error" << std::endl;
        return -1;
    }

    glDeleteShader(vertex_shader);
    glDeleteShader(fragment_shader);

        4. 渲染三角形

        接下来就是最激动人心的渲染时刻了。
        我们将渲染代码写在一个while循环中,只要窗口还没有关闭,我们就一直渲染。

//绘制三角形
    while (!glfwWindowShouldClose(window))
    {
        //清空颜色缓冲,使用黑色最为背景
        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        
        //使用着色器程序
        glUseProgram(shader_program);

        //绘制三角形
        glBindVertexArray(vertex_array_object);
        glDrawArrays(GL_TRIANGLES, 0, 3);
        glBindVertexArray(0);

        //交换缓冲区并检测是否有触发事件
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

         在渲染循环中,首先先用黑色清空屏幕,接着使用前一步编译好的着色器程序。然后开始绘制我们的三角形,先绑定VAO,然后使用glDrawArrays()函数绘制三角形,绘制完成后将VAO进行解绑。最后交换缓冲区并检测触发事件。
        这里解释下为什么要交换缓冲区,图像渲染会有两个缓冲区,绘制时操作的缓冲区并不是我们看到的屏幕所使用的缓冲区,当绘制在另一个缓冲区完成时,交换两个缓冲区,将刚刚绘制的图像直接一次性显示出来,而下一次的绘制是在被交换下去的那个缓冲区,这样做时防止绘制时产生的闪屏,这也叫做双缓冲。

        5. 后续处理

        最后,当我们窗口关闭后,我们将我们之前创建的所有东西进行清理并退出程序。

//删除VAO,VBO
    glDeleteVertexArrays(1, &vertex_array_object);
    glDeleteBuffers(1, &vertext_buffer_object);
    //清理资源并正确退出
    glfwTerminate();

    return 0;