该工程已经开源:

GitHub - Puluomiyuhun/PL_RealtimeRenderer: A renderer made by OpenGL.

引言

之前用C++写过软光栅和软光追,又用optix撸了一遍硬光追,那么是时候也来复现一下硬光栅了。硬光栅可能是这四个渲染器里开发得最舒服的一个(图形api已经把各种功能和模块封装得很完善了,不用像C++渲染器那样所有功能都必须手撸;而且不需要像optix光追那样分析各种数学公式以追求物理正确性)本篇我决定用opengl来复现一个硬光栅渲染器,由于我对opengl的掌握并没有到可以盲写的境界,所以我决定主要参考learnOpengl(LearnOpenGL CN (learnopengl-cn.github.io))的思路和代码,完成一个基础功能健全的realtime-tiny-renderer。

首先我们按照learnOpengl的配置方法,选择构建、编译glfw库,生成opengl3.3版本对应的glad库,并将它们链接到我们的opengl项目中。

Part1. 窗口

每个渲染器的第一步都是搭建管线,这是最复杂也是最枯燥的一步。在建立管线之前,我们先把窗口绑定好。建立一个main.cpp,写入如下代码:

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>

/*重构窗口大小*/
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    glViewport(0, 0, width, height);
}
/*键盘输入响应函数*/
void processInput(GLFWwindow* window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}
int main()
{
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);                                //主版本:3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);                                //次版本:3
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);                //设置为核心模式
    GLFWwindow* window = glfwCreateWindow(800, 600, "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);     //绑定窗口大小改变时调用的函数

    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))               //初始化glad
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }

    while (!glfwWindowShouldClose(window))     //开始渲染循环
    {
        processInput(window);                  //自定义的检测键盘输入函数

        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glfwSwapBuffers(window);               //双缓冲,交换前后buffer
        glfwPollEvents();                      //检查事件队列中是否有事件传达
    }
    glfwTerminate();                           //结束线程,释放资源
    return 0;
}

非常简单基础的窗口初始化,流程是初始化glfw-初始化glad-开启窗口-设置回调-渲染循环-结束程序。基本没有太多要解释的内容。

Part2. 管线

接下来我们开始定义着色器、搭建管线来绘制三角形,用的还是VAO、VBO、EBO那套东西。

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>

/*每个顶点包含坐标(float*3)、颜色(float*3)*/
float vertices1[] = {
    -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,
     -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
};
float vertices2[] = {
    0.6f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
    1.0f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
    0.8f,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f,
    1.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
};
unsigned int indices1[] = {
    0, 1, 2,
    0, 2, 3,
}; 
unsigned int indices2[] = {
    0, 1, 2,
    1, 2, 3,
};
const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"layout (location = 1) in vec3 aCol;\n"
"out vec4 color;\n"
"void main()\n"
"{\n"
"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"   color = vec4(aCol,0);\n"
"}\0";

const char* fragmentShaderSource = "#version 330 core\n"
"in vec4 color;\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = color;\n"
"}\0";

unsigned int VAO[2]; 
unsigned int VBO[2]; 
unsigned int EBO[2];

unsigned int shaderProgram;

void bingdingShader() {
    /*创建顶点着色器*/
    unsigned int vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);
    /*创建片元着色器*/
    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    /*创建管线*/
    shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
    /*绑定VAO、VBO*/
    glGenVertexArrays(2, VAO);               //绑定N个顶点组,并为其绑定了N个数组对象id号存入VAO
    glGenBuffers(2, VBO);                    //生成N个缓冲区,并为其绑定了N个缓冲区对象id号存入VBO
    glGenBuffers(2, EBO);                    //生成N个索引组,并为其绑定了N个索引组对象id号存入EBO

    glBindVertexArray(VAO[0]);               //这里绑定0号VAO,表示准备对0号VBO进行属性拆解
    glBindBuffer(GL_ARRAY_BUFFER, VBO[0]);   //将生成的缓冲区绑定到GL_ARRAY_BUFFER目标上
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices1), vertices1, GL_STATIC_DRAW);    //将顶点信息写入GL_ARRAY_BUFFER目标
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO[0]);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices1), indices1, GL_STATIC_DRAW);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);   //设置VAO的0号属性指针,并定义该属性占用字节数、偏移处
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));   //设置VAO的1号属性指针,并定义该属性占用字节数、偏移处
    glEnableVertexAttribArray(0);            //将0号顶点属性激活为参数
    glEnableVertexAttribArray(1);

    glBindVertexArray(VAO[1]);
    glBindBuffer(GL_ARRAY_BUFFER, VBO[1]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices2), vertices2, GL_STATIC_DRAW);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO[1]);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices2), indices2, GL_STATIC_DRAW);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);   //设置VAO的0号属性指针,并定义该属性占用字节数、偏移处
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));   //设置VAO的1号属性指针,并定义该属性占用字节数、偏移处
    glEnableVertexAttribArray(0);            //将0号顶点属性激活为参数
    glEnableVertexAttribArray(1);
}

/*重构窗口大小*/
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    glViewport(0, 0, width, height);
}
/*键盘输入响应函数*/
void processInput(GLFWwindow* window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}
int main()
{
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);                                //主版本:3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);                                //次版本:3
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);                //设置为核心模式
    GLFWwindow* window = glfwCreateWindow(800, 600, "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);     //绑定窗口大小改变时调用的函数

    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))               //初始化glad
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }

    bingdingShader();

    while (!glfwWindowShouldClose(window))     //开始渲染循环
    {
        processInput(window);                  //自定义的检测键盘输入函数

        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glUseProgram(shaderProgram);
        glBindVertexArray(VAO[0]);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
        glBindVertexArray(VAO[1]);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glfwSwapBuffers(window);               //双缓冲,交换前后buffer
        glfwPollEvents();                      //检查事件队列中是否有事件传达
    }
    glDeleteVertexArrays(2, VAO);
    glDeleteBuffers(2, VBO);
    glDeleteProgram(shaderProgram);
    glfwTerminate();                           //结束线程,释放资源
    return 0;
}

该标的注释我都标了。简单说就是,我们创造了两个矩形,分别将其坐标存在两个vertices中。这里我先不解释着色器,先说下VAO、VBO、EBO。

VBO就是顶点的数据缓冲区,里面可以按序存放每个顶点的所有属性(例如坐标、法线、顶点色、uv等等),我们直接从vertices中将每个顶点的位置信息载入到VBO绑定的缓冲区即可。然而VBO并不知道vertices中顶点有几个属性、的每个属性占几个字节,这样传入shader的时候,shader也不知道该如何分解这些属性,那怎么办呢?

这里就需要VAO出马了。VAO一共存了16个顶点属性指针,每个指针对应1个顶点属性。我们调用glVertexAttribPointer就可以告诉VAO中K号指针 所对应的K号属性 处于每个顶点信息中的偏移位置,以及该属性占几个字节,这样就成功地把VBO中每个顶点的一坨数据 分解成各个有意义的属性了。如下图:

如图所示,VBO就是把所有顶点的所有信息依次排列在了一起,而VAO的每个指针指向每个顶点的一个属性信息。图中VBO1中只有一个属性:坐标,那么VAO1只需要绑定一个0号属性指针,通过glVertexAttribPointer接口定义该属性(即坐标)需要3个float来描述,偏移是0;VBO2中有两个属性:坐标和顶点色,那么VAO2就需要绑定一个0号、一个1号属性指针,然后定义坐标属性和颜色属性的偏移位置和占用字节数。

所以简而言之就是,VBO就是顶点若干个属性的堆积,而VAO内定义了若干个指针,分别指向VBO顶点数据中的各个属性,当我们要取第N个顶点的第M个属性时,VAO可以根据我们定义的M号属性规则,直接将第M号指针指向对应位置,取出该顶点的对应属性。所以我们说VBO就是源数据,而VAO就是将源数据拆解成不同的属性数据。

还有一个东西是EBO,这个就是三角形索引,描述每个三角形由VBO中的哪几号顶点构成,这个东西用了无数回了就不赘述了。(最后还要提一点,当我们用glBindVertexArray(VAO[0])绑定VAO[0]后,后面处理的VBO[0]和EBO[0]会直接和当前的VAO[0]互相绑死,所以在渲染循环中,我们只给出VAO[0],opengl就知道要选用VBO[0]和EBO[0]作为配套数据了。)

对于shader,顶点着色器和片元着色器在我最早的c++软光栅渲染器中详细讲过了,不再赘述定义,这里我们的顶点着色器只收入一个0号属性,就是我们每个顶点的position属性,我们将它定义成aPos,并交给顶点着色器进行坐标定位,同时将传入的颜色信息输出给片元着色器;片元着色器接收到颜色信息后直接着色。

最后得到如下效果: