1. 程式人生 > >OpenGL陰影,Shadow Mapping(附源程式)

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

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

 

實驗平臺:Win7,VS2010

 

先上結果截圖(文章最後下載程式,解壓後直接執行BIN資料夾下的EXE程式):

 

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

本文的程式實現用到了很多開源程式庫,列舉如下:

  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. 數學原理

拋開復雜的現實世界中對“陰影”難以定義的問題(見文獻[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]):

左圖中,黃色光源下面那個藍線框矩形圖即“類似”於上面說的,對於光源發出的每條射線,找一個最近距離,稱為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]。這裡來說明一下投影變換具有所需要的性質:將過光源點(攝像機位置)的射線變換為射線,且射線上的點順序不發生變化(嚴格單調增加)。

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個方向的貢獻圖,1、2行為光源視角深度圖(剔除正面),3、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行從左到右依次為光源1、2、3的深度圖,第2行從左到右依次為光源1、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座標變換及其數學原理,兩種攝像機互動模型(附源程式)