實驗平台:Win7,VS2010
先上結果截圖(文章最后下載程序,解壓后直接運行BIN文件夾下的EXE程序):
本文描述圖形學的兩個最常用的陰影技術之一,Shadow Mapping方法(另一種是Shadow Volumes方法)。在講解Shadow Mapping基本原理及其基本算法的OpenGL實現之后,將繼續深入分析解決幾個實際問題,包括如何處理全方向點光源、多個光源、平行光。最近還有可能寫一篇Shadow Volumes的博文(目前已經將基本理論弄清楚了),在那里,將對Shadow Mapping和Shadow Volumes方法做簡要的分析對比。
本文的程序實現用到了很多開源程序庫,列舉如下:
- GLEW,(.lib, .dll),用於處理OpenGL本地擴展;
- GLFW,(.lib, .dll),用於處理窗口,以及創建OpenGL Context;
- Freeglut,(.lib, .dll),處理窗口,但本文只用其繪制基本幾何體,如茶壺;
- GLM,(純頭文件),OpenGL數學庫,向量及矩陣代數計算;
- DevIL,(.lib, .dll),讀寫圖片,支持很多格式,如JPG、PNG;
- FTGL(作者網站),(.lib, .dll),在OpenGL中顯示字體,支持TrueType字體文件讀取,支持抗鋸齒字體、拉伸實體字形等,需要FreeType,(.lib),庫支持;
- Bullet,(.lib),物理引擎,可以進行剛體可變形體的模擬,本文暫未使用;
- 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 )!
參考文獻
- 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”);
- 《OpenGL Specification Version 3.3 (Compatibility Profile) 2010》, 2.12.3 Generating Texture Coordinates(到官網下載);
- http://en.wikipedia.org/wiki/Shadow_mapping;
- C. Everitt, "Projective texture mapping," White paper, NVidia Corporation, vol. 4, 2001(進入下載);
- Paul's Projects, Shadow Mapping (這里進入網頁);
- OpenGL管線(用經典管線代說着色器內部);
- OpenGL坐標變換及其數學原理,兩種攝像機交互模型(附源程序)。