前言
這次講解的是GPU如何來渲染我們的圖像,了解GPU渲染管線以及相應的代碼編寫流程,並通過講解上一節的代碼來具體了解渲染的過程。
由於篇幅和個人知識有限,有些地方存在錯誤和不足,還望大家提出建議並指正。
目錄
一、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中使用着色器一般分為以下幾步:
- 創建着色器對象
- 源碼關聯到着色器對象
- 編譯着色器
- 創建一個程序對象
- 將着色器對象關聯到程序對象
三、代碼是怎么實現的
知道了如何叫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_MAJOR和GLFW_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;
