OpenGL渲染管线之简单示例(五)

前言:这一章我们将对前面的所有知识进行实践,通过OpenGL编程,完成一个简单的OpenGL程序。你可以通过访问我的GitHub获取我学习的HelloOpenGL项目。这个项目需要的所有头文件及库均配置在项目中,下载后无需再额外配置环境,但由于作者并不想重复配置多个平台,请在Debug - Win32(x86)环境下编译。另外,作者把很多理解都写在了注释中!!!

项目地址:https://github.com/Jnnes/HelloOpengl

引用网址:https://learnopengl-cn.readthedocs.io/zh/latest/ 如遇到问题,请上该网址查找。

OpenGL编程前须知:在开始OpenGL编程之前,我们需要提前了解一些东西,包括以下几方面

  1.   OpenGL是一个大的状态机,我们可以把渲染的每个阶段都看成一个状态,而在每个状态的动作由各种属性控制,当前状态的动作执行完后会自动进入下一个状态,直到完成渲染,我们需要使用属性设置函数来设置属性,通过属性应用函数来应用属性。
  2. 在状态机中,我们把很多东西都当做对象来管理,这样当状态机中有多个相同类型的对象时,我们可以通过绑定某种类型对象和他实际存储数据发挥作用的对象,那么当状态机需要使用该类型时,则会自动去使用绑定该类型的对象。例如:纹理对象,缓冲对象等。所有的对象都继承自Object,对于这种使用对象来管理的数据,我们在使用之前都需要创建一个对象,然后将该类型对象绑定到新创建的这个对象实例上。
// OpenGL的状态
struct OpenGL_Context 
{
    ...
    object* object_Window_Target;
    ...     
};

// 创建对象,后面都通过该对象id找到OpenGL中的这个对象
GLuint objectId = 0;
glGenObject(1, &objectId);

// 绑定对象至上下文,指定GL_WINDOW_TARGET 类型的对象 存储在 objectId
glBindObject(GL_WINDOW_TARGET, objectId);

// 设置GL_WINDOW_TARGET对象的一些选项,也即对 objectId的对象进行操作 
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_WIDTH, 800);
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_HEIGHT, 600);

// 将上下文的GL_WINDOW_TARGET对象设回默认,解除GL_WINDOW_TARGET 与objectId的绑定
// 后面对GL_WINDOW_TARGET的操作将不会操作objectId
glBindObject(GL_WINDOW_TARGET, 0);

但是,有些特殊对象并不是使用上面的函数,例如纹理:

// 创建一个纹理对象,并保存其唯一对象id,OpenGL内所有的对象Id均不相同,不论什么类型。
GLuint texture;
glGenTextures(1, &texture);

// 绑定GL_TEXTURE_2D 的 对象ID为 texture
glBindTexture(GL_TEXTURE_2D, texture);

// 往GL_TEXTURE_2D 中写数据,因为绑定的GL_TEXTURE_2D类型的对象的ID是texture,
// 那么下面的数据会写入到ID为texture的对象上
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

// 绑定GL_TEXTURE_2D 的 对象ID为 0,这段后面的代码如果操作了GL_TEXTURE_2D,将是在id为0
// 的纹理对象上,而id为零的纹理对象是不存在的,所以这句代码实际意思是解绑定GL_TEXTURE_2D 对象ID
glBindTexture(GL_TEXTURE_2D, 0);

其他还有VAO、VBO、VFO等等,虽然创建、绑定、修改这种对象时使用的函数名各不相同,但是他们都有一个统一的流程,这个是OpenGL设计理念决定的。他们都遵循创建,绑定,修改三步走,除非主动修改绑定对象,否则OpenGL上下文中的该类型对象绑定的ID始终不变。


正式编程:

下面代码省略include头文件,详细请看项目源代码。

1. 创建窗口

创建窗口的详细过程可以参照LearnOpengl入门的创建窗口(点击进入),或者使用文章开头提供的项目。下面只对代码进行解释。

因为OpenGL只是一个标准,只提供接口定义,内部实现由各大硬件厂商完成,并且还可以跨平台,所以我们需要一个库,它能够帮我们找到不同平台上的OpenGL函数到底在硬盘的哪里,glew库实现了这个功能。

另外我们可以使用glfw库方便我们创建窗口。

void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);

int main()
{
    // 初始化glfw
    glfwInit(); 

    // 设置OpenGL大版本号为3,小版本号为3,也即3.3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    
    // 设置OpenGL使用Core模式,可使用可编程管线
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    // 禁用可变窗口大小
    glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
    
    // 创建一个窗口对象,宽度800像素,高度600像素,标题为"LearnOpenGL
    GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", nullptr, nullptr);
    if (window == nullptr)
    {    
        // 退出程序前,一定要关闭glfw
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    
    glfwSetKeyCallback(window, key_callback); // 设置按键回调
    glfwSetScrollCallback(window, scroll_callback); // 设置鼠标移动回调
    glfwSetCursorPosCallback(window, mouse_callback); // 设置鼠标滚轮回调

    // 设置窗口的显示上下文,也即下面OpenGL绘制的内容将存储在window这个窗口对象上
    glfwMakeContextCurrent(window);
    
    // 从GLFW中获取视口的维度而不设置为800*600
    // 是为了让它在高DPI的屏幕上(比如说Apple的视网膜显示屏)也能正常工作。
    // 因为高DPI的屏幕上显示图片上的一个像素点可能需要几个屏幕发光单元
    int width, height;
    glfwGetFramebufferSize(window, &width, &height);
    glViewport(0, 0, width, height);    
    
    
    // 初始化glew
    if (glewInit()) {
        Log::e(TAG, "Failed init glew");
        glfwTerminate();
        return -1;
    }
    else {
        Log::i(TAG, "Init glew success");
        
        // 获取当前OpenGL可支持最大纹理数目
        GLint nrAttributes;

        // 我们可以通过glGetxxx来获取OpenGL上下文中的属性,
        glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
        Log::i<std::string>(std::string(TAG), "max vertex attributes supportes:", std::to_string(nrAttributes));
    }
    
    // 我们需要一个主循环来连续绘制并刷新屏幕
    while(!glfwWindowShouldClose(window))
    {
        // 检查事件,包括按键、鼠标移动等等
        glfwPollEvents();

        // 因为数据是一个个像素绘制的,为了使一个屏幕的像素一起显示出来,OpenGL拥有双缓冲机制
        // 当某个缓冲绘制换成后,将其换到前台进行显示,另一个缓冲继续在后台绘制
        glfwSwapBuffers(window);
    }

    // 退出程序前,一定要关闭glfw
    glfwTerminate();
    return 0;
}

void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode) {    
    // 如果按下返回键,则将应该关闭窗口标志位置位,退出main中的主循环
    if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS) {
        glfwSetWindowShouldClose(window, GL_TRUE);
    } 
}

void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
    ;
}

void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
    ;
}

2. 缓冲对象

在创建着色器之前,我们需要定义顶点数据。

GLfloat vertices[] = { // 深度都为0,表现为在XY坐标系下的三个点
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

我们需要使用VBO(顶点缓冲对象)来存储顶点数据,我们还可以使用VAO(顶点数组对象)来绑定,这样当我们切换不同的VBO时只需要切换绑定不同的VAO就可以了,更好的优点是,我们不仅可以吧VBO绑定在VAO上,我们还可以把VEO(顶点索引数组)也绑定在VAO上,这样我们只要切换了VAO就同时切换了VBO和VEO。

VAO、VBO、EBO之间的关系

我们可以像创建其他对象一样创建VAO,VBO,EBO

GLfloat vertices1[] = {
    //     ---- 位置 ----       ---- 颜色 ----     - 纹理坐标 -
         0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   2.0f, 0.0f,   // 右上 // 手动在这里翻转Y轴纹理坐标
         0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,   2.0f, 2.0f,   // 右下
        -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 2.0f,   // 左下
        -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,   0.0f, 0.0f    // 左上
};    
GLuint indices1[] = {
    1,2,3,
    0,1,3,
};

// 创建并绑定顶点缓冲数组
GLuint VAO1;
glGenVertexArrays(1, &VAO1);
glBindVertexArray(VAO1);
    
// 创建、绑定 顶点索引缓冲对象,并设置数据
GLuint EBO1;
glGenBuffers(1, &EBO1);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO1);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices1), indices1, GL_STATIC_DRAW);
    
// 创建、绑定 顶点缓冲对象,并设置数据
GLuint VBO1;
glGenBuffers(1, &VBO1);
glBindBuffer(GL_ARRAY_BUFFER, VBO1);
// 从此刻起,我们使用的任何在GL_ARRAY_BUFFER目标上的缓冲调用都会用来配置当前VBO
// 然后我们可以调用GLBufferData函数,他会把之前定义的顶点数据复制到缓冲的内存中
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices1), vertices1, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (void *)0);
glEnableVertexAttribArray(0);
    
// 继续设置第2个顶点属性,对应的是着色器代码里的 layout(location = 1),和上图中VAO2的第二条黑线
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (void*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(1);
    
// 继续设置第2个顶点属性,对应的是着色器代码里的 layout(location = 2) 
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (void*)(6 * sizeof(GLfloat)));
glEnableVertexAttribArray(2);

// 取消之前顶点缓冲数组的绑定
glBindVertexArray(0);

// 下面绘制的过程最好放在主循环中循环执行,除非你只是想绘制一次就再不刷新
// 我们可以在需要使用VBO 和 EBO时直接使用之前已经绑定好的VAO
glBindVertexArray(VAO1);
// 然后调用glDrawElement,OpenGL就会自动从已绑定的EBO中获取顶点索引,再根据索引在绑定的VBO中获取顶点坐标进行绘制
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, (void*)0);

// 或者我们并不需要顶点索引,我们也可以通过其他方式绘制
// 直接绑定VBO,不使用EBO,然后调用glDrawArrays(), 就会从已绑定的VBO中按顺序获取坐标进行绘制
glBindBuffer(GL_ARRAY_BUFFER, VBO1);
glDrawArrays(GL_TRIANGLES, 0, 3);     

在glBindVertexArray(VAO1)和glBindVertexArray(0)之间的所有对VBO和EBO的操作都会被VAO记录下来,那么当我们再次调用glBindVertexArray(VAO1)时,OpenGL就知道我们需要使用在VAO1中设置好的VBO和VEO。

在使用glVertexAttribPointer() 时,我们要注意里面的参数,主要是位置偏移。具体的请查看相关手册。

3.着色器

要使用着色器,我们必须创建我们自己的着色器对象,然后我们将着色器源码放放入着色器对象,编译它们;我们还需要运行着色器的着色器程序对象,并且将着色器对象放入着色器程序对象中,并链接对个着色器对象,当我们使用某个着色器程序对象时,OpenGL就会自动对 待绘制的图形使用之前放入的着色器对象,它将按照我们再着色器源码中写的那样渲染出图形。

//  声明一个GLuint 来存储顶点着色器对象的ID
GLuint vertexShader;

// 创建一个着色器对象,类型是顶点着色器。片元着色器是 GL_FRAGEMENT_SHADER
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;
}else
    std::cout << "Compile VertexShader success" << std::endl;

/*  
   创建并编译片元着色器 
*/

// 创建着色器程序对象
GLuint shaderProgram;
shaderProgram = glCreateProgram();

// 为着色器程序对象附加着色器对象
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);

// 链接着色器
glLinkProgram(shaderProgram);

// 使用着色器,任何时候,只要我们需要,我们就可以调用这段代码
glUseProgram(shaderProgram);

 上面的代码只列出了顶点着色器,片元着色器的创建方法类似。下面的代码是顶点着色器和偏远着色器的源码。

// shader1.vert 顶点着色器
#version 330 core

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 color;
layout(location = 2) in vec2 texCoord;

out vec3 ourColor;
out vec2 TexCoord;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform mat4 trans;

void main(){
    gl_Position = projection* view*model * vec4(position, 1.0f);
    ourColor = color;
	TexCoord = texCoord;
}

// 片元着色器
#version 330 core

in vec3 ourColor;
in vec2 TexCoord;

out vec4 fragColor;

// GL_TEXTURE0 的纹理采样器ID,只要指定了GL_TEXTURE0,OpenGL会自己生成这个变量
uniform sampler2D ourTexture1;
uniform sampler2D ourTexture2;// GL_TEXTURE1 的纹理采样器ID,同上

uniform float angle;
uniform float opcity;

void main()
{
	float a = angle * 3.1415926 / 180;
	vec2 TexCoord2 = vec2(cos(a) * TexCoord.x - sin(a)*TexCoord.y, TexCoord.x * sin(a) + TexCoord.y * cos(a));
	
    // 混合两个颜色
    fragColor = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord2), opcity);
}

 主循环

 while (!glfwWindowShouldClose(window)) {
        GLfloat timeValue = (GLfloat)glfwGetTime();
        deltaTime = timeValue - lastFrame;
        lastFrame = timeValue;
        do_movement();
        glfwPollEvents();
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f); // 设置默认颜色
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //其他还有深度缓冲,模板缓冲

        glEnable(GL_DEPTH_TEST);
        
        shader1.use();
        glBindVertexArray(VAO1);
        glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, (void*)0);
        glBindVertexArray(0);

        glfwSwapBuffers(window);        
    }

4. 使用纹理

纹理的使用我们需要使用某些库来辅助我们读取图片数据,毕竟谁也不想与种类繁多的图片格式打交道。我们可以使用SOIL库或者stb_image等,但他们的作用都只是帮助你更方便使用图片。这里使用的是SOIL。

很多时候我们需要先禁用4字节对齐,改为1字节对齐,因为OpenGL默认使用4字节对齐,而很多图片他们并没有完整的4通道或者其他原因导致他的数据不是4的整数倍,如果这时OpenGL按照4的倍数来计算图片的尺寸的话往往会存在些偏差,这些偏差表现在显示上就是图片会倾斜。

// 禁用4字节对齐
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); 

// 使用SOIL库读取图片文件,并存储在image指针指向的区域
int widthImg, heightImg;
unsigned char* image = SOIL_load_image("container.png", &widthImg, &heightImg, 0, SOIL_LOAD_RGB);  

// 启动第1个纹理单元,后面对纹理的操作将在TEXTURE0上进行
// 我们可以重复下面的操作继续往第1,2,3...16个纹理单元上设置数据,
// 这样后面我们只需要切换当前使用的纹理既可以获取之前设置的纹理数据
glActiveTexture(GL_TEXTURE0);

//创建一个纹理对象
GLuint texture;
glGenTextures(1, &texture);

// 并将GL_TEXTURE_2D 绑定的对象指定为该纹理对象,后面对GL_TEXTURE_2D的操作都是在这个纹理ID上
glBindTexture(GL_TEXTURE_2D, texture);

//从图片数据中生成纹理,放到之前绑定的纹理对象中
//第二个参数时0表示只生成Mipmap中0级别的纹理,如果需要生成其他级别的修改0即可
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, widthImg, heightImg, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

// 删除之前从文件读取的图片数据,因为纹理数据已经使用了该图片数据
SOIL_free_image_data(image);

//使用之前的纹理对象创建Mipmap,生成所有级别的Mipmap
glGenerateMipmap(GL_TEXTURE_2D);

// 设置纹理边框
float borderColor[] = { 1.0f, 0.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

//环绕方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

// 过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

glBindTexture(GL_TEXTURE_2D, 0);//解绑

// 转换纹理的操作完成后再开启4字节对齐,也可以在读取。设置纹理数据结束后就执行该操作
glPixelStorei(GL_UNPACK_ALIGNMENT, 4); 


/* 我们可以在主循环中这样使用 */

// 启用着色器程序
shader1.use();   

// 使用第1个纹理单元
// 因为上面创建纹理时绑定了一次,但是修改完纹理后马上解绑了,所以这里必须重新绑定
glActiveTexture(GL_TEXTURE0); 
glBindTexture(GL_TEXTURE_2D, texture);
glUniform1i(glGetUniformLocation(shader1.Program, "ourTexture1"), 0);

// 使用第2个纹理单元
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture1);
glUniform1i(glGetUniformLocation(shader1.Program, "ourTexture2"), 1);

5. 坐标系变换及摄像机移动

之前的OpenGL渲染管线之坐标系 (二)已经叙述过OpenGL之间的坐标系,上一节的顶点着色器中已经置入了模型矩阵、观察矩阵、投影矩阵。下面我只会记录一些学习过程中的额外内容。

// 创建模型矩阵,这里只是将所有模型统一往屏幕内部旋转了timeValue * 5度
glm::mat4 modeltemp;
modeltemp = glm::rotate(model, glm::radians(timeValue * 5), glm::vec3(0.5f, 1.0f, 0.0f));

// 创建观察矩阵,由相机的位置,相机的视线聚焦处,以及相机向上的坐标轴即可确定摄像机坐标系
// 视线方向不与cameraUp垂直时,怎么办?
glm::mat4 view;
view = glm::lookAt(cameraPos, cameraFront + cameraPos, cameraUp);

// 创建透视投影矩阵,将相机坐标系中的顶点转换到裁剪空间(平面,二维)
glm::mat4 projection;
projection = glm::perspective(glm::radians(aspect), (GLfloat)screenWidth / screenHeight, 0.1f, 100.0f);

// 将MVP矩阵置入Shader
glUniformMatrix4fv(glGetUniformLocation(shader1.Program, "model"), 1, GL_FALSE, glm::value_ptr(modeltemp));
glUniformMatrix4fv(glGetUniformLocation(shader1.Program, "view"), 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(glGetUniformLocation(shader1.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection));

为了实现可控镜头,我们需要改变镜头的位置,视线方向,以及视角。这些由摄像机当前位置,摄像机视线聚焦位置,以及摄像机向上的坐标轴方向确定。

只改变摄像机位置和视点位置比较简单,这里不赘述。我们讨论如何改变摄像机角度,我们可以使用欧拉角中的俯仰角pitch、偏航角yaw以及桶滚角roll来描述摄像机当前的方向。这里我们使用另外一种方式,以摄像机向上仰视为例,当我们按下上键,摄像机慢慢向上仰视。

我们使用视线方向和摄像机头顶方向的轴,获取另一个坐标轴,将视点往Up轴方向移动一定距离,这样我们的Up轴将发生变化,我们再刷新Up轴,即可。注:我们可以对两个向量叉乘得到与这两条向量都垂直的向量,这样可方便根据两条坐标轴获取第三条坐标轴,计算得到的第三条坐标轴的方向遵循右手定则。

if (keys[GLFW_KEY_UP]) {
   glm::vec3 cameraRight = glm::normalize(glm::cross((cameraFront - cameraPos), cameraUp));
   cameraFront += cameraSpeed * cameraUp;
   cameraUp = glm::normalize(glm::cross(cameraRight, (cameraFront - cameraPos)));
}

另外我们也可以使用鼠标操作来完善整个摄像机变换的过程,文章开头的项目中已经完整实现。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值