OplenGL的功能是什么?這里文中給出了介紹:In OpenGL everything is in 3D space, but the screen and window are a 2D array of pixels so a large part of OpenGL's work is about transforming all 3D coordinates to 2D pixels that fit on your screen。簡單來說,就是把OpenGL中的3D坐標轉換為我們屏幕對應的2D像素。這是由OpenGL的渲染管線實現的,它可以被分為兩大部分:第一部分是把3D坐標轉換為2D坐標,第二部分是把2D坐標轉換為帶有顏色的像素
注意:2D坐標和像素是不同的,2D坐標表示一個點在空間中的位置,而像素則是這個點的近似值,它受到你屏幕或者窗口的分辨率的限制。
那么問題來了,什么是shader呢?前文說過,OplenGL會接收一組3D坐標並把它轉換為2D坐標,這個過程是在渲染管線(graphics pipeline)中實現的。其實渲染管線(graphics pipeline)可以看做一個流水線,它是由許多步驟組成的,但是每個步驟都要用到前一個步驟生成的數據。而這些步驟又是高度專門化的,並且非常容易並執行,正是因為這個特性,當今我們顯卡上有成千上萬的小處理核心,它們在GPU上每一個階段都在運行着自己的小程序,這樣能使它在圖形渲染管線中快速處理我們的數據。而這個小程序就是我們所說的shader。
但是並非渲染管線中所有的shder我們都可以去編輯,有些是默認的不可被更改的,我們可以自己配置的只有部分shader。並且這些shader都是在GPU上面運行的,這樣就可以幫我們節省寶貴的CPU的時間。

這個圖片抽象的表示了我們渲染管線需要經過的步驟。其中背景為藍色的階段表示我們可以自己編輯shader的部分。
因為這里講的是如何繪制一個三角形,那么我們就用三角形來簡略的說明渲染管線中每個階段所進行的操作。
首先,我們以數組的形式傳入3個3D坐標作為輸入,這三個點可以用來表示一個三角形,這個數組就被稱為頂點數據(Vertex Data),頂點數據是一系列頂點的集合,一個頂點就是一個3D數據坐標的集合。而頂點數據是由頂點屬性來表示的,它可以包含任何我們想用的數據(但是為了簡單起見,我們可以理解為每一個定點數據由一個3D位置和一個顏色值組成)。
為了讓OpenGL知道我們的坐標和顏色的值到底是什么,OpenGL需要我們去指定我們這些數據所要渲染的類型。比如我們要把它渲染成一系列的點,還是一個三角形,還是一條直線?而做出這些提示的就是圖元(Primitive),任何一個繪制指令的調用都會把圖元傳遞給OpenGL,下面是提示中的幾個:GL_POINTS,GL_TRIANGLES,GL_LINE_STRIP.
圖形渲染管線的第一部分就是頂點着色器(Vertex shader),它把一個單獨的點作為輸入。它主要的功能就是把3D坐標轉換為另一種3D坐標(這個將在后面提到),同時頂點着色器允許我們對頂點屬性做一些基本的處理。比如卡通渲染里面將角色的頂點膨脹一點點,就是角色外面的黑色描邊。
圖元裝配(Shape Assembly)階段將頂點着色器的輸出作為輸入,並將所有的點組裝成指定圖元的形狀(在這個例子中我們繪制的是一個三角形)。
圖元裝配階段傳出的數據會被傳給幾何着色器(Geometry Shader),它可以通過產生新的頂點來構造出新的圖元l來生成其它形狀(在本例中它生成了另外一個三角形)。也就是說通過shader程序可以指定幾何着色器對頂點信息進行刪減。利用幾何着色器可以自由的生成多邊形,但是!但是!幾何着色器並沒有它描述的那么好,它的實際效益可能並不高,甚至是非常低。所以一般來說是不會去寫到的。
幾何着色器的輸出會被傳入到光柵化階段(Rasterization Stage),這里它會把圖元映射為屏幕上的最終的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment),在片段着色器之前會進行裁切(Clipping),裁切會丟棄掉你的視圖外的所有像素,以此來提高效率
OpenGL中的一個片段(Fragment)是OpenGL渲染一個像素所需要的所有數據。
片段着色器(Fragment Shader)的主要作用是計算一個像素的最終顏色,這里也是OpenGL產生所有高級特效的地方,通常Fragment shader包含3D場景的數據(比如光照,陰影和光的顏色等等)。這些數據被用來計算像素的最終顏色。
在所有的顏色都被確定之后,對象會被傳入到最后一個階段。我們通常把它成為Alpha測試和混合(Blending)階段,這個階段用來檢測片段對應的深度值,用它來判斷這個像素是在其它像素的前面還是后面,決定這個像素時候應該被丟棄。這個階段也會檢測Alpha值(Alpha值表示了一個物體的透明度),並對物體進行混合。因此,即使在Fragment shader 中計算出來了每個像素的顏色,在渲染多個三角形的時候它們的顏色也有可能會不一樣。
由此就可以看出來,渲染管線(Graphics pipeline)非常復雜,它有很多可以配置的部分。但是在大多數場合,我們只需要配置vertex shader和Fragment shader就可以了,這兩個也是我們必須配置的,因為GPU中沒有默認的vertex shader和Fragment shader。而Geometry shader在大部分情況下我們使用的是GPU默認的shader。
以上,就是對渲染管線(Graphics pipeline)每個階段大致的介紹,總體來說還是比較清晰的。如果沒有看明白的話可以參考https://blog.csdn.net/FancyVin/article/details/68062798這篇文章,是一位大佬翻譯的一位日本作家西川善思的3D圖形的概念和渲染管線,里面是關於Direct X渲染管線的介紹,每個過程都介紹的特別清楚。雖然不是OpenGL,但是它們渲染物體的步驟大同小異,可以幫助我們理解。
對graphics pipeline每個階段有了大致的了解之后我們便可以開始着手渲染自己的三角形(我還是比較推薦大家看英文原版的內容,雖然直接搜索也有翻譯過的OpenGL網站,但是建議還是以英文為主翻譯為輔進行學習。)
Vertex Input
在開始介紹頂點輸入(vertex input)之前,先做一個有趣的實驗。如果大家電腦上有blender的話,可以新建一個三角形然后以obj格式導出,用文本打開這個obj文件,大家會看到這樣的數據:

這里可以看到有四個坐標,其中第五行,第六行,第七行的三個坐標就是三角形三個頂點的坐標。有點不同的是,blender在導出文件的時候對坐標軸進行了替換,在blender視圖里面的z軸在導出后就變成了y軸。因為我們創建的三角形是一個平面,它的z軸的坐標理應為0。但是我們在上圖之中可以看到三個坐標中的y坐標都為0,這就是blender在導出后對坐標軸進行的替換,不過並沒有什么影響。然后第八行,就是我們每個坐標對應的法向量。為什么三個坐標會對應一個法向量呢?看第11行 1//1 2//1 3//1的意思就是 第一個坐標對應的法向量為第一個法向量,第二個坐標對應的法向量為第二個法向量,第三個同理。如果我們在blender中創建的圖形是個多面圖形的話,導出來的obj文件在打開后vn的坐標就不止一個,此時坐標與各個法向量之間的關系就會改變。
現在,我們可以開始介紹頂點輸入(vertex input)了。在開始繪圖之前,我們必須給OpenGL來輸入一些定點數據,之前也說過,OpenGL是一個3D的圖形庫,所以我們提供給OpenGL的坐標都是3D坐標。但是OpenGL並不是簡單就把所有的3D坐標轉換為屏幕2D的像素。只有當3D坐標中x,y,z的值在-1.0到1.0之間時,OpenGL才能夠處理它。這樣的坐標被稱為標准化設備坐標(nomalized device coordinates)。只有在標准化設備坐標(normalized device coordinates)范圍內的坐標才能最終展現在屏幕上。這里為了簡單起見,我們提供的三角形的坐標就是標准化設備坐標(normalized device coordinates),它是一個float類型的數組:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
因為OpenGL是在3D中工作的,而這里我們要渲染一個2D的三角形,所以可以把z坐標置零,這樣就能使得三角形每個坐標的深度都是一樣的從而使它看上去像是2D的。
當然,在實際的項目當中並不可能每個3D坐標都是標准化設備坐標(normalized device coordinates)。而OpenGL會把落在標准化設備坐標(nomalized device coordinates)之外的坐標全都剔除掉,它們並不會顯示在你的屏幕上。關於將坐標轉換為標准化設備坐標(normalized device coordinates),這在之后會學到。至於我們傳入的標准化設備坐標(normalized device coordinates)它會轉化為屏幕空間坐標,這個是通過我們glViewPort提供的數據進行視口變換完成的。所得的屏幕坐標會被變換為片段輸入到Fargment shader當中。
那么vertex data究竟是經過怎么樣的處理才能輸入到vertex shader呢?拿我們之前從blender之中導出的三角形的obj文件舉例子,obj文件在經過一系列的序列化后生成一個vertex的數組,然后CPU將這個數組傳送給GPU。GPU接收到CPU的傳送過來的vertex數組之后怎么辦呢?先存起來吧,這個時候GPU就會通過頂點緩沖對象(vertex buffer objects)即VBO來管理這個內存,它會在GPU的內存中(通常稱為顯存)儲存大量的頂點。而使用VBO的好處就是我們可以一次發送大量的定點數據到GPU中,因為從CPU到GPU的數據傳輸是非常慢的,所以這樣做能夠極大的提高效率。同時當數據發送到顯卡中的內存(GPU)之后,vertex shader幾乎能夠立即訪問頂點,並且這是個非常快的過程。
現在VBO將是我們接觸到的第一個OpenGL的對象,就像OpenGL中其它的對象一樣,VBO也擁有一個獨一無二的ID。而我們可以通過glGenBuffers這個函數和一個緩沖ID來生成一個VBO對象,代碼如下:
unsigned int VBO; glGenBuffers(1, &VBO);
當然,我們可以生成不止一個VBO對象,只需要對代碼稍微進行改動就行了:
unsigned int VBO[n]; glGenBuffers(n, VBO); //n為需要生成的VBO的數量
OpenGL中有很多不同種類的緩沖對象,其中VBO對應的緩沖類型為GL_ARRAY_BUFFER。且OpenGL允許我們同時綁定多個緩沖,只要這些緩沖是不同類型的。接着,我們就可以使用glBindBuffer方法把新生成的VBO綁定到GL_ARRAY_BUFFER目標上,代碼如下:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
在綁定之后,我們使用的任何緩沖調用(在GL_ARRAY_BUFFER目標上的)都會被配置到當前綁定的緩沖對象(VBO)中。接下來我們就可以調用glBufferData函數將之前CPU傳過來的vertex data復制到緩沖內存中。代碼如下:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一個專門用來把用戶傳入的數據復制到當前綁定緩沖的函數,它的第一個參數是目標緩沖類型,第二個參數指明我們想要復制到緩沖的數據的大小(以字節為單位),這里使用sizeof()直接求出數據大小即可。第三個參數是我們要傳入的數據,第四個參數是我們告訴顯卡希望它如何處理我們所給的數據,這里有三種類型:
GL_STATIC_DRAW:指我們傳入的數據基本上不變或者很少改變。
GL_DYNAMIC_DRAW:數據經常改變
GL_STREAM_DRAW:數據在每次繪制的時候都會改變
因為我們所繪制的三角形的頂點是不會改變的,所以這里用GL_STATIC_DRAW。但是,當我們知道我們傳入的定點數據需要經常變化的時候,我們需要使用GL_DYNAMIC_DRAW或者GL_STREAM_DRAW。它們可以確保顯卡能夠把數據寫入到能夠高速讀取的內存中。
現在我們把頂點數據存儲在了顯卡的顯存之中,它由我們的VBO進行管理。那么,我們可以直接把這個東西拿給vertex shader使用嗎?當然不行,它現在只是一堆定點數據,我們的GPU並不知道這些數據中哪些部分都是什么東西。比如我們的數據中有角色的UV,有3D坐標等等,GPU需要我們告訴它,每個部分的數據都代表着什么。這就需要我們給GPU提供一張表,就像之前打開的三角形的obj文件那樣,標明哪些頂點是坐標,哪些是法線等等。此時就需要使用到VAO(Vertex Array objects)也就是頂點數組對象。
VAO可以像其他緩沖對象那樣被綁定,而且隨后的頂點屬性配置都會被存儲在VAO中。這樣做的優點是,當配置頂點屬性指針的時候,我們只需要將那些調用執行一次,之后綁定相應的VAO就行了。什么是頂點屬性指針呢?這在之后會提到。因此當我們在不同的頂點數據和屬性配置之間切換時,我們只需要綁定不同的VAO就行了。剛剛設置的狀態都將被存儲在VAO中。
OPENGL的核心功能要求我們使用VAO,但是當我們沒有綁定VAO時,OPENGL將拒絕繪制任何東西。
一個VAO可以存儲下列東西:
glEnableVertexAttribArray或者glDisableVertexAttribArray的調用(這兩個函數將在后面介紹)
通過glVertexAttribPointer設置的頂點屬性
通過glVertexAttribPointer來調用與定點屬性相關聯的VBO
每個VAO都有一個頂點屬性列表,表中一共有15個頂點屬性,他們存儲着每個屬性在VBO中的位置,如下圖所示:

VAO的綁定與VBO非常相似,代碼如下:注意VAO也可以和VBO做一樣的操作來制造多個VAO。
unsigned int VAO; glGenVertexArrays(1, &VAO);
為了使用VAO,我們需要使用glBindVertexArray方法來綁定VAO。代碼如下:
glBindVertexArray(VAO);
之前我們已經使用glBindBuffer方法將VBO與GL_ARRAY_BUFFER綁定了起來。其實這個順序是錯的,我們應該在綁定完VAO之后再去使用glBindBuffer方法綁定VBO,這樣VAO和VBO之間才能關聯起來。正確的順序應該是下面這樣的:
unsigned int VAO; glGenVertexArrays(1, &VAO); glBindVertexArray(VAO); unsigned int VBO; glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
Vertex shader
這里給我們提供了一個Vertext shader,我們只需要直接使用就行了,至於每個shader如何編寫,這在下面一節將會介紹。現在,我們只需要這樣寫入我們的程序當中:
const char* vertexShaderSource =
"#version 330 core \n"
"layout (location = 0) in vec3 aPos; \n"
"void main(){ \n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);} \n";
現在我們有了vertex shader,那么如何編譯它呢?首先,我們需要創建一個shader對象,注意也是用ID來引用的。我們使用unsigned int來儲存shader並且用glCreatShader方法來創建一個shader,代碼如下:
unsigned int vertexShader; vertexShader = glCreateShader(GL_VERTEX_SHADER);
我們需要給glCreatShader方法傳入我們需要創建shader的類型,這里為GL_VERTEX_SHADER。接着我們需要把shader的源碼綁定到shader對象中並且編譯shader,代碼如下:
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL); glCompileShader(vertexShader);
glShaderSource把要編譯的shader對象當作第一個參數,第二個參數為我們源碼的字符串數量,這里是1。第三個為我們對應的shader的源碼,第四個參數為NULL。
如果想知道自己的shader編譯是否成功,可以參考https://learnopengl.com/Getting-started/Hello-Triangle的糾錯,這里不做說明。
Fragment Shader
fragment shader是我們第二個也是最后一個我們打算用於渲染三角形的shader,fragment shader用於計算我們輸出像素的顏色。為了簡單起見,這里提供的shader只會渲染一個橙色的三角形。
計算機中圖形的顏色被分為有四個元素的數組,這4個值分別為:red green blue 和 alpha。通常縮寫為RGBA,當在OpenGL或者GLSL中定義顏色的時候,我們把顏色的每個分量設置為0.0和1.0之間。
接下來可以將下述代碼加入到我們的程序中:
const char* fragmentShaderSource =
"#version 330 core \n "
"out vec4 FragColor; \n "
"void main(){ \n "
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);} \n ";
關於fragment shader的介紹同樣留在之后,這里我們只是使用這個shader來渲染出我們的三角形。
編譯fragment shader的步驟於vertex shader相似,但是這次我們使用GL_FRAGMENT_SHADER作為shader的類型,代碼如下所示:
unsigned int fragmentShader; fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL); glCompileShader(fragmentShader);
現在兩個shader都被編譯完畢了,之后我們就需要把兩個shaderd對象連接到一個用於渲染的shader program
Shader Program
shader Program對象是多個shader鏈接之后的最終版本,如果要使用剛剛編譯的shader對象的話,我們需要把他們鏈接到一個shader program對象中,並且在渲染的時候激活這個shader program。已經被激活的shader program將會在我們發送渲染調用的時候被使用。
當我們把多個shaer鏈接到一個program的時候,上一個shader的輸出會作為下一個shader的輸入,當輸入和輸出不匹配的時候,你將會得到一個鏈接錯誤。
創建一個shader program很簡單,代碼如下:
unsigned int shaderProgram; shaderProgram = glCreateProgram();
glCreatProgram方法會創建一個shader program並返回一個新創建program的ID的引用。現在我們要把之前編譯的shader附加到program對象上並用glLinkProgram鏈接它們。代碼如下:
glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram);
這些操作當然也可以校驗是否成功,這些內容在剛剛提供的鏈接里面也有,有興趣的話可以看一下。
現在我們得到了一個program對象,然后我們可以調用glUseProgram函數,並用得到的program對象作為它的參數,這樣就可激活這個對象,代碼如下:
glUseProgram(shaderProgram);
在glUseProgram函數執行之后,每個shader調用和渲染調用都會使用這個program對象(也就是之前寫的shader)了。
現在我們已經把我們vertex shader傳送給了GPU並且告訴了GPU如何用vertex shader和fragment shader來處理它們。並且告訴了它們如何解釋內存中的定點數據,剩下的就是告訴它如何把vertex data鏈接到vertex shader的頂點屬性上了。
Linking Vertex Attributes
vertex shader允許我們指定任何以頂點屬性為形式的輸入。這使其有很強的靈活性的同時,它還意味着我們必須手動的指定vertex data的哪一部分對應着vertex shader的哪一個頂點屬性。
我們的頂點緩沖數據會被解析為下面的形式:

*位置信息會被存儲為32位(4字節)的浮點值
*每個位置包含三個這樣的值
*這3個值之間沒有空隙(或者其它值),它們在數組中緊密排列
*數值中第一個值開始的位置
通過這些信息我們就可以使用glVertexAttribPointer函數告訴OpenGL如何解析vertex data(應用到頂點屬性上),代碼如下:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);
*第一個參數指明我們要配置的頂點屬性,還記得我們在vertex shader中使用layout(location = 0)定義了position預頂點屬性的位置(Location)嘛?它可以把頂點屬性的位置值設置為0.我們希望把定點數據傳入到這個頂點屬性中,所以我們把它的值設置為0
*第二個參數指明了頂點屬性(vertex attrib)的大小,因為我們的頂點屬性是vec3所以它由3個值組成。
*第三個參數指明參數的類型是GL_FLOAT(GLSL中的vec*都是由浮點數組成的)
*第四個參數表示我們是否希望數據被標准化。如果我們設置的值是GL_TRUE,所有的值都會被映射到0(對於有符號型signed是-1)到1之間。這里我們並不需要,所以把它設置為GL_FALSE.
*第五個參數我們把它叫做stride(步長),它代表在連續頂點屬性組之間的間隔。因為下一組的數據在3個float之后,所以我們這里設置的是3*sizeof(float)。因為我們知道這個數組是緊密排列的(在兩個頂點屬性之間沒有空隙),我們也可以設置它為0讓OpenGL來為我們決定(只有當定點數據是緊密排列的時候才能使用)。一旦我們擁有更多的定點數據,我們必須小心的設定每個頂點數據之間的間隔。在后面我們將看到更多的例子。(這個參數簡單的說就是從這個屬性第二次出現的地方到數組為0的位置一共有多少個字節)。
*第六個參數的類型是void*,我們需要進行強制的類型轉換。它表示數據在緩沖中起始位置的偏移量。
每個頂點屬性從VBO管理的內存中獲取vertex data,而具體是從哪個VBO(我們可以擁有多個VBO)中獲取則是通過調用glVertexAttribPointer函數時綁定到GL_ARRAY_BUFFER的VBO決定的,因為在調用glVertexAttribPointer之前綁定的是先定義的VBO對象,所以頂點屬性0會鏈接到vertex data。
現在我們已經定義了OpenGL如何解釋vertex data,我們需要以頂點屬性位置作為glEnableVertexAttribArray函數的參數來啟用頂點屬性,它默認狀態下是關閉的。
最后要想繪制我們想要的物體,OpenGL給我們提供了glDrawArrays函數,它使用當前激活的着色器,之前定義的頂點屬性配置,和VBO的頂點數據(通過VAO間接綁定)來繪制圖元。代碼如下:
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays函數第一個參數是我們打算繪制的OpenGL圖元的類型。由於我們在一開始時說過,我們希望繪制的是一個三角形,這里傳遞GL_TRIANGLES給它。第二個參數指定了頂點數組的起始索引,我們這里填0。最后一個參數指定我們打算繪制多少個頂點,這里是3(我們只從我們的數據中渲染一個三角形,它只有3個頂點長)。
運行程序,結果如下所示:

以上任何步驟出錯我們都不能得到這個三角形,如果三角形顏色不對的話,建議檢查vertex shader和fragment shader的代碼。
