Shadertoy--光線追蹤與Nayar反射模型

前言

Shadertoy是一個神奇的網站,有無數圖形學大神分享的用shader寫的各種讓人匪夷所思的效果,而代碼僅僅只有數十行至數百行。雖然很多實現無法直接應用到我們開發的實時渲染上,但分析它們的實現可以讓我們理解shader以及帶來很多靈感。

OrenNayar是一種微表面反射模型,本文主要分析shadertoy中實現的一個簡易的OrenNayar模型的實現。要展示OrenNayar模型的效果,必須首先要在着色器裏面實現一個簡單的光線追蹤器(其實我主要是想分析一下作者實現的光線追蹤器....)

當然分析過程純屬個人猜測,不代表原作者思路,原文地址如下:
https://www.shadertoy.com/view/ldBGz3

正文

Lambert和OrenNayar模型的對比

Lambert和OrenNayar都是一種光照反射模型,即描述特定材質的物體表面如何反射光線。(具體原理可以看《Physically Based Rendering》第五章和第八章相關內容)。
在Lambert模型中,所有方向的反射光的輻射率都相等,輻射率大小與入射光與表面法線的夾角的餘弦值成正比。OrenNayar是一種微表面反射模型,即表面是由無數個微小的表面構成,每個微表面被假設爲是理想Lambert模型,OrenNayar模型中考慮了微表面之間的遮擋,陰影,法線方向等因素。對OrenNayar的原理感興趣的同學可以看《Oren_SIGGRAPH94》文檔(反正我是沒怎麼看懂)。該文檔的開頭引入了一張圖片


這張圖片主要是展示理想Lambert模型的不足,左邊爲真實的圓柱體花瓶,右邊是用理想Lambert模型渲染的圓柱體花瓶。可以看到,Lambert模型中,當靠近花瓶兩側邊緣時,亮度顯著下降,而真實的花瓶的亮度變化整體上基本是趨於平坦的。而用OrenNayar渲染的模型可以更接近真實。

可以看下shadertoy中渲染出來的兩個球


可以看出,Lambert模型隨着角度變化亮度會快速下降,而OrenNayar模型亮度變化趨於平緩。

光線追蹤原理

簡單畫了個圖,簡單概況下光線追蹤原理。我們之所以能看見物體,是因爲光源發射的光經過物體表面的反射,最終有一部分進入了我們的眼睛。光線追蹤爲了模擬現象,從眼睛(或相機)位置出發,向屏幕上的每個像素上發射一條或多條射線,屏幕像素的顏色和亮度,取決於射線進入場景後與物體交點的反射輻射率(如果交點)或者背景顏色(如果沒有交點)。

光線追蹤的具體實現由許多方式(比如path tracing),大部分是採用遞歸的方式,即光線碰到物體表面後,計算反射方向後繼續前進,直到遇到光源或遞歸次數達到最大值。本文要分析的光線追蹤非常簡單,只檢測一次碰撞,但用了特殊的技巧來模擬遮擋和陰影,後面會講到。

下面開始分析代碼。

第一步,創建相機空間,放置光源,球體等

首先要明確一點,因爲shadertoy中是在像素着色器中編程,像素着色器的代碼本身就是對每一個像素進行操作的過程,因此我們不需要像在CPU中實現光追那樣手動搭建一個虛擬相機的屏幕(上圖的film平面),也不需要循環遍歷每個像素(像素着色器在GPU中並行運行了)。

粘貼相關代碼:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
   //第一步,創建相機空間,放置光源,球體等    
   vec2 p = (-iResolution.xy + 2.0*fragCoord.xy) / iResolution.y;  //屏幕座標

        //1.1 光源
    vec3 lig = normalize( vec3( 0.6, 0.5, 0.4) );   //主光源
    vec3 bac = normalize( vec3(-0.6, 0.0,-0.4) ); //輔助光源
    vec3 bou = normalize( vec3( 0.0,-1.0, 0.0) );  //輔助光源
    
     // 1.2 相機矩陣
    float an = 0.6 - 0.5*iTime;  
    vec3 ro = vec3( 3.5*cos(an), 1.0, 3.5*sin(an) );   //相機位置,隨時間移動
    vec3 ta = vec3( 0.0, 1.0, 0.0 );        //相機方向

    // camera matrix
    vec3 ww = normalize( ta - ro );
    vec3 uu = normalize( cross(ww,vec3(0.0,1.0,0.0) ) );
    vec3 vv = normalize( cross(uu,ww));
    // create view ray
    vec3 rd = normalize( p.x*uu + p.y*vv + 1.5*ww );  //(p.x,p.y, 1.5)是屏幕上的像素在相機空間的位置(或者說是相機空間下的射線方向,因爲射線的原點是(0,0,0)),乘以lookat矩陣後,rd轉換爲世界空間的射線

    //1.3 放置球體
    vec4 sph1 = vec4(-1.2,1.0,0.0,1.0);
    vec4 sph2 = vec4( 1.2,1.0,0.0,1.0);

   //…….
}
1.1 光源

在場景中放置的三個光源,lig,bac,bou,它們都是方向光,向量就代表光源的方向。lig是主光源,對效果貢獻最大,是產生直接反射和陰影的主要來源。bac和bou主要是爲了模擬真實場景的間接反射而加入的,對效果貢獻較小。

1.2 相機矩陣

我們需要相機矩陣(或者叫lookat矩陣)來進行相機座標系和世界座標系之間的轉換。原理不說了,重要的一點是在相機空間中,觀察方向(從視點望向目標點)是+z軸。如下圖


1.3 放置球體

兩個球體,sph1和sph2,其中 sph1.xyz 爲球體的在世界空間的座標,sp1h.w 爲球體的半徑。

第二步,判斷光線是否與場景中物體是否相交,並求出遮擋係數。

上一步計算的向量rd,就是從攝像頭出發,並穿過當前像素的射線,我們現在要逐一判斷它和場景中的物體是否相交,也就是光線追蹤。

1.1 判斷射線與平面是否相交
// 1.1 判斷射線與平面是否相交   
float h = (0.0-ro.y)/rd.y;
    if( h>0.0 ) 
    { 
           //求出兩個球的遮擋影響係數occ
           //.....
        obj = 0.0;   //obj = 0.0標記光線與平面相交了
    }
}

除了兩個球體外,場景中還有一個y == 0的平面。用 h = (0.0-ro.y) / rd.y 來判斷射線和平面是否相交。如下圖,比如左邊的,ro.h > 0,rd.y < 0那麼h = (0.0-ro.y)/rd.y 的結果h就是大於0,即射線會與平面相交。相反,如右邊的ro和rd,計算出來h小於0,不相交。

如果是相交的,那麼接下來要幹嘛的,我們先看下面這個圖:


首先要注意到,rd是從相機發出的射線,它實際上是經物體表面反射進入到相機的光線wo的取反。wo實際上是場景中所有光入射光線wi的總體作用(這就是BRDF)。這些wi,有可能是直接光照(圖中的wi1),也有可能是間接光照(圖中的wi2)。因爲場景中還存在別的物體(兩個球),光源有可能被球擋住了(比如圖中的light2的一些光線就無法達到pos)。我們可以確定的是,如果直接光被遮擋得越多,那麼到達pos的光的總能量肯定越少,導致wo強度越小。那麼如何衡量遮擋的影響呢?作者用了一個方法來估計球體遮擋的影響,那就是oSphere函數。

float oSphere( in vec3 pos, in vec3 nor, in vec4 sph )
{
    vec3 di = sph.xyz - pos;
    float l = length(di);
    return 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l); 
}

如下圖,di的長度l表示球與交點pos的距離,shp.w表示球的半徑,dot(nor, di / l)表示di與平面法線的夾角。返回的遮擋係數等於 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) ,如果仔細觀察這個公式不難發現,當夾角越小(dot越大),或者球半徑sph.w越大,或者距離l越小時,max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) 越大,也就是 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) 這個遮擋係數越小。換句話說,當球半徑越大,或者距離越近,或者球體位置接近平面的法線時,球體對於交點遮擋的光線就越多,返回的遮擋係數就越小

tip:個人覺得這只是一種估計遮擋係數的技巧,一般的光線追蹤都是通過生成shadow光線來判斷可見性的(可以看《PBRT》的相關章節)

知道了oSphere函數的作用後,我們應該就能理解當下面的代碼了

// 1.1 判斷射線與平面是否相交   
float h = (0.0-ro.y)/rd.y;
    if( h>0.0 ) 
    {       
                tmin = h; 
        nor = vec3(0.0,1.0,0.0);   //平面法線
        pos = ro + h*rd;               //射線與平面交點
        occ = oSphere( pos, nor, sph1 ) *
              oSphere( pos, nor, sph2 );  //求出兩個球的遮擋影響係數occ
        obj = 0.0;   //obj = 0.0標記光線與平面相交了
    }
}

因爲場景中一共有兩個球,遮擋係數occ是sph1和sph2遮擋係數的乘積。

1.2 判斷射線與球體是否相交

    h = iSphere( ro, rd, sph1 );
    if( h>0.0 && h<tmin ) 
    { 
        tmin = h; 
        pos = ro + h*rd;
        nor = normalize(pos-sph1.xyz); 
        occ = (0.5 + 0.5*nor.y) *
              oSphere( pos, nor, sph2 );
        obj = 1.0;
    }
    h = iSphere( ro, rd, sph2 );
    if( h>0.0 && h<tmin ) 
    { 
        tmin = h; 
        pos = ro + h*rd;
        nor = normalize(pos-sph2.xyz); 
        occ = (0.5 + 0.5*nor.y) *
              oSphere( pos, nor, sph1 );
        obj = 2.0;
    }

iSphere函數判斷射線與球體是否相交:

float iSphere( in vec3 ro, in vec3 rd, in vec4 sph )
{
    vec3 oc = ro - sph.xyz;
    float b = dot( oc, rd );
    float c = dot( oc, oc ) - sph.w*sph.w;
    float h = b*b - c;
    if( h<0.0 ) return -1.0;
    return -b - sqrt( h );
}

推導過程不分析了,有興趣的話可以看下我前面寫的《pbrt筆記--第三章 Shapes》3.2.2節的推導。當iSphere函數返回值大於0時,則表示射線與球體相交。

當相交時,同樣要判斷場景中其他物體的遮擋影響:occ = (0.5 + 0.5*nor.y) *
oSphere( pos, nor, sph2 );其中(0.5 + 0.5*nor.y) 是平面遮擋的影響(總感覺怪怪的),oSphere( pos, nor, sph2 )是sph2的遮擋影響。另一個球的相交測試也類似,不贅述了。

上面還有一個地方要注意的是,如果射線既與平面相交,又與球體相交,那麼怎麼處理呢?如下圖:


假設射線和平面有一個交點pos2,對應的射線的係數爲h2;射線與球體也有一個交點pos1,對應的射線的係數爲h1。這個時候,如果h1比h2小,說明射線先和球體相交,那麼射線前進的方向就被擋住了,也就不存在pos2這個交點。代碼中是通過tmin來記錄的。

    h = iSphere( ro, rd, sph1 );
    if( h>0.0 && h<tmin )   //tmin是由上一步計算交點得出的,只有當h小於tmin時,球和射線纔有交點
    { 
///...
    }
1.3 計算陰影

相關代碼如下:

    vec3 col = vec3(0.93);
    if( tmin<100.0 )  //如果有交點
{
         pos = ro + tmin*rd;   //計算出交點位置

        // shadows
        float sha = 1.0;
        sha *= ssSphere( pos, lig, sph1 );
        sha *= ssSphere( pos, lig, sph2 );
}

經過上面的步驟,如果射線與場景中的物體存在交點(if( tmin<100.0 )),我們求出最近的交點pos後,就開始製作陰影效果。

陰影效果主要由ssSphere函數返回的sha係數來決定的。我們先不看ssSphere的實現,簡單說下如何得出陰影效果,如下圖:


假設經過前面的計算,我們得出平面上一個交點pos(這個交點是相機發出的射線和平面的交點,而不是光源和平面的交點)。pos是否處於陰影中,實際上就是看主光源lig的光是否可以直接到達pos上。圖中左邊的pos被球擋住了,那麼pos就應該處於陰影之中,反之,右邊的pos被lig直接照射到,應該具有較強的亮度。現在我們看等式sha *= ssSphere( pos, lig, sph1 );pos爲交點,lig爲光源,sph1爲球體1,我們大概已經猜到,ssSphere函數的作用就是lig和pos的光線是否和shp1有交點!(有交點說明被遮擋了)

float ssSphere( in vec3 ro, in vec3 rd, in vec4 sph )
{
    vec3 oc = sph.xyz - ro;
    float b = dot( oc, rd );  //rd和oc的夾角的餘弦
    
    float res = 1.0;
    if( b>0.0 )   //
    {
        float h = dot(oc,oc) - b*b - sph.w*sph.w;  //注意h小於0的時候是有交點的
        res = clamp( 16.0 * h / b, 0.0, 1.0 );
    }
    return res;
}

首先會判斷oc和rd的夾角的餘弦值,當b小於0.0時,說明夾角超過了90度或者小於0度,由下圖可以看出,球無論如何都無法遮擋住光線,返回值res就是1.0.


如果夾角小於90度時,球就有可能擋住要到達ro的光線。那麼就判斷從ro出發,方向爲rd的射線是否和球有交點。如果有交點(h<0時),那麼公式clamp( 16.0 * h / b, 0.0, 1.0 );就會返回0。如果沒有交點(h>0時,),res的值就爲16.0 * h / b(爲啥是這個還不是很清楚)。

將各個光源的效果進行疊加

代碼如下:

        // integrate irradiance with brdf times visibility
        vec3 diffColor = vec3(0.18);
        if( obj>1.5 )   //sph1
        {
            lin += vec3(0.5,0.7,1.0)*diffColor*occ;
            lin += vec3(5.0,4.5,4.0)*diffColor*OrenNayar( lig, nor, rd, 1.0 )*sha;
            lin += vec3(1.5,1.5,1.5)*diffColor*OrenNayar( bac, nor, rd, 1.0 )*occ;
            lin += vec3(1.0,1.0,1.0)*diffColor*OrenNayar( bou, nor, rd, 1.0 )*occ;
        }
        else   //sph2
        {
            lin += vec3(0.5,0.7,1.0)*diffColor*occ;
            lin += vec3(5.0,4.5,4.0)*diffColor*Lambert( lig, nor )*sha;
            lin += vec3(1.5,1.5,1.5)*diffColor*Lambert( bac, nor )*occ;
            lin += vec3(1.0,1.0,1.0)*diffColor*Lambert( bou, nor )*occ;
        }

有代碼可知,對sph1使用的是OrenNayar模型的BRDF,對sph2使用的是Lambert模型的BRDF。

gamma校正

因爲我們現在計算的亮度是在線性空間,而顯示器會對該亮度調整到gamma2.2空間,所以我們在把亮度傳給顯示器前要把它做一次gamma校正。(gamma校正的問題可以參看https://zhuanlan.zhihu.com/p/66558476

    // gamma    
    col = pow( col, vec3(0.45) );
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章