1 前言
VAO和VBO屬於我們學習opengl最先接觸的幾個概念,最開始學習的時候有可能無法直觀的理解這個概念的作用和使用方法。筆者也是opengl新手,在此記錄學習的相關筆記,便於之後進行檢視。本文主要參考learnopengl 教程以及 opengl官網 中的用法和解釋,文中的程式碼例項使用opengl3.3,過早版本可能無法正常執行。
2 Vertex Specification
在解釋VAO和VBO之前,我們需要了解其所屬的渲染流水線階段,在opengl wiki中,這一階段被稱為Vertex Specification,在接下來的文章中,我們將其稱為頂點規範,以下為官網的原文解釋
Vertex Specification is the process of setting up the necessary objects for rendering with a particular shader program, as well as the process of using those objects to render.
頂點規範是指為特定著色器設定其所需要的物件,以及使用這些物件進行渲染的過程
僅從這個解釋我們無法很好的理解這一過程究竟做些什麼,但我們可以針對這個解釋提出幾個問題:
- 特定著色器是指哪些著色器
- 所需要的物件是指什麼
- 這些物件對於著色器有什麼作用
接下來所介紹的概念將終點回答以上的這些問題,在理解了這幾個問題之後,我們也能夠非常自然的理解VBO和VAO的概念以及作用
2.1 Vertex Stream
為了進行渲染,我們需要使用著色器以及呼叫這些著色器的渲染管線,在opengl中主要包括頂點著色器(Vertex Shader),幾何著色器(Geometry Shader), 片元著色器(Fragment Shader)。其中,頂點著色器包括使用者定義的輸入變數列表,這些變數代表每個頂點在渲染時所需要的屬性
一個常見的頂點著色器輸入變數列表
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
這個變數列表表示的是location0輸入頂點的座標屬性,location1輸入法向量屬性,location2輸入紋理座標屬性。在定義了這些輸入屬性之後,我們需要為其“準備”好相應的資料。
對於每個屬性,我們需要提供一個資料陣列,並且這些資料陣列必須有相同的元素個數 (這裡的陣列類似c語言的陣列概念,但是更加靈活),我們把這些資料陣列稱為頂點資料。
需要注意的是,這些頂點的順序是十分重要的,這個順序決定了OpenGL會如何渲染這些頂點組成的Primitive(圖元),OpenGL將這種按順序進行定點資料傳輸的過程稱為Vertex Stream(頂點流)。
OpenGL從頂點陣列中產生頂點流的方式有兩種,一種是直接按照陣列的原始順序獲取頂點資料,另一種則是定義一個索引列表。索引列表定義了獲取頂點資料的先後順序,並且索引列表可以多次訪問同一資料元素。
例如,我們的頂點座標資料陣列如下:
{ {1, 1, 1}, {0, 0, 0}, {0, 0, 1} }
如果我們採用第一種頂點流生成方法,那麼OpenGL會直接按照順序從左到右處理這些資料,但當我們使用第二種索引列表的方式時,則可以自定義對於這些資料的處理順序和次數
例如,這樣的索引列表
{2, 1, 0, 2, 1, 2}
那麼,按照該索引列表中的順序去對頂點資料陣列進行訪問,我們能夠獲得這樣的頂點流
{ {0, 0, 1}, {0, 0, 0}, {1, 1, 1}, {0, 0, 1}, {0, 0, 0}, {0, 0, 1} }
上述例子來源於Opengl wiki ,這種使用索引的頂點流是一種很好的資料壓縮手段,對於重複使用的頂點來說,其頂點資料通常會佔用32位元組左右的記憶體,而一個索引則通常只有2-4位元組。
注意 : 我們可能會想,能不能透過為每個屬性賦予獨立的索引列表,最大化這種資料壓縮的能力?一些3d編輯工具支援這種操作,但是OpenGL(同Direct3D)是不允許的,其要求所有頂點資料陣列共享同一個索引列表,因此從其他3d邊界工具中匯入的頂點資料中如果含有多個索引列表,則需要進行相應的預處理使其能夠共享同一個索引列表
2.2 Primitive(圖元)
個人認為圖元的概念最直接的使用是在幾何著色器中,因此這裡只會進行一個大概的介紹。
上述的頂點流輸出的終究只是頂點,正如我們平時手工繪畫一樣,並非逐點繪製,而是組合各種線段和幾何圖形,對於OpenGL而言,這些基本的繪畫元素被稱為圖元。我們需要告訴OpenGL如何處理頂點流輸出的頂點資料從而組成各種各樣的圖元。例如將每三個頂點組成一個三角形,每兩個頂點組成一個線段,亦或者將4個頂點組成兩個三角形
2.3 總結
透過上述幾個概念的介紹,我們應該能夠回答之前在Vertex Specification中提出的幾個問題
Q1 : 特定著色器是指哪些著色器
A1 : 我們在頂點著色器中定義其所需要的頂點屬性,併為每個屬性準備其對應的資料陣列。並且透過這些資料陣列產生的頂點流,來讓OpenGL組成圖元。所以這裡的著色器主要是指頂點著色器,但使用幾何著色器時也會涉及到這個概念。
Q2 : 所需要的物件是指什麼
A2 : 所需要的物件應該至少包含頂點屬性資料,對於使用索引進行頂點流產生的方法還應該準備一個屬性之間共享的索引列表。
Q3 : 這些物件對於著色器有什麼作用
A3 : 這些物件為頂點著色器的頂點屬性提供資料來源,並且透過頂點流提供給OpenGL組成圖元的資料處理順序,後半部分或許在GLSL本身的著色器程式碼中體現的不是很明顯,但是對於光柵化階段來說則是非常重要的。
3 VAO&VBO
從第二節中的概念我們知道,要進行Vertex Specification, 我們需要能夠提供資料陣列併產生頂點流的物件, 這也正是VAO和VBO存在的意義。
3.1 VBO(Vertex Buffer Object)
頂點著色器在GPU中建立記憶體用於儲存頂點資料,這些記憶體在顯示卡中,因此也被稱為視訊記憶體(vRam)。我們透過配置OpenGL來“解釋”這些記憶體,即不同的資料段對應的是頂點屬性中的哪一部分。而OpenGL管理這些記憶體的方式即為頂點緩衝物件(Vertex Buffer Objects,VBO),我們可以透過建立與配置這些物件來申請視訊記憶體空間以及解釋這些記憶體。
以下為LearnOpengl中對使用VBO好處的解釋
The advantage of using those buffer objects is that we can send large batches of data all at once to the graphics card, and keep it there if there's enough memory left, without having to send data one vertex at a time. Sending data to the graphics card from the CPU is relatively slow, so wherever we can we try to send as much data as possible at once. Once the data is in the graphics card's memory the vertex shader has almost instant access to the vertices making it extremely fast
我們透過頂點緩衝物件(Vertex Buffer Objects, VBO)管理這個記憶體,它會在GPU記憶體(通常被稱為視訊記憶體)中儲存大量頂點。使用這些緩衝物件的好處是我們可以一次性的傳送一大批資料到顯示卡上,而不是每個頂點傳送一次。從CPU把資料傳送到顯示卡相對較慢,所以只要可能我們都要嘗試儘量一次性傳送儘可能多的資料。當資料傳送至顯示卡的記憶體中後,頂點著色器幾乎能立即訪問頂點,這是個非常快的過程。
VBO的好處在於其可以將大量資料一次傳送到視訊記憶體中,這對於圖形渲染非常重要,畢竟CPU和GPU之間的通訊是昂貴的,我們不希望在每幀之間進行大量的這種資料傳輸操作。但是,當我們只是想繪製一些簡單的圖形進行測試的時候,OpenGL也提供了傳送單個頂點資料的藉口
在Cherno的OpenGL教程中使用的是,其使用的OpenGL3種的介面是
void glVertex2f(GLfloat x,GLfloat y)
而在OpenGL4的reference page中則是這樣的介面
void glVertexAttrib2f(GLuint index,GLfloat v0,GLfloat v1)
opengl官網中現在只能找到gl4的reference page,需要gl3以及更老版本的介面查詢可以訪問docs.gl
雖然介面產生了些許變化,但是這一物件的設計邏輯仍然是共通的,所以我們也透過opengl wiki中對於這一概念的敘述來獲得更加全面的認識
A Vertex Buffer Object (VBO) is the common term for a normal Buffer Object when it is used as a source for vertex array data. It is no different from any other buffer object, and a buffer object used for Transform Feedback or asynchronous pixel transfers can be used as source values for vertex arrays.
官網的解釋是說,VBO是緩衝物件被用在頂點陣列資料是的俗稱, 其和一般的緩衝物件沒有區別。換句話說,OpenGL所支援的緩衝物件理論上都可以用作頂點緩衝物件,比如Transform Feedback和asychronous pixel transfers的緩衝物件也可以作為頂點緩衝物件使用,具體的操作涉及頂點後處理(Vertex post-processing),紋理(Texture)緩衝等內容,在相應的章節再具體講解,這裡先只需要知道VBO並不是一種特殊的緩衝物件,其餘操作的緩衝物件同樣能夠作為VBO使用。
在OpenGL中我們可以建立VBO,繫結物件,以及設定VBO對頂點資料的解釋方式
// 頂點緩衝物件的ID
unsigned int VBO;
// 建立一個頂點緩衝物件
glGenBuffers(1, &VBO);
// 將頂點緩衝物件繫結到GL_ARRAY_BUFFER,從這一刻起,我們使用的任何(在GL_ARRAY_BUFFER目標上的)緩衝呼叫都會用來配置當前繫結的緩衝(VBO)
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 將定義的頂點資料複製到緩衝的記憶體中,第三個引數為NULL時則不進行復制,僅申請規定大小的記憶體,且記憶體為為初始化狀態
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 刪除頂點緩衝物件
glDeleteBuffer(1,&VBO);
glBufferData的最後一個引數usage指定來我們希望顯示卡如何管理給定的資料,例如一下三種形式:
- GL_STATIC_DRAW :資料不會或幾乎不會改變。
- GL_DYNAMIC_DRAW:資料會被改變很多。
- GL_STREAM_DRAW :資料每次繪製時都會改變。
當資料在每次渲染中不改變時最好使用GL_STATIC_DRAW, 而當一個緩衝中的資料被頻繁改變時則使用GL_DYNAMIC_DRAW或GL_STREAM_DRAW,從而確保顯示卡將資料放入告訴寫入的記憶體部分。當然除這三種外還包括很多別的型別,具體的可以參考文件,不同的顯示卡廠商對於這些型別有自己的實現來提高緩衝物件的效能,但是並不影響OpenGL開發者對這些資料的使用。
在gl4中我們能夠使用glBufferData的上位替代,具體見文件
void glBufferStorage(GLenum target,GLsizeiptr size,const GLvoid * data,GLbitfield flags);
void glNamedBufferStorage(GLuint buffer,GLsizeiptr size,const void *data,GLbitfield flags);
VBO能夠申請對應的記憶體區域,同時也使我們能夠解釋這些資料
例如以下這些介面
void glVertexAttribPointer( GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void *offset);
void glVertexAttribIPointer( GLuint index, GLint size, GLenum type, GLsizei stride, const void *offset);
void glVertexAttribLPointer( GLuint index, GLint size, GLenum type, GLsizei stride, const void *offset);
這些介面基本實現一個事情,即設定屬性索引為index屬性的格式以及儲存資訊。該函式告訴我們屬性index會從繫結到type的緩衝物件獲取資料,並且我們需要注意這種關聯是在這個函式被呼叫時建立的。
例如下面這個例子
glBindBuffer(GL_ARRAY_BUFFER, buf1);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
buf1首先被繫結到GL_ARRAY_BUFFER目標,然後設定屬性0的格式與儲存資訊,然後將0繫結到GL_ARRAY_BUFFER目標,但是這並不影響屬性0和buf1之間的關聯,我們可以將GL_ARRAY_BUFFER看作一個設定buf1與屬性0的介面,換綁介面並不會影響之前設定的聯絡
當0繫結到GL_ARRAY_BUFFER時,無法呼叫glVertexAttribPointer,因此在完成一個頂點緩衝物件的設定後,我們通常會將將0繫結到GL_ARRAY_BUFFER上,以避免錯誤修改頂點緩衝物件的響應配置
接下來我們將介紹glVertexAttribPointer如何定義資料的格式
從之前的頂點著色器程式碼中我們可以看到每個頂點屬性都有一個對應的資料型別如vec3、vec2對應著3位float與2位float組成的向量。我們當然可以用整型組成向量如ivec3,或者雙精度浮點數如dvec4(僅在OpenGL4.1中可用)。在OpenGL中,我們透過使用不同的函式來對應不同的資料型別。
glVertexAttribPointer --> float
glVertexAttribIPointer --> int
glVertexAttribLPointer --> double
然後是這些函式的第二個引數size,其可以為1-4中的任意整數,對應屬性向量的1-4維度,這個參數列示每次從緩衝區陣列中連續讀取幾個資料,這個資料不一定需要嚴格對應頂點屬性的維度數,當頂點維度數小於size時,多餘的位數會被忽略,大於size時會從(0,0,0,1)取對應位數來補充。
當size = 4,頂點屬性為vec3型別
緩衝區讀取(0.1,0.2,0.3,0.4)
實際aPos = (0.1,0.2,0.3)
當size = 3, 頂點屬性為vec4型別
緩衝區讀取(0.1,0.2,0.3)
實際aPos = (0.1,0.2,0.4,1.0)
Note:對於雙精度型別不成立,缺少的維度會是undefined value
接著是函式的第3個變數type,對應著緩衝區儲存資料的型別,當這些資料被頂點著色器讀取時會轉換成其所需要的型別。這裡容易和之前的屬性型別弄混,其邏輯關係如下:
vec必須使用glAttribPointer進行配置
ivec必須使用glAttribIPointer進行配置
dvec必須必須使用glAttribLPointer進行配置
函式型別決定輸出的資料型別
glAttribPointer的type可設定為:GL_HALF_FLOAT,GL_FLOAT,GL_DOUBLE,GL_FIXED ...
glAttribIPointer的type可設定為:GL_BYTE,GL_UNSIGNED_BYTE,GL_SHORT ...
glAttribLPointer的type可設定為:GL_DOUBLE ...
type決定了在快取中儲存資料的型別,當從快取中讀取的時候,不同的type到對應的屬性資料型別有各自的轉換方式。比如glVertexAttribPointer到normalized引數決定了,當type為整型時是否使用integer normalization將整型轉換為單精度浮點型別
其中glAttribPointer的size型別除了可以為1-4之間的整數之外還可以為GL_BGRA,其實際上也是一個4位資料,但是前三位是逆序讀取的,這是為了和Direct3D中的特定型別進行匹配,從而方便將D3D的部分資料轉換到OpenGL中,相關細節詳見OpenGL wiki
最後一部分是函式的stride和offset引數,這兩個引數從哪裡開始讀取頂點資料,以及讀取完一個頂點資料後如何移動指標到下一個頂點資料。具體來說stride表示從當前頂點資料的開頭到下一個頂點資料開頭有多少位元組,當stride為0時,OpenGL預設這些資料緊密排列,其根據size和type自動計算到下一個資料的位元組數,stride則為size * sizeof(type)。offset表示開始讀取的指標位置,源於一些歷史原因其型別為(void),我們可以透過型別轉換來將其他型別的指標轉換為void。
透過靈活設定這些引數,我們能夠實現不同的資料儲存方式, 最經典的一種應用即交錯儲存
通常opengl更喜歡交錯儲存,因為我們可以一次性獲取單個頂點所需的所有資料,而不需要從多個記憶體位置讀取資料,類似cpu中的記憶體讀取機制,這種資料訪問方式會有更少的cache miss,更高的效率。但是對於需要經常更改的資料和靜態資料同時存在的情況來說,這種儲存方式是不理想的,比如我們可能希望將經常更改的資料放在高速寫入的記憶體區,而將靜態資料放在其他地方。此時我們可以講兩種資料分開儲存,但對於靜態資料而言,我們仍應儘可能使用交錯儲存的方式。
3.2 VAO(Vertex Array Buffer)
VBO用於儲存頂點屬性對應資料儲存的頂點緩衝區,能夠透過它設定頂點緩衝區的資料格式的解釋。因此,但是這種解釋並非儲存在VBO中,而是針對當前OpenGL狀態的一種設定,這意味著當我們想要使用另一種VBO以及另一種格式解釋時需要重新進行資料格式的設定,這種操作過於繁瑣。為了解決這一問題,OpenGL中出現了VAO這一概念
對於VAO,OpenGL wiki給出這樣的解釋
A Vertex Array Object (VAO) is an OpenGL Object that stores all of the state needed to supply vertex data (with one minor exception noted below). It stores the format of the vertex data as well as the Buffer Objects (see below) providing the vertex data arrays.
一個頂點陣列物件是一個OpenGL物件,其儲存提供頂點資料所需的所有狀態。它儲存頂點資料的格式以及提供頂點資料陣列的緩衝物件
從這個解釋中我們可以看到,VAO的作用實際上就是儲存我們在VBO中所做的操作,從而使我們可以方便的複用VBO以及資料格式設定。但同時,OpenGL wiki中還給出了一個主意事項,較為重要
Note that a VAO merely references the buffers, it does not copy or freeze their contents; if referenced buffers are modified later, those changes will be seen when using the VAO.
VAO中所儲存的是緩衝的引用,而非對其內容的複製,這意味著當我們在其他地方修改了這些緩衝,那麼儲存其引用的VAO在使用時也會應用這些變化
VAO同樣屬於OpenGL物件,因此其也有對應的建立以及銷燬函式
glGenVertexArrays
glCreateVertexArrays (only available in gl4.5)
glDeleteVertexArrays
與之前的VBO不同點在於其繫結函式,VBO繫結其object name到GL_ARRAY_BUFFER目標,而VAO的繫結則不需要繫結到其他目標上
glBindVertexArray(GLuint name)
這意味著,OpenGL可以同時使用多個繫結至不同目標的緩衝物件,而頂點陣列物件同時只能使用一個。注意,VAO不能在OpenGL上下文之間共享。
頂點著色器訪問頂點資料是透過VAO進行的,因此我們需要透過VAO來設定是否啟用某個頂點屬性,他們透過如下介面實現
void glEnableVertexAttribArray(Gluint index);
void glDisableVertexAttribArray(GLuint index);
或者透過DSA(Direct State Access)來設定
void glEnableVertexArrayAttrib(GLuint vao,Gluint index);
void glDisableVertexArayAttrib(GLuint vao,GLuint index);
DSA是gl4.5之後加入的新特性,能夠在不講OpenGL物件繫結至上下文的情況下修改物件狀態, 如果使用舊版本,則必須在此之前使用glBindVertexArray繫結至當前上下文。
同時,我們還需注意,當對應的屬性未被啟用是,頂點著色器在訪問對應屬性時會獲得一個預設值,而非報錯退出。
而且,OpenGL的compatibility profile中會將name為0的VAO物件設定為一個預設物件,這意味著在不主動繫結任何VAO時,所有修改VAO狀態的操作都會預設應用到VAO 0上。而對於core profile,VAO 0並不是一個物件,所以在手動繫結一個VAO物件之前,我們不能使用任何修改VAO狀態的操作。
VAO的使用相對簡單,只需在繪製之前,將對應VAO繫結至當前上下文,即可應用其對應的VBO和資料格式配置
while(!glShouldWindowClose(window))}{
glBindVertexArray(VAO);
// draw call
... ...
}
3.3 Index buffers
最後我們介紹一下index buffer。之前在介紹Vertex Specification的過程中我們介紹過使用索引陣列來決定繪製順序以及進行資料壓縮,而這則是透過index buffer實現的。
index buffer本身和VBO一樣時Buffer Object, 只是透過繫結到GL_ELEMENT_ARRAY_BUFFER來將其當作索引陣列來使用,其他操作和VBO一樣。要使用這個索引緩衝物件,我們還需要在繪製時使用glDrawElements而不是DrawDrawArrays。
4 總結
我們可以從VBO已經index buffer中更進一步的理解buffer object,buffer object自身僅僅用來儲存資料,規定資料的型別以及緩衝區大小。而這個buffer object的具體作用由繫結到的目標決定。繫結到GL_ARRAY_BUFFER是VBO,GL_ELEMENT_ARRAY_BUFFER則是index buffer。頂點資料格式設定的操作則是對OpenGL狀態機進行操作,而這些繫結以及格式設定,又被繫結的VAO記錄,從而可以方便的應用這些設定。