光線追蹤技術(shù)的理論和實(shí)踐(面向?qū)ο?
介紹
這篇文章將介紹光線追蹤技術(shù)。在計(jì)算機(jī)圖形領(lǐng)域中,這種技術(shù)被普遍應(yīng)用于生成高質(zhì)量的照片級(jí)圖像。在為一個(gè)場(chǎng)景計(jì)算光照的時(shí)候,通過(guò)固定圖形渲染管線可以計(jì)算phong光照模型,由于該模型的特征,使得渲染的物體看起來(lái)有塑料的質(zhì)感。如果要渲染一個(gè)有金屬質(zhì)感且能反射周圍環(huán)境的物體,phong模型就無(wú)能為力了。和固定渲染管線相比,可編程圖形渲染管線的力能要強(qiáng)的多,雖然可以實(shí)現(xiàn)很多逼真的光照效果,比如利用環(huán)境貼圖來(lái)現(xiàn)實(shí)物體對(duì)環(huán)境的反射效果。但是這種環(huán)境反射只能反射出已經(jīng)保存在Cube Map中的圖像。在真實(shí)世界中,如果一個(gè)能反射周圍環(huán)境的物體周圍還有很多其他物體,它們就會(huì)相互反射。一般的環(huán)境貼圖技術(shù)達(dá)不到這樣的效果,于是在渲染照片級(jí)畫面的時(shí)候,就要用到光線追蹤的技術(shù)。文本還將利用c++面向?qū)ο?/strong>的方法來(lái)實(shí)現(xiàn)光線追蹤。
原理
在介紹原理之前,先考慮一個(gè)問(wèn)題:我們是怎樣看到真實(shí)世界中的物體的?我們能看到物體,是因?yàn)樵撐矬w上有反射光線到達(dá)我們的眼睛。沒(méi)有任何光線傳入眼睛,我們就看不到任何東西。我們還經(jīng)??吹揭粋€(gè)物體表面能反射另一個(gè)物體。這也是因?yàn)楸环瓷湮矬w表面的反射光線到達(dá)該物體表面后,該物體繼續(xù)將光線反射到我們的眼睛里,于是我們看到了該物體表面反射其他物體的效果?,F(xiàn)在,我們將從物體表面出發(fā)最后到達(dá)眼睛的光線的方向反向。先來(lái)看看下面的Fig1,在Fig1中是一個(gè)虛擬的場(chǎng)景,場(chǎng)景中有2個(gè)球和1個(gè)圓錐,白色的點(diǎn)代表光源,中間四邊形就是虛擬屏幕,屏幕上一個(gè)一個(gè)的小方格就代表像素,相機(jī)的位置代表觀察者眼睛的位置。
(a)
(b)
Fig1 光線追蹤場(chǎng)景
光線追蹤的原理就是從相機(jī)的位置發(fā)出一條條通過(guò)每一個(gè)像素的射線,如果該射線和場(chǎng)景中的物體相交,那么就可以計(jì)算出該交點(diǎn)的顏色,這個(gè)顏色就是對(duì)應(yīng)的像素的顏色。當(dāng)然,計(jì)算像素顏色的時(shí)候首先要計(jì)算出交點(diǎn)處所有與光照計(jì)算相關(guān)量,比如法線,入射光線和反射光線等等。
(a)
(b)
Fig2 光線和空間物體相交
在Fig2中可以看到,從相機(jī)出發(fā)的射線依次穿過(guò)每一個(gè)像素,圖中顯示出其中的三條。這些射線都與物體有交點(diǎn),不同物體的交點(diǎn)計(jì)算方法也不一樣。射線與平面的交點(diǎn)計(jì)算方法和射線與球的交點(diǎn)計(jì)算方法是截然不同的。為了計(jì)算方便,這里就只以球?yàn)槔?。如果一個(gè)物體可以反射周圍的環(huán)境,那么當(dāng)一條射線與該物體相交后,射線還會(huì)在該點(diǎn)產(chǎn)生反射和折射等。例如在Fig2中,當(dāng)射線和藍(lán)色球相交后,光線會(huì)反射,反射的光線又可能和橙色圓錐和綠色球相交,所以我們能在藍(lán)色球的表面看到橙色的圓錐和綠色球。整個(gè)光線追蹤的原理就是這么簡(jiǎn)單,但是實(shí)際操作起來(lái)又有很多要注意的地方。
實(shí)踐
用面向?qū)ο蟮姆椒▉?lái)實(shí)現(xiàn)光線追蹤比使用面向結(jié)構(gòu)要來(lái)的容易一些。因?yàn)樵诠饩€追蹤的整個(gè)過(guò)程中,比較容易抽象出對(duì)象的共同特征,比如我們可以抽象出射線,物體,光源,材質(zhì)等等。當(dāng)然,最最基本的一個(gè)類就是向量類,在計(jì)算光照的時(shí)候向量很重要。在這里我們假設(shè)已經(jīng)實(shí)現(xiàn)了一個(gè)三維向量類GVector3,該類提供所有有關(guān)向量的操作。
除了向量,我們最先能想到一個(gè)關(guān)于射線的類,叫CRay。
對(duì)于一條射線最基本的就是它的出發(fā)點(diǎn)和方向,所以在CRay的類圖中,能看到兩個(gè)私有成員變量m_Origin和m_Direction,它們都是GVector3類型。由于類的設(shè)計(jì)原則要滿足數(shù)據(jù)的封裝性,既然射線的出發(fā)點(diǎn)和方向都是私有的,那么就要提供公共的成員方法來(lái)訪問(wèn)它們,于是我們還需要set和get方法。最后,getPoint(double)方法是通過(guò)向射線的參數(shù)方程傳入?yún)?shù)t而獲得在射線上的點(diǎn)。實(shí)現(xiàn)了射線CRay類后,那么在使用光線追蹤計(jì)算每個(gè)像素顏色的時(shí)候,對(duì)于每一個(gè)像素都要?jiǎng)?chuàng)建一個(gè)CRay的實(shí)例。
for(int y=0; y<=ImageHeight; y++)
{
for(int x=0; x<=ImageWidth; x++)
{
double pixel_x = -20.0 +40.0/ImageWidth*x;
double pixel_y = -15.0 +30.0/ImageHeight*y;
GVector3 direction = GVector3(pixel_x, pixel_y,0)-CameraPosition;
CRay ray(CameraPosition, direction);
// call RayTracer function
}
}
從上面的代碼可以看到,兩個(gè)for循環(huán)用于掃描每一個(gè)像素,然后在循環(huán)里計(jì)算出每個(gè)像素的位置。如果我們假設(shè)Fig1中,四邊形屏幕處于xy平面,長(zhǎng)和寬分別是40和30,且左上頂點(diǎn)坐標(biāo)和右下頂點(diǎn)坐標(biāo)分別為(-20,15,0)和(20,-15,0)。為了將該屏幕映射到實(shí)際分辨率為800*600的窗口上,就要求出虛擬屏幕上每個(gè)像素的坐標(biāo)pixel_x和pixel_y。然后對(duì)每一個(gè)像素都用一條射線穿過(guò)它,射線的方向自然就是像素的位置和相機(jī)位置的差向量的方向。要注意一點(diǎn),實(shí)際窗口的分辨率比例要和虛擬屏幕長(zhǎng)寬比例保持一致,這樣渲染出來(lái)的畫面看起來(lái)長(zhǎng)寬比例才正確。
現(xiàn)在我們來(lái)考慮在場(chǎng)景中的物體。一個(gè)物體可能有很多可以描述它的特征,比如形狀,大小,顏色,材質(zhì)等等。使用面向?qū)ο蟮姆椒ǎ托枰獙⑦@些物體的共同特征抽象出來(lái)。下面是一個(gè)抽象出來(lái)的物體類GCObject。
CGObject類成員變量有五個(gè),分別表示物體表面環(huán)境光反射系數(shù)(m_Ka),漫反射系數(shù)(m_Kd),鏡面反射系數(shù)(m_Ks),鏡面反射強(qiáng)度(m_Shininess)和環(huán)境反射強(qiáng)度(m_Reflectivity)。前四個(gè)變量是計(jì)算光照所需要的最基本量,而環(huán)境反射強(qiáng)度表示該物體能反射環(huán)境的能力。這些成員變量都的類型都是protected,因?yàn)槲覀円袰GObject最為物體的基類,這些protected成員變量可以被該類的子類所繼承。該類的所有g(shù)et方法和set方法都能被子類繼承,而且所有繼承了該類的子類的方法都相同。該類還有兩個(gè)虛成員函數(shù),分別是getNormal()和isIntersected()。getNormal()函數(shù)的作用是獲取物體表面一點(diǎn)的法線,它接受一個(gè)GVector3類型的參數(shù)_Point,并返回物體表面點(diǎn)_Point處的法線。當(dāng)然不同物體表面獲得法線的方法是不一樣的。比如,對(duì)于平面來(lái)說(shuō),平面上所有點(diǎn)的法線都是一樣的。而對(duì)于球來(lái)說(shuō),球面上每一個(gè)的法線是球面上的該交點(diǎn)p和球心的c的差向量。
NSphere = p - c
所以將getNormal()設(shè)置為虛成員函數(shù)就可以實(shí)現(xiàn)類的多態(tài)性,凡是繼承了該方法的子類,都可以實(shí)現(xiàn)自己的getNormal()方法。同樣的道理,函數(shù)isInserted也是虛成員函數(shù),該方法接受參數(shù)射線CRay
和距離Distance,CRay是輸入?yún)?shù),用于判斷射線和該物體的交點(diǎn),Distance是輸出參數(shù),如果物體和射線相交,則返回相機(jī)到該交點(diǎn)的距離。Distance還應(yīng)該有個(gè)很大初始值,表示在無(wú)限遠(yuǎn)處物體和射線相交,這種情況用于判斷物體和射線沒(méi)有交點(diǎn)。函數(shù)isIntersected()還返回一個(gè)枚舉類型INTERSECTION_TYPE,定義如下:
enum INTERSECTION_TYPE {INTERSECTED_IN = -1, MISS = 0, INTERSECTED = 1};
其中INTERSECTED_IN表示射線從物體內(nèi)部出發(fā)并和物體有交點(diǎn),MISS射線和物體沒(méi)有交點(diǎn),INTERSECTED表示射線從物體外部出發(fā)并且和物體有交點(diǎn)。射線和不同物體交點(diǎn)的計(jì)算方法不同,于是該函數(shù)為虛函數(shù),繼承該函數(shù)的子類可以實(shí)現(xiàn)自己的isIntersected()方法。下面的代碼就可以判斷一條射線和場(chǎng)景中所有物體的是否有交點(diǎn),并且返回離相機(jī)最近的一個(gè)。
double distance = 1000000; // 初始化無(wú)限大距離
GVector3 Intersection; // 交點(diǎn)
for(int i = 0; i
{
CGObject *obj = objects_list[i];
if( obj->isIntersected(ray, distance) != MISS) // 判斷是否有交點(diǎn)
{
Intersection = ray.getPoint(distance); //如果相交,求出交點(diǎn)保存到Intersection
}
}
為了計(jì)算方便,這里就以球?yàn)槔瑒?chuàng)建一個(gè)CSphere的類,該類繼承于CGObject。
作為球,只需要提供球心Center和半徑Radius就可以決定它的幾何性質(zhì)。所以CSphere類只有兩個(gè)私有成員變量。在所有成員函數(shù)中,我們重點(diǎn)來(lái)看看isIntersected()方法。
INTERSECTION_TYPE CSphere::isIntersected(CRay _ray, double& _dist)
{
GVector3 v = _ray.getOrigin() - m_Center;
double b = -(v * _ray.getDirection());
double det = (b * b) - v*v + m_Radius;
INTERSECTION_TYPE retval = MISS;
if (det > 0){
det = sqrt(det);
double t1 = b - det;
double t2 = b + det;
if (t2 > 0){
if (t1 < 0) {
if (t2 < _dist) {
_dist = t2;
retval = INTERSECTED_IN;
}
}
else{
if (t1 < _dist){
_dist = t1;
retval = INTERSECTED;
}
}
}
}
return retval;
}
如果射線和球有交點(diǎn),那么交點(diǎn)肯定在球面上。球面上的點(diǎn)P都滿足下面的關(guān)系,
| P – C | = R
很明顯球面上的點(diǎn)和球心的差向量的大小等于球的半徑。然后將射線的參數(shù)方程帶入上面的公式,再利用求根公式判斷解的情況。具體的方法這里就不詳述了,有興趣的同學(xué)可以參考另一篇文章“利用OpenGL實(shí)現(xiàn)RayPicking”,這篇文章詳細(xì)講解了射線和球交點(diǎn)的計(jì)算過(guò)程。
現(xiàn)在我們實(shí)現(xiàn)了射線CRay,球體CSphere,還差一個(gè)重要的角色——光源。光源也是物體的一種,完全可以從我們的基類CGObject類繼承。這里做一點(diǎn)區(qū)別,我們單獨(dú)創(chuàng)建一個(gè)所有光源的基類CLightSource,然后從它在派生出不同的光源種類,比如平行光源DirectionalLight,點(diǎn)光源CPointLight和聚光源CSpotLight。本文中只詳細(xì)講解平行光源的情況,其他兩種光源有興趣的同學(xué)可以自己實(shí)現(xiàn)。
類CLightSource的成員變量有四個(gè),分別表示光源的位置,光源的環(huán)境光成分,漫反射成分和鏡面反射成分。同樣地,所有的set和get方法都為該類的子類提供相同的功能。最后也有三個(gè)虛成員函數(shù),EvalAmbient(),EvalDiffuse()和EvalSpecular(),它們名字分別說(shuō)明它們的功能,并且都返回GVector3類型的值——顏色。由于對(duì)于不同種類的光源,計(jì)算方法可能不同,于是將它們?cè)O(shè)置為虛函數(shù)為以后的擴(kuò)展做準(zhǔn)備。筆者這里將光照計(jì)算放在了光源類里面,當(dāng)然你也可以放在物體類CGObject里,也可以單獨(dú)寫一個(gè)方法,將光源和物體作為參數(shù)傳入,計(jì)算出顏色后最為返回值返回。具體使用哪一種好還是要根據(jù)具體情況具體分析。
上面的平行光源類CDirectionalLight是CLightSource的子類,它繼承了父類三個(gè)虛函數(shù)方法。下面來(lái)看看這三個(gè)函數(shù)的具體實(shí)現(xiàn)。
環(huán)境光的計(jì)算是最簡(jiǎn)單的,將物體材質(zhì)環(huán)境反射系數(shù)和光源的環(huán)境光成分相乘即可。
ambient = Ia•Ka
計(jì)算環(huán)境光的代碼如下
GVector3 CDirectionalLight::EvalAmbient(const GVector3& _material_Ka)
{
return GVector3(m_Ka[0]*_material_Ka[0],
m_Ka[1]*_material_Ka[1],
m_Ka[2]*_material_Ka[2]);
}
漫反射的計(jì)算稍微比環(huán)境光復(fù)雜,漫反射的計(jì)算公式為
diffuse = Id•Kd• (N•L)
其中,Id是光源的漫反射成分,Kd是物體的漫反射系數(shù),N是法線,L是入射光向量。
GVector3 CDirectionalLight::EvalDiffuse(const GVector3& _N, const GVector3& _L, constGVector3& _material_Kd)
{
GVector3 IdKd = GVector3( m_Kd[0]*_material_Kd[0],
m_Kd[1]*_material_Kd[1],
m_Kd[2]*_material_Kd[2]);
double NdotL = MAX(_N*_L, 0.0);
return IdKd*NdotL;
}
鏡面反射的計(jì)算又比環(huán)境光要復(fù)雜,鏡面反射的計(jì)算公式為
specular = Is•Ks• (V·R)n
其中
R = 2(L•N) •N-L
Is是光源鏡面反射成分,Ks是物體的鏡面反射系數(shù),V是相機(jī)方向向量,R是反射向量,n就反射強(qiáng)度Shininess。為了提高計(jì)算效率,也可以利用HalfVector H來(lái)計(jì)算鏡面反射。
specular = Is•Ks• (N•H)n
其中
H=(L+V)/2
計(jì)算H要比計(jì)算反射向量R要快得多。
GVector3 CDirectionalLight::EvalSpecluar(const GVector3& _N, const GVector3& _L, constGVector3& _V,
const GVector3& _material_Ks,const double& _shininess)
{
GVector3 IsKs = GVector3( m_Ks[0]*_material_Ks[0],
m_Ks[1]*_material_Ks[1],
m_Ks[2]*_material_Ks[2]);
GVector3 H = (_L+_V).Normalize();
double NdotL = MAX(_N*_L, 0.0);
double NdotH = pow(MAX(_N*H, 0.0), _shininess);
if(NdotL<=0.0)
NdotH = 0.0;
return IsKs*NdotH;
}
分別計(jì)算出射線和物體交點(diǎn)處的環(huán)境光,漫反射和鏡面反射后,那么該射線對(duì)應(yīng)像素的顏色c為
C = ambient + diffuse + specular
于是,我們可以在代碼中添加一個(gè)方法叫Tracer(),該方法就是遍歷場(chǎng)景中的每個(gè)物體,判斷射線和物體的交點(diǎn),然后計(jì)算交點(diǎn)的顏色。
GVector3 Tracer(CRay R)
{
GVector3 color;
for(/*遍歷每一個(gè)物體*/)
{
if(/*如果有交點(diǎn)*/)
{
GVector3 p = R.getPoint(dist);
GVector3 N = m_pObj[k]->getNormal(p);
N.Normalize();
for(/*遍歷每一個(gè)光源*/)
{
GVector3 ambient = m_pLight[m]->EvalAmbient(m_pObj[k]->getKa());
GVector3 L = m_pLight[m]->getPosition()-p;
L.Normalize();
GVector3 diffuse = m_pLight[m]->EvalDiffuse(N, L, m_pObj[k]->getKd());
GVector3 V = m_CameraPosition - p;
V.Normalize();
GVector3 specular = m_pLight[m]->EvalSpecluar(N, L, V, m_pObj[k]->getKs(), m_pObj[k]->getShininess());
color = ambient + diffuse + specular;
}
}
}
}
如果要渲染可以反射周圍環(huán)境的物體,就需要稍微修改上面的Tracer()方法,因?yàn)榉瓷涫且粋€(gè)遞歸的過(guò)程,一但一條射線被物體反射,那么同樣的Tracer()方法就要被執(zhí)行一次來(lái)計(jì)算被反射光線和其他物體是否還有交點(diǎn)。于是,在Tracer()方法中再傳入一個(gè)代表遞歸迭代深度的參數(shù)depth,它表示射線與物體相交后反射的次數(shù),如果為1,說(shuō)明射線與物體相交后不反射,為2表示射線反射一次,以此類推。
Tracer(CRay R, int depth)
{
GVector3 color;
// 計(jì)算C = ambient + diffuse + specular
if(TotalTraceDepth == depth)
return color;
else
{
//計(jì)算射線和物體交點(diǎn)處的反射射線 Reflect;
GVector3 c = Tracer(Reflect, ++depth);
color += GVector3(color[0]*c[0],color[1]*c[1],color[2]*c[2]);
return color;
}
}
創(chuàng)建一個(gè)場(chǎng)景,然后執(zhí)行代碼,可以看到下面的效果。
Fig3 光線追蹤渲染的場(chǎng)景1
如果設(shè)置Tracer的遞歸深度大于2的話,就可以看到兩個(gè)球相互反射的情況。雖然這個(gè)光線追蹤可以正常的執(zhí)行,但是畫面看起來(lái)總覺(jué)得缺少點(diǎn)什么。仔細(xì)觀察你會(huì)發(fā)現(xiàn)畫面雖然有光源,但是物體沒(méi)有陰影,陰影可以增加場(chǎng)景的真實(shí)性。要計(jì)算陰影,我們應(yīng)該從光源的出發(fā),從光源出發(fā)的射線和物體如果有交點(diǎn),而且這條射線與多個(gè)物體相交,那么除第一個(gè)交點(diǎn)外的后面所有交點(diǎn)都處于陰影中,這點(diǎn)很容易理解。于是,我們需要修改部分代碼。
GVector3 Tracer(CRay R, int depth)
{
GVector3 color;
double shade = 1.0
for(/*遍歷每一個(gè)物體*/)
{
for(/*遍歷每一個(gè)光源*/)
{
GVector3 L = pObj[k]->getCenter() - Intersection;
double dist = norm(L);
L *= (1.0f / dist);
CRay r = CRay( Intersection,L );
for ( /*遍歷每一個(gè)物體*/ )
{
CGObject* pr = pObj[s];
if (pr->isIntersected(r, dist)!=MISS)
{
shade = 0;
break;
}
}
}
}
if(shade>0)
{
// 計(jì)算C = ambient + diffuse + specular
// 遞歸計(jì)算反射
}
return color*shade;
}
增加了陰影計(jì)算后,再運(yùn)行程序,就能看到下面的效果。
Fig4 光線追蹤渲染的場(chǎng)景2
最后我們也可以讓地面反射物體,然后再墻上添加很多小球,讓畫面變得復(fù)雜一些,如下圖。
Fig5 光線追蹤渲染的場(chǎng)景3
總結(jié)
這篇文章通過(guò)利用面向?qū)ο蟮姆椒▉?lái)實(shí)現(xiàn)了光線追蹤渲染場(chǎng)景。利用面向?qū)ο蟮姆椒▉?lái)實(shí)現(xiàn)光線追蹤使程序的擴(kuò)展性得到增強(qiáng),渲染復(fù)雜的場(chǎng)景或者復(fù)雜的幾何物體的時(shí)候,或者有很多光源和復(fù)雜光照計(jì)算的時(shí)候,只需要從基類繼承,然后利用多態(tài)性來(lái)實(shí)現(xiàn)不同物體的不同渲染方法。
從上面的類圖可以看到,利用面向?qū)ο?/strong>的方式可以很容易擴(kuò)展程序。而且,由于光線追蹤的這種結(jié)構(gòu),不論添加多少物體在場(chǎng)景中,不論物體多么復(fù)雜,這種結(jié)構(gòu)總能很好地渲染出正確的畫面。
但是,對(duì)光線追蹤來(lái)說(shuō),越復(fù)雜的場(chǎng)景需要的渲染時(shí)間越長(zhǎng)。有的時(shí)候渲染一幀的畫面甚至需要幾天的時(shí)間。所以好的算法和程序結(jié)構(gòu)對(duì)于光線追蹤來(lái)說(shuō)是很重要的,可以通過(guò)場(chǎng)景管理、使用GPU或CUDA等等技術(shù)來(lái)提高渲染效率。