手把手带你入门opengl(2)——五彩缤纷的三角形

想象一下,一个五彩缤纷的三角形,将颜色作为一个纹理抽出来,那么想要画出一个这样的三角形,需要什么样的数据。

  • 世界坐标,也就是每个像素应该在什么位置,类比将三角形放在直角坐标系里面,每个位置的坐标
  • 纹理坐标,每个像素坐标应该显示的颜色坐标,也就是三角形每个点的颜色。

或者再抽象一下,应该都玩过那种图像贴纸吧?想要将一个图案,贴在一个合适模型上,那么就需要将贴纸(纹理坐标)和模型(相似坐标)一一对齐,这样就能够达到,我们最终想要的效果。

世界坐标

通过名字就可以知道,这是OpenGL自己世界的坐标,是一个标准化坐标系,范围是 -1 ~ 1,原点在中间。

纹理坐标

这个在每个平台有些不一致,在window中,是以左下角为(0.0),右上为(1.1)的坐标系,而在android中,则是,左上(0,0)右下(1,1)的坐标系,本文以windows为准

着色器

opengl是直接和GPU打交道的,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

有些着色器允许开发者自己配置,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的

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

1638341363676.png

通过一步一步的输入输出,最终将顶点数据渲染出来。

顶点输入

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

由于我们希望渲染一个三角形,我们一共要指定三个顶点,每个顶点都有一个3D位置。我们会将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个float数组。

有一点还没说的是,OpenGL 所有的画面都是由三角形构成的,比如一个四边形由两个三角形构成,其他更复杂的图形也都可以分割为大大小小的三角形。

GLfloat vertices[] = {
        // 顶点              //颜色
        0.5f, -0.5f, 0.0f,   1.0f, 0.0f, 0.0f,  
        -0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,  
        0.0f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f  
    };
复制代码

因为我们画的是平面图形,所以这里的Z轴,都设为0即可,类似下图,对应的这里的颜色坐标是RGB,在三个顶点分别显示为红/绿/蓝色

1638341165772.png 定义这样的顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理我们在内存中指定数量的顶点。

我们通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

VBO(顶点缓冲对象)

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

unsigned int VBO;
glGenBuffers(1, &VBO);
复制代码

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

glBindBuffer(GL_ARRAY_BUFFER, VBO);  
复制代码

从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
复制代码

glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。

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

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

三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。

VAO(顶点数组对象)

顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中

一个顶点数组对象会储存以下这些内容:

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

这里会比较难理解,大家可以多看看,多体会下,时间久了自然能够理解,不要急于求成,类似于一次设置,多次调用的这种形式。

链接顶点属性

我们已经把顶点全部塞进去了,但是着色器,怎么知道前面3个是顶点坐标,后面三个是颜色纹理坐标呢?所以我们需要告知管线顶点数据的解析方式

可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (GLvoid*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(1);
复制代码

glVertexAttribPointer函数的参数非常多,会逐一介绍下

  • 第一个参数指定我们要配置的顶点属性,后面介绍着色器代码会看到。
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在6个float之后,我们把步长设置为6 * sizeof(float)。)。
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset),所以在顶点坐标的偏移量为0,而纹理坐标的偏移量就是(GLvoid*)(3 * sizeof(GLfloat))了。

这样就告知了管线,如何解析该顶点数据,然后使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性即可,现在我们代码应该是这样了

GLfloat vertices[] = {
        // 顶点              //颜色
        0.5f, -0.5f, 0.0f,   1.0f, 0.0f, 0.0f,  
        -0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,  
        0.0f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f  
    };
 
    //创建顶点缓冲区对象
    GLuint VBO;
    glGenBuffers(1, &VBO);
 
    //创建顶点数组对象
    GLuint VAO;
    glGenVertexArrays(1, &VAO);
 
 
    // 1.t绑定顶点数据
    glBindVertexArray(VAO);
    // 2.绑定顶点缓冲区对象
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    //将顶点数据传入缓冲区对象中(&传入缓冲区数据的类型&数据量大小以字节为单位&传入的数据&显卡管理传入数据的方式)
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//
    // 3.告知管线顶点数据的解析方式
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
    //启用顶点属性数据0
    glEnableVertexAttribArray(0);
    //告知管线颜色数据的解析方式
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3 * sizeof(GLfloat)));
    glEnableVertexAttribArray(1);
    //启用属性1.
    //4. Unbind the VAO解除VAO绑定
    glBindVertexArray(0);
    glDisableVertexAttribArray(0);
​
复制代码

顶点着色器

我们需要做的第一件事是用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器,然后编译这个着色器,这样我们就可以在程序中使用它了。下面你会看到一个非常基础的GLSL顶点着色器的源代码:

#version 330 core
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 color;
out vec3 ourColor;
void main()
{
    gl_Position = vec4(position, 1.0f);
    ourColor = color;
};
复制代码

首先,第一行,声明版本,这里表示v3.3版本。

layout (location = 0)设定了输入变量的位置值(Location),正是上面的glVertexAttribPointer的第一个参数,表示顶点数据的坐标,layout (location =1)则是纹理坐标

in/out关键字表示输入,输出,这里会把ourColor输出,转换为下一个着色器的输入

片段着色器

const GLchar* fragmentShaderSource =
#version 330 core                   
    in vec3 ourColor;
    out vec4 color;                         
    void main()                             
    {                                   
    color = vec4(ourColor, 1.0f);   
    };
复制代码

这里则是,通过in,获取到顶点着色器,传递的ourColor,并最终通过vec4(ourColor, 1.0f),生成最终的纹理颜色,再通过out,将color传递到下一个流程。

连接着色器

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

unsigned int shaderProgram;
shaderProgram = glCreateProgram();
​
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
复制代码

得到的结果就是一个程序对象,我们可以调用glUseProgram函数,用刚创建的程序对象作为它的参数,以激活这个程序对象:

完整代码

#include <iostream>
#include <string>
// Glad
#define STB_IMAGE_IMPLEMENTATION
#include <glad/glad.h>
// GLFW
#include <GLFW/glfw3.h>
 
using namespace std;
 
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
 
 
const GLchar* vertexShaderSource =
"#version 330 core\n\
layout(location = 0) in vec3 position;\n\
layout(location = 1) in vec3 color;\n\
out vec3 ourColor;\n\
void main()\n\
{\n\
    gl_Position = vec4(position, 1.0f);\n\
    ourColor = color;\n\
}\n\0";
const GLchar* fragmentShaderSource =
"#version 330 core\n                        \
    in vec3 ourColor;\n\
    out vec4 color;\n                           \
    void main()\n                               \
    {\n                                         \
    color = vec4(ourColor, 1.0f);\n     \
    }\n\0";
 
int main()
{
    //GLFW
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
 
    //创建窗口
    GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", nullptr, nullptr);
    if (window == nullptr)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    //glad
    glfwMakeContextCurrent(window);
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return ;
/    }
 
    //顶点着色器
    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);
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
    //片元着色器
    GLuint fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
 
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::FRAGEMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
 
    //创建着色器程序
    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);
        std::cout << "ERROR::PROGRAM::LINK_FAILED\n" << infoLog << std::endl;
    }
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
 
    //事件响应
    glfwSetKeyCallback(window, key_callback);
 
 
    GLfloat vertices[] = {
        // 顶点              //颜色
        0.5f, -0.5f, 0.0f,   1.0f, 0.0f, 0.0f,  
        -0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,  
        0.0f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f  
    };
 
    //创建顶点缓冲区对象
    GLuint VBO;
    glGenBuffers(1, &VBO);
 
    //创建顶点数组对象
    GLuint VAO;
    glGenVertexArrays(1, &VAO);
 
 
    // 1.t绑定顶点数据
    glBindVertexArray(VAO);
    // 2.绑定顶点缓冲区对象
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    //将顶点数据传入缓冲区对象中(&传入缓冲区数据的类型&数据量大小以字节为单位&传入的数据&显卡管理传入数据的方式)
    /*
    *   GL_STATIC_DRAW: 这些数据基本上不会改变或者极少情况下会被改变。
        GL_DYNAMIC_DRAW: 这些数据可能会经常被改变。
        GL_STREAM_DRAW: 这些数据在每次绘制的时候都会被改变。
    */
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//
    // 3.告知管线顶点数据的解析方式
    /*
    该属性在着色器程序中的属性索引。location 0
    该属性的分量个数(1-4)
    分量的数据类型
    是否规格化
    stride两个连续该属性之间的间隔
    该属性第一个分量距离数据起点的偏移量
    */
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
    //启用顶点属性数据0
    glEnableVertexAttribArray(0);
    //告知管线颜色数据的解析方式
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3 * sizeof(GLfloat)));
    glEnableVertexAttribArray(1);//启用属性1.
    //4. Unbind the VAO解除VAO绑定
    glBindVertexArray(0);
    glDisableVertexAttribArray(0);
 
    while (!glfwWindowShouldClose(window))
    {
        //事件处理
        glfwPollEvents();
 
        //清屏背景
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
 
        //渲染程序对象
        glUseProgram(shaderProgram);
 
        glBindVertexArray(VAO);
        glDrawArrays(GL_TRIANGLES, 0, 3);
        glBindVertexArray(0);
 
        //交换前后缓存
        glfwSwapBuffers(window);
        glFlush();
    }
    glfwTerminate();
    cout << "Success!" << endl;
    system("pause");
    return 0;
}
 
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
{
    // When a user presses the escape key, we set the WindowShouldClose property to true, 
    // closing the application
    if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
        glfwSetWindowShouldClose(window, GL_TRUE);
}
复制代码

最终效果就是这样啦

1638347233160.png

总结

  • Opengl流程是通过管线的模式来处理数据的,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的
  • 使用VBO/VAO来管理顶点对象
  • Opengl使用顶点坐标和纹理坐标来最终填充渲染数据。
  • 了解GLSL着色器语言的基本规则
  • 初始化OpenGL程序,并编译、链接顶点着色和片段着色器,最终使用。

延伸

  • 变化顶点坐标,让三角形旋转90度
  • 动态改变纹理坐标color值,让三角形颜色一直在变换。

参考文章

着色器语言GLSL

LearnOpenGL-CN

猜你喜欢

转载自juejin.im/post/7036672636922888200