3 - 了解渲染管线

终于能开始画三角形了!本文将以画一个三角形为案例,初探OpenGL的图形渲染管线及其两个着色器(顶点,片段)等内容。

图形渲染管线

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。它可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的,并且很容易并行执行。

渲染管线的一些阶段可以通过着色器(Shader)高效进行。在OpenGL中,着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的。

如图,蓝色部分为可编程的着色器,其中 顶点着色器和片元着色器是必须设置好的,几何着色器可选。

顶点输入

开始绘制图形之前,需要先给OpenGL输入一些顶点数据。

渲染一个三角形要指定三个顶点,每个顶点都有一个3D位置。将它们以标准化设备坐标的形式(OpenGL的可见区域 [-1.0, 1.0])定义一个float数组:

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

定义顶点数据后,需要通过 顶点缓冲对象(VBO, Vertex Buffer Object) 管理显存,将大批顶点数据发送到显卡上,这样能够提高效率。

// VBO
unsigned int VBO;
glGenBuffers(1, &VBO);              // 生成
glBindBuffer(GL_ARRAY_BUFFER, VBO); // 绑定(以后关于GL_ARRAY_BUFFER
                                    // 的操作都在VBO上进行)
// 将定义的顶点数据复制给VBO
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

其中:

  • glGenBuffers()函数可以生成给定数量个VBO对象,并返回缓冲id;

  • glBindBuffer()函数将新创建的缓冲绑定到绑定到特定缓冲类型上(这里将VBO绑定到GL_ARRAY_BUFFER上),以后使用任何缓冲调用(这里是GL_ARRAY_BUFFER)都会用来配置当前绑定的缓冲对象(这里是VBO);

  • glBufferData()函数把用户定义的数据复制到当前绑定缓冲上。第一个参数是目标缓冲的类型,第二个参数是数据的大小,第三个是实际发送的数据,第四个是指定显卡如何管理数据:

    • GL_STATIC_DRAW :数据不会或几乎不会改变。
    • GL_DYNAMIC_DRAW:数据会被改变很多。
    • GL_STREAM_DRAW :数据每次绘制时都会改变。

    这里三角形顶点数据不会变,选择第一个就好。

现在已经把顶点数据储存在显卡的内存中,用VBO管理。下面会创建一个顶点着色器和片段着色器来真正处理这些数据。

顶点着色器

顶点着色器(Vertex Shader)把一个单独的顶点作为输入,把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。

一个基础的GLSL顶点着色器代码如下:

#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

其中,先声明使用OpenGL的版本和模式;再用in关键字声明所有的输入顶点属性(Input Vertex Attribute);layout (location = 0)设定了输入变量的位置值(Location);为了设置顶点着色器的输出,必须把位置数据赋值给预定义的gl_Position变量(vec4类型)。

然后暂时将此代码硬编码,开始进行编译着色器的操作:

const char *vertexShaderSource = "#version 330 core\n"
    "layout (location = 0) in vec3 aPos;\n"
    "void main()\n"
    "{\n"
    "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
    "}\0";

我们创建一个顶点着色器对象,对它进行编译操作:

// 顶点着色器的创建, 绑定与编译
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
// 检查是否编译出错
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

图元装配

图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入,并所有的点装配成指定图元的形状。

几何着色器

图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。

光栅化

几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段(Fragment,渲染一个像素所需的数据)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出视锥体以外的所有像素,用来提升执行效率。

片段着色器

片段着色器(Fragment Shader)的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

一个最简单的片段着色器如下:

#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
} 

片段着色器只需要一个输出变量,它表示的是最终的输出颜色。先暂时用硬编码存储:

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

然后也把它编译了:

// 片段着色器的创建,绑定,编译与验证
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
    glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}

测试与混合

这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。

这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。

所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

画三角形

链接着色器

编译完两个着色器后,还要将它们链接到一个用来渲染的 着色器程序(Shader Program) 中。

// 链接两个着色器至着色器程序中,并验证
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    std::cout << "ERROR::PROGRAM::SHADER::LINK_FAILED\n" << infoLog << std::endl;
}
// 链接好后,两个shader就没用了
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

别忘了通过glUseProgram()使用它:

// 渲染循环
while (!glfwWindowShouldClose(window))
{
    // 先模拟 
    processInput(window);

    // 后渲染
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // 画三角形
    glUseProgram(shaderProgram);
        
    // glfw: 交换颜色缓冲,检查IO等事件
    glfwSwapBuffers(window);
    glfwPollEvents();
}

这样,就把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。但OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。

链接的顶点属性

我们必须在渲染前手动指定OpenGL该如何解释顶点数据。本例中顶点数据会被解析成下图:

接下来应用glVertexAttribPointer()告诉OpenGL如何解析顶点数据:

// 解析顶点数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);

该函数的参数解释如下:

  • 指定要解析哪个顶点属性。之前在顶点着色器中使用layout(location = 0)定义了顶点属性的位置值(Location),现在我们希望解析这个顶点属性,因此为0.
  • 指定顶点属性的大小。顶点属性是vec3,有3个值,因此大小是3.
  • 指定数据类型。
  • 是否希望被标准化(Normalize)。如果为GL_TRUE,所有数据都会被映射到 [-1, 1](无符号为[0, 1])之间。
  • 步长(Stride),即在连续的顶点属性组之间的间隔。以三角形顶点信息为例,一个顶点有3个float数据,因此是sizeof(float) * 3。也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。
  • 位置数据在缓冲中起始位置的偏移量(Offset)。

告诉它如何解释顶点属性后,就让它开始解释吧:

glEnableVertexAttribArray(0);

使用VAO(顶点数组对象)

顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。

OpenGL的核心模式要求我们使用VAO。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。

一个VAO会存储以下内容:

  • glEnableVertexAttribArrayglDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

创建VAO的代码如下:

unsigned int VAO;
glGenVertexArrays(1, &VAO);

要想使用VAO,要做的只是使用glBindVertexArray()绑定VAO。从绑定之后起,我们应该绑定和配置对应的VBO和属性指针,之后解绑VAO供之后使用。

// 先绑定VAO,然后绑定并设置VBO,最后解析顶点属性
glBindVertexArray(VAO);

当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。

做好准备工作后,终于可以画三角形了,利用glDrawArrays(),它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。:

// 画三角形
glUseProgram(shaderProgram);
glBindVertexArray(VAO);             // 绑定要画的VAO
glDrawArrays(GL_TRIANGLES, 0, 3);

该函数第一个参数是打算绘制的图元类型;第二个参数指定顶点数组的起始索引;第三个参数是打算绘制多少个顶点。

画好的三角形如图:

画矩形

使用EBO(元素缓冲对象)

元素缓冲对象(Element Buffer Object,EBO),也叫索引缓冲对象(Index Buffer Object,IBO)。 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,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

可以发现两个三角形有两个顶点是一模一样的,如果重复绘制它们就会造成资源和效率上的浪费。因此需要 只储存不同的顶点,并设定绘制这些顶点的顺序,这样只需定义4个顶点就好。而这正是EBO/IBO所做的事情。

首先,定义顶点和索引信息:

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开始! 
    // 此例的索引(0,1,2,3)就是顶点数组vertices的下标,
    // 这样可以由下标代表顶点组合成矩形

    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

然后定义EBO:

unsigned int EBO;
glGenBuffers(1, &EBO);

和VBO类似,EBO也需要绑定,类型为GL_ELEMENT_ARRAY_BUFFER

// 绑定EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

然后,修改渲染部分,用glDrawElements()替换glDrawArrays(),表示我们要从索引缓冲区渲染三角形。

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

其中,第一个参数指定要绘制什么;第二个参数指定绘制顶点个数;第三个参数指定索引的数据类型;第四个参数指定EBO中的偏移量/索引数组。

该函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER的EBO中获取其索引。在绑定VAO时,它会帮我们绑定好EBO,所以确保没有在解绑VAO之前解绑EBO,否则它就没有这个EBO配置了。

我们画出的矩形如下:

使用线框模式

可以用线框模式(Wireframe Mode)看看矩形是不是由两个三角形组成的:

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

第一个参数表示我们打算将其应用到所有的三角形的正面和背面,第二个参数告诉我们用线来绘制。之后的绘制调用会一直以线框模式绘制三角形,直到我们用glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)将其设置回默认模式。

参考资料

  • 你好,三角形 - LearnOpenGL CN (learnopengl-cn.github.io)