Write Your Own 3D Engine (5) Chapter 5. 光線

光線造就了物體的明暗,這章要講的就是如何處理與呈現光線明暗的問題。

向量

P 點為 (x1, y1, z1),Q 點為 (x2, y2, z2),從 P 到 Q 的向量 (vector) 為 ( x2 – x1, y2 – y1, z2 – z1 )。

向量的長度

假設向量 A 是 ( x, y, z ) 其長度 (Magnitude) 為 sqrt( x * x + y * y + z * z ),也就是三角形斜邊的公式,數學式寫做 | A |。

向量的內積

二個向量 A (x1, y1, z1)  和B (x2, y2, z2) 的內積 (dot product) 其定義為 x1 * y1 + x2 * y2, x3 + y3 ),數學式寫做 A  B。

向量間的角度

二個向量間的角度可以用下面的公式算出來  |A| |B| cosθ = A  B 。

向量的正常化

向量的正常化 (vector normalization) 也就是把向量的 x, y, z 各除以向量長度,使其方向性不變的前題下,將長度改為 1。你說為什麼要把向量正常化? 很多好處,像是算上面的 cosθ 時就不用再用開根號算 |A| 和 |B| 了。

向量的外積

向量 A (x1, y1, z1) 和向量 B (x2, y2, z2) 的外積為 ( y1z2 – z1y2, z1x2 – x1z2, x1y2 – y1x2 ),數學式寫做 A x B。
在三維向量的意義如下圖,會產生一個同時和 A 和 B 向量垂直的 C 向量,C 向量朝上或朝下是由右手定則決定的。

光源和明暗的關係

以下圖為例,紅色的箭頭是平面的垂直向量、也就是 “法線” (normal)、可以用平面上任何三個點的向量外積算出來。藍色是從平面向量起點到光源的向量,可以用二點相減算出來。當二個向量的 θ 為 0 時光源最強。而 θ 為 90 度時光源最弱。換成 cosθ 就是 1 為最強,小於等於 0 為最弱。

一個由三角形構成的物件,計算法線時如何確保都是指向物件外部?
上面寫 “法線可以由平面上任三點的向量外積算出來” 其實並不完全正確,對外積來說,A x B 和 B x A 的結果正好相差 180 度。對計算光源來說,指向物件內部的法線是沒有意義的。下圖是那隻猴子的法線,當然,通通是向外的。

 

那假設我們做了一個 3D 物件,要怎麼確保計算出來的所有法線都是朝外的呢?

我們先簡化問題,請參考下圖,假設有相鄰的 △ABC 和 △ACD,如果我們已知道 △ABD 的法線 (紅色向量),那有辦法算出和 △ABD 法線同一側的 △ ACD 的法線嗎?

是的,我們算得出來。

首先,因為右手定則,我們知道 (向量AC) x (向量AB) 跟 (向量AC) X (向量AD) 算出來的法線是不同側的。

我們先計算(向量AC) x (向量AB),再比較已知的紅色法線。如果二條向量的夾角 θ = 0,那表示 △ ACD 的法線是 (向量AD) X (向量AC)。如果夾角 θ = 180度,那△ ACD 的法線是(向量AC) X (向量AD)。

在實務上,要確保一個物件上所有的三角形法線都是朝外,需要先用人工指定某一個三角形的法線方向,然後讓演算法將所有相鄰的三角形的法線算出來,再繼續遞迴算出整個物件的法線。

當然,這些事情不是 run time 計算的,是事前就算好了的 (像是交給 Blender 算好匯出成 JSON 格式之類的 (笑))。

拿實際的例子,假設有下面四個點為一個三角錐的四個頂點。

  • 點 A ( 0, 0, 0 )
  • 點 B ( 1, 0, 0 )
  • 點 C ( 0, 1, 0 )
  • 點 D ( 0, 0, 1 )
已知 △ABC 的向外的法線是 ( 0, 0, -1 )。
對 △ABD,AB x AC = ( 0, 0, 1 ) 和 △ABC 法線相反,因此 △ABD 法線是 AB x AD = (0, -1, 0 )
對 △ACD,AC x AB = ( 0, 0, -1) 和 △ABC 法線相同,因此 △ACD 法線 AD x AC = (-1, 0, 0 )
對 △BCD,BC x BA = (0, 0, 1) 和 △ABC 法線相反,因此 △BCD 的法線 BC x BD = (1, 1, 1)
這個 blog 的重點並沒有擺在如何做個像 Blender 一樣的協助建立 3D 模組的軟體,所以我就不寫上面演算法的程式碼了。
利用四個點和四個面的法線可以做出下面的效果。

程式請參考 unittest.cpp 的 Sample_4。

Z Buffer

上面的 Sample_4 有出現 z buffer 一詞,當二個點的 X 和 Y 值一樣,只有 Z 不同時,我們只畫 Z 軸比較大的,Z Buffer 就是由這個概念來的。我們為每個營幕上的像素準備一個整數的 buffer,裏面存放的是目前繪置的點的 Z 軸的值。當有點要畫在同樣的地方時,我們比較新點的 Z 值和 Z Buffer 裏的 Z 值來判對是要覆蓋舊的點、或是要忽略新的點。這樣的機制就叫 Z Buffer。

Flat Shading

上面的三角錐的繪圖裏,同一個面上的所有點都畫同樣的顏色,而這個顏色是由面的中央法線和光源的夾角決定的,這種光線處理法叫 Flat Shading。我沒有寫這部份的 Sample Code,但是這可以很簡單的用修改的 Gourand Shading 或 Phong Shading 的程式做出來 (下面會說明) 。

Gourand Shading

不同於 Flat Shading,整個面都用同樣的顏色,Gourand 會先把三個頂點的顏色分別算出來,然後用內差法用漸層色塗滿整個三角形。
如果是單一個三角形,三個頂點的法線向量是一樣的。但是三個頂點的位置不同,所以頂點到光源的向量值就不同,因此還是會得到三個不同的顏色。
如果三角形的頂點同時也是其他三角形的頂點,那我們算該頂點的法線時是用內積算出同一點上所有法線的平均值。
打開上一章的 monkey.json,會發現裏面有個 array 叫  “normals”,這其實就是那隻猴子的所有頂點的法線向量。跟 “vertices” array 是相互對應的,每三個 vertices 的 X, Y, Z 值都對應到三個 “normals” 的值。所以不用算,直接拿來用就好。

有了三個頂點的法線值,就可以算出與光源的角度,就可以算出三個頂點的顏色。角度換算顏色的算法有簡單的和麻煩的,簡單是 RGB 各乘的 cosine 值就好。麻煩的要先從 RGB 轉 HSL,調整 L (Lightness) 值,再轉回 RGB。以下是簡單的。

if ( cos > 0 )
{      
  buf[anchor] = (int)(255 * cos);
  buf[anchor + 1] = (int)(255 * cos);
  buf[anchor + 2] = (int)(255 * cos);
}

最後再用內插法將三頂點的顏色漸層到整個三角型的每一個像素就可以了

Phong Shading

Phong 和 Gourand 的差別是 Gourand 是算出三角型的三個頂點的顏色,然後漸層到整個三角型。Phong 是算出三個頂點的法線向量,然後漸層法線向量到三角型內每一個像素。

跟 Gourand 比起來,Phong 需要大量的運算,對運算的要求更高,當然看起來比 Gourand 更為自然。Sample_5 是有些許偷懶的 Phong Shading 的實作,偷懶的地方是我漸層的是每個像素對光源的夾角 cosine 值,而且是用類似 Bresemham Line’s Algorithm 的方法用整數算出來的,畫面效果如下。

野生的旋轉的大理石猴子出現了! (笑)

如果你發現你的程式跑不太動了,別忘了從 Debug 換成 Release 啊。開 Debug 還跑得動這隻猴子的電腦應該不便宜。(笑)