OpenGL核心模式入门使用教程

目录

说明

如果你还没有配置opengl的开发环境,可以查看此文章linux下使用opengl的环境配置

如果你是Windows系统,可以参考此链接

以下教程针对有一定图形学基础的人,仅仅介绍opengl核心模式的使用方法。
强烈建议仔细看完下面链接的文章
opengl概述

本入门教程一次性绘制两个不同物体,并且复用了顶点。
你可以边看,边将示例代码拷贝到vscode中,最终可以组合成一个完整的程序。当然,如果你对顺序感到迷惑,我会在文章结尾附上完整代码。

具体组合方式
1.参照下列文件结构先将工程所用到的各种资源放好
2.将主程序(我这里是test2.cpp)整体框架拷贝到vscode中。
3.之后会对各模块进行详细讲解,你可以把各模块代码再放入整体框架(test2.cpp),以加深理解。

整体程序框架如下

文件结构

-.vscode //vscode工程配置文件,请自己配置
        -c_cpp_properties.json
        -tasks.json
        -launch.json
-include //头文件
        -stb_image//纹理所用的图像加载库,下面给链接下载
        -stb_image.h//纹理所用的图像加载库的头文件,下面给链接下载
        -shader_s.h //这个是自己写的类,用来从文件中加载编译连接着色器,下面模块会给出
-resources //资源文件,存放纹理图片等,下面给链接下载
        textures
                xx.jpg
-source //源文件,存放glad库,图像加载库,着色器程序代码,主程序代码
        -glad.c //给链接下载
        -stb_image.c //给链接下载
        shader.vs //下面具体模块会给出
        shader.fs //下面具体模块会给出
        -test2.cpp //下面给出框架,具体内容在下面具体模块

部分库文件下载链接

主程序内代码

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <stb_image.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <shader_s.h>
#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow *window);

//设置窗口宽度和高度,单位为像素
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

int main()
{
    // glfw:初始化和配置
    // ------------------------------
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    // glfw创建窗口
    // --------------------
    GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

    // glad 加载所有函数指针
    // ---------------------------------------
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }

    /*编译连接着色器模块,即下面的模块二*/

    /*初始化数据模块,下面的模块一的一部分*/

    /*纹理加载,模块三*/

    /*渲染(绘制)循环,每秒大概执行几十次,在这里写你要实现的效果*/
    while (!glfwWindowShouldClose(window))
    {
         // 处理一些窗口input事件(如按下某个键)
        // -----
        processInput(window);

        //渲染,清空当前背景,并替换为指定的颜色
        // ------
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

       // 纹理使用,绑定纹理到对应的纹理单元,模块三

        // 渲染(绘制)容器,在这里调用绘制函数,下面模块一的另一部分(绘制)
        ourShader.use();//自己的写的类,实际就是使用编译连接号的着色器程序

        // glfw: 使用双缓冲区,拉取窗口IO事件(如键按下/释放,鼠标移动等等)
        // -------------------------------------------------------------------------------
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // 可选(可写可不写): 当资源不用时释放掉
    // ------------------------------------------------------------------------
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);
    glDeleteBuffers(1, &EBO);

    // glfw: 终止并清空所有前面分配的GLFW资源
    // ------------------------------------------------------------------
    glfwTerminate();
    return 0;
}

// 处理所有的输入事件:询问GLFW在这一次渲染帧中是否有相关事件,有则处理
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow *window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}

// glfw:当窗口大小被用户或操作系统改变时回调此函数
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    //确保视区匹配新窗口大小
    glViewport(0, 0, width, height);
}

各模块

模块一(核心模块,必会),数据初始化 绘制(写在主程序的框架内)

1.使用以下三个函数,把顶点数据从 主存 移到显存(显卡内存)中,可以把显卡中的那块存储空间称为VBO(Vertex Buffer Object,顶点缓冲区对象),之后用缓冲区对象的id来访问这块空间。

假设我们要画的原始数据为

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, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

glGenBuffers 生成缓冲区对象的名称

选项 解释
函数原型 *void glGenBuffers(GLsizei n,GLuint buffers);**
官方解释 generate buffer object names 生成缓冲区对象的名称。
参数一 要生成的缓冲对象的数量
参数二 生成名称的存储位置
作用 即生成n个缓冲区对象,并将对应的id存放在指针指向的地址(可以是一个int对象,也可以是一个数组,取决于n的值).说白了就是给GPU中的一块内存起了个名字(整数类型),我们之后可以通过它的id访问到它。
    /*用法*/
    int vbo;//缓冲区id存储位置,n=1时,注意,如果你是为了组合代码,n=2的情况不需要复制
    glGenBuffers(1,&vbo);//一个缓冲区对象的id,仅需要一个int就可存放,注意第二个参数为指针类型,故需要 取地址&

    int vbo[2];//n=2时
    glGenBuffers(2,vbo);//两个缓存区对象的id,需要int数组存放,数组名就是地址。

    int ebo;//ebo同理
    glGenBuffers(1, &ebo);

OpenGL有很多缓冲对象类型(顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER,索引缓冲对象是GL_ELEMENT_ARRAY_BUFFER),每种类型同时只能绑定一个对象,通过下面的函数可以将缓存区对象绑定到指定类型上

EBO索引缓存对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)与VBO类似,可以看成可以VBO的升级版,可以复用顶点了(一个顶点只存一遍,减少内存占用)。

glBindBuffer 绑定一个已命名的缓冲区对象到指定类型

选项 解释
函数原型 void glBindBuffer(GLenum target,GLuint buffer);
官方解释 bind a named buffer object
参数一 就是缓冲对象的类型
参数二 就是要绑定的缓冲对象的名称,也就是我们在上一个函数里生成的名称.
作用 使用该函数将缓冲对象绑定到OpenGL上下文环境中以便使用。如果把一个已经创建好的缓冲对象(buffer)绑定到target,那么这个buffer将为当前target的激活对象;但是如果绑定的buffer值为0,那么OpenGL将不再对当前target使用任何缓存对象。
/*使用*/
glBindBuffer(GL_ARRAY_BUFFER, vbo);  //vbo是上一个函数所生成的缓冲区对象id存储所在的变量,vbo变成了一个顶点缓冲类型,注意一次只能绑定一个对象到指定类型

//vbo为数组时(int vbo[2]),则第二个参数根据需要,写成vbo[0]或vbo[1]

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);//ebo同理,只是第一个参数不同,记住即可

当一个缓存区有了名字和类型,我们就可以给他赋值

glBufferData 将指定数据(如,我们自己写的一个顶点数组)存到 当前缓冲区类型绑定的对象内(一个类型同时只能绑定一个缓存区对象)

选项 解释
函数原型 *void glBufferData(GLenum target,GLsizeiptr size,const GLvoid data,GLenum usage);**
参数一 就是缓冲对象的类型,上个函数我们绑定了一个缓冲区对象到这个类型上
参数二 指定传输数据的大小(以字节为单位),如果为数组,可以用一个简单的sizeof(数组名)计算出大小
参数三 数据源,传入数据的地址(如数组名)
参数四 指定了我们希望显卡如何管理给定的数据。它有三种形式:GL_STATIC_DRAW :数据不会或几乎不会改变。GL_DYNAMIC_DRAW:数据会被改变很多。GL_STREAM_DRAW :数据每次绘制时都会改变。具体实现是,存放数据的位置不同(如指定GL_DYNAMIC_DRAW或GL_STREAM_DRAW,则显卡把数据放在能够高速写入的内存部分)

作用|使用该函数将缓冲对象绑定到OpenGL上下文环境中以便使用。如果把一个已经创建好的缓冲对象(buffer)绑定到target,那么这个buffer将为当前target的激活对象;但是如果绑定的buffer值为0,那么OpenGL将不再对当前target使用任何缓存对象。

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

glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);//VEO

2.数据已经存在显卡内存中了,我们该如何解释他呢?接下来我们解释那块空间的访问方式(我们称他为顶点属性)。顶点属性有很多,包括三维坐标,颜色,等等

(链接(配置)顶点属性到那块空间(顶点缓冲区VBO))

glVertexAttribPointer 配置某一套顶点属性解析方式

选项 解释
函数原型 *void glVertexAttribPointer( GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride,const GLvoid pointer);**
参数一 指定我们要配置的顶点属性的id。我们可以有很多套解析方法(顶点属性),现在我们配置第一套,我们可以给他分配一个id(可以从0开始),用来区分不同的顶点属性解析方案。我们在顶点着色器中使用layout(location = id)自定义使用哪套方案来解析存放顶点数据的那块空间。请注意,一个顶点可以配置多个属性,如一个顶点有两个属性(坐标,颜色),一个属性负责解析坐标,另一个属性负责解析颜色。
参数二 顶点属性的大小。顶点属性是一个vec3(一个三维向量,即此时顶点属性只有这个顶点的三维坐标值,它由3个值组成),所以大小是3。
参数三 顶点属性的数据类型,一般是GL_FLOAT(GLSL(OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的)中vec*都是由浮点数值组成的)。
参数四 是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE,表示不标准化,因为我们最开始输入的数据就是标准化的。
参数五 叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
参数六 类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。

单个属性的例子
file

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//会将该属性配置给当前所有类型(包括VBO,VEO)上绑定的对象。即这里一句同时给VBO,EBO配置了属性。

//注意,如果只使用索引绘图,则可以单独将VBO的属性解绑,如下
glBindBuffer(GL_ARRAY_BUFFER, 0);//仅仅解绑VBO,VEO仍绑定有属性。

多个属性的例子(如果为了组合代码,请拷贝上面单个属性的代码)
file
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 sizeof(float), (void)0);
glEnableVertexAttribArray(0);
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 sizeof(float), (void)(3* sizeof(float)));
glEnableVertexAttribArray(1);

glEnableVertexAttribArray 启用某一套顶点属性

以方案的id作为参数,启用顶点属性;顶点属性默认是禁用的(没有配置的)。注意,这个函数会将 某套顶点属性 绑定到 当前绑定的顶点缓存区对象上。

 glEnableVertexAttribArray(0);//如上一个函数我们配置了一套属性(并通过上个函数的第一个参数起了id为0),那我们这个函数就传入0.  启用同上,同时给VEO和VBO配置了。

3.前面所做全部是为了配置 一组数据和它对应的解析方法。目前我们已经在显卡内存中有了 一组数据,并配置了如何解析这些数据。我们现在实际上已经可以用这些东西去绘制一个图形了,但如果我们想要给某一组数据配置其他解析方法时,会覆盖掉之前配置的解析方法(将VBO与当前属性解绑再绑定新的 与 直接给当前VBO绑定新的属性 效果一样,其他opengl也对象类似,可见是覆盖式的),如何处理的?

顶点数组对象就是 一组数据+一种解析方法的打包,方便调用?
我们引入VAO(Vertex Array Object,顶点数组对象)的概念,通过它,可以将多个(数据+解析方法=一个顶点对象,注意vao中一个对象就是一套已经配置好的数据+解析方法)顶点对象打包起来,即它含有多个顶点对象,故它称为顶点数组对象。
我们以后一个物体只要配置一次就可以多次调用,不需要重新再配置。
说白了就是,引入VAO,可以将绘制时做的各种复杂绑定操作 移到 绘制流程前,画的时候只用绑定VAO即可。也就是提前打包好做成VAO,之后好调用。

注意 OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入(包含顶点缓冲区中的数据+顶点属性的解析方式)。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。

以下是绘制时的不同

/*引入VAO前,绘制多个物体*/
第一个物体的绑定操作
glBindBuffer(GL_ARRAY_BUFFER, VBOs[0]);//每次用的时候,需要重新绑定VBO到它对应的类型上
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//再重新绑定属性
glEnableVertexAttribArray(0);//再重新启用属性
//绘制

第二个物体的绑定操作
glBindBuffer(GL_ARRAY_BUFFER, VBOs[0]);//每次用的时候,需要重新绑定VBO到它对应的类型上
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//再重新绑定属性
glEnableVertexAttribArray(0);//再重新启用属性
//绘制
/*引入VAO后,绘制多个物体*/
        glBindVertexArray(VAOs[0]);//物体一的绑定,之前配置过一次,这里通过VAO调用
        //绘制

        glBindVertexArray(VAOs[1]);//物体二的绑定,之前配置过一次,这里通过VAO调用
        //绘制

引入VAO后,前面的代码需要做一些修改(需要修改部分有标记),最终本模块,如下

    /*原始数据*/
    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, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

    /*给缓冲区起名,上述步骤1*/
    unsigned int vao;   //------------------------------步骤三,新增
    glGenVertexArrays(1, &vao);//------------------------------步骤三,新增
    unsigned int vbo;//缓冲区id存储位置,n=1时,注意,如果你是为了组合代码,n=2的情况不需要复制
    glGenBuffers(1,&vbo);//一个缓冲区对象的id,仅需要一个int就可存放,注意第二个参数为指针类型,故需要 取地址&
    unsigned int ebo;//ebo同理
    glGenBuffers(1, &ebo);

    /*绑定对象,并将原始数据移到GPU内存中,上述步骤1*/
    glBindVertexArray(vao);//------------------------------步骤三,新增
    glBindBuffer(GL_ARRAY_BUFFER, vbo);  //vbo是上一个函数所生成的缓冲区对象id存储所在的变量,vbo变成了一个顶点缓冲类型,注意一次只能绑定一个对象到指定类型
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);//ebo同理,只是第一个参数不同,记住即可
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//VBO
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);//VEO

    /*配置解析属性,上述步骤2*/
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//会将该属性配置给当前所有类型(包括VBO,VEO)上绑定的对象。即这里一句同时给VBO,EBO配置了属性。
    //注意,如果只使用索引绘图,则可以单独将VBO的属性解绑,如下
    glBindBuffer(GL_ARRAY_BUFFER, 0);//仅仅解绑VBO,VEO仍绑定有属性。
    glEnableVertexAttribArray(0);//如上一个函数我们配置了一套属性(并通过上个函数的第一个参数起了id为0),那我们这个函数就传入0.  启用同上,同时给VEO和VBO配置了。

glBindVertexArray

选项 解释
参数一 要绑定的VA对象(VAO)
作用 将某一VAO绑定到当前上下文(opengl其实就是一个状态机,之前的两个绑定同理),之后我们可以调用或者修改VAO

4.要绘制的图形我们准备工作已经做好了,且打包成了一个VAO对象,下面我们将这个VAO对象绘制出来

两种绘制方法
一种按照VBO绘制,一种按照EBO绘制。

glDrawArrays

选项 解释
参数一 OpenGL图元的类型
参数二 顶点数组的起始索引,我们这里填0,表示从第1个顶点开始绘制
参数三 绘制多少个顶点
作用 绘制当前上下文绑定的vao对象

glDrawElements

选项 解释
参数一 OpenGL图元的类型
参数二 绘制多少个顶点,传一个整数
参数三 索引的类型,这里是GL_UNSIGNED_INT
参数四 指定EBO中的偏移量,我们给的例子使用了索引缓冲对象,故选择传为0,表示从第一个索引顶点开始绘制(或者传递一个索引数组,但是这是当你不在使用索引缓冲对象的时候)

图元

为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去指定这些数据所表示的渲染类型。我们是希望把这些数据渲染成一系列的点?一系列的三角形?还是仅仅是一个长长的线?做出的这些提示叫做图元(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP,分别为画点,画三角形,画线

        ourShader.use();//自己写的着色器类封装了glUseProgram(shaderProgram)。使用编译连接好的着色器程序
        glBindVertexArray(vao);//绑定要绘制的VAO对象(前三步我们打包好的)
        glDrawArrays(GL_TRIANGLES, 0, 6);//区别仅在于此函数,使用vbo的vao绘制
        ourShader.use();//自己写的着色器类封装了glUseProgram(shaderProgram)。使用编译连接好的着色器程序
        glBindVertexArray(vao);//绑定要绘制的VAO对象(前三步我们打包好的)
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);//区别仅在于此函数,使用ebo的bao绘制

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

模块二(核心模块,必回)着色器程序

我们将着色器(多个着色器,包括顶点,几何,片段着色器等)具体代码写在对应文件里,编译连接他们的代码封装成一个类(着色器类),之后在主程序中调用即可。

核心模式绘制(主程序中实际写的也是这个顺序)流程

  • 1.创建窗口
  • 2.加载glad函数
  • 3.创建编译连接成着色器程序
  • 4.设置顶点数据,配置VBO,EBO,VAO
  • 5.加载纹理,并通过设置为着色器程序(注意设置前先激活着色器程序!)全局变量
  • 6.绘制循环中绘制(绘制时顶点数据才传给着色器,而全局变量则在进入绘制循环前就传给着色器程序了,当然也可以绘制循环中再次设置)。并且捕获一些键盘鼠标事件
  • 7.回收空间,释放顶点数据
  • 8.写一些事件处理函数(会在绘制循环中触发这些事件),第8点只是代码空间位置在最后,逻辑上实际应该在第6步前

着色器基本介绍

以下两个是着色器最基本的内容,请先仔细看一下,更为复杂的内容在此基础上修改。
shader.vs 文件中内容

#version 330 core //opengl版本
layout (location = 0) in vec3 aPos;  //关键字in表示输入数据,变量aPos的类型是vec3(三维向量),layout (location = 0) 表示使用 前面我们写好的id为0的属性解析方案  解析最开始定义的顶点数据。
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);//gl_Position opengl是保留的输出变量(会传给后面的着色器,表示这个顶点的位置信息),必须有数据。当然,我们也可以再自定义其他out类型的变量,但需要先在本着色器开头声明。
}

shader.fs 文件中内容

#version 330 core
out vec4 FragColor;//声明一个该着色器将会输出的,四维向量类型的变量
void main()
{   //注释用双斜线
 FragColor = vec4(0.7f, 0.0f, 0.0f, 1.0f);//不是保留变量,使用前需要先定义,指定这个顶点的颜色信息,当然你也可以不指定颜色,编译也不会报错,OpenGL会把你的物体渲染为黑色(你背景的颜色),你可能看不到。前三个参数为红绿蓝三基色的强度,三者相同则最终表现出来为白色(三者越大,颜色越亮)。第四个参数是α值(透明度),为1说明颜色完全由本身决定(完全不透明),为0则说明颜色由其他层决定(如当我们引入纹理层,颜色由纹理决定)(完全透明)。
}

着色器语法

然后请先查看链接中文章着色器语法讲解的前五部分。五部分为
着色器,GLSL(编写着色器所用的语言opengl shader language的缩写),数据类型,输入输出,uniform(说白了就是全局变量)
再继续看下面的内容

主程序中编译连接着色器

Shader ourShader("source/shader.vs", "source/shader.fs");//由于写了自己的类(具体在下面),使用及其简便

着色器类

shader_s.h 自己写的类的内容,实现了从文件中读取着色器代码,并编译,连接,并提供了调用着色器程序和修改着色器程序全局变量的函数。

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h>
#include <glm/glm.hpp>

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

class Shader//着色器类
{
public:
    unsigned int ID;
    // 构造函数生成着色器
    // ------------------------------------------------------------------------
    Shader(const char* vertexPath, const char* fragmentPath, const char* geometryPath = nullptr)
    {
        // 1. 从指定文件中检索顶点着色器和片段着色器的源代码,几何着色器我们目前用不上,故默认为空。
        std::string vertexCode;
        std::string fragmentCode;
        std::string geometryCode;//几何着色器代码
        std::ifstream vShaderFile;//存放顶点着色器的文件
        std::ifstream fShaderFile;//片段着色器
        std::ifstream gShaderFile;//几何着色器
        // 确保文件输入流对象可以抛出异常
        vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
        fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
        gShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
        try 
        {
            // 打开文件
            vShaderFile.open(vertexPath);
            fShaderFile.open(fragmentPath);
            std::stringstream vShaderStream, fShaderStream;//定义两个字符串流
            //将文件的缓冲区内容读入流
            vShaderStream << vShaderFile.rdbuf();
            fShaderStream << fShaderFile.rdbuf();
            // 关闭文件
            vShaderFile.close();
            fShaderFile.close();
            // 将流转化为string类型
            vertexCode = vShaderStream.str();
            fragmentCode = fShaderStream.str(); 
            // 如果几何着色器路径被指定,那么也载入几何着色器所在文件的内容
            if(geometryPath != nullptr)
            {
                gShaderFile.open(geometryPath);
                std::stringstream gShaderStream;
                gShaderStream << gShaderFile.rdbuf();
                gShaderFile.close();
                geometryCode = gShaderStream.str();
            }
        }
        catch (std::ifstream::failure& e)
        {
            std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;//捕获到文件读取异常,则报错
        }
        const char* vShaderCode = vertexCode.c_str();//string转为字符数组
        const char * fShaderCode = fragmentCode.c_str();
        // 2. 编译着色器
        unsigned int vertex, fragment;
        // 编译顶点着色器
        vertex = glCreateShader(GL_VERTEX_SHADER);//调用opengl的创建函数,并返回它的ID(失败则为0),方便之后调用
        glShaderSource(vertex, 1, &vShaderCode, NULL);//第二个参数指定源代码由几个字符数组组成,我们这里一次性将着色器的全部源代码存在一个字符数组中,故此参数的值为1。第三个参数是源代码字符数组,第四个参数是GLSL源码字符串数组的长度,设置为NULL则由系统计算。(如果源代码由两个字符数组组成,则第二个参数为2,第四个参数也是一个有两个元素的数组,元素一,二分别指定两个字符数组的长度)
        glCompileShader(vertex);//调用opengl的编译函数
        checkCompileErrors(vertex, "VERTEX");//检查编译中是否有错,这个函数是自己写的,封装了opengl的一些检查代码,在下面有写
        // 编译片段着色器,同理
        fragment = glCreateShader(GL_FRAGMENT_SHADER);
        glShaderSource(fragment, 1, &fShaderCode, NULL);
        glCompileShader(fragment);
        checkCompileErrors(fragment, "FRAGMENT");
        // 如果有几何着色器,那么也编译几何着色器,同理
        unsigned int geometry;
        if(geometryPath != nullptr)
        {
            const char * gShaderCode = geometryCode.c_str();//不要忘了这句
            geometry = glCreateShader(GL_GEOMETRY_SHADER);
            glShaderSource(geometry, 1, &gShaderCode, NULL);
            glCompileShader(geometry);
            checkCompileErrors(geometry, "GEOMETRY");
        }
        // 连接成着色器程序
        ID = glCreateProgram();//创建着色器程序
        glAttachShader(ID, vertex);//将顶点着色器给刚创建的着色器程序
        glAttachShader(ID, fragment);//同理
        if(geometryPath != nullptr)
            glAttachShader(ID, geometry);//同理
        glLinkProgram(ID);//连接着色器,构成一个完整的着色器程序
        checkCompileErrors(ID, "PROGRAM");//检查连接错误
        //连接成着色器程序后可以删除着色器
        glDeleteShader(vertex);
        glDeleteShader(fragment);
        if(geometryPath != nullptr)
            glDeleteShader(geometry);

    }

    //激活着色器
    // ------------------------------------------------------------------------
    void use() 
    { 
        glUseProgram(ID); //封装了此函数,从通过调用id调用着色器 变为 直接调用着色器类的对象(每个对象内部封装的有id)的成员函数。
    }
    // 设置着色器的所用到的某个全局变量的值(着色器程序既然是个程序,肯定有全局变量。着色器程序所用到的数据可以从顶点着色器输入获取,然后逐步往后传外,当然也可以通过在外部直接设置他的全局变量值,然后从全局变量中获取(此方法不需要将数据在各个着色器传递,所有着色器都可以直接使用)。这是着色器程序获取数据的两种方式!)
    //注意,下个模块讲纹理时会用到全局变量。
    // ------------------------------------------------------------------------
    void setBool(const std::string &name, bool value) const
    {
        glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value); 
    }
    // ------------------------------------------------------------------------
    void setInt(const std::string &name, int value) const
    { 
        glUniform1i(glGetUniformLocation(ID, name.c_str()), value); 
    }
    // ------------------------------------------------------------------------
    void setFloat(const std::string &name, float value) const
    { 
        glUniform1f(glGetUniformLocation(ID, name.c_str()), value); 
    }
    // ------------------------------------------------------------------------
    void setVec2(const std::string &name, const glm::vec2 &value) const
    { 
        glUniform2fv(glGetUniformLocation(ID, name.c_str()), 1, &value[0]); 
    }
    void setVec2(const std::string &name, float x, float y) const
    { 
        glUniform2f(glGetUniformLocation(ID, name.c_str()), x, y); 
    }
    // ------------------------------------------------------------------------
    void setVec3(const std::string &name, const glm::vec3 &value) const
    { 
        glUniform3fv(glGetUniformLocation(ID, name.c_str()), 1, &value[0]); 
    }
    void setVec3(const std::string &name, float x, float y, float z) const
    { 
        glUniform3f(glGetUniformLocation(ID, name.c_str()), x, y, z); 
    }
    // ------------------------------------------------------------------------
    void setVec4(const std::string &name, const glm::vec4 &value) const
    { 
        glUniform4fv(glGetUniformLocation(ID, name.c_str()), 1, &value[0]); 
    }
    void setVec4(const std::string &name, float x, float y, float z, float w) 
    { 
        glUniform4f(glGetUniformLocation(ID, name.c_str()), x, y, z, w); 
    }
    // ------------------------------------------------------------------------
    void setMat2(const std::string &name, const glm::mat2 &mat) const
    {
        glUniformMatrix2fv(glGetUniformLocation(ID, name.c_str()), 1, GL_FALSE, &mat[0][0]);
    }
    // ------------------------------------------------------------------------
    void setMat3(const std::string &name, const glm::mat3 &mat) const
    {
        glUniformMatrix3fv(glGetUniformLocation(ID, name.c_str()), 1, GL_FALSE, &mat[0][0]);
    }
    // ------------------------------------------------------------------------
    void setMat4(const std::string &name, const glm::mat4 &mat) const
    {
        glUniformMatrix4fv(glGetUniformLocation(ID, name.c_str()), 1, GL_FALSE, &mat[0][0]);
    }

private:
    // 检查着色器编译连接错误
    // ------------------------------------------------------------------------
    void checkCompileErrors(GLuint shader, std::string type)
    {
        GLint success;
        GLchar infoLog[1024];
        if(type != "PROGRAM")//不是着色器连接,检查着色器(顶点,片段,几何着色器)编译时的错误
        {
            glGetShaderiv(shader, GL_COMPILE_STATUS, &success);//获取着色器的编译状态
            if(!success)
            {
                glGetShaderInfoLog(shader, 1024, NULL, infoLog);//获取对应着色器日志信息
                std::cout << "ERROR::SHADER_COMPILATION_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
            }
        }
        else//检查着色器连接错误
        {
            glGetProgramiv(shader, GL_LINK_STATUS, &success);//获取着色器程序的连接状态
            if(!success)
            {
                glGetProgramInfoLog(shader, 1024, NULL, infoLog);//获取对应着色器程序日志信息,注意此函数与获取着色器信息所用的opengl函数不同
                std::cout << "ERROR::PROGRAM_LINKING_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
            }
        }
    }
};
#endif

模块三(纹理模块,提高),说白了给前面画的图形加上图片。

即前面做出了一面墙,现在我们给墙上画点图案,让它好看起来。

修改源数据与着色器

使用纹理前,先加载了两张图片(加载图片所用库文件,已在文章最开头的项目结构给出下载链接),我们需要配置纹理图片的坐标与各顶点的对应关系。

主程序中修改数据源为

    float vertices[] = {
        // positions,位置属性          // colors  ,颜色属性        // texture coords,该顶点对应的纹理坐标(两张图片共用此对应关系)
         0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   1.0f, 1.0f, // 右上
         0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,   1.0f, 0.0f, // 右下
        -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f, //左下
        -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,   0.0f, 1.0f  // 左上
    };
    unsigned int indices[] = {
        0, 1, 3, // 第一个三角形
        1, 2, 3  // 第一个三角形
    };

并且我们需要写新的顶点属性的解析方法(顶点对应纹理图片的坐标)。
主程序中增加属性解析方案,具体位置就是以前写属性的位置

    // 位置属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0);//忘记怎么用了,请回去看第一模块里的介绍
    glEnableVertexAttribArray(0);//这个值是上个函数第一个参数设置的id
    // 颜色属性
    glVertexAttribPointer(10, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));//注意最后一个参数,偏移量
    glEnableVertexAttribArray(10);
    // 纹理坐标属性
    glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));//注意最后一个参数,偏移量
    glEnableVertexAttribArray(5);

顶点属解析的调用 需要在顶点着色器中设置,两张图片的混合需要在片段着色器中设置。因此我们也需要修改两个着色器的代码。下面给出修改后的代码

shader.vs

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 10) in vec3 aColor;//新增,location就是我们 设置的解析属性 的id,注意一般系统只支持id为16以下,之后还会在主程序中新增相关属性配置。
layout (location = 5) in vec2 aTexCoord;//新增,之后还会在主程序中新增相关属性配置。

out vec3 ourColor;//新增
out vec2 TexCoord;//新增

void main()
{
    gl_Position = vec4(aPos, 1.0);//修改,向量的另一种用法,在模块二,着色器语法链接中的文章有讲向量的一些用法
    ourColor = aColor;//新增
    TexCoord = aTexCoord;//新增
}

shader.fs

#version 330 core
out vec4 FragColor;

in vec3 ourColor;//新增,注意与前面的顶点着色器的out成对出现
in vec2 TexCoord;//新增,注意与前面的顶点着色器的out成对出现
uniform sampler2D texture1;//新增,全局变量,下面会讲解sampler2D是什么(采样器类型)实际存储的就是纹理
uniform sampler2D texture2;//新增,全局变量

void main()
{   //注释用双斜线
    FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);//注意此处,混合函数的第三个参数0.2,决定了纹理所用的两张图片显示的比重,表示第二个纹理显示强度占20%,如果为0,则第二个纹理不显示,只显示第一个纹理
}

纹理相关概念

源数据和着色器我们已经修改好了,下面将具体讲纹理的相关内容。
先提几个概念
纹理坐标,纹理所用图片本身的坐标(x,y坐标范围都为0-1.0,注意此时是2d纹理图像),注意没有负值。

为了能够把纹理(一般是图片)映射(Map)到我们之前设置的顶点所绘制的图形上,我们需要指定我们自己图形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该顶点从纹理图像的哪个部分采样(采集颜色,可以说是获取纹理图像颜色)(表明该顶点对应纹理的哪个点)。即某个顶点使用纹理坐标获取纹理颜色的过程叫做采样(Sampling)。

之后在我们图形上的中间部分(比如矩形四个顶点之间的区域)上进行片段插值(Fragment Interpolation)(经过计算(比方纹理小于我们的图形,则需要将纹理放大后再填充到我们的图形中)后填充颜色)。

片段插值时会涉及到一些更为复杂的概念。纹理环绕方式纹理过滤

纹理加载代码与解析

下面这段代码加到主程序/纹理加载,模块三/中

    // 加载并创建纹理
    // -------------------------
    unsigned int texture1, texture2;//和之前生成的OpenGL对象一样,纹理也是使用ID引用的。注意id必须是无符号整型才能和opengl中的数据类型对应。
    // 纹理1
    // ---------
    /*生成和绑定纹理到类型*/
    glGenTextures(1, &texture1);//生成纹理名称,与前文生成VBO类似,通过id调用,id存放位置为第二个参数。第一个参数为生成纹理的数量。
    glBindTexture(GL_TEXTURE_2D, texture1); //绑定到对应类型,说白了就是opengl上下文是个状态机,状态机每个状态有多个属性(类型),现在将当前纹理对象绑定到这个类型上。
    //个人理解是,一个厕所(opengl上下文)有5个坑位(类型,如VBO的GL_ARRAY_BUFFER,纹理的GL_TEXTURE_2D),每个坑位同时只能有一个人(一个类型同时只能绑定一个他的类型的对象)~

    /*设置纹理环绕方式*/
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);//对单独的一个坐标轴设置(s、t(如果是使用3D纹理那么还有一个r)它们和x、y、z是等价的)纹理的环绕(Wrapping)方式为重复(GL_REPEAT)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);// GL_TEXTURE_WRAP_T对应y坐标轴

    /*当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项*/
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);//设置缩小时的纹理过滤参数,有两种,线性过滤(linear Filtering)和邻近过滤(Nearest Neighbor Filtering)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);//设置放大时的纹理过滤参数

    // 加载图像, 创建纹理 and generate mipmaps
    int width, height, nrChannels;
    stbi_set_flip_vertically_on_load(true); // 告诉 stb_image.h 以y轴翻转图像,你可以试试注释掉这行看看效果就明白了。这是因为OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部,加载多个图片,只用设置一次这个。
    unsigned char *data = stbi_load("resources/textures/container.jpg", &width, &height, &nrChannels, 0);//第一个参数为图片的路径,图像加载库stb_image.h将会用图像的宽度、高度和颜色通道的个数填充第二,三,四个变量(以引用类型的方式)
    if (data)
    {
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
   /* glTexImage2D
   第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
    第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
    第三个参数告诉OpenGL我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
    第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
    第六个参数应该总是被设为0(历史遗留的问题)。
    第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
    最后一个参数是真正的图像数据。
*/
        glGenerateMipmap(GL_TEXTURE_2D);
        /*
        当调用glTexImage2D时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。
        或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。
        */
    }
    else
    {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(data);//图像生成纹理后,加载到内存中的图像可以释放
    // texture 2,同上
    // ---------
    glGenTextures(1, &texture2);
    glBindTexture(GL_TEXTURE_2D, texture2);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    data = stbi_load("resources/textures/awesomeface.png", &width, &height, &nrChannels, 0);
    if (data)
    {
        // 注意awesomeface.png 有透明度,因此有 alpha通道,所以第三,七个参数使用GL_RGBA,最后多了个A
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
        glGenerateMipmap(GL_TEXTURE_2D);
    }
    else
    {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(data);

    // 片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?
    //GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D、sampler3D,或在我们的例子中的sampler2D。
    //我们前面简单声明一个uniform sampler2D把一个纹理添加到片段着色器中,现在我们会把生成的纹理赋值给这个uniform。
    //这个过程叫做把纹理单元的id给着色器,实际上,纹理先给纹理单元,纹理单元再给着色器。
    //OpenGL至少保证有16个纹理单元供你使用,也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按 顺序 定义的,所以我们也可以通过GL_TEXTURE0 + 8的方式获得GL_TEXTURE8,这在当我们需要循环一些纹理单元的时候会很有用。
    // -------------------------------------------------------------------------------------------
    ourShader.use(); //设置着色器程序的全局变量前一定先激活着色器程序
    glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0);//两种方法设置全局变量,法一,是直接调用opengl的函数,第一个参数找到全局变量在指定id的着色器程序中的位置。第二个参数不是我们自己起的纹理1的id,而是纹理单元的id
    ourShader.setInt("texture2", 1);//法二,封装好了上面的函数,直接给指定着色器对象设置。参数二不是纹理2的id,而是纹理单元的id

调用纹理

注意:纹理-纹理单元-着色器,前面我们设置了着色器和纹理单元的对应关系,现在在绘制循环内每次实际调用时,只用把纹理给纹理单元。

在主程序的绘制循环中增加

        //绑定纹理到对应的纹理单元
        glActiveTexture(GL_TEXTURE0);//先激活纹理单元,实际上,纹理单元0默认激活
        glBindTexture(GL_TEXTURE_2D, texture1);//把纹理给纹理单元
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, texture2);