天天看點

OpenGL渲染入門

前言

在開始之前,先來看一段圖像解碼序列(格式為YUV420)的4個渲染結果,這裡我分别截了4張圖

OpenGL渲染入門

其中4個渲染效果分别是

左上:直接渲染視訊幀并繪制到視窗上

右上:九宮格縮放繪制幀緻視窗上

左下:對視訊幀進行2D變換并繪制到視窗上

右下:渲染視訊幀并繪制到3D變換立方體的6個面上

試着想一下,如果在CPU端進行圖像處理,比如用C/C++實作,包括上述4種效果會涉及到的格式轉換、2D/3D變換、立方體貼圖、無鋸齒縮放等操作,實作的複雜度和代碼量如何,會涉及哪些知識?

如果直接使用OpenGL,實作的複雜度和代碼量又該如何?

問題

  1. 何種場景下更适合使用OpenGL?
  2. OpenGL程式設計與CPU程式設計的差別?
  3. 如何快速入門編寫OpenGL程式?
看完此文,或許你會覺得原來渲染并沒有想像的那麼難!

從C/C++開始

考慮上面的例子,都需要将輸入圖像序列的YUV420格式轉成RGBA32位進行後期渲染和顯示,顔色轉換比如像下面這樣實作

/**
 * @param dst     輸出圖像位址RGBA
 * @param data    輸入圖像位址YUV420P
 * @param width   圖像寬度
 * @param height  圖像高度
 * @param coef    YUV轉RGB的顔色矩陣,支援BT601/709/2020
 */
void yuv420p_2_rgba(uint8_t* dst, uint8_t* data, int width, int height, float coef[9])
{
    for(int i = 0; i < (height >> 1); ++i)  // 一次處理2行
    {
        for(int j = 0; j < (width >> 1); ++j) // 一交處理2列
        {
            auto py = data + width * 2 * i + j * 2; // 擷取左上角Y位址
            auto u = data[width * height + i * (width >> 1) + j] - 128; // 擷取U值
            auto v = data[width * height * 5 / 4 + i * (width >> 1) + j] - 128; // 擷取V值
            // 奇數行奇數列
            for(int k = 0; k < 3; ++k)
            {
                dst[i * width * 8 + j * 8 + k] = clamp(coef[k*3] * py[0] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
            }
            // 奇數行偶數列
            for(int k = 0; k < 3; ++k)
            {
                dst[i * width * 8 + j * 8 + k + 4] = clamp(coef[k*3] * py[1] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
            }
            // Y位址下移一行
            py += width;
            // 偶數行奇數列
            for(int k = 0; k < 3; ++k)
            {
                dst[i * width * 8 + j * 8 + k + width * 4] = clamp(coef[k*3] * py[0] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
            }
            // 偶數行偶數列
            for(int k = 0; k < 3; ++k)
            {
                dst[i * width * 8 + j * 8 + k + width * 4 + 4] = clamp(coef[k*3] * py[1] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
            }
        }
    }
}           

上述C/C++代碼的實作是一個初級版本,如果希望更高性能的運作在CPU上,還需要進行類似彙編優化、多線程優化(如OpenMP)等,但即便這樣,對于解碼4K的圖像,運作在8核心超線程且主頻3.4GHz的CPU上,仍然無法滿足低延時計算的要求。

當C/C++實作的性能無法達到要求時,還可以采用彙編優化、多線程優化等方法,但複雜度會大大增加。

對于這一類計算密集型的工作,下面我們将看到更适合采用GPU進行處理,同時無須像CPU實作那樣需要關注過多的細節。

OpenGL實作

與CPU的計算過程類似,可以将OpenGL了解為一個子產品,我們通過設定給OpenGL子產品相應的參數,并拿到處理的結果。

  • 紋理

類似CPU上的記憶體,需要建立相應的GPU顯存用于存儲圖像資料,如上所舉例子,YUV420P存在三片記憶體,分别是Y、U、V,是以需要建立三張顯存

glGenTextures(3, texture);
    for (int i = 0; i < 3; ++i)
    {
        glBindTexture(GL_TEXTURE_2D, texture[i]);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, 0);
        ASSERT_GL();
    }           

上述代碼是建立三張顯存,分别是Y、U、V,同時指定紋理縮小、放大使用線性插值,WRAP采樣使用邊緣像素顔色值。

當需要将CPU的記憶體資料上傳到GPU顯存中時,隻需要将記憶體指針和相應的寬高格式等資訊傳遞給glTexImage2D接口就行,如下

for (int i = 0; i < 3; ++i)
    {
        glBindTexture(GL_TEXTURE_2D, texture[i]);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, addressof(buffer[planar_offset[i]]));
        ASSERT_GL();
    }           

這裡可以看兩次操作都需要使用glBindTexture接口,這是因為需要更新OpenGL内部TEXTURE_2D的目前綁定對象狀态。

CPU上我們可以直接逐像素甚至逐位元組的操作,但GPU不行,由于GPU内部是像素多線程并行處理,是以我們實際上是通過可程式設計一段可執行程式并發送給GPU處理的,可執行程式的生成分為兩步。

  • 頂點着色器

由于不是像CPU上逐像素操作那樣程式設計,我們隻需要将渲染的頂點邊界值告訴GPU,GPU内部會自動幫我們對邊界以内的像素位置進行計算

#version 330 core
        layout (location = 0) in vec2 aPos;
        layout (location = 1) in vec2 aTexCoord;

        out vec2 TexCoord;

        void main()
        {
            gl_Position = vec4(aPos, 0.0, 1.0);
            TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
        }           

上述代碼示例了一個頂點着色器的實作,雙稱vertex shader,顧名思義,即是對像素頂點進行操作,其中aPos和aTexCoord分别是外部輸入給GPU的頂點位置和坐标,main函數計算更新後的位置和坐标,并發送給片段着色器。

  • 片段着色器
#version 330 core
        out vec4 FragColor;

        in vec2 TexCoord;

        uniform sampler2D texY;
        uniform sampler2D texU;
        uniform sampler2D texV;

        uniform mat3 coef;

        void main()
        {
            float y = (texture(texY, TexCoord).r - 16.0/255.0) * (255.0/219.0);
            float u = (texture(texU, TexCoord).r - 0.5) * (255.0/224.0);
            float v = (texture(texV, TexCoord).r - 0.5) * (255.0/224.0);
            FragColor = vec4(coef * vec3(y, u, v), 1.0);
        }           

這裡可以看到,輸入給片段着色器的是TexCoord,即頂點着色器的輸出,這裡uniform表示可能随時會發生變化的變量,需要外部渲染前設定更新,main函數輸出計算後的像素顔色值。

  • 可執行程式

當完成了頂點着色器和片段着色器後,我們就可以将兩者編譯并連結成可實際在GPU上運作的可執行程式了。

GLuint GenerateProgram(char const* vertexShaderSource, char const* fragmentShaderSource)
{
    // 編譯頂點着色器
    int vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);

    int success;
    char 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;
    }

    // 編譯片段着色器
    int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);

    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }

    // 連結頂點着色器和片段着色器
    int 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::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
    }
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
    return shaderProgram;
}           

就像編譯C++代碼一樣,先分别編譯頂點和片段着色器,生成目标檔案,再将兩個目标檔案連結成可執行程式。

  • 頂點數組對象

    又稱為VAO(Vertex Array Object),當我們生成可執行程式并發送給GPU後,還需要将頂點和紋理坐标等資訊告訴Program,VAO使用如下方法生成

// *INDENT-OFF*
    float vertices[] =
    {
        // 頂點位置      // 紋理坐标
        -1.0f, -1.0f,   0.0f, 0.0f, // bottom left
         1.0f, -1.0f,   1.0f, 0.0f, // bottom right
        -1.0f,  1.0f,   0.0f, 1.0f, // top left
         1.0f,  1.0f,   1.0f, 1.0f, // top right
    };
    // *INDENT-ON*

    glGenVertexArrays(1, &VAO[kRenderNormal]);
    glGenBuffers(1, &VBO[kRenderNormal]);

    glBindVertexArray(VAO[kRenderNormal]);

    glBindBuffer(GL_ARRAY_BUFFER, VBO[kRenderNormal]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // 頂點位置屬性
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    // 紋理坐标屬性
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
    ASSERT_GL();           

先建立VAO,再綁定VAO到目前使用,再建立相應的VBO并綁定到VAO上,注意這裡的頂點位置歸一化範圍為【-1,1】,紋理坐标歸一化範圍為【0,1】,其中左下角為原點坐标【0,0】,這與通常了解的視窗坐标系統和圖像坐标系不同。

這裡可以将VAO了解成一個對象,我們通過調用OpenGL的接口分别更新這個對象的參數值,尤其是頂點位置和紋理坐标固定的情況下,我們隻需要從CPU到GPU上傳一次資料,減少性能開銷。

  • 視口

    渲染之前,還需要告訴OpenGL渲染到視窗的具體位置和區域

glViewport(0, 0, width, height);           
  • 渲染

當上述OpenGL資源都準備好後,我們就可以開始渲染了

glUseProgram(program[render_mode]);
    auto loc = glGetUniformLocation(program[kRenderNormal], "coef");
    glUniformMatrix3fv(loc, 1, GL_FALSE, yuva_to_rgba_709);
    ASSERT_GL();
    for (int i = 0; i < 3; ++i)
    {
        glActiveTexture(GL_TEXTURE0 + i);
        glBindTexture(GL_TEXTURE_2D, texture[i]);
        auto loc = glGetUniformLocation(program[kRenderNormal], texture_name[i]);
        glUniform1i(loc, i);
        ASSERT_GL();
    }

    glBindVertexArray(VAO[render_mode]);
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    ASSERT_GL();
    glBindVertexArray(0);           

如上代碼所示,先将CPU上計算好的值更新到GPU中的Program中,再采用相應的Draw接口,此時OpenGL就渲染完成。

需要注意的是,GL_TEXTURE_2D設定給Program,需要先Active相應的GL_TEXTUREi,再更新位置為索引glUniform1i。

更多渲染實作

  • 九宮格

    從一屏到九屏,使用OpenGL實作非常簡單,着色器可以複用,我們隻需要将CPU發送給GPU的頂點位置和紋理坐标改變一下即可,這裡我用了EBO實作作為例子,實際也可以使用上述的VBO。

// *INDENT-OFF*
    float vertices[] =
    {
        // 頂點位置      // 紋理坐标
        -1.0f, -1.0f,   0.0f, 3.0f, // bottom left
         1.0f, -1.0f,   3.0f, 3.0f, // bottom right
        -1.0f,  1.0f,   0.0f, 0.0f, // top left
         1.0f,  1.0f,   3.0f, 0.0f, // top right
    };
    // *INDENT-ON*

    unsigned char indices[] =
    {
        0, 1, 2, // first triangle
        1, 2, 3  // second triangle
    };

    glGenVertexArrays(1, &VAO[kRenderRepeat]);
    glGenBuffers(1, &VBO[kRenderRepeat]);
    glGenBuffers(1, &EBO[kRenderRepeat]);

    glBindVertexArray(VAO[kRenderRepeat]);

    glBindBuffer(GL_ARRAY_BUFFER, VBO[kRenderRepeat]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO[kRenderRepeat]);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 頂點位置屬性
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    // 紋理坐标屬性
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
    ASSERT_GL();           

如上代碼所未,最大的不同,即是直接将紋理坐标位置從【0,1】的範圍變成了【0,3】,在渲染時還需要更新紋理的采樣模式

for (int i = 0; i < 3; ++i)
    {
        glBindTexture(GL_TEXTURE_2D, texture[i]);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, addressof(buffer[planar_offset[i]]));
        ASSERT_GL();
        if (render_mode == kRenderRepeat)
        {
            glGenerateMipmap(GL_TEXTURE_2D);
        }
    }
        auto wrap_mode = render_mode == kRenderRepeat ? GL_REPEAT : GL_CLAMP_TO_EDGE;
        auto filter_mode = render_mode == kRenderRepeat ? GL_NEAREST_MIPMAP_LINEAR : GL_LINEAR;
        for (int i = 0; i < 3; ++i)
        {
            glBindTexture(GL_TEXTURE_2D, texture[i]);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter_mode);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap_mode);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap_mode);
            ASSERT_GL();
        }           

上述代碼修改後,由于使用EBO,是以需要采用索引繪制接口進行渲染

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, 0);           

如此就可以見到9屏的效果了,是不是so easy!

  • 立方體

試想一下,如果使用C++實作一個立方體渲染,是不是相當複雜,光是想想空間變換及紋理貼圖這些東西就比較頭疼,但是GPU實作就相當簡單了,我們隻需要多增加幾個三角形繪制

// *INDENT-OFF*
    float vertices[] =
    {
        -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,
         0.5f, -0.5f, -0.5f,  1.0f, 0.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,

        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
        -0.5f,  0.5f,  0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,

        -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  1.0f, 1.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,

        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f,  0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f
    };           

我們隻需要在2D渲染的基礎上,增加所有6個面的深度資訊

glDrawArrays(GL_TRIANGLES, 0, 36);           

即是簡單的從渲染2個三角形,到改成渲染12個三角形,就完成立方體渲染。

  • 2D變換

    要讓圖像動起來,我們隻需要将計算逐幀變化的模型、視圖矩陣更新給Program

static glm::mat4 model = glm::mat4(1.0f);
            static glm::mat4 view = glm::mat4(1.0f);
            if (!pause)
            {
                model = glm::scale(glm::mat4(1.0f), glm::vec3(sinf(glfwGetTime()), sinf(glfwGetTime()), 1.f));
                model = glm::scale(model, glm::vec3((float)window_height / window_width, 1.f, 1.f));
                model = glm::rotate(model, glm::radians(-45.0f * (float)glfwGetTime()), glm::vec3(0.0f, 0.0f, 1.0f));
                model = glm::scale(model, glm::vec3((float)window_width / window_height, 1.f, 1.f));
            }
            auto loc = glGetUniformLocation(program[render_mode], "model");
            glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(model));
            ASSERT_GL();
            loc = glGetUniformLocation(program[render_mode], "view");
            glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(view));
            ASSERT_GL();           
  • 3D變換

    三維變換稍微複雜些,需要考慮深度資訊,同時将計算好的變化的模型、視圖、投影矩陣更新給Program

glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)window_width / window_height, 0.1f, 100.0f);
    loc = glGetUniformLocation(program[kRenderCube], "projection");
    glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(projection));
    ASSERT_GL();           
static glm::mat4 view = glm::mat4(1.0f);
            float radius = 5.0f;
            float camX   = sin(glfwGetTime()) * radius;
            float camZ   = cos(glfwGetTime()) * radius;
            if (!pause)
            {
                view = glm::lookAt(glm::vec3(camX, 0.0f, camZ), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
            }
            auto loc = glGetUniformLocation(program[render_mode], "view");
            glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(view));
            ASSERT_GL();

            glm::mat4 model = glm::mat4(1.0f);
            float angle = 45.0f;
            model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 1.0f, 1.0f));
            loc = glGetUniformLocation(program[render_mode], "model");
            glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(model));
            ASSERT_GL();           

總結

本文以執行個體的例子和代碼講解OpenGL的入門概念和實作步驟,附件為實作的具體代碼,更複雜的知識還等待你去發現。