跳到主要內容

012互動

把光追放進 3D 遊樂園:體驗開車碰撞、飛機加速、直升機飛行

上篇的房間是靜止的。這一篇把同一個路徑追蹤器搬到戶外,放進一個可以操控的人、五台車、一架直升機和一架飛機,每一幀都重新算光。東西一動,累積的樣本就全部作廢;看四件事怎麼讓它還是跑得動。

發布
閱讀時間
9 分鐘

先玩。按「進入遊樂園」,再用滑鼠點一下畫面,滑鼠就變成鏡頭(按 Esc 放開);用 W A S D 走路,走到車、直升機或飛機旁按 F。觸控螢幕用左下角的搖桿,拖曳轉鏡頭。右上角的「展開」會把它變成整個視窗,鍵盤提示會跟著你現在在做的事換。

fig 01/playground / fly

正在下載遊樂園、蓋 BVH…

累積的樣本
一個樣本
ms
會動的三角形
每幀重蓋它們的樹
ms
實際算的解析度
960 × 540
怎麼畫
帶我去

先點一下畫面:滑鼠轉鏡頭(Esc 放開),W A S D 走路,走到車、直升機或飛機旁按 F。觸控用搖桿和拖曳。人一動,累積的樣本就全部作廢,畫面回到雜訊;站著不動,它就慢慢變清楚。

Sketchbook by Jan Blaha (swift502), MIT; Rapier (Dimforge), Apache-2.0

畫面裡的每一個像素都是路徑追蹤算出來的:陰影、屋簷下的亮度、車漆映在地上的顏色,都沒有另外寫。「帶我去」會把人放到車、直升機或飛機旁邊。站著不動,畫面會慢慢變清楚;一動,就回到只有幾個樣本的樣子。

上篇的結尾留了一句話:相機和場景一動,累積的樣本就全部作廢。那個房間要幾百個樣本才乾淨,而這裡的東西每秒要動六十次以上,每一幀只來得及算一個樣本。

這一篇講四件事,它們合起來才讓上面那張圖玩得動。先說結果(我的機器,960×540,完整光追):一個樣本 6.8 ms,移動時跟得上 120 Hz 的螢幕(量到每秒 121 幀),移動中的雜訊只剩「每幀從零開始」的五分之一。

第一件事:世界蓋一次,會動的每幀只重算盒子

上篇的 BVH 要蓋一秒,蓋好就不動了。這裡有一個人、五台車、一架直升機、一架飛機,一共 11,275 個會動的三角形,不可能每一幀重蓋。

所以有兩棵樹。遊樂園本身(地面、坡道、軌道)蓋一次;會動的東西另外一棵,光線兩棵都走,取比較近的那個交點。

會動的那一棵也不重蓋。車不會變形,它的三角形之間誰跟誰比較近,永遠不變;所以每台車的樹只在一開始用上篇那個仔細的方法蓋一次,之後每一幀只把每個節點的盒子,依照三角形現在的位置重算一遍,從葉子往上。人會彎手彎腳,但變形不大,同一招也行。我量到的是:每幀重蓋要 2.8 ms,只重算盒子是 1.2 ms。

第二件事:把上一幀的畫面帶過來

一幀一個樣本是雜訊。但上一幀、上上一幀也各有一個樣本,而且畫的幾乎是同一個世界,丟掉太可惜。

做法是每一幀多記一件事:每個像素的第一條光線打到了哪裡(世界座標,和哪一個三角形)。下一幀,對每個像素問:「我現在看到的這個點,上一幀在畫面的哪個位置?」用上一幀的相機把這個點投影回去就知道。再檢查那個位置上一幀看到的是不是同一個三角形;是的話,就把那裡累積的顏色接過來繼續累積,不是的話(上一幀它被擋住、在畫面外),才從零開始。

圖的設定裡有一顆「沿用上一幀」,關掉它走幾步就知道差別。我用同樣的走法各量了兩次,相鄰像素的亮度落差(越大越吵):關掉是 6.8 和 7.4,打開是 3.0 和 2.9。幀率兩邊一樣。

第一版完全沒效果。我原本用「兩個點距離夠近」來判斷是不是同一處,門檻設幾公分;可是地板是斜斜看過去的,同一個像素裡前後兩條光線落在地上的位置可以差幾十公分,於是整片地板每一幀都被當成「不是同一處」。改成比對三角形才對。

會動的東西多一步:車上的某一點,上一幀在哪?它所在的三角形在兩幀裡的順序是一樣的,點在三角形裡的相對位置也不變,所以拿上一幀那個三角形的三個頂點,就能算回它當時的位置。

它有代價,而且看得到:被帶過來的顏色是舊的。人跑過去之後,他的影子會在地上多留一瞬間。能沿用幾個樣本是一個取捨,這裡設 12:再多,畫面更穩,影子拖得更長。

第三件事:跟隔壁的像素借

帶過來的歷史最多抵十二個樣本,陰影裡還是看得到顆粒。還有一個地方可以借:同一面牆上相鄰的像素,照到的光幾乎一樣。

所以在畫面顯示之前,每個像素和周圍的像素做一次加權平均。重點全在「誰可以算進來」。隨便平均就是把畫面弄糊;這裡一個鄰居要同時過四關,才算是「同一個表面、照到同樣的光」:

  1. 同一種材質。 紅色的路障和白色的地面永遠不混。
  2. 法線方向一致。 牆和地板的交界不會糊掉。
  3. 在同一個平面上。 鄰居的位置沿著這個像素的法線量,不能離太遠;前景的車和它後面的地面就這樣分開。
  4. 亮度沒有差太多。 這一關保住陰影的邊:陰影裡和陰影外差好幾倍,不會互相滲過去。

前三關用的是第二件事已經記下來的東西(每個像素看到的點),只多記了法線和材質。

要借得夠遠才有用,但一次看 29×29 個鄰居太貴。做法是同一個 5×5 的濾波跑三次,第二次每隔一格取一個,第三次每隔三格:三次加起來碰得到十四個像素以外,每次還是只讀 25 個點。

設定裡的「向鄰居借(降噪)」可以關掉比較。同樣的走法,我量到的雜訊:兩招都關 6.8 到 7.4,只沿用上一幀 3.1,再加上這一招 1.3。

它也有代價。人的影子邊緣會變軟,很細的影子會變淡;車漆上的反光會被抹平一點。還有一條規則是為了不讓它幫倒忙:一個像素自己的樣本越多,就越不聽鄰居的,從 8 個樣本開始遞減,到 64 個完全不聽。所以站著不動,畫面最後收斂到的是沒有濾過的答案,不是一張糊掉的圖。

第四件事:貴的是地面,不是車

放進車之後,一個樣本要 9 ms 多,比我預期的慢很多。我先猜是車的關係,花了一輪去改會動的那棵樹,幾乎沒有改善。

把「每個像素的光線走訪了幾個節點」畫出來(上篇圖 05 的那種熱圖),才看到整個畫面都是滿的,連沒有車的地方也是。問題在地面:這個遊樂園的地板是幾個上百公尺長的大三角形,包住一個這種三角形的盒子,等於包住半個遊樂園,貼著地面走的光線每一個盒子都得進去看。

解法是把長三角形切短。切到每一邊不超過 12 公尺,三角形變多,盒子變緊:

地面的長三角形一個樣本每條光線走訪的節點
照原檔9.24 ms60.8
每邊最長 12 m6.84 ms36.6
每邊最長 6 m7.07 ms37.2
每邊最長 3 m7.78 ms41.9

不是越細越好:切太細,樹變深,又慢回去。

這一輪我自己量錯過一次。一開始的數字怎麼看都不合理,後來發現是 GPU 上的計數器溢位了:上篇才寫過它是 32 位元的,這裡畫面比較大,一次量 64 個樣本就會繞一圈。改成一次量 16 個,數字才對。

人、車、直升機、飛機

場景的幾何、人物、載具和全部 34 段動畫,來自 Jan Blaha(swift502)的 Sketchbook(開新分頁),MIT 授權。原始的貼圖沒有用:遊樂園的貼圖是不能轉散佈的照片,載具的貼圖是預先烘好的光影,而那正是這一篇要現場算的東西。所以打包的時候只留下頂點、骨架、動畫和材質的名字,顏色是我重新配的。

人物的行為照 Sketchbook 的狀態機移植,一個狀態一個狀態對:依要轉的角度選起步動畫、急停、原地轉身、衝刺、兩種跳、依落地的力道選三種落地;按 F 之後自己走到比較近的那一扇門、開門、坐下、從裡面關門(從副駕上車的話,再滑到駕駛座),下車再關一次。按 G 是當乘客,坐下就不動;X 換到相連的座位,V 是第一人稱。物理用 Rapier:地面是一整個三角網格,人是一顆膠囊,車是四條帶彈簧的射線(五個檔自己換,方向盤會轉,飛起來的時候方向鍵能讓它在空中翻),直升機和飛機是剛體,飛行的算法和按鍵也照 Sketchbook:W S 俯仰、A D 側傾、Q E 轉向,Shift 是上升或油門。直升機引擎要五秒才轉得起來,放手會自己回正;飛機加滿油門大約八秒離地,副翼、升降舵和方向舵會跟著按鍵擺動。

把「太陽的時間」拉過傍晚,天就黑了,車燈和直升機的探照燈會自己亮(L 可以手動開關)。模型本身沒有燈,燈是打包時加上去的:從正前方射一條線,碰到車身的地方貼一片會發光的鏡片。照亮路面的不是那片鏡片,是一盞聚光燈,問法和問太陽一樣:每一次反彈,從最多十二盞燈裡依「這盞燈能給這個點多少光」的比例挑一盞,射一條陰影線。這樣一個樣本多 2 ms(我量到 8.4 → 10.4 ms),換來的是車燈打在別台車上、人站在光柱裡有影子,全部不用另外寫。行駛中沒關好的車門會被加速度甩動,甩得夠用力就自己關上。

人物每一幀在 CPU 上算骨架(14 根骨頭,186 個三角形),算完直接進那棵會動的樹。

和真的不一樣的地方、數字怎麼量的

降噪是最基本的那一種。 真的系統會量每個像素的雜訊有多大(變異數),雜訊大的地方多借、小的地方少借;這裡的門檻是固定的。現在的遊戲多半把這一步交給一個神經網路。

沿用的畫面在光變了的時候是錯的。 拉太陽的時間會直接把歷史清掉重來。真的系統會偵測哪裡的光變了,只丟那一部分。

夜裡的光斑會拖尾。 車燈跟著車走,被沿用的上一幀卻記得光斑原本在哪,所以開車時地上會留一小段淡掉的光。燈亮的時候,能沿用的樣本數從 12 降到 6,換短一點的尾巴。

反射和海面會拖影。 鏡面裡看到的東西會隨相機移動,但我是用表面本身的位置去找上一幀,所以車漆和海面上的倒影是慢半拍的。

飛行是 Sketchbook 的街機式飛行。 直升機自己會平衡;飛機沒有真的氣動力,只是把速度一點一點拗向機頭的方向,所以按住 S 會直接翻一個筋斗,太慢的時候拉機頭就掉下來。Sketchbook 會讓飛機隨速度變輕,這裡沒有做。車門在行駛中不會被甩開。懸吊的數字是我調的,因為兩個物理引擎的彈簧算法不一樣;引擎的力則是把 Sketchbook 的數字照車重放大。

沒有 WebGPU 就玩不了。 上篇還能放一張預先算好的圖,這一篇沒有意義。

數字的來源:圖上的讀數是你的瀏覽器現場量的。文中寫「我量到」的,是 M4 Pro 的 Chrome、960×540、鏡頭在出生點看著停車場,方法和完整的表在這個專案的 docs/research/light/RESULTS.md。車、直升機、飛機的物理,和人物的狀態機,各有不需要 GPU 的自動測試(tests/rt/)。

來源

  • 場景、人物、載具、動畫與人物狀態機的原始設計:Jan Blaha (swift502), Sketchbook, MIT 授權, github.com/swift502/Sketchbook。
  • 物理引擎:Dimforge, Rapier, Apache-2.0, rapier.rs。
  • 用上一幀的結果、依表面位置投影回去再驗證:Schied 等, Spatiotemporal Variance-Guided Filtering, High Performance Graphics 2017(這裡做了它的時間累積,空間濾波用的是下一條那個比較簡單的版本)。
  • 隔著洞取樣的濾波,和用法線、位置擋住邊界:Dammertz, Sewtz, Hanika, Lensch, Edge-Avoiding À-Trous Wavelet Transform for fast Global Illumination Filtering, High Performance Graphics 2010。
  • 變形的物體只重算盒子、不重蓋樹:Wald, Boulos, Shirley, Ray Tracing Deformable Scenes Using Dynamic Bounding Volume Hierarchies, ACM Transactions on Graphics, 2007。