并不简单的三角形绘制
在OpenGL的世界中,一切都是在三维空间中的,但是屏幕和窗口是二维的像素数组。所以OpenGL的一大工作就是将三维坐标转换为适合屏幕显示的二维像素。这个把三维坐标转换为二维坐标的过程是由OpenGL的图形渲染流水线来管理的。这个图形渲染流水线分为两大部分:首先是将三维坐标转换为二维坐标;其次是键二维坐标转换为真正的有颜色值的像素。在本次教程中,我们将会简单地讨论这个图形渲染流水线以及我们应该怎样使用它来帮助我们创建一些酷炫的像素出来。
注意一个二维坐标和一个像素点是不同的。一个二维坐标是一个点在二维空间中的精确表示,但是一个二维的像素点是一个二维空间中的点在屏幕分辨率的限制下的一个近似表示。
图形渲染流水线以一组三维坐标为输入,把它们转换成屏幕上着色的二维像素点。图形渲染流水线又可以分成许多步骤,每个步骤都是以前面步骤的输出作为当前步骤的输入。每一个步骤都是专用的,它们具有特定的功能,这方便了并行执行。因为这种并行特性,当今显卡基本上都包含成千上万个小的处理核心,这些核心帮助我们在GPU图形渲染流水线中的每个步骤中利用小的程序来快速处理数据。而这些在每个核心上面跑的小程序就叫做着色程序(shaders)。
在这些shader中,有一些是可以被开发者配置的,这些可配置的shader允许我们用自己写的shader来替换默认的shader。这给了我们对这个流水线的某些部分更细粒度的控制权,因为它们是在GPU上运行的,这或许也能够帮助我们节省宝贵的CPU时间。shader是用GLSL(OpenGL Shading Language,简称GLSL)语言开发的,我们将在下个教程中了解更多关于GLSL的知识。
下面这幅图展示的是对图形渲染管线所有阶段的一个抽象表示,其中蓝色的部分代表我们可以注入自己的shader,应该就是可以自己配置的意思。这里的图借鉴了
如你所见,这个图形渲染管线中包含了很多阶段,每个阶段完成从顶点数据项最终显示像素点的一部分特定的工作。我们将会以一个简化的方式简短地解释其中的每个阶段,目的是让你有一个对这个流水线工作方式的整体把握。
作为输入,我们把数组中能构成一个三角形的三个三维坐标值(称作顶点数据,Vertex Data)传递进这个流水线;顶点数据实际上就是所有顶点的集合,而每个顶点实际上就是每个三维坐标系中表示这个顶点的数据。实际上,我们用于表示一个点的数据中可以包含我们想要包含的属性,但是为了简化起见,在本例中,我们假设每个顶点只包含这个顶点的三维坐标和顶点的颜色值。
为了让OpenGL知道你想用这些顶点数据或者颜色值绘制什么图形,你需要指定你想用这些数据绘制的图形类型:是需要用它们绘制一些独立的点,还是需要用它们绘制三角形,或者是用它们绘制一条长长的线?点,三角形或者线,这些称作图元,是在任何绘制命令调用前需要告诉OpenGL的,也只有这样,OpenGL才知道在下一个状态用绘制命令和给定的数据绘制什么。指定的方式是通过前面说的状态设置函数完成的,这在后面具体用到的时候会说明。而OpenGL支持的图元类型永宏表示,比如GL_POINTS,GL_TRIANGLES和GL_LINE_STRIP。
好的,以上图为例,假设我们已经指定了要绘制三角形,并且已经输入了顶点数据(包含三个顶点的位置坐标和颜色值),下面真正进入图形渲染流水线:
流水线的第一阶段是顶点处理器,它以单独的顶点(在本例中包含位置坐标和颜色值)作为输入,完成的主要功能是将顶点的三维坐标转换成另一种三维坐标(后面具体会讲到),还有就是对顶点的属性做一些基本的处理。
图元装配阶段,以所有顶点处理器处理过的的顶点为输入(如果在前面指定的绘制的内容是GL_POINTS的话,那么就以单个顶点作为输入),生成一个图元并且根据图元的形状放置所有的顶点。在本例中就是构成一个三角形图元,而且将这个三角形的各个顶点放到该放的位置。
图元装配的输入作为几何处理器的输入。几何shader以形成图元的顶点几何为输入,它能够生成新的顶点形成新的图元(不仅限于前面指定的图元,比如像本例中的三角形)。在本例中,它从给定的三角形(图元装配阶段的输出)中又生成了一个三角形。
几何处理器的输出被传递给光栅化阶段作为输入。光栅化阶段完成图元和最终要显示屏幕的对应像素之间的映射,它生成片段处理器用到的片段。在将这些片段输出到片段处理器之前,裁剪被首先执行。裁剪操作将所有超出显示范围的片段都去除,这样可以提高性能。
在OpenGL中,一个片段就是OpenGL渲染一个像素点需要的所有数据。
片段处理器最主要的作用是计算像素点最终的颜色,这个阶段也是所有高级OpenGL效果施展的地方。通常,片段中包含3D场景的数据(比如说光照、阴影和光照颜色等等),这些数据被用来计算出最终的像素颜色值。
在所有相关的颜色值都被确定后,最终的对象将会被传递到下一个阶段,我们称其为alpha通道测试和混合阶段。这个阶段检查片段的深度值和模板值(我们后面会了解到),并且用他们来检查这些生成的片段是否在其它对象的前面或者后面,如果在其它对象的后面,即被其它对象遮挡,那么这个片段就会被裁减掉。这个阶段也会检查alpha值(alpha值定义了一个对象的透明度)并且进行对象的混合操作(根据透明度的不同生成不同的效果)。所以即使一个像素的颜色值是在片段处理器阶段就生成的,但是到最终显示的时候,还是有可能完全不同(因为在这个阶段还会和其它对象进行相互作用,比如透明遮挡等等)。
如你所见,图形渲染流水线是相当复杂的,而且包含了很多可配置的部分(图中蓝色着色的阶段)。但是,我们大部分只关心顶点和片段处理器。几何处理器虽然是可选的,但是经常被设置为默认的。
在现代OpenGL中,我们需要自己至少定义一个顶点处理器(处理程序,shader)和一个片段处理器。因为在GPU中没有默认的顶点或者片段处理程序供我们选择。基于此,通常开始学习现代OpenGL是非常困难的,因为仅仅是渲染我们的第一个三角形都需要大量的相关知识。但是一旦你成功渲染了你的第一个三角形,你将会学到更多的OpenGL图形编程知识。
下面我们就来渲染我们的第一个三角形吧~
##顶点输入开始绘制之前我们首先要给OpenGL一些顶点数据。OpenGL是一个三维图形库,所以所有的坐标都应该是三维的,即包含x,y和z坐标。OpenGL不会简单地将你的三维坐标转换成屏幕上的二维像素。前面已经提到过,OpenGL中的坐标是标准化设备坐标系,即在x,y和z方向上都是-1到1之间的立方体。所有在这个标准化设备坐标系中的坐标才是可以显示在屏幕上的,而在这个标准化设备坐标系之外的坐标都不可能显示。因为我们想要渲染一个三角形。所以我们总共需要提供构成这个三角形的三个点的三维坐标值。我们利用一个GLfloat类型的数组定义他们在标准化设备坐标系的可见区域。GLfloat vertices[] = {-0.5f, -0.5f, 0.0f,0.5f, -0.5f, 0.0f,0.0f, 0.5f, 0.0f
};
因为OpenGL在三维空间中进行处理,但是我们希望渲染的是一个二维的三角形,所以我们将三个顶点的坐标值中的z值全部都设置为0.0。这样的能够使三角形的深度之保持一致,看上去像一个二维图形一样。>####标准化设备坐标系 Normalized Device Coordinates (NDC)当你的顶点坐标在顶点处理器中处理过,它们就应该在标准化设备坐标系中。标准化设备坐标系是一个小的立方体空间中,这个立方体的三个维度上(x,y和z)都在-1到1之间。任何在这个范围之外的坐标都不会在屏幕上显示。下图中可见在标准化设备坐标系统我们上面定义的三角形(先不考虑z轴,可以认为z轴是垂直于纸面的)。
通常的屏幕坐标系的原点是在屏幕的左上角上,而且y正轴是自原点垂直向下的。在标准化坐标系中却不同,其原点在正中,y轴垂直向上。最终你会希望你绘制的所有的对象的坐标都在这个标准化设备坐标系之内,否则它们不会被显示出来。你的标准化设备坐标最终都会被转换成屏幕坐标系中的坐标。这个转化过程是基于在程序中你设置的glViewport参数来完成的。生成的屏幕坐标系中的坐标被转换成片段并输入到片段处理器。上面我们已经完成了三角形顶点数据的定义,现在我们想要将这些数据作为图形渲染流水线的第一阶段的输入,也就是顶点处理器的输入。为此,我们需要在GPU中申请内存来存储这些顶点数据、告诉OpenGL应该如何解释这块内存并且指定应该如何将这些数据发送到显卡。之后顶点处理器就可以从内存中处理我们指定数量的顶点了。我们利用所谓的顶点缓存对象(vertex buffer objects,简称VBO)来管理这块内存。VBO能够在GPU的内存中存储大量的顶点。利用这种缓存对象的好处是我们可以一次就发送大批量的数据到显卡,而不用每次之传输一个顶点。毕竟从CPU向显卡中传输数据是非常慢的,所以我们总是找机会一次传输尽可能多的数据。一旦数据存储在显卡内存中,顶点处理器对这些数据的访问可以看成是瞬时的,这极大提升了顶点处理器的处理速度。VBO是我们在这个教程中遇到的第一个OpenGL对象。像OpenGL中的其它对象一样,它有一个ID唯一的表示一个缓冲区,所以我们可以像下面这样用glGenBuffers创建一个VBO。
GLuint VBO;
glGenBuffers(1, &VBO);
- 1
- 2
glBindBuffer(GL_ARRAY_BUFFER, VBO);
- 1
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
- 1
GL_STATIC_DRAW: 这些数据基本上不会改变或者极少情况下会被改变。
GL_DYNAMIC_DRAW: 这些数据可能会经常被改变。
GL_STREAM_DRAW: 这些数据在每次绘制的时候都会被改变。
#version 330 corelayout (location = 0) in vec3 position;void main()
{gl_Position = vec4(position.x, position.y, position.z, 1.0);
}
const GLchar* vertexShaderSource ="#version 330 core\n \
layout (location = 0) in vec3 position;\n \
void main()\n \
{\n \
gl_Position = vec4(position.x, position.y, position.z, 1.0);\n \
}\n\0";
那么怎么将它组装到我们的图形渲染流水线中呢?首先编译,然后组装。接着向下看吧。##编译shader我们已经有了顶点渲染程序(像上面那样存储在了字符数组中),在使用的时候,我们需要在运行时从它的源码动态编译它。为了编译这个shader,我们需要先创建一个shader对象,同样需要一个唯一的ID来标识。像下面这样通过GLuint来存储ID,通过glCreateShader来创建shader对象:
GLuint vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
GLint success;
GLchar 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;
}
#version 330 coreout vec4 color;void main()
{color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
GLuint fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
GLuint shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);...
}
glUseProgram(shaderProgram);
- 1
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
上面的过程相当于我们已经准备好了生产产品的硬件条件。一条我们定制化(顶点和片段处理器都由我们创建)的生产线(渲染程序对象),而且我们已经准备好了原材料(顶点数据)。我们似乎可以开工生产我们的产品(渲染我们的图形)了。但是并没有。OpenGL并不知道它应该如何使用我们的原材料(数据)。比如应该怎样取出和存入,怎样将它们和顶点渲染程序中定义的输入数据联系起来。下面我们将告诉OpenGL应该怎么使用这些数据。 ##设定顶点输入方式 前面,我们写的顶点处理程序只是设定了输入的类型(vec3)和输入后的索引(location=0),但是并没有指明我们的顶点数据的输入方式。我们的数组中一共有三个顶点9个数据,是下标为2的先输进去还是下标为0的先输进去?实际上,在OpenGL中顶点定点渲染程序允许我们以多种方式指定类似的输入方式,这提供了数据输入的巨大灵活性,但是也意味着我们需要手工指定我们的顶点数据和顶点处理程序中的顶点属性的对应关系。即我们需要指定OpenGL在渲染前应该如何解释或理解这些顶点数据。我们的顶点缓冲区中的数据的个数如下图所示:
位置坐标值都是32位(4字节)的浮点类型;每个位置由三个坐标值构成;在每组3个坐标值之间没有任何间隙,换句话说,数值在内存中是连续紧密存放的;数据的第一个值位于缓冲区开始的位置。基于以上这些信息,我们可以通过glVertexAttribPointer函数来告诉OpenGL应该如何解释这些顶点数据:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 0. Copy our vertices array in a buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. Then set the vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 2. Use our shader program when we want to render an object
glUseProgram(shaderProgram);
// 3. Now draw the object
someOpenGLFunctionThatDrawsOurTriangle();
GLuint VAO;
glGenVertexArrays(1, &VAO);
// ..:: Initialization code (done once (unless your object frequently changes)) :: ..
// 1. Bind Vertex Array Object
glBindVertexArray(VAO);// 2. Copy our vertices array in a buffer for OpenGL to useglBindBuffer(GL_ARRAY_BUFFER, VBO);glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);// 3. Then set our vertex attributes pointersglVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);glEnableVertexAttribArray(0);
//4. Unbind the VAO
glBindVertexArray(0);[...]// ..:: Drawing code (in Game loop) :: ..
// 5. Draw the object
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
glBindVertexArray(0);
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
到目前为止,全部的代码在这儿。
##元素缓冲对象 Element Buffer Objects除了上面介绍的利用glDrawArrays函数进行图形渲染的方式,实际上还有一种渲染方式,就是借助glDrawElements进行图形渲染。它和元素缓冲对象(Element Buffer Objects,简称EBO)是联系在一起的。解释元素缓冲对象(EBO)是如何工作的最好方式是给出一个例子:假设我们想要绘制一个矩形而不是三角形。我们可以利用两个三角形(OpenGL主要是利用基本图元三角形来完成复杂对象的绘制)来绘制一个矩形。按照上面讲过的流程,首先是数据的产生:GLfloat vertices[] = {// First triangle0.5f, 0.5f, 0.0f, // Top Right0.5f, -0.5f, 0.0f, // Bottom Right-0.5f, 0.5f, 0.0f, // Top Left // Second triangle0.5f, -0.5f, 0.0f, // Bottom Right-0.5f, -0.5f, 0.0f, // Bottom Left-0.5f, 0.5f, 0.0f // Top Left
};
GLfloat vertices[] = {0.5f, 0.5f, 0.0f, // Top Right0.5f, -0.5f, 0.0f, // Bottom Right-0.5f, -0.5f, 0.0f, // Bottom Left-0.5f, 0.5f, 0.0f // Top Left
};
GLuint indices[] = { // Note that we start from 0!0, 1, 3, // First Triangle1, 2, 3 // Second Triangle
};
GLuint EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
// ..:: Initialization code :: ..
// 1. Bind Vertex Array Object
glBindVertexArray(VAO);// 2. Copy our vertices array in a vertex buffer for OpenGL to useglBindBuffer(GL_ARRAY_BUFFER, VBO);glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);// 3. Copy our index array in a element buffer for OpenGL to useglBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);// 3. Then set the vertex attributes pointersglVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);glEnableVertexAttribArray(0);
// 4. Unbind VAO (NOT the EBO)
glBindVertexArray(0);[...]// ..:: Drawing code (in Game loop) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);
线框模式和填充模式
以线框模式绘制三角形(或者其它图元),需要利用状态设置函数glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)来完成,其中第一个参数指定对要绘制的图元的两个面(OpenGL中的绘制对象都是有两个面的,正面和反面,后面应该会讲到怎么区分这两个面)都采用同样的绘制模式,第二个参数指定以线框来绘制图元。随后的绘制命令都会以设定的线框模式来绘制图形,知道我们将绘制模式再次通过glPolygonMode函数将绘制模式指定为填充模式。
错误是不可避免的,如果有错误,说明前面的某一步可能出问题了。同时,也代表理解上可能有点问题,当然也有可能是我表述不清。。。。可以回过头来检查一下,到目前为止的多有代码都在这儿。值得注意的是,代码中为了和glDrawArrays绘制方式区别,以索引绘制的方式为其创建了另一份对应的VBO,EBO和VAO,所以有多个VBO和VAO,这样在切换的时候可以体会利用VAO进行状态设置保存的好处。
如果你按照上面的过程成功绘制了三角形或者矩形。你已经挺过了学习现代OpenGL几乎是最艰难的一段:绘制一个简单的三角形。万事开头难嘛。实际上,这其中包含了很多相关的知识,如果没有学过图形学相关的内容,看起来还是比较吃力的。如果有相关的图形学基础,可以发现,本次教程是对理论知识的一次小小实践。充分地理解这个过程是十分必要的,也是后面继续学习的基础。一旦对这些概念和过程有了充分的了解,后面的内容应该就相对简单一些了。