OpenGL陰影,Shadow Volumes(附源程序,使用 VCGlib )

 

實驗平臺:Win7,VS2010html

 

先上結果截圖:算法

OpenGL陰影 Shadow Volumes 
  

 

本文是我前一篇博客:OpenGL陰影,Shadow Mapping(附源程序)的下篇,描述兩個最經常使用的陰影技術中的第二個,Shadow Volumes 方法。將從基本原理出發,首先講解 Zpass 方法,而後是 Zfail 方法(比較實際的方法),最後對 Shadow Mapping 和 Shadow Volumes 方法作簡要分析對比。編程

Shadow Volumes 須要網格的鏈接信息,本文使用 VCGlib 庫 構造拓撲信息及讀寫網格文件,爲了清晰,將 VCGlib 使用的簡單總結做爲附錄,附於文章的最後。數組

 

1. 數學原理性能優化

關於陰影的定義,請見個人前一篇博客(文獻[1])。Shadow Mapping 將空間各個方向上離光源最近點的距離編碼成深度紋理。Shadow Volumes 採用一種不一樣的方法,它直接構造光源被物體(投射陰影的物體,Shadow caster)遮擋的空間的邊界,即落在這個邊界內的任何點都處於陰影中,反之被光源照亮,以下圖所示(使用Blender製做,另見文獻[3]PPT第10頁):數據結構

 

遮擋空間邊界所包圍的空間即爲 Shadow Volume (陰影體積),構造 Shadow Volume 並不困難,對上圖中的三角形(設頂點爲 A,B,C)只須要從光源點到三角形頂點作連線並延伸出去到足夠遠(設 A,B,C 延伸到點 D,E,F),並用這些多邊形構成封閉體積:面ABC、面ADEB、面BEFC、面CFDA、面EDF,共5個面,注意頂點字母的順序已經考慮了頂點環繞方向向外(右手法則)。app

那如何判斷一個點是否位於 Shadow Volume 內部呢? Shadow Volumes 採用一種間接方法:從一個位於全部 Shadow Volume 外的點出發做射線,從 0 開始計數,每穿入一個 Shadow Volume +1,每穿出一個 Shadow Volume -1,這樣到達點 P 時,若是計數爲 0 說明位於陰影體積外,大於 0 說明在一層或多層 Shadow Volume 內部。原理是,每一個 Shadow Volume 都是封閉的,若是點 P 位於全部 Shadow Volume 外,則穿入和穿出必成對出現,有一種極端狀況:射線與一個 Shadow Volume 相切於棱邊上,這時射線與 Shadow Volume 表面只有 1 個交點而不是一般的 2 個交點(Shadow Volume 爲凸時),好在,這裏說的幾何原理的實際實現使用光柵化進行離散化,在離散化空間中,這種極端狀況並不存在(這和光柵化特性有關,如 "watertight" rasterization 見文獻[3])。這個原理以下圖所示(摘自文獻[3]PPT第18頁,二維示意):ide

這個計數的起點其實就是攝像機所在點,計數的任務能夠由圖形硬件的 Stencil Buffer (模板緩衝)機制提供,能夠看到,這裏要求攝像機位於陰影以外。函數

 

2. Zpass 方法性能

直接實現第1節的數學原理的方法即爲 Zpass 方法。實現 Zpass 須要完成兩方面工做:構造 Shadow Volume 、利用 Stencil Buffer 的功能實現計數。咱們先來看最簡單的狀況,場景中只有兩個三角形和一個地板,以下圖(看到陰影對判斷空間位置的重要性):

 

場景代碼以下:

// 世界,四邊形地板
void draw_world()
{
    glStaff::xyz_frame(2, 2, 2, false);
    glBegin(GL_POLYGON);
        glNormal3f(0, 1, 0);
        glVertex3f(-5, 0,-5); glVertex3f(-5, 0, 5);
        glVertex3f(5, 0, 5); glVertex3f(5, 0,-5);
    glEnd();
}

glm::vec3 tri1[3] = { glm::vec3(0, 3, 0), glm::vec3( 0, 3, 2), glm::vec3(2, 3, 0) };
glm::vec3 tri2[3] = { glm::vec3(1, 2,-1), glm::vec3(-1, 2,-1), glm::vec3(1, 2, 1) };
// 模型,兩個三角形
void draw_model()
{
    GLfloat _ca[4], _cd[4];
    glGetMaterialfv(GL_FRONT, GL_AMBIENT, _ca);
    glGetMaterialfv(GL_FRONT, GL_DIFFUSE, _cd);
    GLfloat c[4];
    glBegin(GL_TRIANGLES);
        c[0]=1; c[1]=0; c[2]=0; c[3]=1; glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c);
        glNormal3fv(&glm::normalize(glm::cross(tri1[1]-tri1[0], tri1[2]-tri1[0]))[0]);
        for(int i=0; i<3; ++i) glVertex3fv(&tri1[i][0]); // tri1,紅色
        c[0]=0; c[1]=1; c[2]=0; c[3]=1; glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c);
        glNormal3fv(&glm::normalize(glm::cross(tri2[1]-tri2[0], tri2[2]-tri2[0]))[0]);
        for(int i=0; i<3; ++i) glVertex3fv(&tri2[i][0]); // tri2,綠色
    glEnd();
    glMaterialfv(GL_FRONT, GL_AMBIENT, _ca);
    glMaterialfv(GL_FRONT, GL_DIFFUSE, _cd);
}

構造 Shadow Volume 代碼以下(light_pos 爲光源位置,位置式光源): 

static float d_far = 10;
// 構造、繪製 Shadow Volume,僅考慮位置光源
void draw_model_volumes()
{for(int t=0; t<2; ++t){
        glm::vec3* tri = t==0 ? tri1 : tri2; // tri1 or tri2
        glm::vec3 tri_far[3];
        for(int i=0; i<3; ++i){
            tri_far[i] = tri[i] + glm::normalize(tri[i]-glm::vec3(light_pos))*d_far;
        }
        for(int i=0; i<3; ++i){
            glBegin(GL_POLYGON); // 三個邊擠出(extrude)的四邊形
                glVertex3fv(&tri[i][0]);
                glVertex3fv(&tri_far[i][0]);
                glVertex3fv(&tri_far[(i+1)%3][0]);
                glVertex3fv(&tri[(i+1)%3][0]);
            glEnd();
        }
        glBegin(GL_TRIANGLES); // 頂部(near cap),原三角形,對 Zpass 來講可選
for(int i=0; i<3; ++i) glVertex3fv(&tri[i][0]); glEnd(); glBegin(GL_TRIANGLES); // 底部(far cap),擠出三角形,對 Zpass 來講可選
for(int i=0; i<3; ++i) glVertex3fv(&tri_far[2-i][0]); glEnd(); } }

構造的 Shadow Volume 以下圖所示:

 

Stencil Buffer 實現計數代碼:

// ------------------------------------------ 清除緩衝區,包括模板緩衝
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

// ------------------------------------------ 第1遍,渲染環境光,深度值
// 關閉光源,打開環境光
GLboolean _li0 = glIsEnabled(GL_LIGHT0); if(_li0) glDisable(GL_LIGHT0);
glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]);
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
if(_li0) glEnable(GL_LIGHT0);

// ------------------------------------------ 第2遍,渲染模板值
// 不須要光照,不更新顏色和深度緩衝
GLboolean _li = glIsEnabled(GL_LIGHTING); if(_li) glDisable(GL_LIGHTING);
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glDepthMask(GL_FALSE); glStencilMask(~0);
glEnable(GL_CULL_FACE);
glEnable(GL_STENCIL_TEST); glStencilFunc(GL_ALWAYS, 0, ~0);
// 剔除背面留下正面,穿入,模板值 加 1
glCullFace(GL_BACK); glStencilOp(GL_KEEP, GL_KEEP, GL_INCR);
glMatrixMode(GL_MODELVIEW);glLoadMatrixf(&mat_view[0][0]);glMultMatrixf(&mat_model[0][0]);
    draw_model_volumes();
// 剔除正面留下背面,穿出,模板值 減 1
glCullFace(GL_FRONT); glStencilOp(GL_KEEP, GL_KEEP, GL_DECR);
glMatrixMode(GL_MODELVIEW);glLoadMatrixf(&mat_view[0][0]);glMultMatrixf(&mat_model[0][0]);
    draw_model_volumes();
// 恢復狀態
if(_li) glEnable(GL_LIGHTING);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glDepthMask(GL_TRUE); glStencilMask(~0);
glDisable(GL_CULL_FACE); glDisable(GL_STENCIL_TEST); glStencilOp(GL_KEEP,GL_KEEP,GL_KEEP);

// ------------------------------------------ 第3遍,渲染光源光照,依據模板值判斷陰影
// 關閉環境光,打開光源
GLfloat _lia[4]; glGetFloatv(GL_LIGHT_MODEL_AMBIENT, _lia);
GLfloat ca[4]={0}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, ca);
// 模板測試爲,等於0經過, 深度測試爲,相等經過,顏色混合爲直接累加
glEnable(GL_STENCIL_TEST); glStencilFunc(GL_EQUAL, 0, ~0);
glDepthFunc(GL_EQUAL); glBlendFunc(GL_ONE, GL_ONE);

glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]);
glLightfv(GL_LIGHT0, GL_POSITION, &light_pos[0]); // 位置式光源
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
// 恢復狀態
glLightModelfv(GL_LIGHT_MODEL_AMBIENT, _lia);
glDisable(GL_STENCIL_TEST); glStencilFunc(GL_ALWAYS, 0, ~0);
glDepthFunc(GL_LESS); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

// 在光源處繪製一個黃色的球
glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]);
    dlight(0.05f);

這裏要用到 Stencil Buffer,要在建立窗口時(即建立 OpenGL Context)啓用 Stencil Buffer,GLFW 默認就啓用了(8-bit)。第1遍渲染時,僅開啓環境光,渲染場景後,顏色緩衝是環境光貢獻,深度緩衝是離攝像機最近的片段的深度。第2遍渲染,只更新 Stencil Buffer,由於深度緩衝已經保存了最近片段深度,深度測試 GL_LESS 經過的片段都是未經遮擋的 Shadow Volume 部分,若是看到了正面,模板值+1,背面-1,注意正背面是依據頂點環繞方向肯定的(光柵化的任務),由於是深度測試經過後計數故稱做 Zpass 。第3遍渲染,由於模板值爲0的點爲光照,不然爲陰影,設置模板測試爲和0比較相等時經過,並設置混合函數爲直接累加(和 Shadow Mapping 相似)。

模板緩衝區的值(全黑爲模板值爲0,每一個顏色梯度模板值變化1),以及最終渲染結果以下圖所示:

 

讀取模板緩衝區使用 glReadPixels(ox,oy, width,height, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, data),上面全部代碼見所附程序中的 volumes_basic0.cpp。

在講輪廓邊以前,先看下上面代碼幾個須要改進的地方:

  1. 在渲染模板值時,不須要渲染兩遍(一遍正面,一遍背面),OpenGL 支持在一遍渲染中對正背面使用不一樣的模板更新操做,使用 glStencilOpSeparate() 函數;
  2. 爲防止模板緩衝區溢出或減少爲負數(默認模板緩衝爲8-bit),可使用繞回模式(wrap,255加1變成0,0減1變成255);
  3. 能夠利用齊次座標特性將 Shadow Volume 延伸到無窮遠,對於 Zpass 來講,不須要對 Shadow Volume 進行封口(cap),底部不須要封口由於 Zpass 只關心未被遮擋(Zpass)部分,頂部不須要封口由於它正好被原三角形遮擋(不能經過 Z 測試)。
  4. 上面代碼沒有考慮光源爲平行光源的狀況(光源位置座標w份量爲0),也沒有考慮三角形背對光源的狀況,背對時 Shadow Volume 的頂點環繞方向將向內部(若是全部 Shadow Volume 都向內部也不要緊,問題是向內向外不一致將致使計數錯誤),是面對仍是背對光源能夠用光源到三角形上任意一點的連線向量和三角形法向量的內積的正負號判斷;
  5. 上面代碼未考慮模型變換矩陣的變換(鼠標左鍵拖動物體,陰影將再也不正確),由於模型變換一樣施加到 Shadow Volume 上,只需對光源位置進行反變換。

上面代碼的 「第2遍,渲染模板值」 的繪製部分等價代碼以下:

// 不須要光照,不更新顏色和深度緩衝
// ...
// 正面加1,背面減1
glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_KEEP, GL_INCR_WRAP); // 改進後
glStencilOpSeparate(GL_BACK, GL_KEEP, GL_KEEP, GL_DECR_WRAP);
glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]); glMultMatrixf(&mat_model[0][0]);
    draw_model_volumes(glm::affineInverse(mat_model)*light_pos);
// 恢復狀態
// ...

將三角形邊擠出到無窮遠的代碼以下(考慮三角形是否背對光源):

// 構造、繪製 Shadow Volume,擠出(extrude)到無窮遠
void draw_model_volumes(glm::vec4& lpos)
{
    for(int t=0; t<2; ++t){
        glm::vec3* tri = t==0 ? tri1 : tri2; // tri1 or tri2
        glm::vec4 tri_far[3];
        for(int i=0; i<3; ++i){
            tri_far[i] = glm::vec4(
                tri[i].x*lpos.w-lpos.x, tri[i].y*lpos.w-lpos.y, tri[i].z*lpos.w-lpos.z, 0);
        }
        glm::vec3 n = glm::cross(tri[1]-tri[0], tri[2]-tri[0]);
        glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) : glm::vec3(lpos)/lpos.w-tri[0];
        int m = glm::dot(n,l0)>=0 ? 1 : -1; // 是否反轉四邊形環繞方向
        for(int i=0; i<3; ++i){
            glBegin(GL_POLYGON); // 三個邊擠出(extrude)的四邊形
                glVertex3fv(&tri[i][0]);
                glVertex4fv(&tri_far[i][0]);
                glVertex4fv(&tri_far[(i+m+3)%3][0]);
                glVertex3fv(&tri[(i+m+3)%3][0]);
            glEnd();
        }
    }
}

位置光源和平行光源的對好比下:

 
 

這部分代碼見所附程序中的 volumes_basic1.cpp。

到目前爲止,咱們的場景過於簡單,如今考慮複雜的網格,這裏僅考慮質量好的三角網格(封閉,任意點爲二維流形,manifold,即每一個邊接兩個面,面之間無交叉)。咱們使用 VCGlib,關於用 VCGlib 讀寫網格文件、構造頂點邊面鏈接信息、法向量計算、平滑等處理請見本文最後的附錄。最簡單的將上述方法擴展到複雜網格的方法是:對每一個三角形都構造 Shadow Volume ,對一個 mesh 的每一個三角形構造 Shadow Volume 的代碼以下(讀入的 PLY 網格文件已經預先用 Blender 和 MeshLab 處理爲 manifold 三角網格,關於 VCGlib 的使用見最後的附錄):

// 構造、繪製 Shadow Volume
void draw_model_volumes(GLMesh& mesh, glm::vec4& lpos)
{
    assert(mesh.FN()==mesh.face.size()); // vcg::tri::Allocator<>::CompactFace/Edge/VertexVector() for(int i=0; i<mesh.FN(); ++i){ // for each face (i.e. triangle)
        GLMesh::FaceType& f = mesh.face[i];
        glm::vec4 tri_far[3]; // 擠出的3個點,到無窮遠
        for(int i=0; i<3; ++i){
            tri_far[i] = glm::vec4(
                f.V(i)->P().X()*lpos.w-lpos.x,
                f.V(i)->P().Y()*lpos.w-lpos.y,
                f.V(i)->P().Z()*lpos.w-lpos.z, 0 );
        }
        glm::vec3 n( vcg_to_glm(f.N()) );
        glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) :
            glm::vec3(lpos)/lpos.w - vcg_to_glm(f.V(0)->P());
        int m = glm::dot(n,l0)>=0 ? 1 : -1; // 是否反轉四邊形環繞方向
        for(int i=0; i<3; ++i){
            glBegin(GL_POLYGON); // 三個邊擠出(extrude)的四邊形
                glVertex3fv(&f.V(i)->P()[0]);
                glVertex4fv(&tri_far[i][0]);
                glVertex4fv(&tri_far[(i+m+3)%3][0]);
                glVertex3fv(&f.V((i+m+3)%3)->P()[0]);
            glEnd();
        }
    }
}

程序結果以下:左上爲最終結果;右上爲對應 Stencil 值(顏色梯度表示變化 1,能夠想見 Stencil 的更新很是頻繁,但由於都是+1和-1操做,因此累積值並不必定很大);下面是 Shadow Volume 的顯示,能夠看到,由於每一個三角形都構造 Shadow Volume,Shadow Volume 的線條很是密。渲染時間約 180ms:

 
 

並不須要對全部邊都進行擠出(extrude),只須要對某些被稱做 「輪廓邊」 的邊(準確的說是 「可能輪廓邊」)進行擠出就能夠構造出合格的 Shadow Volume,「可能輪廓邊」 是指其所鏈接的兩個面(對 manifold 網格每一個邊必鏈接兩個面)一個面對光源另外一個背對光源。面對仍是背對光源能夠用三角形面法向量和光源到三角形上任一點連線向量的內積的正負號判斷,優化後的,只對 「可能輪廓邊」 進行擠出的代碼以下,注意和上面不一樣,此時對邊進行遍歷,而再也不是三角形,注意要保證四邊形環繞方向爲向外:

// 構造、繪製 Shadow Volume
void draw_model_volumes(GLMesh& mesh, glm::vec4& lpos)
{
    assert(mesh.EN()==mesh.edge.size());
    for(int i=0; i<mesh.EN(); ++i){
        GLMesh::EdgeType& e = mesh.edge[i];
        GLMesh::FaceType* fa = e.EFp(); // fa,fb 爲邊 e 鄰接的兩個面
        GLMesh::FaceType* fb = fa->FFp(e.EFi());
        glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) :
            glm::vec3(lpos)/lpos.w-vcg_to_glm(e.V(0)->P());
        int sa = glm::dot(l0, vcg_to_glm(fa->N()))>=0 ? 1 : -1; // 面對仍是背對光源 int sb = glm::dot(l0, vcg_to_glm(fb->N()))>=0 ? 1 : -1;
        if( sa*sb < 0 ){ // 一個面面對,一個面背對光源,「可能輪廓邊」
            GLMesh::VertexType* va = fa->V(e.EFi());
            GLMesh::VertexType* vb = fa->V((e.EFi()+1)%3);
            if(sa<0) std::swap(va, vb); // 肯定頂點順序,是最終四邊形環繞方向向外
            glm::vec4 e_far[2]; // 擠出的2個點,到無窮遠
            e_far[0] = glm::vec4(
                va->P().X()*lpos.w-lpos.x,
                va->P().Y()*lpos.w-lpos.y,
                va->P().Z()*lpos.w-lpos.z, 0 );
            e_far[1] = glm::vec4(
                vb->P().X()*lpos.w-lpos.x,
                vb->P().Y()*lpos.w-lpos.y,
                vb->P().Z()*lpos.w-lpos.z, 0 );
            glBegin(GL_POLYGON); // 邊擠出(extrude)的四邊形
                glVertex3fv(&va->P()[0]);
                glVertex4fv(&e_far[0][0]);
                glVertex4fv(&e_far[1][0]);
                glVertex3fv(&vb->P()[0]);
            glEnd();
        }
    }
}

再看結果,對比上面的圖,如今 Shadow Volume 的邊稀疏多了,且渲染時間減小到了 45ms:

 
 

Zpass 方法的第一個問題是:當攝像機位於陰影中時,光照處 Stencil 值將再也不爲0,見下面的例子:

 
 
 

這個問題能夠經過檢測攝像機是否位於陰影中,並在攝像機位於陰影中時對 Stencil 值進行偏移進行解決,但這須要額外開銷,後面用 Zfail 方法避免這一問題。和想象中的不一樣,攝像機並非 「要麼在陰影中,要麼在陰影外」 ,它有可能 「一半位於陰影中,一半位於陰影外」,這實際上是近裁剪面的做用:

 

近裁剪面問題是 Zpass 方法的第二個問題,詳見文獻[3]。這小節代碼見所附程序中的 volumes_zpass.cpp。

 

3. Zfail 方法,實際方法

Zpass 失敗的緣由,以及 Zfail 方法的原理以下圖所示(摘自文獻[4],a. Zpass 原理,b. Zpass 失敗例子,c. Zfail 方法原理):

Zpass 從攝像機發出射線到無窮遠並計數,而 Zfail 正好相反,它從攝像機射線的窮遠處到攝像機計數,當 Shadow Volume 封閉時且攝像機位於陰影外時,Zpass 和 Zfail 是等價的,由於:一條射線和封閉的 Shadow Volume 老是交於兩個點(凸時,非凸時老是偶數個交點,前面已經分析了,極端狀況在離散空間並不存在),若點 P 在某 Shadow Volume 中,Zpass 和 Zfail 對該 Shadow Volume 計數結果都爲+1,若 P 在該 Shadow Volume 外,則 Zpass 和 Zfail 計數結果爲 「0 和 +1-1」 或者 「+1-1 和 0」,此兩種狀況都是等價的說明了 Zfail 的正確性。

Zfail 較 Zpass 有更好的特性

  • 在攝像機位於陰影中時也能產生正確結果;
  • 不受近裁剪面影響,由於它只關心被物體遮擋的部分。

但其也有缺點須要克服:

  • 受遠裁剪面影響,能夠按照文獻[3]將攝像機遠裁剪面設置於無窮遠處(精度損失並不大),也可使用 glEnable(GL_DEPTH_CLAMP);
  • Zpass 不須要對Shadow Volume 封口(cap),而 Zfail 須要,而且須要對近端和遠端都進行 cap,對遠端進行 cap 是由於 Zfail 須要Shadow Volume 被遮擋的部分(極可能是遠端),對近端進行 cap 是由於 Shadow Volume 被遮擋的部分多是近端(攝像機從 P 點背後看物體);
  • Zfail 較 Zpass 一般產生更多的 Shadow Volume 片段,即須要更多的像素填充,這是由於 Shadow Volume 被遮擋的部分一般比未被遮擋的部分面積大。

實現 Zfail 計數是直接的:

  • 將上面代碼中經過 Depth Test 更新 Stencil Buffer 改成未經過時更新(故名 Zfail)。

對網格構造 Shadow Volume 的代碼和以前稍有區別,須要 cap:

  • 對每一個 「可能輪廓邊」 進行擠出(extrude)到無窮遠,須要四邊形頂點環繞方向向外;
  • 對全部面對光源的三角形面,直接繪製,對全部背對光源的三角形面,將其頂點擠出到無窮遠並繪製。

Zfail 代碼見所附程序中的 Volumes_zfail.cpp。程序結果以下圖所示,如今攝像機位於陰影中也不會有問題了,但渲染幀率也從 23fps 降到了 18 fps:

 

「實際方法」 一詞出自文獻[3],這篇 2002 年的文章經過使用 Zfail 並將攝像機遠裁剪面設置於無窮遠處,改進了 Shadow Volumes 方法,更值得一提的是,它提到的 wrap 方式 Stencil 值更新、Depth Clamping、Two-Sided Stencil Testing 後來都已是 OpenGL 標準了,這使得咱們能夠以更簡潔的方式實現 Shadow Volumes。

多個光源的處理和 Shadow Mapping 相似,下面是結果,代碼見所附程序中的 volumes_multi_lights.cpp,關於平行光,由於已經利用齊次座標特性考慮了光源 w 座標,只需將光源座標 w 份量設爲 0 便可實現平行光:

 

4. 進一步研究

低質量網格(non-manifold 網格) Shadow Volume 構造見文獻[4],另外文獻[4]給出了用幾何着色器構造 Shadow Volume 的代碼,經過裁剪 Shadow Volume 或交替使用 Zpass/Zfail 減少對像素填充率(須要光柵化的多邊形面積)消耗的性能優化方法見文獻[1]的文獻[1]及文獻[4],基於 Shadow Volumes 的 Soft Shadow 方法見文獻[1]的文獻[1]。

 

5. Shadow Volumes VS. Shadow Mapping

先來看同一個場景用 Shadow Volumes 和 Shadow Mapping 兩種方法渲染的對比圖(個人機器配置:Pentium Dual-Core 2.6 GHz,4 GB DDR2,GT240 1GB GDDR5 OpenGL 3.3),代碼見所附程序中的 comparison_volumes_mapping.cpp。

第一個場景,2000 個正方體,每一個正方體有 8 個頂點、12 個三角形,下面依次是無陰影、Shadow Volumes、Shadow Mapping 渲染結果,渲染時間和幀率在圖中左上角和左下角(幀率結果包含所有CPU時間和GPU時間,更具綜合性),Shadow Volumes 使用本文最後的 Zfail 方法,Shadow Mapping 使用 2048x2048 陰影圖:

 
 

第二個場景,50 個猴頭模型,每一個猴頭模型有 28.9K 個頂點、57.8K 個三角形,程序結果以下:

 
 
 

對上圖做放大觀察,Shadow Volumes 和 Shadow Mapping 方法的結果以下,能夠看到 Shadow Volumes 放大後毫無鋸齒,而 Shadow Mapping 方法已經有輕微鋸齒:

 

須要指出的是,這裏實現的 Shadow Volumes 和 Shadow Mapping 能夠進一步優化,如使用頂點列表、使用顯示列表、優化幾何數據結構、若是可能重用陰影圖或陰影體積等等,因此上面的性能比較結果並不很準確,這裏只想給出一個參考。

對 Shadow Volumes 和 Shadow Mapping 做以下分析對比:

  1. 運行速度方面,基本的 Shadow Volumes 須要三遍渲染:環境光和深度值、Shadow Volume 和 Stencil 值、光源光,基本的 Shadow Mapping 須要三遍渲染:攝像機視角深度圖、環境光和深度值、光源光,通常而言,Shadow Mapping 更快,這是由於構造和光柵化 Shadow Volume 比較耗時,粗略估算下 Shadow Mapping 比直接渲染(沒有陰影)慢三倍左右(若是環境光不單獨渲染則是二倍);
  2. 渲染效果方面,Shadow Volumes 實現的是幾何上精確的陰影,不存在鋸齒問題,Shadow Mapping 存在鋸齒問題,這能夠經過增大深度圖尺寸緩解,但並不能根本解決,Shadow Mapping 鋸齒問題的根本緣由是須要兩個不一樣視角:光源視角和攝像機視角,兩個視角下多邊形的斜率以及多邊形投影后的大小差別是產生鋸齒的緣由(無限放大去觀察陰影的邊沿,須要無限大的陰影圖),而這並無好的解決方法,相比之下 Shadow Volumes 的 Stencil 值渲染是在攝像機視角進行的,另一般來講,從 Shadow Mapping 產生 Soft Shadow 相對容易;
  3. 魯棒性或通用性方面,Shadow Volumes 須要良好的幾何數據結構,即便算法可以處理非封閉網格,也要求網格的拓撲信息,從而優化 Shadow Volume 的構造,這使得使用 glutTeaport() 再也不可能,由於幾何數據被封裝在了函數內部,幾何計算髮生很小錯誤時,Shadow Volumes 可能產生很明顯的錯誤結果,相比之下 Shadow Mapping 對幾何數據的要求則小的多,但 Shadow Mapping 也存在問題:須要剔除正面或使用深度偏移值以免斑紋,而偏移值大小很差肯定,須要特殊處理大視角光源,尤爲全方向點光源,也須要特殊處理平行光(簡單);
  4. 研究和工業使用方面,Shadow Mapping 的研究和基於其的陰影的研究相對較多,Shadow Mapping 在工業中的使用也相對較多,這多是由於其算法實現較爲簡單。

 

下載連接:程序集成了上一博客 Shadow Mapping 的源代碼,並支持64位,好多庫是純頭文件,爲了加快編譯速度,使用了預編譯頭,請見代碼中註釋,工程的配置見程序文件夾下 「說明.txt」。

連接: http://pan.baidu.com/s/1i3oXHSL 密碼: agx5

(左Ctrl+鼠標左鍵拖拽改變視角,鼠標滾輪縮放)

 

參考文獻

  1. OpenGL陰影,Shadow Mapping(附源程序),及其參考文獻;
  2. http://en.wikipedia.org/wiki/Shadow_volume
  3. C. Everitt and M. J. Kilgard, "Practical and robust stenciled shadow volumes for hardware-accelerated rendering," arXiv preprint cs/0301002, 2002(進入下載PPT);
  4. GPU Gems 3, Chapter 11. Efficient and Robust Shadow Volumes Using Hierarchical Occlusion Culling and Geometry Shaders(網頁版)。

 

 

*******************************************************************************

附錄VCGlib 庫 使用說明

先來看看 VCGlib 能作什麼

  • 最基本的,它提供 Mesh(triangular mesh,tetrahedral mesh,三角網格或四面體網格)數據結構的定義,該數據結構支持對 Mesh 數據的快速訪問(拓撲信息、空間查詢等)以及高效執行網格上算法;
  • 在 Mesh 數據結構基礎上,實現大量高效的網格算法,如網格修補、平滑、變形、曲率計算、細分、泊松盤採樣、等值面計算等;
  • IO 支持,讀寫 PLY、OBJ、STL、3DS、OFF、DXF 等格式網格文件;
  • UI 支持,如 OpenGL 網格顯示,Trackball 交互等。

VCGlib 的文檔很簡陋,在線文檔並非很全,能夠本身用 Doxygen 從下載的源代碼生成 html API 文檔,爲此只須要(Windows 用戶):

  1. 安裝 Doxygen(Doxywizard) 和 Graphviz,將 「Graphviz安裝目錄\Graphviz2.36\bin」 添加到環境變量Path;
  2. 將本博客所附程序中的 「OpenGL Shadow\_Libs\vcglib\docs\doxyfile-all」 文件拷貝到下載的 VCGlib 文件夾下的 「vcglib\docs\Doxygen」 下,這個文件是我配置好的 Doxygen 配置文件;
  3. 用 Doxywizard 打開上一步拷貝的文件,點擊 「Run」 選項卡下的 「Run doxygen」 按鈕,生成的 API 文檔將位於 「vcglib\docs\Doxygen\html-all」 下(html-all 文件夾將有 129M 大小)。

VCGlib 是純頭文件庫,要安裝只需將下載 VCGlib 庫目錄添加到程序的頭文件包含路徑(有些IO函數如讀寫PLY須要包含相應.cpp文件)。

後面按照以下步驟講解

  1. 定義 Mesh 類型;
  2. 訪問及指定 Mesh 的頂點、三角形(對三角網格,若是是四面體網格則是四面體,這裏默認只講三角網格)等數據;
  3. IO,讀寫 PLY、OBJ 等網格文件;
  4. 構造網格的拓撲信息,如頂點或三角形面法向量、三角形相鄰三角形、邊鏈接的三角形等信息;
  5. 網格處理,如法向量平滑、網格修補等。

定義 Mesh 類型的典型代碼以下(API 文檔主頁 Basic Concepts,在線版):

#include "vcg/complex/complex.h"
// 類型聲明
class MyVertex;class MyEdge;
class MyFace;
typedef vcg::UsedTypes<
    vcg::Use<MyVertex>::AsVertexType,
    vcg::Use<MyEdge>  ::AsEdgeType,
    vcg::Use<MyFace>  ::AsFaceType >
MyUsedTypes;
// 頂點類型 class MyVertex : public vcg::Vertex<MyUsedTypes,
    vcg::vertex::Coord3f,
    vcg::vertex::Normal3f,
    vcg::vertex::BitFlags > { };
// 邊類型 class MyEdge : public vcg::Edge<MyUsedTypes,
    vcg::edge::VertexRef,
    vcg::edge::EFAdj,
    vcg::edge::BitFlags > { };
// 面類型,三角形 class MyFace : public vcg::Face<MyUsedTypes,
    vcg::face::VertexRef,
    vcg::face::Normal3f,
    vcg::face::FFAdj,
    vcg::face::BitFlags > { };
// 網格類型
typedef vcg::tri::TriMesh<
    std::vector<MyVertex>,
    std::vector<MyEdge>,
    std::vector<MyFace> > GLMesh;

拋開 MyUseTypes 不看,上面代碼定義的網格類型爲:

  • 網格包含數據:頂點、邊、三角形數組(std::vector<>);
  • 每一個頂點包含屬性:空間座標(3個float表示)、頂點法向量、標誌位;
  • 每一個邊包含屬性:頂點指針(指向該邊的兩個頂點)、邊-面鄰接信息、標誌位;
  • 每一個三角形麪包含屬性:頂點指針(指向該三角形的三個頂點)、面法向量、面-面鄰接信息、標誌位。

VCGlib 使用 Reference 數據結構,對每一個邊、面用指針記錄其頂點、鄰接面等信息,其餘網格數據結構見 wikipedia Polygon Mesh 條目

爲了作到足夠通用,VCGlib 使用了C++ template metaprogramming(模板元編程)方法。上面代碼中的 MyVertex、MyEdge、MyFace、GLMesh 等類型包含哪些屬性(模板參數)、屬性的順序(模板參數順序)都是能夠根據須要隨意指定的(固然,必須包含足夠的屬性以執行相應網格算法),通常來講,最好使頂點、邊、麪包含標誌位屬性(BitFlags),BitFlags 指示該頂點、邊、面是否可寫、可讀、已刪除(爲了效率,例如,刪除頂點操做可能並不當即刪除頂點數據,而僅僅打個標誌位,待全部操做完成再更新頂點數據)等。不去深刻講解 VCGlib 元編程機理(說實話我還沒弄清楚),可選個數模板參數是經過默認模板參數實現的,vcg::Vertex/Edge/Face<> 將繼承其模板參數

下面列舉全部可選的模板參數

  • 網格 vcg::tri::TriMesh<> 最多可有四個參數:頂點容器、邊容器、面容器、半邊容器(vcg::HEdge<>);
  • 頂點 vcg::Vertex<> 能夠包含的屬性有:座標、法向量、顏色、紋理座標、標誌位、網格質量(網格在該點出優劣評價指標)、曲率、半徑、頂點-邊鄰接信息、頂點-面鄰接信息、頂點-半邊鄰接信息,等(API 文檔 Modules 選項卡 Vertex Components,在線版);
  • 邊 vcg::Edge<> 能夠包含的屬性有:頂點指針、顏色、標誌位、網格質量、邊-頂點鄰接信息、邊-邊鄰接信息、邊-面鄰接信息、邊-半邊鄰接信息,等(API 文檔 Modules 選項卡 Edge Components,在線版);
  • 面 vcg::Face<> 能夠包含的屬性有:頂點指針、法向量、顏色、標誌位、網格質量、頂點-面鄰接信息、面-邊鄰接信息、面-面鄰接信息,等(API 文檔 Modules 選項卡 Face Components,在線版)。

訪問 Mesh 數據示例代碼以下

  // load mesh ...
int
i=0, j=0; // 見 vcg::tri::TriMesh<> ------------------------------------------------------------- mesh.VN(); mesh.EN(); mesh.FN(); // 頂點、邊、面個數,可能小於 vs/es/fs.size() // 由於有些元素被刪除時僅僅打了標誌位而並未刪除存儲數據 std::vector<GLMesh::VertexType>& vs = mesh.vert; // 頂點數組 std::vector<GLMesh::EdgeType>& es = mesh.edge; // 邊數組 std::vector<GLMesh::FaceType>& fs = mesh.face; // 面數組 // 見 vcg::Vertex<> 及其 模板參數 ------------------------------------------------------- GLMesh::VertexType& v = mesh.vert[i]; // 第 i 個頂點,假設 v.isD()==false,即未標誌爲已刪除 v.P().Z(); v.P().V(j); // 頂點座標,其xyz份量 v.N().X(); // 頂點法向,其x份量 // 見 vcg::Edge<> 及其 模板參數 --------------------------------------------------------- GLMesh::EdgeType& e = mesh.edge[i]; // 第 i 個邊,假設 e.isD()==false GLMesh::VertexType* pve = e.V(j); // j=0,1,邊的兩個端點頂點的指針 GLMesh::FaceType* pfa = e.EFp(); // 邊-面鄰接信息,該邊鏈接的第一個面 // 見 vcg::Face<> 及其 模板參數 --------------------------------------------------------- GLMesh::FaceType& f = mesh.face[i]; // 第 i 個面(三角形),假設 f.isD()==false GLMesh::VertexType* pvf = f.V(j); // j=0,1,2,三角形面的三個頂點的指針 f.N(); // 面的法向量 GLMesh::FaceType* pfb = f.FFp(j); // 面-面鄰接信息,j=0,1,2,面 f 經過其第j個邊鏈接的第一個面 // 能夠經過返回的引用(左值)修改數據,但不要隨便修改,見下文 ------------------------------------ v.P().Y() += 3.2f; e.V(j) = &v; f.V(j) = &v; // 遍歷全部頂點、邊、面須要跳過標記爲已刪除的元素 --------------------------------------------- for(size_t i=0; i<vs.size(); ++i){ if(vs[i].IsD()) continue; // do some thing for each vertex vs[i] ... } // 除非已經刪除了全部標記爲已刪除元素的存儲數據,好比: vcg::tri::Allocator<GLMesh>::CompactVertexVector(mesh); vcg::tri::Allocator<GLMesh>::CompactEdgeVector(mesh); vcg::tri::Allocator<GLMesh>::CompactFaceVector(mesh); for(size_t i=0; i<fs.size(); ++i){ // do some thing for each face fs[i] ... }

填充(Fill)Mesh 數據的示例代碼以下(API 文檔主頁 Creating and destroying elements,在線版,代碼摘自那裏):

// VCGlib Reference 數據結構,依賴於指針,直接操做頂點、邊、面數組 mesh.vert/edge/face 可能
// 產生 std::vector<> 存儲從新分配,此時,相關指針將失效,vcg::tri::Allocator<> 處理這些問題
GLMesh m;
GLMesh::VertexIterator vi = vcg::tri::Allocator<GLMesh>::AddVertices(m, 3);
GLMesh::FaceIterator fi = vcg::tri::Allocator<GLMesh>::AddFaces(m, 1);
GLMesh::VertexPointer ivp[4];
ivp[0]=&*vi; vi->P()=GLMesh::CoordType(0.0f,0.0f,0.0f); ++vi;
ivp[1]=&*vi; vi->P()=GLMesh::CoordType(1.0f,0.0f,0.0f); ++vi;
ivp[2]=&*vi; vi->P()=GLMesh::CoordType(0.0f,1.0f,0.0f); ++vi;
fi->V(0)=ivp[0]; fi->V(1)=ivp[1]; fi->V(2)=ivp[2];
// Alternative, more compact, method for adding a single vertex
ivp[3]= &*vcg::tri::Allocator<GLMesh>::AddVertex(m,GLMesh::CoordType(1.0f,1.0f,0.0f));
// Alternative, method for adding a single face (once you have the vertex pointers)
vcg::tri::Allocator<GLMesh>::AddFace(m, ivp[1],ivp[0],ivp[3]);

// 同理,若是本身保存了頂點等數據指針,須要在修改頂點、邊、面數組後更新該指針 --------------------
// a potentially dangerous pointer to a mesh element
GLMesh::FacePointer fp = &m.face[0];
vcg::tri::Allocator<GLMesh>::PointerUpdater<GLMesh::FacePointer> pu;
// now the fp pointer could be no more valid due to eventual re-allocation of the m.face
vcg::tri::Allocator<GLMesh>::AddVertices(m,3);
vcg::tri::Allocator<GLMesh>::AddFaces(m,1,pu);
// check if an update of the pointer is needed and do it.
if(pu.NeedUpdate()) pu.Update(fp); // 能夠想見,pu 保存了地址偏移信息,只需將 fp 偏移 // 刪除元素的代碼以下 --------------------------------------------------------------------
vcg::tri::Allocator<GLMesh>::DeleteFace(m,m.face[0]);

// 拷貝網格(一樣引發地址變化)的代碼以下,GLMesh 沒有拷貝構造函數,也沒有 operator= ------------
GLMesh m2;
vcg::tri::Append<GLMesh,GLMesh>::MeshCopy(m2, m, false, true); // m to m2

IO,讀寫網格文件示例代碼以下(API 文檔主頁 Loading and saving meshes,在線版):

// Mesh 文件通常至少包含頂點數組信息,還能夠包含鏈接信息(三角形)、頂點法向量、頂點顏色、面顏色、
// 面法向量、紋理座標等等屬性,用 mask 的二進制位來標記或控制讀取或寫入了 Mesh 文件的哪些屬性
// 見 vcg::tri::io::Mask,讀取 PLY 須要包含文件 "vcglib/wrap/ply/plylib.cpp"(見這裏// 頭文件包含:#include "wrap/io_trimesh/import.h" #include "wrap/io_trimesh/export.h"
GLMesh m; int mask;
// 讀取 PLY 文件,並檢查返回值,參數 mask 爲可選,mask 是返回參數:讀入了哪些屬性
if( vcg::tri::io::ImporterPLY<GLMesh>::Open(m, "file_to_open.ply", mask)
    != vcg::ply::E_NOERROR ) {
    std::cout << "Load PLY file ERROR\n";
}
    // some modification to m and mask ...
// 保存 PLY 文件,mask 是輸入參數,控制 m 的哪些屬性被寫入到文件
vcg::tri::io::ExporterPLY<GLMesh>::Save(m, "file_to_save.ply", mask);

// 讀取或寫入 OBJ 文件的代碼,mask 做用同上
if( vcg::tri::io::ImporterOBJ<GLMesh>::Open(m, "file_to_open.obj", mask)
    != vcg::tri::io::ImporterOBJ<GLMesh>::E_NOERROR ) {
        std::cout << "Load OBJ file ERROR\n";
}
    // some modification to m and mask ...
vcg::tri::io::ExporterOBJ<GLMesh>::Save(m, "file_to_save.obj", mask);

// 讀取、寫入網格文件,將根據文件擴展名自動匹配文件格式 ---------------------------------------
int oerr = vcg::tri::io::Importer<GLMesh>::Open(m, "file_to_open.off", mask);
if( oerr != 0 ){
    std::cout << "Load mesh file ERROR: "
        << vcg::tri::io::Importer<GLMesh>::ErrorMsg(oerr) << '\n';
}
    // some modification to m and mask ...
int serr = vcg::tri::io::Exporter<GLMesh>::Save(m, "file_to_save.3ds", mask);
if( serr != 0 ){
    std::cout << "Save mesh file ERROR: "
        << vcg::tri::io::Exporter<GLMesh>::ErrorMsg(oerr) << '\n';
}

構造網格拓撲信息示例代碼以下(API 文檔主頁 Adjacency and Topology,在線版):

  // load mesh ...
vcg::tri::UpdateNormal<GLMesh>::PerFaceNormalized(mesh); // 計算頂點法向量,並單位化
vcg::tri::UpdateNormal<GLMesh>::PerVertexNormalized(mesh); // 計算面法向量,並單位化
vcg::tri::UpdateTopology<GLMesh>::FaceFace(mesh); // 計算面-面鄰接信息
vcg::tri::UpdateTopology<GLMesh>::AllocateEdge(mesh); // 計算邊-面鄰接信息,須要面-面信息
vcg::Matrix44f mat(&glm::translate(glm::vec3(1,2,3))[0][0]);
vcg::tri::UpdatePosition<GLMesh>::Matrix(mesh, mat, true); // 更新頂點位置,並更新法向量
// 在調用 UpdateTopology<>::FaceFace() 和 UpdateTopology<>::AllocateEdge() 後就構造了邊到面
// 的信息,對於 manifold 網格,每一個邊必鏈接兩個三角形面,下面代碼對邊 i 查找其鏈接的面 fa 和 fb
int i=0; GLMesh::EdgeType& e = mesh.edge[i];
GLMesh::FaceType* fa = e.EFp();
GLMesh::FaceType* fb = fa->FFp(e.EFi());

在準備這篇博客之初,研究 VCGlib 時,發現了 VCGlib 的一個 BUG,已經報告給開發者並獲得確認(見這裏,看看時間,發現這篇博客由於一些緣由拖了20多天...)。

網格處理示例代碼以下

vcg::tri::Clean<GLMesh>::RemoveDuplicateVertex(mesh); // 去除重合的頂點
vcg::tri::Smooth<GLMesh>::VertexNormalLaplacian(mesh, 5); // 平滑頂點法向量
float maxSizeHole = 2.0f; // fill 全部直徑小於 maxSizeHole 的洞
vcg::tri::Hole<GLMesh>::EarCuttingIntersectionFill
    <vcg::tri::SelfIntersectionEar<GLMesh>>(mesh, maxSizeHole, false);

進一步學習的資源

  • 下載的 VCGlib 源代碼 「vcglib\apps\sample」 下的官方示例代碼;
  • 源代碼,結合 API 文檔;
  • 基於 VCGlib 的軟件 MeshLab,能夠用於網格文件處理。
相關文章
相關標籤/搜索