02 绘制简单几何图形

发布时间 2023-04-19 19:36:42作者: 暮星回音

图形渲染管线与绘制简单几何图形

1. 图形渲染管线回顾

简要回顾一下GAMES101中闫老师提到的图形渲染管线。

img

图形渲染管线可以理解为,将原始的3维图形数据经过一系列变化处理后,转换为2维坐标,再将2维坐标转换为实际的屏幕像素的过程。

这一过程可以简单的描述为:

  1. 首先我们要做的是输入一系列三维空间顶点;
  2. 接着对每一个顶点做model,view,projection变换,把三维空间中的点投影在二维屏幕上。
  3. 其次按照我们需要绘制的几何信息,将点构成三角形。
  4. 然后我们进行光栅化。通过判断屏幕空间上每一个像素中心点是否在三角形内,把三角形离散为不同的像素。当光栅化产生一系列的fragment/像素时,判定其是否可见。
  5. 之后我们对像素进行着色。这一着色过程可以发生在Vertex Processing(Gouraud Shading,每个顶点做一次着色)和Fragment Processing(Phong shading,每像素做一次着色)两个阶段 。
  6. 最后输出屏幕图像。

图形渲染管线的每个阶段会把前一个阶段的输出作为输入,并且可以在GPU上并行执行,快速处理数据。

2. 渲染三角形

想要绘制一个三角形,除了设置顶点数据,将顶点数据初始化至缓冲,之外我们还要构建并编译我们的着色器程序。在这里,我们分别定义一个顶点着色器和片段着色器,然后我们把两个着色器对象链接到一个用来渲染的着色器程序中,并在渲染前告诉OpenGL该如何解析顶点数据。渲染时我们使用定义的顶点属性配置来绘制图元。

我们首先构建编译着色器,接着再传入初始数据绘制三角形。

2.1 构建编译着色器程序

2.1.1 顶点着色器

//暂时将顶点着色器的源代码硬编码在文件顶部的C风格字符串中,为了能够让OpenGL使用它,必须在运行时动态编译它的源代码。
const char *vertexShaderSource = "#version 330 core\n"   //版本声明,核心模式。
    "layout (location = 0) in vec3 aPos;\n"//在顶点着色器中声明所有的输入顶点属性,这里只设置3D坐标。layout (location = 0)设定了输入变量的位置值。
    "void main()\n"
    "{\n"
    "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"//将位置数据赋值给预定义的gl_Position变量,并作为顶点着色器的输出。
    "}\0";

int main(){
......

    //1.顶点着色器

    unsigned int vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);//创建着色器对象,着色器类型为GL_VERTEX_SHADER
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);//glShaderSource把着色器源码附加到着色器对象上。参数分别为:着色器对象,源码字符串数量,着色器源码,NULL(暂定)
    glCompileShader(vertexShader);//编译

    //2.检查着色器编译是否错误

    int  success;//定义一个整型变量表示是否成功编译
    char infoLog[512];//定义一个储存错误消息的容器
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);//glGetShaderiv检查是否成功编译
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);//获取错误信息并打印
        std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
......
}

2.1.2 片段着色器

计算像素最后的颜色

const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n" //用out来声明输出变量
"void main()\n"
"{\n"
"   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"//将一个Alpha为1的颜色赋值给颜色输出。
"}\n\0";

int main(){
......
    //3.片段着色器
    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    //4.检查着色器编译错误
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
......
}

2.1.3 着色器程序

顶点着色器和片段着色器都已经编译完毕,接下来我们必须把两个着色器对象链接到一个用来渲染的着色器程序对象(Shader Program Object)中。在渲染对象的时候激活这个着色器程序,已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

int main(){
......
    //5.链接着色器
    unsigned int shaderProgram;
    shaderProgram = glCreateProgram();//创建一个着色器程序对象,并返回新创建程序对象的ID引用。
    glAttachShader(shaderProgram, vertexShader);//把顶点着色器附加到着色器程序对象上
    glAttachShader(shaderProgram, fragmentS);
    glLinkProgram(shaderProgram);//链接两个着色器
    //6.检查链接着色器程序是否错误,并获得相应的日志
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
    if (!success) {
        glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
        ...
    }
   
    //着色器对象链接到程序对象之后,删除着色器对象
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

}

在渲染循环中,我们可以调用glUseProgram函数,激活这个着色器程序对象:

int main(){
......

while (!glfwWindowShouldClose(window))
{
......
glUseProgram(shaderProgram);//glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用之前写的着色器了。
}
}

2.2 顶点输入

本文的目的是渲染两个相邻的三角形。所以我们在这里传入6个3D坐标作为图形渲染管线的输入。这6个顶点使用数组的形式传入,该数组我们称之为顶点数据(Vertex Data),而顶点数据又是用顶点属性(Vertex Attribute)表示的,它可以包含任何我们想用的数据,在这里为了简便起点,顶点数据只由3D位置和RGB值组成。

OpenGL仅当3D坐标在3个轴(x、y和z)上(-1.0,1.0)的范围内时才处理它。所有在这个范围内的坐标叫做标准化设备坐标(Normalized Device Coordinates),此范围内的坐标最终显示在屏幕上(在这个范围以外的坐标则不会显示)。

定义一个标准化设备坐标的float数组,然后将这个数组发送给顶点着色器:

float vertices[] = {
        // 第一个三角形
        -0.9f, -0.5f, 0.0f,  // 左
        -0.0f, -0.5f, 0.0f,  // 右
        -0.45f, 0.5f, 0.0f,  // 上 
        // 第二个三角形
         0.0f, -0.5f, 0.0f,  // 左
         0.9f, -0.5f, 0.0f,  // 右
         0.45f, 0.5f, 0.0f   // 上 
    };

顶点着色器会在GPU上创建内存用于储存我们的顶点数据,我们通过顶点缓冲对象(VBO)来管理这个内存。

在渲染前,我们需要指定OpenGL该如何解释顶点数据,也即是链接顶点属性,这通过设置顶点属性指针来完成。

    unsigned int VBO;
    glGenBuffers(1, &VBO);//创建顶点缓冲对象VBO
    glBindBuffer(GL_ARRAY_BUFFER, VBO);//利用glBindBuffer函数把新创建的VBO绑定到GL_ARRAY_BUFFER目标上。

    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//把用户定义的数据复制到当前绑定缓冲的函数 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    //第一个参数指定我们要配置的顶点属性,第二个参数指定顶点属性的大小,第三个参数指定数据的类型,第四个参数决定数据是否被标准化,第五个参数为步长,第六为强制类型转换。
    glEnableVertexAttribArray(0);//以顶点属性位置值为参数,启用顶点属性。
    

当绘制更多的物体,或者物体具有更多的顶点属性时,绑定正确的缓冲对象,为每个物体配置所有顶点属性就会变得极其复杂。我们使用顶点数组对象(VAO)来解决这个困难。

VAO可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO即可。

生成/配置/解绑所有的VAO,VBO代码如下:

    unsigned int VBO;
    unsigned int VAO;
    glGenBuffers(1, &VBO);//创建顶点缓冲对象VBO
    glGenVertexArrays(1, &VAO);//创建顶点数组对象VAO
    glBindVertexArray(VAO);//首先绑定VAO,然后绑定并设置顶点缓冲,配置顶点属性

    //绑定顶点缓冲
    glBindBuffer(GL_ARRAY_BUFFER, VBO);//利用glBindBuffer函数把新创建的VBO绑定到GL_ARRAY_BUFFER目标上。
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//把用户定义的数据复制到当前绑定缓冲的函数

    //配置顶点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//该函数告知OpenGL该如何解析顶点数据。第一个参数指定我们要配置的顶点属性,第二个参数指定顶点属性的大小,第三个参数指定数据的类型,第四个参数决定数据是否被标准化,第五个参数为步长,第六为强制类型转换。
    glEnableVertexAttribArray(0);//以顶点属性位置值为参数,启用顶点属性。

    //解绑VAO,VBO
    glBindBuffer(GL_ARRAY_BUFFER, 0);//对glVertexAttribPointer的调用将VB哦注册为顶点属性的绑定顶点缓冲区对象,因此之后我们可以安全的解除绑定
    glBindVertexArray(0);//之后,你可以取消绑定VAO,这样其他VAO调用就不会意外修改此VAO,但这情况很少发生。修改其他VAO无论如何都要调用glBIndVertexArray,所以若无必要,通常不解绑VAO,VBO。

2.3 绘制图形

        //绘制我们的第一个三角形
       
        glUseProgram(shaderProgram);//激活着色器程序对象。glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用之前写的着色器了。
        glBindVertexArray(VAO);
        glDrawArrays(GL_TRIANGLES, 0, 6);//GL_TRIANGLES为OpenGL图元类型,0为顶点数组起始索引,6为绘制六个顶点
        glBindVertexArray(0); //可以注释掉,不需要每次解除绑定

img

3. 渲染矩形

当我们在绘制矩形的时候,我们实际上再绘制两个拥有同一个边的三角形。

矩形有四个顶点,两个三角形确是六个顶点,多出的顶点无疑会造成额外的计算开销。

为了解决这个问题,OpenGL使用索引绘制的方案来储存不同的顶点,并设定绘制这些顶点的顺序。

具体来说,我们首先只定义两个三角形的四个不同顶点,然后创建元素缓冲对象(EBO),这个缓冲区可以存储 OpenGL 用来决定要绘制哪些顶点的索引。通过调用索引缓冲区,我们来绘制两个三角形,达到绘制矩形的目的。

 //设置顶点数据(和缓冲区)并配置顶点属性
    float vertices[] = {
        0.5f,  0.5f, 0.0f, //右上
        0.5f, -0.5f, 0.0f, //右下
       -0.5f, -0.5f, 0.0f, //左下
       -0.5f,  0.5f, 0.0f  //左上
    };

    unsigned int indices[] = {
        0,1,3,//索引从0开始
        1,2,3
    };
 
    ......
    unsigned int EBO;
    glGenBuffers(1, &EBO);//创建元素缓冲对象

    ......

    //绑定元素缓冲对象
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); //绑定EBO
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);//把索引复制到缓冲中。
    

    //解绑EBO
   glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);//VAO处于活跃状态时,不要解除EBO的绑定,因为绑定的元素缓冲区对象存储在VAO中。


 //渲染循环
    while (!glfwWindowShouldClose(window))
    {
        ......
       
        glUseProgram(shaderProgram);//激活着色器程序对象。glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用之前写的着色器了。
        glBindVertexArray(VAO);

        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);//绘制模式,顶点个数,索引类型,EBO偏移量
    
        glBindVertexArray(0); //可以注释掉,不需每次解除绑定
        
    }

img

参考资料:

1.GAMES101-现代计算机图形学入门-闫令琪_哔哩哔哩_bilibili

2.LearnOpenGL CN

3.Anton's OpenGL 4 Tutorials0