# 第十六課:陰影貼圖(Shadow mapping)
第十五課中已經學習了如何創建光照貼圖。光照貼圖可用于靜態對象的光照,其陰影效果也很不錯,但無法處理運動的對象。
陰影貼圖是目前(截止2012年)最好的生成動態陰影的方法。此法最大的優點是易于實現,缺點是想完全**正確**地實現不大容易。
本課首先介紹基本算法,探究其缺陷,然后實現一些優化。由于撰寫本文時(2012),陰影貼圖技術還在被廣泛地研究;我們將提供一些指導,以便你根據自身需要,進一步改善你的陰影貼圖。
## 基本的陰影貼圖
基本的陰影貼圖算法包含兩個步驟。首先,從光源的視角將場景渲染一次,只計算每個片斷的深度。接著從正常的視角把場景再渲染一次,渲染時要測試當前片斷是否位于陰影中。
“是否在陰影中”的測試實際上非常簡單。如果當前采樣點比陰影貼圖中的同一點離光源更遠,那說明場景中有一個物體比當前采樣點離光源更近;即當前片斷位于陰影中。
下圖可以幫你理解上述原理:

## 渲染陰影貼圖
本課只考慮平行光——一種位于無限遠處,其光線可視為相互平行的光源。故可用正交投影矩陣來渲染陰影貼圖。正交投影矩陣和一般的透視投影矩陣差不多,只不過未考慮透視——因此無論距離相機多遠,物體的大小看起來都是一樣的。
## 設置渲染目標和MVP矩陣
十四課中,大家學習了把場景渲染到紋理,以便稍后從shader中訪問的方法。
這里采用了一幅1024x1024、16位深度的紋理來存儲陰影貼圖。對于陰影貼圖來說,通常16位綽綽有余;你可以自由地試試別的數值。注意,這里采用的是深度紋理,而非深度渲染緩沖區(這個要留到后面進行采樣)。
~~~
// The framebuffer, which regroups 0, 1, or more textures, and 0 or 1 depth buffer.
GLuint FramebufferName = 0;
glGenFramebuffers(1, &FramebufferName);
glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName);
// Depth texture. Slower than a depth buffer, but you can sample it later in your shader
GLuint depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0,GL_DEPTH_COMPONENT16, 1024, 1024, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture, 0);
glDrawBuffer(GL_NONE); // No color buffer is drawn to.
// Always check that our framebuffer is ok
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
return false;
~~~
MVP矩陣用于從光源的視角繪制場景,其計算過程如下:
- 投影矩陣是正交矩陣,可將整個場景包含到一個AABB(axis-aligned box, 軸向包圍盒)里,該包圍盒在X、Y、Z軸上的坐標范圍分別為(-10,10)、(-10,10)、(-10,20)。這樣做是為了讓整個場景始終可見,這一點在“再進一步”小節還會講到。
- 視圖矩陣對場景做了旋轉,這樣在觀察坐標系中,光源的方向就是-Z方向(需要溫習[第三課]
-
模型矩陣可設為任意值。
~~~
glm::vec3 lightInvDir = glm::vec3(0.5f,2,2);
// Compute the MVP matrix from the light's point of view
glm::mat4 depthProjectionMatrix = glm::ortho<float>(-10,10,-10,10,-10,20);
glm::mat4 depthViewMatrix = glm::lookAt(lightInvDir, glm::vec3(0,0,0), glm::vec3(0,1,0));
glm::mat4 depthModelMatrix = glm::mat4(1.0);
glm::mat4 depthMVP = depthProjectionMatrix * depthViewMatrix * depthModelMatrix;
// Send our transformation to the currently bound shader,
// in the "MVP" uniform
glUniformMatrix4fv(depthMatrixID, 1, GL_FALSE, &depthMVP[0][0])
~~~
## Shaders
這一次渲染中所用的著色器很簡單。頂點著色器僅僅簡單地計算一下頂點的齊次坐標:
~~~
#version 330 core
// Input vertex data, different for all executions of this shader.
layout(location = 0) in vec3 vertexPosition_modelspace;
// Values that stay constant for the whole mesh.
uniform mat4 depthMVP;
void main(){
gl_Position = depthMVP * vec4(vertexPosition_modelspace,1);
}
~~~
fragment shader同樣簡單:只需將片斷的深度值寫到location 0(即寫入深度紋理)。
~~~
#version 330 core
// Ouput data
layout(location = 0) out float fragmentdepth;
void main(){
// Not really needed, OpenGL does it anyway
fragmentdepth = gl_FragCoord.z;
}
~~~
渲染陰影貼圖比渲染一般的場景要快一倍多,因為只需寫入低精度的深度值,不需要同時寫深度值和顏色值。顯存帶寬往往是影響GPU性能的關鍵因素。
## 結果
渲染出的紋理如下所示:

顏色越深表示z值越小;故墻面的右上角離相機更近。相反地,白色表示z=1(齊次坐標系中的值),離相機十分遙遠。
## 使用陰影貼圖
## 基本shader
現在回到普通的著色器。對于每一個計算出的fragment,都要測試其是否位于陰影貼圖之“后”。
為了做這個測試,需要計算:**在創建陰影貼圖所用的坐標系中**,當前片斷的坐標。因此要依次用通常的`MVP`矩陣和`depthMVP`矩陣對其做變換。
不過還需要一些技巧。將depthMVP與頂點坐標相乘得到的是齊次坐標,坐標范圍為[-1,1],而紋理采樣的取值范圍卻是[0,1]。
舉個例子,位于屏幕中央的fragment的齊次坐標應該是(0,0);但要對紋理中心進行采樣,UV坐標就應該是(0.5,0.5)。
這個問題可以通過在片斷著色器中調整采樣坐標來修正,但用下面這個矩陣去乘齊次坐標則更為高效。這個矩陣將坐標除以2(主對角線上[-1,1] -> [-0.5, 0.5]),然后平移(最后一行[-0.5, 0.5] -> [0,1])。
~~~
glm::mat4 biasMatrix(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0
);
glm::mat4 depthBiasMVP = biasMatrix*depthMVP;
~~~
終于可以寫vertex shader了。和之前的差不多,不過這次要輸出兩個坐標。
- `gl_Position`是當前相機所在坐標系下的頂點坐標
- `ShadowCoord`是上一個相機(光源)所在坐標系下的頂點坐標
~~~
// Output position of the vertex, in clip space : MVP * position
gl_Position = MVP * vec4(vertexPosition_modelspace,1);
// Same, but with the light's view matrix
ShadowCoord = DepthBiasMVP * vec4(vertexPosition_modelspace,1);
~~~
fragment shader就很簡單了:
- `texture2D( shadowMap, ShadowCoord.xy ).z` 是光源到距離最近的遮擋物之間的距離。
- `ShadowCoord.z`是光源和當前片斷之間的距離
……因此,若當前fragment比最近的遮擋物還遠,那意味著這個片斷位于(這個最近的遮擋物的)陰影中
~~~
float visibility = 1.0;
if ( texture2D( shadowMap, ShadowCoord.xy ).z < ShadowCoord.z){
visibility = 0.5;
}
~~~
我們只需把這個原理加到光照計算中。當然,環境光分量無需改動,畢竟這只分量是個為了模擬一些光亮,讓即使處在陰影或黑暗中的物體也能顯出輪廓來(否則就會是純黑色)。
~~~
color =
// Ambiant : simulates indirect lighting
MaterialAmbiantColor +
// Diffuse : "color" of the object
visibility * MaterialDiffuseColor * LightColor * LightPower * cosTheta+
// Specular : reflective highlight, like a mirror
visibility * MaterialSpecularColor * LightColor * LightPower * pow(cosAlpha,5);
~~~
## 結果——陰影瑕疵(Shadow acne)
這是目前的代碼渲染的結果。很明顯,大體的思想是實現了,不過質量不盡如人意。

逐一檢查圖中的問題。代碼有兩個工程:`shadowmaps`和`shadowmaps_simple`,任選一項。simple版的效果和上圖一樣糟糕,但代碼比較容易理解。
## 問題
## 陰影瑕疵
最明顯的問題就是**陰影瑕疵**:

這種現象可用下面這張簡單的圖解釋:

通常的“補救措施”是加上一個誤差容限(error margin):僅當當前fragment的深度(再次提醒,這里指的是從光源的坐標系得到的深度值)確實比光照貼圖像素的深度要大時,才將其判定為陰影。這可以通過添加一個偏差(bias)來辦到:
~~~
float bias = 0.005;
float visibility = 1.0;
if ( texture2D( shadowMap, ShadowCoord.xy ).z < ShadowCoord.z-bias){
visibility = 0.5;
}
~~~
效果好多了::

不過,您也許注意到了,由于加入了偏差,墻面與地面之間的瑕疵顯得更加明顯了。更糟糕的是,0.005的偏差對地面來說太大了,但對曲面來說又太小了:圓柱體和球體上的瑕疵依然可見。
一個通常的解決方案是根據斜率調整偏差:
~~~
float bias = 0.005*tan(acos(cosTheta)); // cosTheta is dot( n,l ), clamped between 0 and 1
bias = clamp(bias, 0,0.01);
~~~
陰影瑕疵消失了,即使在曲面上也看不到了。

還有一個技巧,不過這個技巧靈不靈得看具體的幾何形狀。此技巧只渲染陰影中的背面。這就對厚墻的幾何形狀提出了硬性要求(請看下一節——陰影懸空(Peter Panning),不過即使有瑕疵,也只會出現在陰影遮蔽下的表面上。【譯者注:在迪斯尼經典動畫[《小飛俠》](http://movie.douban.com/subject/1296538/)中,小飛俠彼得·潘的影子和身體分開了,小仙女溫蒂又給他縫好了。】

渲染陰影貼圖時剔除正面的三角形:
~~~
// We don't use bias in the shader, but instead we draw back faces,
// which are already separated from the front faces by a small distance
// (if your geometry is made this way)
glCullFace(GL_FRONT); // Cull front-facing triangles -> draw only back-facing triangles
~~~
渲染場景時正常地渲染(剔除背面)
~~~
glCullFace(GL_BACK); // Cull back-facing triangles -> draw only front-facing triangles
~~~
代碼中也用了這個方法,和“加入偏差”聯合使用。
## 陰影懸空(Peter Panning)
現在沒有陰影瑕疵了,但地面的光照效果還是不對,看上去墻面好像懸在半空(因此術語稱為“陰影懸空”)。實際上,加上偏差會加劇陰影懸空。

這個問題很好修正:避免使用薄的幾何形體就行了。這樣做有兩個好處:
- 首先,(把物體增厚)解決了陰影懸空問題:物體比偏差值要大得多,于是一切麻煩煙消云散了
- 其次,可在渲染光照貼圖時啟用背面剔除,因為現在,墻壁上有一個面面對光源,就可以遮擋住墻壁的另一面,而這另一面恰好作為背面被剔除了,無需渲染。
缺點就是要渲染的三角形增多了(每幀多了一倍的三角形!)

## 走樣
即使是使用了這些技巧,你還是會發現陰影的邊緣上有一些走樣。換句話說,就是一個像素點是白的,鄰近的一個像素點是黑的,中間缺少平滑過渡。

## PCF(percentage closer filtering,百分比漸近濾波)
一個最簡單的改善方法是把陰影貼圖的`sampler`類型改為**`sampler2DShadow`**。這么做的結果是,每當對陰影貼圖進行一次采樣時,硬件就會對相鄰的紋素進行采樣,并對它們全部進行比較,對比較的結果做雙線性濾波后返回一個[0,1]之間的float值。
例如,0.5即表示有兩個采樣點在陰影中,兩個采樣點在光明中。
注意,它和對濾波后深度圖做單次采樣有區別!一次“比較”,返回的是true或false;PCF返回的是4個“true或false”值的插值結果

可以看到,陰影邊界平滑了,但陰影貼圖的紋素依然可見。
## 泊松采樣(Poisson Sampling)
一個簡易的解決辦法是對陰影貼圖做N次采樣(而不是只做一次)。并且要和PCF一起使用,這樣即使采樣次數不多,也可以得到較好的效果。下面是四次采樣的代碼:
~~~
for (int i=0;i<4;i++){
if ( texture2D( shadowMap, ShadowCoord.xy + poissonDisk[i]/700.0 ).z < ShadowCoord.z-bias ){
visibility-=0.2;
}
}
~~~
`poissonDisk`是一個常量數組,其定義看起來像這樣:
~~~
vec2 poissonDisk[4] = vec2[](
vec2( -0.94201624, -0.39906216 ),
vec2( 0.94558609, -0.76890725 ),
vec2( -0.094184101, -0.92938870 ),
vec2( 0.34495938, 0.29387760 )
);
~~~
這樣,根據陰影貼圖采樣點個數的多少,生成的fragment會隨之變明或變暗。

常量700.0確定了采樣點的“分散”程度。散得太密,還是會發生走樣;散得太開,會出現**條帶**(截圖中未使用PCF,以便讓條帶現象更明顯;其中做了16次采樣)


## 分層泊松采樣(Stratified Poisson Sampling)
通過為每個像素分配不同采樣點個數,我們可以消除這一問題。主要有兩種方法:分層泊松法(Stratified Poisson)和旋轉泊松法(Rotated Poisson)。分層泊松法選擇不同的采樣點數;旋轉泊松法采樣點數保持一致,但會做隨機的旋轉以使采樣點的分布發生變化。本課僅對分層泊松法作介紹。
與之前版本唯一不同的是,這里用了一個隨機數來索引`poissonDisk`:
~~~
for (int i=0;i<4;i++) {
int index = // A random number between 0 and 15, different for each pixel (and each i !)
visibility -= 0.2*(1.0-texture( shadowMap, vec3(ShadowCoord.xy + poissonDisk[index]/700.0, (ShadowCoord.z-bias)/ShadowCoord.w) ));
}
~~~
可用如下代碼(返回一個[0,1]間的隨機數)產生隨機數
~~~
float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
return fract(sin(dot_product) * 43758.5453);
~~~
本例中,`seed4`是參數`i`和`seed`的組成的vec4向量(這樣才會是在4個位置做采樣)。參數seed的值可以選用`gl_FragCoord`(像素的屏幕坐標),或者`Position_worldspace`:
~~~
// - A random sample, based on the pixel's screen location.
// No banding, but the shadow moves with the camera, which looks weird.
int index = int(16.0*random(gl_FragCoord.xyy, i))%16;
// - A random sample, based on the pixel's position in world space.
// The position is rounded to the millimeter to avoid too much aliasing
//int index = int(16.0*random(floor(Position_worldspace.xyz*1000.0), i))%16;
~~~
這樣做之后,上圖中的那種條帶就消失了,不過噪點卻顯現出來了。不過,一些“漂亮的”噪點可比上面那些條帶“好看”多了。

上述三個例子的實現請參見tutorial16/ShadowMapping.fragmentshader。
## 深入研究
即使把這些技巧都用上,仍有很多方法可以提升陰影質量。下面是最常見的一些方法:
## 早優化(Early bailing)
不要把采樣次數設為16,太大了,四次采樣足矣。若這四個點都在光明或都在陰影中,那就算做16次采樣效果也一樣:這就叫過早優化。若這些采樣點明暗各異,那你很可能位于陰影邊界上,這時候進行16次采樣才是合情理的。
## 聚光燈(Spot lights)
處理聚光燈這種光源時,不需要多大的改動。最主要的是:把正交投影矩陣換成透視投影矩陣:
~~~
glm::vec3 lightPos(5, 20, 20);
glm::mat4 depthProjectionMatrix = glm::perspective<float>(45.0f, 1.0f, 2.0f, 50.0f);
glm::mat4 depthViewMatrix = glm::lookAt(lightPos, lightPos-lightInvDir, glm::vec3(0,1,0));
~~~
大部分都一樣,只不過用的不是正交視域四棱錐,而是透視視域四棱錐。考慮到透視除法,采用了texture2Dproj。(見“第四課——矩陣”的腳注)
第二步,在shader中,把透視考慮在內。(見“第四課——矩陣”的腳注。簡而言之,透視投影矩陣根本就沒做什么透視。這一步是由硬件完成的,只是把投影的坐標除以了w。這里在著色器中模擬這一步操作,因此得自己做透視除法。順便說一句,正交矩陣產生的齊次向量w始終為1,這就是為什么正交矩陣沒有任何透視效果。)
用GLSL完成此操作主要有兩種方法。第二種方法利用了內置的`textureProj`函數,但兩種方法得出的效果是一樣的。
~~~
if ( texture( shadowMap, (ShadowCoord.xy/ShadowCoord.w) ).z < (ShadowCoord.z-bias)/ShadowCoord.w )
if ( textureProj( shadowMap, ShadowCoord.xyw ).z < (ShadowCoord.z-bias)/ShadowCoord.w )
~~~
## 點光源(Point lights)
大部分是一樣的,不過要做深度立方體貼圖(cubemap)。立方體貼圖包含一組6個紋理,每個紋理位于立方體的一面,無法用標準的UV坐標訪問,只能用一個代表方向的三維向量來訪問。
空間各個方向的深度都保存著,保證點光源各方向都能投射影子。T
## 多個光源組合
該算法可以處理多個光源,但別忘了,每個光源都要做一次渲染,以生成其陰影貼圖。這些計算極大地消耗了顯存,也許很快你的顯卡帶寬就吃緊了。
## 自動光源四棱錐(Automatic light frustum)
本課中,囊括整個場景的光源四棱錐是手動算出來的。雖然在本課的限定條件下,這么做還行得通,但應該避免這樣的做法。如果你的地圖大小是1Km x 1Km,你的陰影貼圖大小為1024x1024,則每個紋素代表的面積為1平方米。這么做太蹩腳了。光源的投影矩陣應盡量緊包整個場景。
對于聚光燈來說,只需調整一下范圍就行了。
對于太陽這樣的方向光源,情況就復雜一些:光源**確實**照亮了整個場景。以下是計算方向光源視域四棱錐的一種方法:
潛在陰影接收者(Potential Shadow Receiver,PSR)。PSR是這樣一種物體——它們同時在【光源視域四棱錐,觀察視域四棱錐,以及場景包圍盒】這三者之內。顧名思義,PSR都有可能位于陰影中:相機和光源都能“看”到它。
潛在陰影投射者(Potential Shadow Caster,PSC)= PSR + 所有位于PSR和光源之間的物體(一個物體可能不可見但仍然會投射出一條可見的陰影)。
因此,要計算光源的投影矩陣,可以用所有可見的物體,“減去”那些離得太遠的物體,再計算其包圍盒;然后“加上”位于包圍盒與廣元之間的物體,再次計算新的包圍盒(不過這次是沿著光源的方向)。
這些集合的精確計算涉及凸包體的求交計算,但這個方法(計算包圍盒)實現起來簡單多了。
此法在物體離開視域四棱錐時,計算量會陡增,原因在于陰影貼圖的分辨率陡然增加了。你可以通過多次平滑插值來彌補。CSM(Cascaded Shadow Map,層疊陰影貼圖法)無此問題,但實現起來較難。
## 指數陰影貼圖(Exponential shadow map)
指數陰影貼圖法試圖借助“位于陰影中的、但離光源較近的片斷實際上處于‘某個中間位置’”這一假設來減少走樣。這個方法涉及到偏差,不過測試已不再是二元的:片斷離明亮曲面的距離越遠,則其越顯得黑暗。
顯然,這純粹是一種障眼法,兩物體重疊時,瑕疵就會顯露出來。
## LiSPSM(Light-space perspective Shadow Map,光源空間透視陰影貼圖)
LiSPSM調整了光源投影矩陣,從而在離相機很近時獲取更高的精度。這一點在“duelling frustra”現象發生時顯得尤為重要。所謂“duelling frustra”是指:點光源與你(相機)距離遠,『視線』方向又恰好與你的視線方向相反。離光源近的地方(即離你遠的地方),陰影貼圖精度高;離光源遠的地方(即離你近的地方,你最需要精確陰影貼圖的地方),陰影貼圖的精度又不夠了。
不過LiSPSM實現起來很難。詳細的實現方法請看參考文獻。
CSM(Cascaded shadow map,層疊陰影貼圖)CSM和LiSPSM解決的問題一模一樣,但方式不同。CSM僅對觀察視域四棱錐的各部分使用了2~4個標準陰影貼圖。第一個陰影貼圖處理近處的物體,所以在近處這塊小區域內,你可以獲得很高的精度。隨后幾個陰影貼圖處理遠一些的物體。最后一個陰影貼圖處理場景中的很大一部分,但由于透視效應,視覺感官上沒有近處區域那么明顯。
撰寫本文時,CSM是復雜度/質量比最好的方法。很多案例都選用了這一解決方案。
## 總結
正如您所看到的,陰影貼圖技術是個很復雜的課題。每年都有新的方法和改進方案發表。但目前為止尚無完美的解決方案。
幸運的是,大部分方法都可以混合使用:在LiSPSM中使用CSM,再加PCF平滑等等是完全可行的。盡情地實驗吧。
總結一句,我建議您堅持盡可能使用預計算的光照貼圖,只為動態物體使用陰影貼圖。并且要確保兩者的視覺效果協調一致,任何一者效果太好/太壞都不合適。