參考資料:OpenGL中文翻譯

一、銜接

在上一個教程中,我們學習了如何有效地利用矩陣的變換來對所有頂點進行變換。OpenGL希望在每次頂點著色器執行後,我們可見的所有頂點都為標準化裝置座標(Normalized Device Coordinate, NDC)。也就是說,每個頂點的x,y,z座標都應該在-1.0到1.0之間超出這個座標範圍的頂點都將不可見。我們通常會自己設定一個座標的範圍,之後再在頂點著色器中將這些座標變換為標準化裝置座標。然後將這些標準化裝置座標傳入光柵器(Rasterizer),將它們變換為螢幕上的二維座標或畫素

(1)將座標變換為標準化裝置座標(2)接著再轉化為螢幕座標的過程通常是分步進行的,也就是類似於流水線那樣子。在流水線中,物體的頂點在最終轉化為螢幕座標之前還會被變換到多個座標系統(Coordinate System)。將物體的座標變換到幾個過渡座標系(Intermediate Coordinate System)的優點在於,在這些特定的座標系統中,一些操作或運算更加方便和容易,這一點很快就會變得很明顯。對我們來說比較重要的總共有5個不同的座標系統

  1. 區域性空間(Local Space,或者稱為物體空間(Object Space))
  2. 世界空間(World Space)
  3. 觀察空間(View Space,或者稱為視覺空間(Eye Space))
  4. 裁剪空間(Clip Space)
  5. 螢幕空間(Screen Space)

這就是一個頂點在最終被轉化為片段之前需要經歷的所有不同狀態

二、概述

為了將座標從一個座標系變換到另一個座標系,我們需要用到幾個變換矩陣,最重要的幾個分別是模型(Model)觀察(View)投影(Projection)三個矩陣。我們的頂點座標起始於區域性空間(Local Space),在這裡它稱為區域性座標(Local Coordinate),它在之後會變為世界座標(World Coordinate)觀察座標(View Coordinate)裁剪座標(Clip Coordinate),並最後以螢幕座標(Screen Coordinate)的形式結束。下面的這張圖展示了整個流程以及各個變換過程做了什麼:

  1. 區域性座標是物件相對於區域性原點的座標,也是物體起始的座標
  2. 下一步是將區域性座標變換為世界空間座標,世界空間座標是處於一個更大的空間範圍的。這些座標相對於世界的全域性原點,它們會和其它物體一起相對於世界的原點進行擺放
  3. 接下來我們將世界座標變換為觀察空間座標,使得每個座標都是從攝像機或者說觀察者的角度進行觀察的。
  4. 座標到達觀察空間之後,我們需要將其投影到裁剪座標。裁剪座標會被處理至-1.0到1.0的範圍內,並判斷哪些頂點將會出現在螢幕上
  5. 最後,我們將裁剪座標變換為螢幕座標,我們將使用一個叫做視口變換(Viewport Transform)的過程。視口變換將位於-1.01.0範圍的座標變換到由glViewport函式所定義的座標範圍內。最後變換出來的座標將會送到光柵器,將其轉化為片段。

三、各個座標系統

區域性空間

區域性空間是指物體所在的座標空間,即物件最開始所在的地方。想象你在一個建模軟體(比如說Blender)中建立了一個立方體。你建立的立方體的原點有可能位於(0, 0, 0),即便它有可能最後在程式中處於完全不同的位置。甚至有可能你建立的所有模型都以(0, 0, 0)為初始位置(譯註:然而它們會最終出現在世界的不同位置)。所以,你的模型的所有頂點都是在區域性空間中:它們相對於你的物體來說都是區域性的

我們一直使用的那個箱子的頂點是被設定在-0.5到0.5的座標範圍中,(0, 0)是它的原點。這些都是區域性座標

世界空間

如果我們將我們所有的物體匯入到程式當中,它們有可能會全擠在世界的原點(0, 0, 0)上,這並不是我們想要的結果。我們想為每一個物體定義一個位置,從而能在更大的世界當中放置它們。世界空間中的座標正如其名:是指頂點相對於(遊戲)世界的座標。如果你希望將物體分散在世界上擺放(特別是非常真實的那樣),這就是你希望物體變換到的空間。物體的座標將會從區域性變換到世界空間;該變換是由模型矩陣(Model Matrix)實現的

模型矩陣是一種變換矩陣,它能通過對物體進行位移、縮放、旋轉將它置於它本應該在的位置或朝向。你可以將它想像為變換一個房子,你需要先將它縮小(它在區域性空間中太大了),並將其位移至郊區的一個小鎮,然後在y軸上往左旋轉一點以搭配附近的房子。你也可以把上一節將箱子到處擺放在場景中用的那個矩陣大致看作一個模型矩陣;我們將箱子的區域性座標變換到場景/世界中的不同位置。

觀察空間

觀察空間經常被人們稱之OpenGL的攝像機(Camera)(所以有時也稱為攝像機空間(Camera Space)視覺空間(Eye Space))。觀察空間是世界空間座標轉化為使用者視野前方的座標而產生的結果。因此觀察空間就是從攝像機的視角所觀察到的空間。而這通常是由一系列的位移和旋轉的組合來完成,平移/旋轉場景從而使得特定的物件被變換到攝像機的前方。這些組合在一起的變換通常儲存在一個觀察矩陣(View Matrix)裡,它被用來將世界座標變換到觀察空間。在下一節中我們將深入討論如何建立一個這樣的觀察矩陣來模擬一個攝像機。

裁剪空間

在一個頂點著色器執行的最後,OpenGL 期望所有的座標都能落在一個特定的範圍內,且任何在這個範圍之外的點都應該被裁剪掉(Clipped)。被裁剪掉的座標就會被忽略,所以剩下的座標就將變為螢幕上可見的片段。這也就是裁剪空間(Clip Space)名字的由來。

因為將所有可見的座標都指定在-1.0到1.0的範圍內不是很直觀,所以我們會指定自己的座標集(Coordinate Set)將它變換回標準化裝置座標系,就像OpenGL期望的那樣。

為了將頂點座標從觀察變換到裁剪空間,我們需要定義一個投影矩陣(Projection Matrix),它指定了一個範圍的座標,比如在每個維度上的-1000到1000。投影矩陣接著會將在這個指定的範圍內的座標變換為標準化裝置座標的範圍(-1.0, 1.0)。所有在範圍外的座標不會被對映到在-1.0到1.0的範圍之間,所以會被裁剪掉

tips: 例如在上面這個投影矩陣所指定的範圍內,座標(1250, 500, 750)將是不可見的,這是由於它的x座標超出了範圍,它被轉化為一個大於1.0的標準化裝置座標,所以被裁剪掉了。

投影矩陣建立的觀察箱(Viewing Box)被稱為平截頭體(Frustum),每個出現在平截頭體範圍內的座標都會最終出現在使用者的螢幕上

將特定範圍內的座標轉化到標準化裝置座標系的過程(而且它很容易被對映到2D觀察空間座標)被稱之為投影(Projection),因為使用投影矩陣能將3D座標很容易對映到2D的標準化裝置座標系中。

一旦所有頂點被變換到裁剪空間,最終的操作——透視除法(Perspective Division)將會執行,在這個過程中我們將位置向量的x,y,z分量分別除以向量的齊次w分量;透視除法是將4D裁剪空間座標變換為3D標準化裝置座標的過程。這一步會在每一個頂點著色器執行的最後被自動執行。

在這一階段之後,最終的座標將會被對映到螢幕空間中(使用glViewport中的設定),並被變換成片段

將觀察座標變換為裁剪座標的投影矩陣可以為兩種不同的形式,每種形式都定義了不同的平截頭體。我們可以選擇建立一個正射投影矩陣(Orthographic Projection Matrix)或一個透視投影矩陣(Perspective Projection Matrix)

四、兩種投影矩陣

正射投影

正射投影矩陣定義了一個類似立方體的平截頭箱。建立一個正射投影矩陣需要指定可見平截頭體的長寬高。它的平截頭體看起來像一個容器:



上面的平截頭體定義了可見的座標,它由由寬、高、近(Near)平面和遠(Far)平面所指定。任何出現在近平面之前或遠平面之後的座標都會被裁剪掉。正射平截頭體直接將平截頭體內部的所有座標對映為標準化裝置座標,因為每個向量的w分量都沒有進行改變如果w分量等於1.0,透視除法則不會改變這個座標

我們可以使用GLM的內建函式glm::ortho來建立一個正射投影矩陣:

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

ortho()函式引數:

  1. 左邊座標
  2. 右邊座標
  3. 底部座標
  4. 頂部座標
  5. 近平面距離
  6. 遠平面距離

前兩個引數指定了平截頭體的左右座標,第三和第四引數指定了平截頭體的底部和頂部。通過這四個引數我們定義了近平面和遠平面的大小,然後第五和第六個引數則定義了近平面和遠平面的距離。這個投影矩陣會將處於這些x,y,z值範圍內的座標變換為標準化裝置座標

正射投影矩陣直接將座標對映到2D平面中,即你的螢幕,但實際上一個直接的投影矩陣會產生不真實的結果,因為這個投影沒有將透視(Perspective)考慮進去。所以我們需要透視投影矩陣來解決這個問題。

透視投影

在生活中觀察可以發現離你越遠的東西看起來更小。這個奇怪的效果稱之為透視(Perspective)。透視的效果在我們看一條無限長的高速公路或鐵路時尤其明顯,正如下面圖片顯示的那樣:



正如你看到的那樣,由於透視,這兩條線在很遠的地方看起來會相交。這正是透視投影想要模仿的效果,它是使用透視投影矩陣來完成的。這個投影矩陣將給定的平截頭體範圍對映到裁剪空間,除此之外還修改了每個頂點座標的w值,從而使得離觀察者越遠頂點座標w分量越大。被變換到裁剪空間的座標都會在-w到w的範圍之間(任何大於這個範圍的座標都會被裁剪掉)。OpenGL要求所有可見的座標都落在-1.0到1.0範圍內,作為頂點著色器最後的輸出,因此,一旦座標在裁剪空間內之後,透視除法就會被應用到裁剪空間座標上

\[out = \begin{pmatrix}
x/w\\
y/w\\
z/w
\end{pmatrix}
\]

頂點座標的每個分量都會除以它的w分量,距離觀察者越遠頂點座標就會越小。這是也是w分量非常重要的另一個原因,它能夠幫助我們進行透視投影。最後的結果座標就是處於標準化裝置空間中的。

在GLM中可以這樣建立一個透視投影矩陣:

glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

同樣,glm::perspective所做的其實就是建立了一個定義了可視空間的大平截頭體,任何在這個平截頭體以外的東西最後都不會出現在裁剪空間體積內,並且將會受到裁剪。一個透視平截頭體可以被看作一個不均勻形狀的箱子,在這個箱子內部的每個座標都會被對映到裁剪空間上的一個點。下面是一張透視平截頭體的圖片:

perspective()函式的引數:

  1. fov的值,它表示的是視野(Field of View),並且設定了觀察空間的大小。如果想要一個真實的觀察效果,它的值通常設定為45.0f,但想要一個末日風格的結果你可以將其設定一個更大的值

  2. 寬高比,由window的寬除以高所得

  3. 平截頭體的平面。

  4. 平截頭體的平面。

    我們通常設定近距離0.1f,而遠距離設為100.0f。所有在近平面和遠平面內且處於平截頭體內的頂點都會被渲染。

tips: 當你把透視矩陣的 near 值設定太大時(如10.0f),OpenGL會將靠近攝像機的座標(在0.0f和10.0f之間)都裁剪掉,這會導致一個你在遊戲中很熟悉的視覺效果:在太過靠近一個物體的時候你的視線會直接穿過去。

五、把它們都組合到一起

我們為上述的每一個步驟都建立了一個變換矩陣:(1)模型矩陣、(2)觀察矩陣和(3)投影矩陣。一個頂點座標將會根據以下過程被變換到裁剪座標

\[V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local}
\]

注意矩陣運算的順序是相反的(記住我們需要從右往左閱讀矩陣的乘法)。最後的頂點應該被賦值到頂點著色器中的gl_PositionOpenGL將會自動進行透視除法和裁剪。

然後呢?

頂點著色器的輸出要求所有的頂點都在裁剪空間內,這正是我們剛才使用變換矩陣所做的。OpenGL然後對裁剪座標執行透視除法從而將它們變換到標準化裝置座標。OpenGL會使用glViewPort內部的引數來將標準化裝置座標對映到螢幕座標,每個座標都關聯了一個螢幕上的點(在我們的例子中是一個800x600的螢幕)。這個過程稱為視口變換。

六、編碼實現

1. 實現卡片旋轉

  1. 首先建立一個模型矩陣。這個模型矩陣包含了位移、縮放與旋轉操作,它們會被應用到所有物體的頂點上,以變換它們到全域性的世界空間。讓我們變換一下我們的平面,將其繞著x軸旋轉,使它看起來像放在地上一樣。這個模型矩陣看起來是這樣的:
glm::mat4 model;
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));

通過將頂點座標乘以這個模型矩陣,我們將該頂點座標變換到世界座標。我們的平面看起來就是在地板上,代表全域性世界裡的平面。

  1. 接下來我們需要建立一個觀察矩陣。我們想要在場景裡面稍微往後移動,以使得物體變成可見的(當在世界空間時,我們位於原點(0,0,0))。要想在場景裡面移動,先仔細想一想下面這個句子:

將攝像機向後移動,和將整個場景向前移動是一樣的。

這正是觀察矩陣所做的,我們以相反於攝像機移動的方向移動整個場景。因為我們想要往後移動,並且OpenGL是一個右手座標系(Right-handed System),所以我們需要沿著z軸的正方向移動。我們會通過將場景沿著z軸負方向平移來實現。它會給我們一種我們在往後移動的感覺。

右手座標系(Right-handed System)

按照慣例,OpenGL是一個右手座標系。簡單來說,就是正x軸在你的右手邊,正y軸朝上,而正z軸是朝向後方的。想象你的螢幕處於三個軸的中心,則正z軸穿過你的螢幕朝向你。座標系畫起來如下:

在下一個教程中我們將會詳細討論如何在場景中移動。就目前來說,觀察矩陣是這樣的:

glm::mat4 view;
// 注意,我們將矩陣向我們要進行移動場景的反方向移動。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
  1. 最後我們需要做的是定義一個投影矩陣。我們希望在場景中使用透視投影,所以像這樣宣告一個投影矩陣:
glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);

然後我們需要將變換矩陣傳入著色器中。

首先,讓我們在頂點著色器中宣告一個uniform變換矩陣然後將它乘以頂點座標

#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection; void main()
{
// 注意乘法要從右向左讀
gl_Position = projection * view * model * vec4(aPos, 1.0);
...
}

然後我們在主迴圈中將矩陣傳入到著色器中即可:

glm::value_ptr(model));view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
glUniformMatrix4fv(glGetUniformLocation(shader.program, "view"),1,GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(glGetUniformLocation(shader.program, "projection"),1,GL_FALSE, glm::value_ptr(projection));

執行結果:

進而我們還可以使它隨著時間的變化而變化(修改model矩陣) :

model = glm::rotate(model, (float)glm::radians(glfwGetTime()*50), glm::vec3(0.0f, 1.0f, 0.0f));

然後我們就能看到我們的正方形像一張卡片一樣旋轉起來了:

2. 實現正方體旋轉

我們先準備正方體的六個面的頂點座標資料:官網給出的資料

因為使用這個資料時取消了顏色的資料,所以我們需要修改填入資料的規則,即glVertexAttribPointer()函式的引數:

// 位置屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 紋理屬性
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1);

然後頂點著色器片段著色器也需要進行相應的修改,這裡不再贅述。

此外我們需要使用glDrawArrays來繪製立方體(不再使用索引緩衝了),這一次有36個頂點:

glDrawArrays(GL_TRIANGLES, 0, 36);

一個面有6個頂點(兩個三角形形成一個正方形),一共6個面,索引一共有36個頂點。

然後在執行時,我們會得到非常奇怪的結果,這是因為立方體的某些本應被遮擋住的面繪製在了這個立方體其他面之上,即前後遮掩關係不符合。

幸運的是,OpenGL儲存深度資訊在一個叫做Z緩衝(Z-buffer)的緩衝中,它允許OpenGL決定何時覆蓋一個畫素而何時不覆蓋。通過使用Z緩衝,我們可以配置OpenGL來進行深度測試

Z緩衝

OpenGL儲存它的所有深度資訊於一個Z緩衝(Z-buffer)中,也被稱為深度緩衝(Depth Buffer)。GLFW會自動為你生成這樣一個緩衝(就像它也有一個顏色緩衝來儲存輸出影象的顏色)。深度值儲存在每個片段裡面(作為片段的z值),當片段想要輸出它的顏色時,OpenGL將它的深度值和z緩衝進行比較,如果當前的片段在其它片段之後,它將會被丟棄,否則將會覆蓋。這個過程稱為深度測試(Depth Testing),它是由OpenGL自動完成的。

OpenGL的深度測試預設是關閉的,需要我們手動來開啟它,glEnableglDisable函式允許我們啟用或禁用某個OpenGL功能。這個功能會一直保持啟用/禁用狀態,直到另一個呼叫來禁用/啟用它。而此時我們需要開啟GL_DEPTH_TEST

glEnable(GL_DEPTH_TEST);

因為我們使用了深度測試,我們也想要在每次渲染迭代之前清除深度緩衝(否則前一幀的深度資訊仍然儲存在緩衝中)。就像清除顏色緩衝一樣,我們可以通過在glClear函式中指定DEPTH_BUFFER_BIT位來清除深度緩衝(位於每次主迴圈的前面渲染部分):

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

執行結果:

3. 繪製更多的立方體

現在我們想在螢幕上顯示10個立方體。每個立方體看起來都是一樣的,區別在於它們在世界的位置及旋轉角度不同。立方體的圖形佈局已經定義好了,所以當渲染更多物體的時候我們不需要改變我們的緩衝陣列和屬性陣列,我們唯一需要做的只是改變每個物件的模型矩陣來將立方體變換到世界座標系中

我們將在一個glm::vec3陣列中定義10個立方體位置:

glm::vec3 cubePositions[] = {
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 2.0f, 5.0f, -15.0f),
glm::vec3(-1.5f, -2.2f, -2.5f),
glm::vec3(-3.8f, -2.0f, -12.3f),
glm::vec3( 2.4f, -0.4f, -3.5f),
glm::vec3(-1.7f, 3.0f, -7.5f),
glm::vec3( 1.3f, -2.0f, -2.5f),
glm::vec3( 1.5f, 2.0f, -2.5f),
glm::vec3( 1.5f, 0.2f, -1.5f),
glm::vec3(-1.3f, 1.0f, -1.5f)
};

現在,在遊戲迴圈中,我們呼叫glDrawArrays 10次,但這次在我們渲染之前每次傳入一個不同的模型矩陣到頂點著色器中。我們將會在遊戲迴圈中建立一個小的迴圈用不同的模型矩陣渲染我們的物體10次。注意我們也對每個箱子加了一點旋轉:

glBindVertexArray(VAO);
for (int i = 0; i < 10; ++i) {
// 準備轉換矩陣
glm::mat4 model;
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * (float)i + glfwGetTime()*50;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f)); glUniformMatrix4fv(glGetUniformLocation(shader.program, "model"),1,GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 36);
}

執行結果: