OpenGL學習腳印: 繪製一個三角形
寫在前面
接著上一節內容,開發環境搭建好後,我們當然想立即編寫3D應用程式了。不過我們還需要些耐心,因為OpenGL是一套底層的API,因而我們要掌握的基本知識稍微多一點,在開始繪製3D圖形之前,本節我們將通過繪製一個三角形的程式來熟悉現代OpenGL的概念和流程。
通過本節可以瞭解到:
- 快取物件VAO和VBO
- GLSL著色器程式的編譯、連結和使用方法
- OpenGL繪圖的基本流程
繪圖流水線簡要了解
與使用高階繪圖API(例如java裡swing繪圖,MFC裡的繪圖)不同,使用OpenGL繪製圖形時需要對底層知識有所瞭解。在現代OpenGL中,完成圖形繪製的流水線與舊版的固定流水線有所不同,現代OpenGL程式中允許使用者自己定製著色器,這使得繪圖更靈活。現代繪圖流水線如下圖所示(來自:
這個繪圖流水線是比較複雜的,初學時只需要關注vertex shader頂點著色器和Fragment shader片元著色器即可。頂點著色器負責將使用者指定的頂點轉換為內部表示,片元著色器決定最終生成影象的顏色。頂點著色器的和片元著色器之間可以通過傳遞變數來溝通。使用這兩個著色器就可以繪製基本的圖形了,主要的流程是:
(1) 使用者在程式中指定或者載入頂點屬性資料
(2) 將頂點屬性資料傳送到GPU,由頂點著色器處理頂點資料
(3) 由片元著色器負責最終圖形的顏色
根據這個步驟,下面逐一熟悉相關概念和操作。
VBO和VAO
在OpenGL程式中指定或者載入的資料是儲存在CPU中的,要加快圖形渲染,必定要充分利用GPU的優勢,因此需要將資料傳送到GPU中。在GPU中,VBO即vertex buffer object,頂點快取物件負責實際資料的儲存;而VAO即 vertex array object, 記錄資料的儲存和如何使用的細節資訊。
OpenGL是一個狀態機(state machine),我們繪製圖形時需要在不同的狀態之間切換。例如上一節中通過glClearColor設定清除顏色緩衝區時設定的顏色,OpenGL則記住了這一狀態,當呼叫glClear時則使用這個顏色重置顏色緩衝區。直到再次使用glClearColor設定不同顏色為止,OpenGL會一直使用這個狀態值。
使用VAO的優勢就在於,如果有多個物體需要繪製,那麼我們設定一次繪製物體需要的頂點資料、資料解析方式等資訊,然後通過VAO儲存起來後,後續的繪製操作不再需要重複這一過程,只需要將VAO設定為當前VAO,那麼OpenGL則會使用這些狀態資訊。當場景中物體較多時,優勢十分明顯。VAO和VBO的關係如下圖所示(圖片來自Best Practices for Working with Vertex Data):
上圖中表示,頂點屬性包括位置、紋理座標、法向量、顏色等多個屬性,每個屬性的資料可以存放在不同的buffer中。我們可以根據需求,在程式中建立多個VBO和VAO。
使用VAO和VBO的虛擬碼如下(來自SO):
initialization:
for each batch
generate, store, and bind a VAO
bind all the buffers needed for a draw call
unbind the VAO
main loop/whenever you render:
for each batch
bind VAO
glDrawArrays(...); or glDrawElements(...); etc.
unbind VAO
那麼如何建立VBO和VAO呢? OpenGL中的物件建立和使用與C++中物件建立不一樣,下面程式碼描述了在C++中建立和使用物件的方式(來自[Learning Modern 3D Graphics Programming]):
struct Object
{
int count;
float opacity;
char *name;
};
//建立物件.
Object newObject;
// 設定物件的狀態
newObject.count = 5;
newObject.opacity = 0.4f;
newObject.name = "Some String";
在OpenGL中建立和使用物件卻類似這樣:
//建立物件 不允許使用自定義名字
GLuint objectName;
glGenObject(1, &objectName);
// 設定物件狀態
glBindObject(GL_MODIFY, objectName);
glObjectParameteri(GL_MODIFY, GL_OBJECT_COUNT, 5);
glObjectParameterf(GL_MODIFY, GL_OBJECT_OPACITY, 0.4f);
glObjectParameters(GL_MODIFY, GL_OBJECT_NAME, "Some String");
注意OpenGL中建立一個物件,由GLuint 型別來作為物件識別符號,而不允許使用自定義名字,這樣就不會導致物件重名了。在OpenGL中每個物件在使用前,要繫結到上下文物件,即所謂的target,例如上例中就是GL_MODIFY這個target。
Step1: 建立VBO 我們這樣來建立:
GLuint VBOId;
glGenBuffers(1, &VBOId);
Step2: 將頂點資料傳送到VBO或者為VBO預分配空間。
本節我們繪製一個三角形,對於三角形要在3D空間中指定頂點,必定使用三維座標。這個頂點座標需要經過頂點著色器處理後最終才能用於生產三角形,這裡面涉及到座標轉換等內容,本節不做深入探討。經過座標轉換後,頂點座標最終落在規範化裝置座標系(normalized device coordinate , NDC)中, NDC中座標範圍均為[-1,1],因此這裡我們簡化處理,將頂點座標全部定在這個範圍內,指定為:
GLfloat vertices[] = {
-0.5f, 0.0f, 0.0f,
0.5f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f
};
將資料傳送到GPU中需要通過函式glBufferData實現。
API void glBufferData( GLenum target,
GLsizeiptr size,
const GLvoid * data,
GLenum usage);
1.函式中target引數表示繫結的目標,包括像GL_ARRAY_BUFFER用於Vertex attributes(頂點屬性),GL_ELEMENT_ARRAY_BUFFER用於索引繪製等目標。
2.size引數表示需要分配的空間大小,以位元組為單位。
3.data引數用於指定資料來源,如果data不為空將會拷貝其資料來初始化這個緩衝區,否則只是分配預定大小的空間。預分配空間後,後續可以通過glBufferSubData來更新緩衝區內容。
4.usage引數指定資料使用模式,例如GL_STATIC_DRAW指定為靜態繪製,資料保持不變, GL_DYNAMIC_DRAW指定為動態繪製,資料會經常更新。
我們這裡繪製一個靜態的三角形,vertex attribute頂點屬性這個概念包括頂點的位置、紋理座標、法向量、顏色等屬性資料,因此我們的頂點位置資料適合繫結到GL_ARRAY_BUFFER目標,同時資料在傳送時初始化緩衝區,因此可以這樣實現:
glBindBuffer(GL_ARRAY_BUFFER, VBOId);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
Step3: 通知OpenGL如何解釋這個頂點屬性陣列
將資料傳送到GPU後,我們還需要告知OpenGL如何解釋這個資料,也就是告知其資料格式,因為從底層來看資料一個位元組塊而已。要通知OpenGL如何解釋資料,要使用函式glVertexAttribPointer.
API void glVertexAttribPointer( GLuint index,
GLint size,
GLenum type,
GLboolean normalized,
GLsizei stride,
const GLvoid * pointer);
1. 引數index 表示頂點屬性的索引 這個索引即是在頂點著色器中的屬性索引,索引從0開始記起。
2. 引數size 每個屬性資料由幾個分量組成。例如上面頂點每個屬性為3個float組成的,size即為3。分量的個數必須為1,2,3,4這四個值之一。
3. 引數type表示屬性分量的資料型別,例如上面的頂點資料為float則填寫GL_FLOAT.
4. 引數normalized 表示是否規格化,當儲存整型時,如果設定為GL_TRUE,那麼當被以浮點數形式訪問時,有符號整型轉換到[-1,1],無符號轉換到[0,1]。否則直接轉換為float型,而不進行規格化。
5. 引數stride表示連續的兩個頂點屬性之間的間隔,以位元組大小計算。當頂點屬性緊密排列(tightly packed)時,可以填0,由OpenGL代替我們計算出該值。
6. 引數pointer表示當前繫結到 GL_ARRAY_BUFFER緩衝物件的緩衝區中,頂點屬性的第一個分量距離資料的起點的偏移量,以位元組為單位計算。
上面這個函式是很重要的,剛接觸時可能對多個引數感到厭煩,慢慢就會習慣。這裡以上述包含頂點位置的屬性陣列為例,做一個圖解(來自:learn opengl):
這裡我們可以看出,呼叫上述函式時,屬性索引為0(稍後著色器中會與之對應), 屬性的分量個數為3,分量的資料型別為GL_FLOAT, normalized設為GL_FALSE, 引數stride為3*sizeof(GL_FLOAT)=12,
pointer的偏移量為0,但是要寫為(GLvoid*)0(強制轉換),具體如下所示:
glVertexAttribPointer(0, 3, GL_FLOAT,
3 * sizeof(GL_FLOAT), (GLvoid*)0);
glEnableVertexAttribArray(0);
關於glVertexAttribPointer函式中stride和offset函式的詳細解釋,你還可以參考我的另一篇關於buffer object的文章。
這樣我們建立了VBO,並將資料傳送到GPU,並告知了OpenGL如何解析這些資料。在整個過程中,我們呼叫了很多函式,如果在以後繪製時好需要繼續呼叫這些函式,那將會多麼麻煩,因此這時候VAO就起到了關鍵作用。VAO能記錄VBO的相關資訊,在以後繪圖時,只需要繫結對應的VAO就能找到這些狀態,方便OPenGL使用。因此,在建立VBO這一過程中,我們要使用VAO來記錄。方法便是,在所有VBO操作之前,先建立和繫結VAO。
繪製三角形時建立VAO和VBO的最終的程式碼如下:
// 指定頂點屬性資料 頂點位置
GLfloat vertices[] = {
-0.5f, 0.0f, 0.0f,
0.5f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f
};
// 建立快取物件
GLuint VAOId, VBOId;
// Step1: 建立並繫結VAO物件
glGenVertexArrays(1, &VAOId);
glBindVertexArray(VAOId);
// Step2: 建立並繫結VBO物件
glGenBuffers(1, &VBOId);
glBindBuffer(GL_ARRAY_BUFFER, VBOId);
// Step3: 分配空間 傳送資料
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// Step4: 指定解析方式 並啟用頂點屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 解除繫結
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
在程式碼的最後,我們暫時解除繫結,能夠防止後續操作干擾到了當前VAO和VBO。
現在在程式中使用VAO繪製三角形則只需要呼叫:
glBindVertexArray(VAOId); // 使用VAO資訊
glUseProgram(shaderProgramId); // 使用著色器
glDrawArrays(GL_TRIANGLES, 0, 3);
這裡使用著色器,稍後介紹。glDrawArrays函式使用VBO資料繪製物體。其使用方法為:
API void glDrawArrays( GLenum mode,
GLint first,
GLsizei count);
1.mode 引數表示繪製的基本型別,OpenGL預製了 GL_POINTS, GL_LINE_STRIP等基本型別。一個複雜的圖形,都是有這些基本型別構成的。
2.first表示啟用的頂點屬性陣列中第一個資料的索引。
3.count表示繪製需要的頂點數目。
上述呼叫時我們選擇GL_TRIANGLES表示繪製三角形,使用3個頂點。
著色器程式
目前我們主要使用頂點著色器和片元著色器。對於著色器,採用的是GLSL語言(OpenGL Shading Language)編寫的程式,類似於C語言程式。
要使用著色器需要經歷3個步驟:
- 建立和編譯shader object
- 建立shader program,連結多個shader object到program
- 在繪製場景時啟用shader program
具體流程如下圖所示:
在上面的流程中,一個著色器程式物件可以包含多個著色器物件,例如頂點著色器(vertex shader)、幾何著色器(geometry shader,後續介紹)、片元著色器(fragment shader)。我們也可以將著色器原始碼放在程式程式碼中,當然這一做法僅作為示例,不值得提倡。
我們這裡寫一個簡單的直通著色器,在頂點著色器中輸出傳入的頂點位置,在片元著色器中輸出指定顏色。實際應用中這兩個程式將決定圖形最終效果,這裡只是做一個簡單示例。
頂點著色器程式碼為:
#version 330 // 指定GLSL版本3.3
layout(location = 0) in vec3 position; // 頂點屬性索引
void main()
{
gl_Position = vec4(position, 1.0); // 輸出頂點
}
其中gl_Position為內建變數,表示頂點輸出位置,以gl_字首開頭的一般都表示內建變數。position宣告為vec3型別, vec3表示3個float型別的向量。gl_Positon為vec4型別,其中第四個分量為1.0,關於這個分量後面會做介紹。
片元著色器程式碼為:
#version 330
out vec4 color; // 輸出片元顏色
void main()
{
color = vec4(0.8, 0.8, 0.0, 1.0);
}
通過color指定最終顏色為黃色,vec4型別表示顏色為RGB再加上alpha值構成最終的輸出顏色。關於alpha值後面會介紹。
首先建立頂點和片元著色器物件,要注意其中的錯誤處理。其中頂點著色器程式碼如下:
const GLchar* vertexShaderSource = "#version 330\n"
"layout(location = 0) in vec3 position;\n"
"void main()\n"
"{\n gl_Position = vec4(position, 1.0);\n}";
// Step2 建立Shader object
// 頂點著色器
GLuint vertexShaderId = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShaderId, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShaderId);
GLint compileStatus = 0;
glGetShaderiv(vertexShaderId, GL_COMPILE_STATUS, &compileStatus); // 檢查編譯狀態
if (compileStatus == GL_FALSE) // 獲取錯誤報告
{
GLint maxLength = 0;
glGetShaderiv(vertexShaderId, GL_INFO_LOG_LENGTH, &maxLength);
std::vector<GLchar> errLog(maxLength);
glGetShaderInfoLog(vertexShaderId, maxLength, &maxLength, &errLog[0]);
std::cout << "Error::shader vertex shader compile failed," << &errLog[0] << std::endl;
}
片元著色器也有類似處理,然後建立並連線,形成shader program物件,程式碼如下:
GLuint shaderProgramId = glCreateProgram();// 建立program
glAttachShader(shaderProgramId, vertexShaderId);
glAttachShader(shaderProgramId, fragShaderId);
glLinkProgram(shaderProgramId);
GLint linkStatus;
glGetProgramiv(shaderProgramId, GL_LINK_STATUS, &linkStatus);
if (linkStatus == GL_FALSE)
{
GLint maxLength = 0;
glGetProgramiv(shaderProgramId, GL_INFO_LOG_LENGTH, &maxLength);
std::vector<GLchar> errLog(maxLength);
glGetProgramInfoLog(shaderProgramId, maxLength, &maxLength, &errLog[0]);
std::cout << "Error::shader link failed," << &errLog[0] << std::endl;
}
注意: 在shader object連結到program後,即可斷開連結,如果不需要再連結到其他program,比較好的做法就是釋放資源:
// 連結完成後detach
glDetachShader(shaderProgramId, vertexShaderId);
glDetachShader(shaderProgramId, fragShaderId);
// 不需要連線到其他程式時 釋放空間
glDeleteShader(vertexShaderId);
glDeleteShader(fragShaderId);
繪製三角形
通過上面使用VAO和VBO完成了資料儲存和解析部分工作,通過著色器完成了圖形的渲染,將這兩個部分組成一起,我們便可以繪製我們的三角形了。執行結果如下圖所示:
注意如果著色器程式失敗,我們得到的圖形如下圖所示:
失敗時請檢查著色器程式碼部分。
重構程式碼
將上述著色器程式碼,分裝成一個shader類,這個類從檔案讀取著色器原始碼,並建立著色器程式,是程式碼更簡潔。使用shader類的專案結構為:
使用著色器類建立著色器程式碼簡化為:
Shader shader("triangle.vertex", "triangle.frag");
著色器程式只讀取程式原始碼,與檔名稱和型別無關。
使用封裝的類實現的三角形繪製版本和shader類的程式碼可以從github下載。
新增頂點的顏色屬性
上面繪製的三角形,使用的顏色是在片元著色器中指定的,我們可以通過vertex attribute指定頂點顏色屬性,同頂點位置屬性一樣傳送給著色器。修改頂點屬性陣列資料為:
GLfloat vertices[] = {
// 頂點座標 頂點顏色
-0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f
};
我們需要重新指定OpenGL解析資料的方式,需要更新著色器。
Step1: 首先通過glVertexAttribPointer重新解釋資料,程式碼變為:
// Step4: 指定解析方式 並啟用頂點屬性
// 頂點位置屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(GL_FLOAT), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 頂點顏色屬性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(GL_FLOAT), (GLvoid*)(3 * sizeof(GL_FLOAT)));
glEnableVertexAttribArray(1);
這裡注意glVertexAttribPointer的引數設定,stride和pointer引數解釋如下圖所示(來自www.learnopengl.com):
即頂點位置和顏色的stride即都為 6 * sizeof(GL_FLOAT) = 24, 頂點位置資料首地址偏移量為0,而顏色資料首地址偏移量為 3 * sizeof(GL_FLOAT) = 12。
Step2 更新著色器,在頂點著色器中為顏色屬性指定索引為1,更新後的頂點著色器為:
#version 330
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 color;
out vec3 vertColor;
void main()
{
gl_Position = vec4(position, 1.0);
vertColor = color; // 輸出頂點顏色
}
這裡通過定義location = 1將顏色屬性索引設為1,同時直接輸出到片元著色器。片元著色器為:
#version 330
in vec3 vertColor;
out vec4 color;
void main()
{
color = vec4(vertColor, 1.0);
}
片元著色器接受頂點著色器輸出的顏色vertexColor,然後直接作為最終片元顏色。注意在頂點著色器和片元著色器之間傳遞變數時,要求變數的型別和名字必須一致,例如這裡的vertexColor變數。
更新後的程式,執行效果如下圖所示:
完整的程式碼可以從github下載。
如上圖所示,我們在頂點屬性中指定了紅綠藍三個顏色,實際在產生圖形時生成更多的片元,這些片元的顏色是通過顏色插值(Color Interpolation)來生成的。顏色插值的一種方法是線性插值,例如一條直線一端點指定為紅色,另一端點指定為綠色,則位於中間部分點的顏色,可以按照公式:
來生成顏色。當t=0時,取值為a表示顏色為紅色,t=1時取值為b表示為綠色,當t取值在0和1之間時,則按照比例混合紅色和綠色生成最終的顏色。上圖中除了頂點以外的顏色,其餘部分都是通過顏色插值得來的。顏色插值理論後面還要深入瞭解。
使用模板快速獲取本節工程
有網友留言索要整個工程,因為github上面上傳二進位制的VS工程不太合適,這裡製作了一個方便的模板供使用,可以從我的github下載。模板使用方法:
Step1: 將模板getting-started.zip拷貝值VS的專案模板目錄,如下圖所示:
Step2: 使用模板新建工程,如下圖:
Step3: 將libraries拷貝至新建專案的同級目錄下。
Step4: 編譯執行新建工程即可。