使用OpenGL进行3D图形渲染

引言


渲染3D图形不是一件容易的事,而是极其有趣和令人兴奋的。 本文适用于刚开始接触OpenGL的人员,或对图形管线的工作方式及其含义感兴趣的人员。 本文将不提供有关如何创建OpenGL上下文和窗口或如何编写第一个OpenGL窗口应用程序的确切说明。 这是由于每种编程语言的功能以及用于OpenGL的库或框架的选择(我将使用C ++和GLFW )的缘故 ,特别是因为可以轻松地在网络上找到您感兴趣的语言的教程。 本文中给出的所有示例都可以在其他语言中使用,但是命令的语义会稍有变化,为什么会这样,我稍后会讲。

什么是OpenGL?


OpenGL是一个规范,定义了一个平台无关的软件接口,用于使用二维和三维计算机图形编写应用程序。 OpenGL不是一种实现,而只是描述了应该实现的那些指令集,即 是一个API。


OpenGL的每个版本都有其自己的规范,我们将在3.3版到4.6版之间工作,因为 版本3.3中的所有创新都会影响对我们没有多大意义的方面。 在开始编写第一个OpenGL应用程序之前,建议您找出驱动程序支持的版本(可以在视频卡的供应商网站上执行)并将驱动程序更新为最新版本。


OpenGL设备


可以将OpenGL与大型状态机进行比较,大型状态机具有许多更改状态的功能。 OpenGL状态基本上是指OpenGL上下文。 在使用OpenGL时,我们将通过几个状态更改功能来更改上下文,并根据OpenGL的当前状态执行操作。


例如,如果我们给OpenGL命令在渲染之前使用线条而不是三角形,那么OpenGL将在所有后续绘制中使用线条,直到我们更改此选项或更改上下文为止。


OpenGL中的对象


OpenGL库是用C编写的,并具有用于不同语言的大量API,但它们都是C库。 C语言中的许多构造都没有翻译成高级语言,因此OpenGL是使用大量抽象技术开发的,其中一种抽象技术是对象。


OpenGL中的对象是确定其状态的一组选项。 OpenGL中的任何对象都可以通过其ID和它负责的选项集来描述。 当然,每种类型的对象都有其自己的选项,并且尝试为该对象配置不存在的选项将导致错误。 其中存在使用OpenGL的不便之处:一组选项由C相似的结构描述,其标识符通常是数字,这不允许程序员在编译阶段发现错误,因为 错误和正确的代码在语义上是无法区分的。


glGenObject(&objectId); glBindObject(GL_TAGRGET, objectId); glSetObjectOption(GL_TARGET, GL_CORRECT_OPTION, correct_option); //Ok glSetObjectOption(GL_TARGET, GL_WRONG_OPTION, wrong_option); //  , ..    

您会经常遇到这样的代码,因此当您习惯了设置状态机的感觉时,对您来说将变得更加容易。 此代码仅显示OpenGL工作方式的示例。 随后,将给出真实的例子。


但是有优点。 这些对象的主要特征是,我们可以在应用程序中声明许多对象,设置它们的选项,并且每当使用OpenGL状态开始操作时,我们都可以简单地将对象与我们的首选设置绑定在一起。 例如,这可能是带有3D模型数据的对象,或者是我们要在此模型上绘制的对象。 拥有多个对象可轻松在渲染过程中在它们之间切换。 通过这种方法,我们可以配置渲染所需的许多对象并使用它们的状态,而不会浪费帧之间的宝贵时间。


要开始使用OpenGL,您需要熟悉几个基本对象,否则我们将无法显示任何内容。 以这些对象为例,我们将了解如何在OpenGL中绑定数据和可执行指令。


基础对象:着色器和着色器程序。


Shader是一个小程序,可在图形管道中的某个点在图形加速器(GPU)上运行。 如果我们抽象地考虑着色器,可以说这些是图形管线的阶段,其中:

  1. 知道从何处获取数据进行处理。
  2. 知道如何处理输入数据。
  3. 他们知道在何处写入数据以进行进一步处理。

但是图形管道是什么样的? 很简单,像这样:



到目前为止,在此方案中,我们仅对主垂直线感兴趣,该主垂直线以“顶点规范”开始,以“帧缓冲区”结束。 如前所述,每个着色器都有自己的输入和输出参数,这些参数的类型和数量不同。
我们简要描述了管道的每个阶段,以了解其作用:

  1. 顶点着色器-需要处理3D坐标数据和所有其他输入参数。 最常见的是,顶点着色器计算顶点相对于屏幕的位置,计算法线(如果需要)并生成输入数据到其他着色器。
  2. 镶嵌着色器着色器和镶嵌控制着色器-这两个着色器负责详细说明来自顶点着色器的图元,并准备要在几何着色器中处理的数据。 很难用两个句子来描述这两个着色器的功能,但是为了使读者有所了解,我将给出几张重叠程度较低和较高的图像:

    如果您想了解有关镶嵌的更多信息,建议您阅读本文 。 在本系列文章中,我们将介绍曲面细分,但不会很快。
  3. 几何着色器-负责根据曲面细分着色器的输出来形成几何图元。 使用几何着色器,您可以从基本的OpenGL基本体(GL_LINES,GL_POINT,GL_TRIANGLES等)创建新的基元,例如,使用几何着色器,可以通过仅按颜色,聚类中心,半径和密度描述粒子来创建粒子效果。
  4. 光栅化着色器是非可编程着色器之一。 用一种易于理解的语言讲,它将所有输出的图形基元转换为片段(像素),即 确定它们在屏幕上的位置。
  5. 片段着色器是图形管道的最后阶段。 片段着色器计算将在当前帧缓冲区中设置的片段(像素)的颜色。 通常,在片段着色器中,会计算片段的阴影和光照,纹理映射和法线贴图-所有这些技术都可以使您获得令人难以置信的精美效果。

OpenGL着色器以类似于C的特殊GLSL语言编写,然后从中进行编译并链接到着色器程序中。 在这个阶段,编写着色器程序似乎已经是非常耗时的任务,因为 您需要确定图形管线的5个步骤并将它们链接在一起。 幸运的是,事实并非如此:镶嵌和几何着色器默认情况下是在图形管道中定义的,这使我们只能定义两个着色器-顶点着色器和片段着色器(有时称为像素着色器)。 最好以经典示例考虑这两个着色器:


顶点着色器
 #version 450 layout (location = 0) in vec3 vertexCords; layout (location = 1) in vec3 color; out vec3 Color; void main(){ gl_Position = vec4(vertexCords,1.0f) ; Color = color; } 


片段着色器
 #version 450 in vec3 Color; out vec4 out_fragColor; void main(){ out_fragColor = Color; } 


着色器组装示例
 unsigned int vShader = glCreateShader(GL_SHADER_VERTEX); //    glShaderSource(vShader,&vShaderSource); //  glCompileShader(vShader); //  //        unsigned int shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vShader); //    glAttachShader(shaderProgram, fShader); //    glLinkProgram(shaderProgram); //  


这两个简单的着色器不计算任何内容,它们只是将数据传递到管道中。 让我们注意顶点着色器和片段着色器的连接方式:在顶点着色器中,声明Color变量,执行主函数后将颜色写入其中,而在片段着色器中,声明与in限定符完全相同的变量,即 如前所述,片段着色器通过进一步简单地通过管道推送数据来从顶点接收数据(但实际上并不是那么简单)。

注意:如果未在片段着色器中声明和初始化vec4类型的变量,则屏幕上将不会显示任何内容。

细心的读者已经注意到,在顶点着色器的开头声明了带有奇怪的布局限定符的vec3类型的输入变量,逻辑上假设它是输入的,但是我们从哪里得到呢?

基础对象:缓冲区和顶点数组


我认为不应该解释什么是缓冲区对象,我们最好考虑如何在OpenGL中创建和填充缓冲区。
 float vertices[] = { // // -0.8f, -0.8f, 0.0f, 1.0f, 0.0f, 0.0f, 0.8f, -0.8f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.8f, 0.0f, 0.0f, 0.0f, 1.0f }; unsigned int VBO; //vertex buffer object glGenBuffers(1,&VBO); glBindBuffer(GL_SOME_BUFFER_TARGET,VBO); glBufferData(GL_SOME_BUFFER_TARGET, sizeof(vertices), vertices, GL_STATIC_DRAW); 

这没有什么困难,我们将生成的缓冲区附加到所需的目标(以后我们将找出哪个),并加载指示其大小和使用类型的数据。


GL_STATIC_DRAW-缓冲区中的数据将不会更改。
GL_DYNAMIC_DRAW-缓冲区中的数据将更改,但不会经常更改。
GL_STREAM_DRAW-每次绘制调用时缓冲区中的数据都会更改。

太好了,现在我们的数据位于GPU内存中,着色器程序已编译并链接,但是有一个警告:该程序如何知道从哪里获取顶点着色器的输入数据? 我们下载了数据,但没有指出着色器程序将从何处获取数据。 此问题由另一种OpenGL对象-顶点数组解决。


图片
该图像取自本教程

与缓冲区一样,最好使用其配置示例查看顶点数组。


  unsigned int VBO, VAO; glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); glGenVertexArrays(1, &VAO); glBindVertexArray(VAO); //    glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); //     () glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr); //     () glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast<void*> (sizeof(float) * 3)); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); 

创建顶点数组与创建其他OpenGL对象没有什么不同,最有趣的是在该行之后开始:

 glBindVertexArray(VAO); 
顶点数组(VAO)会记住所有与其执行的绑定和配置,包括用于数据卸载的缓冲区对象的绑定。 在此示例中,只有一个这样的对象,但实际上可以有多个。 之后,配置具有特定编号的vertex属性:


  glBindBuffer(GL_ARRAY_BUFFER, VBO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr); 

我们从哪里得到这个号码? 还记得顶点着色器输入变量的布局限定符吗? 由他们确定输入变量将绑定到哪个顶点属性。 现在简要地回顾一下函数参数,这样就不会出现不必要的问题:
  1. 我们要配置的属性号。
  2. 我们要带走的物品数。 (由于layout = 0的顶点着色器的输入变量的类型为vec3,因此我们采用3个float类型的元素)
  3. 项目类型。
  4. 如果它是向量,是否有必要对元素进行规范化。
  5. 下一个顶点的偏移量(因为我们依次定位了坐标和颜色,并且每个顶点的类型均为vec3,所以我们将其移位6 * sizeof(float)= 24字节)。
  6. 最后一个参数显示第一个顶点要采用的偏移量。 (对于坐标,此参数为0字节,对于颜色为12字节)

现在,我们已经准备好渲染第一张图像


记住在调用渲染之前绑定VAO和着色器程序。
 { // your render loop glUseProgram(shaderProgram); glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES,0,3); //        } 


如果您做对了所有事情,那么您应该得到以下结果:



结果令人印象深刻,但是渐变填充来自三角形的何处,因为我们仅指示了三种颜色:每个单独的顶点分别是红色,蓝色和绿色? 这就是光栅化着色器的魔力:事实是,我们在顶点中设置的“颜色”值没有进入片段着色器。 我们仅传输3个顶点,但是生成的片段更多(片段与填充像素一样多)。 因此,对于每个片段,取三个“颜色”值的平均值,具体取决于它与每个顶点的接近程度。 在三角形的拐角处可以很好地看到这一点,在这些拐角处片段采用我们在顶点数据中指示的颜色值。

展望未来,我将说纹理坐标的传输方式相同,这使得将纹理轻松叠加到图元上非常容易。

我认为这篇文章值得完成,最艰巨的挑战已经过去,但最有趣的只是开始。 如果您有任何疑问或在文章中看到错误,请在评论中写出,我将非常感谢。


在下一篇文章中,我们将研究转换,从变量中学习uni,并学习如何在基元上施加纹理。

Source: https://habr.com/ru/post/zh-CN467599/


All Articles