前言
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) );