OpenGL学习笔记《二》绘制简单的图形
在开始绘图之前,简单的了解一下opengl的绘图流程。在opengl里面,所有的事物都是处于3D空间中,而我们的屏幕及像素是以2D表现的,所以就需要将3D转换为2D。opengl内部管理这个流程的叫做渲染管线,主要分为两个部分:3D坐标转换为2D坐标,2D坐标转换为带颜色的像素数据。细分来看主要分为6个流程/步骤,每个流程相互独立,以流程数据的输入、输出作为数据传递方式。每个流程/步骤,由一小段程序/代码组成,所以又可以称之为shader/着色器程序。而gpu对于这些片段程序的执行效率非常高,并且由于gpu的核心数多,可以并发执行的量也非常大。
这六个步骤如下所示:
上图中蓝色标识的步骤,就是一般我们做shader编程的步骤。不过目前我们主要是做第一步(Vertex Shader顶点着色器)和第五步(Fragment Shader片段着色器)这两个步骤的编程。顶点着色器处理输入的顶点数据,转换好坐标,片段着色器处理进过光栅化后的像素数据,生成最终显示的颜色信息。
我们要在屏幕上绘制出简单的图形,那么就必须要提供顶点着色器和片段着色器,才能最终显示出样式、颜色正常的图形。
1、简单的着色器程序
首先是Vertex Shader,顶点着色器程序代码
const char* vertexShaderSource = "#version 330 core\n" "layout (location = 0) in vec3 aPos;\n" "void main()\n{" " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n" "}\0";
代码中指定了opengl的版本,然后指定了 in vec3类型的输入变量,这个是我们输入的顶点数据,注意到这里我们额外用到了layout(location = 0),这个在后面会提到。然后就是着色器程序的核心main函数了,在这里我们给opengl的內建变量gl_Position赋值,即我们传过来的顶点位置属性值。
其次是Fragment Shader,片段着色器程序代码
const char* fragmentShaderSource = "#version 330 core\n" "out vec4 FragColor;\n" "void main()\n{" " FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n" "}\0";
同样的需要指定opengl的版本,然后声明一个out vec4类型的变量,这个表示是着色器的输出数据,也就是片段着色器处理好的像素颜色值的数据,没有这个返回,我们的绘图出来的将会是空白或者黑的。
然后我们需要根据程序代码编译链接成着色器程序
// build and compile shader // -------------------------------- // create vertex shader unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER); // attach shader source glShaderSource(vertexShader, 1, &vSource, nullptr); // compile shader glCompileShader(vertexShader); // check error int success; char infoLog[512]; glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success); if (!success) { glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog); std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl; } // create fragment shader unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, &fSource, nullptr); glCompileShader(fragmentShader); glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success); if (!success) { glGetShaderInfoLog(fragmentShader, 512, nullptr, infoLog); std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl; } // build and link shaderprogram // -------------------------------- // create shader program unsigned int shaderProgram = glCreateProgram(); // attach shader and link program glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); // check error glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success); if (!success) { glGetProgramInfoLog(shaderProgram, 512, nullptr, infoLog); std::cout << "ERROR::PROGRAM::ATTACH_LINK_FAILED\n" << infoLog << std::endl; } // delete shader glDeleteShader(vertexShader); glDeleteShader(fragmentShader);
创建和编译着色器的流程都差不多:
glCreateShader根据参数类型,返回我们需要创建的着色器类型;
glShaderSource将我们提供的着色器代码绑定到着色器上;
glCompileShader编译我们的着色器;
glGetShaderiv可以检查我们的编译结果;
编译好着色器之后,我们需要创建着色器程序:
glCreateProgram创建着色器程序,返回一个unsigned int类型的索引ID,在后面我们需要用到;
glAttachShader将我们编译好的着色器附加到着色器程序上;
glLinkProgram链接好着色器程序;
glGetProgramiv可以检查我们的链接结果。
最后,我们需要删除编译的着色器,通过glDeleteShader方法。
我们最终需要用到的是glCreateProgram方法返回的着色器程序的索引ID。
2、提供顶点数据
上面的流程图中可以看到,第一步就是对输入的顶点数据进行处理,所以我们需要提供顶点数据。顶点数据包含顶点的位置、颜色、纹理坐标等多种类型的数据,在这里我们先简单的提供位置数据,颜色的话直接在片段着色器中固定一种颜色。
我们可以一次提供一个顶点数据,但是这个方式效率太低。以前的opengl也有一个Display list 的概念,一次打包提供一组数据,这样效率高,但是由于数据是直接存储在了GPU端,数据一旦提供了就无法再调整了。后来又提供了VBO(vertex buffer object)的概念,也是一次收集/打包好一组数据,但相较于Display List,数据是收集在CPU端,每次渲染会再传递一次。所以这种方式相较于一次提供一个顶点数据,效率更高,但是相较于Display List方式,效率稍微低一点,不过灵活性提高了。
所以我们首先可以声明一个顶点数据数组,然后绑定到VBO对象上:
float vertices[] = { -1.0f, -0.5f, 0.0f, // left 0.0f, -0.5f, 0.0f, // right -0.5f, 0.5f, 0.0f // top }; unsigned int VBO; glGenBuffers(1, &VBO); ... loop glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); glUseProgram(shaderProgram);
...draw
...loop
glBindBuffer 和 glBufferData 两个方法,及将顶点数据绑定VBO及赋值VBO对象的方法。然后GPU需要知道如何使用这些数据,glVertexAttribPointer方法负责让GPU知道如何使用这些数据。
glVertexAttribPointer的参数1对应我们在顶点着色器中提到的(layout = 0)的属性,参数2表示这个属性值用到的数据数量(我们这里用到的是位置属性,每个属性由三个浮点数据构成),参数3表示数据类型(我们这里用到的是浮点型),参数4表示是否需要对数据类型进行转换,参数5表示两两数据属性间的间隔(因为我们这里每个属性值之间是紧挨着的,所以间隔就是一个属性的数据量),参数6表示属性数据的起始索引(在这里我们填0,在后面涉及到配置多个顶点属性的时候,这个地方的值就会有变化);
glEnableVertexAttribArray 方法的参数,指定激活我们配置的哪个属性,在上面我提到了配置的是(layout = 0) 的属性,所以这里填0;
在上面的代码中我们可以看到,在渲染的循环中,我们一直要调用绑定VBO、赋值VBO、设置数据使用方法等接口,有点复杂。这个时候就需要引入另外一个概念vertex array object(VAO),来简化我们的操作。VAO概念是用来记录我们在绑定、赋值VBO对象,设置数据使用方法的,在引入VAO之后,我们的渲染流程可以调整为: unsigned int VBO, VAO; glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO); // bind the vertex array object glBindVertexArray(VAO); // bind the vertex buffer object glBindBuffer(GL_ARRAY_BUFFER, VBO); // fill data uage:GL_STATIC_DRAW, GL_DYNAMIC_DRAW, GL_STREAM_DRAW glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // tell opengl how it should interpret the vertex data glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);
...loop
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
...draw
...loop
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
可以看到,我们的渲染流程变得简单了,数据的初始化和配置,放到了渲染流程之外,在渲染的时候,只需要绑定VAO对象,就可以直接执行绘图操作了。当然在循环退出之后,我们也要释放掉VAO和VBO对象。
3、绘制第一个三角形
在上面我们提供了一个包含3个顶点位置的顶点数据,创建好了着色器程序,同时也引入了VAO概念,简化了我们的渲染流程。opengl提供了简单的绘图方法供我们使用,在这里我们需要绘制的是三角形,我们可以用如下的代码来绘图:
glDrawArrays(GL_TRIANGLES, 0, 3);
我们画的是三角形,所以我们第一个参数类型填的就是三角形;第二个参数指定顶点数据在数组中的起始位置,在这里我们填0;第三个参数表示要绘制的点数量,我们这里有三个顶点,我们传3就可以了。编译执行我们的项目,可以得到一个简单的三角形:
4、绘制一个矩形
在上面我们绘制了一个三角形,现在我们要绘制一个矩形,该如何操作?
我们知道一个矩形可以通过画两个三角形实现,所以我们可以改一下我们的顶点数据数组,提供两个三角形的顶点数据
float vertices[] = { // first triangle 0.5f, 0.5f, 0.0f, // top right 0.5f, -0.5f, 0.0f, // bottom right -0.5f, 0.5f, 0.0f, // top left // second triangle 0.5f, -0.5f, 0.0f, // bottom right -0.5f, -0.5f, 0.0f, // bottom left -0.5f, 0.5f, 0.0f // top left };
然后修改我们的绘图函数,将顶点数量改为6,我们就可以得到一个矩形了。
但是再看一下数组的数据,发现其中其实是有重复的,比如第一个三角形的右下角和第二个三角形的右下角。如果要绘制的矩形数量较少影响不大,如果矩形数量多了,那么我们就会有好多重复的数据,将会占用额外的内存,同时也造成项目数据复杂化了。所以此时我们需要引入另外一个概念element buffer object(EBO)对象,来简化我们的操作,在中文中这个对象也翻译为顶点索引对象。顾名思义,这个是对顶点进行索引编号,告诉GPU该如何使用我们提供的顶点数据。
// indice data unsigned int indices[] = { // note that we start from 0! 0, 1, 3, // first triangle 1, 2, 3 // second triangle }; unsigned int VAO, VBO, EBO; // gen glGenBuffers(1, &EBO); // bind VAO // bind VBO // bind EBO glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // set pointer ... render loop ... render loop glDeleteVertexArrays(1, &VAO); glDeleteBuffers(1, &VBO); glDeleteBuffers(1, &EBO);
简化一下流程。我们需要提供一份索引列表,对应的就是顶点数据中的顶点索引。然后绑定并赋值EBO对象,其余的操作跟之前类似,编译执行,我们就得到了一个简单的矩形。
在这里我们需要注意的是,在最后删除VAO、VBO、EBO的时候,一定不能在删除VAO对象之前先删除EBO,因为在VAO对象内部其实维护了一份EBO对象的数据,如果先删除了EBO,会导致删除出现异常。
以上就是利用opoengl提供的接口,绘制的简单图形。
对应的代码在这里。
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步