OpenGL學習:立方體紋理和天空包圍盒(Cubemaps And Skybox)

寫在前面 
之前學習了2D紋理映射,實際上還有其他類型的紋理有待我們進一步學習,本節將要學習的立方體紋理(cubemaps),是一種將多個紋理圖片複合到一個立方體表面的技術。在遊戲中應用得較多的天空包圍盒可以使用cubemap實現。本節示例程序均可以在我的github下載

本節內容整理自: 
1.Tutorial 25:SkyBox 
2.www.learnopengl.com Cubemaps

創建Cubemap

cubemap是使用6張2D紋理綁定到GL_TEXTURE_CUBE_MAP目標而創建的紋理。GL_TEXTURE_CUBE_MAP包含6個面,分別是:

綁定目標 紋理方向
GL_TEXTURE_CUBE_MAP_POSITIVE_X 右邊
GL_TEXTURE_CUBE_MAP_NEGATIVE_X 左邊
GL_TEXTURE_CUBE_MAP_POSITIVE_Y 頂部
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 底部
GL_TEXTURE_CUBE_MAP_POSITIVE_Z 背面
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 前面

如下圖所示,形成一個立方體紋理(來自[Cubemaps] 
(https://scalibq.wordpress.com/2013/06/23/cubemaps/)):

這裏寫圖片描述

需要注意的是,OpenGL中相機默認朝向-z方向,因此GL_TEXTURE_CUBE_MAP_NEGATIVE_Z表示前面,而GL_TEXTURE_CUBE_MAP_POSITIVE_Z表示背面。在構建cubemaps,一般利用枚舉常量遞增的特性,一次綁定到上述6個目標。例如在OpenGL中枚舉常量定義爲:

#define GL_TEXTURE_CUBE_MAP_POSITIVE_X 0x8515
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_X 0x8516
#define GL_TEXTURE_CUBE_MAP_POSITIVE_Y 0x8517
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 0x8518
#define GL_TEXTURE_CUBE_MAP_POSITIVE_Z 0x8519
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 0x851A
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

可以看到上述6個枚舉常量一次遞增,我們可以使用循環來創建這個立方體紋理,將這個函數封裝到texture.h中如下:

//加載一個cubeMap
static GLuint loadCubeMapTexture(std::vector<const char*> picFilePathVec, 
GLint internalFormat = GL_RGB,
 GLenum picFormat = GL_RGB,
GLenum picDataType = GL_UNSIGNED_BYTE, 
int loadChannels = SOIL_LOAD_RGB)
{
    GLuint textId;
    glGenTextures(1, &textId);
    glBindTexture(GL_TEXTURE_CUBE_MAP, textId);
    GLubyte *imageData = NULL;
    int picWidth, picHeight;
for (std::vector<const char*>::size_type  i =0; i < picFilePathVec.size(); ++i)
    {
        int channels = 0;
        imageData = SOIL_load_image(picFilePathVec[i], &picWidth, 
            &picHeight, &channels, loadChannels);
        if (imageData == NULL)
        {
            std::cerr << "Error::loadCubeMapTexture could not load texture file:"
                << picFilePathVec[i] << std::endl;
            return 0;
        }
        glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
            0, internalFormat, picWidth, picHeight, 0, picFormat, picDataType, imageData);
        SOIL_free_image_data(imageData);
    }
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
    glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
        return textId;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

關於代碼中GL_TEXTURE_WRAP_R參數,稍後會做解釋。

實際使用時加載6個2D紋理圖片,如下所示:

    faces.push_back("sky_rt.jpg");
    faces.push_back("sky_lf.jpg");
    faces.push_back("sky_up.jpg");
    faces.push_back("sky_dn.jpg");
    faces.push_back("sky_bk.jpg");
    faces.push_back("sky_ft.jpg");
    GLuint skyBoxTextId = TextureHelper::loadCubeMapTexture(faces);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

需要注意加載圖片的順序 我們使用GL_TEXTURE_CUBE_MAP_POSITIVE_X + i的方式來一次創建了6個2D紋理,加載圖片時的順序以需要對應枚舉變量定義的順序。

使用cubemaps

cubemaps創建了一個立方體紋理,那麼如何對紋理進行採樣呢? 
與2D紋理使用的紋理座標(s,t)不同,我們這裏需要使用三維紋理座標(s,t,r),如下圖所示(來自www.learnopengl.com Cubemaps):

三維紋理座標

圖中橙色的方向向量,當立方體中心處於原點時,即代表的是立方體表面頂點的位置,這個向量即是三維紋理座標。利用(s,t,r)決定紋理採樣時,首先根據(s,t,r)中模最大的分量決定在哪個面採樣,然後使用剩下的2個座標在對應的面上做2D紋理採樣。例如根據(s,t,r)中模最大的爲s分量,並且符號爲正,則決定選取+x面作爲採樣的2D紋理,然後使用(t,r)座標在+x面上做2D紋理採樣。關於這個計算過程的解釋可以參考cubemaps

2D紋理映射一節我們提到WRAP參數會決定,當紋理座標超出[0,1]範圍時的紋理採樣方式。上述代碼中,我們使用:

glTexParameteri(GL_TEXTURE_CUBE_MAP,GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP,GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP,GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
  • 1
  • 2
  • 3

其中參數GL_CLAMP_TO_EDGE主要用於指定,當(s,t,r)座標沒有落在哪個面,而是落在兩個面之間時的紋理採樣,使用GL_CLAMP_TO_EDGE參數表明,當在兩個面之間採樣時使用邊緣的紋理值。

創建天空包圍盒

上面介紹了創建和使用cubemap的方法,實際遊戲應用得較多的就是利用cubemap實現天空包圍盒。天空包圍盒的主要實現思路是: 
在場景中繪製一個cubemap紋理採樣的立方體,將這個立方體總是置於場景中最外圍,讓遊戲玩家感覺到好像場景非常大,觸不可及像天空一樣,即是玩家靠近一些,天空依然還是離得很遠的感覺。 
例如下圖,我們繪製了一個包圍盒: 
包圍盒

繪製包圍盒,是將1x1x1的立方體作爲包圍盒,將上面建立的cubemap映射到這個包圍盒上。這個立方體的中心處於原點,因此立方體上的頂點位置,就當做前面講的用於紋理採樣的向量。 
在頂點着色器中實現爲:

#version 330 core
layout(location = 0) in vec3 position;

uniform mat4 projection;
uniform mat4 view;
out vec3 TextCoord;
void main()
{
    gl_Position = projection * view * vec4(position, 1.0); 
    TextCoord = position;  // 當立方體中央處於原點時 立方體上位置即等價於向量
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在片元着色器中只需要採樣紋理即可:

#version 330 core
in vec3 TextCoord;
uniform samplerCube  skybox;  // 從sampler2D改爲samplerCube
out vec4 color;

void main()
{
    color = texture(skybox, TextCoord);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

要將包圍盒置於場景中,最外層,基本的方式是,暫時關閉深度緩存寫入,首先繪製包圍盒,這樣包圍盒總是處於場景中最外圍。同時實現的時候需要注意是如何保持玩家移動時,包圍盒看起來很遠很大的感覺,有兩種實現方式。

第一種方式,去掉視變換中移動的部分(translate部分),但保留旋轉等其他成分,這樣當你在場景內移動,轉動相機時,包圍盒仍然在以正常角度顯示,只是包圍盒沒有因爲玩家的前進後退而發生移動,這樣看起來就比較正常。這種方式實現爲:

   // 先繪製skyBox
glDepthMask(GL_FALSE); // 禁止寫入深度緩衝區
skyBoxShader.use();
glm::mat4 projection = glm::perspective(camera.mouse_zoom,
    (GLfloat)(WINDOW_WIDTH) / WINDOW_HEIGHT, 0.1f, 100.0f); // 投影矩陣
glm::mat4 view = glm::mat4(glm::mat3(camera.getViewMatrix())); // 視變換矩陣 移除translate部分
        glUniformMatrix4fv(glGetUniformLocation(skyBoxShader.programId, "projection"),
            1, GL_FALSE, glm::value_ptr(projection));
        glUniformMatrix4fv(glGetUniformLocation(skyBoxShader.programId, "view"),1, GL_FALSE, glm::value_ptr(view));
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

第二種方式,是每次將包圍盒的中心設定在玩家的位置,同時以一定比例縮放包圍盒,這樣達到的效果基本相同,但是缺點是如果縮放比例不當的話,場景中物體移動時可能超出包圍盒,而引起視覺Bug。這種方式實現爲:

skyBoxShader.use();

glUniformMatrix4fv(glGetUniformLocation(skyBoxShader.programId, "projection"),1, GL_FALSE, glm::value_ptr(projection)); 

glUniformMatrix4fv(glGetUniformLocation(skyBoxShader.programId, "view"),1, GL_FALSE, glm::value_ptr(view));

model = glm::translate(glm::mat4(), camera.position);

model = glm::scale(model, glm::vec3(20.0f, 20.0f, 20.0f));

glUniformMatrix4fv(glGetUniformLocation(skyBoxShader.programId, "model"),1, GL_FALSE, glm::value_ptr(model));
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

天空包圍盒的改進

上面在繪製天空包圍盒時,我們首先關閉深度緩存寫入,繪製包圍盒,讓它處於場景最外圍,這樣做當然能正常工作,缺點是如果場景中物體需要顯示在包圍盒前面,最終包圍盒的某些部分會被遮擋住,按上述繪製方式我們還是繪製了這部分內容,導致了不必要的着色器調用,這是一種性能上的損失。

一種改進的策略是首先繪製場景中物體,然後根據利用包圍盒的深度值和當前深度值進行比較,如果通過深度測試就繪製包圍盒。我們知道默認情況下,清除深度緩存時使用的值爲1.0表示深度最大,因此我們也想用1.0來表示包圍盒的深度值,這樣它就始終處於場景中最外圍,當進行深度測試時,我們改變默認的測試函數,從GL_LESS變爲GL_LEQUAL,如下:

   glDepthFunc(GL_LEQUAL); // 深度測試條件 小於等於
  • 1

那麼如何讓包圍盒的深度值總是1.0呢? 我們知道,在頂點着色器中,gl_Position表示的是當前頂點的裁剪座標系座標(對應的z分量爲Zclip),而一個頂點最終的深度值是通過透視除法得到NDC座標(對應的z分量爲Zndc),以及最後的視口變換後得到窗口座標的Zwin值決定的。關於這個深度值的計算,如果感覺陌生,可以回過頭去查看深度測試一節。這裏使用的技巧是,手動將gl_Position的z值設定爲w,即在頂點主色器中輸出:

   void main()
{
    vec4 pos = projection * view * model * vec4(position, 1.0); 
    gl_Position = pos.xyww;  // 此處讓z=w 則對應的深度值變爲depth = w / w = 1.0
    TextCoord = position;  // 當立方體中央處於原點時 立方體上位置即等價於向量
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

這樣通過OpenGL默認執行的透視除法和視口變換後,得到的深度值就是Zwin=1.0,達到我們的目的。這種方式首先繪製場景中物體,最後渲染包圍盒。需要注意的是,繪製包圍盒時將深度測試函數變爲:

   glDepthFunc(GL_LEQUAL);
  • 1

繪製完畢後,又恢復默認的GL_LESS。使用不同的包圍盒素材,我們得到另一個包圍盒效果如下圖所示:

包圍盒2

最後的說明

在實現包圍盒時,需要通過移除translate部分(上文中第一種方式)或者將包圍盒設爲觀察者原點,並且放大包圍盒的方式(上文中第二種方式)來使包圍盒看起來很遠很大。如果設置不當得到的錯誤效果可能如下: 
錯誤效果

在實現包圍盒時,注意調整合適的投影變換參數,這裏我們設置的參數爲:

   glm::mat4 projection = glm::perspective(camera.mouse_zoom,
            (GLfloat)(WINDOW_WIDTH) / WINDOW_HEIGHT, 0.1f, 100.0f); // 投影矩陣
  • 1
  • 2

如果投影參數設置不當,得到錯誤的效果可能如下: 
投影參數不當

要想獲得更多的包圍盒,可以訪問在線資源

另外Cubemap還可以用來實現environment mapping等技術,下一節將會學習這個主題


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