1. 程式人生 > >OpenGL學習腳印: 繪製一個三角形

OpenGL學習腳印: 繪製一個三角形

寫在前面
接著上一節內容,開發環境搭建好後,我們當然想立即編寫3D應用程式了。不過我們還需要些耐心,因為OpenGL是一套底層的API,因而我們要掌握的基本知識稍微多一點,在開始繪製3D圖形之前,本節我們將通過繪製一個三角形的程式來熟悉現代OpenGL的概念和流程。

通過本節可以瞭解到:

  • 快取物件VAO和VBO
  • GLSL著色器程式的編譯、連結和使用方法
  • OpenGL繪圖的基本流程

繪圖流水線簡要了解

與使用高階繪圖API(例如java裡swing繪圖,MFC裡的繪圖)不同,使用OpenGL繪製圖形時需要對底層知識有所瞭解。在現代OpenGL中,完成圖形繪製的流水線與舊版的固定流水線有所不同,現代OpenGL程式中允許使用者自己定製著色器,這使得繪圖更靈活。現代繪圖流水線如下圖所示(來自:

opengl wiki Rendering_Pipeline_Overview):
這裡寫圖片描述
這個繪圖流水線是比較複雜的,初學時只需要關注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個步驟:

  1. 建立和編譯shader object
  2. 建立shader program,連結多個shader object到program
  3. 在繪製場景時啟用shader program

具體流程如下圖所示:

Created with Raphaël 2.1.0著色器原始檔(shader file)讀取原始碼(read source)編譯原始碼(compile source)連結著色器物件(link shader objects)著色器程式物件(program object)

在上面的流程中,一個著色器程式物件可以包含多個著色器物件,例如頂點著色器(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)來生成的。顏色插值的一種方法是線性插值,例如一條直線一端點指定為紅色,另一端點指定為綠色,則位於中間部分點的顏色,可以按照公式:
y=a+(ba)t
來生成顏色。當t=0時,取值為a表示顏色為紅色,t=1時取值為b表示為綠色,當t取值在0和1之間時,則按照比例混合紅色和綠色生成最終的顏色。上圖中除了頂點以外的顏色,其餘部分都是通過顏色插值得來的。顏色插值理論後面還要深入瞭解。

使用模板快速獲取本節工程

有網友留言索要整個工程,因為github上面上傳二進位制的VS工程不太合適,這裡製作了一個方便的模板供使用,可以從我的github下載。模板使用方法:

Step1: 將模板getting-started.zip拷貝值VS的專案模板目錄,如下圖所示:
安裝模板

Step2: 使用模板新建工程,如下圖:

新建工程

Step3: 將libraries拷貝至新建專案的同級目錄下。
拷貝libraries

Step4: 編譯執行新建工程即可。

瞭解更多

參考資料