1. 程式人生 > >OpenGL3.0教程 第十六課:陰影貼圖

OpenGL3.0教程 第十六課:陰影貼圖

第十五課中已經學習瞭如何建立光照貼圖。光照貼圖可用於靜態物件的光照,其陰影效果也很不錯,但無法處理運動的物件。

陰影貼圖是目前(截止2012年)最好的生成動態陰影的方法。此法最大的優點是易於實現,缺點是想完全正確地實現不大容易。

本課首先介紹基本演算法,探究其缺陷,然後實現一些優化。由於撰寫本文時(2012),陰影貼圖技術還在被廣泛地研究;我們將提供一些指導,以便你根據自身需要,進一步改善你的陰影貼圖。

基本的陰影貼圖

基本的陰影貼圖演算法包含兩個步驟。首先,從光源的視角將場景渲染一次,只計算每個片斷的深度。接著從正常的視角把場景再渲染一次,渲染時要測試當前片斷是否位於陰影中。

“是否在陰影中”的測試實際上非常簡單。如果當前取樣點比陰影貼圖中的同一點離光源更遠,那說明場景中有一個物體比當前取樣點離光源更近;即當前片斷位於陰影中。

下圖可以幫你理解上述原理:

shadowmapping

渲染陰影貼圖

本課只考慮平行光——一種位於無限遠處,其光線可視為相互平行的光源。故可用正交投影矩陣來渲染陰影貼圖。正交投影矩陣和一般的透視投影矩陣差不多,只不過未考慮透視——因此無論距離相機多遠,物體的大小看起來都是一樣的。

設定渲染目標和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(-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效能的關鍵因素。

結果

渲染出的紋理如下所示:

DepthTexture

顏色越深表示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)

這是目前的程式碼渲染的結果。很明顯,大體的思想是實現了,不過質量不盡如人意。

1rstTry-1024x793

逐一檢查圖中的問題。程式碼有兩個工程:shadowmaps和shadowmaps_simple,任選一項。simple版的效果和上圖一樣糟糕,但程式碼比較容易理解。

問題

陰影瑕疵

最明顯的問題就是陰影瑕疵

ShadowAcne

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

shadow-acne

通常的“補救措施”是加上一個誤差容限(error margin):僅噹噹前fragment的深度(再次提醒,這裡指的是從光源的座標系得到的深度值)確實比光照貼影象素的深度要大時,才將其判定為陰影。這可以通過新增一個偏差(bias)來辦到:

float bias = 0.005;
float visibility = 1.0;
if ( texture2D( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z-bias){
visibility = 0.5;
}

效果好多了::

FixedBias-1024x793

不過,您也許注意到了,由於加入了偏差,牆面與地面之間的瑕疵顯得更加明顯了。更糟糕的是,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);

陰影瑕疵消失了,即使在曲面上也看不到了。

VariableBias-1024x793

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

shadowmapping-backfaces

渲染陰影貼圖時剔除正面的三角形:

    // 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)

現在沒有陰影瑕疵了,但地面的光照效果還是不對,看上去牆面好像懸在半空(因此術語稱為“陰影懸空”)。實際上,加上偏差會加劇陰影懸空。

PeterPanning

這個問題很好修正:避免使用薄的幾何形體就行了。這樣做有兩個好處:

  • 首先,(把物體增厚)解決了陰影懸空問題:物體比偏差值要大得多,於是一切麻煩煙消雲散了
  • 其次,可在渲染光照貼圖時啟用背面剔除,因為現在,牆壁上有一個面面對光源,就可以遮擋住牆壁的另一面,而這另一面恰好作為背面被剔除了,無需渲染。

缺點就是要渲染的三角形增多了(每幀多了一倍的三角形!)

NoPeterPanning-1024x793

走樣

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

Aliasing

PCF(percentage closer filtering,百分比漸近濾波)

一個最簡單的改善方法是把陰影貼圖的sampler型別改為sampler2DShadow。這麼做的結果是,每當對陰影貼圖進行一次取樣時,硬體就會對相鄰的紋素進行取樣,並對它們全部進行比較,對比較的結果做雙線性濾波後返回一個[0,1]之間的float值。

例如,0.5即表示有兩個取樣點在陰影中,兩個取樣點在光明中。

注意,它和對濾波後深度圖做單次取樣有區別!一次“比較”,返回的是true或false;PCF返回的是4個“true或false”值的插值結果

PCF_1tap

可以看到,陰影邊界平滑了,但陰影貼圖的紋素依然可見。

泊松取樣(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會隨之變明或變暗。

SoftShadows-1024x793

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

SoftShadows_Close-1024x793

SoftShadows_Wide-1024x793

分層泊松取樣(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;

這樣做之後,上圖中的那種條帶就消失了,不過噪點卻顯現出來了。不過,一些“漂亮的”噪點可比上面那些條帶“好看”多了。

PCF_stratified_4tap

上述三個例子的實現請參見tutorial16/ShadowMapping.fragmentshader。

深入研究

即使把這些技巧都用上,仍有很多方法可以提升陰影質量。下面是最常見的一些方法:

早優化(Early bailing)

不要把取樣次數設為16,太大了,四次取樣足矣。若這四個點都在光明或都在陰影中,那就算做16次取樣效果也一樣:這就叫過早優化。若這些取樣點明暗各異,那你很可能位於陰影邊界上,這時候進行16次取樣才是合情理的。

聚光燈(Spot lights)

處理聚光燈這種光源時,不需要多大的改動。最主要的是:把正交投影矩陣換成透視投影矩陣:

glm::vec3 lightPos(5, 20, 20);
glm::mat4 depthProjectionMatrix = glm::perspective(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平滑等等是完全可行的。盡情地實驗吧。

總結一句,我建議您堅持儘可能使用預計算的光照貼圖,只為動態物體使用陰影貼圖。並且要確保兩者的視覺效果協調一致,任何一者效果太好/太壞都不合適。