這是工程開發的細節,不是理論篇,不瞭解RVT理論概念的,請先搜索。
RVT的理論普及度比較高,Farcry5和UnrealEngine裡都有大量的分享,但是好幾篇文章提到。這種方案工作量和難度主要是在工程細節上。也就是一個資深技術完整按方案寫完落地功能大概要1個月。所以我很想造一個既有差不多性能收益 又簡單很多的實現方案,差不多開發3天可落地項目。
為什麼要對地形做RVT?因為地形的采樣除瞭抖動混合其他都需要非常多次的采樣albedo和normal然後混合。這已經成為多數項目在GPU方面的最大開銷所以有必要緩存他。其次,地形的mesh結構單一性,材質統一性,貼圖共用性都導致僅對地形實現RVT是比較方便的.
以下是我參與的項目4年多來地形渲染的技術迭代過程,本來計劃僅1次采樣的抖動混合作為終極方案,實際上產品和美術對於噪點難以接受。 這樣就反而簡單瞭,必須開發RVT的想法就在我腦中種下瞭。
真實項目落地補充
普通視角 提升2ms,70fps->82fps.截圖為最大收益處空氣背包與跳傘時
1050 中畫質 線上遊戲地形(優化前)1050 中畫質 線上遊戲地形(優化後)
采用i5-9600kf+amd rx590 unity5.6測試 總共16層地表
靜幀對比
unity自帶地形:231 FPS
本方案地形:546 FPS
移動時對比看視頻裡幀數
先放效果圖看下收益幅度,或許才有耐心能看完細節。後面 與gpudriven地形結合 性能會更好,因為時候已經是地形地塊cpu瓶頸瞭
http://www.icpchaxun.com/video/1541230442267504640
原文的思路有很多種,也很復雜。首先要多渲染一份數據 然後feedback,延遲一幀獲得,然後各種vt的不同尺寸,覆蓋不同pagetable的數量,對應物理貼圖圖集裡的不同大小,這些不同大小的覆蓋或對應關系有些是在1個mipmap裡做 有些是放到不同的mipmap上實現,以及物理貼圖圖集裡,不同尺寸可用空間的申請,回收,占用維護等。還有異步加載時處於為準備好的區域如何尋找代替的更低精度的mipmap地址等等。看的我非常暈,所以這一刻忘記這一切概念。重新想一個最最簡單直觀的。先用4叉樹劃分地塊,這一點之前做4叉樹靜態陰影和4叉樹gpudriven的地形都用到過。所以比較簡單瞭。
根據相機距離如下圖這樣 劃分世界空間,並給每個空間分配一個獨立的編號,叫他物理地址索引。我們隻要讓每一塊都用一張相同大小的貼圖去顯示,那麼自然就是比較合理的使用顯存瞭,也等於是近處用瞭mipmap0 遠處用瞭mipmap1,2,3....瞭,用這種思想來實現就直觀且方便很多,因為所有貼圖尺寸一樣可以用一個Texture2DArray來存放,而編號 就是這個數組的index。當某一塊的尺寸需要變化時 才重新加載變化後對應的圖。
用四叉樹 把世界空間按xz平面投影(根據相機距離) 劃分方式
四叉樹是這個方案的主要功能所以會寫得比較多。 四叉樹雖然反復使用,但常常長的不同,這是因為有時候需要用來遍歷,有時候需要用來查找,有時候是為瞭內容相近而壓縮數據,有時候是做lod劃分。所以這裡詳細講下這次四叉樹的實現細節。
因為上圖每個要顯示的節點都是 葉節點,不是枝節點。所以常見有2種方式遍歷。
為什麼要判斷lod是否發生變化呢?因為如果lod沒變,那麼原來顯示圖不需要替換,就不需要做任何處理。如果發現遠離瞭相機並且lod需要更大,那麼說明不需要這麼高清瞭,他可以嘗試合並 用他父節點來加載一張覆蓋更大面積的圖來顯示(圖是一樣尺寸的所以覆蓋更大面積等於更低精度),反之相機靠近瞭 lod就需要小,他就需要細分出4個節點,每個節點都去加載對應的圖,這樣他就精度翻倍瞭。
我這2天把這2種都實現瞭一遍,發現2的代碼邏輯更直觀,1需要做一個狀態維護,所以這裡講2的方式。
四叉樹數據結構
static變量:
currentAllLeaves:當前幀所有葉節點
nextAllLeaves:下一幀所有葉節點
physicEmptyIndexQueue:可用的物理地址隊列
onLoadData:某節點需要加載貼圖資源時回調,因為這種加載一般不做在樹結構內
splitCount:當前幀已經細分的次數
eventFrameSplitCountMax:每幀可細分的最大次數,與splitCount一起 避免在同一幀加載太多導致卡頓,實際是一種簡單又高性能的分幀機制。分幀加載機制 我用分幀細分四叉樹代替,極大簡化瞭維護。否則異步的加載 ,相鄰部分加載完成替換索引會出現臟數據等問題。
成員變量:
x,z,size:四叉樹最最基礎的數據,記錄這個格子坐標和尺寸
children,parent:描述四叉樹樹結構關系的引用 類似Transform
isLeaf:判斷是否是 葉節點
parentMerged:當前幀parent是否被合並過瞭,因為遍歷某節點的4個子節點順序是不可控的,避免出現一個子節點判斷應該合並,但其他子節點卻判斷為細分出現矛盾。
physicTexIndex:當前節點的物理貼圖(Texture2DArray)索引,用他來渲染自己覆蓋的區域
一個樹一般手動創建根節點,然後通過規則讓他自己內部去細分或合並。也常在這裡做些初始化或靜態數據創建。這裡主要是創建一個Node節點 size 獲得一個物理地址,並放入葉節點。因為這個時候 根節點就是葉節點,他還沒子節點。這裡沒設置xz,是因為不論真實場景如何四叉樹內部都是從(0,0)點開始往x+,z+方向去計算的。外部的實際情況可根據offset調整,不在內部考慮外界的特殊性。
創建根節點函數
遍歷所有當前葉節點,檢查lod是否發生變化,如果沒變化就放入下一幀葉節點隊列。如果變化,根據變大還是變小來做合並還是細分的處理,最後是常見的 交換2個列表,下一幀的數據 作為下一幀的“當前”數據反復遍歷。不用簡單賦值而用交換,是因為不想每幀new一個空隊列產生GC。
每幀主循環
對於每個節點 首先判斷他父節點是否需要合並,如果自己和其他3兄弟節點都沒子節點且 父節點計算後lod發現應該合並,那麼才執行合並,並且設置每個子節點的 parentMerged 為true,如果不能合並再判斷自己是維持到下一幀還是 細分,細分也有很多約束,這些細節的考量是我花的主要時間。
f84df7c559dfe6795ca5118e1e06b726節點lod 計算判斷是 維持 還是合並 還是細分
合並函數比較簡單,把自己放入葉節點,把4個子對象 標記合並過瞭,回收子對象物理索引 ,分配自己一個地址索引然後加載這個索引對應的資源。
節點合並
節點細分
不論合並還是細分,每幀都隻執行一次 這樣很好的天然實現瞭分幀處理,如果要更好的效果還需要設置權重決定處理的順序,比如近的優先,或lod變化大的優先。 還有一個小技巧就是先回收索引資源,然後再分配,這樣減少一點點資源不足的情況。
每幀隻對一個葉節點處理一次 實現自然的分幀效果實時移動相機的 四叉樹分幀細分與合並效果
分配瞭索引之後 我們可以根據節點所在的位置和size,去加載這塊混合後的貼圖。並拷貝到Texture2DArray 對應的index裡。這裡說的加載不是真的加載,如果是svt那就是硬盤加載。我們做rvt這裡其實是 實時創建。為瞭流程描述統一特意說成加載。這種實時創建有2種方式,第一種是放個相機去拍,這種簡單也能對格子貼花 路面等自動支持,但是性能不好。因為渲染流程要走一遍。相機要對地形mesh各種處理,這些都是我們不需要的。所以我這裡采用性能更高的blit方式,缺點是做路面與貼花時需要再開發功能支持。
實時生成 地塊內容
本來直接用 地形shader改改就行,但是他是每4張一個pass,需要blit好多次,關鍵是還要對這些結果做混合,像素拷貝太多瞭,所以這裡改瞭下 用Texture2DArray 存放地形地表紋理比如16張。一次性采樣完。因為要輸出albedo和normal2份數據 所以這裡給瞭開關,如果要一次獲得可嘗試mrt,但我這裡不想再展開。利用 builtin自帶地形shader的firstpass 一次性采樣完所有圖層。
根據地塊位置不同 尺寸不同 做偏移和縮放制作節點對應貼圖的 shader
四叉樹節點上對應的貼圖創建好瞭,但是渲染的時候一個 shadingpoint怎麼知道,自己要采樣第幾張圖的呢?根據自己的世界坐標或地形uv 查詢四叉樹?這肯定是不好的復雜又過度采樣。所以都是給他制作一張索引圖,和他uv一一對應,他根據地形uv就能訪問到對應的紋素,比如frag函數裡一個shadingpoint 他在地形上uv是(0.5,0.5) ,那麼他去索引圖的 (0.5,0.5)采樣就可以獲得節點數據 包含瞭索引,尺寸 起始的xz4個值。然後就可以計算出在Texture2DArray裡的uv坐標和index。但是這個索引如果是均勻的單位是多少呢?比如這裡我們四叉樹最小一格size是1,(世界坐標先當1米用吧)。那麼這個索引圖就是1個紋素對應1米。四叉樹節點加載好貼圖放入Texture2DArray後 ,就要填充索引圖 對應紋素的 內容,好讓采樣的shader能查詢正確。如果這個節點size是1當然就填1x1紋素,如果是4x4的size 覆蓋瞭4x4米,當然索引圖需要填充4x4紋素同樣的值。這樣看起來有點傻,1浪費空間 2寫入數據也變多,3采樣時候采樣瞭不是同一位置的同一個值結果正確但緩存命中變差。所以有同事建議根據farcry5那套把 2x2的寫到mipmap1的一個像素,把4x4寫到mipmap2的一個像素。看上去很美好實際上不行,因為我這方案省略瞭feedback這一整個過程,所以cpu根據距離計算出的mipmaps與渲染時fragment裡計算的會不一致,這樣導致他去索引貼圖某mipmap取值時,內容是錯誤瞭 因為寫入的是另一個mipmap,而隻有獲取到正確內容後 才知道cpu計算這處的lod是多少 才知道他寫入瞭哪個mipmap。這裡使用computeshader填充索引貼圖內容。就一句代碼 Result[id.xy+ uint2(offsetX, offsetZ)] = value;
制作數據 放入數組 並填充 索引貼圖內容2a9048911dad0e173dda4c689a34ba5d斜面噪點噪點對比
按這樣實現出來 會發現斜面有噪點,這是必然的。因為我們是根據距離指定的lod,也就相當於貼圖的mipmap。而實際渲染是根據ddx和ddy來計算mipmap的。也就是說法線與視角方向接近垂直的面,他們即便離的近,也不能用mipmap0的,因為2個屏幕像素在貼圖空間跨瞭好幾個紋素。所以需要給他準備多份mipmap數據,但是最清晰的那個就是我們現在給的,所以嚴格來說 如果我們要傳給他mipmap4,但他最後一級到mipmap8,我們需要把4 5 6 7 8 都傳給他。但實際上不會這麼極端。所以我經過實踐發現給4個足夠用瞭。所以我們這樣修改代碼和shader,我們需要在shader內手動計算mipmap並與cpu計算的mipmap(也就是節點的size大小做差值,因為用一個同尺寸貼圖渲染一個更大size范圍 等於已經做瞭mipmap變化)這個算法我自己想的 :)
2697198b4a65eef1443429b7f6078f90傳4個mipmap 用於不同角度的 不同需求修正mipmap問題的 shader采樣修正mipmap 計算後 不會特別銳化
這樣看起來效果就好多瞭,但是ddx ddy一定要用 均勻的地形uv來做,如果用數組內uv會有接縫,這是因為相鄰節點銜接處textureArray內uv一個是0 一個是1,ddx/ddy就會誤認為是不連續 大變化。
為瞭做貼花和道理渲染準備,已經升級到mrt渲染方式,從0.2(2次0.1)ms 優化到0.14ms. 其中computeshader開銷較大是測試問題,反復加載最遠處圖來測試,實際上遠處圖變化頻率很低
原來2次 drawquadmrt1次 drawquad
git庫地址