1. 程式人生 > >OpenGL深入探索——陰影貼圖(一)

OpenGL深入探索——陰影貼圖(一)

背景
陰影和光是緊密聯絡在一起的,因為如果你想要產生一個陰影就必須要光。有許多的技術可以生成陰影,在接下來的兩個章節中我們將學習其中的一種比較基礎簡單的技術-陰影貼圖。

當光柵化的時候,你會問這個畫素是否位於陰影中?讓我們換個問法,從光源到畫素的路徑是否中間經過其他物體?如果是,這個畫素可能位於陰影中(假定其他的物體不透明),如果不是,則畫素不位於陰影中。某種程度上這個問題和我們之前章節問的問題相似,如何確定當兩個物體覆蓋彼此時,我們看到的是比較近的那個。如果我們把相機放在光源的位置,那麼兩個問題變成一個。我們希望在深度測試中失敗的畫素處於陰影中。只有在在深度測試中獲勝的畫素受到光的照射。這些畫素都是直接和光源接觸的,其間沒有任何東西會遮蔽它們。簡單的說,這是在陰影貼圖背後的原理。

看似深度測試可以幫助我們探測一個畫素是否位於陰影中,但是還有一個問題:相機和光源不總位於同一個地方。深度測試通常用於解決從相機視口看物體是否可見的問題。所以當光源處於遠處的時候,我們如何利用深度測試來進行陰影測試?解決方案是渲染場景兩次。第一次從光源的角度。這次渲染過程的結果沒有被儲存到顏色緩衝區中。相反,離光源最近的深度值被渲染進入由應用程式建立的(而不是由 GLUT 自動生成的)深度緩衝區。在第二個過程則是像以前一樣以相機為視口渲染場景。我們建立的深度緩衝區被繫結到片元著色器以便讀取。對於每一個畫素我們從這個深度緩衝區中取出相應的深度值(準確的說是取出當前畫素到光源的路徑上離光源最近的那個片元的深度值),同時我們也計算這個畫素到光源的距離。有時候這兩個值是相等的。這種情況說明這個畫素與光源最近,因此它的深度值才會被寫進深度緩衝區。如果這種情況發生,這個畫素就被認為處於光照中並和往常一樣計算它的顏色。如果這兩個值是不相同,這意味著從光源看這個畫素時有其他畫素遮擋了它。這種情況下我們在顏色計算中增加陰影因子來模仿陰影效果。看看下面這幅圖:


我們的場景由兩個物件組成——表面和立方體。光源是位於左上角並且指向立方體。在第一個渲染過程,我們以光源位置為視口將深度資訊渲染到深度緩衝區中。單看 A,B,C 這 3 個點。當 B 被渲染時,它的深度值進入深度緩衝區。因為在 B 和光源之間沒有其他的東西。我們預設它是那條線上離光源最近的點。然而當 A 和 C 被渲染的時候,它們在深度緩衝區的同一個點進行比較。兩個點都在同一條來自光源的直線上,所以在透視投影后,光柵器發現這兩個點需要去往螢幕上的同一個畫素。這就是深度測試,最後 C 點“贏”了,則 C 點的深度值被寫入了深度快取中。

在第二個渲染過程,我們以相機為視口渲染表面和立方體。我們在著色器中除了為每個畫素做一些計算,我們還計算從光源到畫素之間的距離,並和在深度緩衝區中對應的深度值進行比較。當我們光柵化 B 點時,這兩個值應該是差不多相等的(可能由於插值的不同和浮點型別的精度問題會有一些差距),因此我們認為 B 不在陰影中而和往常一樣進行計算。當光柵化 A 點的時候,我們發現儲存的深度值明顯比 A 到光源的距離要小。所以我們認為 A 在陰影中,並且在 A 點上應用一些陰影引數,以使它比以往黑一點。

這個簡言之就是陰影對映演算法(在第一次渲染過程中我們渲染的深度緩衝區被稱為 "shadow map" )。我們將分兩個階段學習它。在第一個階段

(本節)我們將學習如何將深度資訊渲染到 shadow map 中。渲染一些東西(深度,顏色等等)到由應用程式建立的紋理,被稱為 'render to texture' 。我們將用十分熟悉的紋理貼圖技術在螢幕上顯示 shadow map 。這是一個很好的除錯過程,因為保證 shadow map 的正確性對於正確實現整個陰影效果至關重要。在下一節我們將看見如何使用 shadow map 來計算頂點“是否處於陰影中”。

這一節我們使用的模型包括一個可以被用來顯示 shadow map 的簡單四邊形。這個四邊形是由兩個三角形組成的,並設定紋理座標使它們覆蓋整個紋理。當四邊形被渲染的時候,紋理座標被光柵器插值,於是你就可以在整個紋理上取樣並在螢幕上顯示。

(shadow_map_fbo.h:50)
class ShadowMapFBO
{
    public:
        ShadowMapFBO();
        ~ShadowMapFBO();
        bool Init(unsigned int WindowWidth, unsigned int WindowHeight);
        void BindForWriting();
        void BindForReading(GLenum TextureUnit);
    private:
        GLuint m_fbo;
        GLuint m_shadowMap;
};

OpenGL 中的 3D 管線最終輸出到 'framebuffer object'(簡稱 FBO )。FBO 可以掛載顏色緩衝(在螢幕上顯示),深度緩衝和一些有其他用處的緩衝區。當 glutInitDisplayMode() 被呼叫的時候,它用一些特定的引數建立預設的 framebuffer。這個 framebuffer被視窗系統所管理,不能夠被 OpenGL 刪除。除了預設的 framebuffer ,應用程式可以建立自己的 FBOs 。在應用程式的控制下,這些物件可以被控制和用於不同的技術。ShadowMapFBO 類為 FBO 提供一個容易使用的介面 ( FBO 將被用來實現陰影貼圖技術)。 ShadowMapFBO 類內部有兩個 OpenGL 控制代碼。'm_fbo' 控制代碼代表真正的 FBO 。 FBO 封裝了 framebuffer 的所有狀態。一旦這個物件被建立並設定合適的引數,我們可以簡單的通過繫結一個不同的物件來改變 framebuffers。注意只有預設的 framebuffers 才可以被使用來在螢幕上顯示東西,應用程式建立的 framebuffers 只能用於”離屏渲染“。這個可以說是一箇中間的渲染過程(比如我們的陰影貼圖緩衝區),為後面用於螢幕顯示的真正的渲染過程所服務。

Framebuffer 本身僅僅是一個佔位符。為了使它變得可用,我們需要把紋理依附於一個或者更多的可用的掛載點。紋理含有 framebuffers 實際的記憶體空間。OpenGL 定義了下面的一些附著點。
  1. COLOR_ATTACHMENTi —— 附著到這裡的紋理將接收來自片元著色器的顏色。‘i’ 字尾意味著可以有多個紋理同時被附著為顏色附著點。在片元著色器中有一個機制可以確保同時將顏色輸出到多個緩衝區中。
  2. DEPTH_ATTACHMENT —— 附著在上面的紋理將收到深度測試的結果。
  3. STENCIL_ATTACHMENT —— 附著在上面的紋理將充當模板緩衝區。模板緩衝區限制了光柵化的區域,可被用於不同的技術。
  4. DEPTH_STENCIL_ATTACHMENT —— 這僅是一個深度和模板緩衝區的結合,因為它倆經常被一起使用。

為實現陰影貼圖技術,我們只需要得到經過深度測試之後獲得的場景的深度值。成員屬性 'm_shadowMap' 是一個紋理的控制代碼,這個紋理將要附著到DEPTH_ATTACHMENT 附著點上。ShadowMapFBO 也提供兩個介面供主渲染函式的呼叫。在渲染進 shadow map 之前我們將呼叫 BindForWriting(),在第二次渲染過程開始的時候呼叫 BindForReading()。

(shadow_map_fbo.cpp:43)
glGenFramebuffers(1, &m_fbo);

這裡我們建立 FBO。和紋理與、緩衝區這些物件的建立方式一樣,我們指定一個 GLuints 陣列的地址和它的大小。這個陣列被控制代碼填充。
(shadow_map_fbo.cpp:46)
glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
接下來我們建立將充當 shadow map 的紋理。通常這是一個標準的 2D 紋理,同時我們對其的引數進行設定,使得這個紋理能夠適應以下的需求:

  1. 紋理的內部格式是 GL_DEPTH_COMPONENT 。之前我們通常將紋理的內部格式設定為與顏色有關的型別如(GL_RGB),但是這裡我們將其設定為 GL_DEPTH_COMPONENT 意味著紋理中的每個紋素都存放著一個單精度浮點數用於存放已經標準化後深度值。
  2. glTexImage2D 的最後一個引數是空,這意味著我們不提供任何用於初始化 buffer 的資料,因為我們想要 buffer 包含每一幀的深度值並且每一幀的深度值都可能會變化。無論我們什麼時候開始一個新的幀,我們會用 glClear() 清除 buffer。這就是我們在初始化過程中需要做的。
  3. 我們告訴 OpenGL 如果紋理座標越界,需要將其截斷到[0,1]之間。當以相機為視口的投影視窗超過以光源為視口的投影視窗時會發生紋理座標越界。為了避免奇怪的現象比如陰影在別的地方重複(由於 wraparound ),我們截斷紋理座標。
(shadow_map_fbo.cpp:54)
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
我們已經生成 FBO,紋理物件,併為陰影貼圖配置了紋理物件。現在我們需要把紋理物件附到 FBO。我們要做的第一件事就是繫結 FBO,之後所有對 FBO 操作都會對它產生影響。這個函式的引數是 FBO 控制代碼和所需的 target。target 可以是 GL_FRAMEBUFFER, GL_DRAW_FRAMEBUFFER 或者 GL_READ_FRAMEBUFFER。GL_READ_FRAMEBUFFE 用於當我們想呼叫 glReadPixels(本課中不會使用)從 FBO 中讀取內容時。當我們想要把場景渲染進入 FBO 時需要使用 GL_DRAW_FRAMEBUFFE。當我們使用 GL_FRAMEBUFFER 時,FBO 的讀寫狀態都會被更新,因此建議您這樣初始化 FBO。當我們真正開始渲染的時候我們將會使用 GL_DRAW_FRAMEBUFFER。
(shadow_map_fbo.cpp:55)
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);

這裡我們把 shadow map 紋理附著到 FBO 的深度附著點。這個函式最後一個引數指明要用的 Mipmap 層級。Mipmap 層是紋理貼圖的一個特性,以不同解析度展現一個紋理。0 代表最大的解析度,隨著層級的增加,紋理的解析度會越來越小。將 Mipmap 紋理和三線性濾波結合起來能產生更好的結果。這裡我們只有一個 mipmap 層,所以我們使用 0。我們讓 shadow map 控制代碼作為第四個引數。如果這裡我們使用 0,那麼當前的紋理(在上面的例子是深度)將從指定的附著點上脫落。
(shadow_map_fbo.cpp:58)
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

因為我們沒打算渲染到 color buffer(只輸出深度),我們通過上面的函式來明確的禁止向顏色快取中的寫入。預設情況下,顏色快取會被繫結在 GL_COLOR_ATTACHMENT0,但是我們 FBO 中甚至不會包含一個紋理緩衝區,所以,最好明確的告訴 OpenGL 我們的意願。這個函式可用的引數是 GL_NONE 和 GL_COLOR_ATTACHMENT0 到 GL_COLOR_ATTACHMENTm ,‘m’ 是(GL_MAX_COLOR_ATTACHMENTS – 1)。這些引數只對 FBOs 有效。如果用了預設的 framebuffer,那麼有效的引數是 GL_NONE, GL_FRONT_LEFT, GL_FRONT_RIGHT, GL_BACK_LEFT 和 GL_BACK_RIGHT。它允許你可以直接將場景渲染進入前或者後 buffer(每一個都有左和右 buffer)。我們也將從快取中的讀取操作設定為 GL_NONE(記住,我們不打算呼叫 glReadPixel APIs 中的任何一個函式)。這主要是為了避免因 GPU 只支援 opengl3.x 而不支援 4.x 而出現問題。
(shadow_map_fbo.cpp:61)
GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (Status != GL_FRAMEBUFFER_COMPLETE) {
    printf("FB error, status: 0x%x\n", Status);
    return false;
}

當我們完成 FBO 的配置後,一定要確認其狀態是否為 OpenGL 定義的的 “complete” 。否則可能雖然沒檢查出錯誤,而 framebuffer 卻可以被使用。上面的程式碼就是用於對這些狀態的檢測。
(shadow_map_fbo.cpp:72)
void ShadowMapFBO::BindForWriting()
{
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}

在渲染過程章我們需要將渲染目標在 shadow map 和預設的 framebuffer 之間進行切換。在第二個渲染過程中,我們將需要繫結 shadow map 作為輸入。這個函式和下一個函式提供一個便捷的的封裝來做這項工作。上面的函式僅繫結 FBO 用於寫入資料。在第一次渲染之前我們將呼叫它。
(shadow_map_fbo.cpp:78)
void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
    glActiveTexture(TextureUnit);
    glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}

這個函式在第二次渲染之前被呼叫以繫結 shadow map 用於讀取資料。注意我們繫結紋理物件而不是 FBO 本身。這個函式的引數是紋理單元,並把 shadow map 繫結到這個紋理單元上。這個紋理單元的索引一定要和著色器同步(因為著色器有一個 sampler2D 一致變數用來訪問這個紋理)。注意 glActiveTexture 的引數是紋理索引的列舉值(比如GL_TEXTURE0, GL_TEXTURE1等等)。著色器中的一致變數只需要索引值本身(如0,1等)。這可能會是許多 bugs 的源頭。
(shadow_map.vs)
 #version 330
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
uniform mat4 gWVP;
out vec2 TexCoordOut;
void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoordOut = TexCoord;
}

我們將在兩次的渲染中都使用同一著色器程式。頂點著色器在兩次渲染過程中都用到,而片元著色器將只在第二次渲染過程中被使用。因為我們在第一次渲染過程中禁止把資料寫入顏色快取,所以就沒用到片元著色器。上面的頂點著色器是十分簡單的,它僅僅是通過 WVP 矩陣將位置座標變換到裁剪座標系中,並將紋理座標傳遞到片元著色器中。在第一次的渲染過程中,紋理座標是多餘的(因為沒有片元著色器)。然而,這沒有實際的影響。正如你所看見的,從著色器角度來看,無論這是一個渲染深度的過程還是一個真正的渲染過程都沒有什麼不同,而真正不同的地方是應用程式在第一次渲染過程傳遞的是以光源為視口的 WVP 矩陣,而在第二次渲染過程傳遞的是以相機為視口的 WVP 矩陣。在第一次的渲染過程 Z buffer 將被最靠近光源位置的 Z 值所填充,在第二次渲染過程中,Z buffer將被最靠近相機位置的 Z 值所填充。在第二次渲染過程中我們需要使用片元著色器中的紋理座標,因為我們將從 shadow map(此時它是著色器的輸入)中進行取樣。
(shadow_map.fs)
 #version 330
in vec2 TexCoordOut;
uniform sampler2D gShadowMap;
out vec4 FragColor;
void main()
{
    float Depth = texture(gShadowMap, TexCoordOut).x;
    Depth = 1.0 - (1.0 - Depth) * 25.0;
    FragColor = vec4(Depth);
}

這是在渲染過程中用來顯示 shadow map 的片元著色器。2D 紋理座標被用來從 shadow map 中進行取樣。Shadow map 紋理是以 GL_DEPTH_COMPONENT 型別為內部格式而建立的。這意味著紋理中每一個紋素都是一個單精度的浮點型資料而不是一種顏色。這就是為什麼 '.x' 在取樣的過程中被使用。當我們顯示深度快取中的內容時,我們可能遇到的一個情況是渲染的結果不夠的清楚。所以,在我們從 shadow map 中取樣獲得深度值後,為使效果明顯,我們放大當前點的距離到遠邊緣(此處Z為1),然後再用1減去這個放大後值。我們將這個值作為片元的每個顏色通道的值,這意味著我們將得到一些灰度的變化(遠裁剪面處是白色,近裁剪面處是黑色)。

現在讓我們如何結合上面的這些程式碼片段而建立應用程式。
(tutorial23.cpp:109)
virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_scale += 0.05f;
    ShadowMapPass();
    RenderPass();
    glutSwapBuffers();
}
主渲染程式隨著大部分的功能移到其他函式變得更加簡單。首先我們處理全域性的東西比如更新相機的位置和用來旋轉物件的類成員。然後我們呼叫一個 ShadowMapPass() 函式將深度資訊渲染進入 shadow map 紋理中,接著用 RenderPass() 函式來顯示這個紋理。最後呼叫 glutSwapBuffer() 用來將最終結果顯示到螢幕上。
(tutorial23.cpp:120)
virtual void ShadowMapPass()
{
    m_shadowMapFBO.BindForWriting();
    glClear(GL_DEPTH_BUFFER_BIT);
    Pipeline p;
    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 5.0f);
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());
    m_pMesh->Render();
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

在渲染 Shadow map 之前我們先繫結 FBO。從現在起,所有的深度值將被渲染進入 shadow map,同時顏色的寫入被捨棄。我們只在渲染開始之前清除深度緩衝區。之後我們為了渲染 mesh(例中為一個坦克)初始化了一個 pipeline 類物件。這裡值得注意的一點是相機相關設定是基於聚光燈的位置和方向。我們先渲染 mesh,然後通過繫結 FBO 為 0 來切換回預設的 framebuffer。
(tutorial23.cpp:138)
virtual void RenderPass()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    m_pShadowMapTech->SetTextureUnit(0);
    m_shadowMapFBO.BindForReading(GL_TEXTURE0);
    Pipeline p;
    p.Scale(5.0f, 5.0f, 5.0f);
    p.WorldPos(0.0f, 0.0f, 10.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());
    m_pQuad->Render();
}
第二個渲染過程開始先清除顏色和深度快取。這些快取屬於預設的 framebuffer我們告訴著色器使用紋理單元 0,並繫結 shadow map 用來讀取其中的資料。從這裡起處理就都和以前一樣了。我們放大四邊形,把他直接放在相機的前面並渲染它。在光柵化期間 shadow map 被取樣和並顯示到模型上。

注意:在這個章節的程式碼中,當 mesh 檔案中不包含紋理時,我們不再自動的載入一個白色的紋理。因為我們會為其繫結 Shadow map。

操作結果: