微信扫一扫 分享朋友圈

已有 263 人浏览分享

开启左侧

学习笔记2——OpenGL开始画第一个三角形

[复制链接]
263 1

本帖最后由 chelsea 于 2024-5-11 15:10 编辑

本文是LearnOpenGL文档第四节《Hello Triangle》的学习笔记。

Graphics Pipeline

3D坐标转为2D坐标的处理过程是由OpenGL的Graphics Pipeline管理的。Graphics Pipeline可以被划分为两个主要部分:第一部分把3D坐标转换为2D坐标,第二部分把2D坐标转变为实际的有颜色的像素。

注意:2D坐标和像素是不同的,2D坐标精确表示一个点在2D空间中的位置,而2D像素是这个点的近似值,2D像素受到你的屏幕/窗口分辨率的限制。

下面是一个Graphics Pipeline的每个阶段的抽象展示。蓝色部分代表的是可以注入自定义的着色器(shader)的部分。

image.png

首先,我们以数组的形式传递3个3D坐标作为Graphics Pipeline的输入,用来表示一个三角形,这个数组叫做顶点数据(Vertex Data),用顶点属性(Vertex Attribute)表示,它可以包含任何我们想用的数据,但是简单起见,我们可以假定每个顶点只由一个3D位置和一些颜色值组成。

OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的。

特别地,OpenGL需要指定渲染类型。我们是希望把这些数据渲染成一系列的点?一系列的三角形?还是一个长长的线?做出的这些提示叫做图元(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP。

Graphics Pipeline的各个阶段概述

顶点着色器(Vertex Shader):把3D坐标转为另一种3D坐标,同时允许我们对顶点属性进行一些基本处理。

图元装配(Primitive Assembly):将顶点着色器的输出作为输入,并将所有的点装配成指定图元的形状。

几何着色器(Geometry Shader):把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。上面的例子中,它生成了另一个三角形。

光栅化阶段(Rasterization Stage):把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段(一个片段是OpenGL渲染一个像素所需的所有数据)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

片段着色器(Fragment Shader):计算一个像素的最终颜色。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等)。

Alpha测试和混合(Blending):检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

顶点输入

OpenGL仅当3D坐标在3个轴上的范围都为-1.0到1.0时才处理它。即,所有在标准化设备坐标范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。

我们希望渲染一个三角形,由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度(Depth,通常深度可以理解为z坐标,它代表一个像素在空间中和你的距离)都是一样的,从而使它看上去像是2D的。

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

标准化设备坐标(Normalized Device Coordinates, NDC)

经过顶点着色器处理的顶点坐标,它们就应该是标准化设备坐标了。如下是上面定义的,在标准化设备坐标中的三角形(忽略z轴):

image.png

(0, 0)坐标是这个图像的中心,而不是左上角。最终,希望所有(变换过的)坐标都在这个坐标空间中,否则它们就不可见了。

标准化设备坐标接着会变换为屏幕空间坐标(Screen-space Coordinates),这是使用你通过glViewport函数提供的数据,进行视口变换(Viewport Transform)完成的。所得的屏幕空间坐标又会被变换为片段输入到片段着色器中。

顶点缓冲对象(Vertex Buffer Objects, VBO)

顶点着色器会在GPU上创建内存,用于储存顶点数据。我们通过VBO管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。

就像OpenGL中的其它对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用glGenBuffers函数和一个缓冲ID生成一个VBO对象:

GLuint VBO;  // 定义了一个变量VBO,用来存储缓冲区对象的标识符(ID)
glGenBuffers(1, &VBO);  // 生成一个新的缓冲区对象,第一个参数表示要生成的缓冲区对象的数量,第二个参数意味着函数会将生成的缓冲区对象ID存储在VBO变量中

OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:

glBindBuffer(GL_ARRAY_BUFFER, VBO);  

从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。简而言之,这条命令的作用是告诉OpenGL:“现在开始,所有的顶点数据操作都关联到VBO这个缓冲区对象上。”

然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:

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

第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:

1.GL_STATIC_DRAW :数据不会或几乎不会改变。 2.GL_DYNAMIC_DRAW:数据会被改变很多。 3.GL_STREAM_DRAW :数据每次绘制时都会改变。

如果使用的类型是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,显卡会把数据放在能够高速写入的内存部分。

顶点着色器

源码

下面你会看到一个非常基础的GLSL顶点着色器的源代码:

#version 330 core

layout (location = 0) in vec3 position;  // 使用in关键字,声明所有的输入顶点属性(Input Vertex Attribute);layout (location = 0)设定了输入变量的位置值

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

可以看到,GLSL看起来很像C语言。每个着色器都起始于一个版本声明。OpenGL 3.3以及和更高版本中,GLSL版本号和OpenGL的版本是匹配的。我们同样明确表示我们会使用核心模式。

编译

我们必须在运行时动态编译顶点着色器的源码。

首先,创建一个着色器对象,注意还是用ID来引用的:

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

如果编译失败,我们会用glGetShaderInfoLog获取错误消息,然后打印它:

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

片段着色器

源码

用于计算像素最后的颜色输出。为了让事情更简单,我们的片段着色器将会一直输出橘黄色。

#version 330 core

out vec4 color;

void main()
{
    color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度,1.0为完全不透明)分量,通常缩写为RGBA

编译

编译片段着色器的过程与顶点着色器类似,只不过我们使用GL_FRAGMENT_SHADER常量作为着色器类型:

GLuint fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, null);
glCompileShader(fragmentShader);

两个着色器现在都编译了,剩下的事情是把两个着色器对象链接到一个用来渲染的shader program中。

着色器程序(Shader Program)

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。在渲染对象时,需要激活着色器程序。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

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);
  ...
}

在把着色器对象链接到程序对象以后,可以删除着色器对象,我们不再需要它们了:

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

现在激活这个程序对象:

glUseProgram(shaderProgram);

链接顶点属性

我们必须手动指定,输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。

我们的顶点缓冲数据会被解析为下面这样子:

image.png

位置数据被储存为32-bit(4字节)浮点值。每个位置包含3个这样的值。在这3个值之间没有空隙(或其他值)。数据中第一个值在缓冲开始的位置。

我们可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);  // 以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的

第一个参数:指定我们要配置的顶点属性。我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值。 第二个参数:指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。 第三个参数:指定数据的类型,这里是GL_FLOAT(GLSL中vec都是由浮点数值组成的)。 第四个参数:定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。 第五个参数:在连续的顶点属性组之间的间隔(Stride,步长)。由于下个组位置数据在3个GLfloat之后,我们把步长设置为3 sizeof(GLfloat)。此外,由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少。 第六个参数:它的类型是GLvoid,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。

每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO获取,则是通过在调用glVetexAttribPointer时绑定到的GL_ARRAY_BUFFER的VBO决定的。

自此,所有东西都已经设置好了:我们使用一个顶点缓冲对象将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:

// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();

每当我们绘制一个物体的时候都必须重复这一过程。有没有方法可以把这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?

顶点数组对象(Vertex Array Object, VAO)

VAO可以像VBO那样被绑定。这样的好处是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单。

一个VAO会储存以下这些内容:

glEnableVertexAttribArray和glDisableVertexAttribArray的调用。--> 启用和禁用顶点属性 通过glVertexAttribPointer设置的顶点属性配置。 --> 配置应该如何读取顶点数据 通过glVertexAttribPointer调用进行的VBO与顶点属性链接。

image.png

创建一个VAO和创建一个VBO很类似:

GLuint VAO;
glGenVertexArrays(1, &VAO);  

要想使用VAO,要做的只是使用glBindVertexArray绑定VAO。绘制物体的代码如下:

// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
glBindVertexArray(VAO);
    // 2. 把顶点数组复制到缓冲中供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 3. 设置顶点属性指针
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
    glEnableVertexAttribArray(0);
//4. 解绑VAO
glBindVertexArray(0);  // 0 通常用来表示“无”或“不绑定任何对象”

[...]

// ..:: 绘制代(游戏循环中) :: ..
// 5. 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
glBindVertexArray(0);

通常情况下当我们配置好OpenGL对象以后要解绑它们,这样我们才不会在其它地方错误地配置它们。

现在,我们得到了一个储存了顶点属性配置和应使用的VBO的VAO。当绘制多个物体时,首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。

绘制三角形

使用glDrawArrays函数绘制物体,它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);  // 第二个参数指定了顶点数组的起始索引,最后一个参数指定我们打算绘制多少个顶点
glBindVertexArray(0);  

image.png

完整代码

索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)

假设我们不再绘制一个三角形而是绘制一个矩形。我们可以绘制两个三角形来组成一个矩形(OpenGL主要处理三角形)。这会生成下面的顶点的集合:

GLfloat vertices[] = {
    // 第一个三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二个三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

然而,更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。EBO正是用于解决这个问题,它也是一个缓冲,专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。首先,我们先要定义(独一无二的)顶点,和绘制出矩形所需的索引:

GLfloat vertices[] = {
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

GLuint indices[] = { // 注意索引从0开始! 
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

接下来,创建索引缓冲对象:

GLuint EBO;
glGenBuffers(1, &EBO);

与VBO类似,我们先绑定EBO然后用glBufferData把索引复制到缓冲里

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); 

最后,用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染:

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);  // 第二个参数是我们打算绘制顶点的个数,第三个参数是索引的类型
// 最后一个参数指定EBO中的偏移量(或者传递一个索引数组,但是这是当你不在使用索引缓冲对象的时候)

VAO同样可以保存EBO的绑定状态。

image.png

最后的初始化和绘制代码现在看起来像这样:

// ..:: 初始化代码 :: ..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
    // 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
    // 3. 设定顶点属性指针
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
    glEnableVertexAttribArray(0);
// 4. 解绑VAO(不是EBO!)
glBindVertexArray(0);

[...]

// ..:: 绘制代码(游戏循环中) :: ..

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

线框模式(Wireframe Mode)

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

image.png

完整代码

练习

1.添加更多顶点到数据中,使用glDrawArrays,尝试绘制两个彼此相连的三角形

image.png

官方参考代码在设置VAO的过程中,把VBO解绑了(glBindBuffer(GL_ARRAY_BUFFER, 0)),这是前面都没提到过的:

GLuint VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// Bind the Vertex Array Object first, then bind and set vertex buffer(s) and attribute pointer(s).
glBindVertexArray(VAO);

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

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);

glBindBuffer(GL_ARRAY_BUFFER, 0); // Note that this is allowed, the call to glVertexAttribPointer registered VBO as the currently bound vertex buffer object so afterwards we can safely unbind

glBindVertexArray(0); // Unbind VAO (it's always a good thing to unbind any buffer/array to prevent strange bugs)

2.创建相同的两个三角形,但对它们的数据使用不同的VAO和VBO

这里记录一下我自己写的代码和参考答案对比的区别。我用的是:

GLuint VBO_1, VAO_1;
GLuint VBO_2, VAO_2;

根据参考答案,用数组会更简洁!

GLuint VBOs[2], VAOs[2];
glGenVertexArrays(2, VAOs); // We can also generate multiple VAOs or buffers at the same time
glGenBuffers(2, VBOs);
// ================================
// First Triangle setup
// ===============================
glBindVertexArray(VAOs[0]);
glBindBuffer(GL_ARRAY_BUFFER, VBOs[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(firstTriangle), firstTriangle, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);   // Vertex attributes stay the same
glEnableVertexAttribArray(0);
glBindVertexArray(0);
// ================================
// Second Triangle setup
// ===============================
glBindVertexArray(VAOs[1]); // Note that we bind to a different VAO now
glBindBuffer(GL_ARRAY_BUFFER, VBOs[1]); // And a different VBO
glBufferData(GL_ARRAY_BUFFER, sizeof(secondTriangle), secondTriangle, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, (GLvoid*)0); // Because the vertex data is tightly packed we can also specify 0 as the vertex attribute's stride to let OpenGL figure it out.
glEnableVertexAttribArray(0);
glBindVertexArray(0);

此外,在game loop中,事实上可以只激活一次shaders:

// Activate shader (same shader for both triangles)
glUseProgram(shaderProgram);
// Draw first triangle using the data from the first VAO
glBindVertexArray(VAOs[0]);
glDrawArrays(GL_TRIANGLES, 0, 3);
// Then we draw the second triangle using the data from the second VAO
glBindVertexArray(VAOs[1]);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);

而且第一个画完,绑定第二个VAO的时候,不需要额外解绑。最后删除的时候,也只需要删除数组就好啦!

// Properly de-allocate all resources once they've outlived their purpose
glDeleteVertexArrays(2, VAOs);
glDeleteBuffers(2, VBOs);

3.创建两个着色器程序,第二个程序使用与第一个不同的片段着色器,输出黄色;再次绘制这两个三角形,其中一个输出为黄色。

image.png

参考答案也没啥神奇的操作,也没有用数组,命名了两个不同的变量。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

评论 1

avi9111  魔法画童  发表于 2024-6-5 14:16:58 | 显示全部楼层
还得是c++,看看
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

0

关注

0

粉丝

3

主题
精彩推荐
热门资讯
网友晒图
图文推荐
  • iOS App

  • 安卓App

Archiver|手机版|小黑屋|技你太美101

GMT+8, 2024-6-26 15:34 , Processed in 0.112478 second(s), 33 queries .

Powered by 技你太美101

© 2024 JNTM101 Team