OpenGL學習腳印: 視變換(view transformation)

寫在前面
OpenGL中的座標處理過程包括模型變換、視變換、投影變換、視口變換等內容,這個主題的內容有些多,因此分節學習,主題將分爲5節內容來學習。上一節模型變換,本節學習模型變換的下一階段——視變換。到目前位置,主要在2D下編寫程序,學習了視變換後,我們可以看到3D應用的效果了。本節示例程序均可在我的github下載

通過本節可以瞭解到

  • 視變換的概念
  • 索引繪製立方體
  • LookAt矩陣的推導(對數學不感興趣,可以跳過)
  • 相機位置隨時間改變的應用程序

座標處理的全局過程(瞭解,另文詳述)

OpenGL中的座標處理包括模型變換、視變換、投影變換、視口變換等內容,具體過程如下圖1所示:

座標處理過程

每一個過程處理都有其原因,這些內容計劃將會在不同節裏分別介紹,最後再整體把握一遍。
今天我們學習第二個階段——視變換。

並不存在真正的相機

OpenGL成像採用的是虛擬相機模型。在場景中你通過模型變換,將物體放在場景中不同位置後,最終哪些部分需要成像,顯示在屏幕上,主要由視變換和後面要介紹的投影變換、視口變換等決定。

其中視變換階段,通過假想的相機來處理矩陣計算能夠方便處理。對於OpenGL來說並不存在真正的相機,所謂的相機座標空間(camera space 或者eye space)只是爲了方便處理,而引入的座標空間。

在現實生活中,我們通過移動相機來拍照,而在OpenGL中我們通過以相反方式調整物體,讓物體以適當方式呈現出來。例如,初始時,相機鏡頭指向-z軸,要觀察-z軸上的一個立方體的右側面,那麼有兩種方式:

  1. 相機繞着+y軸,旋轉+90度,此時相機鏡頭朝向立方體的右側面,實現目的。注意這時立方體並沒有轉動。

  2. 相機不動,讓立方體繞着+y軸,旋轉-90度,此時也能實現同樣的目的。注意這時相機沒有轉動。完成這一旋轉的矩陣記作Ry(π2)

在OpenGL中,採用方式2來完成物體成像的調整。例如下面的圖表示了假想的相機:

相機


進一步說明

進一步說明這裏相對的概念,對這個概念不感興趣的可以跳過。默認時相機位於(0,0,0),指向-z軸,相當於調用了:

glm::lookAt(glm::vec(0.0f,0.0f,0.0f),
        glm::vec3(0.0f, 0.0f, -1.0f),
        glm::vec3(0.0f, 1.0f, 0.0f)),

得到是單位矩陣,這是相機的默認情況。

上述第一種方式,相機繞着+y軸旋轉90度,相機指向-x軸,則等價於調用變爲:

   glm::mat4 view =glm::lookAt(glm::vec(0.0f,0.0f,0.0f),
        glm::vec3(-1.0f, 0.0f, 0.0f),
        glm::vec3(0.0f, 1.0f, 0.0f)),

得到的視變換矩陣爲:

view=0010010010000001

上述第二種方式,通過立方體繞着+y軸旋轉-90度,則得到的矩陣M,相當於:

   glm::mat4 model = glm::rotate(glm::mat4(1.0), glm::radians(-90.0f), glm::vec3(0.0, 1.0, 0.0));

這裏得到的矩陣M和上面的矩陣view是相同的,可以自行驗證下。
也就是說,通過旋轉相機+y軸90度,和旋轉立方體+y軸-90度,最終計算得到的矩陣相同。調整相機來得到觀察效果,可以通過相應的方式來調整物體達到相同的效果。在OpenGL中並不存在真正的相機,這只是一個虛構的概念。


視變換矩陣的推導(瞭解,對數學不感興趣可跳過)

相機座標系由相機位置eye和UVN基向量(或者說由forward, side ,up)構成,如下圖所示:

相機座標系
各個參數的含義如下:

  • 相機位置 也稱爲觀察參考點 (View Reference Point) 在世界座標系下指定相機的位置eye。
  • 相機鏡頭方向,由相機位置和相機指向的目標(target)位置計算出,forwrad=(targeteye)
  • 相機頂部正朝向: View Up Vector 確定在相機哪個方向是向上的,一般取(0, 1, 0)。這個參數稍後詳細解釋。

上面的圖簡化爲:
相機參數

在使用過程中,我們是要指定的參數即爲相機位置(eye),相機指向的目標位置(target)和viewUp vector三個參數。
Step1 : 首選計算相機鏡頭方向 forwrad=(targeteye) ,
進行標準化forward=forwardforwrad
Step2: 根據view-up vector和forward確定相機的side向量:
viewUp=viewUpviewUp
side=cross(forward,viewUp)

Step3 : 根據forward和side計算up向量:
up=cross(side,forward)
這樣eye位置,以及forward、side、up三個基向量構成一個新的座標系,注意這個座標系是一個左手座標系,因此在實際使用中,需要對forward進行一個翻轉,利用-forward、side、up和eye來構成一個右手座標系。

我們的目標是計算世界座標系中的物體在相機座標系下的座標,也就是從相機的角度來解釋物體的座標。從一個座標系的座標變換到另一個座標系,這就是不同座標系間座標轉換的過程。

計算方法1——直接計算變換矩陣

座標和變換一節,瞭解到,要實現不同座標系之間的座標轉換,需要求取一個變換矩陣。而這個矩陣就是一個座標系A中的原點和基在另一個座標系B下的表示。
我們將相機座標系的原點和基,使用世界座標系表示爲(s代表side基向量,u代表up基向量,f代表forward基向量):

[Camera]world=s[0]s[1]s[1]0u[0]u[1]u[2]0f[0]f[1]f[2]0eyexeyeyeyez1

現在要求取的是座標從世界座標系變換到相機座標系,則計算點p在相機座標系下表示爲:
[p]camera=[World]camera[p]world=[Camera]1world[p]world=view[p]world
即求得視變換矩陣爲
view=[Camera]1world=s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]0dot(s,eye)dot(u,eye)dot(f,eye)1

上面計算逆矩陣的過程中使用到了分塊矩陣求逆矩陣的定理:

設方陣A、D可逆,那麼分塊矩陣(A0BD) 可逆,且其逆矩陣爲T1=(A10A1BD1D1)

這種方式對應的計算代碼如下:

    // 手動構造LookAt矩陣 方式1
glm::mat4 computeLookAtMatrix1(glm::vec3 eye, glm::vec3 target, glm::vec3 viewUp)
{
    glm::vec3 f = glm::normalize(target - eye); // forward vector
    glm::vec3 s = glm::normalize(glm::cross(f, viewUp)); // side vector
    glm::vec3 u = glm::normalize(glm::cross(s, f)); // up vector
    glm::mat4 lookAtMat(
        glm::vec4(s.x, u.x, -f.x, 0.0), // 第一列
        glm::vec4(s.y, u.y, -f.y, 0.0), // 第二列
        glm::vec4(s.z, u.z, -f.z, 0.0), // 第三列
        glm::vec4(-glm::dot(s, eye),
        -glm::dot(u, eye), glm::dot(f, eye), 1.0)  // 第四列
        );
    return lookAtMat;
}

這種方式求取過程中涉及到了分塊矩陣的逆矩陣計算,如果不習慣,可以看下面的方式2,這是比較常用的方式。

計算方法2——利用旋轉和平移矩陣求逆矩陣

求取座標轉換矩陣的過程,也可以從另外一個角度出發,即將世界座標系旋轉和平移至於相機座標系重合,這樣這個旋轉R 和平移T 矩陣的組合矩陣M=TR ,就是將相機座標系中座標變換到世界座標系中座標的變換矩陣,那麼所求的視變換矩陣(世界座標系中座標轉換到相機座標系中座標的矩陣)view=M1 .
其中R就是上面求得的side、up、forward基向量構成的矩陣,如下:

R=s[0]s[1]s[2]0u[0]u[1]u[2]0f[0]f[1]f[2]00001

T=000000000000eyexeyeyeyez1

那麼所求的矩陣view計算過程如下:
view=(TR)1=R1T1=RTT1
在計算過程中,使用到了旋轉矩陣的性質,即旋轉矩陣是正交矩陣,它的逆矩陣等於矩陣的轉置。
因此所求的:
RT=s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]00001

T1=000000000eyexeyeyeyez

同樣計算得到視變換矩陣爲:

viewRTT1  s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]0dot(s,eye)dot(u,eye)dot(f,eye)1

這種方式對應的計算代碼如下:

   // 手動構造LookAt矩陣 方式2
glm::mat4 computeLookAtMatrix2(glm::vec3 eye, glm::vec3 target, glm::vec3 viewUp)
{
    glm::vec3 f = glm::normalize(target - eye); // forward vector
    glm::vec3 s = glm::normalize(glm::cross(f, viewUp)); // side vector
    glm::vec3 u = glm::normalize(glm::cross(s, f)); // up vector
    glm::mat4 rotate(
        glm::vec4(s.x, u.x, -f.x, 0.0), // 第一列
        glm::vec4(s.y, u.y, -f.y, 0.0), // 第二列
        glm::vec4(s.z, u.z, -f.z, 0.0), // 第三列
        glm::vec4(0.0, 0.0, 0.0, 1.0)  //  第四列
        );
    glm::mat4  translate;
    translate = glm::translate(translate, -eye);
    return rotate * translate;
}

OpenGL中視變換的實現

在OpenGL中,我們可以通過函數glm::lookAt來實現相機指定,這個函數計算的就是上面求出的視變換矩陣。以前glu版本實現爲gluLookAt,這兩個函數完成的功能是一樣的,參數定義如下:

API lookAt ( GLdouble eyeX, GLdouble eyeY, GLdouble eyeZ, GLdouble centerX, GLdouble centerY, GLdouble centerZ, GLdouble upX, GLdouble upY, GLdouble upZ)
其中eye指定相機位置,center指定相機指向目標位置,up指定viewUp向量。

利用GLM數學庫一般實現爲:

glm::mat4 view = glm::lookAt(eyePos,
    glm::vec3(0.0f, 0.0f, 0.0f), 
    glm::vec3(0.0f, 1.0f, 0.0f));

下面利用這個函數進行一些實驗,以幫助理解。在設置相機參數之前,我們學習下繪製立方體,爲實驗增加素材。

繪製立方體

前面索引繪製矩形一節使用了索引了矩形,如果利用索引繪製立方體,表面上看確實可以節省頂點數據,但是存在的問題是,不能爲不同面上的共同頂點指定不同的紋理座標,這在某些情況下會出現問題的。例如下面使用索引繪製的立方體:
索引繪製
由於在正面和側面的頂點制定了相同的紋理座標,插值後紋理一致,並沒有出現可愛的貓咪圖案。爲此,我們需要爲共用頂點指定不同的頂點屬性,那麼解決辦法之一是,繼續使用頂點數組繪製方式,定義立方體的數據如下:

  // 指定頂點屬性數據 頂點位置 顏色 紋理
GLfloat vertices[] = {
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,    // B
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f,     // C
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f,     // C
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A

    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f,  // E
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 0.0, 1.0f,    // H
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
        0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f,  // E

    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0, 1.0f,    // H
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f,  // E
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f,  // E
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D

    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,     // C
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,    // C
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f,    // B
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F

    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 0.0, 1.0f,    // H
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,    // D
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,    // D
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, // C
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G

    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f,  // E
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f,   // F
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f,   // F
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,    // B
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    };

着色器使用上一節繪製矩形的着色器程序。繪製立方體後,通過設定相機位置隨着時間發生改變來觀察這個立方體。指定相機位置爲在xoz平面圓周運動的點軌跡,代碼爲:

GLfloat radius = 3.0f;
GLfloat xPos = radius * cos(glfwGetTime());
GLfloat zPos = radius * sin(glfwGetTime());
glm::vec3 eyePos(xPos, 0.0f, zPos);
glm::mat4 view = glm::lookAt(eyePos,
glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

同時在代碼中指定投影方式爲透視投影,代碼爲:

// 投影矩陣
glm::mat4 projection = glm::perspective(glm::radians(45.0f),
        (GLfloat)(WINDOW_WIDTH) / WINDOW_HEIGHT, 1.0f, 100.0f);

投影方式和投影矩陣的計算將在後面小結介紹,這裏只需要知道使用方法即可。

實現繪製立方體,效果如下圖所示:

需要開啓深度測試

從上面的圖中我們看到了奇怪的現象,立方體後面的部分繪製在了前面的部分上,這種現象是由於深度測試(Depth Test)未開啓影響的。深度測試根據物體在場景中到觀察者的距離,根據設定的glDepthFunc函數判定是否通過深度測試,默認爲GL_LESS,即深度小者通過測試繪製在最終的屏幕上。關於深度測試這個主題,後面會繼續學習,這裏不再展開。
OpenGL中開啓深度測試方法:

  glEnable(GL_DEPTH_TEST);

同時在主循環中,清除深度緩衝區和顏色緩衝區:

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

開啓深度測試後,旋轉相機來觀察立方體,效果如下:
旋轉的相機

viewUp向量

上面提到了在指定相機時需要指定相機的viewUP向量,這個向量指定了相機中哪個方向是向上的。對於相機而言,指定了相機位置eye和相機指向位置target後確定了相機的指向,位置不變,指向不變時,還是可以通過改變這個viewUp而影響成像的。這個類似於你眼睛的位置不變,看着的方向不變,但是你可以扭動脖子來確定哪個方向是向上,這個viewUp好比頭頂給定的方向。相機位置固定在(0,0,3.0),指向原點,依次取viewUp爲(0,1,0),(1,0,0),(0,1,0) ,繪製立方體後的效果如下圖所示:

viewUp

圖中viewUp爲(0,1,0)時貓的尾巴朝上,爲(1,0,0)時相當於把脖子右旋轉90度,看到貓的尾巴是在左邊的;爲(0,-1,0)相當於倒立過來看,貓的尾巴是向下的。如果想了解更多關於viewUp的解釋,可以參考What exactly is the UP vector in OpenGL’s LookAt function.

更多立方體

上一節介紹了模型變換,我們可以利用模型變換,在場景中繪製多個立方體,同時相機的位置可以採用圓的參數方程或者球面參數方程設定。繪製多個立方體的方法:

 // 指定立方體位移
 glm::vec3 cubePostitions[] = {
        glm::vec3(0.0f, 0.0f, 1.2f),
        glm::vec3(0.0f, 0.0f, 0.0f),
        glm::vec3(1.2f, 1.2f, 0.0f),
        glm::vec3(-1.2f, 1.2f, 0.0f),
        glm::vec3(-1.2f, -1.5f, 0.0f),
        glm::vec3(1.2f, -1.5f, 0.0f),
        glm::vec3(0.0f, 0.0f, -1.2f),
    };
  // 在主循環中繪製立方體
for (int i = 0; i < sizeof(cubePostitions) / sizeof(cubePostitions[0]); ++i)
{
    model = glm::mat4();
    model = glm::translate(model, cubePostitions[i]);
            glUniformMatrix4fv(glGetUniformLocation(shader.programId, "model"),
        1, GL_FALSE, glm::value_ptr(model));
    glDrawArrays(GL_TRIANGLES, 0, 36);
}

對相機位置隨着時間進行改變,可以採用圓的參數方程或者球面參數方程設定。這裏只是作爲一個示例來設定,可以根據你的具體需求設定對應角度值。示例代碼如下:

 // xoz平面內圓形座標
glm::vec3 getEyePosCircle()
{
    GLfloat radius = 6.0f;
    GLfloat xPos = radius * cos(glfwGetTime());
    GLfloat zPos = radius * sin(glfwGetTime());
    return glm::vec3(xPos, 0.0f, zPos);
}
// 球形座標 這裏計算theta phi角度僅做示例演示
// 可以根據需要設定
glm::vec3 getEyePosSphere()
{
    GLfloat radius = 6.0f;
    GLfloat theta = glfwGetTime(), phi = glfwGetTime() / 2.0f;
    GLfloat xPos = radius * sin(theta) * cos(phi);
    GLfloat yPos = radius * sin(theta) * sin(phi);
    GLfloat zPos = radius * cos(theta);
    return glm::vec3(xPos, yPos, zPos);
}

例如利用球面座標方程設定的相機位置,效果如下圖所示:

球形相機座標

最後的說明

經過視變換後,世界座標系中座標轉換到了相機座標系下。需要注意的相機在OpenGL中是個假想的概念,本質是通過矩陣來完成計算的。本節設定相機位置爲圓周或者球面運動軌跡,並不能讓用戶來交互地觀察場景中物體,下一節將設計一個第一人稱FPS相機,讓用戶通過鍵盤和鼠標控制相機,更好地觀察場景。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章