OpenGL教程(三)

发布于:2022-11-09 ⋅ 阅读:(14) ⋅ 点赞:(0) ⋅ 评论:(0)

前言

之前的文章我们已经介绍了如何绘制一个窗口,但是也仅仅就是一个窗口,没有任何的图案和纹理,这篇文章就将介绍如何利用OpenGL去绘制一个三角形。在OpenGL中所有的东西都是3D空间中,但是显示屏是2D的,所以绝大多数OpenGL的工作就是通过管线将3D坐标转化为2D的屏幕坐标。管线主要可以分为两个部分,一个部分将3D坐标转化为2D坐标,另一部分则是将2D坐标转化为着色的像素。这篇文章会简单介绍一下管线以及如何利用它去创造有趣的像素。

着色器(shaders)

正如之前所提到的,管线将3D坐标作为输入,输出为屏幕上着色的2D坐标,这个过程可以分为若干步,每一步都将上一步的输出作为输入,这些步骤都有一个特定的函数,这些函数很容易并行执行,正是由于它们的并行性,现在的显卡拥有成千上万的小的处理核心快速处理管线里的数据,这些小的处理程序就是着色器(shader)。这些着色器是可以配置的,开发者可以编写自己的着色器去代替默认的着色器,着色器是使用OpenGL Shading Language(GLSL)编写的,GLSL的语法等相关内容后面会详细介绍。通过这些着色器我们可以更好地控制GPU的运行过程,从而绘制想要的内容。

下图展示了管线的所有的阶段,蓝色的部分表示我们可以替换其中的shader。
正如图中所示,管线包含多个阶段,以如何绘制一个三角形为例,我们会简要介绍每一个阶段,首先建立起一个对于管线操作全局的视角。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ISDGSfro-1668007801130)(img/shader.png)]

为了绘制一个三角形,我们需要传入三个3D坐标去形成一个三角形,这些坐标数据成为顶点数据(Vertex data), 每一个顶点是关于一个3D坐标的一系列数据,我们使用顶点属性去表征一个顶点数据,理论上来说顶点属性可以包含一切我们想要的数据,但是为了简化我们假定每一个顶点只包含一个3D坐标和一些颜色值。

为了让OpenGL知道我们提供的坐标数据和颜色数据是如何组成,我们需要提供一个提示给OpengL,这个提示表明我们想要这些数据渲染成什么,一个点,一个三角形或是一条线,这些提示叫做图元(primitive),每次调用绘制命令时都需要指定图元,例如GL_POINTS,GL_TRIANGLES,GL_LINE_STRIP。

管线的第一个部分是顶点着色器(vertex shader),顶点着色器的输入是一个顶点,其主要的作用是将3D坐标转化为另一种3D坐标(之后会解释),与此同时,顶点着色器允许对顶点属性做一些简单的处理。

在图元装配阶段(primitive assembly stage)将所有顶点着色器产生的顶点作为输入,然后通过给定的图元将所有的给定的顶点装配起来,在我们这个例子中是三角形。

图元装配阶段的输出会传送给几何着色器(geometry shader),几何着色器将组成图元的一系列顶点作为输入,而且几何着色器可以发散新的顶点去形成新的或其他的图元,并由此去产生新的图形,在这个例子里是在跟定的图形中产生了第二个三角形。

几何着色器的输出会进入光栅化阶段(rasterization stage),光栅化阶段会将产生的图元转化为最终屏幕上的坐标。其产生的片段会交给片段着色器(fragment shader)。在片段着色器运行前会先进行裁剪,将视图外的所有的片段去除以此提高性能。

一个片段在OpenGL中是指渲染一个像素点所需要的全部数据。

片段着色器的主要作用就是计算每一个像素最终的颜色,这是很多OpenGL效果叠加的结果,通常情况下片段着色器包含的数据是3D场景下的,可以计算出最终像素的颜色(入灯光、阴影等)。

在所有的颜色值都已经被确定后,其最后的结果还是要经过alpha测试和混合阶段,这个阶段会检查相应的深度值去确认最终的片段是在一些物体前面还是后面,并以此为根据确定是否丢弃该片段。

在现代的OpenGL中开发者需要定义一个顶点着色器和片段着色器,因为在GPU中没有默认的顶点着色器和片段着色器,因为这个原因开始学习OpenGL十分的困难,因为仅仅是渲染一个三角形也需要非常多的知识,不过一旦你渲染出了第一个三角形,你对于图像编程也会有更深的理解。

在开始绘制之前首先需要给OpenGL一些顶点数据,OpenGL库是一个3D的图形库,所以在OpenGL中使用的坐标都是3D的(x,y,z),OpenGL不是简单将3D坐标转化为2D坐标,OpenGL只会处理处于-1与1之间的坐标
,这种坐标称作标准设备坐标。

因为我们想要绘制一个三角形,所以需要3个3D顶点坐标,我们将其定义在一个浮点数数组中,因为是在一个3D空间里渲染一个2D图形,所以需要将z设置为0。代码如下

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

当我们定义好顶点数据后需要把这些顶点数据传递给图形管线的第一个处理阶段:顶点着色器,这个过程需要在GPU上创建内存来存储顶点数据,配置OpenGL如何与内存交互传递给显卡,然后顶点着色器从内存中获取相应的顶点数据。

我们通过顶点缓冲对象(VBO)将顶点数据存储在GPU的内存中,从CPU传输数据到GPU是非常慢的,使用这些缓冲对象可以一次性传输大量数据到显卡,将这些顶点数据传输到显卡上后,顶点着色器就可以很快的读取数据。顶点缓冲对象和其他我们之前讨论的OpengL对象一样都有一个唯一的ID,可以用glGenBuffers函数来生成,

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

OpenGL拥有很多类型的缓冲对象,顶点缓冲对象的类型是GL_ARRAY_BUFFER,Open GL允许同时绑定多个缓冲对象只要它们的类型不相同即可,可以通过glBindBuffer将创建的缓冲对象与类型绑定。

glBindBuffer(GL_ARRAY_BUFFER, VBO);

glBufferData函数则是用于拷贝用户之前定义的数据并转化为新的类型,第一个参数是目标缓冲对象类型,第二个参数是数据的大小,第三个参数是传输给缓冲对象的数据,第四个参数是决定显卡如何管理传输的数据,主要有三种形式:

  • GL_STREAM_DRAW:数据只会被设置一次,GPU只会使用几次
  • GL_STATIC_DRAW:数据只会被设置一次,但是会多次使用
  • GL_DYNAMIC_DRAW:数据会多次改变且会被使用多次

在我们的例子中三角形的位置数据是不会变,每一次渲染都是一样,所以使用的是GL_STATIC_DRAW,如果需要经常改变渲染数据则需要使用GL_DYNAMIC_DRAW。

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

顶点着色器(vertex shader)

当把数据存储到GPU就需要顶点着色器进行处理,那么该如何编写顶点着色器呢,着色器程序的编写有专门的语言GLSL(OpenGL Shading Language),以下代码就是一个非常基础的顶点着色器程序。

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

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

正如代码所显示的,GLSL的语法与C语言类似,每一个着色器程序都是以版本号声明开头的,
然后通过in关键字声明顶点着色器需要使用的输入数据,现在我们只关心位置数据,所以只需关注一个顶点属性,GLSL拥有一个数据类型vector,其中包含1-4个固定精度浮点数,因为每一个顶点都有一个3D坐标,所以创建一个vec3类型的变量aPos,并通过layout设置输入变量的位置,至于为什么这么做之后会解释。

上面介绍了顶点着色器的输入,那么顶点着色器的输出呢?顶点着色器的输出是已经定义好的gl_Position, 他的类型是vec4,是四维数据,所有我们需要将输入的三维数据转化为四维数据,其办法就是将第四维数据设置为1.0,至于为什么这么做之后的章节会详细解释。这就是一个最简单的顶点着色器程序了,我们几乎没有做任何操作,而在实际的顶点着色器程序中,由于传入的数据都还没有转为标准设备坐标,所以第一件事就是将坐标转化为标准设备坐标。

现在我们可以将顶点着色器的代码存储在一个字符串中

    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";

为了OpenGL能够使用顶点着色器,所以我们必须要编译顶点着色器,首先需要创建一个顶点着色器的对象,我们通过一个无符号整型存储顶点着色器对象,然后通过glCreateShader创建着色器,通过GL_VERTEX_SHADER指定是顶点着色器。

 unsigned int vertexShader;
 vertexShader = glCreateShader(GL_VERTEX_SHADER);

然后将着色器的源码与着色器绑定并编译着色器,glShaderSource函数第一个参数是着色器,第二个参数是源码的字符串的数量。第三个参数是源码,第四个参数暂时置为0。

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

这里存在一个编译的过程,有时候我们希望知道编译的结果,并对一些情况做一些处理,通过一下代码可以创建一个标志位来标志编译是否成功,然后通过infoLog来存储日志,如果出错便于定位问题,,然后通过glGetShaderiv来检查是否成功。

int success;

char infoLog[512];

glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

如果不成功通过glGetShaderInfoLog来打印错误信息。

if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" <<infoLog <<std::endl;

最后

这篇文章主要介绍了图形管线的六个阶段,以及介绍了如何编写一个简单的顶点着色器,更多文章可以关注公众号QStack。