LearnOpenGL note - Getting started:Hello Triangle

OpenGL入门系列1

Posted by Tao on Wednesday, February 16, 2022

0 前言

本节笔记对应的内容 你好,三角形
本节我们将可以实现在窗口中画出一个三角形

通过本节可以了解到:

  • 缓存对象VAO和VBO
  • GLSL着色器程序的编译、链接和使用方法
  • OpenGL图形渲染管线

1 图形渲染管线

在OpenGL中,事物都是处在3D空间中的,但是我们的屏幕和窗口却是2D像素数组(XY坐标),这导致OpenGL的大部分工作都是关于把3D坐标转变为适应我们屏幕的2D像素。
3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线管理的(Graphics Pipeline,译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)。
图形渲染管线可以被划分为两个主要部分:

  • 第一部分把3D坐标转换为2D坐标
  • 第二部分是把2D坐标转变为实际的有颜色的像素。

简单来说,图形渲染管线接受一组3D坐标,然后把它们转变为屏幕上的有色2D像素输出。

  • 注:2D坐标和像素也是不同的,2D坐标精确表示一个点在2D空间中的位置,而2D像素是这个点的近似值,2D像素受到屏幕/窗口分辨率的限制(区别:有没有进行光栅化)。

图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。
所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且这些函数很容易并行执行。由于它们具有并行执行的特性,所以当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理数据。
这些小程序叫做着色器(Shader)。
OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的,在下一节中会进行具体展开。

下面是一个图形渲染管线的每个阶段的抽象展示。
蓝色部分代表的是我们可以注入自定义的着色器的部分。
Graphics Pipeline

详细的现代绘图流水线如下图所示(来自:opengl wiki Rendering_Pipeline_Overview

RenderingPipeline.png
Diagram of the Rendering Pipeline. The blue boxes are programmable shader stages.

下面解释一些与上图流程有关的概念:

  • 顶点数据(以三角形为例)
    首先,我们以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据(Vertex Data)。顶点数据是一系列顶点的集合。
    一个顶点(Vertex)是一个3D坐标的数据的集合。
    而顶点数据是用顶点属性(Vertex Attribute)表示的,它可以包含任何我们想用的数据。
    简单起见,我们假定每个顶点只由一个3D位置和一些颜色值组成的。
  • 图形渲染管线的第一个部分是顶点着色器(Vertex Shader)。
    顶点着色器主要的目的是把3D坐标转为另一种3D坐标(比如坐标系统变换等等),同时在顶点着色器中,我们可以对顶点属性进行一些基本处理。
  • 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_TRIANGLES,那么就是一个三角形),OpenGL把所有的点装配成指定图元的形状,本节例子中是一个三角形。
    注:为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,我们需要去指定这些数据所表示的渲染类型。
    图元(Primitive)会告诉OpenGL状态机我们是希望把这些数据渲染成一系列的点、一系列三角形还是一条条线。任何一个绘制指令的调用都将把图元传递给OpenGL。
    这是其中的几个图元:GL_POINTSGL_TRIANGLESGL_LINE_STRIP
  • 图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入。
    它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。
  • 几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片元着色器(Fragment Shader)使用的片元(Fragment)——OpenGL中的一个片段是指OpenGL渲染一个像素所需的所有数据。
    在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
  • 片元着色器主要是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。
    通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
  • 最后一个阶段,我们叫做Alpha测试和混合(Blending)阶段。
    这个阶段检测会进行片元深度测试和模板测试,用来判断这个像素是其它物体的前面还是后面,是否应该丢弃。
    这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。

在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。
出于这个原因,刚开始学习现代OpenGL的时候可能会有些困难,因为在我们能够渲染自己的第一个三角形之前已经需要了解一大堆知识了。

2 顶点输入

开始绘制图形之前,我们要给OpenGL输入一些顶点数据。
OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是3D坐标(x、y和z)。
OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素;OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。
只有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。

  • 标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。
    任何落在范围外的坐标都会被丢弃/裁剪,不会显示在屏幕上。
    下面是一个定义的在标准化设备坐标中的三角形(忽略z轴):
    Normalized Device Coordinates
    关于详细的坐标相关内容,会在之后章节讲解坐标系统时进行描述。

由于是要渲染一个三角形,所以一共要指定三个顶点,每个顶点都有一个3D位置。
我们将它们以标准化设备坐标的形式定义为一个float数组。
由于OpenGL是在3D空间中工作的,而我们渲染的像是一个2D三角形,我们将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度都是一样的,从而使它看上去像是2D的。

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

3 VAO,VBO

下面我们来讲述一下顶点缓冲对象(Vertex Buffer Objects, VBO)以及顶点数组对象(Vertex Array Object,VAO)这两个概念。

根据图形渲染管线的步骤,我们一开始会拥有一些顶点数组,其实这个说法有些含糊,因为没有建模软件会直接生成这种数组,它们传输出的文件往往是其他类型的文件,比如一个obj档案。
如下图这个折叠的三角形以及它对应的obj文件:
三角形
三角型数据
这些数据包括顶点,法向量,材质,面的定义等,我们要让它变成一个数组才好传入渲染管线。

所以接下来我们需要对图形渲染管线再做一个说明:

pipeline
顶点导入示意图

  • obj档案在最左边,经过序列化的处理,转变成一堆vertex的数组

  • 数据跨越CPU和GPU的接口进到GPU,在GPU中用来存储这些数组的东西就是顶点缓冲对象(Vertex Buffer Objects, VBO)。顶点缓冲对象负责实际数据的存储

  • 为了在VBO中认出这堆数据,各自分别是什么,所以我们就需要类似索引的东西——顶点数组对象(Vertex Array Object,VAO)。VAO记录数据的存储和如何使用的细节信息
    在VAO的0号,1号,2号…槽位分别指到VBO里的数据。一般一个模型就会用到一个VAO缓存它。
    如果还有其他模型,比如右上的“哆啦A梦”,“蛋蛋老师”都会各自有一个VAO。

  • 比如一开始有个“兔子”数据(图左),VAO通过认出VBO中哪几个数值是顶点,法向,uv等等,重现出了一个“兔子”(图VAO上方虚线那个)。
    有了VAO之后,我们就可以单独的把vertex的数据放进vertex data[]进入到渲染管线。

  • 接下来会先学习怎么用OpenGL函数新增一个VAO,然后绑到目前所用的位置上面,接下来造一个VBO绑上这个VAO。
    VAO其实还可以绑EBO(索引缓冲对象Element Buffer Object)(下面用到会再进行描述)
    VAO和VBO之间的通道是ARRAY_BUFFER,VAO和EBO之间的通道是ELEMENT_ARRAY_BUFFER。

使用VAO的优势就在于,如果有多个物体需要绘制,那么我们设置一次绘制物体需要的顶点数据、数据解析方式等信息,然后通过VAO保存起来后,后续的绘制操作不再需要重复这一过程,只需要将VAO设定为当前VAO,那么OpenGL则会使用这些状态信息。当场景中物体较多时,优势十分明显。VAO和VBO的关系如下图所示(图片来自Best Practices for Working with Vertex Data):

vertex_array_objects_2x.png
Vertex array object configuration

上图中表示,顶点属性包括位置、纹理坐标、法向量、颜色等多个属性,每个属性的数据可以存放在不同的buffer中。我们可以根据需求,在程序中创建多个VBO和VAO。

3.1 VAO

之前我们已经有了坐标的数组,现在我们进行VAO的建立:

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

注意到Array后面有个s,其实这条生成的方法可以产生多个VAO,而这次我们只用1个所以这样写。创建完毕后,该方法返回一个VAO的ID。如果一次产生很多个VAO可能会这样写:

unsigned int VAO[10];
glGenVertexArrays(10, VAO);

这两行之后我们获得了一个VAO,但它并没有塞进目前渲染管线需要的数据位置上,所以接下来bind上

glBindVertexArray(VAO);

3.2 VBO

Creating a VBO requires 3 steps:

  1. Generate a new buffer object with glGenBuffers().
  2. Bind the buffer object with glBindBuffer().
  3. Copy vertex data to the buffer object with glBufferData().
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER,VBO);

API void glGenBuffers(GLsizei n, GLuint * buffers);
glGenBuffers() 创建缓冲区对象并返回缓冲区对象的标识符。它需要 2 个参数:第一个是要创建的缓冲区对象的数量,第二个参数是 GLuint 变量或数组的地址,用于存储单个ID或多个ID。一次可以产生一个或者多个buffer、

一旦创建了缓冲区对象,我们需要在使用缓冲区对象之前将缓冲区对象与相应的 ID 挂钩。vertex attribute顶点属性这个概念包括顶点的位置、纹理坐标、法向量、颜色等属性数据,因此我们的顶点位置数据适合绑定到GL_ARRAY_BUFFER目标,同时数据在传送时初始化缓冲区。

API void glBindBuffer(GLenum target, GLuint id)
Target是一个提示符,告诉 VBO 这个缓冲区对象将存储顶点数组数据还是索引数组数据:GL_ARRAY_BUFFERGL_ELEMENT_ARRAY_BUFFER。任何顶点属性,例如顶点坐标、纹理坐标、法线和颜色分量数组都应该使用 GL_ARRAY_BUFFER。用于glDraw[Range]Elements()的索引数组应与GL_ELEMENT_ARRAY_BUFFER绑定。

一旦glBindBuffer()被第一次调用,VBO用一个大小为零的内存缓冲区初始化缓冲区并设置初始 VBO 状态,例如使用和访问属性。

初始化缓冲区后,您可以使用 glBufferData() 将数据复制到缓冲区对象中。

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

API void glBufferData(GLenum target, GLsizeiptr size,
const GLvoid * data,GLenum usage);

glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数(\(CPU\to GPU\))。

  • target参数表示目标缓冲的类型,包括像GL_ARRAY_BUFFER用于Vertex attributes(顶点属性),GL_ELEMENT_ARRAY_BUFFER用于索引绘制等目标。
  • size参数表示传输数据的大小(以字节为单位);用sizeof计算出顶点数据大小就行。
  • data参数用于指定数据源,如果data不为空将会拷贝其数据来初始化这个缓冲区,否则只是分配预定大小的空间。预分配空间后,后续可以通过glBufferSubData来更新缓冲区内容。
  • usage参数指定数据使用模式,表示显卡如何管理给定的数据,例如GL_STATIC_DRAW指定为静态绘制,数据保持不变, GL_DYNAMIC_DRAW指定为动态绘制,数据会经常更新,GL_STREAM_DRAW指数据每次绘制时都会改变。

3.3 VAO/VBO的再解释

根据参考文献3所述:

A Vertex Array Object (VAO) is an object which contains one or more Vertex Buffer Objects and is designed to store the information for a complete rendered object. In our example this is a diamond consisting of four vertices as well as a color for each vertex.
A Vertex Buffer Object (VBO) is a memory buffer in the high speed memory of your video card designed to hold information about vertices. In our example we have two VBOs, one that describes the coordinates of our vertices and another that describes the color associated with each vertex. VBOs can also store information such as normals, texcoords, indicies, etc.

VAO即Vertex Array Object ,是一个包含一个或多个VBO的对象,被设计用来存储一个完整被渲染对象所需的信息。

VBO即Vertex Buffer Object,是一个在高速视频卡中的内存缓冲,用来保存顶点数据,也可用于包含诸如归一化向量、纹理和索引等数据。

根据参考文献4所述:

VBO stores actual vertex data. The most important thing about a VBO is not that it stores data, though it is its primary function, but where it is stored. A VBO object resides on GPU, the graphics processing unit. This means it is very fast, it is stored in memory on the graphics card itself. How cool is that? Storing data on the computer processor or RAM is slow mostly because it needs to be transferred to the GPU, and this transfer can be costly.
VAO represents properties, rather than actual data. But these properties do describe the objects actually stored in the VBO. VAO can be thought of as an advanced memory pointer to objects. Similar to C-language pointers, they do a whole lot more tracking than just the address. They are very sophisticated.
VAOs are a lot like helpers, rather than actual data storage. That’s what they’re for. They also keep track of properties to be used in current rendering process. Finally, they describe properties of objects, rather than the raw data of those objects that is by all means already stored in a VBO.
VAOs are not directly related to VBOs, although it may seem that way at first. VAOs simply save time to enable a certain application state needed to be set. Without VAO, you would have to call a bunch of gl* commands to do the same thing.

VBO存储了实际的数据,真正重要的不是它存储了数据,而是他将数据存储在GPU中。这意味着VBO它会很快,因为存在RAM中的数据需要被传送到GPU中,因此这个传送是有代价的。
VAO代表的是一些描述存储在VBO中对象的属性。VAO可以被视为指向对象的高级内存指针,有点类似于C语言指针,但比地址多了跟多的跟踪作用。他们很复杂。

VAO很像是辅助对象,而不是实际的数据存储对象。他们记录了当前绘制过程中的属性,描述对象的属性,而不是已经存储在VBO中原始数据。

VAO并不与VBO直接相关,尽管初看起来如此。VAOs节省了设置程序所需的状态的时间。如果没有VAO,你需要调用一堆类似gl*之类的命令。这里从songho[5]文的用户反馈列表中找到一个示例解释了VAO节省时间的例子:

Q:How do Vertex Buffer Objects relate to Vertex Array Objects?
A:The name, VAO (Vertex Array Object) looks somewhat related to VBO, but it is not. VAO is for encapsulating vertex array states/functions into it. Therefore, you can replace the multiple OpenGL calls to a single call of glBindVertexArray(), in order to setup various vertex array states and attributes before drawing. The following example gives a better sense of VAO purpose;

// draw with VAO
glBindVertexArray(vaoId); // bind vao
glDrawElements(...);
glBindVertexArray(0);     // unbind vao
 
// draw without VAO
// need to set many states before drawing
glEnableClientState(GL_VERTEX_ARRAY); // enable client states
glEnableClientState(GL_NORMAL_ARRAY);
glBindBuffer(GL_ARRAY_BUFFER, vboId); // bind vbo
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iboId);
glVertexPointer(3, GL_FLOAT, 0, 0); // vertex attributes
glNormalPointer(GL_FLOAT, 0, offset); // normal attributes
glDrawElements(...);
glBindBuffer(GL_ARRAY_BUFFER, 0); // unbind vbo
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_NORMAL_ARRAY);

另外:使用VAO可以自动管理glVertexAttribPointer指定的锚点信息,防止被多个VBO覆盖(而且仅在Compat模式下覆盖,Core profile直接不绘制。如果不用VAO,每次调用VBO绘图时,都得重新设置锚点),其他更多关于VAO、VBO讨论不在此处展开。

4 Shader

要使用着色器需要经历3个步骤:

  1. 创建和编译shader object
  2. 创建shader program,链接多个shader object到program
  3. 在绘制场景时启用shader program

具体流程如下图所示:

shader.svg

这一节的学习不用太深入学习shader,其原理和相关概念会在之后的章节进行具体学习,所以我们就直接套用前言中提到的这节教材的两组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";

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

我们已经写了一个shader源码(储存在字符串中),但是为了能够让OpenGL使用它,我们必须在运行时动态编译它的源码。
于是在建立VBO的代码后面我们加入以下代码:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
  • 我们把需要创建的着色器类型以参数形式提供给glCreateShader。如GL_VERTEX_SHADERGL_FRAGMENT_SHADER
  • 下一步我们用glShaderSource这个函数把着色器源码附加到着色器对象上,然后用glCompileShader编译它。
  • glShaderSource
    第一个参数是要编译的着色器对象;
    第二参数指定了传递的源码字符串数量,这里我们每一项只有一个字符串;
    第三个参数是顶点着色器真正的源码;
    第四个参数我们先设置为NULL。

接下来要把这两个shader链接成一个program才能使用

unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
  • 着色器程序对象(Shader Program Object)是多个着色器合并之后,最终链接完成的版本。
    如果要使用刚才编译的着色器,我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。
    已激活着色器程序的着色器,将在我们发送渲染调用的时候被使用。
  • glCreateProgram函数创建一个程序,并返回新创建程序对象的ID引用。
  • glAttachShader可以把之前编译的着色器附加到程序对象上
  • glLinkProgram来链接它们

5 绘制三角形

接下来我们可以画这个三角形了,根据教材上典型的使用方式,每尝试画一个VAO我们就要bind到目前的槽位上去,然后呼叫目前要用的program。
我们先链接顶点属性:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

关于这两行内容,实际上就是描述示意图中如何把VBO的数据放入VAO上的0,1,2……槽位上。
这些槽位,叫做顶点属性(vertex attribute)(包括:Position/UV/Normal…)。

将数据传送到GPU后,我们还需要告知OpenGL如何解释这个数据(接第3节),也就是告知其数据格式,因为从底层来看数据一个字节块而已。要通知OpenGL如何解释数据,要使用函数glVertexAttribPointer

比如我们的顶点缓冲数据会被解析为下面这样子:
vertex_attribute_pointer顶点属性指针

API void glVertexAttribPointer( GLuint index, GLint size, GLenum type,
GLboolean normalized, GLsizei stride, const GLvoid * pointer);

  • 参数index指定我们要配置的顶点属性位置在哪里。(锚定点概念)
    我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)是0。
    也就是说shader会从0号栏位拿position的信息,因此我们这里指定了0号的位置(也就是说这两个要对应!)。
    ( 这一部分内容在之后shader章节的笔记会进行具体说明 )
  • 参数size指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,size即为3。分量的个数必须为1,2,3,4这四个值之一。
  • 参数type表示属性分量的数据类型,例如上面的顶点数据为float则填写GL_FLOAT.
  • 参数normalized是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们不需要就把它设置为GL_FALSE。
  • 参数stride步长表示连续的两个顶点属性之间的间隔,以字节大小计算。由于下组位置数据在3个float之后,我们把步长设置为3*sizeof(float)。
    要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。
    一旦我们有更多的顶点属性(比如uv值啊什么的),我们就必须更小心地定义每个顶点属性之间的间隔
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。
    它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置position数据在数组的开头,所以这里是0。
    如果不只是position的信息,还有其他信息在的话,可能会需要偏移量。

我们在熟悉一下上面的关于顶点属性的这段代码:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

上诉代码告诉OpenGL(好好理解此段)

  • 程序需要选中0号栏位开始放入,把我们送进去的这些数值,每三个当成一份资料,它们都是float,不需要正规化,每隔3*float个长度去挖下一个,不需要偏移量,然后再激活对外开放0号栏位。

绘制三角形时创建VAO和VBO的最终的代码如下:

// 指定顶点属性数据 顶点位置
GLfloat vertices[] = {
    -0.5f, 0.0f, 0.0f,
    0.5f, 0.0f, 0.0f,
    0.0f, 0.5f, 0.0f
};
// 创建缓存对象
GLuint VAOId, VBOId;
// Step1: 创建并绑定VAO对象
glGenVertexArrays(1, &VAOId);
glBindVertexArray(VAOId);
// Step2: 创建并绑定VBO对象
glGenBuffers(1, &VBOId);
glBindBuffer(GL_ARRAY_BUFFER, VBOId);
// Step3: 分配空间 传送数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// Step4: 指定解析方式  并启用顶点属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 解除绑定
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);

在代码的最后,我们暂时解除绑定,能够防止后续操作干扰到了当前VAO和VBO。
现在在程序中使用VAO绘制三角形则只需要调用:

glBindVertexArray(VAOId); // 使用VAO信息
glUseProgram(shaderProgramId); // 使用着色器
glDrawArrays(GL_TRIANGLES, 0, 3);

glDrawArrays函数使用VBO数据绘制物体。其使用方法为:

API void glDrawArrays(GLenum mode, GLint first, GLsizei count);

  • mode参数表示绘制的基本类型,OpenGL预制了GL_POINTSGL_TRIANGLESGL_LINE_STRIP等基本类型。一个复杂的图形,都是由这些基本类型构成的。
  • first参数指定了顶点数组的起始索引,我们这里填0。
  • 参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长)。

6 绘制四边形(数组绘制/背面剔除)

我们尝试绘制四边形,所以把顶点数组稍加改动

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

同时改一下DrawArrays

glDrawArrays(GL_TRIANGLES, 0, 6);

得到如下的四边形
rectangle
这时候会有疑惑,为什么需要6个点来画四边形呢?
因为OpenGL画的是三角面,看起来是四边形,其实是画了两次,一次要三点,所以会重复画两个顶点。
这样很没有效率,所以就出现了EBO的绘制方法。(见下面的第七节)

另外的,OpenGL的三角形绘制是逆时针顺序,在上面的数组中,我们第一组是逆时针,第二组却变成了顺时针(虽然是输入手滑了但是又可以增加记录一个知识点)。
看着绘制出来的是一个四边形,但其实这个四边形是第一个三角形的正面,与第二个三角形的反面的组合产物。
OpenGL会好心的把正反面都画出来,所以我们看不出差异,我们可以试一试开启背面剔除。
glViewport(0, 0, 800, 600);这一行下面,我们输入这些代码

    glEnable(GL_CULL_FACE);
    glCullFace(GL_BACK);

这样的话背面就被剔除掉了
如果是剔除正面的话,改成glCullFace(GL_FRONT);
因为之后用不到,所以就把面剔除的这两行注释了。

7 用EBO绘制四边形/OpenGL的状态机概念

如果给坐标标上序号

float vertices[] = {
    -0.5f, -0.5f, 0.0f, //0
    0.5f, -0.5f, 0.0f,  //1
    0.0f,  0.5f, 0.0f,  //2
    0.5f, -0.5f, 0.0f,
    0.0f,  0.5f, 0.0f,
    0.8f,  0.8f, 0.0f   //3
};

对于一个矩形,怎么看我们都只是需要四点而已,但是多出了两点的空间,所以未来我们画的时候,我们想第一个三角形就只是用0,1,2的顺序,第二个三角形就只是用2,1,3的顺序,用索引值来选点绘制。
这个方法就是所谓的索引绘制(Indexed Drawing),我们只需要存储它的索引值来绘图。而存储索引,就是所谓的索引缓冲对象(Element Buffer Object,EBO,也叫_Index Buffer Object,IBO_)。
所以接下来我们把前面的位置数据改成

float vertices[] = {
 -0.5f, -0.5f, 0.0f,  //0
  0.5f, -0.5f, 0.0f,  //1
  0.0f,  0.5f, 0.0f,  //2
  //0.5f, -0.5f, 0.0f,
  //0.0f,  0.5f, 0.0f,
  0.8f,  0.8f, 0.0f   //3
};
//0 1 2   2 1 3
unsigned int indices[] = {
 0,1,2,
 2,1,3
};

改动到这里我们停一下,回来讲讲OpenGL来便于理解。
pipeline3

  • OpenGL是一个状态机,任何一个时间只会有一个状态位于运行中,即图中的虚线框,运行中的这个状态叫做Context上下文。
  • 这个Context在运行的时候,只会认识当下操作的那个VAO(图中的“兔子”),所以在某个时刻运行时,可能会有很多模型在外面等(右上角的“哆啦A梦”和“蛋蛋老师”)。
  • 当下的VAO如何去存取外界的VBO呢?尽管会送进来很多VBO,但是OpenGL这个状态机同时间只能操作一个VBO,操作的地方就是ARRAY_BUFFER。
  • 于是我们就可以理解bind这个操作了,首先选一个VAO进来当前状态,选的这个动作,就叫做glBindVertexArray(VAO);
  • VBO也要bind进来,并且需要通过ARRAY_BUFFER这个槽位进行操作,所以就是glBindBuffer(GL_ARRAY_BUFFER,VBO);
  • 没有被bind到的东西是不会被Context认到的。
  • 连接一个VBO之后,读取顶点属性到VAO的栏位上,之后可能另外的资料是存在另外的VBO上,那么我们就可以把上一VBO放掉,再bind到下一个读取,读完解除掉,再bind下一个,以此类推。
  • 为了要达到高效绘制,我们会用到EBO,而EBO操作的槽位,叫做Element Buffer,而且进到Context以后,它会固定到某个栏位上,不属于之前操作VBO读取时用到0-15个栏位。EBO进来,填充它所有的indices。
  • 当然,Context中还有很多功能,比如之前的背面剔除等等,通过glEnable能开启很多状态。

好,现在回到代码,改完位置和索引之后,我们要创造EBO,在宣告VBO的代码下面,输入以下代码,可以根据上面概念的讲解进行理解:

 unsigned int EBO;
 glGenBuffers(1, &EBO);
 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
 glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

在渲染循环bindVAO的下面,输入以下代码

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
  • glDrawElements第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样。
  • 第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点。
  • 第三个参数是索引的类型,这里是GL_UNSIGNED_INT。
  • 最后一个参数里我们可以指定EBO中的偏移量(或者传递一个索引数组,但是这是当我们不在使用索引缓冲对象的时候),我们会在这里填写0。

关于VAO,VBO,EBO的关系,可以再结合教材上的图片理解
vertex_array_objects_ebo
我们也可以用线框模式来看看绘制的效果,可以把线框模式的代码加在背面剔除那块地方。

  • 线框模式(Wireframe Mode)
    要想用线框模式绘制三角形,可以通过glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)函数配置OpenGL如何绘制图元。
  • 第一个参数表示我们打算将其应用到所有的三角形的正面和背面,第二个参数告诉我们用线来绘制。
  • 之后的绘制调用会一直以线框模式绘制三角形,直到我们用glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)将其设置回默认模式。

可以看到线框模式的效果
Wireframe
完整的代码应该是这样(注释掉了线框和背面剔除)

8 开启独显渲染

为了使OpenGL能在笔记本平台上采用独显渲染(以英伟达显卡为例),需要添加以下代码(仅针对N卡有效):

extern "C" {
    _declspec(dllexport) DWORD NvOptimusEnablement = 0x00000001;
}

9 完整代码

#include <GL/glew.h>
#include <GLFW/glfw3.h>

#include <windows.h>
#include <iostream>

extern "C"
{
    _declspec(dllexport) DWORD NvOptimusEnablement = 0x00000001;
}

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";
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"
                                   "}\n\0";

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

unsigned int indices[] = {
    0, 1, 2,
    1, 2, 3};

void processInput(GLFWwindow *window);

int main()
{
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    // glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);

    // Open glfw window
    GLFWwindow *window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "Failed to Create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);

    // Init glew
    glewExperimental = true;
    if (glewInit() != GLEW_OK)
    {
        std::cout << "Init GLEW failed" << std::endl;
        glfwTerminate();
        return -1;
    }

    glViewport(0, 0, 800, 600);
    // face culling
    // glEnable(GL_CULL_FACE);
    // glCullFace(GL_BACK);

    // create vao
    unsigned int VAO;
    glGenVertexArrays(1, &VAO);
    // bind to pipeline
    glBindVertexArray(VAO);
    // create VBO
    unsigned int VBO;
    glGenBuffers(1, &VBO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    unsigned int EBO;
    glGenBuffers(1, &EBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    unsigned int vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);

    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);

    unsigned int shaderProgram;
    shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0);
    glEnableVertexAttribArray(0);

    while (!glfwWindowShouldClose(window))
    {
        processInput(window);

        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glBindVertexArray(VAO);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
        glUseProgram(shaderProgram);
        // glDrawArrays(GL_TRIANGLES, 0, 6);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    glfwTerminate();
    return 0;
}

void processInput(GLFWwindow *window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
    {
        glfwSetWindowShouldClose(window, true);
    }
}

10 Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus