Li Jiaheng's blog
3068 字
15 分钟
LearnOpenGL·一·2 第一个三角形
2024-04-23

这是对learnopengl的简单笔记。原教程网址:learnopengl。原教程同时涉及图形学的基本理论与opengl API,本文更多关注API,而简化甚至省略了背后的图形学原理性内容。

渲染任何图形都有相当长的前摇,涉及相当繁琐的东西,大体来说,我们需要:

  • 编写并编译着色器(至少顶点着色器与片段着色器)。
  • 准备要输入的顶点数据。设置 顶点数组对象,顶点缓冲对象,元素(索引)缓冲对象
  • 链接顶点属性
  • 绘图(到这才算真正开始画图)

着色器 Shader#

openGL的绘图管线有六个步骤、其中有三个着色器,它们组成一条流水线,大多数工作在硬件显卡中完成。着色器是我们可以自己编写的,用类C的GLSL语言编写。

  • 顶点着色器(vertex shader)。将输入的顶点的坐标(五花八门)变换为另一种坐标(位于-1,1的标准化设备坐标。落在外面的被丢弃)。没有默认的顶点着色器,必须自己至少编写一个。
  • 形状(图元)装配。将顶点依序和所要连接的图形连起来成几何图元。
  • 几何着色器(geometry shader)。有默认的。这里可以通过增删顶点等调整图元,可能网格的变换就在这里。
  • 光栅化。
  • 片段着色器(fragment shader)。需要自己手动配置至少一个。设置像素的颜色。但这个颜色并非最后的输出。openGL中一个片段是渲染一个像素所需的所有数据。这里大部分是根据自己的算法与需要定义的。
  • 测试与混合。光照、消隐等可能在这里。Alpha测试(透明度),多图形混合,深度等等。

顶点着色器#

定义着色器#

将输入的顶点依照自己的规则转换为标准化设备坐标输出。这里写一个最简单的,原样输出(保证输入就是标准化设备坐标NDC)。

#version 330 core  
layout (location = 0) in vec3 aPos;//布局,把对这个着色器的输入放到位置0  
// in 表示以三维向量形式输入,输入的命名为aPos。  
void main()  
{  
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);//原样输出  
	//gl_Position应该就是顶点着色器的(固定?)输出。还有其它输出信息,这里略去。  
	//这里vec4的最后一个是w,用在后续透视除法。  
}  

gl_Position: 长 宽 高 for透视除法(最后那个也许是用作齐次坐标的扩展)
其实着色器语言 GLSL 和硬件描述语言 HDL 有点像。后面在片段着色器的地方更像。
OpenGL定义了vecn, 是n维向量结构,最多6维。

着色器是运行在GPU上的小程序,所以编写着色器的语言其实和硬件描述语言有很高的类似。虽然它是类似C的,但我们应当将它和硬件描述语言(如verilog)类比学习。

编译着色器#

将着色器的定义用字符串完全存下来,然后用OpenGL函数编译他。

//整个存为一个字符串。  
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";  

创建一个顶点着色器对象,它有一个唯一的id辨识它,放在unsigned int变量里。glCreateShader

unsigned int vertexShader;  
vertexShader = glCreateShader(GL_VERTEX_SHADER);//GL_VERTEX_SHADER  

现在可以通过vertexShader访问将要编译的着色器对象。
下面编译( glShaderSource, glCompileShader ):

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);  
//依次是要编译的着色器对象,原码字符串数量,原码字符串指针的地址,最后一个略。  
glCompileShader(vertexShader);//编译  

错误信息处理:glGetShaderiv glGetShaderInfoLog

...compile code... shaderID is vertexShader  
int  success;	//成功标志  
char infoLog[512];	//存放可能的错误信息  
glGetShaderiv(vertexShader,GL_COMPILE_STATUS,&success)  
if(!success){  
	glGetShaderInfoLog(vertexShader,512,NULL,infoLog);  
	printf("ERROR::SHADER::VERTEX::COMPILATION_FAILED\n%s\n",infoLog);  
}  

总的来说:

int  success;	//成功标志  
char infoLog[512];	//存放可能的错误信息`  
//整个原码存为一个字符串。  
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";  
//创建顶点着色器对象  
unsigned int vertexShader;  
vertexShader = glCreateShader(GL_VERTEX_SHADER);//GL_VERTEX_SHADER  
//设置源码并编译  
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);  
//依次是要编译的着色器对象,原码字符串数量,原码字符串指针的地址,最后一个略。  
glCompileShader(vertexShader);//编译  
//检查错误  
glGetShaderiv(vertexShader,GL_COMPILE_STATUS,&success)  
if(!success){  
	glGetShaderInfoLog(vertexShader,512,NULL,infoLog);  
	printf("ERROR::SHADER::VERTEX::COMPILATION_FAILED\n%s\n",infoLog);  
}  

片段着色器#

这里直接输出固定的颜色:

#version 330 core  
out vec4 FragColor; //out表示“输出端口”,是不是很像HDL  
  
void main()  
{  
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);  
}  

颜色向量vec4 是RGBA,A是透明度。为1表示完全不透明。

编译步骤一样。

着色器程序#

把自定义的所有着色器拼接在一起变成完整的管线,用到着色器程序。检查错误的步骤也一样。
glCreateProgram, glAttachShader, glLinkProgram, glGetProgramiv, glGetProgramInfoLog
过程:创建 ShaderProgram,添加着色器,连接着色器,应用着色器,删除之前的着色器…

int success;  
char infoLog[512];  
unsigned int shaderProgram;  
shaderProgram = glCreateProgram();  
glAttachShader(shaderProgram, vertexShader);  
glAttachShader(shaderProgram, fragmentShader);  
glLinkProgram(shaderProgram);  
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);  
if(!success) {  
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);  
	printf("......");  
    ...  
}  

最后应用这个着色器程序,并且删除已经没用的着色器对象。在下一次应用新的着色器程序前,都会用这个。
glUseProgram, glDeleteShader

glUseProgram(shaderProgram);  
glDeleteShader(vertexShader);  
glDeleteShader(fragmentShader);  

猜测,按照惯例,用glUseProgram(0)单纯解绑,但恐怕不会这么用)。

顶点数据结构#

用顶点缓冲对象VBO与显存交流#

顶点数据缓存在显存里,它们的识别符号是GL_ARRAY_BUFFER
一组顶点数据,如三角形三顶点:

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

将要存在显存里进行处理。用顶点缓冲对象(vertex buffer object)管理显存。注意,顶点缓存对象才是管理显存中的顶点的本体,其它VAO IBO 都只是便于我们使用的辅助,因而要用VBO绑定GL_ARRAY_BUFFER
glBufferData 将数组中的数据保存到显存中。

unsigned int VBO;  
glGenBuffers(1, &VBO);	//创建一个缓冲区对象用作VBO  
glBindBuffer(GL_ARRAY_BUFFER, VBO); //绑定VBO为顶点缓存数组  
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);  
//目标缓冲:GL_ARRAY_BUFFER,还有许多其它。  
//要缓存的数组字节大小,sizeof  
//数组指针  
//GL_STATIC_DRAW,数据基本不变,GL_DYNAMIC_DRAW数据时而会变,GL_STREAM_DRAW,实时在变。  

用 glBindBuffer(GL_ARRAY_BUFFER, VBO)设置Buffer的状态,表示接下来的BufferData等操作用VBO对GL_ARRAY_BUFFER进行,用 glBindBuffer(GL_ARRAY_BUFFER, 0) 表示解除绑定。后面的EBO VAO的绑定、解绑行为都是一样的。

链接顶点属性#

告诉硬件你存下的一串数据是怎样的格式与结构。
glVertexAttribPointer glEnableVertexAttribArray

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);  
//属性位置,还记得顶点着色器里的 layout(location=0)吗,这就设置了顶点属性位置为0.  
//要输入的顶点属性大小,in vecx 里的x,这里是vec3,即3  
//每个参数的数据类型  
//是否希望提前将数据标准化。提前映射到标准区间。(0-1 for unsigned, -1-1 for signed)  
//步长,连续两项(两个顶点)间的字节数。也是一整个顶点数据所包含的字节数  
//缓冲区中起始位置的偏移。后面再说  
glEnableVertexAttribArray(0);  
//使能**属性位置为0**的缓冲!  

openGL根据所绑定的 VBO 来寻找顶点数据。如果有两个VBO,分别绑定、分别传数据,只要在画图的时候,并且在这之前分别用不同的VAO记录,那么只要在画图前绑定了相应VAO,就能找到对应VBO中的点。注意每次处理VBO的过程是完全一样的,都需要 glEnableVertexAttribArray(0),不能因为另一个VBO的处理中已经调用过了就少用一次,而是都需要!

顶点数组对象VAO#

openGL要求必须使用VAO。
用顶点数组对象VAO记录、管理所有对它相关的VBO的修改与状态。从而在不同的VBO组之间轻松切换。
VAO会保存:

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置(参数)。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象(可以有多个VBO)。

VertexArray
glGenVertexArray, glBindVertexArray,

// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..  
// 0. 创建一个VAO  
unsigned int VAO;  
glGenVertexArrays(1, &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(float), (void*)0);  
glEnableVertexAttribArray(0);  
  
[...]  
  
// ..:: 绘制代码(渲染循环中) :: ..  
// 4. 绘制物体  
glUseProgram(shaderProgram);  
glBindVertexArray(VAO);  
someOpenGLFunctionThatDrawsOurTriangle();  
glBindVertexArray(0);	//解绑VAO  

在绑定VAO后(glBindVertexArray)后,所有对顶点缓冲的操作、相关的参数、VBO、使能与链接属性的函数调用都会记录在VAO中。在下次再次 glBindVertexArray 后,这些操作将被”重现“已回到之前的状态来在这组VBO上继续操作。所以确保对VBO的众多操作全部放到绑定相应VAO之后!

元素(索引)缓冲对象EBO(IBO)#

GL_ELEMENT_ARRAY_BUFFER
用顶点数组中每个点的编号(从0开始的伪下标)来指定绘制顺序(防止只能按顺序画顶点下的重复存储许多顶点)。这也是直接和显存相关的对象!因而同样用glBindBuffer, glBufferData,绑定GL_ELEMENT_ARRAY_BUFFER缓冲。
对它的操作和VBO很像。但EBO似乎不需要经历一个类似 VBO glVertexAttribPointer(…) and glEnableVertexAttribArray(…)的过程。对它存入内存中结构的指定在后面绘制中的 Draw_Element() 中指定。

float 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   // 左上角  
};  
  
unsigned int indices[] = {  
    // 注意索引从0开始!  
    // 此例的索引(0,1,2,3)就是顶点数组vertices的下标,  
    // 这样可以由下标代表顶点组合成矩形  
  
    0, 1, 3, // 第一个三角形  
    1, 2, 3  // 第二个三角形  
};  
/**************************************/  
unsigned int EBO;  
glGenBuffers(1, &EBO);  
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);  
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);  

EBO也可以记录在VAO中,确保glBindVertexArray(VAO) 在最前面。

注意
VBO 通过location指定了位置,通过 glEnableVertexAttribArray(location) 指定从显存中的哪个地方读取数据,VAO通过记录这个 glEnableVertexAttribArray的调用来让程序知道从哪里找顶点(当然它也会记录 glDisableVertexAttriArray,所以在 glBindVertexArray(0)解绑VAO前是否真的要调用 glDisableVertexAttribArray),而 EBO, Draw_Elemets 函数中从 GL_ELEMENT_VERTEX_ARRAY 所绑定的EBO那里获得索引数组,所以这之前必须要有一个 glBindBuffer(GL_ELEMENT_VERTEX_ARRAY, EBO) 来指定这个EBO。VAO会记录这个过程(当然也会记录解绑的过程),最后一次绑定、解绑EBO的操作被VAO记录下来,所以确定 不要在VAO被解绑之前解绑EBO!
最好:绑定VAO在所有其它VBO、EBO的绑定、操作之前,而解绑VAO同样在所有其它解绑之前。

绘制!!!#

走完了前摇终于开始绘制。
glDrawArrays,不用EBO画图。

glDrawArray(GL_TRIANGLES,0,3);  
//GL_TRIANGLES,表示要画的是三角形。其它还有许多。  
//顶点数组的起始索引  
//要绘制多少个顶点  

glDrawElements,使用EBO画图。

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);  
//画三角形  
//画几个点  
//EBO里的数据是什么类型,索引类型  
//EBO中的偏移量。  
通过指定最后一个参数,不用EBO而用索引数组似乎也可以做到类似的效果。  

用之前的顶点与EBO,最后出来是个矩形。

此外,设置状态为线框图、恢复填色用:

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);  
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);  

完整例程#

完整的例程-第一个三角形

关于VAO,VBO,EBO等乱七八糟的一个成功了例子#
    //vertex info. VAO, VBO, contain an EBO this time.  
    float 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,   // 左上角  
    };  
    unsigned int indics[] = {  
        0,1,3,  
        1,2,3,  
    };  
  
    unsigned int VAO,VBO,EBO;  
    glGenVertexArrays(1,&VAO);  
    glGenBuffers(1,&VBO);  
    glGenBuffers(1,&EBO);  
  
    glBindVertexArray(VAO);  
    //VBO  
    glBindBuffer(GL_ARRAY_BUFFER,VBO);  
    glBufferData(GL_ARRAY_BUFFER,sizeof(vertices),vertices,GL_STATIC_DRAW);  
  
    glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,3*sizeof(float),NULL);  
    glEnableVertexAttribArray(0);   //enable location=0 的显存区域(大概)它被VAO记录,所以只要绑了VAO就能用这个VAO  
    //EBO  
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,EBO);  //它被VAO记录,所以只要绑了VAO就能找到EBO  
    glBufferData(GL_ELEMENT_ARRAY_BUFFER,sizeof(indics),indics,GL_STATIC_DRAW);  
  
    glBindVertexArray(0);               //解绑VAO,在后面要用的时候再绑定。VAO最早绑定,最早解绑  
    glBindBuffer(GL_ARRAY_BUFFER,0);    //解绑,对于VAO,数据传完了、设置完了就没必要绑着内存了!  
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,0);  
  
	//render loop//  
	glBindVertexArray(VAO);  
	glDrawElement(...);  
LearnOpenGL·一·2 第一个三角形
https://namisntimpot.github.io/posts/cg/opengl/一2-第一个三角形/
作者
Li Jiaheng
发布于
2024-04-23
许可协议
CC BY-NC-SA 4.0