黑馬 SLG 遊戲《三國:謀定天下》怎麼用 Unity 技術實現高效地形渲染?

三國時期以其風雲變幻的政治環境和英雄輩出的時代背景,一直是遊戲開發者與玩家創作的靈感源泉。SLG 遊戲《三國:謀定天下》憑藉多項對傳統 SLG 手遊的重大創新和改動,自 2024 年 6 月 13 日公測以來,迅速獲得市場關注,iOS 手機端 6 月 16 日單日遊戲收入高達 1,821,632 美元。

多職業的遊戲設定不僅豐富了遊戲的戰略維度,也提升了玩家之間的互動性和合作性。更引人注目的是,爲了給玩家帶來更優的視覺與玩法體驗,遊戲採用了無極縮放技術,使得玩家可以流暢的從宏觀戰場切換到微觀細節的觀察視角。

在 Unite Shanghai 2024 遊戲專場上,華娛網絡 CTO 吳志強先生爲大家分享了《三國:謀定天下》無極縮放和大世界地形渲染方案,講解如何在移動平臺實現更大觀察視野、更流程絲滑的無極縮放與超多層地表材質混合等。

無極縮放功能

我是來自浙江華娛網絡科技有限公司東風工作室的吳志強,我今天分享主題是《三國:謀定天下》無極縮放和地形渲染技術,我在《三國:謀定天下》主要負責引擎渲染、性能優化、基礎設施的搭建。

《三國:謀定天下》是首款多職業戰爭策略手遊,玩家可以選擇天工、神行、青囊、司倉、鎮軍、奇佐六大職業,每個職業都能通過其特色的職業技能對沙盤局勢造成影響,爲了讓玩家能夠從戰略視角清晰地查看沙盤上的局勢,我們引入了無極縮放功能。

在《三國:謀定天下》裡面一共有 13 個州,在這個視角下我們可以觀察到 6 個州的局勢。

隨着相機高度的不斷下降,可以看到局部地區更加詳細的信息,比如說這張圖我們能看到官道、同盟建築、城池模型。

繼續下降可以看到玩家建築的圖表、同盟標記的文本信息。

當相機下降到最低高度可以看到玩家建築的模型、軍隊模型、行軍線這些玩家更加關注的信息。

無極縮放對遊戲體驗的提升是巨大的。

它能讓遊戲目標更加清晰呈現在沙盤中,所有的同盟成員都能清晰知道需要攻打哪座城,哪裡是戰場的前線,哪裡可能藏着老六。

基於上述原因我們在立項之初就決定要做好無極縮放,爲了讓無極縮放的需求得到滿足,我們在《三國:謀定天下》的沙盤地形渲染中一共使用了7 級 LOD,其中 LOD0 的單個地塊覆蓋了3.2×3.2米的區域,約等於遊戲中4×4個遊戲格子。

LOD 級別每增加 1,其地塊覆蓋區域的邊長會翻一倍,LOD6 的單個地塊是 284.8 米,我們全地圖的尺寸是1200×1200米,一共包含了225萬個遊戲格子。

這張圖是 LOD0 的地塊模型,它正好包含了遊戲中的 16 個資源地。

第 6 級的地塊會大很多,已經有小半個州的面積了,經過減面後其模型精度相比於 LOD0 的 Mesh 會低很多。

地形渲染技術方案

地塊加載

爲了方便後續討論我們先引入兩個名詞,一個是Grid,整個大地圖我們會把它均勻地劃分成若干個 3.2×3.2 米的網格,每個小格子我們稱爲一個 Grid。第二個是LOD Chunk,也叫 LOD 地塊,每個 LOD0 的 chunk 正好包含一個 Grid,每個 LOD1 的 chunk 包含了 2×2 個 Grid,以此類推。

每級 LOD 的 chunk 位置和數量是固定的,LOD 級別每增加一級,chunk 的數量會降低至四分之一,其面積也會相應地擴大四倍,這張圖中有 64 個 Grid,64 個 LOD0 的 chunk,16 個 LOD1 的 chunk,4 個 LOD2 的 chunk。

前面提到 7 級 LOD,每一級 LOD 都會加載距離視野中間點最近的 5×5 個地塊進內存,一共是 25×7,175 個地塊。

這裡有幾個點是需要注意的,從上面的三張圖可以清晰看出不同級別的 LOD 加載的地塊區域會相互重疊的。另外相機大約需要移動一個 Grid 的距離纔會觸發 LOD0 的刷新,而 LOD2 的刷新大約需要移動 4 個 Grid 纔會觸發,也就是說LOD 的級別越大它的刷新頻率越低。

我們會設定一個相機的基礎高度 H0,當相機高度大於 H0 後會隱藏所有 LOD0 的地塊,大於 2 倍 H0 會開始隱藏所有 LOD1 的地塊,當相機高度達到 32 倍 H0 後,全場景只會顯示 LOD6 的地塊。這樣的好處是當相機高度很高時,不用再刷新低級別的 LOD 地塊,否則隨着相機高度的增加,其移動速度也會成倍增長,從而導致每幀都需要刷新大量低級別的 LOD 地塊。

這個算法是很簡單的,有興趣的同學可以看一下PPT。

通過上面的算法我們就能夠知道每幀需要加載、卸載哪些 LOD chunk,而加載 LOD chunk 又需要做哪些事情呢?

第一步肯定是異步加載 LOD chunk 需要的資源,包括 Mesh 和地塊相關的材質貼圖。由於我們項目中使用了 RVT 來做地形渲染,所以在加載完這些地塊資源之後,我們會在 GPU 中做地表的貼圖混合,輸出這個地塊的 Albedo、Normal、Roughness 貼圖,其中 Normal、roughness 我們選擇存儲在同一張貼圖的 RG 通道和 B 通道。大致的渲染流程也比較簡單,首先是修改相機矩陣和投影矩陣,保證相機的方向是自頂向下,並且使用正交投影,這樣可以讓當前烘焙的地塊正好覆蓋整個 RT。

然後是渲染地形,將地形的 Albedo 和 Normal 輸出到 RT0 和 RT1 中,然後再渲染和當前地塊相交的山體、貼花、道路、邏輯網格線這些地形上的靜態元素,這裡有一個好處是隻需要通過 Alpha Blend 就能實現山體、貼花和地形做材質融合。

第三步是需要在 GPU 上做實時的 ASTC 貼圖壓縮,我們會將 RT0 和 RT1 都壓縮成 ASTC6×6 的格式,有兩個好處,一個是降低內存佔用,一個是降低後續的 RVT 採樣帶寬,實時壓縮 ASTC 這部分代碼我在去年的時候已經在 github 開源了,有需要的同學可以直接在 github 上搜索 UnityAstcGpuEncoder 項目。

圖上這一步是將地形渲染到兩個 G-buffer 的 RT 中,由於這個地塊沒有包含山體,所以它的 Mesh 也很簡單隻有兩個三角形,如果有山體的話 Mesh 會複雜很多。

將官道直接以Alpha Blend的形式疊加到 RT 上,貼花、山體這些都會以這樣的形式疊加到地形上。

經過上面的步驟我們會得到當前地塊兩張 ASTC 格式的 G-buffer 貼圖,爲了後續支持場景建築跟地表做材質融合,這兩張貼圖是不能直接使用的,而是需要將所有地塊的 G-buffer 貼圖都存儲到兩個全局的 texteure array 中,這樣才能夠實現在場景建築的材質中可以採樣任意位置的地表貼圖,這也是 RVT 對資源佔用最大的部分。兩張 ASTC6×6 格式的貼圖,尺寸是 720×720,會包含三級 Mipmap,包含 175 個 slice,這大約需要 100 兆的內存,低配我們可以通過限制貼圖尺寸,將內存降低到 25 兆左右,效果勉強能接受。

最後是將當前地塊的 VT copy 到 textere array 後還需要處理 Mipmap,但是我們並不能直接通過 GenerateMipmap 這個接口自動生成 Mipmap,這是因爲同一個區域在不同 LOD 的表現可能差異很大,這個差異主要有兩個因素造成,其一是我們的貼花它只會在 LOD 小於等於 3 的地塊上顯示。其二是爲了降低相機拉高後的貼圖重複感,我們會在渲染 LOD 大於等於 2 的地塊 VT 時使用更大的 tiling 值去採樣另外一組沒有明顯特徵的低精度地表貼圖,使用無明顯特徵的貼圖是爲了避免貼圖中的一個小坑它突然變成一個很大的坑這種問題,所以說除了最後一級 LOD 外,其他的 LOD Mipmap 都是從相鄰的 LOD 中複製過來的。

比如說這裡加載到下圖中 LOD1 的紅色地塊的時候,這個紅色地塊的第一級 Mipmap,它是直接從 LOD2 的綠色地塊的 VT 的左下角區域複製出來的,同樣如果 LOD0 四個地塊已經加載完成了,也需要將 LOD1 中的紅色地塊對應區域分別複製到這四個 VT 的第一 Mipmap 中。這個可以看出 LOD chunk 它們之間是存在一定的層級關係的,我們將 LOD1 中的紅色地塊稱爲綠色地塊的 child chunk,同時它也是 LOD0 中四個地塊的 parent chunk,而 LOD0 中的四個 chunk 它們是互爲 sibling chunk,完成了上述過程後,我們就可以爲每一個地塊創建其對應的 game object,然後通過加載出來的 Mesh 和烘焙後的 VT 進行最終的地塊渲染。

但是並不是所有的創建出來都會直接顯示,它是否需要顯示是根據這裡列出的 4 條顯示規則決定的。

首先從我們之前提到的 Mipmap 更新算法可以看出,如果一個 chunk 它的 Parent chunk 沒有加載完成的話,這個 chunk 是不能顯示的,因爲它的 Mip1 還沒有更新。

第二如果一個 chunk 它的四個 Child chunk,沒有全部加載完成,那麼當前已經完成加載的 Child chunk 也不能顯示的,不然就會出現 Parent chunk 的 Mesh 和 Child chunk 的 Mesh 會發生重疊,換句話說除了最後一級 LOD 外,其他的 LOD chunk 只有當自己的 sibling 全部加載完成自己才能顯示。

第三點是 Child chunk 全部加載完成之後,這個 Parent chunk 就不需要顯示了,這也是避免發生重疊。

最後再加上之前說過的相機高度增加後需要隱藏低級別 LOD 這一點,一共組成了四條顯示規則。

有了這些規則我們就可以實現一個遞歸的算法來決定哪些加載完成的地塊它應該要顯示,這裡首先是遍歷所有第 6 級 LOD 的chunk,調用 UpdateChunkVisibility 這個函數,如果這個 chunk 它的 LOD 是大於 min visible LOD,並且這個 chunk 的所有 children 都是加載完成的狀態,就遞歸遍歷它的 child chunks,不然這個地塊它就可以顯示了。

地表材質融合

再回到之前提到過的地表融合問題上,前面已經說過了爲了讓融合材質能夠採樣任意位置的地表材質信息,我們會將所有地塊的 G-buffer 貼圖放進兩張全局的 texture array 中,這爲在 PS 中採樣任意位置的貼圖提供了可能性,但是想要知道某一個世界座標位置它對應了 texture array 中哪一層貼圖中哪一個像素仍然是很麻煩的事情。

因爲我們並不能知道某一個世界座標位置它當前使用的是哪一級 LOD,由於 LOD chunk 它的最小單位是一個 Grid,所以最簡單的做法是創建一張lookup 貼圖,這個貼圖的每個像素對應沙盤中每一個 Grid,像素中這個 Grid 應該採樣 texture 的哪一層貼圖,以及這一層貼圖的 LOD 層級。但是這個 lookup 貼圖會比較大,在遊戲中大概需要 375×375 這個分辨率的尺寸,這樣在相機大範圍移動的時候可能需要單幀更新上萬個像素的值,這個開銷也是不小的,同時一旦地圖的面積擴大,開銷會成倍增長。

另一種做法是將當前顯示的地塊信息通過 cbuffer 傳遞進 shader 中,然後按 LOD 級別從高到低的順序依次判斷應該使用哪一級 LOD,我們直接看下面的例子。

這個例子中綠色的地塊表示正在顯示的,紅色表示隱藏的地塊,白色是未加載的。

左邊是 LOD3 右邊是 LOD2,黑色的點表示的是查詢點,黑色的大方框是當前 LOD 的加載區域,在這個例子中我們首先進行 LOD3 的查詢,可以計算出查詢點所處的 LOD3 chunk,在整個加載列表中的 index 是 4,這個 chunk 的狀態是隱藏中,因此我們繼續向下查詢 LOD2,查詢點所處的 LOD2 chunk 在 LOD2 的加載列表中 index 是 3,經過查詢發現這個 chunk 的狀態是顯示中可以直接採樣,這樣只需要將查詢點的座標轉化爲 chunk 的貼圖 UV 座標可以採樣得到查詢點的地表信息。這個算法雖然很直觀但是性能比較差,因爲大部分視野內能看到的建築都位於 LOD0 的地表上,因此可以反轉一下遍歷的順序,改爲從 LOD0 開始遍歷,如果查詢點的 LOD chunk 不是顯示中的狀態,再查詢更高級別的 LOD,除此之外我們可以把當前可見最小的 LOD 級別傳到 shader 裡面去,使得可以快速跳過完全不可見的 LOD,這個在相機拉高之後會很實用。

這是最終的僞代碼,我們首先從 min visible LOD 一直到最高級 LOD,然後計算查詢點在當前 LOD 當中的 chunk index,如果 chunk index 小於 0 就表示這個查詢點並不在當前的 LOD 的加載區域內,就直接去查詢下一級 LOD,如果在加載區域內,我們通過 chunk 去拿到當前這個 chunk 它在 VT 中是屬於哪一層 slice,如果 slice 也是大於等於 0,就表示這個 chunk 處於顯示中的狀態,不然還是隻能繼續查詢下一級 LOD。

地表精度優化

上面的渲染方式實現後我們偶爾會在屏幕邊緣出現這個圖裡面的明顯分割線,這裡應該也能看清,在分割線的一側是比較清晰的地表,另外一側是比較模糊的地表,這個很明顯在分割線那裡發生了 LOD 的切換,在很長一段時間內我們都認爲這是一個比較合理的情況,一直沒有處理。

但是這種情況出現很頻繁,對畫面的影響已經到了不能忽視的程度,經過一番排查後分割線下方是 LOD0 的地塊,但是上面那一部分不是 LOD1,是 LOD2 的地塊,這就相當不合理了。

經過排查我們發現 LOD0 到 LOD2 的顯示區域如下面這三張圖所示,從左到右依次是 LOD0、LOD1、LOD2,我們能夠看到 LOD0 中顯示的地塊數量是 16 個,我們加載 25 個,爲什麼少了 9 個是因爲顯示規則中的第二條導致的,只有當自己的 sibling chunk 全部加載完畢才能顯示,所以這裡少了 9 個。LOD1 同樣是因爲這個規則而導致有 9 個 chunk 沒有顯示,直接導致了我們 LOD0 和 LOD2 地塊是直接相接的。

所以雖然最終表現上覺得不太合常理,但是經過分析後我們發現它是正常的現象,圖中的模糊區域我們實際已經加載了精度更高的地塊,但是因爲顯示規則導致無法使用高精度的地塊。第二條顯示規則本質是因爲 parent chunk 和 child chunk 的 Mesh 會相互重疊,所以只能要麼隱藏所有的 child chunk 只顯示 parent,要麼隱藏 parent 然後顯示全部的 child chunk。

但是我們現在碰到的問題是地表的貼圖精度不夠,並不是 Mesh 精度不夠,所以可能只使用 parent chunk 的 Mesh,但是貼圖使用 child chunk 嗎?這是完全可以的,而且我們只需要做非常小的改動。在地表材質融合的時候我們已經實現了傳入世界座標位置就可以採樣出任意位置 G-buffer 的功能,但是這個只能採樣出已經顯示的地塊,不過我們可以直接忽略當前地塊是否顯示,只要地塊完成了加載,我們就將地塊信息寫入剛剛的 ChunkInfo 數組中,這樣的話就能夠實現任意位置 G-buffer 的功能。

最後我們需要將地表 shader 修改一下,改成和融合材質一樣,直接調用剛纔實現的SampleTerrainAtPosition這個函數,去獲取地表的信息,而不是說直接採樣某一層貼圖,這是一個動態的採樣。

優化後同樣視角下已經完成看不到分割線了,之前採樣 LOD 貼圖的區域已經轉爲採樣 LOD0 的貼圖了。

多層地表的材質混合

最後一個話題是多層地表的材質混合,2022-2024 年將近兩年的時間我們只支持四層地表材質貼圖的混合,而且由於一開始沒有專門的美術同學複雜場景地形的繪製,當時完全是靠在 Photoshop 中盲畫地形的分佈圖,隨着一次次的畫質迭代,在 2024 年 1 月下旬我們決定至少要支持16層地表貼圖的混合,這其實在當時是非常大膽的決定,因爲當時離公測封包只有不到 4 個月的時間了,程序實現完機制還需要美術熟悉工具,去繪製、去調優,所以我們要在 4 個月的時間把之前兩年沒有解決的問題解決。

從 1 月 20 日開始動工,大概耗費 1 個月的時間實現渲染方案和工具,我們爲了敏捷開發直接在工具上使用了 Unity 的terrain tool package來支持地形的筆刷功能,所有的筆刷操作都是在 Unity 上進行的,編輯完成之後再導出自定義的資源格式。

這個圖是當隱藏 Unity Terrain 切換成遊戲內的 LOD Terrain 的效果圖,我們對 URP 自帶的 TerrainLit 材質做了大量的修改,來保證 Unity 的地形效果和遊戲內 LOD 地形效果是幾乎完全匹配的。

我們會將整個場景拆分成 36 個分組,每個分組下面包含了 16 個 Unity 節點,每個節點它覆蓋了 51.2 米的範圍,所有這些節點都位於單獨的編輯場景中,運行時不會加載這個場景的。每個 Unity 節點都有一個對應的 terrain data 資源文件存儲美術繪製的地形數據,這樣可以支持美術協作繪製同一個場景的不同區域,在導出運行時需要的地形數據時,也會根據 terrain data 的 hash 判斷是否需要導出,通常導出時間只需要幾秒鐘,全部導出不到 2 分鐘的時間,能夠加快美術的迭代效率。

我們目前支持 16 層地表貼圖,技術上已經實現了最多 256 層,只是美術暫時用不上就沒有開放,16 層雖然不多但是運行時不能直接在 PS 裡直接採樣,所以傳統的 SplatMap 方案是不可行的,爲了解決這個主要的方法是使用IDMap,每個控制點它存儲的不再是 16 層貼圖的混合強度,而是材質 ID,然後通過雙線性插值做不同 ID 貼圖的混合,這 16 層貼圖會被存儲到多個 texture array 中,一般會有 Normal texture array 和 Albedo texture array,貼圖的 ID 對應其在 texture array 中的索引,但是 IDMap 的效果並不夠好,主要還是因爲雙線性插值的結果不太可控,很多區域只能使用單層材質,對於確實需要多層混合效果的區域是不能支持的。

我們最終使用了一個 hybrid 的方法,在每個 3.2×3.2 米 block 內,我們可以單獨指定使用 4 層地表貼圖,這個 block 有 32×32 個控制點,每個控制點可以單獨控制這 4 層地表貼圖的混合強度,也就是說我們會有一張低精度的 IDMap,IDMap 中的每個像素記錄的是一個 Block 內的 4 層地表貼圖 ID,以及一張記錄了控制點混合強度的全精度的 splat map,splat map 的分辨率大約是 IDMap 分辨率的 32×32 倍。

這個做法聽起來很簡單,但是有一個非常麻煩的問題需要解決,那就是Grid 之間它的 splat map 插值問題,我們直接看這個例子。

它的一個 block 中是 4×4 個控制點,左邊的 block 是使用了 ID1、2、3、4 的地表貼圖,右邊的 block 使用了 ID1、2、4、5 的貼圖,我們可以看到控制點 A 記錄的混合權重是 0.3、0、0、0.7,它表示的是 30% 的 1 號貼圖和 70% 的 4 號貼圖做混合,控制點 B 中記錄了權重是 0.6、0.2、0.2、0,它表示的是 60% 的 1 號貼圖,20% 的 2 號貼圖和 20% 的 4 號貼圖。

如果直接使用雙線性插值的話,在 AB 的中間插值權重是 0.45、0.1、0.1、0.35,但是中點左右兩側的像素會對這個權重值有不一樣的解釋,比如說 B 通道的值是 0.1,會被左側的像素理解成是 10% 的 3 號貼圖,右側的像素會把它理解成是 10% 的 4 號貼圖,這個是錯誤的,會在最終的圖像上產生一條很明顯的接縫。

爲了解決這個問題我們需要把邊界上的頂點複製一份,使其同時存在於左邊的 block 和右邊的 block 中,修改之前一個 block 包含了 4×4 個控制點,影響世界座標系下 0.4×0.4 米的區域(假定這個小格子是 0.1 米),修改之後一個 block 中仍然包含 4×4 個控制點,但是它隻影響0.3×0.3米的區域。圖中左邊的 0.3×0.3 米的區域包含 4×4 個控制點,每個控制點記錄的混合強度、含義都是一樣的,也就是它 4 個通道都依次表示 ID1、2、3、4 的貼圖混合強度,也就不會出現插值問題,右邊的 0.3×0.3 米的區域同樣也包含了 4×4 個控制點,並且也包含了 A 點。但是這個 block 中它存儲的控制點 A 的值和左邊 block 中 A 的值是不一樣的左邊是 0.3、0、0.7,右邊 0.3、0.7、0,因爲左邊區域第四個 ID 是 4,右邊區域的4號貼圖是第三個 ID。

在《三國:謀定天下》我們提到一共會創建576個 unity terrain 節點,每個都會導出一張 Splat 貼圖,每一個 Splat 貼圖包含了 17×17 個 block,每個 block 包含 32×32 個控制點,並且覆蓋 3.1×3.1 米的區域。可以算出來我們每張 Splat 的貼圖尺寸是 544×544,這裡有一個注意點,由於跨 block 的 Splat 像素沒有辦法插值的,不能讓它們同時出現在 ASTC 中的 block 中,我們的 block 現在是 32×32 的,所以它存儲在 ASTC8×8 中是不會有插值問題的,但是如果 block 是 36×36,那就只能是使用 ASTC6×6 或者 4×4 這種格式了,要讓它整除 36。

然後 IDMap 只有一張全局的,貼圖寬度是 24×17 408 個像素,每個 IDMap 的像素需要兩個字節存儲 4 個 ID。

由於每張 Splat 貼圖它覆蓋的區域是 51.2×51.2,正好等於一個 LOD4chunk 的面積,所以在渲染 LOD0 到 LOD4 chunk 時,只需要採樣一張 Splat 貼圖就可以完成渲染,但是 LOD5 和 LOD6 chunk 需要更多張 Splat 貼圖,因此我們將 4 張 544×544 的 Splat 貼圖合併成一張新的 544×544 尺寸的 Splat1,這個 Splat1 的貼圖是用於 LOD5 的 chunk 渲染。同樣我們會將 4 張 Splat1 的貼圖合併一張同尺寸的 Splat2 的貼圖用於 LOD6 的 chunk 渲染,這個跟Mipmap是很像的,也是一種 LOD。

最終所有的 Splat 貼圖加起來是 53.3 兆,由於每級 LOD 同時加載進內存的地塊數量是 25 個,所以大約需要加載 75 張 splatmap,也就是 5.3 兆左右的貼圖完成單個視角的渲染,在生成的第二級 Splat 貼圖中,每個 block 包含的控制點的數量也將從 32×32 降低至 8×8 個,它更好可以放進 ASTC8×8 的 block 中,這是爲什麼我們要選 32×32 作爲 block。

Unity 官方微信

第一時間瞭解Unity引擎動向,學習進階開發技能

每一個“在看”,都是我們前進的動力