vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

這套教材的程式碼「about 500 lines」,唯一的外部依賴是一個處理 TGA 影像的類別——其餘從畫一條線開始,一路刻到光柵化、z-buffer、相機投影、貼圖與陰影貼圖。作者把話說在前面:「I do not intend to show how to write GPU applications — I want to show how they work.」

tinyrenderer:500 行純 C++ 從零做算繪

tinyrenderer 是一套線上教材,目標明講在頁面上:「I aim to demonstrate how OpenGL, Vulkan, Metal, and DirectX work by writing a simplified clone from scratch.」你已經知道 vertex shader、rasterizer、depth test、fragment shader 這些字眼,但如果從沒有親手寫過一次點在不在三角形裡的測試,這些名詞就只是黑盒子外面的貼紙。教材不碰任何圖形函式庫,作者只提供一個處理 TGA 檔案(支援 RGB、RGBA、灰階)的類別,其餘——畫線、光柵化、隱面消除、相機、著色、貼圖、法線貼圖、陰影貼圖——全部要求學生自己刻。作者的學生平均花「10 to 20 hours」把這些元件組起來,做出第一個能吃相機、著色、貼圖、陰影的算繪器。下面按管線的組裝順序,把每一段黑盒子拆開看。

從像素開始:Bresenham 到三角形光柵化與重心座標

螢幕上沒有線,只有一格一格的像素。要在點陣圖上畫一條理論上連續的線段,最早也最經典的解法是 Bresenham 演算法——它把「這一步該不該讓 y 座標往上跳一格」變成一個純整數的累積誤差判斷,完全不需要浮點數、也不需要除法。每往 x 方向走一格,就把誤差累加一次斜率;誤差累積到超過半格,y 座標才跳一格,同時把誤差扣回去。這是後面所有東西的起點:課程用畫線建立「螢幕座標是離散格子」這個最基本的心智模型,再往上疊三角形。

Bresenham 選擇整數運算不是為了炫技——早期硬體根本沒有浮點運算單元,一切都得靠整數加法跟位移完成;今天的 GPU 光柵化單元雖然早就用上浮點與定點混合電路,但「每一步只做加法、不做除法」這個核心精神仍然留在硬體光柵化的設計裡,這也是為什麼教材從畫線開始,而不是直接跳進三角形。

三角形光柵化要回答的問題,形狀跟畫線很像但維度多了一層:給一個三角形跟螢幕上任一個像素,這個像素算不算落在三角形裡面?最直接的做法是先算出三角形的螢幕座標包圍盒,再逐一測試盒子裡每一個像素——真正決定「在不在裡面」的,是重心座標(barycentric coordinates)。給定三角形三個頂點 A、B、C,任何一點 P 都可以寫成三個權重 u、v、w 的加權組合,且 u + v + w 恆等於 1。只要三個權重同號(全部不小於零),P 就落在三角形內部;只要有一個變成負的,P 就已經跑到某條邊的外側。這組數字不只是一個布林測試——它同時是插值的權重:三角形三個頂點各自的顏色、UV 座標、深度值,套上同一組 u、v、w,就能算出三角形內部任何一個像素該有的顏色、貼圖座標與深度,光柵化與著色用的是同一套數學。

拖曳藍點移到三角形內外 · 即時顯示三個重心座標權重

A B C
u(權重 A)0.333
v(權重 B)0.333
w(權重 C)0.333
內部——u、v、w 皆不小於 0,三個權重加總為 1。

光柵化本身要處理的量並不小:一張中等解析度的畫面有數十萬個像素,一個場景動輒上千個三角形,每個三角形的包圍盒又要逐格測試——這正是為什麼「掃過包圍盒、逐格算重心座標」這件事雖然概念簡單,實作起來卻是整個算繪器裡最貼近硬體光柵化單元真正在做的事。下面這個模擬器把這個掃描過程放慢到看得見每一格,並且疊上下一節要談的深度比較,讓兩件事一起發生。

播放逐格掃描 · 2 個三角形 · 每格顯示重心座標與深度比較結果

0 / 576 格
按「播放」開始逐格掃描。
橘色三角形 A 較近(z=0.30),綠色三角形 B 較遠(z=0.70)。掃到重疊區時,兩個三角形的重心座標都算「內部」,但 z-buffer 只留下深度較小的那個。

橘色三角形 A 較近(z=0.30),綠色三角形 B 較遠(z=0.70)

重心座標判斷像素落在哪個三角形內;兩個三角形命中同一格時,深度較小、較近的那個贏——z-buffer 逐格記住最近深度,與繪製順序無關。

深度競爭:z-buffer 如何解決隱面

光柵化只回答「這個像素落在哪個三角形裡」,沒回答「如果兩個三角形都蓋到同一格,該畫哪一個」。最早、最直覺的解法是畫家演算法:把所有三角形依深度由遠到近排序,依序畫上去,後畫的自然蓋掉先畫的——原理上跟畫家先鋪遠景、再疊近景一樣。問題是排序本身就不便宜,而且遇到三角形互相穿插、或是深度值來回交錯的場景,根本排不出一個對所有像素都正確的順序。z-buffer 把這個問題整個換掉:不管三角形依什麼順序送進管線,每畫一個像素前,先跟這格目前記錄的最近深度比一比,比較近才覆蓋、比較遠就跳過。這樣一來,最終結果只跟每個像素「誰最近」有關,完全不依賴三角形的繪製順序——上一節模擬器裡橘色三角形之所以能穩定蓋住重疊區,靠的正是這格逐一比較,而不是誰先畫。

沒有 z-buffer 時,順序錯了會發生什麼事,比文字描述更容易用眼睛看出來。下面把同樣兩個三角形分成兩半:左邊是照送入順序直接疊畫、完全不比深度的結果;右邊是每一格都比較深度之後的結果。拖動分隔線可以看到,同一個重疊區域在兩種規則下畫出完全不同的三角形。

拖曳分隔線比較兩種畫法 · 左:無深度比較的疊畫順序 · 右:z-buffer 逐格比較

無深度比較 先畫 A(近),後畫 B(遠)蓋上去 z-buffer 逐格比較 A 深度較小,重疊區永遠是 A

左半邊那種瑕疵不是罕見狀況——只要場景裡有任何一對三角形深度交錯,固定的畫家順序就一定會在某個角度露出破綻。z-buffer 用空間換掉排序的麻煩:多開一塊跟畫面一樣大的深度緩衝區,每格存一個浮點數,換來「不管三角形怎麼送進來,結果都一樣」的性質,這也是為什麼它是隱面消除的預設解法,而不是排序技巧。

從場景到螢幕:相機與透視投影

光柵化跟 z-buffer 處理的都是已經在螢幕座標系裡的三角形,但場景裡的模型座標通常跟螢幕毫無關係——一個立方體的頂點可能落在以物體中心為原點的座標系裡,跟相機、跟螢幕像素都對不上。相機這一段要做的事,是把場景座標、相機座標、螢幕座標這三層串起來:先決定相機站在哪裡、看向哪裡,把整個場景轉換到「以相機為原點」的座標系;再做透視投影,把有深度的三維座標壓扁成螢幕上的二維像素。課程分成兩章處理這件事——先給一版 naive camera 建立基本框架,再換成一版更完整的相機模型,補上先前簡化掉的細節。

透視投影裡最違反直覺、也最核心的一步是「除以深度」:同一個物體離相機越遠,它在螢幕上佔的像素範圍就應該越小,而做到這件事的數學操作,就是把 x、y 座標各自除以該點的 z 值。z 值越大(越遠),除出來的結果越小,物體因此顯得更小——鐵軌看起來在遠方交會、房間裡的地磚往深處收斂成一點,都是同一個除法在起作用。下面這個立體堆疊,把「近平面」到「遠平面」按深度前後排開;因為套用了 CSS 的 `perspective`,越往後排的層級在視覺上自然縮得越小——這正是除以深度那一步在螢幕上留下的痕跡。

遠平面上的三角形
場景中景
近平面
相機座標原點
四個平面代表相機到場景不同深度的切片,位置只依 CSS 的 translateZ 排開;越遠的平面在瀏覽器套用透視後顯得越小,跟算繪器裡「螢幕座標除以深度」是同一件事在視覺上的類比。

從「naive camera handling」進到「better camera」,通常補的是第一版刻意省略的細節:naive 版本容易假設相機永遠正對場景、視野角固定、只做一次簡化過的投影運算;更完整的相機模型則要處理相機可以任意旋轉與平移、視野角可調、投影矩陣要跟視口尺寸對齊,這些條件疊在一起才撐得住一個能自由繞著場景走位的觀察點,而不是只能從一個固定角度看模型。這種「先給一個能動的簡化版,再逐步補上被省掉的一般性」的教學順序,貫穿了整套課程——三角形光柵化也是先講最基本的包圍盒掃描,等後面章節才處理裁剪、邊界之類的邊角情況。

相機這一層算完之後,管線裡剩下的三角形座標已經是螢幕像素了——這也是為什麼前兩節的光柵化跟 z-buffer 可以完全不管三角形原本在場景裡的位置:它們拿到手上的永遠是已經投影過的二維座標加一個深度值,這正是相機這一段交出來的契約。

讓表面看起來像材質:Phong 著色、貼圖與切線空間法線貼圖

幾何交代完之後,剩下的問題是「這個三角形的表面看起來該是什麼樣子」。Phong 著色模型把一個表面點的亮度拆成三份相加:環境光(不管方向、到處都有的底光)、漫反射(跟表面法線與光源方向的夾角有關,夾角越小越亮)、鏡面反射(跟觀察角度、光源反射角度都有關,負責高光那一小撮亮點)。這三份加起來就是這個像素該有的亮度,而算出每個像素法線方向的插值權重,用的正是第一節那組重心座標——著色不是光柵化之後另起爐灶的新演算法,是同一組數字換了一個用途。

貼圖(texturing)是同樣邏輯的延伸:三角形的三個頂點除了位置跟法線,還各自帶一組 UV 座標,指向一張紋理圖片上的位置;重心座標插值出每個像素的 UV,再去紋理圖上取樣顏色,表面就有了花紋,而不是單一平色。章節導覽把貼圖後面那一章直接標成「More data!」——從標題本身合理推測,這一步要處理的是頂點該再多帶哪些資料才夠:光有位置跟一組 UV 還不夠支撐後面的著色計算,至少還得再帶一份法線,貼圖坐標本身也可能不只一組。這跟前面「重心座標插值的是頂點屬性」那句話是同一件事——屬性清單變長,插值出來能用的東西也跟著變多,貼圖只是清單上第一個被加進去的例子。

切線空間法線貼圖再往上疊一層——它讓貼圖不只改變顏色,還能局部改變「這個像素該用哪個法線做光照計算」,讓平坦的三角形看起來有凹凸。要做到這件事,每個表面點需要三個互相垂直的基底向量:切線 T(沿著貼圖 U 方向)、副切線 B(沿著貼圖 V 方向)、法線 N(垂直於表面)。法線貼圖裡存的偏移是相對於這組 T、B、N 定義的,而不是相對於世界座標——這樣同一張法線貼圖才能套在任何朝向的三角形上,不需要為每個三角形另外烘焙一份。

法線貼圖每個像素改變的其實不是世界座標的法線,而是相對於 切線 T沿著貼圖 U 軸方向的基底向量,通常從三角形頂點的 UV 座標梯度算出來。副切線 B沿著貼圖 V 軸方向的基底向量,跟 T 與 N 互相垂直,三者合成一組正交基底。法線 N表面本身的幾何法線方向,也是這組基底裡唯一不需要額外資料就能算出來的一個。 這三個基底向量的偏移;因為 T、B、N 是跟著三角形本身的方向而變化,同一張法線貼圖才能套用在任意朝向的三角形上,不必為每個三角形另外準備一份。

光從哪來:陰影貼圖

Phong 著色算的是「這個點面對光源的角度」,但沒有回答「這個點跟光源之間有沒有東西擋著」——沒有這一步,被遮住的表面一樣會被算亮,整個場景看起來像沒有陰影。陰影貼圖的做法是把算繪流程跑兩遍:第一遍把相機換到光源的位置,只記錄每個方向上最近的深度,存成一張「光源視角的深度圖」;第二遍才是正常的相機視角著色,每算一個像素的亮度之前,先把這個點轉換到光源座標系,跟第一遍存的深度圖比較——如果這個點比光源深度圖記錄的值更遠,代表中間有別的東西先擋住了光,這個點就該在陰影裡。這跟隱面消除用的 z-buffer 是同一種資料結構,只是換了一次視角、換了一個用途。

光源的位置一變,光源視角看到的「誰擋住誰」也整個變——陰影的方向與長度因此完全跟著光源角度走,這也是為什麼陰影貼圖需要重新算一遍深度圖,而不能只是拿相機視角的結果重複利用。下面這個滑桿直接改變光源仰角,陰影多邊形的形狀跟長度即時跟著重算。

拖曳滑桿改變光源仰角 · 即時重算陰影多邊形

光源仰角 45°
地面

陰影貼圖之後,教材還留了 ambient occlusion 跟 toon shading 兩個章節——前者處理環境光在凹角、縫隙裡自然變暗的效果,後者是把整套寫實光照規則換成卡通式的色階分段,剛好證明同一套管線骨架換一種著色規則,就能長出完全不同的美術風格。這也是整套課程刻意選擇「軟體」而非「呼叫 GPU API」的價值所在——當著色規則本身就是你自己寫的一段函式,換一種美術風格只是換一個函式回傳值,不需要先弄懂某套 API 提供了哪些現成的著色器階段可以掛。教材選擇不用任何圖形函式庫,付出的代價是效能——同樣的畫面,硬體光柵化單元跟可程式化著色器一個週期能做完的事,這裡得用 CPU 一格一格算;但換來的是每一步都可以停下來印出中間值、單步除錯,這種可觀察性在真正的 GPU pipeline 裡幾乎拿不到。

組起來之後能做什麼:把畫線、光柵化、重心座標、z-buffer、相機投影、著色、貼圖、切線空間法線貼圖、陰影貼圖這些各自獨立的小節接起來,就是一個能吃模型、能吃貼圖、有陰影、有材質的軟體算繪器——中間沒有一行呼叫 OpenGL 或 Vulkan,卻走過了現代圖形 API 內部真正在跑的每一步;「about 500 lines」、「10 to 20 hours」,換來的是那些名詞不再是黑盒子。