1. 程式人生 > >opengl學習之路二,你好,三角形

opengl學習之路二,你好,三角形

譯註

在學習此節之前,建議將這三個單詞先記下來:
•頂點陣列物件:Vertex Array Object,VAO
•頂點緩衝物件:Vertex Buffer Object,VBO
•索引緩衝物件:Element Buffer Object,EBO或Index Buffer Object,IBO

當指代這三個東西的時候,可能使用的是全稱,也可能用的是英文縮寫,翻譯的時候和原文保持的一致。由於沒有英文那樣的分詞間隔,中文全稱的部分可能不太容易注意。但請記住,縮寫和中文全稱指代的是一個東西。

在OpenGL中,任何事物都在3D空間中,而螢幕和視窗卻是2D畫素陣列,這導致OpenGL的大部分工作都是關於把3D座標轉變為適應你螢幕的2D畫素。3D座標轉為2D座標的處理過程是由OpenGL的圖形渲染管線(Graphics Pipeline,大多譯為管線,實際上指的是一堆原始圖形資料途經一個輸送管道,期間經過各種變化處理最終出現在螢幕的過程)管理的。圖形渲染管線可以被劃分為兩個主要部分:第一部分把你的3D座標轉換為2D座標,第二部分是把2D座標轉變為實際的有顏色的畫素。這個教程裡,我們會簡單地討論一下圖形渲染管線,以及如何利用它建立一些漂亮的畫素。

2D座標和畫素也是不同的,2D座標精確表示一個點在2D空間中的位置,而2D畫素是這個點的近似值,2D畫素受到你的螢幕/視窗解析度的限制。

圖形渲染管線接受一組3D座標,然後把它們轉變為你螢幕上的有色2D畫素輸出。圖形渲染管線可以被劃分為幾個階段,每個階段將會把前一個階段的輸出作為輸入。所有這些階段都是高度專門化的(它們都有一個特定的函式),並且很容易並行執行。正是由於它們具有並行執行的特性,當今大多數顯示卡都有成千上萬的小處理核心,它們在GPU上為每一個(渲染管線)階段執行各自的小程式,從而在圖形渲染管線中快速處理你的資料。這些小程式叫做著色器(Shader)。

有些著色器允許開發者自己配置,這就允許我們用自己寫的著色器來替換預設的。這樣我們就可以更細緻地控制圖形渲染管線中的特定部分了,而且因為它們執行在GPU上,所以它們可以給我們節約寶貴的CPU時間。OpenGL著色器是用OpenGL著色器語言(OpenGL Shading Language, GLSL)寫成的,在下一節中我們再花更多時間研究它。

下面,你會看到一個圖形渲染管線的每個階段的抽象展示。要注意藍色部分代表的是我們可以注入自定義的著色器的部分。 在這裡插入圖片描述 如你所見,圖形渲染管線包含很多部分,每個部分都將在轉換頂點資料到最終畫素這一過程中處理各自特定的階段。我們會概括性地解釋一下渲染管線的每個部分,讓你對圖形渲染管線的工作方式有個大概瞭解。

首先,我們以陣列的形式傳遞3個3D座標作為圖形渲染管線的輸入,用來表示一個三角形,這個陣列叫做頂點資料(Vertex Data);頂點資料是一系列頂點的集合。一個頂點(Vertex)是一個3D座標的資料的集合。而頂點資料是用頂點屬性(Vertex Attribute)表示的,它可以包含任何我們想用的資料,但是簡單起見,我們還是假定每個頂點只由一個3D位置(譯註1)和一些顏色值組成的吧。

譯註1

當我們談論一個“位置”的時候,它代表在一個“空間”中所處地點的這個特殊屬性;同時“空間”代表著任何一種座標系,比如x、y、z三維座標系,x、y二維座標系,或者一條直線上的x和y的線性關係,只不過二維座標系是一個扁扁的平面空間,而一條直線是一個很瘦的長長的空間。

為了讓OpenGL知道我們的座標和顏色值構成的到底是什麼,OpenGL需要你去指定這些資料所表示的渲染型別。我們是希望把這些資料渲染成一系列的點?一系列的三角形?還是僅僅是一個長長的線?做出的這些提示叫做圖元(Primitive),任何一個繪製指令的呼叫都將把圖元傳遞給OpenGL。這是其中的幾個:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP。

圖形渲染管線的第一個部分是頂點著色器(Vertex Shader),它把一個單獨的頂點作為輸入。頂點著色器主要的目的是把3D座標轉為另一種3D座標(後面會解釋),同時頂點著色器允許我們對頂點屬性進行一些基本處理。

圖元裝配(Primitive Assembly)階段將頂點著色器輸出的所有頂點作為輸入(如果是GL_POINTS,那麼就是一個頂點),並所有的點裝配成指定圖元的形狀;本節例子中是一個三角形。

圖元裝配階段的輸出會傳遞給幾何著色器(Geometry Shader)。幾何著色器把圖元形式的一系列頂點的集合作為輸入,它可以通過產生新頂點構造出新的(或是其它的)圖元來生成其他形狀。例子中,它生成了另一個三角形。

幾何著色器的輸出會被傳入光柵化階段(Rasterization Stage),這裡它會把圖元對映為最終螢幕上相應的畫素,生成供片段著色器(Fragment Shader)使用的片段(Fragment)。在片段著色器執行之前會執行裁切(Clipping)。裁切會丟棄超出你的檢視以外的所有畫素,用來提升執行效率。

OpenGL中的一個片段是OpenGL渲染一個畫素所需的所有資料。

片段著色器的主要目的是計算一個畫素的最終顏色,這也是所有OpenGL高階效果產生的地方。通常,片段著色器包含3D場景的資料(比如光照、陰影、光的顏色等等),這些資料可以被用來計算最終畫素的顏色。

在所有對應顏色值確定以後,最終的物件將會被傳到最後一個階段,我們叫做Alpha測試和混合(Blending)階段。這個階段檢測片段的對應的深度(和模板(Stencil))值(後面會講),用它們來判斷這個畫素是其它物體的前面還是後面,決定是否應該丟棄。這個階段也會檢查alpha值(alpha值定義了一個物體的透明度)並對物體進行混合(Blend)。所以,即使在片段著色器中計算出來了一個畫素輸出的顏色,在渲染多個三角形的時候最後的畫素顏色也可能完全不同。

可以看到,圖形渲染管線非常複雜,它包含很多可配置的部分。然而,對於大多數場合,我們只需要配置頂點和片段著色器就行了。幾何著色器是可選的,通常使用它預設的著色器就行了。

在現代OpenGL中,我們必須定義至少一個頂點著色器和一個片段著色器(因為GPU中沒有預設的頂點/片段著色器)。出於這個原因,剛開始學習現代OpenGL的時候可能會非常困難,因為在你能夠渲染自己的第一個三角形之前已經需要了解一大堆知識了。在本節結束你最終渲染出你的三角形的時候,你也會了解到非常多的圖形程式設計知識。

頂點輸入

開始繪製圖形之前,我們必須先給OpenGL輸入一些頂點資料。OpenGL是一個3D圖形庫,所以我們在OpenGL中指定的所有座標都是3D座標(x、y和z)。OpenGL不是簡單地把所有的3D座標變換為螢幕上的2D畫素;OpenGL僅當3D座標在3個軸(x、y和z)上都為-1.0到1.0的範圍內時才處理它。所有在所謂的標準化裝置座標(Normalized Device Coordinates)範圍內的座標才會最終呈現在螢幕上(在這個範圍以外的座標都不會顯示)。

由於我們希望渲染一個三角形,我們一共要指定三個頂點,每個頂點都有一個3D位置。我們會將它們以標準化裝置座標的形式(OpenGL的可見區域)定義為一個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座標設定為0.0。這樣子的話三角形每一點的深度(Depth,譯註2)都是一樣的,從而使它看上去像是2D的。

譯註2

通常深度可以理解為z座標,它代表一個畫素在空間中和你的距離,如果離你遠就可能被別的畫素遮擋,你就看不到它了,它會被丟棄,以節省資源。

標準化裝置座標(Normalized Device Coordinates, NDC)

一旦你的頂點座標已經在頂點著色器中處理過,它們就應該是標準化裝置座標了,標準化裝置座標是一個x、y和z值在-1.0到1.0的一小段空間。任何落在範圍外的座標都會被丟棄/裁剪,不會顯示在你的螢幕上。下面你會看到我們定義的在標準化裝置座標中的三角形(忽略z軸):

NDC

與通常的螢幕座標不同,y軸正方向為向上,(0, 0)座標是這個影象的中心,而不是左上角。最終你希望所有(變換過的)座標都在這個座標空間中,否則它們就不可見了。

你的標準化裝置座標接著會變換為螢幕空間座標(Screen-space Coordinates),這是使用你通過glViewport函式提供的資料,進行視口變換(Viewport Transform)完成的。所得的螢幕空間座標又會被變換為片段輸入到片段著色器中。 在這裡插入圖片描述 定義這樣的頂點資料以後,我們會把它作為輸入傳送給圖形渲染管線的第一個處理階段:頂點著色器。它會在GPU上建立記憶體用於儲存我們的頂點資料,還要配置OpenGL如何解釋這些記憶體,並且指定其如何傳送給顯示卡。頂點著色器接著會處理我們在記憶體中指定數量的頂點。

我們通過頂點緩衝物件(Vertex Buffer Objects, VBO)管理這個記憶體,它會在GPU記憶體(通常被稱為視訊記憶體)中儲存大量頂點。使用這些緩衝物件的好處是我們可以一次性的傳送一大批資料到顯示卡上,而不是每個頂點發送一次。從CPU把資料傳送到顯示卡相對較慢,所以只要可能我們都要嘗試儘量一次性發送儘可能多的資料。當資料傳送至顯示卡的記憶體中後,頂點著色器幾乎能立即訪問頂點,這是個非常快的過程。

頂點緩衝物件是我們在OpenGL教程中第一個出現的OpenGL物件。就像OpenGL中的其它物件一樣,這個緩衝有一個獨一無二的ID,所以我們可以使用glGenBuffers函式和一個緩衝ID生成一個VBO物件:

unsigned int VBO;
glGenBuffers(1, &VBO);

OpenGL有很多緩衝物件型別,頂點緩衝物件的緩衝型別是GL_ARRAY_BUFFER。OpenGL允許我們同時繫結多個緩衝,只要它們是不同的緩衝型別。我們可以使用glBindBuffer函式把新建立的緩衝繫結到GL_ARRAY_BUFFER目標上:

glBindBuffer(GL_ARRAY_BUFFER, VBO);  


從這一刻起,我們使用的任何(在GL_ARRAY_BUFFER目標上的)緩衝呼叫都會用來配置當前繫結的緩衝(VBO)。然後我們可以呼叫glBufferData函式,它會把之前定義的頂點資料複製到緩衝的記憶體中:

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

glBufferData是一個專門用來把使用者定義的資料複製到當前繫結緩衝的函式。它的第一個引數是目標緩衝的型別:頂點緩衝物件當前繫結到GL_ARRAY_BUFFER目標上。第二個引數指定傳輸資料的大小(以位元組為單位);用一個簡單的sizeof計算出頂點資料大小就行。第三個引數是我們希望傳送的實際資料。

第四個引數指定了我們希望顯示卡如何管理給定的資料。它有三種形式: •GL_STATIC_DRAW :資料不會或幾乎不會改變。 •GL_DYNAMIC_DRAW:資料會被改變很多。 •GL_STREAM_DRAW :資料每次繪製時都會改變。

三角形的位置資料不會改變,每次渲染呼叫時都保持原樣,所以它的使用型別最好是GL_STATIC_DRAW。如果,比如說一個緩衝中的資料將頻繁被改變,那麼使用的型別就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,這樣就能確保顯示卡把資料放在能夠高速寫入的記憶體部分。

現在我們已經把頂點資料儲存在顯示卡的記憶體中,用VBO這個頂點緩衝物件管理。下面我們會建立一個頂點和片段著色器來真正處理這些資料。現在我們開始著手建立它們吧。

頂點著色器

頂點著色器(Vertex Shader)是幾個可程式設計著色器中的一個。如果我們打算做渲染的話,現代OpenGL需要我們至少設定一個頂點和一個片段著色器。我們會簡要介紹一下著色器以及配置兩個非常簡單的著色器來繪製我們第一個三角形。下一節中我們會更詳細的討論著色器。

我們需要做的第一件事是用著色器語言GLSL(OpenGL Shading Language)編寫頂點著色器,然後編譯這個著色器,這樣我們就可以在程式中使用它了。下面你會看到一個非常基礎的GLSL頂點著色器的原始碼:

#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

可以看到,GLSL看起來很像C語言。每個著色器都起始於一個版本宣告。OpenGL 3.3以及和更高版本中,GLSL版本號和OpenGL的版本是匹配的(比如說GLSL 420版本對應於OpenGL 4.2)。我們同樣明確表示我們會使用核心模式。

下一步,使用in關鍵字,在頂點著色器中宣告所有的輸入頂點屬性(Input Vertex Attribute)。現在我們只關心位置(Position)資料,所以我們只需要一個頂點屬性。GLSL有一個向量資料型別,它包含1到4個float分量,包含的數量可以從它的字尾數字看出來。由於每個頂點都有一個3D座標,我們就建立一個vec3輸入變數aPos。我們同樣也通過layout (location = 0)設定了輸入變數的位置值(Location)你後面會看到為什麼我們會需要這個位置值。

向量(Vector)

在圖形程式設計中我們經常會使用向量這個數學概念,因為它簡明地表達了任意空間中的位置和方向,並且它有非常有用的數學屬性。在GLSL中一個向量有最多4個分量,每個分量值都代表空間中的一個座標,它們可以通過vec.x、vec.y、vec.z和vec.w來獲取。注意vec.w分量不是用作表達空間中的位置的(我們處理的是3D不是4D),而是用在所謂透視除法(Perspective Division)上。我們會在後面的教程中更詳細地討論向量。

為了設定頂點著色器的輸出,我們必須把位置資料賦值給預定義的gl_Position變數,它在幕後是vec4型別的。在main函式的最後,我們將gl_Position設定的值會成為該頂點著色器的輸出。由於我們的輸入是一個3分量的向量,我們必須把它轉換為4分量的。我們可以把vec3的資料作為vec4構造器的引數,同時把w分量設定為1.0f(我們會在後面解釋為什麼)來完成這一任務。

當前這個頂點著色器可能是我們能想到的最簡單的頂點著色器了,因為我們對輸入資料什麼都沒有處理就把它傳到著色器的輸出了。在真實的程式裡輸入資料通常都不是標準化裝置座標,所以我們首先必須先把它們轉換至OpenGL的可視區域內。

編譯著色器

我們已經寫了一個頂點著色器原始碼(儲存在一個C的字串中),但是為了能夠讓OpenGL使用它,我們必須在執行時動態編譯它的原始碼。

我們首先要做的是建立一個著色器物件,注意還是用ID來引用的。所以我們儲存這個頂點著色器為unsigned int,然後用glCreateShader建立這個著色器:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

我們把需要建立的著色器型別以引數形式提供給glCreateShader。由於我們正在建立一個頂點著色器,傳遞的引數是GL_VERTEX_SHADER。

下一步我們把這個著色器原始碼附加到著色器物件上,然後編譯它:

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

glShaderSource函式把要編譯的著色器物件作為第一個引數。第二引數指定了傳遞的原始碼字串數量,這裡只有一個。第三個引數是頂點著色器真正的原始碼,第四個引數我們先設定為NULL。

你可能會希望檢測在呼叫glCompileShader後編譯是否成功了,如果沒成功的話,你還會希望知道錯誤是什麼,這樣你才能修復它們。檢測編譯時錯誤可以通過以下程式碼來實現:

int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

首先我們定義一個整型變數來表示是否成功編譯,還定義了一個儲存錯誤訊息(如果有的話)的容器。然後我們用glGetShaderiv檢查是否編譯成功。如果編譯失敗,我們會用glGetShaderInfoLog獲取錯誤訊息,然後列印它。

if(!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

如果編譯的時候沒有檢測到任何錯誤,頂點著色器就被編譯成功了。

片段著色器

片段著色器(Fragment Shader)是第二個也是最後一個我們打算建立的用於渲染三角形的著色器。片段著色器所做的是計算畫素最後的顏色輸出。為了讓事情更簡單,我們的片段著色器將會一直輸出橘黃色。

在計算機圖形中顏色被表示為有4個元素的陣列:紅色、綠色、藍色和alpha(透明度)分量,通常縮寫為RGBA。當在OpenGL或GLSL中定義一個顏色的時候,我們把顏色每個分量的強度設定在0.0到1.0之間。比如說我們設定紅為1.0f,綠為1.0f,我們會得到兩個顏色的混合色,即黃色。這三種顏色分量的不同調配可以生成超過1600萬種不同的顏色!

#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
} 

片段著色器只需要一個輸出變數,這個變數是一個4分量向量,它表示的是最終的輸出顏色,我們應該自己將其計算出來。我們可以用out關鍵字宣告輸出變數,這裡我們命名為FragColor。下面,我們將一個alpha值為1.0(1.0代表完全不透明)的橘黃色的vec4賦值給顏色輸出。

編譯片段著色器的過程與頂點著色器類似,只不過我們使用GL_FRAGMENT_SHADER常量作為著色器型別:

unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

兩個著色器現在都編譯了,剩下的事情是把兩個著色器物件連結到一個用來渲染的著色器程式(Shader Program)中。

著色器程式

著色器程式物件(Shader Program Object)是多個著色器合併之後並最終連結完成的版本。如果要使用剛才編譯的著色器我們必須把它們連結(Link)為一個著色器程式物件,然後在渲染物件的時候啟用這個著色器程式。已啟用著色器程式的著色器將在我們傳送渲染呼叫的時候被使用。

當連結著色器至一個程式的時候,它會把每個著色器的輸出連結到下個著色器的輸入。當輸出和輸入不匹配的時候,你會得到一個連線錯誤。

建立一個程式物件很簡單:

unsigned int shaderProgram;
shaderProgram = glCreateProgram();

glCreateProgram函式建立一個程式,並返回新建立程式物件的ID引用。現在我們需要把之前編譯的著色器附加到程式物件上,然後用glLinkProgram連結它們:

glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

程式碼應該很清楚,我們把著色器附加到了程式上,然後用glLinkProgram連結。

就像著色器的編譯一樣,我們也可以檢測連結著色器程式是否失敗,並獲取相應的日誌。與上面不同,我們不會呼叫glGetShaderiv和glGetShaderInfoLog,現在我們使用:

glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

得到的結果就是一個程式物件,我們可以呼叫glUseProgram函式,用剛建立的程式物件作為它的引數,以啟用這個程式物件:

glUseProgram(shaderProgram);

在glUseProgram函式呼叫之後,每個著色器呼叫和渲染呼叫都會使用這個程式物件(也就是之前寫的著色器)了。

對了,在把著色器物件連結到程式物件以後,記得刪除著色器物件,我們不再需要它們了:

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

現在,我們已經把輸入頂點資料傳送給了GPU,並指示了GPU如何在頂點和片段著色器中處理它。就快要完成了,但還沒結束,OpenGL還不知道它該如何解釋記憶體中的頂點資料,以及它該如何將頂點資料鏈接到頂點著色器的屬性上。我們需要告訴OpenGL怎麼做。

連結頂點屬性

頂點著色器允許我們指定任何以頂點屬性為形式的輸入。這使其具有很強的靈活性的同時,它還的確意味著我們必須手動指定輸入資料的哪一個部分對應頂點著色器的哪一個頂點屬性。所以,我們必須在渲染前指定OpenGL該如何解釋頂點資料。

我們的頂點緩衝資料會被解析為下面這樣子: 在這裡插入圖片描述

•位置資料被儲存為32位(4位元組)浮點值。 •每個位置包含3個這樣的值。 •在這3個值之間沒有空隙(或其他值)。這幾個值在陣列中緊密排列(Tightly Packed)。 •資料中第一個值在緩衝開始的位置。

有了這些資訊我們就可以使用glVertexAttribPointer函式告訴OpenGL該如何解析頂點資料(應用到逐個頂點屬性上)了:

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

glVertexAttribPointer函式的引數非常多,所以我會逐一介紹它們: •第一個引數指定我們要配置的頂點屬性。還記得我們在頂點著色器中使用layout(location = 0)定義了position頂點屬性的位置值(Location)嗎?它可以把頂點屬性的位置值設定為0。因為我們希望把資料傳遞到這一個頂點屬性中,所以這裡我們傳入0。 •第二個引數指定頂點屬性的大小。頂點屬性是一個vec3,它由3個值組成,所以大小是3。 •第三個引數指定資料的型別,這裡是GL_FLOAT(GLSL中vec都是由浮點數值組成的)。 •下個引數定義我們是否希望資料被標準化(Normalize)。如果我們設定為GL_TRUE,所有資料都會被對映到0(對於有符號型signed資料是-1)到1之間。我們把它設定為GL_FALSE。 •第五個引數叫做步長(Stride),它告訴我們在連續的頂點屬性組之間的間隔。由於下個組位置資料在3個float之後,我們把步長設定為3 * sizeof(float)。要注意的是由於我們知道這個陣列是緊密排列的(在兩個頂點屬性之間沒有空隙)我們也可以設定為0來讓OpenGL決定具體步長是多少(只有當數值是緊密排列時才可用)。一旦我們有更多的頂點屬性,我們就必須更小心地定義每個頂點屬性之間的間隔,我們在後面會看到更多的例子(譯註: 這個引數的意思簡單說就是從這個屬性第二次出現的地方到整個陣列0位置之間有多少位元組)。 •最後一個引數的型別是void,所以需要我們進行這個奇怪的強制型別轉換。它表示位置資料在緩衝中起始位置的偏移量(Offset)。由於位置資料在陣列的開頭,所以這裡是0。我們會在後面詳細解釋這個引數。

每個頂點屬性從一個VBO管理的記憶體中獲得它的資料,而具體是從哪個VBO(程式中可以有多個VBO)獲取則是通過在呼叫glVetexAttribPointer時繫結到GL_ARRAY_BUFFER的VBO決定的。由於在呼叫glVetexAttribPointer之前繫結的是先前定義的VBO物件,頂點屬性0現在會連結到它的頂點資料。

現在我們已經定義了OpenGL該如何解釋頂點資料,我們現在應該使用glEnableVertexAttribArray,以頂點屬性位置值作為引數,啟用頂點屬性;頂點屬性預設是禁用的。自此,所有東西都已經設定好了:我們使用一個頂點緩衝物件將頂點資料初始化至緩衝中,建立了一個頂點和一個片段著色器,並告訴了OpenGL如何把頂點資料鏈接到頂點著色器的頂點屬性上。在OpenGL中繪製一個物體,程式碼會像是這樣: // 0. 複製頂點陣列到緩衝中供OpenGL使用 glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 1. 設定頂點屬性指標 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 2. 當我們渲染一個物體時要使用著色器程式 glUseProgram(shaderProgram); // 3. 繪製物體 someOpenGLFunctionThatDrawsOurTriangle();

每當我們繪製一個物體的時候都必須重複這一過程。這看起來可能不多,但是如果有超過5個頂點屬性,上百個不同物體呢(這其實並不罕見)。繫結正確的緩衝物件,為每個物體配置所有頂點屬性很快就變成一件麻煩事。有沒有一些方法可以使我們把所有這些狀態配置儲存在一個物件中,並且可以通過繫結這個物件來恢復狀態呢?

頂點陣列物件

頂點陣列物件(Vertex Array Object, VAO)可以像頂點緩衝物件那樣被繫結,任何隨後的頂點屬性呼叫都會儲存在這個VAO中。這樣的好處就是,當配置頂點屬性指標時,你只需要將那些呼叫執行一次,之後再繪製物體的時候只需要繫結相應的VAO就行了。這使在不同頂點資料和屬性配置之間切換變得非常簡單,只需要繫結不同的VAO就行了。剛剛設定的所有狀態都將儲存在VAO中

OpenGL的核心模式要求我們使用VAO,所以它知道該如何處理我們的頂點輸入。如果我們繫結VAO失敗,OpenGL會拒絕繪製任何東西。

一個頂點陣列物件會儲存以下這些內容: •glEnableVertexAttribArray和glDisableVertexAttribArray的呼叫。 •通過glVertexAttribPointer設定的頂點屬性配置。 •通過glVertexAttribPointer呼叫與頂點屬性關聯的頂點緩衝物件。

在這裡插入圖片描述

建立一個VAO和建立一個VBO很類似:

unsigned int VAO;
glGenVertexArrays(1, &VAO);

要想使用VAO,要做的只是使用glBindVertexArray繫結VAO。從繫結之後起,我們應該繫結和配置對應的VBO和屬性指標,之後解綁VAO供之後使用。當我們打算繪製一個物體的時候,我們只要在繪製物體前簡單地把VAO繫結到希望使用的設定上就行了。這段程式碼應該看起來像這樣: // …:: 初始化程式碼(只執行一次 (除非你的物體頻繁改變)) :: … // 1. 繫結VAO glBindVertexArray(VAO); // 2. 把頂點陣列複製到緩衝中供OpenGL使用 glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 3. 設定頂點屬性指標 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);

[…]

// …:: 繪製程式碼(渲染迴圈中) :: … // 4. 繪製物體 glUseProgram(shaderProgram); glBindVertexArray(VAO); someOpenGLFunctionThatDrawsOurTriangle();

就這麼多了!前面做的一切都是等待這一刻,一個儲存了我們頂點屬性配置和應使用的VBO的頂點陣列物件。一般當你打算繪製多個物體時,你首先要生成/配置所有的VAO(和必須的VBO及屬性指標),然後儲存它們供後面使用。當我們打算繪製物體的時候就拿出相應的VAO,繫結它,繪製完物體後,再解綁VAO。

我們一直期待的三角形

要想繪製我們想要的物體,OpenGL給我們提供了glDrawArrays函式,它使用當前啟用的著色器,之前定義的頂點屬性配置,和VBO的頂點資料(通過VAO間接繫結)來繪製圖元。

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

glDrawArrays函式第一個引數是我們打算繪製的OpenGL圖元的型別。由於我們在一開始時說過,我們希望繪製的是一個三角形,這裡傳遞GL_TRIANGLES給它。第二個引數指定了頂點陣列的起始索引,我們這裡填0。最後一個引數指定我們打算繪製多少個頂點,這裡是3(我們只從我們的資料中渲染一個三角形,它只有3個頂點長)。

現在嘗試編譯程式碼,如果彈出了任何錯誤,回頭檢查你的程式碼。如果你編譯通過了,你應該看到下面的結果:

在這裡插入圖片描述

如果你的輸出和這個看起來不一樣,你可能做錯了什麼。去檢視一下原始碼,檢查你是否遺漏了什麼東西,或者你也可以在評論區提問。

索引緩衝物件

在渲染頂點這一話題上我們還有最有一個需要討論的東西——索引緩衝物件(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)。要解釋索引緩衝物件的工作方式最好還是舉個例子:假設我們不再繪製一個三角形而是繪製一個矩形。我們可以繪製兩個三角形來組成一個矩形(OpenGL主要處理三角形)。這會生成下面的頂點的集合:


    float vertices[] = {
    // 第一個三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二個三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};


可以看到,有幾個頂點疊加了。我們指定了右下角和左上角兩次!一個矩形只有4個而不是6個頂點,這樣就產生50%的額外開銷。當我們有包括上千個三角形的模型之後這個問題會更糟糕,這會產生一大堆浪費。更好的解決方案是隻儲存不同的頂點,並設定繪製這些頂點的順序。這樣子我們只要儲存4個頂點就能繪製矩形了,之後只要指定繪製的順序就行了。如果OpenGL提供這個功能就好了,對吧?

很幸運,索引緩衝物件的工作方式正是這樣的。和頂點緩衝物件一樣,EBO也是一個緩衝,它專門儲存索引,OpenGL呼叫這些頂點的索引來決定該繪製哪個頂點。所謂的索引繪製(Indexed Drawing)正是我們問題的解決方案。首先,我們先要定義(不重複的)頂點,和繪製出矩形所需的索引:

float vertices[] = {
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

unsigned int indices[] = { // 注意索引從0開始! 
    0, 1, 3, // 第一個三角形
    1, 2, 3  // 第二個三角形
};

你可以看到,當時用索引的時候,我們只定義了4個頂點,而不是6個。下一步我們需要建立索引緩衝物件:

unsigned int EBO;
glGenBuffers(1, &EBO);


與VBO類似,我們先繫結EBO然後用glBufferData把索引複製到緩衝裡。同樣,和VBO類似,我們會把這些函式呼叫放在繫結和解綁函式呼叫之間,只不過這次我們把緩衝的型別定義為GL_ELEMENT_ARRAY_BUFFER。 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

要注意的是,我們傳遞了GL_ELEMENT_ARRAY_BUFFER當作緩衝目標。最後一件要做的事是用glDrawElements來替換glDrawArrays函式,來指明我們從索引緩衝渲染。使用glDrawElements時,我們會使用當前繫結的索引緩衝物件中的索引進行繪製:

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

第一個引數指定了我們繪製的模式,這個和glDrawArrays的一樣。第二個引數是我們打算繪製頂點的個數,這裡填6,也就是說我們一共需要繪製6個頂點。第三個引數是索引的型別,這裡是GL_UNSIGNED_INT。最後一個引數裡我們可以指定EBO中的偏移量(或者傳遞一個索引陣列,但是這是當你不在使用索引緩衝物件的時候),但是我們會在這裡填寫0。

glDrawElements函式從當前繫結到GL_ELEMENT_ARRAY_BUFFER目標的EBO中獲取索引。這意味著我們必須在每次要用索引渲染一個物體時繫結相應的EBO,這還是有點麻煩。不過頂點陣列物件同樣可以儲存索引緩衝物件的繫結狀態。VAO繫結時正在繫結的索引緩衝物件會被儲存為VAO的元素緩衝物件。繫結VAO的同時也會自動繫結EBO。

在這裡插入圖片描述

當目標是GL_ELEMENT_ARRAY_BUFFER的時候,VAO會儲存glBindBuffer的函式呼叫。這也意味著它也會儲存解綁呼叫,所以確保你沒有在解綁VAO之前解綁索引陣列緩衝,否則它就沒有這個EBO配置了。

最後的初始化和繪製程式碼現在看起來像這樣: // …:: 初始化程式碼 :: … // 1. 繫結頂點陣列物件 glBindVertexArray(VAO); // 2. 把我們的頂點陣列複製到一個頂點緩衝中,供OpenGL使用 glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 3. 複製我們的索引陣列到一個索引緩衝中,供OpenGL使用 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // 4. 設定頂點屬性指標 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);

[…]

// …:: 繪製程式碼(渲染迴圈中) :: …

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

執行程式會獲得下面這樣的圖片的結果。左側圖片看應該起來很熟悉,而右側的則是使用線框模式(Wireframe Mode)繪製的。線框矩形可以顯示出矩形的確是由兩個三角形組成的。 在這裡插入圖片描述

線框模式(Wireframe Mode)

要想用線框模式繪製你的三角形,你可以通過glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)函式配置OpenGL如何繪製圖元。第一個引數表示我們打算將其應用到所有的三角形的正面和背面,第二個引數告訴我們用線來繪製。之後的繪製呼叫會一直以線框模式繪製三角形,直到我們用glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)將其設定回預設模式。

如果你遇到任何錯誤,回頭檢查程式碼,看看是否遺漏了什麼。同時,你可以在這裡找到全部原始碼,你也可以在評論區自由提問。

如果你像我這樣成功繪製出了這個三角形或矩形,那麼恭喜你,你成功地通過了現代OpenGL最難部分之一:繪製你自己的第一個三角形。這部分很難,因為在可以繪製第一個三角形之前你需要了解很多知識。幸運的是我們現在已經越過了這個障礙,接下來的教程會比較容易理解一些。