動(dòng)態(tài)分頁加載


項(xiàng)目第一次對外技術(shù)測試落下帷幕,終于有時(shí)間來填大世界動(dòng)態(tài)加載這樣一個(gè)大坑注簿。

從去年11月份開始女阀,在需求改變宅荤、制作方案更改等各種影響下,斷斷續(xù)續(xù)地制作維護(hù)這個(gè)功能浸策,估算下來花費(fèi)在它上面的有效時(shí)間也得有1個(gè)月左右冯键。目前我們游戲大世界的制作進(jìn)入鋪量階段,已經(jīng)制作好的功能也經(jīng)過了第一次技術(shù)測試的驗(yàn)證庸汗,靜下心來寫這篇《Unity手游開發(fā)札記——2.5D大世界動(dòng)態(tài)加載實(shí)戰(zhàn)》惫确。

需要說明的是:一方面任何技術(shù)方案都有其適用范圍,相對應(yīng)的也就是它們有著自身的局限性蚯舱,因此這篇文章肯定不是一顆萬能的“銀彈”改化;另外一方面,在實(shí)際工程中枉昏,實(shí)現(xiàn)一段代碼陈肛、一個(gè)技術(shù)功能點(diǎn)往往是最為簡單的那步,設(shè)計(jì)適合團(tuán)隊(duì)工作的工作流程兄裂,讓功能可以快速高效的產(chǎn)出結(jié)果句旱,并且便于維護(hù),才是工作量更大的部分懦窘。因此前翎,正在閱讀這篇文章的你稚配,不必抱著多大的希翼可以通過學(xué)習(xí)它實(shí)現(xiàn)你們自己項(xiàng)目的大世界動(dòng)態(tài)加載架構(gòu)畅涂,它是一篇“實(shí)戰(zhàn)記錄”——意味著這里的經(jīng)驗(yàn)經(jīng)歷過一個(gè)真實(shí)項(xiàng)目的洗禮,也意味著它們可能更適用于我們項(xiàng)目而已道川。


1.1 需求分析

“需求一直都在變化午衰,沒有需求會(huì)消失立宜。。臊岸〕仁”回頭來看我們游戲整個(gè)大世界的制作方案的確定過程,我要把改編自奧維德名言的這句話送給我們團(tuán)隊(duì)的策劃和美術(shù)同學(xué)帅戒,這里飽含了一個(gè)程序的吐(fen)槽(hen)灯帮。我們項(xiàng)目的需求變更主要體現(xiàn)在大世界制作方案的改變上,從2016年11月份開始逻住,經(jīng)歷過傳統(tǒng)3D制作方案钟哥、基于六邊形的風(fēng)格化方案、比例縮小版寫實(shí)風(fēng)格瞎访,最后到基于Terrain的沙盤風(fēng)格腻贰。每次變更都意味著美術(shù)制作流程的變化,隨之而來的就是程序需要開發(fā)的工具集調(diào)整扒秸。

回到項(xiàng)目立項(xiàng)初期討論的時(shí)候播演,當(dāng)時(shí)我們就確定了大世界的方向。其實(shí)從程序的角度能夠預(yù)估這其中的技術(shù)難度伴奥,畢竟團(tuán)隊(duì)中從策劃到程序再到美術(shù)誰都沒做過完整的大世界項(xiàng)目写烤。帶著初創(chuàng)團(tuán)隊(duì)初生牛犢不怕虎的勁,再加上策劃同學(xué)拍著胸脯說“實(shí)在不行我們就用2D地圖也能接受”的允諾拾徙,就往這個(gè)方向來努力顶霞。

第一個(gè)版本美術(shù)預(yù)研的大世界效果出來之后,糾結(jié)在視角使用3D還是2.5D——2.5D的大世界制作成本和技術(shù)難度會(huì)比較低锣吼,但是從當(dāng)時(shí)的設(shè)計(jì)來看选浑,3D的體驗(yàn)會(huì)更好,而且看得越遠(yuǎn)越好……因此最初的技術(shù)預(yù)研方向也是在往自由3D視角的目標(biāo)來做玄叠。

1.2 Unity插件調(diào)研

從程序角度古徒,無論什么視角,針對Unity引擎做初步的技術(shù)調(diào)研是最基礎(chǔ)的工作读恃。這時(shí)候有那么一點(diǎn)懷念之前自己掌握引擎代碼的日子隧膘,即使沒有引擎組的支持,自己在引擎C++底層來做是方法明確而且效率更高的方式寺惫。好在Unity也有自身的優(yōu)勢——Asset Store疹吃。搜索加詢問,最后找到看著還比較靠譜的兩個(gè)插件——SECTR COMPLETEWorld Streamer西雀。

World Streamer這個(gè)我沒有非常仔細(xì)去看實(shí)現(xiàn)細(xì)節(jié)萨驶,整體的思路是按照位置拆分成按照Scene組織的格子(Grid),然后根據(jù)距離做逐步加載艇肴,因?yàn)橐獏^(qū)分地表腔呜、特物體和細(xì)節(jié)物體等不同粒度叁温,提供了分層拆分的功能。提供一篇找到的博客供需要的同學(xué)參考:《Unity 場景分頁插件 World Streamer 支持無限大地圖的解決方案》核畴。

WorldStreamer拆分后的場景列表

SECTR COMPLETE是我購買并學(xué)習(xí)了一段時(shí)間的一個(gè)插件膝但,原因之一是這個(gè)插件是被Unity官方推薦過的,而且FireWatch游戲就是用的這個(gè)插件谤草,可以參考GDC的演講Making the World of Firewatch跟束。這個(gè)COMPLETE是一個(gè)售價(jià)100美元的插件集合,它包括CORE丑孩、STREAM泳炉、VIS和AUDIO等幾個(gè)部分。VIS做動(dòng)態(tài)的遮擋剔除嚎杨,動(dòng)態(tài)的大世界主要是STREAM部分花鹅。

SECTR STREAM通過自動(dòng)或者手動(dòng)創(chuàng)建Sector的方式,用包圍盒來決定場景中的哪些物體被放置到哪一個(gè)Sector中枫浙,然后將這些Sector導(dǎo)出為名稱對應(yīng)的分塊場景刨肃,加載的時(shí)候在攝像機(jī)上添加一個(gè)Loader,通過Loader與留在場景中的Sector碰撞盒進(jìn)行交互來判斷哪些Sector對應(yīng)的場景組件需要被加載箩帚。Loader的類型不同加載方式也不同真友,比如包括Neighbor Loader、Region Loader紧帕、Trigger Loader甚至DIY Loader等盔然。

SECTR STREAM的拆分界面

由于最終我們并沒有使用這兩個(gè)插件,因此在此不進(jìn)行更詳細(xì)的描述是嗜,有興趣的朋友可以自己買來玩一下愈案。

1.3 UWA技術(shù)分享

在2016年11月份的時(shí)候,UWA組織了一場在上海的分享鹅搪,其中有一個(gè)就是張強(qiáng)的《大規(guī)模場景的資源拆分和動(dòng)態(tài)加載》站绪,很興奮地去聽了一下,主要是2.5D視角下基于Terrian的實(shí)現(xiàn)方案丽柿,因?yàn)楫?dāng)時(shí)我們的需求方案還是傾向于3D自由視角恢准,所以聽的時(shí)候感覺幫助沒有那么大。當(dāng)時(shí)在回來之后的博客筆記里說——

“我個(gè)人覺得這部分的一個(gè)問題是整個(gè)工程是基于一個(gè)Demo性質(zhì)的實(shí)現(xiàn)甫题,而非正式的項(xiàng)目馁筐,因?yàn)闀r(shí)間關(guān)系沒有在后面進(jìn)行深入的交流,因此也不清楚目前的實(shí)現(xiàn)是否在正式的項(xiàng)目中應(yīng)用了坠非∶舫粒”

在現(xiàn)在來看,其實(shí)張強(qiáng)的分享內(nèi)容中有很多是我在后面設(shè)計(jì)和實(shí)現(xiàn)的過程中沒有去考慮的部分,比如資源打包策略的制定等赦抖,這些問題都是在實(shí)際項(xiàng)目中需要去注意的內(nèi)容舱卡。而當(dāng)時(shí)我想了解但這個(gè)分享不包含的內(nèi)容是大世界的制作和維護(hù)流程的部分辅肾,鑒于主題是《大規(guī)模場景的資源拆分和動(dòng)態(tài)加載》队萤,其實(shí)針對這一主題已經(jīng)很有實(shí)用性了。這里也借這篇文章的機(jī)會(huì)矫钓,給UWA的張強(qiáng)同學(xué)做一個(gè)小小的道歉要尔,當(dāng)時(shí)的評(píng)價(jià)過于草率,非常抱歉新娜。

如果想要了解這次分享的同學(xué)可以去UWA官網(wǎng)搜索赵辕,這里給一個(gè)我自己備份的PPT下載地址

1.4 調(diào)研結(jié)果

通過對這兩個(gè)插件和UWA分享沙龍的學(xué)習(xí)概龄,基本確定了在Unity中制作動(dòng)態(tài)大世界的基本思路:美術(shù)制作完整場景 -> 自動(dòng)/手動(dòng)拆分場景 -> 運(yùn)行時(shí)根據(jù)規(guī)則自動(dòng)加載角色周圍的部分还惠。

制作動(dòng)態(tài)大世界的基本思路

與此同時(shí),也了解到幾個(gè)需要去注意的技術(shù)點(diǎn):

光照貼圖私杜,整個(gè)大世界使用一張Lightmap顯然不合適蚕键,SECTR STREM是支持自動(dòng)拆分的,也有一些插件支持光照貼圖的拆分衰粹,這個(gè)貌似不用太擔(dān)心锣光;

尋路信息,Unity 5.6之前的版本是不支持動(dòng)態(tài)的Nav Mesh的铝耻,只能跟隨場景加載/卸載誊爹。既然沒有辦法更改,暫時(shí)看起來也沒有擔(dān)心的必要瓢捉;

光照探針频丘,這個(gè)也是不支持動(dòng)態(tài)加載的,但是初步看起來手游項(xiàng)目用這個(gè)的可能性不太大泡态,暫時(shí)不去擔(dān)心椎镣。

2. Demo實(shí)現(xiàn)

在進(jìn)行一系列的技術(shù)調(diào)研之后,也迎來了一大波的需求調(diào)整兽赁。通過美術(shù)工作量状答、項(xiàng)目時(shí)間限制和技術(shù)難度評(píng)估的綜合考量之后,我們終于妥協(xié)為了2.5D視角刀崖,但是鏡頭高度會(huì)相對普通的2.5D要高不少惊科。2.5D視角的確定讓整個(gè)功能實(shí)現(xiàn)的技術(shù)難度降低了很大一部分,也確定了自己來開發(fā)動(dòng)態(tài)加載核心功能的技術(shù)方向亮钦。經(jīng)歷一些糾結(jié)和試驗(yàn)之后馆截,最終選擇最為通用的基于九宮格的動(dòng)態(tài)加載方案,主要原因包括:

現(xiàn)成的插件雖然功能強(qiáng)大但是有各種問題,比如SECTR STREM需要對每一個(gè)Sector創(chuàng)建一個(gè)GameObject和對應(yīng)的碰撞盒蜡娶,在手游上擔(dān)心有比較大的消耗混卵;拆分過程雖然很靈活但是需要美術(shù)進(jìn)行較多的操作,當(dāng)拆分完畢之后窖张,如果想再進(jìn)行編輯幕随,需要再做一遍完整的拆分過程才行;

九宮格的方案技術(shù)難度比較低,需要定制的內(nèi)容也相對較少宿接,可以按照我們自己的美術(shù)制作流程來進(jìn)行定制赘淮,做到最大程度上的自動(dòng)化;

最后睦霎,自己造輪子不也是程序員的樂趣之一梢卸,不是么?(手動(dòng)微笑)

九宮格的方案其實(shí)很簡單也很好理解副女,將完整的大世界按照固定大小拆分成小的Chunk蛤高,然后運(yùn)行時(shí)根據(jù)角色位置和約定好的Chunk尺寸判斷角色所在的Chunk和周圍八塊的索引,加載對應(yīng)的Chunk文件即可碑幅。當(dāng)角色移動(dòng)的時(shí)候戴陡,判斷是否需要加載新的Chunk和卸載老的Chunk文件。在這個(gè)階段美術(shù)還在做效果預(yù)研枕赵,所以自己先制作一個(gè)Demo來模擬整個(gè)功能猜欺。首先還是先設(shè)想了一下整個(gè)大世界的制作流程,大致如下:

美術(shù)完成整個(gè)大世界場景的制作拷窜;

使用自動(dòng)拆分工具开皿,根據(jù)設(shè)置好的分塊大小,將場景中的每一個(gè)物體根據(jù)位置坐標(biāo)拆分到對應(yīng)的Chunk中篮昧;

將Chunk自動(dòng)保存成規(guī)定路徑下對應(yīng)名稱的場景文件(.scene)赋荆,刪除拆分過的Chunk文件,剩下的作為BaseWorld.scene文件懊昨;

運(yùn)行時(shí)首先加載BaseWorld窄潭,然后在角色身上綁定一個(gè)DynamicLoader,根據(jù)角色位置自動(dòng)按照Additive的方式加載周圍九塊Chunk對應(yīng)的場景酵颁。

在這個(gè)工作流程下嫉你,美術(shù)制作的永遠(yuǎn)都是完整的大世界場景,約定好分塊大小躏惋,只需要使用自動(dòng)拆分工具就可以更新拆分后的場景文件幽污。這里關(guān)于尋路信息和光照貼圖信息的處理如下:

美術(shù)烘焙場景的單位為一個(gè)單獨(dú)Chunk的場景文件,即在確定本塊場景不修改之后再進(jìn)行烘焙工作簿姨,如果還需要修改距误,就需要重新烘焙簸搞,烘焙過的場景加入到自動(dòng)導(dǎo)出工具的覆蓋黑名單中,完整重新導(dǎo)出時(shí)不再進(jìn)行覆蓋准潭;

Navmesh和一些跨Chunk的全局物體(比如大面積的水域)暫時(shí)放置在BaseWorld中趁俊,運(yùn)行時(shí)BaseWorld.scene為激活的場景;

所有的光照刑然、霧效果等信息一律放置在BaseWorld.scene中寺擂。

按照這個(gè)工作流程,需要制作的工具包括場景自動(dòng)拆分功能和自動(dòng)加載組件兩個(gè)部分闰集。

2.1 場景自動(dòng)拆分實(shí)現(xiàn)

場景自動(dòng)拆分的功能比較簡單沽讹,最終也僅僅實(shí)現(xiàn)了如下截圖中的幾個(gè)功能般卑,最為核心的也就是“自動(dòng)拆分場景”和“導(dǎo)出拆分后的物體”兩個(gè)了武鲁。

自動(dòng)拆分工具界面截圖

代碼也很簡單,首先遍歷所有需要處理的GameObject蝠检,我們只需要處理包含MeshRender組件和Terrain組件的物體即可沐鼠。這里給美術(shù)添加了一個(gè)限制,有MeshRender的GameObject的孩子節(jié)點(diǎn)不再進(jìn)行拆分叹谁,因?yàn)闉榱吮3衷械膶哟谓Y(jié)構(gòu)饲梭,如果一個(gè)GameObject的孩子被分配到了不同Chunk,那個(gè)這個(gè)作為父節(jié)點(diǎn)的GameObject會(huì)被完整拷貝到多個(gè)Chunk中焰檩。那么憔涉,如果父節(jié)點(diǎn)包含了比如MeshRender的組件,就會(huì)導(dǎo)致較多的渲染消耗析苫,也并不合理兜叨,因此只要包含MeshRender這樣的組件就會(huì)連著其孩子節(jié)點(diǎn)完整地放置到一個(gè)Chunk中。

// 首先使用遍歷出所有需要處理的GameObject

GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();

List objsToProcess = new List();

foreach (GameObject root in roots)

{

TraverseHierarchy(root.transform, new ActionTransform((Transform obj) =>

{

//如果有MeshRender或者Terrain組件衩侥,并且是靜態(tài)物體回右,則認(rèn)為是一個(gè)要處理的葉子節(jié)點(diǎn)心软,不再處理其孩子節(jié)點(diǎn)了

GameObject tempObj = obj.gameObject;

if (tempObj.activeSelf == false)

{

return false;

}

if ((tempObj.GetComponent() || tempObj.GetComponent()) && (!onlyStatic || (onlyStatic && tempObj.isStatic)))

{

objsToProcess.Add(tempObj);

return false;

}

else

{

return true;

}

}), false);

}

找到所有需要拆分的物體之后,直接按照位置進(jìn)行拆分即可。

// 逐個(gè)處理可能需要移動(dòng)的GameObject

for (int i = 0; i < objsToProcess.Count; ++i)

{

EditorUtility.DisplayProgressBar(progressTitle, "Processing " + objsToProcess[i].name, (float)i / (float)objsToProcess.Count);

ClassifyGameObject(objsToProcess[i], width, height);

}

///

/// 對一個(gè)GameObject按照位置進(jìn)行分類贩耐,放置到對應(yīng)的根節(jié)點(diǎn)下面。

///

///

static void ClassifyGameObject(GameObject obj, float width, float height)

{

Vector3 pos = obj.transform.position;

// chunk的索引

int targetChunkX = (int)(pos.x / width) + 1;

int targetChunkZ = (int)(pos.z / height) + 1;

string chunkName = ChunkRootNamePrefix + string.Format("{0}_{1}", targetChunkX, targetChunkZ);

GameObject chunkRoot = GameObject.Find(chunkName) ;

if (chunkRoot == null)

{

chunkRoot = new GameObject(chunkName);

}

//復(fù)制層次關(guān)系到Chunk的節(jié)點(diǎn)下面

GameObject tempObj = obj;

List objs2Copy = new List();

while(tempObj.transform.parent)

{

objs2Copy.Add(tempObj.transform.parent.gameObject);

tempObj = tempObj.transform.parent.gameObject;

}

tempObj = chunkRoot;

for (int i = objs2Copy.Count - 1; i > -1; --i)

{

GameObject targetObj = objs2Copy[i];

// 對于符合Chunk命名規(guī)則的父節(jié)點(diǎn)不進(jìn)行拷貝過程售葡。

if (targetObj.name.StartsWith(ChunkRootNamePrefix))

{

continue;

}

Transform parent = tempObj.transform.FindChild(targetObj.name);

if (parent == null)

{

parent = new GameObject(targetObj.name).transform;

CopyComponents(targetObj, parent.gameObject);

parent.parent = tempObj.transform;

targetObj = parent.gameObject;

}

tempObj = parent.gameObject;

}

Transform tempParent = obj.transform.parent;

obj.transform.parent = tempObj.transform;

// 移動(dòng)完畢之后發(fā)現(xiàn)父節(jié)點(diǎn)沒有孩子節(jié)點(diǎn)的情況下蝶桶,向上遍歷將無用節(jié)點(diǎn)刪除。

while (tempParent != null && tempParent.childCount == 0)

{

Transform temp = tempParent.parent;

EngineUtils.Destroy(tempParent.gameObject);

tempParent = temp;

}

}

拆分完畢之后的場景如下圖所示爱榔。這一步需要美術(shù)進(jìn)行一個(gè)大致的檢查被环,保證拆分結(jié)果的正確性。

經(jīng)過拆分的場景結(jié)構(gòu)

然后將拆分后的組件保存到對應(yīng)的Scene文件中搓蚪,這里為了避免遺漏拷貝場景參數(shù)蛤售,采用了比較trick的方式——生成每一個(gè)Chunk文件時(shí),將完整場景文件進(jìn)行一次拷貝,然后刪除掉不需要的GameObject悴能,即比如要生成_worldchunk6_8.scene揣钦,將整個(gè)場景文件完整拷貝,然后刪除掉除了_worldchunk6_8這個(gè)GameObject之外的所有物件漠酿。這樣就做到了所有場景參數(shù)的一致性冯凹,但是代價(jià)是花費(fèi)的時(shí)間稍微久一點(diǎn)。

這樣做的意義在于炒嘲,比如Ambient Source相關(guān)的參數(shù)會(huì)影響烘焙結(jié)果宇姚,如果稍微有些不同,會(huì)導(dǎo)致最終烘焙出來的Chunk之間存在明顯的接縫問題夫凸。

static void ExportChunksToScenes()

{

EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();

GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();

List rootsNamesToExport = new List();

foreach (GameObject root in roots)

{

if (root.name.StartsWith(ChunkRootNamePrefix))

{

rootsNamesToExport.Add(root.name);

}

}

if (rootsNamesToExport.Count == 0)

{

EditorUtility.DisplayDialog("Export Error", "不存在符合導(dǎo)出要求的分組浑劳,請先使用自動(dòng)拆分功能!", "確定");

return;

}

if (!EditorUtility.DisplayDialog("Info", "導(dǎo)出場景將會(huì)刪除之前已經(jīng)導(dǎo)出過的場景Chunk目錄夭拌,是否繼續(xù)?", "繼續(xù)", "取消"))

{

return;

}

string sceneDir;

string sceneName;

string exportDir = MakeExportFolder("Chunks", true, out sceneDir, out sceneName);

if (string.IsNullOrEmpty(exportDir))

{

EditorUtility.DisplayDialog("Export Error", "Could not create Chunks folder. Aborting Export.", "Ok");

return;

}

string progressTitle = "導(dǎo)出拆分后的場景";

EditorUtility.DisplayProgressBar(progressTitle, "Preparing", 0);

string originalScenePath = CurrentScene();

int counter = -1;

foreach (string rootName in rootsNamesToExport)

{

counter += 1;

EditorUtility.DisplayProgressBar(progressTitle, "Processing " + rootName, (float)counter / (float)rootsNamesToExport.Count);

string chunkScenePath = exportDir + "/" + rootName + ".unity";

AssetDatabase.CopyAsset(originalScenePath, chunkScenePath);

EditorSceneManager.OpenScene(chunkScenePath, OpenSceneMode.Single);

GameObject[] tempRoots = EditorSceneManager.GetActiveScene().GetRootGameObjects();

foreach (GameObject r in tempRoots)

{

if (r.name != rootName)

{

EngineUtils.Destroy(r);

}

}

EditorSceneManager.SaveScene(EditorSceneManager.GetActiveScene());

AssetDatabase.Refresh();

}

// 拷貝出一個(gè)刪除了Chunk物體的Base場景

string baseScenePath = sceneDir + "/" + "baseworld.unity";

AssetDatabase.DeleteAsset(baseScenePath);

AssetDatabase.CopyAsset(originalScenePath, baseScenePath);

EditorSceneManager.OpenScene(baseScenePath, OpenSceneMode.Single);

GameObject[] chunkRoots = EditorSceneManager.GetActiveScene().GetRootGameObjects();

foreach (GameObject r in chunkRoots)

{

if (rootsNamesToExport.Contains(r.name))

{

EngineUtils.Destroy(r);

}

}

EditorSceneManager.SaveScene(EditorSceneManager.GetActiveScene());

AssetDatabase.Refresh();

// Cleanup

EditorUtility.ClearProgressBar();

}

拆分后的Chunk場景列表如下:

拆分后的Chunk場景列表

2.2 動(dòng)態(tài)加載組件

動(dòng)態(tài)加載的過程也并不復(fù)雜魔熏,因?yàn)樯婕暗接螒騼?nèi)的代碼,這里就不放源碼了鸽扁,整個(gè)算下也也就不到500行蒜绽,邏輯也很簡單。綁定一個(gè)Transform桶现,每幀update檢查Transform的位置所對應(yīng)的Chunk的索引是否有變化躲雅,如果有則計(jì)算出需要卸載的Chunk和需要加載的Chunk執(zhí)行卸載和加載操作。

在Demo階段骡和,選擇使用Scene來作為Chunk的存儲(chǔ)單元的原因主要有:

看到的兩款插件都是基于Scene來做的相赁,而且Unity從5.0開始就原生支持Multi-Scenes的場景加載方式,因此預(yù)想問題應(yīng)該不大即横;

考慮到美術(shù)進(jìn)行烘焙的最小單元是Scene噪生,使用Scene作為最小單元可以“偷懶”不用去手動(dòng)管理每一個(gè)Chunk的Lightmap數(shù)據(jù),對于多個(gè)Scene同時(shí)進(jìn)行烘焙的方案也是進(jìn)行過實(shí)驗(yàn)东囚,證明具有可行性的跺嗽。

這樣,我就基于設(shè)想中的美術(shù)制作流程實(shí)現(xiàn)了第一版本的動(dòng)態(tài)加載Demo页藻。

2.3 問題總結(jié)

除了一些代碼實(shí)現(xiàn)上的bug之外桨嫁,這里值得記錄的幾個(gè)問題有:

1) Static Batching導(dǎo)致的頓卡

在電腦上運(yùn)行的時(shí)候已經(jīng)可以感受到明顯的卡頓,打開Profiler看了下發(fā)現(xiàn)是由于Static Batching導(dǎo)致的:

Static Batching導(dǎo)致的加載頓卡

解決方法很簡單份帐,在測試項(xiàng)目中關(guān)閉了工程的Static Batching璃吧,而在正式工程中,場景組件不再勾選Static Batching選項(xiàng)废境,就可以避免Chunk的場景加載時(shí)這段CPU消耗的峰值畜挨。當(dāng)然代價(jià)也是無法進(jìn)行batching筒繁,draw call的消耗比較高。

2) NavMesh分塊測試

因?yàn)椴凰佬陌驮蕴匾庾隽艘幌翹avMesh分場景bake之后加載的效果毡咏,果然是不行的——在其中一塊NavMesh上無法移動(dòng)到另外一個(gè)Chunk的NavMesh上:

多塊NevMesh的移動(dòng)試驗(yàn)

3) 場景物件導(dǎo)入到Unity的時(shí)候中心點(diǎn)需要在原點(diǎn)

這個(gè)比較好理解,按照位置把物體劃分到Chunk的時(shí)候是按照世界坐標(biāo)來劃分的逮刨,如果物件的中心點(diǎn)位置并不在中心點(diǎn)的話呕缭,可能會(huì)造成偏差,這也是自動(dòng)拆分工具執(zhí)行完畢之后需要美術(shù)進(jìn)行檢查的一部分工作之一修己。解決方法一方面是要告知美術(shù)場景物件導(dǎo)入到Unity的時(shí)候中心點(diǎn)需要在原點(diǎn)這個(gè)規(guī)則恢总,另外一方面是在代碼中使用包圍盒的中心點(diǎn)而非世界坐標(biāo)的位置來作為劃分區(qū)域,這樣可能錯(cuò)誤的概率更小一點(diǎn)睬愤。當(dāng)然片仿,如果物件的形狀太過奇怪,包圍盒的方式也可能會(huì)有問題戴涝。

4) 所有場景的Lightmap模式必須一致

在測試應(yīng)用烘焙效果的問題的時(shí)候滋戳,出現(xiàn)過Lightmap失效的情況钻蔑,檢查后發(fā)現(xiàn)是因?yàn)椴糠謭鼍笆褂昧四J(rèn)的Directional模式啥刻,部分場景使用了Non-Directional的模式導(dǎo)致的。

在Demo完成之后咪笑,進(jìn)行打包和手機(jī)上的簡單測試可帽,基本滿足了設(shè)想的要求。這段時(shí)間場景美術(shù)也進(jìn)入了美術(shù)效果和制作方案的頻繁更改階段窗怒,這塊工作也就擱置了很長一段時(shí)間映跟。

3. 正式版本實(shí)現(xiàn)

最初的Demo版本沒有去考慮的一個(gè)內(nèi)容是像地表這樣的大塊Mesh是如何拆分的,原因也主要是當(dāng)時(shí)美術(shù)的制作方案是按照六邊形作為一個(gè)單元扬虚,每一個(gè)單元都不會(huì)很大努隙,自然可以正確地被分割到不同的Chunk中。而后面改為T4M刷地表貼圖來表現(xiàn)更多細(xì)節(jié)的制作方案之后辜昵,就有了可能需要讓美術(shù)手動(dòng)拆分或者程序來做Mesh分割的需求荸镊。想來也不是很難,按照頂點(diǎn)的位置來做判斷堪置,確定要分割的邊界之后把這些邊界上的頂點(diǎn)復(fù)制多份分別放到對應(yīng)的Chunk下似乎也就可以了躬存。但當(dāng)這塊預(yù)研工作剛剛開始推進(jìn)的時(shí)候,美術(shù)又改了主意舀锨,為了表現(xiàn)地面的高低起伏岭洲,想用Terrain的方式來進(jìn)行地表的制作。

技術(shù)上仍然不算什么難題坎匿,Unity有豐富的插件來做這種事情盾剩,而且相比于Unity5之后就不再維護(hù)的T4M雷激,似乎官方的Terrain更好用也更穩(wěn)定一點(diǎn)點(diǎn)。Terrain轉(zhuǎn)Mesh的插件有不少告私,我們使用的是Terrain To Mesh侥锦,后文統(tǒng)一簡稱T2M。經(jīng)過思考和討論德挣,權(quán)衡一些問題之后恭垦,最后制定了如下圖所示的工作流程。

基于Terrain和T2M的工作流程圖

我們分幾步來說明一下這個(gè)流程圖的幾個(gè)關(guān)鍵步驟的設(shè)置原因和具體的制作方式格嗅。

3.1 Chunk大小的確定

其實(shí)在這個(gè)流程開始之前番挺,第一件要做的事情是確定Chunk的大小尺寸。在之前Demo中構(gòu)想的流程里屯掖,因?yàn)橐曇靶亍⒚佬g(shù)風(fēng)格都未確定,為了能夠方便地兼容Chunk尺寸更改的情況贴铜,所有的組件都是在美術(shù)進(jìn)行了Chunk大小的設(shè)置之后自動(dòng)拆分的粪摘。這樣如果中途要更改Chunk大小,其實(shí)是一件工作量不太大的事情绍坝,只是烘焙過程要重新進(jìn)行徘意。而基于Terrain的方案,雖然T2M也有自動(dòng)拆分的功能轩褐,但是手游上處于性能和省電的要求椎咧,我們規(guī)定——

每一個(gè)地表所能使用的貼圖層數(shù)不能超過4張,盡量保證3張的時(shí)候也是可看的把介,低配下程序保留了強(qiáng)制切換為3張的權(quán)利勤讽。

于是美術(shù)就要求可以更加靈活地使用和分配這幾層貼圖。由于我們大世界會(huì)有不同的地貌和氣候風(fēng)格拗踢,風(fēng)格之間還要有過度的效果脚牍,因此經(jīng)過商討,美術(shù)可以自由分配貼圖的最小單位為一個(gè)Chunk巢墅。這樣就不太好把很大一塊區(qū)域作為一整個(gè)Terrain來制作诸狭,因此我們使用了一個(gè)Chunk就是一個(gè)Terrain的方案,讓美術(shù)可以自由分配這個(gè)Chunk下的四張Layer貼圖的內(nèi)容砂缩。(這里和美術(shù)討論的糾結(jié)過程就不詳細(xì)描述了作谚,這些瑣碎的細(xì)節(jié)可能只有真正使用這種制作方案的人才能有更深的體會(huì)。)

那么庵芭,首要的問題就是確定Chunk的大小妹懒,而這個(gè)一旦確定,制作工作開展之后双吆,再修改的代價(jià)就非常大了眨唬。好在這時(shí)候鏡頭的參數(shù)早已確定会前,于是作為靈魂畫手的我就經(jīng)過“現(xiàn)場踩點(diǎn)”等精妙操作,畫了這樣一張圖匾竿。瓦宜。。

此處輸入圖片的描述

考慮到我們的地表還有高低起伏岭妖,再加上為了兼容策劃后面一些變動(dòng)的可能临庇,我們最終把一個(gè)Chunk大小定義為70m * 70m。由于我們的美術(shù)風(fēng)格還比較特殊昵慌,偏抽象沙盤的風(fēng)格假夺,因此面數(shù)和Draw Call方面相比于3D的視角或者更加寫實(shí)的2.5D具有更多的可壓空間,這種比較遠(yuǎn)的視野范圍在性能方面目前還可以接受斋攀。

3.2 為美術(shù)自動(dòng)生成Chunk

這個(gè)時(shí)候的工作推進(jìn)其實(shí)已經(jīng)比較順利了已卷,因?yàn)檎麄€(gè)大世界的功能需求已經(jīng)確定,尺寸也不會(huì)很大淳蔼,估計(jì)在1000m * 1000m左右的大小侧蘸。Terrain在Unity中的拷貝也有點(diǎn)煩,因?yàn)樯婕暗絋errainData的拷貝鹉梨,而且這貨會(huì)默認(rèn)創(chuàng)建在Assets的根目錄下讳癌,讓美術(shù)去手動(dòng)創(chuàng)建100多個(gè)Terrain對象,人力消耗暫且不說俯画,光是想想位置擺放精準(zhǔn)度析桥、參數(shù)設(shè)定、資源命名和存放等問題艰垂,就覺得可能有很多屁股要擦。埋虹。猜憎。

于是半個(gè)小時(shí),寫一段簡單代碼搔课,來自動(dòng)創(chuàng)建:

private static void onInitTerrain(int xNum, int yNum, float xWidth, float yWidth)

{

string folderPath = "Assets/Res/Environments/Worlds/Terrains/";

if (!System.IO.Directory.Exists(folderPath))

{

// Create new folder. Use substring b/c Unity dislikes the trailing /

AssetDatabase.CreateFolder("Assets/Res/Environments/Worlds", "Terrains");

}

GameObject parent = new GameObject("WorldTerrains");

parent.transform.position = new Vector3(0, 0, 0);

for (int x = 1; x <= xNum; x++)

{

for (int y = 1; y <= yNum; y++)

{

TerrainData terrainData = new TerrainData();

string name = "WorldTerrain" + x + "_" + y;

terrainData.size = new Vector3(xWidth/16f, 600, yWidth / 16f);

terrainData.baseMapResolution = 1024;

terrainData.heightmapResolution = 513;

terrainData.SetDetailResolution(1024, 16);

// 可以在此設(shè)置默認(rèn)貼圖

//SplatPrototype[] terrainTexture = new SplatPrototype[3];

//terrainTexture[0] = new SplatPrototype();

//terrainTexture[0].texture = (Texture2D)Resources.Load("Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4");

//terrainTexture[1] = new SplatPrototype();

//terrainTexture[1].texture = (Texture2D)Resources.Load("Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4");

//terrainTexture[2] = new SplatPrototype();

//terrainTexture[2].texture = (Texture2D)Resources.Load("Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4");

//terrainData.splatPrototypes = terrainTexture;

terrainData.name = name;

GameObject terrain = (GameObject)Terrain.CreateTerrainGameObject(terrainData);

terrain.name = name;

terrain.transform.parent = parent.transform;

terrain.transform.position = new Vector3(xWidth * (x - 1), 0, yWidth * (y - 1));

AssetDatabase.CreateAsset(terrainData, folderPath + name + ".asset");

}

}

}

我只能說胰柑,雖然那天白天就制作方案各種討論糾結(jié),但是寫完這段代碼之后爬泥,美術(shù)更加愛我了呢~~(可惜我們美術(shù)中沒有妹子=_=)

3.3 場景細(xì)化和修改

在這個(gè)工作流程中柬讨,我專門用淺綠色部分畫出了一次性的部分,即地形生成之后袍啡,會(huì)進(jìn)行整個(gè)大世界的地形和白模制作踩官。一旦用自動(dòng)拆分工具拆分出Chunk文件,這一過程在之后將不再重復(fù)進(jìn)行境输。一方面因?yàn)檫@一過程代價(jià)很大蔗牡,另外一方面后面基于Chunk和Multi-Scenes的方式也可以對地形等進(jìn)行比較方便的修改颖系。

美術(shù)最早想在T2M轉(zhuǎn)換之后的mesh上應(yīng)用T4M來進(jìn)行地表的修改,這個(gè)方案被我否決了辩越。因?yàn)槭紫葍煞N插件的Shader是不同的嘁扼,需要時(shí)間整合(雖然到寫這篇文章時(shí),我們的同事已經(jīng)進(jìn)行了部分整合)黔攒,其次如果再引入T4M的結(jié)點(diǎn)趁啸,使得這個(gè)工作流變得太過復(fù)雜——雖然看上去似乎靈活了,轉(zhuǎn)為Mesh之后仍然可以修改地表貼圖督惰,但這個(gè)修改對于Terrain層是不可逆的莲绰,如果需要再在Terrain上進(jìn)行修改的時(shí)候,那些在T4M節(jié)點(diǎn)做的修改就會(huì)被沖掉姑丑。

因此蛤签,在這套工作流程中,美術(shù)進(jìn)行頻繁修改栅哀、細(xì)化震肮、迭代的對象,是基于Terrain的地表和場景組件留拾,轉(zhuǎn)換后的Mesh地表不會(huì)進(jìn)行大的改動(dòng)以保證其修改源的唯一性戳晌。

為了處理同時(shí)編輯多個(gè)Terrain的問題,比如要保證地表的連續(xù)性痴柔、貼圖細(xì)節(jié)的連續(xù)性沦偎,我們引入了Multiple Terrain Brush這個(gè)插件到工作流程中,結(jié)合Unity原生的Multi-Scenes同時(shí)編輯的功能咳蔚,可以很好地處理多個(gè)Chunk需要同時(shí)編輯的需求豪嚎。同時(shí)提醒一下,注意控制相鄰Chunk相同貼圖的Tilling參數(shù)的一致性谈火,來避免一些邊界接縫問題侈询。

基于Demo制作的工具,在正式的制作流程中雖然引入了T2M插件糯耍,但是之前的功能在進(jìn)行較小的修改之后也都可以正常使用扔字。而正式的版本花費(fèi)精力最多的部分還是在流程的梳理和討論,確定每一步驟的編輯對象和產(chǎn)出結(jié)果温技,并驗(yàn)證整個(gè)工作流的證確性革为。當(dāng)然,正確性得到保證之后舵鳞,性能上的優(yōu)化也就被推到最前面了震檩。

4. 修改Chunk的存儲(chǔ)方式

在實(shí)現(xiàn)完成正式版本的工作流之后,使用正式的美術(shù)資源在設(shè)備上運(yùn)行之后發(fā)現(xiàn)了一個(gè)比較嚴(yán)重的問題——在移動(dòng)設(shè)備上系任,加載Chunk的過程中恳蹲,會(huì)有比較明顯的頓卡感虐块。

通過Profiler工具進(jìn)行排查,首先看到的問題之一是Shader.Parse()函數(shù)的消耗嘉蕾,在每一個(gè)Chunk的加載時(shí)占用到了200ms以上的時(shí)間贺奠,檢查了一下是由于美術(shù)在部分組件上錯(cuò)誤使用了Diffuse等系統(tǒng)材質(zhì),并且每一個(gè)Chunk場景中都保留了默認(rèn)的天空盒错忱,以及在FBX上的Default-Material中引用了Standard Shader儡率,這些都導(dǎo)致在設(shè)備上有Shader編譯的過程花費(fèi)較多的時(shí)間。在解決完這一問題之后以清,發(fā)現(xiàn)依然有頓卡的問題儿普,尤其當(dāng)角色在Chunk邊界來回行走的時(shí)候,由于初期沒有做緩存掷倔,幀率的降低非常明顯眉孩。下圖是在設(shè)備上截取的頓卡點(diǎn)的時(shí)間消耗數(shù)據(jù)。

Chunk場景加載時(shí)頓卡Profiler截圖

經(jīng)過一些思考和方案對比勒葱,我作出了將Chunk的存儲(chǔ)方式由Scene修改為Prefab的決定浪汪,原因主要有兩個(gè):

之前相信插件使用Scene的方式來做加載,應(yīng)該是有比較好效果的凛虽,然而調(diào)研的兩個(gè)插件雖然都支持mobile死遭,但貌似并沒有找到實(shí)際在移動(dòng)設(shè)備上發(fā)布的項(xiàng)目,再加上詢問了一些在手游做了場景動(dòng)態(tài)加載的項(xiàng)目凯旋,都是使用了Prefab的方案呀潭,因此覺得Prefab的方案在手游上的坑應(yīng)該更少一些;

Scene的加載至非、卸載過程不如Prefab具有可控性钠署,針對Scene對象做緩存也沒有Prefab方便。

這其實(shí)是工作量還比較最大的一次改動(dòng)了睡蟋,主要原因是需要針對Lightmap進(jìn)行存儲(chǔ)踏幻。這里使用的也是Unity中動(dòng)態(tài)更改光照貼圖設(shè)置的做法,即在每一個(gè)進(jìn)行了烘焙的GameObject上添加一個(gè)Component用于存儲(chǔ)它的lightmapIndex和lightmapScaleOffset戳杀,核心的代碼參考:《Unity5.x場景優(yōu)化之動(dòng)態(tài)設(shè)置光照貼圖lightmap》。具體實(shí)現(xiàn)細(xì)節(jié)就不說了夭苗,直接可以參考文章中的源碼信卡,這里只說明下將這一方案用于動(dòng)態(tài)加載大世界的時(shí)候需要進(jìn)行的修改和遇到的問題。

4.1 全局光照貼圖索引的建立

在通常的動(dòng)態(tài)切換場景光照貼圖的實(shí)現(xiàn)方案中题造,只需要在更換的瞬間遍歷所有的需要更改貼圖的組件進(jìn)行更改即可傍菇,光照貼圖的索引在一套光照貼圖內(nèi)也是不變的。但是動(dòng)態(tài)加載Prefab的時(shí)候就有一個(gè)很嚴(yán)重的問題界赔。

美術(shù)是按照單獨(dú)的場景進(jìn)行烘焙的丢习,在每個(gè)場景內(nèi)都有索引從0開始的Lightmap貼圖牵触,而如果想要每一個(gè)Prefab的烘焙信息都是正確的,在運(yùn)行時(shí)需要所有Lightmap貼圖的索引具有唯一性咐低,即需要提前為它們分配一個(gè)整個(gè)大世界場景的全局索引揽思。

我選擇使用一個(gè)ScriptableObject對象來做這件事情,把它納入到自動(dòng)保存光照信息功能中见擦。

[CreateAssetMenu(fileName = "WorldLightmapProfile.asset", menuName = "Custom/DynamicLightMapProfile")]

public class DynamicWorldLightmapProfile : ScriptableObject

{

public List GlobalLightmaps;

///

/// 尋找第一個(gè)為空的位置索引钉汗,作為全局光照貼圖的索引值

///

public int AddGloblaLightmap(string lightmapPath)

{

if (GlobalLightmaps.Contains(lightmapPath))

{

return -1;

}

else

{

for (int i = 0; i < GlobalLightmaps.Count; ++i)

{

if (GlobalLightmaps[i] == "")

{

GlobalLightmaps[i] = lightmapPath;

return i;

}

}

GlobalLightmaps.Add(lightmapPath);

return GlobalLightmaps.Count - 1;

}

}

public int GetGlobalIndex(string linghtmapPath, bool autoAdd=false)

{

int idx = GlobalLightmaps.IndexOf(linghtmapPath);

if (idx > -1)

{

return idx;

}

else if (autoAdd)

{

return AddGloblaLightmap(linghtmapPath);

}

else

{

return -1;

}

}

}

///

/// 方便管理大世界對應(yīng)的光照貼圖全局索引文件的輔助類

///

///

public class DynamicWorldLMProfileHelper

{

// 存儲(chǔ)全局的光照索引文件路徑

// Todo 這樣設(shè)置會(huì)導(dǎo)致全局只能使用這一份,目前還不打算兼容多個(gè)動(dòng)態(tài)場景鲤屡,暫時(shí)先這樣损痰。。酒来。

private static string _worldLightmapProfile = "Assets/Res/Environments/Worlds/WorldLightmapProfile.asset";

private static DynamicWorldLightmapProfile _profile = null;

public static DynamicWorldLightmapProfile getProfile()

{

if (_profile == null)

{

DynamicWorldLightmapProfile profile = AssetDatabase.LoadAssetAtPath(_worldLightmapProfile, typeof(DynamicWorldLightmapProfile)) as DynamicWorldLightmapProfile;

if (profile == null)

{

Debug.LogWarning("沒有默認(rèn)的大世界lightmap信息的配置文件卢未,自動(dòng)創(chuàng)建!");

profile = ScriptableObject.CreateInstance();

AssetDatabase.CreateAsset(profile, _worldLightmapProfile);

AssetDatabase.SaveAssets();

}

_profile = profile;

}

return _profile;

}

public static void ClearProfile()

{

_profile = null;

}

public static void SaveProfile()

{

if (_profile)

{

EditorUtility.SetDirty(_profile);

AssetDatabase.SaveAssets();

}

}

}

這個(gè)ScriptableObject對象中只有一個(gè)數(shù)組,下標(biāo)即全局的光照貼圖索引堰汉,值為光照貼圖的路徑辽社。選擇exr文件的完整路徑是為了兼容Lightmap共用或者一個(gè)場景中存在多張lightmap的情況。(目前推薦美術(shù)一個(gè)Chunk場景只使用一張Lightmap衡奥,因此這種情況并不多見爹袁,但程序結(jié)構(gòu)上是完整支持的。)一個(gè)簡單的示例截圖如下:

全局光照貼圖數(shù)組

在每一個(gè)Chunk對應(yīng)的Prefab文件中矮固,只有一個(gè)用于控制光照貼圖加載和刪除的ChunkLightMapSetting對象失息,它里面除了存儲(chǔ)直接的光照貼圖文件之外,還存儲(chǔ)了局部光照貼圖索引和全局光照貼圖索引的對應(yīng)關(guān)系档址。

public Texture2D[] lightmapLight, lightmapDir;

public LightmapsMode mode;

public int[] globalIndex;? ? ? // 存儲(chǔ)局部光照貼圖索引和全局光照貼圖索引的對應(yīng)關(guān)系

在每一個(gè)帶有烘焙信息的GameObject身上的RendererLightMapSetting組件中存儲(chǔ)的lightmapIndex盹兢,是全局的光照信息。這樣只需要在ChunkLightMapSetting加載和銷毀的時(shí)候重新設(shè)置當(dāng)前LightmapSettings的屬性即可守伸。注意由于其lightmaps屬性為一個(gè)數(shù)組绎秒,因此需要將其擴(kuò)展到當(dāng)前存在的全局索引的最大值,運(yùn)行時(shí)這個(gè)數(shù)組中間會(huì)有很多貼圖是空著的尼摹。

// 擴(kuò)充lightmap的數(shù)量到最大索引值

int maxLength = Mathf.Max(globalIndex) + 1;

if (LightmapSettings.lightmaps.Length < maxLength)

{

lightmaps = new LightmapData[maxLength];

for (int i = 0; i < maxLength; ++i)

{

lightmaps[i] = (i < LightmapSettings.lightmaps.Length && LightmapSettings.lightmaps[i] != null) ? LightmapSettings.lightmaps[i] : new LightmapData();

}

}

else

{

lightmaps = LightmapSettings.lightmaps;

}

4.2 LightmapSettings設(shè)置的幾個(gè)小坑

在使用LightmapSettings的時(shí)候感覺有幾個(gè)跟預(yù)期不太一樣的小坑见芹。

LightmapSettings的lightmaps屬性直接賦值是無效的,必須new一個(gè)新的對象數(shù)組或者將其賦值給一個(gè)臨時(shí)數(shù)組對象蠢涝,修改完畢之后再賦值回去才可以玄呛。不知道是我使用的姿勢不對還是什么原因,另外個(gè)人覺得這里會(huì)有內(nèi)存分配的問題和二,但是目前也沒有找到更好的解決方法徘铝。

當(dāng)?shù)谝粡坙ightmap為空的時(shí)候,整個(gè)場景會(huì)變暗很多。這個(gè)問題一開始遇到的時(shí)候以為是Lightmap加載的一個(gè)bug惕它,反復(fù)觀察了一會(huì)才發(fā)現(xiàn)當(dāng)index為0的那個(gè)Prefab被卸載了之后怕午,整個(gè)場景都變暗了。這個(gè)目前依然不知道原因淹魄,我們的做法是如果第0張為空的話郁惜,則選擇一張已經(jīng)存在的Lightmap貼圖賦值給它,注意這個(gè)處理要在任何一個(gè)Prefab加載或者卸載時(shí)進(jìn)行揭北。

4.3 改進(jìn)后的工作流程

使用Prefab代替Scene來存儲(chǔ)Chunk扳炬,不但需要把之前已經(jīng)制作好的Scene轉(zhuǎn)換為Prefab,而且對于整個(gè)工作流也增加了一點(diǎn)工作量搔体。改進(jìn)之后的工作流程如下:

改進(jìn)后的工作流程

對于美術(shù)來說影響不大恨樟,只是多了一個(gè)要?jiǎng)?chuàng)建Prefab和修改之后應(yīng)用到Prefab上的過程。這個(gè)修改同時(shí)帶來的一個(gè)好處是在場景中可以同時(shí)存在最后使用的prefab和之前的Terrain疚俱、光照等數(shù)據(jù)了劝术,避免了需要?jiǎng)h除再次修改不方便,或者隱藏掉導(dǎo)致打包的時(shí)候帶入包體等問題呆奕。一個(gè)Chunk場景的結(jié)構(gòu)大致如下圖所示:

Chunk場景結(jié)構(gòu)

圖中紅框內(nèi)的是最后要保存的prefab數(shù)據(jù)养晋,其他部分可以用于烘焙和修改用,保存在Scene中梁钾。需要說明的是绳泉,我們的資源打包采用了拆分美術(shù)工作目錄和游戲運(yùn)行目錄的方式,美術(shù)的工作目錄為Assets/Res姆泻,游戲運(yùn)行目錄為Assets/BundleResource的方式零酪,Res中存放所有的美術(shù)資源,但是Prefab拇勃、Scene等需要被游戲直接使用的文件存儲(chǔ)在BundleResource目錄下四苇,打包時(shí)是根據(jù)BundleResource目錄下的所有文件,檢索出其引用到的文件進(jìn)行AssetBundle打包方咆。在這種結(jié)構(gòu)下月腋,Chunk拆分后的Scene文件存放在Res目錄下,Terrain數(shù)據(jù)也存放在Res目錄下瓣赂,只有最后使用的Prefab文件存儲(chǔ)在BundleResource目錄下榆骚。

經(jīng)過修改為Prefab的迭代,其實(shí)使得整個(gè)工作流程更加合理煌集。付出的一個(gè)小代價(jià)是美術(shù)在保存光照信息之后寨躁,在編輯模式下無法正常預(yù)覽烘焙的效果,需要運(yùn)行游戲來預(yù)覽牙勘。但這也可以通過添加ExecuteInEditor相關(guān)的邏輯來實(shí)現(xiàn)。(感謝錢康來同學(xué)提供這個(gè)思路~)

5. Chunk緩存

使用Prefab代替Scene之后,加載Chunk頓卡的問題得到了一定程度上的緩解方面,但是仍然存在一點(diǎn)頓卡的感覺放钦。臨近測試,這里只做了一個(gè)簡單的優(yōu)化就是使用最近使用的Cache來緩存加載過的場景文件恭金。思路非常簡單操禀,這里直接給出我們實(shí)現(xiàn)的LRUCache的代碼。

public class LRUCache

{

public delegate void CacheOperation(TValue obj);

const int DEFAULT_CAPACITY = 255;

int _capacity;

IDictionary _dictionary;

LinkedList _linkedList;

private CacheOperation _putInOper = null;? //當(dāng)放入cache中的時(shí)候要做的處理

private CacheOperation _takeOutOper = null; //當(dāng)從cache中取出來的時(shí)候要做的處理

private CacheOperation _discardOper = null; //當(dāng)由于容量有限要從cache中丟棄的時(shí)候要做的處理

public LRUCache() : this(DEFAULT_CAPACITY) { }

public LRUCache(int capacity)

{

_capacity = capacity > 0 ? capacity : DEFAULT_CAPACITY;

_dictionary = new Dictionary(_capacity);

_linkedList = new LinkedList();

}

public void Set(TKey key, TValue value)

{

_dictionary[key] = value;

_linkedList.Remove(key);

_linkedList.AddFirst(key);

if (_putInOper != null)

{

_putInOper(value);

}

if (_linkedList.Count > _capacity)

{

TKey lastKey = _linkedList.Last.Value;

if (_discardOper != null)

{

_discardOper(_dictionary[lastKey]);

}

_dictionary.Remove(lastKey);

_linkedList.RemoveLast();

}

}

public bool TryGet(TKey key, out TValue value)

{

bool b = _dictionary.TryGetValue(key, out value);

if (b)

{

LinkedListNode tempNode = _linkedList.Find(key);

_linkedList.Remove(tempNode);

_dictionary.Remove(key);

if (_takeOutOper != null)

{

_takeOutOper(value);

}

}

return b;

}

///

/// 設(shè)置針對緩存對象存取或者丟棄時(shí)的處理函數(shù)

///

/// 放入時(shí)的處理函數(shù)

/// 取出時(shí)的處理函數(shù)

/// 丟棄時(shí)的處理函數(shù)

public void SetOperation(CacheOperation putin, CacheOperation takeout, CacheOperation discard)

{

_putInOper = putin;

_takeOutOper = takeout;

_discardOper = discard;

}

public bool ContainsKey(TKey key)

{

return _dictionary.ContainsKey(key);

}

public int Count

{

get

{

return _dictionary.Count;

}

}

public int Capacity

{

get

{

return _capacity;

}

set

{

if (value > 0 && _capacity != value)

{

_capacity = value;

while (_linkedList.Count > _capacity)

{

TKey keyToRemove = _linkedList.Last.Value;

if (_dictionary.ContainsKey(keyToRemove))

{

if (_discardOper != null)

{

_discardOper(_dictionary[keyToRemove]);

}

_dictionary.Remove(keyToRemove);

}

_linkedList.RemoveLast();

}

}

}

}

public void ClearCache()

{

if (_discardOper != null)

{

foreach (TKey key in _dictionary.Keys)

{

_discardOper(_dictionary[key]);

}

}

_linkedList.Clear();

_dictionary.Clear();

}

public ICollection Keys

{

get

{

return _dictionary.Keys;

}

}

public ICollection Values

{

get

{

return _dictionary.Values;

}

}

}

運(yùn)行的時(shí)候開辟了一個(gè)大小為5的緩存横腿,因?yàn)榭紤]到會(huì)多占用額外內(nèi)存颓屑,并且對于九宮格的方案來說,最壞情況下一次加載和卸載的chunk數(shù)量也就是5個(gè)耿焊。

private LRUCache ChunkLRUCache = new LRUCache(5);

6. 總結(jié)

我們不是第一個(gè)在手機(jī)上實(shí)現(xiàn)九宮格的項(xiàng)目揪惦,也肯定不是做得最好的那個(gè)。我花了大約兩天時(shí)間完成這篇總結(jié)罗侯,除了給一些正在做這個(gè)功能或者想做這個(gè)功能的朋友一些經(jīng)驗(yàn)上分享之外器腋,也是對自己之前很長一段時(shí)間斷斷續(xù)續(xù)在做的工作的一個(gè)總結(jié)。雖然它包含了很多細(xì)節(jié)钩杰,但是因?yàn)闀r(shí)間跨度實(shí)在有點(diǎn)久纫塌,一些討論和思考過的細(xì)節(jié)已經(jīng)遺失在了記憶中。

前面其實(shí)已經(jīng)說了讲弄,九宮格的方案原理上非常簡單措左,可能在需求明確的情況下,算上周邊工具避除,開發(fā)的代碼量也不過幾千行怎披,加上調(diào)試時(shí)間也可能最多2周也能夠搞定。但是在整個(gè)工作流程的構(gòu)建上驹饺,需要和策劃需求對接钳枕,和美術(shù)制作方法匹配,要考慮的問題就多了很多赏壹,再加上可能不斷變化的需求鱼炒,才有了這跨度有半年之久的工作內(nèi)容。

我想借用兩個(gè)工業(yè)界的概念來表達(dá)我在整理這篇文章時(shí)的感受——“實(shí)驗(yàn)室技術(shù)”和“工廠技術(shù)”蝌借。作Demo實(shí)現(xiàn)的過程和之前學(xué)習(xí)的兩個(gè)Unity插件的內(nèi)容比較像是“實(shí)驗(yàn)室技術(shù)”昔瞧,它只需要關(guān)注核心的技術(shù)實(shí)現(xiàn),提供盡量通用的解決方案菩佑,可以做得很快很漂亮自晰;而最終落實(shí)到項(xiàng)目中,要整個(gè)團(tuán)隊(duì)可以一起應(yīng)用起整個(gè)制作流程稍坯,這里有很多妥協(xié)酬荞,有很多一點(diǎn)也不優(yōu)美的“臨時(shí)解決方案”搓劫,要兼顧更多細(xì)節(jié),甚至要考慮工具使用者的感受混巧。后者的過程既無法寫論文又不易做分享枪向,甚至有些至關(guān)重要的細(xì)節(jié)只存在于已經(jīng)熟練應(yīng)用這一流程的每一個(gè)團(tuán)隊(duì)成員腦海中。就像富士康公司的流水線咧党,看上去每一個(gè)步驟都沒有什么技術(shù)門檻秘蛔,但是外人模仿的時(shí)候卻又發(fā)現(xiàn)有各種各樣的困難,達(dá)不到同樣的效果傍衡,又或者效率低下深员。在游戲開發(fā)中,這兩項(xiàng)技術(shù)相輔相成蛙埂,缺一不可倦畅,“實(shí)驗(yàn)室技術(shù)”負(fù)責(zé)提供詩和遠(yuǎn)方的大方向,“工廠技術(shù)”負(fù)責(zé)腳踏實(shí)地地把技術(shù)應(yīng)用到團(tuán)隊(duì)生產(chǎn)中箱残。而我滔迈,作為一個(gè)一線開發(fā)人員,可能接觸和思考更多的是后者被辑,因此這篇文章涉及到的高大上的“實(shí)驗(yàn)室技術(shù)”很少燎悍,更多的是期望把那些開發(fā)中瑣碎的“工廠技術(shù)”的經(jīng)驗(yàn)盡可能地記錄下來,分享出去盼理。

至于未來的工作谈山,大世界動(dòng)態(tài)加載這塊還有很多問題要解決,比如第一次加載Chunk時(shí)的頓卡宏怔,為了降低DrawCall是否需要在加載時(shí)進(jìn)行一次合批過程(目前我們大世界場景的DrawCall在100~150左右)等等奏路。這些問題等到解決后會(huì)再補(bǔ)充一篇后續(xù)的文章進(jìn)行記錄和分享。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末臊诊,一起剝皮案震驚了整個(gè)濱河市鸽粉,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌抓艳,老刑警劉巖触机,帶你破解...
    沈念sama閱讀 222,252評(píng)論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異玷或,居然都是意外死亡儡首,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,886評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門偏友,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蔬胯,“玉大人,你說我怎么就攤上這事位他》毡簦” “怎么了产场?”我有些...
    開封第一講書人閱讀 168,814評(píng)論 0 361
  • 文/不壞的土叔 我叫張陵,是天一觀的道長泼橘。 經(jīng)常有香客問我涝动,道長,這世上最難降的妖魔是什么炬灭? 我笑而不...
    開封第一講書人閱讀 59,869評(píng)論 1 299
  • 正文 為了忘掉前任,我火速辦了婚禮靡菇,結(jié)果婚禮上重归,老公的妹妹穿的比我還像新娘。我一直安慰自己厦凤,他們只是感情好鼻吮,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,888評(píng)論 6 398
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著较鼓,像睡著了一般椎木。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上博烂,一...
    開封第一講書人閱讀 52,475評(píng)論 1 312
  • 那天香椎,我揣著相機(jī)與錄音,去河邊找鬼禽篱。 笑死畜伐,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的躺率。 我是一名探鬼主播玛界,決...
    沈念sama閱讀 41,010評(píng)論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼悼吱!你這毒婦竟也來了慎框?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,924評(píng)論 0 277
  • 序言:老撾萬榮一對情侶失蹤后添,失蹤者是張志新(化名)和其女友劉穎笨枯,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體吕朵,經(jīng)...
    沈念sama閱讀 46,469評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡猎醇,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,552評(píng)論 3 342
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了努溃。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片硫嘶。...
    茶點(diǎn)故事閱讀 40,680評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖梧税,靈堂內(nèi)的尸體忽然破棺而出沦疾,到底是詐尸還是另有隱情称近,我是刑警寧澤,帶...
    沈念sama閱讀 36,362評(píng)論 5 351
  • 正文 年R本政府宣布哮塞,位于F島的核電站刨秆,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏忆畅。R本人自食惡果不足惜衡未,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,037評(píng)論 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望家凯。 院中可真熱鬧缓醋,春花似錦、人聲如沸绊诲。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,519評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽掂之。三九已至抗俄,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間世舰,已是汗流浹背动雹。 一陣腳步聲響...
    開封第一講書人閱讀 33,621評(píng)論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留冯乘,地道東北人洽胶。 一個(gè)月前我還...
    沈念sama閱讀 49,099評(píng)論 3 378
  • 正文 我出身青樓,卻偏偏與公主長得像裆馒,于是被迫代替她去往敵國和親姊氓。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,691評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容