計算機圖形學(1)-如何一步步教你的顯卡畫畫?


前言

 

        這次講解的是GPU如何來渲染我們的圖像,了解GPU渲染管線以及相應的代碼編寫流程,並通過講解上一節的代碼來具體了解渲染的過程。
        由於篇幅和個人知識有限,有些地方存在錯誤和不足,還望大家提出建議並指正。

目錄

一、GPU是怎么畫畫的(GPU渲染管線流程)

        1. 渲染流水線

        2. 應用程序階段

        3. 幾何階段

         4. 光柵化階段

二、怎么告訴GPU畫什么,怎么畫

        1. 窗口和視口

        2. EBO、VBO和VAO

        3. 着色器

三、代碼是怎么實現的

        1. 初始化相關設置並創建窗口與視口

        2. 生成和綁定VBO和VAO

        3. 頂點着色器和片段着色器

        4. 渲染三角形

        5. 后續處理


一、GPU是怎么畫畫的(GPU渲染管線流程)

        1. 渲染流水線

        圖像的渲染過程可概括為如下三個步驟,渲染管線的作用主要是:在給定虛擬相機、三維物體、光源、照明模式,以及紋理等諸多條件下生成或繪制一張二維圖像

        眾所周知,CPU是一台計算機的核心,在計算機的圖像中,渲染的起點同樣是CPU而非GPU。渲染的時候,CPU會將需要繪制的數據信息發送給GPU,也就是告訴GPU:“嘿!老哥,幫我把這些東西畫在屏幕上。”這就是渲染的第一個階段,應用程序階段。
        在接收到CPU老大哥發來的畫畫命令和數據后,GPU就開始畫畫了。GPU拿到的數據是最原始的數據,沒有經過任何加工,所以在畫到屏幕之前,GPU要對數據進行一系列的處理。
        首先,原始數據中一般都是三維坐標和二維坐標的數據,GPU需要將這些數據處理成能在屏幕上顯示的屏幕坐標數據,也就是說GPU要處理這些圖形應該畫在屏幕的哪個位置。這個就是渲染管線中的幾何階段。
        然后知道了要在哪里畫畫了,接下來就是要開始上色了。根據幾何階段變換后的數據,計算出屏幕上每個像素的顏色值,然后輸出到屏幕。這就是光柵化階段。
        GPU完成的工作就是GPU的渲染流水線,也叫GPU渲染管線。接下來詳細介紹每一個階段的工作。

        2. 應用程序階段

        該階段是CPU將需要顯示在屏幕上顯示繪制出來的幾何體,也就是會制圖元,如點、線、矩形等輸入到渲染管線的下一階段(幾何階段)。數據包括圖元的頂點數據、攝像機位置、光照紋理等。

        3. 幾何階段

        幾何階段的功能是將頂點數據進行屏幕映射。其中包括:

  • 將各個圖元放入到世界坐標系中,即進行模型變換
  • 根據光照紋理等計算頂點處材質的着色效果
  • 根據攝像機的位置、取景范圍進行觀察變換和裁剪
  • 最后進行屏幕映射,即把三維模型轉換到屏幕坐標系中

        幾何階段的步驟如下圖所示

        其中,在頂點着色器中完成模型變換、視圖變換、頂點着色,在幾何、曲面細分着色器中完成頂點的增刪和曲面的細分,在裁剪步驟中完成投影變換和裁剪,最終進行屏幕坐標映射。 

         4. 光柵化階段

        光柵化階段的功能是給每個像素正確配色,以便繪制整幅圖形。由於輸入的是三角形的頂點,所以根據三角形的表面差異遍歷每個三角形,計算每個像素點的顏色值,再根據可見性等進行合並,得到最后的圖形

        其中,三角形設置是將映射到屏幕后的三角形頂點連接成三角形網格,然后通過三角形遍歷得到每個三角形對屏幕像素的覆蓋情況,接着通過片元着色器計算出每個三角形覆蓋的像素顏色值,最后的片元操作是對所有片元進行遮擋、透明、融合等處理,最后得到每個像素點的顏色值用於繪制。

二、怎么告訴GPU畫什么,怎么畫

        知道了GPU是怎么畫畫的,那如何讓GPU畫我們想要的東西呢?顯然,讓我們直接和GPU溝通是不實際的,我們需要設置一大堆的寄存器、顯存等,效率低且存在出錯的風險,且不通硬件的具體設置不盡相同。所以在圖形編程中,我們需要借助一些諸如OpenGL、DirectX之類的工具來代替我們跟GPU打交道。這些工具提供的圖形編程接口在不同的硬件上進行了抽象,提高編程效率的同時增加的兼容性。
        那么,如何使用這些工具來告訴GPU我們要畫什么呢?作者學習使用的是OpenGL。
        首先,了解過圖形渲染流程的我們知道,要繪制一個圖形,我們至少需要頂點位置數據以及相應的着色器來着色。並且要看到我們畫出來的東西,我們還需要一個窗口,並且設置視口大小,視口顧名思義就是用來觀看的一個窗口,超過這個范圍的東西我們是看不到的。通俗講就是現實中,在我們實現范圍內的東西我們可以看見,我們視線范圍外(比如腦袋后面)的東西我們是看不見的。那么我們該如何創建窗口,定義視口、頂點和着色器呢?
        接下來介紹OpenGL中使用的方式,並在下一節使用代碼實現。

        1. 窗口和視口

        窗口和視口是兩個概念。窗口就是windos中最常見的東西了,比如你現在正在看這篇文章使用的瀏覽器,它就是一個窗口,窗口就是用來顯示的,它可以移動、最大化、最小化,以像素為單位。而OpenGL中的視口是在窗口中可以用於繪圖的一塊區域,它可以大於、小於或等於窗口大小,一般我們將它的大小設置與窗口等大。在一個窗口中可以創建多個視口,比如不同視口用於顯示一個物體的三視圖。
        OpenGL中的視口坐標系是正規化的空間坐標系,也就是視口的X、Y、Z軸的范圍都是[-1.0f, 1.0f],即視口的最左邊為X軸的-1.0f,最右邊是X軸的1.0f,Y軸是最上邊是1.0f,下邊是-1.0f,而Z軸是垂直於屏幕的方向,用於表示深度。

        2. EBO、VBO和VAO

        創建好窗口並定義好視口后,就該創建頂點了,那么該怎么創建並管理我們的頂點呢?這里介紹一下EBO、VBO和VAO的概念。
        EBO(Element Buffer Object, 也叫IBO:Index Buffer Object)索引緩沖區對象,它用來儲存頂點的索引信息。那么它是用來干啥的呢?這邊舉一個例子,當我們要畫一個四邊形(即兩個三角形面片),我們需要繪制兩個三角形,需要六個頂點信息。如下圖左圖所示[V0,V1,V2]、[V3,V4,V5]為兩個需要繪制的三角形面片的頂點集合。顯然,V2和V3,以及V1和V4應該是兩個完全相同的點,但是在存儲時卻存在兩個不同的拷貝,這不免造成了空間的浪費,所以出現了右圖使用索引的方式來確定三角形面片的方式。這種方式下相同的頂點可以通過索引的方式重復使用,所以每個頂點在儲存時只需要儲存一次即可。如下圖右圖所示,綠色區域即是四個頂點的索引,通過索引的重復使用[0,1,2]、[1,2,3]來定義兩個三角形面片。
        由於索引信息大小遠遠小於頂點信息,所以頂點較多且重復使用較多的情況下,使用索引的方式能大大減少對儲存空間的占用。

        VBO(Vertex Buffer Object)頂點緩沖區對象。它主要用來儲存頂點的各種信息。使用VBO的好處是將模型的頂點數據存入VBO后,數據不再是由CPU讀取內存后送入GPU,而是GPU中直接從顯存中讀取,從而提高效率。
        VAO(Vertex Array Object)頂點數組對象。它主要作用是來管理VBO,它是一個保存了所有頂點數據屬性的狀態結合 ,儲存了頂點數據的格式以及頂點所需的VBO對象的引用。即它是一個VBO引用的集合。VAO的出現是因為VBO在每次繪制時需要綁定頂點信息,當數據量很大時,這種綁定將會很麻煩,而VAO是將多個VBO保存在一個VAO對象中,這樣每次繪制模型是要綁定VBO即可。
         這三者的簡單關系如下,由於篇幅問題,這里不探討中復雜的關系,要是有人想了解的話之后單獨寫一篇講解。(其實我還在學)

        3. 着色器

        有了頂點之后,接下來就是告訴GPU該怎么給我們要的模型上色了。這里就需要用到上面流程中頻繁出現的東西——着色器了,而使用着色器需要用到着色器語言,着色器語言編寫的代碼在程序編譯時不編譯,只是以文本的方式存在,傳到GPU后由顯卡驅動進行翻譯。由於我們使用的是OpenGL,所以我們使用OpenGL的着色器語言GLSL(OpenGL Shading Language)。GLSL和C語言的語法較為相似,具體將在之后學習過程再整理講解。
        在我們使用的OpenGL版本中,有以下四個着色器:

  • 頂點着色器(vertex shader)
  • 幾何着色器(geometry shader)
  • 片元着色器(fragment shader)
  • 曲面細分着色器(tessellation shader)

        由於着色器語言在CPU不編譯,所以OpenGL中使用着色器一般分為以下幾步:

  1. 創建着色器對象
  2. 源碼關聯到着色器對象
  3. 編譯着色器
  4. 創建一個程序對象
  5. 將着色器對象關聯到程序對象

三、代碼是怎么實現的

        知道了如何叫GPU畫畫,接下來就開始用代碼來實現吧。這里使用上一節的三角形繪制代碼進行講解。

        1. 初始化相關設置並創建窗口與視口

        該步驟分為四個部分:初始化GLFW、創建窗口、初始化GLAD、創建視口。

        首先初始化GLFW

    //初始化GLFW
    if (GLFW_FALSE == glfwInit())
        return -1;

    //主次版本號
    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);

        首先直接使用glfw自帶的初始化函數glfwInit()來初始化glfw。接着使用glfwWindowHint()函數來配置相應字段。
        首先是主次版本號GLFW_CONTEXT_VERSION_MAJORGLFW_CONTEXT_VERSION_MINOR這里使用的是3.3版本的OpenGL,所以主次版本都設置為3。接下兩句分別是設置使用核心模式以及不允許改變窗口大小。

        接下來創建窗口。

    //創建窗口
    GLFWwindow* window = glfwCreateWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "First", nullptr, nullptr);
    if (nullptr == window)
    {
        std::cout << "Failed create window" << std::endl;
        return -1;
    }
    //設置上下文
    glfwMakeContextCurrent(window);

        使用glfwCreateWindow()函數來創建窗口,設置其寬度、高度以及標題。后面兩個參數表示是否使用全屏模式和使用共享上下文窗口,這里不使用,直接賦值空指針即可。然后將窗口的上下文設置為當前進程的主上下文。上下文是指OpenGL的狀態,OpenGL其實是一個大的狀態機,我們設置完后表示使用當前的上下文,也就是當前的OpenGL狀態來渲染圖形。

        接下來初始化GLAD。

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

        由於GLAD是用來管理OpenGL的函數指針的,所以在調用任何OpenGL函數之前,必須初始化GLAD。這里要特別提醒一下,在包含glad和glfw頭文件時,glad一定要在glfw之前被include。

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

         最后是創建視口,調用gl函數進行創建。這里創建的大小和窗口大小一樣。

    //創建視口
    glViewport(0, 0, SCREEN_HEIGHT, SCREEN_HEIGHT);

        2. 生成和綁定VBO和VAO

        初始化完成后,就要開始設置頂點了,我們畫的是三角形,所以只需要設置3個頂點。然后進VBO和VAO的生成和綁定,這里沒有用到EBO是因為繪制的圖形非常簡單,而且沒有重復使用的頂點。

    //生成和綁定VAO、VBO
    //三角形頂點
    const float triangle[] = {
        -0.5f,  -0.5f,  0.0f,
        0.5f,   -0.5f,  0.0f,
        0.0f,   0.5f,   0.0f
    };

    //VAO
    GLuint vertex_array_object;
    glGenVertexArrays(1, &vertex_array_object);
    glBindVertexArray(vertex_array_object);
    //VBO
    GLuint vertext_buffer_object;
    glGenBuffers(1, &vertext_buffer_object);
    glBindBuffer(GL_ARRAY_BUFFER, vertext_buffer_object);

    glBufferData(GL_ARRAY_BUFFER, sizeof(triangle), triangle, GL_STATIC_DRAW);


    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    //解綁VAO、VBO
    glBindVertexArray(0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);

         VAO和VBO都需要先使用相應的glGen函數先生成,在用相應的glBind函數進行綁定。然后使用glBufferData()函數將頂點數據綁定至當前默認的緩沖中。
        將數據發送到GPU后,需要告訴GPU如何解釋這些頂點數據。使用glVertexAttribPointer()來告訴GPU如何來解釋頂點數據。
        這個函數的第一個參數表示頂點着色器的位置值,將在后面用到。
        第二個3表示頂點數據是一個3分量的向量。
        第三個參數表示頂點的類型,這里用GL_FLOAT。
        第四個參數表示是否希望我們的數據被標准化,也就是重映射成-1到1之間,這里設置為GL_FALSE,不需要標准化。
        第五個參數是步長,它表示連續頂點屬性之間的間隔,我們的下一個頂點數據是在3個float值之后,所以我們設置為3 * sizeof(float)。
        最后一個參數是數據的偏移量,我們的位置屬性是在數組的開頭,所以這里設置為0,並進行強制類型轉換。
        然后使用glEnableVertexAttribArray()函數來開啟這個通道。

        在屬性指針設置完后,將VAO和VBO進行解綁,原因是防止之后的綁定對當前的數據進行修改,同時代碼更加規范。

        3. 頂點着色器和片段着色器

        上一步完成后,數據已經在GPU上了,接下來就是創建着色器來處理數據。按照之前的步驟來創建着色器。

        首先是着色器代碼,使用GLSL編寫

    //着色器代碼
    const char* vertex_shader_source =
        "#version 330 core\n"
        "layout(location = 0) in vec3 aPos;\n"
        "void main()\n"
        "{\n"
        "   gl_Position = vec4(aPos, 1.0);\n"
        "}\n\0";

    const char* fragment_shader_source = 
        "#version 330 core\n"
        "out vec4 FragColor;\n"
        "void main()\n"
        "{\n"
        "   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);"
        "}\n\0";

         有了着色器源碼后,需要進行生成和編譯着色器。

    //編譯頂點着色器
    int vertex_shader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertex_shader, 1, &vertex_shader_source, NULL);
    glCompileShader(vertex_shader);

    int success;
    char info_log[512];

    glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertex_shader, 512, NULL, info_log);
        std::cout << info_log << std::endl;
    }

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

    glGetShaderiv(fragment_shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragment_shader, 512, NULL, info_log);
        std::cout << info_log << std::endl;
    }

         最后,將頂點着色器和片段着色器鏈接到着色器程序中,在將頂點着色器和片段着色器刪除,因為已經編譯連接到着色器程序中了,后續的渲染只需要用到着色器程序即可。

    int shader_program = glCreateProgram();
    glAttachShader(shader_program, vertex_shader);
    glAttachShader(shader_program, fragment_shader);
    glLinkProgram(shader_program);

    glGetShaderiv(fragment_shader, GL_LINK_STATUS, &success);
    if (!success)
    {
        std::cout << "error" << std::endl;
        return -1;
    }

    glDeleteShader(vertex_shader);
    glDeleteShader(fragment_shader);

        4. 渲染三角形

        接下來就是最激動人心的渲染時刻了。
        我們將渲染代碼寫在一個while循環中,只要窗口還沒有關閉,我們就一直渲染。

    //繪制三角形
    while (!glfwWindowShouldClose(window))
    {
        //清空顏色緩沖,使用黑色最為背景
        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        
        //使用着色器程序
        glUseProgram(shader_program);

        //繪制三角形
        glBindVertexArray(vertex_array_object);
        glDrawArrays(GL_TRIANGLES, 0, 3);
        glBindVertexArray(0);

        //交換緩沖區並檢測是否有觸發事件
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

         在渲染循環中,首先先用黑色清空屏幕,接着使用前一步編譯好的着色器程序。然后開始繪制我們的三角形,先綁定VAO,然后使用glDrawArrays()函數繪制三角形,繪制完成后將VAO進行解綁。最后交換緩沖區並檢測觸發事件。
        這里解釋下為什么要交換緩沖區,圖像渲染會有兩個緩沖區,繪制時操作的緩沖區並不是我們看到的屏幕所使用的緩沖區,當繪制在另一個緩沖區完成時,交換兩個緩沖區,將剛剛繪制的圖像直接一次性顯示出來,而下一次的繪制是在被交換下去的那個緩沖區,這樣做時防止繪制時產生的閃屏,這也叫做雙緩沖。

        5. 后續處理

        最后,當我們窗口關閉后,我們將我們之前創建的所有東西進行清理並退出程序。

    //刪除VAO,VBO
    glDeleteVertexArrays(1, &vertex_array_object);
    glDeleteBuffers(1, &vertext_buffer_object);
    //清理資源並正確退出
    glfwTerminate();

    return 0;


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM