王禰:虛幻引擎5的開發路線圖及技術解析
2021年11月22日-24日,由騰訊遊戲學堂舉辦的第五屆騰訊遊戲開發者大會(Tencent Game Developers Conference,簡稱TGDC)線上上舉行。本屆大會以“Five by Five”為主題,邀請了海內外40多位行業嘉賓,從主論壇、產品、技術、藝術、獨立遊戲、市場及遊戲社會價值7大專場共同探討遊戲產業趨勢和多元價值,以開發者視角與需求為出發點,助力遊戲行業良性發展,探索遊戲的更多可能性。
大家好,我是來自Epic Games中國的首席引擎開發工程師王禰,主要負責引擎相關技術的開發者支援工作,幫助國內的開發者解決各種使用UE開發專案時遇到的技術問題,同時也會參與部分引擎工作的開發。
今天我主要為大家介紹UE5的新功能。當然,UE5有太多新功能了,我會挑大家最關心的Nanite和Lumen多講一些。
在開發UE5的時候,我們的目標是:提高各方面的渲染品質,讓構建的數字世界更動態一些,提高整個虛擬世界構建和表現的上限;同時我們也希望提高開發和迭代的效率,提供更多更豐富易用的工具,改善使用者編輯和創造的體驗,降低大家使用的門檻。
相比UE4,UE5做了大量改進,主要是Nanite和Lumen等渲染技術,構建整個大世界的工具以及底層對渲染大量物件生成一些Proxy Mesh技術。
在協同工作方面,改進包括管理大量資產的效能、編輯器和使用者體驗、次世代的一些動畫技術Chaos、網路同步的物理系統,以及一些全新模組、遊戲框架、AI叢集系統、進一步完善的Niagara系統以及各種音訊模組,像Meta Sound之類的功能都有非常大的改善。
Nanite功能
首先是我們主打功能之一的Nanite,Nanite是全新的Mesh表示形式,是一種虛擬微表面幾何體,解放了之前美術同學製作模型時對大量細節的限制,現在可以直接使用真正用於影視級別的資產,幾百萬甚至上億面的模型直接可以匯入到引擎中,非常順暢的放很多的例項去高效的渲染。
例如來自照片建模或者Zbrush雕刻的高模,或者CAD的資料都可以直接放進來,我們有過測試可能幾萬甚至十幾萬的,這些例項每個都是百萬面以上的都在view內能被看到的情況下,用Nanite的方式渲染依然能在2080s這樣的GPU上跑到60fps,解析度可能是1080P左右。Nanite還在開發中,還有很多功能支援並不完善,我們在後續會慢慢改進。
Nanite支援的平臺主要是新一代的主機和PC,相比去年我們放出來的Lumen in the land of Nanite ,這項技術的品質和效率都有不少提升,包括磁碟的編解碼效率和壓縮、支援Lightmap烘焙光照、支援可破碎物體、對光線追蹤場景或者物理碰撞支援自動生成減面高質量的替代Proxy mesh。
另外通過這種方式,我們還可以用解析微分法決定畫素誤差,使誤差肉眼不可見。最後,我們還高效支援了多光源投影,整個Nanite管線基於GPU driven的管線產生,主要流程我會分以下幾個部分來講。
為了讓大量物件在場景上高效剔除,我們需要把所有場景資料都送到GPU上。其實從4.22開始,引擎就慢慢在不影響上層使用的情況下,在底層做出改進了,使渲染器成為retained mode,維護了完整的GPU scene,Nanite在這個基礎上做了大量新的工作。
Nanite中cluster的生成
接下來我們簡單講講Nanite的工作機制。首先在模型匯入時,我們會做一些預處理,比如按128面的cluster做切分處理。有了這些cluster以後,我們就可以在距離拉遠拉近時,做到對每個cluster group同時切換,讓肉眼看不到切換lod導致的誤差沒有crack,同時還能對這些不同層級、細節的cluster做streaming,這其實就是Nanite最關鍵的部分。
cluster的生成主要分以下幾步:首先,原始的mesh lod0資料進來後,我們會做一個graph partition。partition條件是比如說我希望共享的邊界儘可能少,這樣我在lock邊界做減面處理時,減面的質量會更高一些。
第二是希望這些面積儘可能均勻、大小一致,這樣在lod計算誤差處理投影到螢幕上時,都是對每個cluster或cluster group一致處理。我們會把其中一組cluster合併成一個cluster group,又一次按照“lock的邊界儘可能少、面積儘可能均勻”的條件找出,一組組cluster生成group,對這個group內cluster的邊解鎖,等於把這組group看成一個大的cluster,然後對這組group做對半的減面。
減完面後,我們可以得到一個新的cluster誤差,我會對這個減面的group重新做cluster劃分。這時,cluster的數量在同一個group裡其實就已經減半,然後我會計算每個新的cluster誤差。大家要注意,這個過程是迴圈的,遞迴一直到最終值 ,對每個instance、模型只生成一個cluster為止。這裡有一個比較關鍵的點:我們在減面生成每個cluster時,會通過減面演算法(QEM)得到這個cluster的誤差值並存下。
除此之外,我們還會存group的誤差值,這個值其實就是更精細的那一級cluster group裡cluster的最大誤差值,和我新一級裡產生的每個cluster誤差值取maximum得到的值。這樣我就能保證這個cluster每次合併的group,去減面到上一級的group裡的cluster時的誤差值,永遠是從不精細到精細慢慢上升的狀態。
也就是說,我從最根結點的cluster慢慢到最細的cluster,裡面的error一定是降序排序的。這一點很重要,因為它能保證後續選擇culling和lod時,恰好是在一個cluster組成的DAG上。因為cluster會合並group,group生成打散以後在下一級裡,又會有一個共享的cluster。
有了這個降序排列的誤差,我就能保證這個DAG上有一刀很乾淨的cut,使我的邊界一定是跨lod的cluster group的邊界。最後,我們對這個生成的各個lod層級的cluster分別生成bvh,再把所有lod的cluster的bvh的root,掛到總的bvh root上。
當然,這裡還有很多額外處理,現在沒有講是考慮到做streaming時的一些分頁處理。這個分頁可能會對cluster group造成切割,所以cluster group,還有一些group partition的概念,我們這裡不做細化。
另外,對於一些微小物體離得很遠以後的情況,我們減到最後一級cluster,其實它還是有128個面,那如果場景裡非常小的東西位於很遠的地方,這又是一個模組化的構成。我們又不能直接把它culling掉,這種情況下,我們會有另外一種Imposter atlas的方式,這裡我也不展開講了。
Nanite裁剪流程
最後裁減到它bvh的葉子節點,其實就是我們剛才說的cluster group,然後再對其中的cluster做裁減。裁減完之後,我們就會有一個特殊的光柵化過程,然後我們就能得到新的Depth Buffer,重新構建HZB,再對這個新的HZB做一遍裁減。
前面那次HZB的可見性,我們用了上一幀可見的instance來做,做完之後形成新的HZB,我們再把上一幀不可見的,在這一幀內所有剩下的再做一遍,就能保守地保證沒有什麼問題。
重新經過光柵化後,生成到新的visibility buffer,再從visibility buffer經過material pass,最終合入Gbuffer。具體做culling時會有一些問題,比如剛才cluster生成時我們說到過,生成cluster group的bvh結構,我們在CPU上不會知道它有多少層。
也就是說,如果我要去做的話,CPU要發足夠多的dispatch,這時比如小一點的物件,它空的dispatch就會很多,這種情況下GPU的利用率也會很低。
所以我們選擇了一種叫persistent culling的方法,利用一個persistent thread去做culling,也就是隻做一次dispatch,開足夠多的執行緒,用一個簡單的多生產者、多消費者的任務佇列來喂滿這些執行緒。
這些執行緒從佇列裡執行時,每個node會在做封層級別剔除的同時產生新的node,也就是bvh node,Push back回新的。在可見的children的列表裡,我們一直處理這個列表,直到任務為空。
這裡的處理分為幾種型別:首先在一開始的node裡,只有我們開始構建的bvh的節點,直到我一直做剔除,剔除到葉子節點以後,裡面是個cluster group,再進入下一級,就是這個group裡面所有的cluster culling。最後cluster並行獨立地判斷,自己是否被culling 掉,這裡其實和剛剛lod選擇的條件是一模一樣的。
還記得我剛才說的error的單調性吧?因為這裡的cluster中,所有lod都是混合在一起的,所以我們每個cluster在並行處理時,我不知道父級關係是什麼樣的,但我在每個cluster上存了自己的誤差,和我整個group在父一級上的最大誤差,所以這時我就知道,如果我自己的誤差足夠小,但是我Parent的誤差不夠小,我就不應該被culling掉。
同理,跟我共處一個cluster group的這些節點,如果它在我上一級lod裡,也就是比較粗的那一級裡,那它的error一定不夠大,所以上面那一級lod所在的整個group都會被拋棄掉,而選中下一個。
但是下一個裡面,其實還是可能會有一些誤差太大的——它的誤差如果足夠大,就意味著它在再下一級更精細的地方,肯定屬於另外一個cluster group。所以它又在下一級的cluster group裡又有一個邊界,和它下一級的cluster group邊界接起來會沒有接縫,整個cluster的選擇就是這樣並行做的。
同時,對應自己cluster group的parent,剛剛我們說了,肯定會被剔除掉。這樣就能保證我們能分cluster group為邊界,去對接不同lod層級的cluster,並使經過culling存活下來的cluster來到特殊的光柵化階段。
Nanite中的光柵化
由於當前圖形硬體假設了pixel shading rate,肯定是高於triangle的,所以普通硬體光柵化處理器在處理非常的微小表面時,光柵化效率會很差,完整並行也只能一個時鐘週期處理4個triangle,因為2x2畫素的會有很多quad overdraw,所以我們選擇使用自己用compute shader實現的軟體光柵化,輸出的結果就是Visibility Buffer。
我這裡列出的結構總共是64位的,所以我需要atomic64的支援,利用interlocked maximum的實現來做模擬深度排序。所以我最高的30位存了depth、instanceID、triangleID。因為每個cluster128個面,所以triangleID只要7位,我們現在其實整個opaque的Nanite pass,一個draw就能畫完生成到visibility Buffer,後續的材質pass會根據數量,為每種材質分配一個draw,輸出到Gbuffer,然後畫素大小的三角面就會經過我們的軟體光柵化。
我們以cluster為單位來計算,比如我當前這個cluster覆蓋螢幕多大範圍,來估算我接下來這個cluster裡是要做軟體光柵化還是硬體光柵化。我們也利用了一些比如浮點數當定點數的技巧,加速整個掃描線光柵化的效率。
比如我在subpixel sample的時候是256,我就知道是因為邊長是16。亞畫素的修正保證了8位小數的精度,這時我們分界使用軟光柵的邊界,剛好是16邊長的三角面片的時候,可以保證整數部分需要4位的精度,在後續計算中最大誤差,比如乘法縮放導致小數是8位、整數是4位,就是4.8。
乘法以後精度縮放到8.16,依然在浮點精度範圍內,實際的深度測試是通過Visibility buffer高位的30位的深度,利用一些原子化的指令,比如InterlockedMax實現了光柵化。大家感興趣可以去看看Rasterizer.ush裡面有Write Pixel去做了,其實我們為了並行地執行軟體光柵化和硬體光柵化,最終硬體光柵化也依然是用這個Write Pixel去寫的。
Nanite中的材質處理
有了Visibility buffer後,我們實際的材質pass會為每種材質繪製一個draw call,這裡我們在每個cluster用了32位的材質資訊去儲存,有兩種編碼方式共享這32位,每個三角面都有自己對應的材質索引,支援最多每個物件有64種材質,所以需要6位去編碼。
普通的編碼方式一共有兩種,一種是fast path直接編碼,這時只要每個cluster用的材質不超過三種就可以,比如每一種64個材質,我需要用6位來表示索引是第幾位,用掉3X6=18位還剩下14位,剛好每7位分別存第一,和第二種材質索引的三角面片數的範圍,因為7位可以存cluster 128個面, 這是最大範圍了。
前幾個面索引用第一種,剩下的範圍用第二種,再多出來的就是第三種。當一個cluster超過3種材質時,我們會用一種間接的slow path,高7位本來存第一種材質,三角面片的範圍的那7位,我們現在padding 0 剩餘其中19位存到一個全域性的,材質範圍表的Buffer Index,還有6位存Buffer Length,Slow path會間接訪問全域性的GPU上的材質範圍表,每個三角面在表裡面順著entry找自己在哪一組範圍內。
這個結構裡存有兩個8位三角面index開始和結束,6位(64種)材質index,其實這種方式也很快。大家想一下,其實我們大部分材質、模型,就算用滿64個材質,我切成小小的cluster以後,128個面裡你切了好多section,超過三種材質的可能性其實很低。
這裡可以看到不同的繪製物件,它在Material Index表裡面其實順序是不一樣的,我們需要重新統一對映材質ID,也能幫助合併同樣材質的shading計算開銷。
在處理Nanite的mesh pass時,我們會對每一種material ID做一個screen quad的繪製,這個繪製只寫一個“材質深度”,我們用24位存“材質深度”可表示幾百萬種材質,肯定是夠了。每一種材質有一個材質深度平面,我們利用螢幕空間的小Tile做instanced draw,用深度材質的深度平面做depth equal的剔除,來對每種材質實際輸出的Gbuffer做無效畫素的剔除。
那為什麼要切tile做instanced draw呢?因為就算用硬體做Early Z,做了rejection,也還是會耗一些時間的。所以如果在vs階段,某個tile里根本沒有的材質的話,就能進一步減少開銷,具體可以看ExportGbuffer.usf裡的FullScreenVS這裡的處理。
Nanite中的串流
處理完渲染部分,我們來看看串流。因為時間關係,我這裡可能要稍微簡化一下:因為資源很大,我們希望佔用記憶體是比較固定的,有點類似VT這種概念。但是geometry對比virtual texture有特殊的challenge。
還記得之前lod選擇的時候我們說過,最終結果剛好是讓DAG上有一個乾淨的Cut,所以如果資料還沒進來,這個cut就不對了,我們也不能在cluster culling時加入已有資料資訊的判斷,只能在runtime去patching這個實際的資料指標。
所以我們保留了所有用來culling的層級資訊,讓每個instance載入的時候都在GPU裡面,只streaming實際用到的geometry的細節資料。這樣做有很多好處——在新的物件被看到的一瞬間,我們最低一級的root那一級的cluster還是有的,我們就不用一級一級請求。
並且我有整個cluster表,所以我可以在一幀中就準確知道,我feedback時實際要用到的那些cluster實際層級的資料。整個層級資訊本身是比較小的,在記憶體裡的佔用,相對來說不那麼可觀。
回憶之前culling的過程可以知道,我們在streaming粒度最小的時候, 也是在cluster group層級的,所以我們的streaming會按照我剛剛說的cluster group來切配置。因為有些切割的邊界最好是在cluster group的中間,所以我們會有一些partial group的概念,在最後讓GPU發出請求。
在哪個cluster group裡,我就發這個group所在的那個page。如果我是partial的切到幾個page,我就會同時發這幾個page的請求。載入完之後,我會重新在GPU上patch,我剛剛整個culling的演算法,條件如果變成了是葉子節點,我剛剛說的誤差滿足條件裡還有一個並行條件——是不是葉子節點。
除了真的lod0的cluster是葉子節點,還有就是我現在沒有填充patch完、沒有載入進來的時候,記憶體裡最高、最精細的那一級是什麼?也是葉子節點,總體概念就是這樣的。
Nanite中的壓縮
實際上,我們在硬碟裡利用了通用的壓縮,因為大部分的主機硬體都有LZ77這類通用的壓縮格式,這種壓縮一般都是基於重複字串的index+length編碼,把長字串和利用率高的字串利用Huffman編碼方式。
按頻度來做優化的,我們其實可以重新調整。比如在我們切成cluster以後,每個cluster的index buffer是高度相似的,我們的vertex 在cluster的區域性位移又很小,所以我們可以做大量的position量化,用normal八面體編碼把vertex的所有index排到一起,來幫助重複字串的編碼和壓縮。
其實我們每個三角形就用一個bit,表示我這個index是不是不連續下去要重新開始算,並且另外一個bit表示重新開始算的朝向的是減還是加,這樣頂點資料跨culster的去重,做過這樣的操作後,我們磁碟上的壓縮率是非常非常高的。當然,我們還在探索進一步壓縮的可能性。
Nanite的未來與其他
由於時間關係, 藉助Nanite其他的一些feature,尤其是Virtual Shadow Map,我們可以高效地通過Nanite去做多個view的渲染,並且帶投shadow的光源——每個都有16k的shadowmap,自動選擇每個texel投到螢幕一個pixel的精度,應該在哪個miplevel裡面,並且只渲染螢幕可見畫素到shadowmap,效率非常高,具體細節這裡就不詳細講了。
接下來我們看看Nanite未來有什麼樣的計劃:儘管我們目前只支援了比如純opaque的剛體幾何型別,對於微小物體,最後我們還是會用Imposter的方式來畫,但是在超過90%的情況下,場景中其實都是全靜態物件。
所以目前的Nanite,其實已經能處理複雜場景的渲染,在大部分情況下都能起到非常大的作用。至於那些不支援的情況,我們依然會走傳統管線,然後整合起來。當然,這遠沒有達到我們的目標,我們希望以後能支援幾乎所有型別的幾何體,讓場景裡不再有概念,不再需要去區分哪些物件是啟用了Nanite的,包括植被、動畫、地形、opaque、mask和半透。
伴隨Nanite的研究,我們也希望達成一些新技術,比如核外光線追蹤,就是做到讓實際ray tracing的資料,真的是Nanite已經載入進來的細節層級的資料。當然,離屏的資料可能還是proxy mesh。
另外,因為我們現在已經不支援曲面細分了,所以也希望在Nanite的基礎上做微多邊形的曲面細分。
Lumen
UE5的另一大功能Lumen,是全新的全動態GI和反射系統,支援在大型高細節場景中無限次反彈的漫反射GI,以及間接的高光反射,跨度可以從幾公里到幾釐米,一些CVar的設定甚至可以到5釐米的精度。
美術和設計師們可以用Lumen建立更加動態的場景。譬如做實時日夜變化、開關手電筒,甚至是場景變換。比如炸開天花板後,光從洞裡射進來,整個光線和場景變化都能實時反饋。所以Lumen改善了烘焙光照帶來的大量迭代時間損失,也不需要再處理lightmap的uv,讓品質和專案迭代效率都有了很大提升。
為了跨不同尺度提供高質量GI,Lumen在不同平臺上也適用不同的技術組合。但是目前Lumen還有很多功能不足正在改善。我們先來簡單瞭解下Lumen的大框架:為了支援高效追蹤,我們除了支援RTX硬體的ray tracing,其他情況下我們也用Lumen在GPU上維護了完整的簡化場景結構,我們稱之為Lumen scene。
其中部分資料是離線通過mesh烘焙生成一些輔助的資訊,包括mesh SDF和mesh card,這裡的card只標記這個mesh經過grid切分之後,從哪些位置去拍它的一些朝向,和Bounding Box的一些標記。
利用剛剛這些輔助資訊,和Nanite的多view高效光柵化生成Gbuffer,以及後續需要用到的其他資料,執行時會通過兩個層面更新LumenScene:一層是CPU上控制新的Instance進來,或者一些合併的streaming的計算;另一層是更新的GPU資料,以及更新LumenScene注入,直接和間接Diffuse光照到光照快取裡面。
我們會基於當前螢幕空間放一些Radiance Probe,利用比較特殊的手段去做重要度取樣。通過高效的Trace probe得到Probe裡面的光照資訊,對Probe的光照資訊進行編碼,生成Irradiance Cache 做spatial filter。
當然,接著還會有一些fallback到global世界空間,最後再Final Gather回來,和全螢幕的bentnormal合成生成,最終全螢幕的間接光照,再在上面做一些temporal濾波。這就是我們Diffuse整個全屏的光照,最後再跟Direct光照合起來,就得到了最終的渲染結果。
Lumen中的Tracing
Lumen的整體框架是軟體追蹤,靠Mesh SDF來做快速的Ray Tracing。在硬體允許時,我們會用RTX,這個今天不展開講。Lumen的追蹤是個Hybrid的方案,包括優先利用HZB做螢幕空間的Trace,如果失敗的話,我們在近距離用一個全屏做Mesh SDF的Trace,這裡因為Mesh SDF的instance做遍歷效率其實還比較低。
因為用bvh在GPU上訪問時,樹形結構的快取一致性很不好,所以我們只在很近距離1.8米內做第一層級的加速結構,這時我們利用一個簡單的Froxel去做grid劃分,快速求交所有instance的Bounding Sphere和對應cell相交結果,並存在對應cell的列表裡,這是全屏做一次的。
接下來在tracing時,我每次只需要訪問當前tracing點,比如marching以後所在的位置,所在的cell就能很快算出來,然後直接查詢裡面的instance列表,將第二層加速結構實際的,以及查出來列表裡instance的SDF,都做一遍marching,取一個minimum值。
對於稍遠一點的,我們會對場景做一個合併生成Global的SDF,它是個clipmap。但因為提高精度以後,資料儲存等各方面每翻一倍精度會有8倍增加,我們會有一些稀疏的表達,我之後會簡單講一下。
在都沒有trace到的情況下,我們會迴圈Global SDF的clipmap,對每一級clipmap做loop,直到Global SDF。比如二百多米全都沒有trace到,那就是miss。當然,我們在之前的Demo裡也用了RSM做最後的fallback,現在這個版本我們還沒有放進去。
在SDF生成時,tracing我們都會做一些保守的處理,保證不會有薄牆被穿透。SDF其實是個volumetric,按voxel間隔來取樣的生成過程,如果我的面很薄,在你的voxel精度以內,其實我們會有一些保守處理。
Lumen與場景結構
隨之而來的問題是,我們trace到了某個表面之後,SDF裡面沒有辦法拿到我們實際需要的資料,只能幫助快速找到交點位置,這個時候我們能拿到什麼?近場MeshSDF時MeshId是我知道的,因為遍歷列表的時候存了;另外我還知道SDF,所以可以靠SDF的gradient算出對應的normal,但是我有ID、normal和位置,要怎樣得到我要的Radiance呢?包括Gbuffer的一些資料,這時我們是沒有三角面片資料來插值計算的,沒有各種材質的屬性,所以我們需要一種高效的引數化方法。
我們使用了一種平鋪的CubeMapTree結構:首先在Mesh匯入時我們會預先處理,剛剛提到生成一組Card的描述,在runtime的時候,我們對放在地圖裡的每個例項,會根據mesh的Card資訊實際利用Nanite高效光柵化,生成對應的Gbuffer。
Atlas在一張大的Atlas裡面,其實是幾張裡面存了MRT,存了三張——包括albedo,opacity,normal,depth這樣的資訊。存的這個Atlas我們叫做Surface Cache,其實就是大家最終看到的LumenScene。當然,LumenScene還會經過SDF tracing,然後做tri-planar reprojection,這其實就是我們 tracing的結果。
我們tracing時tracing到哪個位置,就會找到它對應三個方向的Lumen card,把光柵化完的那些資訊tri-planar reproject出來,得到的就是這個點要的資訊。包括Gbuffer、Radiance資訊。
Radiance資訊從哪裡來呢?是在生成這個card時,還會做直接的光照注入,然後生成它Irradiance的Atlas,並且這個Atlas中會根據維護的budget更新對應的Card,從texel出發,利用GlobalSDF去trace上一幀的lighting狀態,也就是上一幀LumenScene的資訊。
所以我們用螢幕空間Probe去trace時,trace到的那個Irradiance cache裡的東西,就是多次反彈的結果。這個Atlas裡card存的cache,其實都是2的整數次冪,為了方便我們做mip。因為我們有些階段要用prefilter的mip,利用conetracing快速地做prefiltering結果的tracing。對於更遠的Ray,我們其實在trace的時候,就已經藉助的GlobalSDF,超過1.8米時,這個時候我們也沒有對應的MeshID了。
所以類似地,在對應生成GlobalSDF的clipmap時,我們也會用Surface Cache生成一個voxel Lighting Cache,也就是LumenScene更低精度的voxel的表達。這個voxel Scene就是來自Cube Map Tree預處理後,radiance合併生成出來的。
這時我們每一幀都會重新生成voxel Lighting Cache,整個Lumen的結構是持續存在GPU上的,在CPU上維護對它的增減。我們哪些東西重新Streaming進來了,視角調整以後哪些card變得可見,為了控制開銷,我會每幀固定更新一定數量的card,並且根據對應的Lighting型別,對這個Surface cache做一些裁減。對於那些tracing時不在螢幕中的shadow遮擋,我們都是靠Global SDF Trace來做的。
Final Gather
有了Tracing的手段,又從中獲得了想要的資料的資訊後,我們就要解決最終的GI問題了。傳統模式中,比如Cards裡存的是Surface Cache,已經有了多次反彈的照度資訊,這裡我們已經把追蹤到的表面快取不一致的求解計算分離到Card Capture和Card光照計算部分,就只需要在螢幕空間直接來Trace Ray,Trace這些Surface Cache裡的Irradiance就可以了。
傳統做RTX GI時,往往只能支撐1-2spp在Gbuffer發出BentNormal半球空間均勻分佈的光線,如果靠SpatialTemporay,方差引導的這種濾波,在光線相對充足的情況下效果會非常好,但是當光線很不充足,譬如只有一束光從門縫或小視窗照進來時,離遠一點的地方你Trace出來的Ray能取樣到,實際有光源的地方概率太低,導致在濾波前的畫面資訊實在太少,最終濾波完的品質也是非常差、不能接受的。
我們的方法,是利用遠低於Gbuffer解析度的Screen Space的Probe,約每16個畫素,根據實際畫素插值失敗的情況下,我們在格子裡面還會進一步細化放置,放到一個Atlas裡,我的每個Probe其實有8×8個Atlas,小的一個八面體投影的就是半球,自己World Space normal的半球,均勻分佈我的立體角朝向的那個Tracing的方向,每一幀我還會對這個取樣點做一些jitter,之後再去插值。
我們也會在畫素平面,將最後全屏每個畫素按照BRDF重要度取樣,找周圍Screen的Probe做跟我方向一致的weight調整,再去做插值,然後在計算probe的時候,我們利用半球投到八面體的方式,存了8×8的畫素全都Atlas到一起,在細化時一直往下放。
所以最壞的情況,是比如每個畫素都是一個前景,下一個畫素就是一個後景——這其實不太可能,只是極端情況。這種情況我就變成要細化到每個畫素,又變成逐畫素去做這個tracing的Probe Cache。為了避免這種情況,我們其實是粗暴地限制了整個Atlas的大小,也就是最細化的東西,我填不下就不要了。
這樣的好處是,我按照1/16的精度去做的Screen Probe,其實是1/256的精度,即使8×8我處理的畫素數還是以前的1/4或者1/8,在做Spatial Filter最後每個畫素插值時,我只要做Screen Probe3×3的filter,其實就相當於以前48x48的filter大小,而且效率很高。並且在求解間接的環境光蒙特卡洛積分時,可以靠上一幀這些ScreenProbe裡reproject回來的Incoming Radiance的值,作為lighting的importance sampling的引導。
同樣,BRDF也可以這樣做。譬如BRDF值小於0的部分,無論入射光如何都不會貢獻出射,隨便這個方向上lighting在上一幀的incoming radiance。在這個點上有多少,這個朝向有光過來,我貢獻也是0——我不需要它,所以我最終就把這兩個東西乘到一起,作為我新的這一幀probe的importance sampling的方向。
最後,我就會根據這個方向去tracing,之後radiance會存到跟它對應起來另外一張8×8的圖裡,Atlas到一起。對於小而亮的部分離的表面越遠,每幀又有jitter又有方向,引導方向不一樣。有時沒追蹤到,它的噪點就會比較多,並且trace長度越長光線的一致性也不好,所以相反離得遠的光源,相對貢獻得光照變化頻率也比較低。因為我離的很遠以後區域性光有一些位移,對我這裡的影響是很小的。
所以我們可以用一個世界空間的probe來處理,因為這個時候可以做大量的cache,這裡我的世界空間也是一個clipmap,它也是稀疏儲存的。因為只有我Screen Space的Probe Tracing訪問不到的東西,我才會去佈置更多的World Space的Probe去做更新處理,這裡就不展開講了。
最終,我們需要在全解析度的情況下做積分,這時有一個辦法,就是根據全解析度畫素得到BRDF取樣,方法就是我剛才說的,從Screen Probe裡面找。比如8×8畫素周圍的都去找跟它方向一致的weight去插值,但這樣噪點還是很多,所以我們其實是從它的mip裡面去預處理,從filter過的結果裡去找。
這樣還會有一個問題:我自己朝向的平面,比如8×8畫素周圍的都去找跟它方向一致的weight去插值,所以最終我們把八面體的radiance轉成了三階球諧,這樣全解析度的時候能非常高效的利用球諧係數做漫反射積分,這樣的結果質量和效率都很好。
最後的最後我們又做了一次,我對每個畫素都做完之後,再做一次temporal的濾波,但是會根據畫素追蹤到的位置的速度和深度來決定我這個畫素的變化,是不是快速移動物體區域投影過來的,來決定我這個temporal filter的強度。
我temporal filter越弱,其實就相當於前面我去取樣的時候積分起來的時候,我取樣周圍3×3 Spatial Filter效果就越強。整體上Lumen的框架就是這樣,我略過了大量細節和一些特殊處理的部分。譬如半透明物體的GI沒有講到,Spectular我也沒有特殊講,但是像spectular在粗糙度0.3到1的情況下,和這裡importance sampling的diffuse其實是一致的。
Lumen的未來
在未來,我們也希望能做進一步改進,比如鏡面反射,Glossy反射我們已經能很好處理,但是鏡面反射在不用硬體追蹤的情況下,現在Lumen效果還是不夠的,包括SkeletalMesh的場景表達方式、破碎物體的場景表達方式,以及更好處理非模組化的整個物體。因為現在模組化整體captured card或者SDF的各種精度處理,可能還不夠完善。
我們希望提升植被品質,以及更快速地支援光照變化,因為我們有很多hard limiter的更新,比如card數量之類的,會導致你過快更新時跟不上。最後,我們還希望能支援更大的世界,譬如可以串流SDF資料,以及做GPU driven的Surface Cache。關於Lumen我們今天就先講到這裡。
其他功能與Q&A
講完兩大招牌功能,我們快速過一下別的功能:比如最常被大家提到的大世界支援。從UE5開始我們有了更好的工具,比如World Partition就升級成了全新的資料組織方式,配合一套streaming系統,我們不需要手動處理runtime的streaming,引擎會幫你自動切分出不同的Partition,自動處理載入策略。
而且在這個基礎上,我們又有Data Layer對於不同邏輯的處理,有World Partition Stream Policy根據layer對不同的Policy的定製,有Level Instance——可以把Level看成Actor、巢狀組成模板、模組化搭建地圖,並且在Level Instance層級上設定Hlod的引數。
為了協同工作,我們還引入了One File Per Actor,大家每次在地圖上編輯或新增時,其實只改到了一個獨立的actor所對應的檔案,檔案鎖的粒度比較細,就不會去動整個地圖檔案,這樣引擎也會自動幫你管理這些散檔案的changelist生成。
最後,我們還做了大世界的精度支援,把整個Transform的各種計算都改到了雙精度浮點支援。另外,我們在Mobile上也做了更多支援,比如Turnkey全新的打包工作流程,移動端延遲渲染也進入了beta階段。
除此之外,iOS我們也做了很多改進,在正式版本我們新增了opengles延遲渲染管線的支援,比如mali上的pixel local storage。同時我們也加入了DFShadow支援,以及一些新的shading model:例如和pc統一利用Burley SSS引數驅動的移動版本的preintegrated皮膚。
同時我們終於對DXC下的半精度做了支援,而且把所有的Metal Vulkan openGLES都用DXC做了轉換。同時我們還加入了point light shadow、CSM cache和頻寬優化過的565的RVT,做了全新的 gpu instance culling和更高效的auto-instancing等功能。
對於其他的各種平臺,我們Unreal Turnkey工作流是跨所有平臺開發打包釋出的流程。目標是一鍵就把專案釋出到任意支援的平臺的全新裝置上,自動完成所有工具鏈和SDK的安裝部署,從4.25後面版本開始我們已經支援下一代主機了,伴隨接下來《堡壘之夜》在這些平臺上的優化和遷移,UE5上我們對這些支援會更完善一些。
在這些主機平臺上我們對體積雲、物理場等,包括載入效率 TSR等都做了大量的優化。也有大量記憶體優化(尤其DX12,buddy allocator改pooling)。rdg insights提供了對RHI層的每幀中間渲染資源的複用的這種profiling編輯器。
編輯器我們改善了使用者體驗,用了比較深色的主題的統一配色,大家可以自己去靈活配置。擴大了視窗的利用率,整合Quixel Bridge能方便的瀏覽bridge庫中的資產並直接拖到場景中。
另外我們也加入了Game Feature plugin,是一個全新的資料驅動的框架,類似之前的遊戲框架,現在可以乾淨的分離到gamefeature外掛裡方便的啟用和解除安裝,做Game Features和模組化的Gameplay,你可以把各種類、資料、內容以及對應的debug/cheat相關的內容封裝到不同的外掛模組裡面,可以針對性的通過component來在特定的Actor上啟用,這樣 gameplay模組的擴充套件性、重用性、穩定性都能有很好的改善。
MetaSounds也是在EA版本就釋出的, 是讓音訊系統有跟渲染管線類似的可程式設計性的一套工具。類似audio用的shader,所以可以更靈活的使用和操縱原始的音訊資料。MetaSounds用來替換以前比較簡陋的soundcue,可以更高效、更靈活、更可擴充套件,Quartz 是暴露給bp系統的一套用來精確取樣音訊資料和事件的系統,可以讓你完善地完成類似吉他英雄這種的需要非常準確的幀時間的取樣音效、同步控制的這樣一套系統。
另外我們還有別的一套系統(Audio Synesthesia),可以幫助你離線也可以實時對音訊做分析,然後把音訊做視覺化。比如利用niagara或者材質做視覺化。
chaos的物理系統,在UE5開始我們整個物理系統都會切換到chaos,它的一大目標是能有更多的gameplay的互動介面,跟gameplay系統結合更緊密,並且支援網路同步。Early Access版本中我們Rigid body模擬已經修了很多bug,減少了碰撞對的生成、預先做了更多剔除、修復了一些查詢巨大三角面時的錯誤結果、改進了超高速移動物體或很低幀數下預設啟用CCD時不正常的效果、也改了ragdoll模擬穩定性的一些bug。
在正式版本中,對於PBD的效能和穩定性做了更多優化,做了很好的載具的支援,並且支援網路同步的物理模擬的框架。然後還有各種特殊的solver,比如布料和流體的模擬也得到了增強和改善。
關於整套Unreal Insights工具,我們改善了現有的Timing, Loading, Network和Animation 的功能,同時新版本里面還有Memory Insights。通過Memory Insights,我們能按照LLM的方式按型別來看記憶體是怎麼分配的,然後我們如果加Memory Malloc這樣一個引數的話,Memory Insights還可以幫助大家看到所有的呼叫、堆疊、分類 甚至查Memory Leak。有很多這種好用的工具,剛剛也說到RDG Insights,來看整個渲染的管線資源分配、複用、生命週期各種各樣比較好用的功能。
最後是Meta Human Creator,高質量的數字人建立工具,可以幫助小團隊在幾分鐘或者幾小時內就創作出3A品質的、帶完整rigging,可以用來做動畫的數字角色。伴隨UE5的釋出,我們MHC的建立角色在引擎裡面會有更多更好的表現。這樣品質的數字角色本來只在3A作品裡能看到,現在每個人都能製作,並且通過bridge直接在content browser拓出來,在引擎裡面使用。
Q & A
UE5.0正式版會在什麼時候釋出?
王禰:目前預計是明年上半年,可能在4月份左右釋出。
UE5.0之後還會支援曲面細分嗎?
王禰:由於不少硬體平臺曲面細分效率的問題,我們打算徹底去掉。未來我們會嘗試用Nanite去做,但是目前還沒有做到。所以現在的workaround如果不做變形,那就只能靠Nanitemesh或者靠Virtual Heightfield Mesh來處理。
原文:https://mp.weixin.qq.com/s/uBEDKbA7B7qlBmgwkCef1w
大家好,我是來自Epic Games中國的首席引擎開發工程師王禰,主要負責引擎相關技術的開發者支援工作,幫助國內的開發者解決各種使用UE開發專案時遇到的技術問題,同時也會參與部分引擎工作的開發。
今天我主要為大家介紹UE5的新功能。當然,UE5有太多新功能了,我會挑大家最關心的Nanite和Lumen多講一些。
在開發UE5的時候,我們的目標是:提高各方面的渲染品質,讓構建的數字世界更動態一些,提高整個虛擬世界構建和表現的上限;同時我們也希望提高開發和迭代的效率,提供更多更豐富易用的工具,改善使用者編輯和創造的體驗,降低大家使用的門檻。
相比UE4,UE5做了大量改進,主要是Nanite和Lumen等渲染技術,構建整個大世界的工具以及底層對渲染大量物件生成一些Proxy Mesh技術。
在協同工作方面,改進包括管理大量資產的效能、編輯器和使用者體驗、次世代的一些動畫技術Chaos、網路同步的物理系統,以及一些全新模組、遊戲框架、AI叢集系統、進一步完善的Niagara系統以及各種音訊模組,像Meta Sound之類的功能都有非常大的改善。
Nanite功能
首先是我們主打功能之一的Nanite,Nanite是全新的Mesh表示形式,是一種虛擬微表面幾何體,解放了之前美術同學製作模型時對大量細節的限制,現在可以直接使用真正用於影視級別的資產,幾百萬甚至上億面的模型直接可以匯入到引擎中,非常順暢的放很多的例項去高效的渲染。
例如來自照片建模或者Zbrush雕刻的高模,或者CAD的資料都可以直接放進來,我們有過測試可能幾萬甚至十幾萬的,這些例項每個都是百萬面以上的都在view內能被看到的情況下,用Nanite的方式渲染依然能在2080s這樣的GPU上跑到60fps,解析度可能是1080P左右。Nanite還在開發中,還有很多功能支援並不完善,我們在後續會慢慢改進。
Nanite支援的平臺主要是新一代的主機和PC,相比去年我們放出來的Lumen in the land of Nanite ,這項技術的品質和效率都有不少提升,包括磁碟的編解碼效率和壓縮、支援Lightmap烘焙光照、支援可破碎物體、對光線追蹤場景或者物理碰撞支援自動生成減面高質量的替代Proxy mesh。
另外通過這種方式,我們還可以用解析微分法決定畫素誤差,使誤差肉眼不可見。最後,我們還高效支援了多光源投影,整個Nanite管線基於GPU driven的管線產生,主要流程我會分以下幾個部分來講。
為了讓大量物件在場景上高效剔除,我們需要把所有場景資料都送到GPU上。其實從4.22開始,引擎就慢慢在不影響上層使用的情況下,在底層做出改進了,使渲染器成為retained mode,維護了完整的GPU scene,Nanite在這個基礎上做了大量新的工作。
Nanite中cluster的生成
接下來我們簡單講講Nanite的工作機制。首先在模型匯入時,我們會做一些預處理,比如按128面的cluster做切分處理。有了這些cluster以後,我們就可以在距離拉遠拉近時,做到對每個cluster group同時切換,讓肉眼看不到切換lod導致的誤差沒有crack,同時還能對這些不同層級、細節的cluster做streaming,這其實就是Nanite最關鍵的部分。
cluster的生成主要分以下幾步:首先,原始的mesh lod0資料進來後,我們會做一個graph partition。partition條件是比如說我希望共享的邊界儘可能少,這樣我在lock邊界做減面處理時,減面的質量會更高一些。
第二是希望這些面積儘可能均勻、大小一致,這樣在lod計算誤差處理投影到螢幕上時,都是對每個cluster或cluster group一致處理。我們會把其中一組cluster合併成一個cluster group,又一次按照“lock的邊界儘可能少、面積儘可能均勻”的條件找出,一組組cluster生成group,對這個group內cluster的邊解鎖,等於把這組group看成一個大的cluster,然後對這組group做對半的減面。
減完面後,我們可以得到一個新的cluster誤差,我會對這個減面的group重新做cluster劃分。這時,cluster的數量在同一個group裡其實就已經減半,然後我會計算每個新的cluster誤差。大家要注意,這個過程是迴圈的,遞迴一直到最終值 ,對每個instance、模型只生成一個cluster為止。這裡有一個比較關鍵的點:我們在減面生成每個cluster時,會通過減面演算法(QEM)得到這個cluster的誤差值並存下。
除此之外,我們還會存group的誤差值,這個值其實就是更精細的那一級cluster group裡cluster的最大誤差值,和我新一級裡產生的每個cluster誤差值取maximum得到的值。這樣我就能保證這個cluster每次合併的group,去減面到上一級的group裡的cluster時的誤差值,永遠是從不精細到精細慢慢上升的狀態。
也就是說,我從最根結點的cluster慢慢到最細的cluster,裡面的error一定是降序排序的。這一點很重要,因為它能保證後續選擇culling和lod時,恰好是在一個cluster組成的DAG上。因為cluster會合並group,group生成打散以後在下一級裡,又會有一個共享的cluster。
有了這個降序排列的誤差,我就能保證這個DAG上有一刀很乾淨的cut,使我的邊界一定是跨lod的cluster group的邊界。最後,我們對這個生成的各個lod層級的cluster分別生成bvh,再把所有lod的cluster的bvh的root,掛到總的bvh root上。
當然,這裡還有很多額外處理,現在沒有講是考慮到做streaming時的一些分頁處理。這個分頁可能會對cluster group造成切割,所以cluster group,還有一些group partition的概念,我們這裡不做細化。
另外,對於一些微小物體離得很遠以後的情況,我們減到最後一級cluster,其實它還是有128個面,那如果場景裡非常小的東西位於很遠的地方,這又是一個模組化的構成。我們又不能直接把它culling掉,這種情況下,我們會有另外一種Imposter atlas的方式,這裡我也不展開講了。
Nanite裁剪流程
最後裁減到它bvh的葉子節點,其實就是我們剛才說的cluster group,然後再對其中的cluster做裁減。裁減完之後,我們就會有一個特殊的光柵化過程,然後我們就能得到新的Depth Buffer,重新構建HZB,再對這個新的HZB做一遍裁減。
前面那次HZB的可見性,我們用了上一幀可見的instance來做,做完之後形成新的HZB,我們再把上一幀不可見的,在這一幀內所有剩下的再做一遍,就能保守地保證沒有什麼問題。
重新經過光柵化後,生成到新的visibility buffer,再從visibility buffer經過material pass,最終合入Gbuffer。具體做culling時會有一些問題,比如剛才cluster生成時我們說到過,生成cluster group的bvh結構,我們在CPU上不會知道它有多少層。
也就是說,如果我要去做的話,CPU要發足夠多的dispatch,這時比如小一點的物件,它空的dispatch就會很多,這種情況下GPU的利用率也會很低。
所以我們選擇了一種叫persistent culling的方法,利用一個persistent thread去做culling,也就是隻做一次dispatch,開足夠多的執行緒,用一個簡單的多生產者、多消費者的任務佇列來喂滿這些執行緒。
這些執行緒從佇列裡執行時,每個node會在做封層級別剔除的同時產生新的node,也就是bvh node,Push back回新的。在可見的children的列表裡,我們一直處理這個列表,直到任務為空。
這裡的處理分為幾種型別:首先在一開始的node裡,只有我們開始構建的bvh的節點,直到我一直做剔除,剔除到葉子節點以後,裡面是個cluster group,再進入下一級,就是這個group裡面所有的cluster culling。最後cluster並行獨立地判斷,自己是否被culling 掉,這裡其實和剛剛lod選擇的條件是一模一樣的。
還記得我剛才說的error的單調性吧?因為這裡的cluster中,所有lod都是混合在一起的,所以我們每個cluster在並行處理時,我不知道父級關係是什麼樣的,但我在每個cluster上存了自己的誤差,和我整個group在父一級上的最大誤差,所以這時我就知道,如果我自己的誤差足夠小,但是我Parent的誤差不夠小,我就不應該被culling掉。
同理,跟我共處一個cluster group的這些節點,如果它在我上一級lod裡,也就是比較粗的那一級裡,那它的error一定不夠大,所以上面那一級lod所在的整個group都會被拋棄掉,而選中下一個。
但是下一個裡面,其實還是可能會有一些誤差太大的——它的誤差如果足夠大,就意味著它在再下一級更精細的地方,肯定屬於另外一個cluster group。所以它又在下一級的cluster group裡又有一個邊界,和它下一級的cluster group邊界接起來會沒有接縫,整個cluster的選擇就是這樣並行做的。
同時,對應自己cluster group的parent,剛剛我們說了,肯定會被剔除掉。這樣就能保證我們能分cluster group為邊界,去對接不同lod層級的cluster,並使經過culling存活下來的cluster來到特殊的光柵化階段。
Nanite中的光柵化
由於當前圖形硬體假設了pixel shading rate,肯定是高於triangle的,所以普通硬體光柵化處理器在處理非常的微小表面時,光柵化效率會很差,完整並行也只能一個時鐘週期處理4個triangle,因為2x2畫素的會有很多quad overdraw,所以我們選擇使用自己用compute shader實現的軟體光柵化,輸出的結果就是Visibility Buffer。
我這裡列出的結構總共是64位的,所以我需要atomic64的支援,利用interlocked maximum的實現來做模擬深度排序。所以我最高的30位存了depth、instanceID、triangleID。因為每個cluster128個面,所以triangleID只要7位,我們現在其實整個opaque的Nanite pass,一個draw就能畫完生成到visibility Buffer,後續的材質pass會根據數量,為每種材質分配一個draw,輸出到Gbuffer,然後畫素大小的三角面就會經過我們的軟體光柵化。
我們以cluster為單位來計算,比如我當前這個cluster覆蓋螢幕多大範圍,來估算我接下來這個cluster裡是要做軟體光柵化還是硬體光柵化。我們也利用了一些比如浮點數當定點數的技巧,加速整個掃描線光柵化的效率。
比如我在subpixel sample的時候是256,我就知道是因為邊長是16。亞畫素的修正保證了8位小數的精度,這時我們分界使用軟光柵的邊界,剛好是16邊長的三角面片的時候,可以保證整數部分需要4位的精度,在後續計算中最大誤差,比如乘法縮放導致小數是8位、整數是4位,就是4.8。
乘法以後精度縮放到8.16,依然在浮點精度範圍內,實際的深度測試是通過Visibility buffer高位的30位的深度,利用一些原子化的指令,比如InterlockedMax實現了光柵化。大家感興趣可以去看看Rasterizer.ush裡面有Write Pixel去做了,其實我們為了並行地執行軟體光柵化和硬體光柵化,最終硬體光柵化也依然是用這個Write Pixel去寫的。
Nanite中的材質處理
有了Visibility buffer後,我們實際的材質pass會為每種材質繪製一個draw call,這裡我們在每個cluster用了32位的材質資訊去儲存,有兩種編碼方式共享這32位,每個三角面都有自己對應的材質索引,支援最多每個物件有64種材質,所以需要6位去編碼。
普通的編碼方式一共有兩種,一種是fast path直接編碼,這時只要每個cluster用的材質不超過三種就可以,比如每一種64個材質,我需要用6位來表示索引是第幾位,用掉3X6=18位還剩下14位,剛好每7位分別存第一,和第二種材質索引的三角面片數的範圍,因為7位可以存cluster 128個面, 這是最大範圍了。
前幾個面索引用第一種,剩下的範圍用第二種,再多出來的就是第三種。當一個cluster超過3種材質時,我們會用一種間接的slow path,高7位本來存第一種材質,三角面片的範圍的那7位,我們現在padding 0 剩餘其中19位存到一個全域性的,材質範圍表的Buffer Index,還有6位存Buffer Length,Slow path會間接訪問全域性的GPU上的材質範圍表,每個三角面在表裡面順著entry找自己在哪一組範圍內。
這個結構裡存有兩個8位三角面index開始和結束,6位(64種)材質index,其實這種方式也很快。大家想一下,其實我們大部分材質、模型,就算用滿64個材質,我切成小小的cluster以後,128個面裡你切了好多section,超過三種材質的可能性其實很低。
這裡可以看到不同的繪製物件,它在Material Index表裡面其實順序是不一樣的,我們需要重新統一對映材質ID,也能幫助合併同樣材質的shading計算開銷。
在處理Nanite的mesh pass時,我們會對每一種material ID做一個screen quad的繪製,這個繪製只寫一個“材質深度”,我們用24位存“材質深度”可表示幾百萬種材質,肯定是夠了。每一種材質有一個材質深度平面,我們利用螢幕空間的小Tile做instanced draw,用深度材質的深度平面做depth equal的剔除,來對每種材質實際輸出的Gbuffer做無效畫素的剔除。
那為什麼要切tile做instanced draw呢?因為就算用硬體做Early Z,做了rejection,也還是會耗一些時間的。所以如果在vs階段,某個tile里根本沒有的材質的話,就能進一步減少開銷,具體可以看ExportGbuffer.usf裡的FullScreenVS這裡的處理。
Nanite中的串流
處理完渲染部分,我們來看看串流。因為時間關係,我這裡可能要稍微簡化一下:因為資源很大,我們希望佔用記憶體是比較固定的,有點類似VT這種概念。但是geometry對比virtual texture有特殊的challenge。
還記得之前lod選擇的時候我們說過,最終結果剛好是讓DAG上有一個乾淨的Cut,所以如果資料還沒進來,這個cut就不對了,我們也不能在cluster culling時加入已有資料資訊的判斷,只能在runtime去patching這個實際的資料指標。
所以我們保留了所有用來culling的層級資訊,讓每個instance載入的時候都在GPU裡面,只streaming實際用到的geometry的細節資料。這樣做有很多好處——在新的物件被看到的一瞬間,我們最低一級的root那一級的cluster還是有的,我們就不用一級一級請求。
並且我有整個cluster表,所以我可以在一幀中就準確知道,我feedback時實際要用到的那些cluster實際層級的資料。整個層級資訊本身是比較小的,在記憶體裡的佔用,相對來說不那麼可觀。
回憶之前culling的過程可以知道,我們在streaming粒度最小的時候, 也是在cluster group層級的,所以我們的streaming會按照我剛剛說的cluster group來切配置。因為有些切割的邊界最好是在cluster group的中間,所以我們會有一些partial group的概念,在最後讓GPU發出請求。
在哪個cluster group裡,我就發這個group所在的那個page。如果我是partial的切到幾個page,我就會同時發這幾個page的請求。載入完之後,我會重新在GPU上patch,我剛剛整個culling的演算法,條件如果變成了是葉子節點,我剛剛說的誤差滿足條件裡還有一個並行條件——是不是葉子節點。
除了真的lod0的cluster是葉子節點,還有就是我現在沒有填充patch完、沒有載入進來的時候,記憶體裡最高、最精細的那一級是什麼?也是葉子節點,總體概念就是這樣的。
Nanite中的壓縮
實際上,我們在硬碟裡利用了通用的壓縮,因為大部分的主機硬體都有LZ77這類通用的壓縮格式,這種壓縮一般都是基於重複字串的index+length編碼,把長字串和利用率高的字串利用Huffman編碼方式。
按頻度來做優化的,我們其實可以重新調整。比如在我們切成cluster以後,每個cluster的index buffer是高度相似的,我們的vertex 在cluster的區域性位移又很小,所以我們可以做大量的position量化,用normal八面體編碼把vertex的所有index排到一起,來幫助重複字串的編碼和壓縮。
其實我們每個三角形就用一個bit,表示我這個index是不是不連續下去要重新開始算,並且另外一個bit表示重新開始算的朝向的是減還是加,這樣頂點資料跨culster的去重,做過這樣的操作後,我們磁碟上的壓縮率是非常非常高的。當然,我們還在探索進一步壓縮的可能性。
Nanite的未來與其他
由於時間關係, 藉助Nanite其他的一些feature,尤其是Virtual Shadow Map,我們可以高效地通過Nanite去做多個view的渲染,並且帶投shadow的光源——每個都有16k的shadowmap,自動選擇每個texel投到螢幕一個pixel的精度,應該在哪個miplevel裡面,並且只渲染螢幕可見畫素到shadowmap,效率非常高,具體細節這裡就不詳細講了。
接下來我們看看Nanite未來有什麼樣的計劃:儘管我們目前只支援了比如純opaque的剛體幾何型別,對於微小物體,最後我們還是會用Imposter的方式來畫,但是在超過90%的情況下,場景中其實都是全靜態物件。
所以目前的Nanite,其實已經能處理複雜場景的渲染,在大部分情況下都能起到非常大的作用。至於那些不支援的情況,我們依然會走傳統管線,然後整合起來。當然,這遠沒有達到我們的目標,我們希望以後能支援幾乎所有型別的幾何體,讓場景裡不再有概念,不再需要去區分哪些物件是啟用了Nanite的,包括植被、動畫、地形、opaque、mask和半透。
伴隨Nanite的研究,我們也希望達成一些新技術,比如核外光線追蹤,就是做到讓實際ray tracing的資料,真的是Nanite已經載入進來的細節層級的資料。當然,離屏的資料可能還是proxy mesh。
另外,因為我們現在已經不支援曲面細分了,所以也希望在Nanite的基礎上做微多邊形的曲面細分。
Lumen
UE5的另一大功能Lumen,是全新的全動態GI和反射系統,支援在大型高細節場景中無限次反彈的漫反射GI,以及間接的高光反射,跨度可以從幾公里到幾釐米,一些CVar的設定甚至可以到5釐米的精度。
美術和設計師們可以用Lumen建立更加動態的場景。譬如做實時日夜變化、開關手電筒,甚至是場景變換。比如炸開天花板後,光從洞裡射進來,整個光線和場景變化都能實時反饋。所以Lumen改善了烘焙光照帶來的大量迭代時間損失,也不需要再處理lightmap的uv,讓品質和專案迭代效率都有了很大提升。
為了跨不同尺度提供高質量GI,Lumen在不同平臺上也適用不同的技術組合。但是目前Lumen還有很多功能不足正在改善。我們先來簡單瞭解下Lumen的大框架:為了支援高效追蹤,我們除了支援RTX硬體的ray tracing,其他情況下我們也用Lumen在GPU上維護了完整的簡化場景結構,我們稱之為Lumen scene。
其中部分資料是離線通過mesh烘焙生成一些輔助的資訊,包括mesh SDF和mesh card,這裡的card只標記這個mesh經過grid切分之後,從哪些位置去拍它的一些朝向,和Bounding Box的一些標記。
利用剛剛這些輔助資訊,和Nanite的多view高效光柵化生成Gbuffer,以及後續需要用到的其他資料,執行時會通過兩個層面更新LumenScene:一層是CPU上控制新的Instance進來,或者一些合併的streaming的計算;另一層是更新的GPU資料,以及更新LumenScene注入,直接和間接Diffuse光照到光照快取裡面。
我們會基於當前螢幕空間放一些Radiance Probe,利用比較特殊的手段去做重要度取樣。通過高效的Trace probe得到Probe裡面的光照資訊,對Probe的光照資訊進行編碼,生成Irradiance Cache 做spatial filter。
當然,接著還會有一些fallback到global世界空間,最後再Final Gather回來,和全螢幕的bentnormal合成生成,最終全螢幕的間接光照,再在上面做一些temporal濾波。這就是我們Diffuse整個全屏的光照,最後再跟Direct光照合起來,就得到了最終的渲染結果。
Lumen中的Tracing
Lumen的整體框架是軟體追蹤,靠Mesh SDF來做快速的Ray Tracing。在硬體允許時,我們會用RTX,這個今天不展開講。Lumen的追蹤是個Hybrid的方案,包括優先利用HZB做螢幕空間的Trace,如果失敗的話,我們在近距離用一個全屏做Mesh SDF的Trace,這裡因為Mesh SDF的instance做遍歷效率其實還比較低。
因為用bvh在GPU上訪問時,樹形結構的快取一致性很不好,所以我們只在很近距離1.8米內做第一層級的加速結構,這時我們利用一個簡單的Froxel去做grid劃分,快速求交所有instance的Bounding Sphere和對應cell相交結果,並存在對應cell的列表裡,這是全屏做一次的。
接下來在tracing時,我每次只需要訪問當前tracing點,比如marching以後所在的位置,所在的cell就能很快算出來,然後直接查詢裡面的instance列表,將第二層加速結構實際的,以及查出來列表裡instance的SDF,都做一遍marching,取一個minimum值。
對於稍遠一點的,我們會對場景做一個合併生成Global的SDF,它是個clipmap。但因為提高精度以後,資料儲存等各方面每翻一倍精度會有8倍增加,我們會有一些稀疏的表達,我之後會簡單講一下。
在都沒有trace到的情況下,我們會迴圈Global SDF的clipmap,對每一級clipmap做loop,直到Global SDF。比如二百多米全都沒有trace到,那就是miss。當然,我們在之前的Demo裡也用了RSM做最後的fallback,現在這個版本我們還沒有放進去。
在SDF生成時,tracing我們都會做一些保守的處理,保證不會有薄牆被穿透。SDF其實是個volumetric,按voxel間隔來取樣的生成過程,如果我的面很薄,在你的voxel精度以內,其實我們會有一些保守處理。
Lumen與場景結構
隨之而來的問題是,我們trace到了某個表面之後,SDF裡面沒有辦法拿到我們實際需要的資料,只能幫助快速找到交點位置,這個時候我們能拿到什麼?近場MeshSDF時MeshId是我知道的,因為遍歷列表的時候存了;另外我還知道SDF,所以可以靠SDF的gradient算出對應的normal,但是我有ID、normal和位置,要怎樣得到我要的Radiance呢?包括Gbuffer的一些資料,這時我們是沒有三角面片資料來插值計算的,沒有各種材質的屬性,所以我們需要一種高效的引數化方法。
我們使用了一種平鋪的CubeMapTree結構:首先在Mesh匯入時我們會預先處理,剛剛提到生成一組Card的描述,在runtime的時候,我們對放在地圖裡的每個例項,會根據mesh的Card資訊實際利用Nanite高效光柵化,生成對應的Gbuffer。
Atlas在一張大的Atlas裡面,其實是幾張裡面存了MRT,存了三張——包括albedo,opacity,normal,depth這樣的資訊。存的這個Atlas我們叫做Surface Cache,其實就是大家最終看到的LumenScene。當然,LumenScene還會經過SDF tracing,然後做tri-planar reprojection,這其實就是我們 tracing的結果。
我們tracing時tracing到哪個位置,就會找到它對應三個方向的Lumen card,把光柵化完的那些資訊tri-planar reproject出來,得到的就是這個點要的資訊。包括Gbuffer、Radiance資訊。
Radiance資訊從哪裡來呢?是在生成這個card時,還會做直接的光照注入,然後生成它Irradiance的Atlas,並且這個Atlas中會根據維護的budget更新對應的Card,從texel出發,利用GlobalSDF去trace上一幀的lighting狀態,也就是上一幀LumenScene的資訊。
所以我們用螢幕空間Probe去trace時,trace到的那個Irradiance cache裡的東西,就是多次反彈的結果。這個Atlas裡card存的cache,其實都是2的整數次冪,為了方便我們做mip。因為我們有些階段要用prefilter的mip,利用conetracing快速地做prefiltering結果的tracing。對於更遠的Ray,我們其實在trace的時候,就已經藉助的GlobalSDF,超過1.8米時,這個時候我們也沒有對應的MeshID了。
所以類似地,在對應生成GlobalSDF的clipmap時,我們也會用Surface Cache生成一個voxel Lighting Cache,也就是LumenScene更低精度的voxel的表達。這個voxel Scene就是來自Cube Map Tree預處理後,radiance合併生成出來的。
這時我們每一幀都會重新生成voxel Lighting Cache,整個Lumen的結構是持續存在GPU上的,在CPU上維護對它的增減。我們哪些東西重新Streaming進來了,視角調整以後哪些card變得可見,為了控制開銷,我會每幀固定更新一定數量的card,並且根據對應的Lighting型別,對這個Surface cache做一些裁減。對於那些tracing時不在螢幕中的shadow遮擋,我們都是靠Global SDF Trace來做的。
Final Gather
有了Tracing的手段,又從中獲得了想要的資料的資訊後,我們就要解決最終的GI問題了。傳統模式中,比如Cards裡存的是Surface Cache,已經有了多次反彈的照度資訊,這裡我們已經把追蹤到的表面快取不一致的求解計算分離到Card Capture和Card光照計算部分,就只需要在螢幕空間直接來Trace Ray,Trace這些Surface Cache裡的Irradiance就可以了。
傳統做RTX GI時,往往只能支撐1-2spp在Gbuffer發出BentNormal半球空間均勻分佈的光線,如果靠SpatialTemporay,方差引導的這種濾波,在光線相對充足的情況下效果會非常好,但是當光線很不充足,譬如只有一束光從門縫或小視窗照進來時,離遠一點的地方你Trace出來的Ray能取樣到,實際有光源的地方概率太低,導致在濾波前的畫面資訊實在太少,最終濾波完的品質也是非常差、不能接受的。
我們的方法,是利用遠低於Gbuffer解析度的Screen Space的Probe,約每16個畫素,根據實際畫素插值失敗的情況下,我們在格子裡面還會進一步細化放置,放到一個Atlas裡,我的每個Probe其實有8×8個Atlas,小的一個八面體投影的就是半球,自己World Space normal的半球,均勻分佈我的立體角朝向的那個Tracing的方向,每一幀我還會對這個取樣點做一些jitter,之後再去插值。
我們也會在畫素平面,將最後全屏每個畫素按照BRDF重要度取樣,找周圍Screen的Probe做跟我方向一致的weight調整,再去做插值,然後在計算probe的時候,我們利用半球投到八面體的方式,存了8×8的畫素全都Atlas到一起,在細化時一直往下放。
所以最壞的情況,是比如每個畫素都是一個前景,下一個畫素就是一個後景——這其實不太可能,只是極端情況。這種情況我就變成要細化到每個畫素,又變成逐畫素去做這個tracing的Probe Cache。為了避免這種情況,我們其實是粗暴地限制了整個Atlas的大小,也就是最細化的東西,我填不下就不要了。
這樣的好處是,我按照1/16的精度去做的Screen Probe,其實是1/256的精度,即使8×8我處理的畫素數還是以前的1/4或者1/8,在做Spatial Filter最後每個畫素插值時,我只要做Screen Probe3×3的filter,其實就相當於以前48x48的filter大小,而且效率很高。並且在求解間接的環境光蒙特卡洛積分時,可以靠上一幀這些ScreenProbe裡reproject回來的Incoming Radiance的值,作為lighting的importance sampling的引導。
同樣,BRDF也可以這樣做。譬如BRDF值小於0的部分,無論入射光如何都不會貢獻出射,隨便這個方向上lighting在上一幀的incoming radiance。在這個點上有多少,這個朝向有光過來,我貢獻也是0——我不需要它,所以我最終就把這兩個東西乘到一起,作為我新的這一幀probe的importance sampling的方向。
最後,我就會根據這個方向去tracing,之後radiance會存到跟它對應起來另外一張8×8的圖裡,Atlas到一起。對於小而亮的部分離的表面越遠,每幀又有jitter又有方向,引導方向不一樣。有時沒追蹤到,它的噪點就會比較多,並且trace長度越長光線的一致性也不好,所以相反離得遠的光源,相對貢獻得光照變化頻率也比較低。因為我離的很遠以後區域性光有一些位移,對我這裡的影響是很小的。
所以我們可以用一個世界空間的probe來處理,因為這個時候可以做大量的cache,這裡我的世界空間也是一個clipmap,它也是稀疏儲存的。因為只有我Screen Space的Probe Tracing訪問不到的東西,我才會去佈置更多的World Space的Probe去做更新處理,這裡就不展開講了。
最終,我們需要在全解析度的情況下做積分,這時有一個辦法,就是根據全解析度畫素得到BRDF取樣,方法就是我剛才說的,從Screen Probe裡面找。比如8×8畫素周圍的都去找跟它方向一致的weight去插值,但這樣噪點還是很多,所以我們其實是從它的mip裡面去預處理,從filter過的結果裡去找。
這樣還會有一個問題:我自己朝向的平面,比如8×8畫素周圍的都去找跟它方向一致的weight去插值,所以最終我們把八面體的radiance轉成了三階球諧,這樣全解析度的時候能非常高效的利用球諧係數做漫反射積分,這樣的結果質量和效率都很好。
最後的最後我們又做了一次,我對每個畫素都做完之後,再做一次temporal的濾波,但是會根據畫素追蹤到的位置的速度和深度來決定我這個畫素的變化,是不是快速移動物體區域投影過來的,來決定我這個temporal filter的強度。
我temporal filter越弱,其實就相當於前面我去取樣的時候積分起來的時候,我取樣周圍3×3 Spatial Filter效果就越強。整體上Lumen的框架就是這樣,我略過了大量細節和一些特殊處理的部分。譬如半透明物體的GI沒有講到,Spectular我也沒有特殊講,但是像spectular在粗糙度0.3到1的情況下,和這裡importance sampling的diffuse其實是一致的。
Lumen的未來
在未來,我們也希望能做進一步改進,比如鏡面反射,Glossy反射我們已經能很好處理,但是鏡面反射在不用硬體追蹤的情況下,現在Lumen效果還是不夠的,包括SkeletalMesh的場景表達方式、破碎物體的場景表達方式,以及更好處理非模組化的整個物體。因為現在模組化整體captured card或者SDF的各種精度處理,可能還不夠完善。
我們希望提升植被品質,以及更快速地支援光照變化,因為我們有很多hard limiter的更新,比如card數量之類的,會導致你過快更新時跟不上。最後,我們還希望能支援更大的世界,譬如可以串流SDF資料,以及做GPU driven的Surface Cache。關於Lumen我們今天就先講到這裡。
其他功能與Q&A
講完兩大招牌功能,我們快速過一下別的功能:比如最常被大家提到的大世界支援。從UE5開始我們有了更好的工具,比如World Partition就升級成了全新的資料組織方式,配合一套streaming系統,我們不需要手動處理runtime的streaming,引擎會幫你自動切分出不同的Partition,自動處理載入策略。
而且在這個基礎上,我們又有Data Layer對於不同邏輯的處理,有World Partition Stream Policy根據layer對不同的Policy的定製,有Level Instance——可以把Level看成Actor、巢狀組成模板、模組化搭建地圖,並且在Level Instance層級上設定Hlod的引數。
為了協同工作,我們還引入了One File Per Actor,大家每次在地圖上編輯或新增時,其實只改到了一個獨立的actor所對應的檔案,檔案鎖的粒度比較細,就不會去動整個地圖檔案,這樣引擎也會自動幫你管理這些散檔案的changelist生成。
最後,我們還做了大世界的精度支援,把整個Transform的各種計算都改到了雙精度浮點支援。另外,我們在Mobile上也做了更多支援,比如Turnkey全新的打包工作流程,移動端延遲渲染也進入了beta階段。
除此之外,iOS我們也做了很多改進,在正式版本我們新增了opengles延遲渲染管線的支援,比如mali上的pixel local storage。同時我們也加入了DFShadow支援,以及一些新的shading model:例如和pc統一利用Burley SSS引數驅動的移動版本的preintegrated皮膚。
同時我們終於對DXC下的半精度做了支援,而且把所有的Metal Vulkan openGLES都用DXC做了轉換。同時我們還加入了point light shadow、CSM cache和頻寬優化過的565的RVT,做了全新的 gpu instance culling和更高效的auto-instancing等功能。
對於其他的各種平臺,我們Unreal Turnkey工作流是跨所有平臺開發打包釋出的流程。目標是一鍵就把專案釋出到任意支援的平臺的全新裝置上,自動完成所有工具鏈和SDK的安裝部署,從4.25後面版本開始我們已經支援下一代主機了,伴隨接下來《堡壘之夜》在這些平臺上的優化和遷移,UE5上我們對這些支援會更完善一些。
在這些主機平臺上我們對體積雲、物理場等,包括載入效率 TSR等都做了大量的優化。也有大量記憶體優化(尤其DX12,buddy allocator改pooling)。rdg insights提供了對RHI層的每幀中間渲染資源的複用的這種profiling編輯器。
編輯器我們改善了使用者體驗,用了比較深色的主題的統一配色,大家可以自己去靈活配置。擴大了視窗的利用率,整合Quixel Bridge能方便的瀏覽bridge庫中的資產並直接拖到場景中。
另外我們也加入了Game Feature plugin,是一個全新的資料驅動的框架,類似之前的遊戲框架,現在可以乾淨的分離到gamefeature外掛裡方便的啟用和解除安裝,做Game Features和模組化的Gameplay,你可以把各種類、資料、內容以及對應的debug/cheat相關的內容封裝到不同的外掛模組裡面,可以針對性的通過component來在特定的Actor上啟用,這樣 gameplay模組的擴充套件性、重用性、穩定性都能有很好的改善。
MetaSounds也是在EA版本就釋出的, 是讓音訊系統有跟渲染管線類似的可程式設計性的一套工具。類似audio用的shader,所以可以更靈活的使用和操縱原始的音訊資料。MetaSounds用來替換以前比較簡陋的soundcue,可以更高效、更靈活、更可擴充套件,Quartz 是暴露給bp系統的一套用來精確取樣音訊資料和事件的系統,可以讓你完善地完成類似吉他英雄這種的需要非常準確的幀時間的取樣音效、同步控制的這樣一套系統。
另外我們還有別的一套系統(Audio Synesthesia),可以幫助你離線也可以實時對音訊做分析,然後把音訊做視覺化。比如利用niagara或者材質做視覺化。
chaos的物理系統,在UE5開始我們整個物理系統都會切換到chaos,它的一大目標是能有更多的gameplay的互動介面,跟gameplay系統結合更緊密,並且支援網路同步。Early Access版本中我們Rigid body模擬已經修了很多bug,減少了碰撞對的生成、預先做了更多剔除、修復了一些查詢巨大三角面時的錯誤結果、改進了超高速移動物體或很低幀數下預設啟用CCD時不正常的效果、也改了ragdoll模擬穩定性的一些bug。
在正式版本中,對於PBD的效能和穩定性做了更多優化,做了很好的載具的支援,並且支援網路同步的物理模擬的框架。然後還有各種特殊的solver,比如布料和流體的模擬也得到了增強和改善。
關於整套Unreal Insights工具,我們改善了現有的Timing, Loading, Network和Animation 的功能,同時新版本里面還有Memory Insights。通過Memory Insights,我們能按照LLM的方式按型別來看記憶體是怎麼分配的,然後我們如果加Memory Malloc這樣一個引數的話,Memory Insights還可以幫助大家看到所有的呼叫、堆疊、分類 甚至查Memory Leak。有很多這種好用的工具,剛剛也說到RDG Insights,來看整個渲染的管線資源分配、複用、生命週期各種各樣比較好用的功能。
最後是Meta Human Creator,高質量的數字人建立工具,可以幫助小團隊在幾分鐘或者幾小時內就創作出3A品質的、帶完整rigging,可以用來做動畫的數字角色。伴隨UE5的釋出,我們MHC的建立角色在引擎裡面會有更多更好的表現。這樣品質的數字角色本來只在3A作品裡能看到,現在每個人都能製作,並且通過bridge直接在content browser拓出來,在引擎裡面使用。
Q & A
UE5.0正式版會在什麼時候釋出?
王禰:目前預計是明年上半年,可能在4月份左右釋出。
UE5.0之後還會支援曲面細分嗎?
王禰:由於不少硬體平臺曲面細分效率的問題,我們打算徹底去掉。未來我們會嘗試用Nanite去做,但是目前還沒有做到。所以現在的workaround如果不做變形,那就只能靠Nanitemesh或者靠Virtual Heightfield Mesh來處理。
原文:https://mp.weixin.qq.com/s/uBEDKbA7B7qlBmgwkCef1w
相關文章
- 祖龍技術總監王遠明:用虛幻引擎解決開放世界5大難題
- 2020線上虛幻引擎技術開放日 與您共襄盛會!
- 虛幻引擎5正式公佈,Epic高管解讀技術奇蹟
- 2020線上虛幻引擎技術開放日 Unreal Open Day Online 即將登陸!Unreal
- 2019 Unreal Open Day虛幻引擎技術開放日圓滿落幕Unreal
- 【UE5】虛幻引擎入門
- 虛幻引擎5現已釋出!
- 虛幻引擎Unreal Circle線下技術沙龍 | 3月19日廈門站Unreal
- 2019虛幻引擎技術開放日Unreal Open Day大會主旨演講及完整議程曝光Unreal
- Unreal Fest Shanghai 2023 | 虛幻引擎技術開放日,邀您齊聚上海UnrealAI
- Unreal Open Day 2021虛幻引擎技術開放日 完整議程正式曝光Unreal
- 虛幻引擎5推出搶先體驗版
- 虛幻引擎 5 來了!不止 Lumen、Nanite 新技術,效能及 UI 均迎來大升級NaNUI
- 初探虛幻引擎5,預計於2021年推出
- Unreal Fest Shanghai 2024 | 虛幻引擎技術開放日,十年相伴重回故地UnrealAI
- 虛幻引擎中的實時光線追蹤(一):起源
- 「虛幻引擎5」為何讓開發者們拍手叫好?它到底厲害在哪?
- UOD2022|虛幻引擎技術開放日完整議程,11月17日-18日可線上觀看直播
- 虛幻引擎5的演示畫面能代表PS5畫面嗎?
- Tim Sweeney:750萬開發者使用虛幻引擎,免費開放Epic Games線上服務GAM
- 第三波演講主題公佈 | Unreal Fest Shanghai 2024 | 虛幻引擎技術開放日UnrealAI
- 深度解析混合開發技術成熟度曲線
- UE虛幻引擎CSV轉ExcelExcel
- [22] 虛幻引擎知識擴充 智慧指標、JSON解析、外掛指標JSON
- 一項獨有技術,能讓使用舊版本虛幻引擎的遊戲在Switch上執行遊戲
- Meetup回顧 | FISCO BCOS v3.0 2022年技術路線圖解析圖解
- Web前端發展史(自我成長技術路線圖)Web前端
- 劃時代的虛幻5引擎,恐怕撐不起3A夢
- 技術路線:前端開發已進入深水區前端
- 虛幻引擎學習資源彙總
- 前端開發路線圖前端
- 感受手柄和MOBA的碰撞!虛幻4引擎大作《Genesis》今日上線!
- 虛幻引擎中的實時光線追蹤(二):建築視覺化視覺化
- 技術路線應該會的技術
- 所有Unreal網遊開發者都應該看的文章:使用虛幻引擎4年,再談UE的網路架構Unreal架構
- 解讀圖資料庫技術路線資料庫
- 詳解TF雲原生技術路線圖
- 前後端分離技術路線圖後端