本帖最后由 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)的部分。
首先,我们以数组的形式传递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轴):
(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);
链接顶点属性
我们必须手动指定,输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。
我们的顶点缓冲数据会被解析为下面这样子:
位置数据被储存为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与顶点属性链接。
创建一个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);
完整代码
索引缓冲对象(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的绑定状态。
最后的初始化和绘制代码现在看起来像这样:
// ..:: 初始化代码 :: ..
// 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)
将其设置回默认模式。
完整代码
练习
1.添加更多顶点到数据中,使用glDrawArrays,尝试绘制两个彼此相连的三角形
官方参考代码在设置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.创建两个着色器程序,第二个程序使用与第一个不同的片段着色器,输出黄色;再次绘制这两个三角形,其中一个输出为黄色。
参考答案也没啥神奇的操作,也没有用数组,命名了两个不同的变量。