基於CSM和PCF的軟陰影實現
斷斷續續花了兩個多禮拜才把這個問題完全搞定,比開始預想的時間多多了,一開始也沒想到會碰到這麼多的狀況,不過好在是都解決了。
陰影技術是三維渲染裡面的一個非常重要的課題,實現方式多種多樣,最基本的是從光源方向渲一張ShadowMap,簡單易行,但是效果很差,鋸齒像牛一樣大。想要獲得更精細的陰影,唯一的辦法就是加大SM的解析度。
事實上我們對遠處的陰影要求並沒有近處那麼高,粗糙點無所謂,反正離得遠也看不見,於是在此之上,出現了Cascaded ShadowMap,簡稱CSM,它的做法是把相機的可視範圍從近裁到遠裁分割成N個子視錐,每個視錐渲一張ShadowMap。
這樣做的好處很明顯,我們希望的是近處的陰影比遠處的更精細,這種切分很好的實現了我們的要求。
我這次用的就是這個方法,具體的程式碼就不貼了,說下大概的實現吧。我們首先要拿到被切分相機的遠近裁,然後計算出子視錐的八個頂點座標。
下圖中,C0是近裁,Cm是遠裁,Ci代表分割線,假如我們想要分割出N個子視錐,那我們就需要求出N-1個Ci。
通用演算法是:
使用混合因子λ將平均切分和指數切分融合起來。
其中,指數切分公式是:
平均切分的公式是:
其中,n為近裁,f為遠裁,i為切分線,m為子視錐總數。
代入最上面的公式,就能得到最終的切分公式。
得到Ci之後,就能根據這些切分線算出子視錐的投影矩陣,他們的View矩陣和主相機是一致的。
確定了子視錐的投影矩陣,我們就可以求出子視錐在世界空間下的八個頂點,計算過程很簡單,用一個範圍為[(-1, -1, 0), (1, 1 ,1)]的包圍盒的八個頂點,乘以子視錐的(VP)^-1矩陣,就是子視錐的八個頂點了。
注意:這裡討論的只適用於燈光是平行光的情況。
然後使用子視錐的八個頂點,計算出這個子視錐在世界空間的包圍球,至於這裡為什麼用包圍球而不是包圍盒,是為了保持ShadowMap照射區域大小的穩定,後面會詳細講到。把這個包圍球轉換到燈光空間,然後給它套一個包圍盒,這個包圍盒在燈光座標系的XY面上的投影,就是燈光矩陣的XY軸的範圍了。包圍球中心向光源方向移動一段距離(足夠大),作為燈光相機的座標,包圍球中心作為燈光相機的Look方向,Up方向可以用Look方向和Right相乘得到,這樣就拿到了燈光相機的View矩陣。
接下來就是求出燈光的遠近裁了,如果這時候能拿到陰影接受體的包圍盒,可以把這些包圍盒統一到一個大的包圍盒裡面,然後將這個大的接受體包圍盒轉換到子視錐的NDC空間,與前面的頂點包圍盒求交,再把求出來的新包圍盒轉換到燈光空間,用這個包圍盒的z的最大值作為之後的燈光視錐的遠裁剪面。
下圖中,假如怪物是唯一的接受體,那麼怪物在燈光空間的最遠端顯然就是燈光矩陣的遠裁面了。
陰影投影體的包圍盒也統一成一個大包圍盒,再轉換到燈光空間,離燈光最近的一個點作為近裁面。
到現在,構造燈光相機的投影矩陣的所有引數我們都已經拿到了,這裡面還有許多的小優化,不過都屬於是錦上添花的事情,各位有空可以研究一下。
接下來就是用這兩個矩陣設定燈光相機,然後渲染出ShadowMap,再在主相機渲染的時候,比較一下深度,就完事兒了。
如果各位做出來了,會發現效果並不好,問題有這麼幾個:1、陰影邊緣的鋸齒很明顯,沒有軟陰影;2、相機移動鋸齒也會跟著動,因為燈光相機一直跟著主相機在移動。3、不同層級的陰影之間有明顯的交界;
第三個問題很好解決,只需要在分割主視錐的時候,兩相鄰的兩個視錐有一定的重疊,然後再shader裡面進行線性插值就能解決,我這裡用的是讓下一級子視錐覆蓋上一級視錐的20%範圍,效果很不錯。
第二個問題和第一個問題我一度以為是無解的,後來看了unity的Cascaded Shadow的效果,發現他們的陰影邊緣的鋸齒是不會跟著動的,這起碼證明這個問題是有解的,經過多次嘗試,終於找到了解決方案。鋸齒會跟著相機動,是因為燈光相機在跟著主相機在動,我們不可能讓燈光相機停住不動,但是隻要讓SM裡的每一個畫素移動走之後,其他位置移動過來的畫素剛好和上一個畫素的位置一致,鋸齒位置也就能固定下來。簡單來說,就是燈光相機每次移動整數個畫素的位置(在燈光空間的xy平面),且畫素的大小無論怎麼移動都要保持不變。我們在燈光空間找一個固定的錨點(比如世界空間的0點轉換到燈光空間的位置),把這個錨點投影到xy平面,然後計算出SM中每個畫素的大小(投影區域已知,SM解析度已知,就可以求到每個畫素的大小),燈光相機也投影到xy平面,用燈光的x,y座標與畫素的長寬求模,得到的餘數就是燈光需要偏移的值,在構造燈光相機的投影矩陣的時候把這個偏移加上,就可以保證SM每次都是移動畫素大小的整數倍距離了。前面提到的使用包圍球替代包圍盒,就是為了方便這裡的計算,使用包圍球可以保證主相機轉動,SM的投影區域大小保持不變。
第一個問題,是最麻煩的。軟陰影一直都是很多業界專家研究的物件,現有的最佳解決方案是VSM,渲染SM的時候,除了儲存深度值以為,使用另外一個通道儲存深度的平方,沒錯,就是深度的平方,深度*深度。然後使用硬體進行線性過濾,這樣每個畫素裡的兩個通道代表的分別是深度的期望和深度的方差,最後利用切比雪夫不等式,可以計算出主相機裡的每個畫素在陰影中和不在陰影中的概率,從而實現非常完美的軟陰影。不過我這裡用的是PCF的方式實現的軟陰影。
在OpenGL中,只要開啟了
GL_TEXTURE_COMPARE_MODE = GL_COMPARE_PEF_TO_TEXTURE
這個狀態之後,就可以使用sampler2DShadow紋理了,對於shadow紋理,OpenGL在取樣的時候,不再是返回顏色,而是返回取樣點在陰影中的概率。函式是:
texture(sampler2DShadow, vec3);
其中,vec3中,x有放的是uv座標,z放的是需要比較的深度,這個函式會使用取樣點周圍的四個點來比較,返回值是一個float, 代表通過測試的概率(25%、50%、75%、100%)。
使用兩個for迴圈,分別在x,y方向上進行偏移4次,一共取樣16次,平均下來的值就是這個畫素在陰影中的概率了。
for(float y=-1.5; y<=1.5; y+=1.0)
for(float x=-1.5; x<=1.5; x+=1.0)
float fProbability += texture(s2sCascadedShadowMap, vec3(
v4TexCoord.x + x * fCascadedSMTexelSize,
v4TexCoord.y + y * fCascadedSMTexelSize,
fDepthCompare));
fDepth *= 0.0625;
用了PCF之後,陰影邊緣的鋸齒模糊了很多,半陰影效果還算可以。
眼見的人可能會發現,一些和光源方向夾角很小的平面上,會出現很多條紋,這是因為PCF多次取樣導致的,在shader的pixel shader裡面計算的時候,因為我們是用當前點在燈光空間中的深度與當前點在SM中對應畫素附近畫素深度進行的深度比較,在和光源方向垂直的面上,整個三角形在燈光空間中深度變換基本一致,但是隨著三角形和光源方向的夾角逐漸增大,當前點和附近點在燈光空間中的深度差距越來越大,這時候還用當前點的深度去和SM裡面多次取樣的深度比較,顯然是錯誤的。
拿上圖來說,在主相機渲染的時候,pixel shader裡面現在正在處理某個畫素,這個畫素轉換到燈光空間是右圖中的位置D,我們用PCF把D附近的深度也取樣出來並且和這個畫素的深度比較,這顯然是錯誤的,我們應該計算出D附近問號畫素對應的,在當前正在處理的三角形上的座標,再把這個座標轉換到燈光空間進行深度測試。
OpenGL裡面有這麼一個函式可以實現我們的想法:dFdx和dFdy,這兩個函式可以返回當前畫素和附近畫素的梯度,我們把當前畫素的紋理座標放進去,他會返回當前畫素的紋理和周圍畫素的紋理的的變化量,用這個變化量就可以計算出當前三角形在燈光空間的斜率,從而消除平面上的條紋。
囉囉嗦嗦一大堆,其實還有很多細節沒有講,就把這些當成一個線索,知道大概是怎麼弄出來的,出了問題大概知道從哪一方面入手去解決,也就夠了。