使用OpenGL渲染一個三角形
坑邊閒話:如說之前的算是小打小鬧,從這裡開始才是真正的打開了OpenGL的大門,這裡是勸退開始的地方(笑。沒有基礎的話學下去確實挺吃力的,不過會堅持下去的。
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。
注意: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的程式碼。程式的原始碼同樣可以在我的github上找到(左上角)。