OpenGL陰影,Shadow Mapping(附源程序)

 

實驗平臺:Win7,VS2010html

 

先上結果截圖(文章最後下載程序,解壓後直接運行BIN文件夾下的EXE程序):算法

 

本文描述圖形學的兩個最經常使用的陰影技術之一,Shadow Mapping方法(另外一種是Shadow Volumes方法)。在講解Shadow Mapping基本原理及其基本算法的OpenGL實現以後,將繼續深刻分析解決幾個實際問題,包括如何處理全方向點光源、多個光源、平行光。最近還有可能寫一篇Shadow Volumes的博文(目前已經將基本理論弄清楚了),在那裏,將對Shadow Mapping和Shadow Volumes方法作簡要的分析對比。app

本文的程序實現用到了不少開源程序庫,列舉以下:函數

  1. GLEW,(.lib, .dll),用於處理OpenGL本地擴展;
  2. GLFW,(.lib, .dll),用於處理窗口,以及建立OpenGL Context;
  3. Freeglut,(.lib, .dll),處理窗口,但本文只用其繪製基本幾何體,如茶壺;
  4. GLM,(純頭文件),OpenGL數學庫,向量及矩陣代數計算;
  5. DevIL,(.lib, .dll),讀寫圖片,支持不少格式,如JPG、PNG;
  6. FTGL做者網站),(.lib, .dll),在OpenGL中顯示字體,支持TrueType字體文件讀取,支持抗鋸齒字體、拉伸實體字形等,須要FreeType,(.lib),庫支持;
  7. Bullet,(.lib),物理引擎,能夠進行剛體可變形體的模擬,本文暫未使用;
  8. VCG,(純頭文件,有些IO操做須要.cpp),讀寫.obj等網格數據,高效表示網格,並有大量如網格修復算法實現,本文暫未使用。

 

1. 數學原理post

拋開復雜的現實世界中對「陰影」難以定義的問題(見文獻[1]第1章),直接來看圖形學實際採用的陰影的數學定義,以下圖(摘自文獻[1]):測試

lit(lighted)是直接接受光照的區域,umbra中文爲「本影」,是某個光源徹底被遮擋的局域,penumbra中文爲「半影」,是僅能接受到有限大光源部分光照的區域。有限大光源產生半影,使得陰影的邊沿柔和化,也稱做Soft Shadow,理想點光源的半影將消失,也稱爲Hard Shadow。本文中,我將只考慮Hard Shadow,並主要討論點光源,能夠想見,有限大光源能夠用無窮多個點光源逼近。字體

有了陰影的定義,用OpenGL實現陰影的問題就歸結爲:對攝像機看到的每一個表面上的點,肯定其和光源之間是否有遮擋,若是有則該點位於陰影中,若是沒有則該點直接接受光照(不考慮半影)。Shadow Mapping方法將這個問題等價轉換爲:對於每一個表面上的點P,過該點作一條從光源射出的射線(再次,咱們主要說點光源),這條射線和場景中物體的表面有多個交點,設這些交點中離光源最近的爲A,若是P點離光源距離大於A,則P點位於陰影中,不然接受光照;若是對從光源發出的每條射線,均找到這樣的A,並將A到光源距離計算出來作成「表」,這樣對於P點只須要「查表」找到其所在射線的那個表項就能夠了;固然,計算機處理不了「每條射線」這種無窮問題,須要將光源照射的方向離散化,轉化爲有限問題,這將用到現代圖形硬件的「光柵化」功能。網站

下圖說明了Shadow Mapping的基本原理,先不用看圖中文字,請看下面解釋(摘自文獻[1]):ui

左圖中,黃色光源下面那個藍線框矩形圖即「相似」於上面說的,對於光源發出的每條射線,找一個最近距離,稱爲Shadow Map(陰影圖),在實際渲染中,對於每一個表面點P,只需找到和P在同一條光源發出射線上的Shadow Map中的表項,比較P點到光源距離和表項值的大小,便可判斷陰影。這裏之因此說「相似」是由於,Shadow Map中存儲的並非最近點A到光源的距離(設這個距離爲d),而是d的函數,設爲f(d),能夠看到只要f嚴格單調遞增,比較d的大小和比較f(d)的大小是等價的(好在只須要比較大小,而不須要知道具體大多少)。這個f即齊次座標變換,f(d)即深度值,說的具體一點,就是模型視圖變換和投影變換,模型變換將物體座標變換到世界座標,再通過視圖變換到視覺座標,再通過投影變換到裁剪座標(視景體被變爲xyz爲±1的邊長爲2的正方體),詳見我前兩篇博文:文獻[6][7]。這裏來講明一下投影變換具備所須要的性質:將過光源點(攝像機位置)的射線變換爲射線,且射線上的點順序不發生變化(嚴格單調增長)。spa

Shadow Mapping方法概述以下:

  • 定義一個變換生成Shadow Map,記爲表S,其中保存了最近點深度值,即視圖矩陣V爲攝像機在光源點對準物體,投影矩陣P爲開口和聚光燈開角相等或足以包括場景物體的透視投影矩陣,記M=PV爲視圖矩陣和投影矩陣變換的疊加;
  • 在渲染場景時,對每一個片段,設其世界座標爲p,則其到光源的深度值可以下計算,q=Mp=(xq,yq,zq,wq),d=zq/wq,用d和S的表項S(xq/wq,yq/wq)比較,若結果爲等於則p接受光照,若大於則位於陰影中。

這裏再注意一個細節,上面算法對每一個片段進行,對每一個片段的座標進行齊次變換求得其到光源深度值,其實這是沒有必要的,對於每一個圖元:點、線、多邊形,其片段的到光源深度值可由其頂點到光源的深度值插值獲得,畢竟,同一圖元一定落於某平面上。這裏的「插值」是在光柵化階段進行的,就像我以前博文說的,其實它並不簡單(文獻[6]),但咱們不用管,即便在着色器程序中,光柵化也由固定管線功能實現。

 

2.基本算法的OpenGL實現

咱們先來看一個最簡單的程序,從光源繪製一個深度圖,將其拷貝到紋理,咱們先手動計算紋理座標,以直觀表達計算過程。程序的全局變量,紋理初始化代碼以下(請見文獻[6]最後的OpenGL函數總結):

GLuint tex_shadow; // 紋理名字
glm::vec4 light_pos; // 光源在世界座標中的位置
glm::mat4 shadow_mat_p; // 光源視角的投影矩陣
glm::mat4 shadow_mat_v; // 光源視角的視圖矩陣 void tex_init() // 紋理初始化
{
    // 紋理如何影響顏色,和光照計算結果相乘
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    // 分配紋理對象,並綁定爲當前紋理
    glGenTextures(1, &tex_shadow);
    glBindTexture(GL_TEXTURE_2D, tex_shadow);
    // 紋理座標超出[0,1]時如何處理
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    // 非整數紋理座標處理方式,線性插值
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    // 深度紋理,深度值對應亮度
    glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);
} 

繪製函數裏,先將攝像機放置在光源位置,渲染後將深度緩衝拷貝到紋理,代碼以下:

//---------------------------------------第1次繪製,生成深度紋理--------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 將攝像機放置在光源位置,投影矩陣和視圖矩陣
shadow_mat_p = glm::perspective(glm::radians(90.0f), 1.0f, 1.0f, 1.0e10f);
shadow_mat_v = glm::lookAt(glm::vec3(light_pos), glm::vec3(0), glm::vec3(0,1,0));
glMatrixMode(GL_PROJECTION); glPushMatrix();
glLoadMatrixf(&shadow_mat_p[0][0]); // 加載投影矩陣
glMatrixMode(GL_MODELVIEW); glPushMatrix();
glLoadMatrixf(&shadow_mat_v[0][0]); // 加載視圖矩陣
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
glMatrixMode(GL_PROJECTION); glPopMatrix();
glMatrixMode(GL_MODELVIEW); glPopMatrix();
// 拷貝深度緩衝到紋理
glBindTexture(GL_TEXTURE_2D, tex_shadow);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
    0, 0, glStaff::get_frame_width(), glStaff::get_frame_height(), 0);
glEnable(GL_TEXTURE_2D); // 使能紋理
void draw_model() // 繪製模型,一個茶壺
{    
    glMatrixMode(GL_MODELVIEW);
    glPushMatrix();
    glTranslatef(0, 1, 0);
        glutSolidTeapot(1);
    glPopMatrix();
}

咱們的draw_world就繪製一個平面,draw_model就繪製一個茶壺,場景以下(黃色爲光源位置):

能夠用以下代碼獲取紋理像素,並用DevIL保存(il_saveImgDep是我寫的函數,字符串前加L是wchar_t字符串):

GLfloat* data = new GLfloat[glStaff::get_frame_width()*glStaff::get_frame_height()];
glGetTexImage(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, GL_FLOAT, data); // 獲取紋理數據
il_saveImgDep(L"d0.png", data, glStaff::get_frame_width(), glStaff::get_frame_height());
delete[] data;

深度圖以下,距離攝像機近的點深度值小,因此顏色爲黑色,距離越遠顏色越白:

咱們手動將這個紋理貼到那個正方形地板上:

void draw_world() // 繪製世界,一個地板
{
    glm::vec4 v1(-3, 0,-3, 1), v2(-3, 0, 3, 1), v3( 3, 0, 3, 1), v4( 3, 0,-3, 1);//四個頂點
    glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.5f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 須要將裁剪座標的[-1,+1]縮放到[0,1]
    glm::vec4 t;
    glBegin(GL_POLYGON);
      glNormal3f(0, 1, 0);
      t = m*shadow_mat_p*shadow_mat_v*v1; // 按和生成紋理相同的變換計算紋理座標
      glTexCoord4fv(&t[0]); glVertex3fv(&v1[0]);
      t = m*shadow_mat_p*shadow_mat_v*v2;
      glTexCoord4fv(&t[0]); glVertex3fv(&v2[0]);
      t = m*shadow_mat_p*shadow_mat_v*v3;
      glTexCoord4fv(&t[0]); glVertex3fv(&v3[0]);
      t = m*shadow_mat_p*shadow_mat_v*v4;
      glTexCoord4fv(&t[0]); glVertex3fv(&v4[0]);
    glEnd();
}
//-------------------------------------------第2次繪製,繪製場景------------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]);
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();

注意一個細節,通過模型視圖和投影變換獲得的裁剪座標xyz座標位於[-1,+1],而紋理座標以及紋理像素值也就是深度值位於[0,1]須要將[-1,+1]縮放到[0,1],也即先縮放0.5倍,再平移0.5(OpenGL管線中這一變換在視口變換時進行,見文獻[6])。繪製結果以下:

請對照深度圖,由於計算紋理座標的變換和生成紋理的變換相同,因此,深度紋理中的地板的四個角正好被貼圖到了場景地板的四個角。因爲茶壺函數是 glut 內置,其內部可能指定了紋理座標,因此紋理也被貼到了茶壺上。上述全部代碼請見所附程序中的 mapping_basic0.cpp。

上面程序的結果,地板上看着挺像陰影的,由於剛好在遮擋的地方紋理的顏色又偏黑(深度值小)。如今還需將計算出的紋理座標的z值和紋理像素值也即深度值進行比較,並根據結果選擇進行光照仍是沒有光照,由於紋理的影響模式爲乘積,徹底的光照也就是紋理值爲1,徹底沒有光照也就是紋理值爲0,OpenGL提供了紋理比較機制:用紋理座標的r(紋理座標四個份量爲strq)值和紋理像素值比較,比較的結果是0和1(相等時爲1),用比較的結果替換原來紋理值。只需在上面代碼的初始化紋理函數 tex_init 中加入以下兩行代碼便啓用此機制:

// 紋理比較模式,用紋理座標r和紋理值(深度值)比較,若小於等於紋理值改成1,不然改成0
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

你可能已經想到了,對兩種計算方式下計算出的浮點數進行相等比較(程序中用小於等於,理論上用等於就能夠)結果是不肯定的,以下圖的斑紋:

能夠對計算的紋理座標r座標進行少量偏移,讓其偏小:

glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.49f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 須要將裁剪座標的[-1,+1]縮放到[0,1]

直接對r座標進行偏移或者直接對深度紋理的深度值進行偏移並非一個好方法,由於透視投影下深度值和裁剪座標的z值之間並非線性關係:在離攝像機很遠的地方,兩個z值差異很大的點其深度值可能差異很是小(都接近1)。合理的作法是:1.多邊形偏移,2.在生成深度紋理時剔除正面,更多方法請見文獻[1]。

除了手動計算紋理座標,咱們能夠將變換放到紋理變換矩陣中,上面繪製世界函數的等價版本以下:

void draw_world() // 繪製世界,一個地板
{
    glm::vec4 v1(-3, 0,-3, 1), v2(-3, 0, 3, 1), v3( 3, 0, 3, 1), v4( 3, 0,-3, 1);
    glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.5f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 須要將裁剪座標的[-1,+1]縮放到[0,1]
    m = m*shadow_mat_p*shadow_mat_v;
    glMatrixMode(GL_TEXTURE); glLoadMatrixf(&m[0][0]); glMatrixMode(GL_MODELVIEW);
    glBegin(GL_POLYGON);
      glNormal3f(0, 1, 0);
      glTexCoord4fv(&v1[0]); glVertex3fv(&v1[0]);
      glTexCoord4fv(&v2[0]); glVertex3fv(&v2[0]);
      glTexCoord4fv(&v3[0]); glVertex3fv(&v3[0]);
      glTexCoord4fv(&v4[0]); glVertex3fv(&v4[0]);
    glEnd();
}

其實目前還有一個問題,就是咱們的影子只投到了地板上,茶壺上並無,這是由於茶壺函數是封裝好的,咱們不能到茶壺函數內部去指定紋理座標。OpenGL提供了紋理座標自動生成機制,能夠從頂點物體座標或頂點視覺座標自動生成紋理座標,咱們先來看看從頂點物體座標自動生成紋理座標。須要在紋理初始化時加入以下代碼:

// 紋理座標自動生成,從頂點物體座標生成
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_GEN_R);
glEnable(GL_TEXTURE_GEN_Q);

並在使用紋理前,也就是渲染深度紋理後將紋理座標變換矩陣分行傳遞到紋理座標自動生成的參數,以下:

// 將紋理座標變換矩陣分行傳遞到紋理座標自動生成的參數
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f))*glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_OBJECT_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_OBJECT_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_OBJECT_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_OBJECT_PLANE, &mat[3][0]);

還記得 OpenGL 和 GLM 的矩陣都是列優先,因此按行加載前要轉置。其實,所謂紋理座標自動生成就是,管線在遇到一個頂點時自動計算一個紋理座標,這和以前手動計算或加載到紋理矩陣的計算方式是徹底相同的,只不過如今自動計算而已,這裏看到這些 GL_OBJECT_PLANE 參數合起來就是紋理矩陣,但 OpenGL 支持對 strq 座標指定不一樣變換的行。看看結果,有點驚訝:

能夠看到,地板上的陰影是正確的,但茶壺上不正確,緣由是咱們使用頂點的物體座標,也就是直接傳遞給 glVertex3f() 等函數的值,這樣茶壺函數指定的頂點物體座標可能通過模型視圖矩陣的變換,而咱們沒有跟蹤到這些變換,畢竟那是封裝的函數。其實咱們想用的是頂點的世界座標,不過OpenGL紋理座標自動生成除了用頂點物體座標外,另只支持從頂點視覺座標生成紋理座標,由於OpenGL將視圖和模型變換矩陣合二爲一了,不過,視覺座標到世界座標的轉換能夠經過攝像機定義的視圖變換矩陣的逆作到。下面是從頂點視覺座標自動生成紋理座標的代碼,請對照前面的物體座標代碼:

// 紋理座標自動生成,從頂點視覺座標
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_GEN_R);
glEnable(GL_TEXTURE_GEN_Q);
// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v * glm::affineInverse(mat_view);
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);

指定從頂點視覺座標自動生成紋理座標的參數時,OpenGL會自動將參數表明的矩陣和當前模型視圖矩陣的逆相乘,這原本是要給咱們帶來方便的,但不少時候這種額外的耦合會被忽略從而獲得莫名其妙的結果。上面代碼等價於:

// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]); // glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v/* * glm::affineInverse(mat_view)*/;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);

來看結果,拋開浮點數相等比較帶來的斑紋問題,都是正確的,茶壺把手和壺蓋那裏也有了陰影:

下面來看用多邊形偏移和剔除正面方法解決斑紋問題的代碼:

//---------------------------------------第1次繪製,生成深度紋理--------
// ...
glEnable(GL_POLYGON_OFFSET_FILL); // 多邊形偏移
glPolygonOffset(0, 20000);
// draw_world() ...
glPolygonOffset(0, 0); // 別忘了恢復原來的值
glDisable(GL_POLYGON_OFFSET_FILL);
//---------------------------------------第1次繪製,生成深度紋理--------
// ...
glEnable(GL_CULL_FACE); // 剔除正面
glCullFace(GL_FRONT);
// draw_world() ...
glCullFace(GL_BACK); // 別忘了恢復原來的值
glDisable(GL_CULL_FACE);

就像我前一篇博文文獻[6]說的,多邊形頂點的環繞方向(右手法則)要和多邊形的法向量一直,glut茶壺函數就是個反例,這使得咱們不得不在繪製茶壺前臨時剔除背面。

多邊形偏移結果以下:

剔除正面的結果以及剔除先後的深度圖以下:

結果差很少,注意茶壺相對光照的背面還有斑紋,那可有可無,由於那裏是不受光源照射(法向量和到光源向量乘積小於0)的地方,後面將對環境光和光源光分開兩遍渲染,背面斑紋將天然消失。這裏再次強調上圖結果之因此對每一個片段都正確,得益於光柵化對紋理座標進行了正確插值(文獻[6])。後面將採用剔除正面的作法,由於:多邊形偏移方法的偏移值很差肯定,剔除正面能夠減小片段數量提升效率。但剔除正面方法也有問題:幾何體(除了只接收陰影的物體)必須是封閉的,幾何體的多邊形頂點環繞方向必須和多邊形法向量一致。這部分全部代碼請見所附程序中的 mapping_basic1.cpp。

在進入下節以前,咱們先來看一個 Shadow Mapping 方法給咱們帶來的附加產品:投影貼圖,將上面深度紋理改爲普通紋理,繼續使用紋理座標自動生成,代碼以下:

void tex_init() // 紋理初始化
{
    // 紋理如何影響顏色,和光照計算結果相乘
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    // 分配紋理對象,並綁定爲當前紋理
    glGenTextures(1, &tex_lena);
    glBindTexture(GL_TEXTURE_2D, tex_lena);
    // 紋理座標超出[0,1]時如何處理
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
    // 邊框顏色
    GLfloat c[4] = {1,1,1, 1};
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, c);
    // 非整數紋理座標處理方式,線性插值
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    // 紋理座標自動生成
    glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glEnable(GL_TEXTURE_GEN_S);
    glEnable(GL_TEXTURE_GEN_T);
    glEnable(GL_TEXTURE_GEN_R);
    glEnable(GL_TEXTURE_GEN_Q);
    // 紋理數據
    void* data; int w, h;
    il_readImg(L"Lena Soderberg.jpg", &data, &w, &h);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
    delete data;
}
// 將攝像機放置在光源位置,投影矩陣和視圖矩陣
shadow_mat_p = glm::perspective(glm::radians(45.0f), 1.0f, 1.0f, 1.0e10f);
shadow_mat_v = glm::lookAt(glm::vec3(light_pos), glm::vec3(0), glm::vec3(0,1,0));
// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]); // glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v/* * glm::affineInverse(mat_view)*/;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);
glEnable(GL_TEXTURE_2D);
//-------------------------------------------繪製場景------------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// ...

結果以下,左上角爲紋理原圖(已打馬賽克):

結果就好像在光源處有一個投影儀(沒處理遮擋問題,能夠用下一節方法)將圖片投影下來,若是將上圖中著名的 Lena 換成窗戶,結果像光透過窗戶灑在地上:

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

後面咱們將在基礎 Shadow Mapping 方法上進行改進以解決以下問題:

  • 目前深度圖渲染使用默認幀緩衝區(Default Frame Buffer,請見文獻[6]),這個緩衝區的寬和高跟隨窗口,另外從默認幀緩衝中將深度值拷貝到紋理效率也不高,爲了提升效率,也爲了渲染大尺寸深度紋理來減輕陰影鋸齒,將使用幀緩衝對象(Framebuffer Objects),並將紋理綁定到幀緩衝對象的深度緩衝,這樣將可以直接將深度值渲染到紋理;
  • 在渲染深度圖時,因爲只須要深度值,把光照、紋理關閉以及屏蔽顏色緩衝寫操做能夠提升效率;
  • Shadow Mapping方法佔用紋理通道,若是還想用普通的紋理貼圖,須要使用多重紋理;
  • 目前陰影部分是純黑色的,咱們但願陰影部分不接受對應光源的照射,但接受環境光和其餘光源的照射,這須要在渲染場景時進行多遍渲染,並將結果累加,這時後續渲染不須要清除深度緩衝和顏色緩衝,並須要修改深度測試函數和混合函數;
  • 目前渲染深度圖時只有一個視角,若是點光源的四周都有物體將不能正確處理,最簡單的方法是用6個視角爲90度的光源視角將光源的全方向都渲染到深度紋理(想象光源位於某正方體中心),並在應用時將結果累加;
  • 多個光源的處理也須要多遍渲染,這和環境光光源光分離以及全方向點光源的處理相似;
  • 另外還有平行光問題,將光源視角的投影矩陣從透視投影換成平行投影便可,另外須要合理設置視景體以將場景所有包括進來,這時不存在全方向的問題。

下一節將逐個解決這些問題。 

 

3.解決實際問題

3.1 多重紋理,渲染到紋理,環境光

OpenGL多重紋理很簡單,用 glActiveTexture(GL_TEXTURE0[1,2,...]) 函數指定當前紋理單元(紋理單元是個術語,就是一個紋理組,不一樣紋理組能夠同時應用紋理功能),這裏要分清紋理單元和紋理的參數,紋理單元的參數包括 glTexEnvi[f]() 指定的紋理影響模式以及 glTexGeni[f]() 指定的紋理座標自動生成參數,紋理的參數包括紋理像素和 glTexParameteri[f]() 指定的參數。以下例子:

// 紋理單元0爲當前紋理單元
glActiveTexture(GL_TEXTURE0);
    // 紋理單元0的影響模式
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
    glGenTextures(1, &tex1);
    glBindTexture(GL_TEXTURE_2D, tex1);
    // 紋理單元0中的一個紋理tex1,其像素和參數
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    // 紋理單元0中的一個紋理tex2,其參數
    glGenTextures(1, &tex2);
    glBindTexture(GL_TEXTURE_2D, tex2);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);

// 紋理單元1爲當前紋理單元
glActiveTexture(GL_TEXTURE1); // shadow texture
    // 紋理單元1的環境函數,以及紋理座標自動生成
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glEnable(GL_TEXTURE_GEN_S);
    glTexGenfv(GL_S, GL_EYE_PLANE, v1);
    // 紋理單元1的一個紋理,其參數
    glGenTextures(1, &tex3);
    glBindTexture(GL_TEXTURE_2D, tex3);
    glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, //指定像素數據且傳入0指針,預分配存儲
        shadow_w, shadow_h, 0, GL_DEPTH_COMPONENT, GL_FLOAT, 0);

// 紋理單元1,禁用紋理
glActiveTexture(GL_TEXTURE1);
    glDisable(GL_TEXTURE_2D);
// 紋理單元0,啓用紋理
glActiveTexture(GL_TEXTURE0);
    glEnable(GL_TEXTURE_2D);

// -------------------------------------- 繪製函數 -------------------------------------
// 由於設置紋理單元0爲當前紋理單元,且綁定tex1,紋理座標t1,t2,t3將索引紋理tex1
// 另可用glMultiTexCoord指定多重紋理中特定紋理單元的紋理座標,將索引那個紋理單元中最後綁定的紋理 glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, tex1); glBegin(GL_POLYGON); glNormal3f(0, 1, 0); glTexCoord4fv(&t1[0]); glVertex3fv(&v1[0]); glTexCoord4fv(&t2[0]); glVertex3fv(&v2[0]); glTexCoord4fv(&t3[0]); glVertex3fv(&v3[0]); glEnd();

幀緩衝對象的使用例子以下:

// 分配一個幀緩衝對象,並綁定爲當前寫緩衝對象
glGenFramebuffers(1, &frame_buffer_s);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, frame_buffer_s);
// 分配一個渲染緩衝,綁定,分配存儲
glGenRenderbuffers(1, &render_buff_rgba);
glBindRenderbuffer(GL_RENDERBUFFER, render_buff_rgba);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA, shadow_w, shadow_h);
// 將渲染緩衝設定爲幀緩衝對象的顏色緩衝,幀緩衝能夠有顏色、深度、模板緩衝
glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
    GL_RENDERBUFFER, render_buff_rgba);
// 將深度紋理設定爲幀緩衝對象的深度緩衝
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
    GL_TEXTURE_2D, tex_shadow, 0);

// -------------------------------------- 繪製函數 -------------------------------------
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, frame_buffer_s);
// 如下繪製將繪製到幀緩衝對象frame_buffer_s,即render_buff_rgba和tex_shadow
glViewport(0, 0, shadow_w, shadow_h); // 將視口設置爲和frame_buffer_s相同
glClear(GL_DEPTH_BUFFER_BIT); // 清除tex_shadow
// ...

glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 如下繪製將繪製到幀默認緩衝對象,即窗口的附屬幀緩衝
glViewport(0, 0, get_frame_width(), get_frame_height()); // 將視口設置爲和窗口大小相同
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清除屏幕
// ....

上面代碼中,幀緩衝對象用於存放渲染目標,並用紋理或渲染對象做爲幀緩衝對象這個「殼子」的具體存儲。除此以外幀緩衝對象還能夠用於指定 glReadPixels() 函數的讀目標。

爲減輕 Shadow Mapping 陰影的鋸齒問題,須要增長紋理的分辨率,如今,應用繪製到紋理以後紋理的大小將能夠自由設置,能夠用 glGetIntegerv(GL_MAX_TEXTURE_SIZE, GLint*) 獲取系統支持的最大紋理,個人機器(GT240 1GB GDDR5 OpenGL 3.3)最大爲 8192x8192,下面是128x128 和 8192x8192 分辨率深度紋理的對比:

能夠看到,如今陰影再也不是全黑色了,這用到了多遍渲染,並將結果累加,代碼以下:

//-------------------------------- 第2次繪製,繪製場景 ----------------------------
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 1 環境光
glDisable(GL_LIGHT0);
glActiveTexture(GL_TEXTURE1); glDisable(GL_TEXTURE_2D);
glActiveTexture(GL_TEXTURE0); glEnable(GL_TEXTURE_2D);
//float gac2[4]={0,0,0,1}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, gac2); // black
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]);
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
// 2 點光源
GLfloat la[4]; glGetFloatv(GL_LIGHT_MODEL_AMBIENT, la);
float gac[4]={0,0,0,1}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, gac); // black
glEnable(GL_LIGHT0);
glActiveTexture(GL_TEXTURE1); glEnable(GL_TEXTURE_2D);
glActiveTexture(GL_TEXTURE0); glEnable(GL_TEXTURE_2D);
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, la); // 恢復環境光
glDepthFunc(GL_LESS); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

要點是,第二次不清除顏色和深度緩衝,並將深度測試函數設爲相等(這裏怎麼又能夠對浮點數進行相等比較了呢,由於第二遍渲染和第一遍的深度值計算過程徹底相同),將混合設爲直接相加(源,即片段,和目標,即以前顏色緩衝的值,的因子均爲1)。第一遍打開環境光,關閉點光源,第二遍關閉環境光,打開點光源。

疊加示意圖以下:

注意一個細節,OpenGL光照爲逐頂點光照,上圖中底板的明暗變化是用多個小方塊才產生的,若是簡單的將底板用四個頂點繪製,底板內部的顏色將是從頂點光照顏色插值而來(光柵化的結果),這樣就不會有明暗變化,對比下圖的左右邊:

本小節代碼見所附程序中的 mapping_render_to_tex.cpp。

 

3.2 全方向點光源

能夠渲染6個深度紋理,每一個表明點光源全方向的6分之1,以下圖所示:

全方向點光源的實現和上一小節的環境和點光源分離相似,都是採用「1+1」疊加的混合實現的,具體實現代碼見所附程序中的 mapping_omni_directional.cpp。下面是程序結果:

下面是這幅圖的6個深度圖,以及環境、點光源6個方向的貢獻圖,一、2行爲光源視角深度圖(剔除正面),三、4行爲對應點光源貢獻,5行爲環境光貢獻、最後結果、攝像機視角深度圖:

一個細節,爲了讓點光源每一個方向的貢獻,在超出紋理座標[0,1]以後全是黑色,把深度紋理的邊框設爲黑色,將紋理座標環繞模式(紋理座標超出[0,1]時處理方式)設置爲 GL_CLAMP_TO_BORDER,並將紋理比較函數從 GL_LEQUAL 改成 GL_LESS(影響能夠忽略不計,浮點數比較),代碼以下:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat c[4]={0,0,0,1}; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, c);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LESS);

同理,當使用單個視角的 Shadow Mapping 時,爲防止超出紋理座標範圍[0,1]的部分變爲黑色,能夠將紋理的邊框顏色設置爲白色,並將紋理座標環繞模式設置爲GL_CLAMP_TO_BORDER。

 

3.2 多個光源

多個光源的處理和全方向光源很是相似,也是進行多遍渲染,請見所附程序中的 mapping_multi_lights.cpp,程序利用了這樣的性質 GL_LIGHTi=GL_LIGHT0+i,程序結果見最前面彩色圖(gif圖片顏色有損失,小黑點是光源位置)。

再看各個光源以及環境光的貢獻:

上圖中第1行從左到右依次爲光源一、二、3的深度圖,第2行從左到右依次爲光源一、二、3的貢獻,第3行爲環境光貢獻,下面是最後結果:

 

3.3 平行光 

平行光的處理很是簡單,只需將前面的從光源視角的透視投影矩陣改成平行投影矩陣,並設置視景體使得場景所有落在裁剪體內,另外,平行投影的深度值和視覺座標的z值是線性關係(有平移),因此深度比較的精度也會高些,具體代碼加所附程序中的 mapping_parallel.cpp。下面是結果截圖:

深度圖以下(剔除正面,光源視角攝像機沿y軸向上):

 

4.進一步研究

Shadow Mapping 方法雖然提出很早,但直到如今仍有許多前沿研究,這多是由於 Shadow Mapping 方法的簡潔性(不須要幾何信息,只須要將場景額外的從光源渲染),研究內容主要位於從陰影圖過濾產生柔和陰影,詳見文獻[1]。

 

下載連接,由於程序將全部的庫都打包了,這樣的好處是程序不依賴系統,另外將微軟雅黑字體也拷貝了進去,還有幾張貼圖,因此程序壓縮後仍有25MB大小,見諒。

連接: http://pan.baidu.com/s/1qWPWC7i 密碼: nwdo

該程序已過期,請下載我後一篇博客所附支持64bit的程序:OpenGL陰影,Shadow Volumes(附源程序,使用 VCGlib )

 

參考文獻

  1. Eisemann, E., Assarsson, U., Schwarz, M. and Wimmer, M., Shadow algorithms for real-time rendering. in Eurographics 2010-Tutorials, (2010), The Eurographics Association(進入做者給的下載連接,另該做者在ACM SIGGRAPH 2012,2013 Course 「Efficient real-time shadows」,ACM SIGGRAPH Asia 2009 Course 「Casting Shadows in Real Time」,2011 Book 「Real-Time Shadows」);
  2. 《OpenGL Specification Version 3.3 (Compatibility Profile) 2010》, 2.12.3 Generating Texture Coordinates(到官網下載);
  3. http://en.wikipedia.org/wiki/Shadow_mapping
  4. C. Everitt, "Projective texture mapping," White paper, NVidia Corporation, vol. 4, 2001(進入下載);
  5. Paul's Projects, Shadow Mapping (這裏進入網頁);
  6. OpenGL管線(用經典管線代說着色器內部)
  7. OpenGL座標變換及其數學原理,兩種攝像機交互模型(附源程序)
相關文章
相關標籤/搜索