№ 011互動
一張圖怎麼從雪花變清晰:從零寫一個 WebGPU 路徑追蹤器
一個像素的顏色,是很多條亂彈的光線的平均。看一張圖從滿是雜訊慢慢變清楚,射一條光線看它怎麼算出一個顏色,打開一個開關讓雜訊少五倍,換上金屬和玻璃,再看 BVH 怎麼讓一百萬個三角形也跑得動。
- 發布
- 閱讀時間
- 9 分鐘
下面這個房間現在只算了一個樣本,幾乎全是雪花。按「開始」,剩下的交給你的 GPU。
正在蓋 BVH…
誤差怎麼量的:奇數樣本和偶數樣本各自累積成一張圖,兩張圖差距的一半就是這張圖的誤差,不需要參考圖。數字是相對於整張圖的平均亮度:10% 表示一個像素平均還差平均亮度的一成。
畫面裡沒有一個像素是「畫」上去的。沒有陰影的程式,沒有「紅牆要把地板染紅」的程式,天花板那盞燈照不到的角落為什麼還有一點亮,也沒有人寫。整個畫面只做一件事,做了很多次:從一個像素射一條光線出去,讓它在房間裡亂彈,看它最後有沒有碰到燈。
這種做法叫路徑追蹤(path tracing)。這一篇把它從零寫出來:場景、加速結構、CPU 上的對照版、GPU 上的 WGSL 核心,一共約一千一百行(TypeScript,加上寫給 GPU 的 WGSL;下篇戶外要用的部分也算在裡面),沒有用任何繪圖函式庫。然後回答幾個問題:一條光線怎麼變成一個顏色?雜訊要多久才會散,能不能讓它散快一點?金屬和玻璃要多寫什麼?三角形從一千個變成一百萬個,為什麼還跑得動?
這是上篇。下篇把同一個引擎搬到戶外:一座可以走、可以開車、可以開直升機和飛機的遊樂園,每一幀都是這樣算出來的。
一個像素,一條光線
先只看一個像素。下面的畫面是 GPU 算好的,疊在上面的線,是你的 CPU 用同一套演算法、從虛線圈住的那個像素射出去的一條路徑。
正在蓋 BVH…
- 這一條算出來的顏色
- 0 條的平均
- GPU 那張圖上這個像素的顏色
點畫面上任何一個地方,換一個像素。路徑是在你的 CPU 上用同一套演算法算的,線的顏色是光走到那裡還剩下的顏色。
一條路徑的規則只有三條:
- 從眼睛穿過這個像素射出去,找到打中的第一個表面。
- 打到的是燈,這條路徑就有顏色了:燈的亮度,乘上一路上「還剩下的」比例。
- 打到的不是燈,就把「還剩下的」乘上這個表面的顏色(紅牆只留下紅色的部分),然後朝一個隨機的方向再彈出去,回到第 2 步。
多按幾次「再射一條」。大部分的路徑到最後都沒碰到燈:不是從房間的開口飛出去,就是彈到沒力了。它們算出來的顏色是純黑。我量過幾個像素,碰到燈的路徑只有 3% 到 7%;那少數幾條非常亮,亮到遠超過螢幕能顯示的白。
所以單獨一條路徑的答案幾乎一定是錯的:不是全黑,就是過亮。這個像素真正的顏色,是很多條路徑的平均。 按「射 100 條」,看第二個色塊怎麼靠近第三個。紅牆上的一個像素,101 條路徑的平均是 rgb(195, 38, 36),GPU 用 1,024 條算出來的是 rgb(163, 31, 29):已經是同一種紅,但還沒到。
圖 01 一開始的雪花就是這件事:每個像素都只射了一條,多數是黑的,少數是白的。
雜訊要多久才會散
「很多條的平均」要多少條才夠?回到圖 01 右下那張圖。它的橫軸是每個像素的樣本數,縱軸是整張畫面的誤差,兩軸都是對數。
誤差點會沿著那條虛線走,虛線的斜率是 −½,意思是誤差和樣本數的平方根成反比。在我的機器上,從 2 個樣本到 2,000 個樣本,量到的點都落在這條線上。這是用亂數猜一個平均值時躲不掉的規律,和畫面內容無關,和 GPU 多快也無關。
它的代價很具體:雜訊要少一半,樣本要四倍。 從「看得出是什麼」到「乾淨」,差的不是兩倍的時間,是一百倍。這就是為什麼圖 01 前幾秒變化很大,之後好像停住了:它沒有停,只是每一次肉眼看得出來的進步,都比上一次貴四倍。
沒有標準答案,誤差是怎麼量的?奇數次和偶數次的樣本各自累積成一張圖。兩張圖算的是同一個房間,差別只來自運氣,所以它們差距的一半,就是合起來那張圖的誤差。
不加樣本,改成去問燈
要畫面乾淨,到這裡只有一招:多射幾條。但圖 02 已經說了問題在哪:九成以上的路徑根本沒碰到燈,全部白算。
房間裡最值得去的方向,就是燈的方向。所以每次碰撞的時候多做一件事:在燈上隨機挑一個點,射一條光線過去,看中間有沒有東西擋住。 沒被擋住,就把燈的亮度直接算進來(乘上這個方向該有的比重),然後路徑照常繼續彈。它有一個一定要處理的陷阱:路徑之後如果自己撞到燈,那一次不能再算,不然燈就被算了兩次,畫面會太亮。
回到圖 01,等誤差曲線畫出一段之後,按「直接問燈」。畫面會從頭開始,上一次的曲線留在圖上讓你比。新的曲線和舊的平行,斜率還是 −½,這條規律躲不掉;但它整條低了一截。我量到的是:256 個樣本時,誤差從 17.7% 降到 3.3%。誤差少 5.3 倍,等於樣本多了 28 倍。它不是免費的,每次碰撞多射一條光線,一個樣本的時間從 2.9 ms 變成 5.7 ms。算下來,同樣的時間,等於多了十四倍的樣本。
這一招叫 next event estimation。它背後的想法比它本身重要:樣本要花在貢獻大的地方。 下篇的遊樂園裡,太陽就是這樣問的;不這樣做,戶外的畫面根本來不及算。
光要彈幾次
規則第 3 步說「再彈出去」,沒有說彈幾次。下面把同一個房間畫四次,用的是一模一樣的樣本,只差在一條路徑最多能彈幾次。
正在蓋 BVH…
0 次的時候只看得到燈本身。1 次叫直接照明:表面被燈直接照到才亮,照不到的地方全黑,天花板也是黑的,因為燈朝下。到了第 2 次,陰影裡開始有東西,紅牆和綠牆的顏色滲到地板和環面上。這些都不是另外寫的效果,只是讓同一條規則多跑一輪。
不限次數的那一格比 2 次的再亮一點、柔一點。不過「不限」不是真的彈到永遠:從第 4 次反彈開始,每彈一次就擲一次骰子,表面越暗,這條路徑越可能就此結束;活下來的路徑則把亮度放大來補償,平均起來不多也不少。這一招叫俄羅斯輪盤(Russian roulette)。
金屬和玻璃
到目前為止,所有表面都是霧面的:光打上去,往哪裡去都差不多。換一個房間,三顆球,中間那顆由你決定。
正在蓋 BVH…
對路徑追蹤器來說,一個材質只回答一個問題:光從這個方向來,會往哪些方向去,各有多少? 規則第 3 步的「朝一個隨機的方向」,換成照這個答案去挑,其他什麼都不用改。
金屬:把表面想成無數個很小的鏡子,各自朝著稍微不同的方向。粗糙度決定它們有多亂:拉到 0.02,球就是一面鏡子;拉到 0.6,反光糊成一片。金屬的顏色不是塗上去的,是它正面反射時紅綠藍各反射多少;越斜著看,三個顏色都越接近全反射,所以球的邊緣比中間白。
玻璃:每次碰到表面,一部分反射,其餘折射進去;正面看幾乎全部穿過去,斜著看像鏡子。折射率決定光彎多少:拉到 1.0,光不彎,球整顆消失;拉到 2.4 是鑽石。球底下那一小塊很亮的光斑,是被玻璃聚在一起的光。沒有人另外寫它。
一百萬個三角形為什麼還跑得動
到這裡,每一步最貴的都是同一件事:這條光線打到了誰? 老實的做法是把場景裡每個三角形都測一次。這個房間有 876 個三角形,一張 512×512 的圖,每個像素、每個樣本、每次反彈都要問一次。
解法是先把三角形裝進盒子,盒子再裝進更大的盒子,做成一棵樹,叫 BVH(bounding volume hierarchy)。光線沒碰到大盒子,裡面的東西就全部不用看。下面這張圖不上色,每個像素的顏色,是它的光線為了找到答案,走訪了樹上幾個節點。
正在蓋 BVH…
0 48個節點
把三角形一路拉到一百萬,環面越來越圓,右邊的數字幾乎不動。這是我量到的(M4 Pro,Chrome,256×256,只算從相機出發的那一段):
| 三角形 | BVH | 每條光線走訪的節點(或測試的三角形) | 每秒光線(百萬) |
|---|---|---|---|
| 876 | 開 | 9.5 | 112 |
| 9,612 | 開 | 10.4 | 170 |
| 101,412 | 開 | 11.4 | 158 |
| 998,796 | 開 | 11.9 | 141 |
| 876 | 關 | 876 | 45 |
| 9,612 | 關 | 9,612 | 5.4 |
三角形多了一千一百倍,每條光線多走 2.4 個節點。樹每深一層,能裝的三角形就多一倍,所以一百萬個三角形的樹也只有 23 層。關掉 BVH,9,612 個三角形就已經慢了三十倍;超過一萬個我把開關鎖住了,因為一個樣本會久到瀏覽器直接把這個 GPU 工作殺掉。
最上面兩列,876 個三角形反而比 9,612 個慢,不是 BVH 的關係:場景太小,64 個樣本一下就算完,GPU 還來不及跑到全速。每秒光線數請當作量級來看,走訪的節點數才是精確的。
蓋這棵樹的方法叫 binned SAH:每次要把一堆三角形分成兩半時,三個軸各分成 16 格、試 15 種切法,挑「兩邊盒子的表面積 × 各自的三角形數」加起來最小的那一刀。一百萬個三角形,在你的瀏覽器裡大約要蓋一秒,放在 worker 裡做,頁面不會卡住。
和真的不一樣的地方、數字怎麼量的
玻璃是完全光滑的,很粗的金屬會吃掉一些光。 毛玻璃沒有做。金屬的模型(GGX)只算光在小鏡子之間彈一次,粗糙度拉到 1 的時候,我量到球只還回三成的光;真的渲染器會把它補回去。
穿過玻璃的光還是靠運氣。 問燈的那條光線會被玻璃球自己擋住,所以球底下那塊光斑是全畫面最慢乾淨的地方。
沒有降噪。 現在的即時光追,一個像素一幀只有一兩個樣本,靠的是把前幾幀和鄰近的像素拿來一起平均,或交給一個神經網路。
東西不會動。 相機和場景一動,累積的樣本就全部作廢。這是下篇的題目。
數字的來源:圖裡的讀數都是你的瀏覽器現場量的,所以會和我的不一樣。文中寫「我量過」的,是在 M4 Pro 的 Chrome 上、在這一頁裡量的,紀錄在這個專案的 docs/research/light/RESULTS.md。「問燈」不會改變答案這件事有測試守著:CPU 版的三個像素、各 6,000 條路徑,問和不問的平均值差距都在雜訊容許的範圍內(tests/rt/strategies.test.ts)。CPU 版和 GPU 版是同一套演算法各寫一次:我在圖 02 挑了八個像素(兩面色牆、地板、天花板、後牆、環面、燈),CPU 的 2,001 條路徑平均和 GPU 的 1,024 個樣本,每個色版最多差 12 級(滿分 255),有高有低,沒有一邊倒;BVH 找到的交點和逐一測試每個三角形的結果一致,由 tests/rt/ 的測試保證。沒有 WebGPU 的瀏覽器會看到一張預先算好的圖,圖 02 在那張圖上照樣能用,因為它的光線是 CPU 算的。
來源
- 把「一個像素的顏色」寫成一個積分、再用隨機的路徑去估計它:Kajiya, The Rendering Equation, SIGGRAPH 1986。
- 問燈和照材質挑方向怎麼一起用而不重複計算:Veach, Guibas, Optimally Combining Sampling Techniques for Monte Carlo Rendering, SIGGRAPH 1995。
- 金屬的模型(GGX)和怎麼挑它的方向:Walter, Marschner, Li, Torrance, Microfacet Models for Refraction through Rough Surfaces, EGSR 2007;Heitz, Sampling the GGX Distribution of Visible Normals, JCGT 2018。
- 蓋 BVH 的方法:Wald, On fast Construction of SAH-based Bounding Volume Hierarchies, IEEE Symposium on Interactive Ray Tracing, 2007。
- GPU 上用的亂數:Jarzynski, Olano, Hash Functions for GPU Rendering, Journal of Computer Graphics Techniques, 2020。
- 把算出來的亮度壓進螢幕能顯示的範圍:Narkowicz, ACES Filmic Tone Mapping Curve, 2016。
- 場景是 Cornell box 的變形,原始的盒子來自 Goral, Torrance, Greenberg, Battaile, Modeling the Interaction of Light Between Diffuse Surfaces, SIGGRAPH 1984。