[教學視頻]計算機圖形學基礎 在線學習教程
*原創文章,轉載請註明出處*
光線追蹤技術的理論和實踐(面向對象)
Theory & Practice of Raytracing(Object Oriented)
介紹
這篇文章將介紹光線追蹤技術。在計算機圖形領域中,這種技術被普遍應用於生成高質量的照片級圖像。在爲一個場景計算光照的時候,通過固定圖形渲染管線可以計算phong光照模型,由於該模型的特徵,使得渲染的物體看起來有塑料的質感。如果要渲染一個有金屬質感且能反射周圍環境的物體,phong模型就無能爲力了。和固定渲染管線相比,可編程圖形渲染管線的力能要強的多,雖然可以實現很多逼真的光照效果,比如利用環境貼圖來現實物體對環境的反射效果。但是這種環境反射只能反射出已經保存在Cube Map中的圖像。在真實世界中,如果一個能反射周圍環境的物體周圍還有很多其他物體,它們就會相互反射。一般的環境貼圖技術達不到這樣的效果,於是在渲染照片級畫面的時候,就要用到光線追蹤的技術。文本還將利用c++面向對象的方法來實現光線追蹤。
原理
在介紹原理之前,先考慮一個問題:我們是怎樣看到真實世界中的物體的?我們能看到物體,是因爲該物體上有反射光線到達我們的眼睛。沒有任何光線傳入眼睛,我們就看不到任何東西。我們還經常看到一個物體表面能反射另一個物體。這也是因爲被反射物體表面的反射光線到達該物體表面後,該物體繼續將光線反射到我們的眼睛裏,於是我們看到了該物體表面反射其他物體的效果。現在,我們將從物體表面出發最後到達眼睛的光線的方向反向。先來看看下面的Fig1,在Fig1中是一個虛擬的場景,場景中有2個球和1個圓錐,白色的點代表光源,中間四邊形就是虛擬屏幕,屏幕上一個一個的小方格就代表像素,相機的位置代表觀察者眼睛的位置。
(a) |
(b) |
Fig1光線追蹤場景
光線追蹤的原理就是從相機的位置發出一條條通過每一個像素的射線,如果該射線和場景中的物體相交,那麼就可以計算出該交點的顏色,這個顏色就是對應的像素的顏色。當然,計算像素顏色的時候首先要計算出交點處所有與光照計算相關量,比如法線,入射光線和反射光線等等。
(a) |
(b) |
Fig2光線和空間物體相交
在Fig2中可以看到,從相機出發的射線依次穿過每一個像素,圖中顯示出其中的三條。這些射線都與物體有交點,不同物體的交點計算方法也不一樣。射線與平面的交點計算方法和射線與球的交點計算方法是截然不同的。爲了計算方便,這裏就只以球爲例。如果一個物體可以反射周圍的環境,那麼當一條射線與該物體相交後,射線還會在該點產生反射和折射等。例如在Fig2中,當射線和藍色球相交後,光線會反射,反射的光線又可能和橙色圓錐和綠色球相交,所以我們能在藍色球的表面看到橙色的圓錐和綠色球。整個光線追蹤的原理就是這麼簡單,但是實際操作起來又有很多要注意的地方。
實踐
用面向對象的方法來實現光線追蹤比使用面向結構要來的容易一些。因爲在光線追蹤的整個過程中,比較容易抽象出對象的共同特徵,比如我們可以抽象出射線,物體,光源,材質等等。當然,最最基本的一個類就是向量類,在計算光照的時候向量很重要。在這裏我們假設已經實現了一個三維向量類GVector3,該類提供所有有關向量的操作。
除了向量,我們最先能想到一個關於射線的類,叫CRay。
對於一條射線最基本的就是它的出發點和方向,所以在CRay的類圖中,能看到兩個私有成員變量m_Origin和m_Direction,它們都是GVector3類型。由於類的設計原則要滿足數據的封裝性,既然射線的出發點和方向都是私有的,那麼就要提供公共的成員方法來訪問它們,於是我們還需要set和get方法。最後,getPoint(double)方法是通過向射線的參數方程傳入參數t而獲得在射線上的點。實現了射線CRay類後,那麼在使用光線追蹤計算每個像素顏色的時候,對於每一個像素都要創建一個CRay的實例。
for(inty=0;y<=ImageHeight;y++) { for(intx=0; x<=ImageWidth;x++) { doublepixel_x = -20.0 +40.0/ImageWidth*x; doublepixel_y = -15.0 +30.0/ImageHeight*y;
GVector3direction =GVector3(pixel_x, pixel_y,0)-CameraPosition; CRayray(CameraPosition,direction);
// call RayTracer function } } |
從上面的代碼可以看到,兩個for循環用於掃描每一個像素,然後在循環裏計算出每個像素的位置。如果我們假設Fig1中,四邊形屏幕處於xy平面,長和寬分別是40和30,且左上頂點座標和右下頂點座標分別爲(-20,15,0)和(20,-15,0)。爲了將該屏幕映射到實際分辨率爲800*600的窗口上,就要求出虛擬屏幕上每個像素的座標pixel_x和pixel_y。然後對每一個像素都用一條射線穿過它,射線的方向自然就是像素的位置和相機位置的差向量的方向。要注意一點,實際窗口的分辨率比例要和虛擬屏幕長寬比例保持一致,這樣渲染出來的畫面看起來長寬比例才正確。
現在我們來考慮在場景中的物體。一個物體可能有很多可以描述它的特徵,比如形狀,大小,顏色,材質等等。使用面向對象的方法,就需要將這些物體的共同特徵抽象出來。下面是一個抽象出來的物體類GCObject。
CGObject類成員變量有五個,分別表示物體表面環境光反射係數(m_Ka),漫反射係數(m_Kd),鏡面反射係數(m_Ks),鏡面反射強度(m_Shininess)和環境反射強度(m_Reflectivity)。前四個變量是計算光照所需要的最基本量,而環境反射強度表示該物體能反射環境的能力。這些成員變量都的類型都是protected,因爲我們要把CGObject最爲物體的基類,這些protected成員變量可以被該類的子類所繼承。該類的所有get方法和set方法都能被子類繼承,而且所有繼承了該類的子類的方法都相同。該類還有兩個虛成員函數,分別是getNormal()和isIntersected()。getNormal()函數的作用是獲取物體表面一點的法線,它接受一個GVector3類型的參數_Point,並返回物體表麪點_Point處的法線。當然不同物體表面獲得法線的方法是不一樣的。比如,對於平面來說,平面上所有點的法線都是一樣的。而對於球來說,球面上每一個的法線是球面上的該交點p和球心的c的差向量。
NSphere= p - c
所以將getNormal()設置爲虛成員函數就可以實現類的多態性,凡是繼承了該方法的子類,都可以實現自己的getNormal()方法。同樣的道理,函數isInserted也是虛成員函數,該方法接受參數射線CRay
和距離Distance,CRay是輸入參數,用於判斷射線和該物體的交點,Distance是輸出參數,如果物體和射線相交,則返回相機到該交點的距離。Distance還應該有個很大初始值,表示在無限遠處物體和射線相交,這種情況用於判斷物體和射線沒有交點。函數isIntersected()還返回一個枚舉類型INTERSECTION_TYPE,定義如下:
enumINTERSECTION_TYPE {INTERSECTED_IN = -1,MISS = 0,INTERSECTED = 1}; |
其中INTERSECTED_IN表示射線從物體內部出發並和物體有交點,MISS射線和物體沒有交點,INTERSECTED表示射線從物體外部出發並且和物體有交點。射線和不同物體交點的計算方法不同,於是該函數爲虛函數,繼承該函數的子類可以實現自己的isIntersected()方法。下面的代碼就可以判斷一條射線和場景中所有物體的是否有交點,並且返回離相機最近的一個。
double distance = 1000000;//初始化無限大距離 GVector3 Intersection; //交點 for(inti = 0;i<objects_numbers;i++) //遍歷場景中每一個物體 { CGObject*obj = objects_list[i]; if(obj->isIntersected(ray,distance) !=MISS)// 判斷是否有交點 { Intersection =ray.getPoint(distance); //如果相交,求出交點保存到Intersection } } |
爲了計算方便,這裏就以球爲例,創建一個CSphere的類,該類繼承於CGObject。
作爲球,只需要提供球心Center和半徑Radius就可以決定它的幾何性質。所以CSphere類只有兩個私有成員變量。在所有成員函數中,我們重點來看看isIntersected()方法。
INTERSECTION_TYPE CSphere::isIntersected(CRay_ray,double& _dist) { GVector3v =_ray.getOrigin() -m_Center; doubleb = -(v *_ray.getDirection()); doubledet = (b *b) -v*v +m_Radius; INTERSECTION_TYPEretval =MISS; if (det > 0){ det =sqrt(det); doublet1 =b - det; doublet2 =b + det; if (t2 > 0){ if (t1 < 0) { if (t2 <_dist) { _dist =t2; retval =INTERSECTED_IN; } } else{ if (t1 <_dist){ _dist =t1; retval =INTERSECTED; } } } } returnretval; } |
如果射線和球有交點,那麼交點肯定在球面上。球面上的點P都滿足下面的關係,
| P – C | = R
很明顯球面上的點和球心的差向量的大小等於球的半徑。然後將射線的參數方程帶入上面的公式,再利用求根公式判斷解的情況。具體的方法這裏就不詳述了,有興趣的同學可以參考另一篇文章“利用OpenGL實現RayPicking”,這篇文章詳細講解了射線和球交點的計算過程。
現在我們實現了射線CRay,球體CSphere,還差一個重要的角色——光源。光源也是物體的一種,完全可以從我們的基類CGObject類繼承。這裏做一點區別,我們單獨創建一個所有光源的基類CLightSource,然後從它在派生出不同的光源種類,比如平行光源DirectionalLight,點光源CPointLight和聚光源CSpotLight。本文中只詳細講解平行光源的情況,其他兩種光源有興趣的同學可以自己實現。
類CLightSource的成員變量有四個,分別表示光源的位置,光源的環境光成分,漫反射成分和鏡面反射成分。同樣地,所有的set和get方法都爲該類的子類提供相同的功能。最後也有三個虛成員函數,EvalAmbient(),EvalDiffuse()和EvalSpecular(),它們名字分別說明它們的功能,並且都返回GVector3類型的值——顏色。由於對於不同種類的光源,計算方法可能不同,於是將它們設置爲虛函數爲以後的擴展做準備。筆者這裏將光照計算放在了光源類裏面,當然你也可以放在物體類CGObject裏,也可以單獨寫一個方法,將光源和物體作爲參數傳入,計算出顏色後最爲返回值返回。具體使用哪一種好還是要根據具體情況具體分析。
上面的平行光源類CDirectionalLight是CLightSource的子類,它繼承了父類三個虛函數方法。下面來看看這三個函數的具體實現。
環境光的計算是最簡單的,將物體材質環境反射係數和光源的環境光成分相乘即可。
ambient = Ia•Ka
計算環境光的代碼如下
GVector3CDirectionalLight::EvalAmbient(constGVector3&_material_Ka) { returnGVector3(m_Ka[0]*_material_Ka[0], m_Ka[1]*_material_Ka[1], m_Ka[2]*_material_Ka[2]); } |
漫反射的計算稍微比環境光復雜,漫反射的計算公式爲
diffuse = Id•Kd• (N•L)
其中,Id是光源的漫反射成分,Kd是物體的漫反射係數,N是法線,L是入射光向量。
GVector3CDirectionalLight::EvalDiffuse(constGVector3&_N, const GVector3& _L, const GVector3&_material_Kd) { GVector3IdKd =GVector3(m_Kd[0]*_material_Kd[0], m_Kd[1]*_material_Kd[1], m_Kd[2]*_material_Kd[2]);
doubleNdotL =MAX(_N*_L, 0.0); returnIdKd*NdotL; } |
鏡面反射的計算又比環境光要複雜,鏡面反射的計算公式爲
specular = Is•Ks• (V·R)n
其中
R = 2(L•N)•N-L
Is是光源鏡面反射成分,Ks是物體的鏡面反射係數,V是相機方向向量,R是反射向量,n就反射強度Shininess。爲了提高計算效率,也可以利用HalfVectorH來計算鏡面反射。
specular = Is•Ks• (N•H)n
其中
H=(L+V)/2
計算H要比計算反射向量R要快得多。
GVector3CDirectionalLight::EvalSpecluar(constGVector3&_N, const GVector3& _L, const GVector3&_V, constGVector3&_material_Ks,constdouble& _shininess) { GVector3IsKs =GVector3(m_Ks[0]*_material_Ks[0], m_Ks[1]*_material_Ks[1], m_Ks[2]*_material_Ks[2]);
GVector3H = (_L+_V).Normalize();
doubleNdotL =MAX(_N*_L, 0.0); doubleNdotH =pow(MAX(_N*H, 0.0),_shininess);
if(NdotL<=0.0) NdotH = 0.0;
returnIsKs*NdotH; } |
分別計算出射線和物體交點處的環境光,漫反射和鏡面反射後,那麼該射線對應像素的顏色c爲
C = ambient + diffuse + specular
於是,我們可以在代碼中添加一個方法叫Tracer(),該方法就是遍歷場景中的每個物體,判斷射線和物體的交點,然後計算交點的顏色。
GVector3 Tracer(CRayR) { GVector3color; for(/*遍歷每一個物體*/) { if(/*如果有交點*/) { GVector3p =R.getPoint(dist); GVector3N =m_pObj[k]->getNormal(p); N.Normalize(); for(/*遍歷每一個光源*/) {
GVector3ambient =m_pLight[m]->EvalAmbient(m_pObj[k]->getKa()); GVector3L =m_pLight[m]->getPosition()-p; L.Normalize(); GVector3diffuse =m_pLight[m]->EvalDiffuse(N,L,m_pObj[k]->getKd()); GVector3V =m_CameraPosition -p; V.Normalize(); GVector3specular =m_pLight[m]->EvalSpecluar(N,L,V, m_pObj[k]->getKs(),m_pObj[k]->getShininess());
color =ambient +diffuse +specular; } } } } |
如果要渲染可以反射周圍環境的物體,就需要稍微修改上面的Tracer()方法,因爲反射是一個遞歸的過程,一但一條射線被物體反射,那麼同樣的Tracer()方法就要被執行一次來計算被反射光線和其他物體是否還有交點。於是,在Tracer()方法中再傳入一個代表遞歸迭代深度的參數depth,它表示射線與物體相交後反射的次數,如果爲1,說明射線與物體相交後不反射,爲2表示射線反射一次,以此類推。
Tracer(CRayR,int depth) { GVector3color; //計算C = ambient + diffuse + specular if(TotalTraceDepth ==depth) returncolor; else { //計算射線和物體交點處的反射射線Reflect;
GVector3c =Tracer(Reflect, ++depth); color +=GVector3(color[0]*c[0],color[1]*c[1],color[2]*c[2]); returncolor; } } |
創建一個場景,然後執行代碼,可以看到下面的效果。
Fig3光線追蹤渲染的場景1
如果設置Tracer的遞歸深度大於2的話,就可以看到兩個球相互反射的情況。雖然這個光線追蹤可以正常的執行,但是畫面看起來總覺得缺少點什麼。仔細觀察你會發現畫面雖然有光源,但是物體沒有陰影,陰影可以增加場景的真實性。要計算陰影,我們應該從光源的出發,從光源出發的射線和物體如果有交點,而且這條射線與多個物體相交,那麼除第一個交點外的後面所有交點都處於陰影中,這點很容易理解。於是,我們需要修改部分代碼。
GVector3 Tracer(CRayR,int depth) { GVector3color; doubleshade = 1.0 for(/*遍歷每一個物體*/) { for(/*遍歷每一個光源*/) { GVector3L =pObj[k]->getCenter() -Intersection; doubledist =norm(L); L *= (1.0f /dist); CRayr =CRay( Intersection,L ); for (/*遍歷每一個物體*/ ) { CGObject*pr =pObj[s]; if (pr->isIntersected(r,dist)!=MISS) { shade = 0; break; } } } } if(shade>0) { //計算C = ambient + diffuse + specular //遞歸計算反射 } return color*shade; } |
增加了陰影計算後,再運行程序,就能看到下面的效果。
Fig4光線追蹤渲染的場景2
最後我們也可以讓地面反射物體,然後再牆上添加很多小球,讓畫面變得複雜一些,如下圖。
Fig5光線追蹤渲染的場景3
總結
這篇文章通過利用面向對象的方法來實現了光線追蹤渲染場景。利用面向對象的方法來實現光線追蹤使程序的擴展性得到增強,渲染複雜的場景或者複雜的幾何物體的時候,或者有很多光源和複雜光照計算的時候,只需要從基類繼承,然後利用多態性來實現不同物體的不同渲染方法。
從上面的類圖可以看到,利用面向對象的方式可以很容易擴展程序。而且,由於光線追蹤的這種結構,不論添加多少物體在場景中,不論物體多麼複雜,這種結構總能很好地渲染出正確的畫面。
但是,對光線追蹤來說,越複雜的場景需要的渲染時間越長。有的時候渲染一幀的畫面甚至需要幾天的時間。所以好的算法和程序結構對於光線追蹤來說是很重要的,可以通過場景管理、使用GPU或CUDA等等技術來提高渲染效率。
*原創文章,轉載請註明出處*