細讀
《Ray tracing in one weekend》
,在我心中這本書可能是
入門圖形學
的最好方式,
作者PeterShirly
用C++語言加上一些簡單的初中物理知識,帶我們實現一個簡單的光線追蹤渲染器。這裡是我用
Java
復現的版本,以及記錄一些我的想法,已完工。每一章節工程程式碼已上傳至我的Github倉庫
有幫助的話求一個star,如果有意見和建議還請指出,感謝!
最後的最後實現的效果圖就是上方的題圖,其中所包含的所有實現細節(比如鏡面反射等)都會隨著章節的推進一一實現。文章中的貼出來的程式碼有所截選,請以Github倉庫裡的程式碼為準。
Chapter 0. 關於C++版本和Java版本執行時間的對比
在最前面這部分內容個,是因為評論區裡都是關於
C++和Java速度對比
的討論,當然這也確實是一個常年在討論的問題,這裡我做了測試對比。為了節省時間,測試條件都是
200*100的解析度
,每個畫素
100次取樣
。直接貼結果圖如下。C++的IDE是VS2017,Java的IDE是IDEA2018。
C++ 版本 Release下O3最佳化
Java版本執行時間
測試下的Java速度比C++稍微快一點點,其實是比較奇怪的,我打算最佳化下Cpp版本的code後面再測試一下~到時候繼續補充在這個Chapter裡。
Chapter 1. Output an image
這一章我們要了解
ppm影象檔案格式
自己畫出一張ppm圖
1。1 ppm格式檔案
PPM 是透過RGB三種顏色顯現的影象(pixmaps)。
PPM影象格式分為兩部分,分別為
頭部分
和
影象資料部分
。
頭部分:一共3部分,用換行或空格進行分割。
第1部分:P3或P6,指明PPM的編碼格式,這裡我們用P3。 第2部分:影象的寬度和高度,透過ASCII表示,這裡設定1980*1080。 第3部分:最大畫素值,0-255位元組表示,這裡設定255。
P3
1980 1080
255
影象資料部分: ASCII格式:按RGB的順序排列,RGB中間用空格隔開,圖片每一行用回車隔開。
//R G B
0 253 51
1 253 51
更多ppm格式介紹:
https://
blog。csdn。net/kinghzkin
gkkk/article/details/70226214
ppm格式的圖片使用的場景比較少,我們可以用XnView等軟體檢視,當然,我們也可以轉換ppm格式成jpg格式的圖片。下面是用ImageMagick實現的轉換例子(python語言),可以參考。
#ppm2jpg
import
PythonMagick
as
PM
img
=
PM
。
Image
(
r
“C:\Users\yh\Documents\CG\result\Chapter6_20_39_01。ppm”
)
img
。
write
(
r
“C:\Users\yh\Documents\CG\result\Cp6。jpg”
)
ppm2jpg:
https://
blog。csdn。net/y_jwei/ar
ticle/details/80502675
1。2 畫張ppm圖
寫一個我們最最最主要的Display類,我們最後顯示出來的結果(生成的圖片)都在這實現,Ch1裡我們先用Display類畫一張漸變圖。
程式碼見下方,透過兩個for迴圈,遍歷寬和高,最後繪畫整張圖。可以看到程式碼裡r變數從0到1,g是從1到0變化的。想象一下畫面應該是:從左往右紅色越來越深,從上到下綠色越來越淺,而藍色保持0。2*255灰階不變,結合RGB三種顏色可以想象下最後能得到什麼樣的影象?
FileWriter
fw
=
new
FileWriter
(
pictureName
);
//PPM格式頭部分
fw
。
write
(
“P3\n”
+
width
+
“ ”
+
height
+
“\n255\n”
);
int
index
=
0
;
//PPM格式影象資料部分
for
(
int
j
=
height
-
1
;
j
>=
0
;
j
——){
for
(
int
i
=
0
;
i
<
width
;
i
++){
float
r
=
(
float
)
i
/(
float
)
width
;
float
g
=
(
float
)
j
/(
float
)
height
;
float
b
=
0
。
2f
;
index
+=
1
;
int
ir
=
(
int
)(
255
。
59f
*
r
);
int
ig
=
(
int
)(
255
。
59f
*
g
);
int
ib
=
(
int
)(
255
。
59f
*
b
);
fw
。
write
(
ir
+
“ ”
+
ig
+
“ ”
+
ib
+
“\n”
);
if
(
index
%
100
==
0
){
cpb
。
show
(
index
);
}
}
}
fw
。
close
();
題外話:如果你看了我Github裡的工程檔案,你會發現我使用了一個在控制檯中即時顯示進度的小外掛,在後期渲染大圖的時候方便檢視渲染進度。
ConsoleProgress
cpb
=
new
ConsoleProgress
(
0
,
width
*
height
,
20
,
‘=’
);
最後我們寫個main呼叫一下Display類,解析度自己調整,得到我們繪製的一張圖。
public
class
RayTracing
{
public
static
void
main
(
String
[]
args
){
Display
TracingDisplay
=
new
Display
(
1980
,
1080
,
“Ray Tracer”
);
}
}
Ch1最後得到的結果:
Chapter 2. The vec3 class
這一章我們主要
寫一個Vec3類(它的作用將貫穿所有的章節)
2。1 Vec3類的作用
可以想象,我們會不斷和RGB這種三維資料打交道,因此我們要寫一個類來把RGB這樣的三維資料整合起來使用,表示一個顏色。但其實,這樣一個描述三維資料的類,只是用來表現顏色的話未免顯得太浪費了。仔細想想,這個類還可以用來表示一個向量的
三維向量
,也可以用來表示一個固定的
三維座標
,總之任意三維資料都可以表示。
2。2 Vec3類的基本操作
這個類也實現了一些Vec3類
基本操作
方法,包括以後會用到的
加(Add)、減(Subtract)、乘(Scale)、點乘(dot)、叉乘(cross)、按向量對應維度乘(Multiply)、求模(length)、歸一化(normalize)
。原著用到了C++過載運算子來實現這些操作,Java因為沒有運算子過載故用方法實現,舉個例子如下:
public
class
Vec3
{
//定義最基本的資料格式:三維的浮點型座標
public
float
[]
e
=
new
float
[
3
];
//建構函式
public
Vec3
()
{
}
public
Vec3
(
float
x
,
float
y
,
float
z
)
{
e
[
0
]
=
x
;
e
[
1
]
=
y
;
e
[
2
]
=
z
;
}
//向量求和
public
Vec3
Add
(
Vec3
v
)
{
return
new
Vec3
(
e
[
0
]
+
v
。
e
[
0
],
e
[
1
]
+
v
。
e
[
1
],
e
[
2
]
+
v
。
e
[
2
]);
}
//新增其他Vec3基本操作。。。
}
Chapter 3: Rays, a simple camera, and background
這一章我們才算正式開啟光線追蹤之路:
模擬一條光線
繪製天空(背景)
3。1 模擬一條光線
光是沿著直線傳播的,把光的運動想象成一條射線,那麼它就有兩個屬性射線的原點和射出的方向,因此我們設光線的方程式
其中A是光線的
源點
(是一個固定的三維座標),B是光線的發射
方向
(還是一個三維資料代表方向,按照向量的定義,光線的
方向
就是
終點座標減去起點座標
),t是時間,隨著t的變化,光線到達的位置不同。
我們寫一個光線類,來模擬上面的光線表示式p(t),其實主要就是
記錄一下源點和方向
,此外還要寫一個方法叫point_at_parameter,返回t時刻下光線的座標位置。
public
class
Ray
{
public
Vec3
o
;
//源點
public
Vec3
d
;
//方向
public
Ray
()
{
}
public
Ray
(
Vec3
origin
,
Vec3
direction
)
{
o
=
origin
;
d
=
direction
;
}
public
Vec3
origin
()
{
return
o
;
}
public
Vec3
direction
()
{
return
d
;
}
//p(t)=A+t*B 即返回t時刻光線的位置
public
Vec3
point_at_parameter
(
float
t
)
{
return
o
。
Add
(
d
。
Scale
(
t
));
}
}
3。2 繪製天空(背景)
我們這次要用光線追蹤
渲染
一張圖,這和Ch1裡直接畫出來的圖是有一點區別的。
為了簡單起見,我們以(0,0,0)為
原點
,模擬我們
人眼
(也可以叫相機)來看世界。順便建立一個右手直角座標系,這樣整個世界就固定下來了。但是,畢竟人眼看到的
範圍
也是有限的,相機拍的照片大小也是有限的,所以我們把它控制在一個框內(畫布),如圖所示(右邊的矩形),畫布的位置在Z=-1的平面上,並且寬高固定的,這裡我們把
寬(horizontal)
設為4,
高(vertical)
設為2。
光線照射在畫布上,交點座標可以用一個
二維座標(u, v)
來表示。怎麼表示?記左下角點為lower_left,它的座標是(-2,-1,-1)。任一交點座標可以表示為
horizotal是畫布長,vertical是畫布高。u和v是什麼呢?u是離lower_left點的橫向距離變數,v是離lower_left點的縱向距離變數,上圖已經詳細標明。並且除了u和v兩個變數以外,其他值都是固定的座標值。u和v這兩個變數唯一標識了畫布上的某一點,u和v的取值範圍都是[0,1),我們透過u和v的取值就可以遍歷整個畫布,並且u和v越精細,那麼圖畫就越精細(因為畫素的顏色越趨近於它原來該存在的位置)。
private
Vec3
lower_left
=
new
Vec3
(-
2
。
0f
,
-
1
。
0f
,
-
1
。
0f
);
private
Vec3
horizontal
=
new
Vec3
(
4
。
0f
,
0
。
0f
,
0
。
0f
);
private
Vec3
vertical
=
new
Vec3
(
0
。
0f
,
2
。
0f
,
0
。
0f
);
private
Vec3
origin
=
new
Vec3
(
0
。
0f
,
0
。
0f
,
0
。
0f
);
因為起點是(0,0,0),所以交點的向量也就是光線的方向向量。用上面的Ray類表示就是Ray(Vec3(0,0,0),
Vec3(畫布上的交點)-Vec3(0,0,0)
)。
發現了什麼?只要我們遍歷u和v,我們就能
把整個畫布用光線鋪滿
!而只要u和v越精細,整張圖的畫質就越高!
所以,我們要修改Display類了,我們不再單純地按畫素位置著色,而是對畫布上的每個畫素點都想象成是一束光投影到畫布上,然後返回那一點在畫布上的顏色。所以我們在遍歷畫布的時候,對每個畫素應該顯示的顏色都要經過計算,所以我們對每個畫素點都相當於new一個Ray物件,模擬每條光束,並
添加了一個color方法,每一個Ray物件都要經過color方法計算顯示的顏色,最後顯示出來。
因為目前的光線都是沒有阻擋的,直接投射到畫布上,所以想象一下,各畫素點應該是沒什麼區別的。不過為了模擬天空背景,我們在color方法裡我們做了個沿y軸的
線性插值
,讓我們的畫布看起來是藍色和白色的漸變。
try
{
FileWriter
fw
=
new
FileWriter
(
pictureName
);
fw
。
write
(
“P3\n”
+
width
+
“ ”
+
height
+
“\n255\n”
);
int
index
=
0
;
for
(
int
j
=
height
-
1
;
j
>=
0
;
j
——){
for
(
int
i
=
0
;
i
<
width
;
i
++){
float
u
=
(
float
)
i
/(
float
)
width
;
float
v
=
(
float
)
j
/(
float
)
height
;
Ray
r
=
new
Ray
(
origin
,
lower_left
。
Add
(
horizontal
。
Scale
(
u
))。
Add
(
vertical
。
Scale
(
v
)));
//模擬一條光線
Vec3
col
=
color
(
r
);
//根據每條光線(每個畫素點)上色
index
+=
1
;
int
ir
=
(
int
)(
255
。
59f
*
col
。
x
());
int
ig
=
(
int
)(
255
。
59f
*
col
。
y
());
int
ib
=
(
int
)(
255
。
59f
*
col
。
z
());
fw
。
write
(
ir
+
“ ”
+
ig
+
“ ”
+
ib
+
“\n”
);
if
(
index
%
100
==
0
){
cpb
。
show
(
index
);
}
}
}
fw
。
close
();
}
catch
(
Exception
e
){
System
。
out
。
println
(
“GG!”
);
}
public
Vec3
color
(
Ray
r
)
{
Vec3
unit_dir
=
r
。
direction
()。
normalize
();
//單位光線的方向 並歸一化
float
t
=
0
。
5f
*
(
unit_dir
。
y
()
+
1
。
0f
);
//取出y的座標值,原本範圍為[-1,1]調整為[0,1]
return
new
Vec3
(
1
。
0f
,
1
。
0f
,
1
。
0f
)。
Scale
(
1
。
0f
-
t
)。
Add
(
new
Vec3
(
0
。
5f
,
0
。
7f
,
1
。
0f
)。
Scale
(
t
));
//返回背景(1。0-t)*vec3(1。0, 1。0, 1。0) + t*vec3(0。5, 0。7, 1。0);
//沿著y軸線性插值,所以返回的顏色介於白色與天藍色之間,即模擬天空的顏色
}
執行結果如下:
Chapter 4: Adding a sphere
本章只有一個目標,思考下面的情況:
光線碰撞到障礙物
4。1 判斷光線是否碰撞障礙物的數學推導
光線不能總是無阻礙的傳播,遇到障礙物的時候,光線就會被攔截,我們設定的第一個障礙物就是個球體。光線和球體的相互關係,只有如下三種情況(假設光線能穿過物體)。
設球的球心是C(cx,cy,cz),半徑是R,球的方程式是
仔細看,左式完全可以
寫成是向量內積的形式
。也就是一個向量=(x,y,z)的點-球心座標,然後這個向量自己的內積就是左式。
我們要求的是光線和球的交點,所以我們把光線表示式帶入球的方程式。設p(x,y,z),結合p和C,上式不就可寫成這樣了嗎:
把光線的方程p(t)=A+t*B帶入
以t看做未知數,整理後如下:是一個二元一次方程
根據
求根公式
可知,如果
說明和光線和球有交點,如果
說明光線和球沒有交點。
4。2 光線碰撞障礙物的程式碼實現
我們先寫一個hitSphere方法,返回光是否碰撞到障礙物。透過上述推導的公式計算出discriminant即判別式,即可實現這個功能。
/**
*
* @param center 球的圓心
* @param radius 球的半徑
* @param r 光線
* @return 光線是否碰到球
*/
public
boolean
hitSphere
(
final
Vec3
center
,
float
radius
,
final
Ray
r
)
{
Vec3
oc
=
r
。
origin
()。
Subtract
(
center
);
//oc = A-C
float
a
=
r
。
direction
()。
dot
(
r
。
direction
());
//a = B·B
float
b
=
2
。
0f
*
oc
。
dot
(
r
。
direction
());
//b = 2B·oc
float
c
=
oc
。
dot
(
oc
)
-
radius
*
radius
;
//c = oc^2 - R^2
float
discriminant
=
b
*
b
-
4
*
a
*
c
;
if
(
discriminant
<
0
)
{
return
false
;
}
else
{
return
true
;
}
}
然後修改color方法,我們設定:球的圓心位置在(0,0,-1),半徑為0。5。如果光碰到了那個球,就返回顏色(0,0,1)即藍色。否則返回上一章畫出來的天空背景。
/**
*
* @param r 光線
* @return 光線代表畫素的顏色
*/
public
Vec3
color
(
Ray
r
)
{
if
(
hitSphere
(
new
Vec3
(
0
,
0
,-
1
),
0
。
5f
,
r
)){
return
new
Vec3
(
0
,
0
,
1
);
}
else
{
Vec3
unit_dir
=
r
。
direction
()。
normalize
();
//單位方向向量
float
t
=
0
。
5f
*
(
unit_dir
。
y
()
+
1
。
0f
);
//原本範圍為[-1,1]調整為[0,1]
return
new
Vec3
(
1
。
0f
,
1
。
0f
,
1
。
0f
)。
Scale
(
1
。
0f
-
t
)。
Add
(
new
Vec3
(
0
。
5f
,
0
。
7f
,
1
。
0f
)。
Scale
(
t
));
//返回背景(1。0-t)*vec3(1。0, 1。0, 1。0) + t*vec3(0。5, 0。7, 1。0); 沿著y軸線性插值,返回的顏色介於白色與天藍色之間
}
}
Ch4最後顯示如圖:
Chapter 5: Surface normals and multiple objects.
這一章主要要實現
球體顏色漸變
多個球體共存
5。1 球體顏色最佳化
上圖的藍球實在是太醜了,看起來就是個圓,根本
看不出來是個球
好嗎?我們做個簡單的最佳化,我們要根據位置的不同,顯示出不同的顏色,比如不如用
法向量方向座標
來顯示成顏色?
首先hitable方法需要改變,返回值本來是boolean改成了float,因為我們不止是判斷是否碰撞了,我們還要拿到碰撞時的點。這裡返回的是第一個碰到的點的t值。
public
float
hitSphere
(
final
Vec3
center
,
float
radius
,
final
Ray
r
)
{
Vec3
oc
=
r
。
origin
()。
Subtract
(
center
);
//oc = A-C
float
a
=
r
。
direction
()。
dot
(
r
。
direction
());
//a = B·B
float
b
=
2
。
0f
*
oc
。
dot
(
r
。
direction
());
//b = 2B·oc
float
c
=
oc
。
dot
(
oc
)
-
radius
*
radius
;
//c = oc^2 - R^2
float
discriminant
=
b
*
b
-
4
*
a
*
c
;
if
(
discriminant
<
0
)
{
return
-
1
。
0f
;
}
else
{
return
(-
b
-
(
float
)
Math
。
sqrt
(
discriminant
))
/
(
2
。
0f
*
a
);
}
}
同時,color方法也得更改,只要t>0,就說明有撞點,我們求一下法向量方向,根據向量公式:
交點的座標P - 圓心座標C = 法向量(P-C)
,具體實現如下。
public
Vec3
color
(
Ray
r
)
{
float
t
=
hitSphere
(
new
Vec3
(
0
,
0
,-
1
),
0
。
5f
,
r
);
if
(
t
>
0
。
0
){
//t時刻(即交點)的座標 - 圓心座標 = 法向量 (並做了歸一化)
Vec3
N
=
r
。
point_at_parameter
(
t
)。
Subtract
(
new
Vec3
(
0
,
0
,
-
1
))。
normalize
();
return
new
Vec3
(
N
。
x
()+
1
,
N
。
y
()+
1
,
N
。
z
()+
1
)。
Scale
(
0
。
5f
);
}
//如果沒撞點就返回天空背景
Vec3
unit_dir
=
r
。
direction
()。
normalize
();
//單位方向向量
t
=
0
。
5f
*
(
unit_dir
。
y
()
+
1
。
0f
);
//原本範圍為[-1,1]調整為[0,1]
return
new
Vec3
(
1
。
0f
,
1
。
0f
,
1
。
0f
)。
Scale
(
1
。
0f
-
t
)。
Add
(
new
Vec3
(
0
。
5f
,
0
。
7f
,
1
。
0f
)。
Scale
(
t
));
//返回背景(1。0-t)*vec3(1。0, 1。0, 1。0) + t*vec3(0。5, 0。7, 1。0); 沿著y軸線性插值,返回的顏色介於白色與天藍色之間
}
實現效果如圖:
5。2 多個球體同時出現
對於多個球的情況,我們不可能去對每一個球單獨描述,我們當然是考慮面向物件的思想。寫個Hitable的抽象類,它代表著碰撞物,其中碰撞物可以是球,可以是方塊等等,後面我們例項的物體都要繼承它,並且必須重寫hit方法。
public
abstract
class
Hitable
{
public
abstract
boolean
hit
(
final
Ray
r
,
float
t_min
,
float
t_max
,
HitRecord
rec
);
}
注意這裡的引數,除了打架熟悉的Ray,還有
兩個t,一個HitRecord
。其中t
min和t
max是限定一個時間區間,也就是說在這個區間內發生的碰撞才是有效的。然後這個HitRecord是碰撞點的資訊,我們把有關碰撞點的資訊也封裝起來成一個單獨的類。
public
class
HitRecord
{
public
float
t
;
//相撞的時間
public
Vec3
p
;
//撞擊點的座標
public
Vec3
normal
;
//撞擊點的法向量
public
HitRecord
()
{
t
=
0
;
p
=
new
Vec3
(
0
,
0
,
0
);
normal
=
new
Vec3
(
0
,
0
,
0
);
}
}
然後,按計劃,當然是寫個球體類繼承這個Hitable,並
重寫hit方法
。首先當然還是判斷是否碰撞,如果t>0則代表相撞,根據求根公式算出碰撞時間t,如果t在時間範圍內,說明是有效碰撞,記錄下一些碰撞資訊。因為一般求根公式有兩個解,如果第一個點是有效碰撞,則不需要球第二個,因為第二個點更遠會被第一個點覆蓋。
public
class
Sphere
extends
Hitable
{
Vec3
center
;
float
radius
;
public
Sphere
()
{
}
public
Sphere
(
Vec3
center
,
float
radius
)
{
this
。
center
=
center
;
this
。
radius
=
radius
;
}
/**
* 判斷與球體是否有撞擊
* @param r 光線
* @param t_min 範圍
* @param t_max 範圍
* @param rec 撞擊點
* @return 是否有撞擊
*/
@Override
public
boolean
hit
(
Ray
r
,
float
t_min
,
float
t_max
,
HitRecord
rec
)
{
Vec3
oc
=
r
。
origin
()。
Subtract
(
center
);
float
a
=
r
。
direction
()。
dot
(
r
。
direction
());
float
b
=
2
*
oc
。
dot
(
r
。
direction
());
float
c
=
oc
。
dot
(
oc
)
-
radius
*
radius
;
float
discriminant
=
b
*
b
-
4
。
0f
*
a
*
c
;
if
(
discriminant
>
0
)
{
//優先選取符合範圍的根較小的撞擊點,若沒有再選取另一個根
float
discFactor
=
(
float
)
Math
。
sqrt
(
discriminant
);
float
temp
=
(-
b
-
discFactor
)
/
(
2
。
0f
*
a
);
if
(
temp
<
t_max
&&
temp
>
t_min
)
{
rec
。
t
=
temp
;
rec
。
p
=
r
。
point_at_parameter
(
rec
。
t
);
rec
。
normal
=
(
rec
。
p
。
Subtract
(
center
))。
Scale
(
1
。
0f
/
radius
);
return
true
;
}
temp
=
(-
b
+
discFactor
)
/
(
2
。
0f
*
a
);
if
(
temp
<
t_max
&&
temp
>
t_min
)
{
rec
。
t
=
temp
;
rec
。
p
=
r
。
point_at_parameter
(
rec
。
t
);
rec
。
normal
=
(
rec
。
p
。
Subtract
(
center
))。
Scale
(
1
。
0f
/
radius
);
return
true
;
}
}
return
false
;
}
}
但是,但是,但是我們這次明明要解決的是多個球體共存的場面,因此還不夠。我們怎麼解決這個問題呢?我們再寫一個類,把對所有的球的判斷封裝起來,
其實也就是一個for迴圈的事罷了,每一條光線都會遍歷所有的球判斷是否有交點
(當然這是個比較低效,並且蠢的寫法,後面第二本書作者提到了
層次包圍盒
就是最佳化這個的)。寫個HitableList類,仍是繼承Hitable類。這裡有個closestSoFar要注意下,是這樣的,對於一條光線來說,如果有更近的碰撞點出現的話,那麼遠的碰撞點是要捨棄的。我們可以想象,很多球出現的時候,可能一條光射過去,會有很多球與光線相交,但作為人眼,我們只能看到前面的那個。所以,我們維護closestSoFar這個變數,每次有新撞擊點(更近的)記錄下來,就會更新它。
public
class
HitableList
extends
Hitable
{
List
<
Hitable
>
list
;
public
HitableList
()
{
list
=
new
ArrayList
<
Hitable
>();
}
public
HitableList
(
List
<
Hitable
>
list
)
{
this
。
list
=
list
;
}
/**
* 判斷列表裡的任一個球是否撞擊
* @param r 光線
* @param t_min 範圍
* @param t_max 範圍
* @param rec 撞擊點
* @return 任意一個球是否撞擊
*/
@Override
public
boolean
hit
(
Ray
r
,
float
t_min
,
float
t_max
,
HitRecord
rec
)
{
HitRecord
tempRec
=
new
HitRecord
();
boolean
hitAnything
=
false
;
float
closestSoFar
=
t_max
;
for
(
int
i
=
0
;
i
<
list
。
size
();
i
++)
{
if
(
list
。
get
(
i
)。
hit
(
r
,
t_min
,
closestSoFar
,
tempRec
))
{
hitAnything
=
true
;
closestSoFar
=
tempRec
。
t
;
//每當有撞擊點,tmax就會減為最近的一次撞擊點
rec
。
t
=
tempRec
。
t
;
rec
。
normal
=
tempRec
。
normal
;
rec
。
p
=
tempRec
。
p
;
}
}
return
hitAnything
;
}
}
至此,我們的準備工作都做好了。可以開始使用上述新寫的類開始實現多個球共存下的光線追蹤了。如下,我們自己弄3個球出來,並串成一個HitableList。
//多個球體的資訊
List
<
Hitable
>
objList
=
new
ArrayList
<
Hitable
>();
objList
。
add
(
new
Sphere
(
new
Vec3
(
0
。
0f
,
0
。
0f
,-
1
。
0f
),
0
。
5f
));
objList
。
add
(
new
Sphere
(
new
Vec3
(
0
。
3f
,
0
。
0f
,-
1
。
0f
),
0
。
3f
));
objList
。
add
(
new
Sphere
(
new
Vec3
(
0
。
0f
,-
100
。
5f
,-
1
。
0f
),
100f
));
world
=
new
HitableList
(
objList
);
然後,修改color方法。判斷整個List裡是否有任意一個球發生了碰撞。如果有就返回法向量代表的顏色。
public
Vec3
color
(
Ray
r
)
{
HitRecord
rec
=
new
HitRecord
();
if
(
world
。
hit
(
r
,
0
。
0f
,
Float
。
MAX_VALUE
,
rec
)){
//有撞擊點,按撞擊點法向量代表的顏色繪製
return
new
Vec3
(
rec
。
normal
。
x
()+
1
,
rec
。
normal
。
y
()+
1
,
rec
。
normal
。
z
()+
1
)。
Scale
(
0
。
5f
);
}
else
{
//沒有撞擊點,繪製背景
Vec3
unit_dir
=
r
。
direction
()。
normalize
();
//單位方向向量
float
t
=
0
。
5f
*
(
unit_dir
。
y
()
+
1
。
0f
);
//原本範圍為[-1,1]調整為[0,1]
return
new
Vec3
(
1
。
0f
,
1
。
0f
,
1
。
0f
)。
Scale
(
1
。
0f
-
t
)。
Add
(
new
Vec3
(
0
。
5f
,
0
。
7f
,
1
。
0f
)。
Scale
(
t
));
//返回背景(1。0-t)*vec3(1。0, 1。0, 1。0) + t*vec3(0。5, 0。7, 1。0); 沿著y軸線性插值,返回的顏色介於白色與天藍色之間
}
}
發現了嗎,地面其實是一個半徑為100的大球,它之所以是綠色的原因是,它上方的法向量幾乎都是保持(0,1,0)這樣的,所以對應起來就顯示的是綠色。
Chapter 6: Antialiasing
這一章兩個小目標~
寫個Camera類,把和相機以及光線有關的資訊封裝起來
抗鋸齒的實現
6。1 寫一個Camera類
如果我們把第五章的output圖點開,不斷放大,會發現物體的交界處是有明顯的鋸齒的。比如球的下面部分。
作為精緻的圖形學男孩一定要最佳化這個抗鋸齒,但是我們先來做下準備工作:寫一個Camera類,
封裝一下有關Camera的資訊
,方便以後使用,都是我們之前設定好的資訊。
public
class
Camera
{
private
Vec3
lower_left
;
//畫布左下角點
private
Vec3
horizontal
;
//寬
private
Vec3
vertical
;
//高
private
Vec3
origin
;
//相機原點
public
Camera
()
{
lower_left
=
new
Vec3
(-
2
。
0f
,
-
1
。
0f
,
-
1
。
0f
);
horizontal
=
new
Vec3
(
4
。
0f
,
0
。
0f
,
0
。
0f
);
vertical
=
new
Vec3
(
0
。
0f
,
2
。
0f
,
0
。
0f
);
origin
=
new
Vec3
(
0
。
0f
,
0
。
0f
,
0
。
0f
);
}
/**
*
* @param u 距離lower_left的橫向距離
* @param v 距離lower_left的縱向距離
* @return 光線向量
*/
public
Ray
GetRay
(
float
u
,
float
v
)
{
return
new
Ray
(
origin
,
lower_left
。
Add
(
horizontal
。
Scale
(
u
))。
Add
(
vertical
。
Scale
(
v
)));
}
}
6。2 抗鋸齒的實現
抗鋸齒的原理其實很簡單,我們先設定一個取樣次數,比如100,意思就是同一個點取樣100次,當然後面要再除以100,這樣我們獲得的最後的值就是這
100次取樣後得出的平均值
。然後在之前u和v上都新增一個[0,1)的
隨機數
,讓100次取樣的點不是固定一點,而是取原點附近的一個隨機的點(由於Math。random()的範圍限制,這裡其實只是取原來點右上角小正方形內的隨機點,但效果其實已經很不錯了),用來代替原來的點。這樣取樣100次後,得出的點在邊界上會明顯平滑。實際上,最好是把取樣的範圍拉成如下圖所示的範圍(即原來點往外擴散成一個正方形),聰明的你知道該怎麼改嗎?
int
ns
=
100
;
//取樣次數 消鋸齒
for
(
int
j
=
height
-
1
;
j
>=
0
;
j
——){
for
(
int
i
=
0
;
i
<
width
;
i
++){
Vec3
col
=
new
Vec3
(
0
,
0
,
0
);
//初始化該點的畫素
for
(
int
s
=
0
;
s
<
ns
;
s
++){
float
u
=
(
float
)(
i
+
Math
。
random
())/(
float
)
width
;
//新增隨機數 消鋸齒
float
v
=
(
float
)(
j
+
Math
。
random
())/(
float
)
height
;
Ray
r
=
camera
。
GetRay
(
u
,
v
);
//根據uv得出光線向量
col
=
col
。
Add
(
color
(
r
,
world
));
//根據每個畫素點上色 累加
}
col
=
col
。
Scale
(
1
。
0f
/(
float
)
ns
);
//除以取樣次數 求平均
int
ir
=
(
int
)(
255
。
59f
*
col
。
x
());
int
ig
=
(
int
)(
255
。
59f
*
col
。
y
());
int
ib
=
(
int
)(
255
。
59f
*
col
。
z
());
fw
。
write
(
ir
+
“ ”
+
ig
+
“ ”
+
ib
+
“\n”
);
}
}
看效果,還是很明顯的:
Chapter 7: Diffuse Materials
從這一章開始,我們終於能渲染出好看點的圖片了,也就是有點貼近真實的水平了,這一章講的是
漫反射。
日常生活中,有的物體表面是粗糙的,光線照射過去的時候,將按照不規則的方向反射,如下圖所示。
下面
見下圖
,我們現在就是要
模擬漫反射光線的路線
。設我們入射光線是V,我們先忽略不看上面的那個小球(那個小球是我們虛擬出來的),V射到了如下的P點上,假設它隨機反射的光線是射線S方向。那我們怎麼模擬這個方向PS呢,我們假設一個單位球(就是那個小球)是與大球相切的,切點是P。在這個小球裡,我們任取一點,作為反射的方向,用來貼近真實世界漫反射的方向機率。
這兩個紅色的箭頭是光漫反射的路徑
那我們來寫一下這個
隨機點的生成
,寫成一個方法需要的時候直接呼叫即可,我們用一個三維隨機數可以生成一個
單位立方體
,但是這樣生成的點並不都滿足在一個
單位球
內,所以我們要判斷一下,如果不在球內的話就迴圈再隨機一次,直到那個隨機點是球內的隨機一點。(這裡隨機數範圍調整了一下成[-1,1],大家應該都看的懂哈~)
public
Vec3
randomInUnitSphere
(){
Vec3
p
;
do
{
p
=
new
Vec3
((
float
)(
Math
。
random
()),
(
float
)(
Math
。
random
()),
(
float
)(
Math
。
random
()))。
Scale
(
2
。
0f
)。
Subtract
(
new
Vec3
(
1
。
0f
,
1
。
0f
,
1
。
0f
));
}
while
(
p
。
dot
(
p
)
>=
1
。
0f
);
return
p
;
}
我們上面這個方法得出的三維向量是基於原點C到S的方向,即向量CS,但我們真正要的是向量PS,所以我們可以用
向量相加
的方式,即PS=PC+CS,PC是碰撞點P點的法向量,我們之前已經存在HitRecord了,直接獲得即可~
也可以用我下面寫的方法,直接算出S點的座標,P點的座標我們也可以在HitRecord獲得,再加上單位法向量,再加上剛才求得相對隨機點,得到S座標,這裡用target表示。
然後,我們調整下color方法,如果碰到了障礙物的話,算出
新的反射光線
,新光線的原點是P,方向是PS,然後新光線又可能再次發生反射,我們假設每次反射會吸收50%的能量,對新光線返回的顏色乘以係數0。5,這樣遞迴,直到最後的光線射到天空背景上。
HitRecord
rec
=
new
HitRecord
();
//如果有撞擊點
if
(
world
。
hit
(
r
,
0
。
0f
,
Float
。
MAX_VALUE
,
rec
)){
//求出反射方向的座標
Vec3
target
=
rec
。
p
。
Add
(
rec
。
normal
)。
Add
(
randomInUnitSphere
());
//遞迴,每次吸收50%的能量
return
color
(
new
Ray
(
rec
。
p
,
target
。
Subtract
(
rec
。
p
)),
world
)。
Scale
(
0
。
5f
);
}
我們想象一條光,經過k次漫反射,最後投射到背景上,抽象出color的計算式,最後得到的大概是這樣:
也就是說,
物體的顏色其實是和背景有關的
,有點背景烘托物體的感覺,不然你試著把背景顏色改成紅色(渲染出來還有點恐怖呢~)。如果經過的漫反射次數太多會發生什麼呢,會使color返回的數趨向於0,最後顯示成
黑色的陰影
!
哦對了,這一章開始作者還進行了一個gamma最佳化處理。我們不是透過color函式返回了每個畫素(光線)的顏色,即得到了畫素RGB比例值嗎?返回的值是[0,1]之間的資料,還要乘以255才是最後的RGB值。作者進行了一個操作,把這些畫素值,進行了一個開根號的處理,因為本來的值是小於1的,開根號以後反而變大了,因此最後的畫素值會更白(畫素趨於0是黑色,趨於255是白色),因此我們經過這個gamma最佳化後,整體效果會變的更明亮。
//gamma矯正
col
=
new
Vec3
((
float
)
Math
。
sqrt
(
col
。
x
()),
(
float
)
Math
。
sqrt
(
col
。
y
()),
(
float
)
Math
。
sqrt
(
col
。
z
()));
如果沒有進行這個gamma最佳化處理的話,得出來的整個圖片是比較暗的~
這一章極其優雅結果圖如下:
好了,本篇文章到此為止,已經達到專欄文章字數上限了,下半部分請見: