Unity手游開發(fā)札記——2.5D大世界動態(tài)加載實戰(zhàn)

知乎鏈接(部分內(nèi)容沒有更新過來):https://zhuanlan.zhihu.com/p/28042244

0. 前言

項目第一次對外技術(shù)測試落下帷幕,終于有時間來填大世界動態(tài)加載這樣一個大坑捌蚊。
從去年11月份開始集畅,在需求改變、制作方案更改等各種影響下逢勾,斷斷續(xù)續(xù)地制作維護(hù)這個功能,估算下來花費在它上面的有效時間也得有1個月左右藐吮。目前我們游戲大世界的制作進(jìn)入鋪量階段溺拱,已經(jīng)制作好的功能也經(jīng)過了第一次技術(shù)測試的驗證逃贝,靜下心來寫這篇《Unity手游開發(fā)札記——2.5D大世界動態(tài)加載實戰(zhàn)》。

需要說明的是:一方面任何技術(shù)方案都有其適用范圍迫摔,相對應(yīng)的也就是它們有著自身的局限性沐扳,因此這篇文章肯定不是一顆萬能的“銀彈”;另外一方面句占,在實際工程中沪摄,實現(xiàn)一段代碼、一個技術(shù)功能點往往是最為簡單的那步纱烘,設(shè)計適合團(tuán)隊工作的工作流程杨拐,讓功能可以快速高效的產(chǎn)出結(jié)果,并且便于維護(hù)擂啥,才是工作量更大的部分哄陶。因此,正在閱讀這篇文章的你哺壶,不必抱著多大的希翼可以通過學(xué)習(xí)它實現(xiàn)你們自己項目的大世界動態(tài)加載架構(gòu)屋吨,它是一篇“實戰(zhàn)記錄”——意味著這里的經(jīng)驗經(jīng)歷過一個真實項目的洗禮,也意味著它們可能更適用于我們項目而已山宾。

1. 需求分析和技術(shù)調(diào)研

一切都在變化至扰,沒有東西會消失∽拭蹋——奧維德:《變形記》

1.1 需求分析

“需求一直都在變化敢课,沒有需求會消失。台妆。翎猛。”回頭來看我們游戲整個大世界的制作方案的確定過程接剩,我要把改編自奧維德名言的這句話送給我們團(tuán)隊的策劃和美術(shù)同學(xué)切厘,這里飽含了一個程序的吐(fen)槽(hen)。我們項目的需求變更主要體現(xiàn)在大世界制作方案的改變上懊缺,從2016年11月份開始疫稿,經(jīng)歷過傳統(tǒng)3D制作方案、基于六邊形的風(fēng)格化方案鹃两、比例縮小版寫實風(fēng)格遗座,最后到基于Terrain的沙盤風(fēng)格。每次變更都意味著美術(shù)制作流程的變化俊扳,隨之而來的就是程序需要開發(fā)的工具集調(diào)整途蒋。

回到項目立項初期討論的時候,當(dāng)時我們就確定了大世界的方向馋记。其實從程序的角度能夠預(yù)估這其中的技術(shù)難度号坡,畢竟團(tuán)隊中從策劃到程序再到美術(shù)誰都沒做過完整的大世界項目懊烤。帶著初創(chuàng)團(tuán)隊初生牛犢不怕虎的勁,再加上策劃同學(xué)拍著胸脯說“實在不行我們就用2D地圖也能接受”的允諾宽堆,就往這個方向來努力腌紧。
第一個版本美術(shù)預(yù)研的大世界效果出來之后,糾結(jié)在視角使用3D還是2.5D——2.5D的大世界制作成本和技術(shù)難度會比較低畜隶,但是從當(dāng)時的設(shè)計來看壁肋,3D的體驗會更好,而且看得越遠(yuǎn)越好……因此最初的技術(shù)預(yù)研方向也是在往自由3D視角的目標(biāo)來做籽慢。

1.2 Unity插件調(diào)研

從程序角度浸遗,無論什么視角,針對Unity引擎做初步的技術(shù)調(diào)研是最基礎(chǔ)的工作嗡综。這時候有那么一點懷念之前自己掌握引擎代碼的日子乙帮,即使沒有引擎組的支持,自己在引擎C++底層來做是方法明確而且效率更高的方式极景。好在Unity也有自身的優(yōu)勢——Asset Store察净。搜索加詢問,最后找到看著還比較靠譜的兩個插件—— SECTR COMPLETEWorld Streamer盼樟。
World Streamer這個我沒有非常仔細(xì)去看實現(xiàn)細(xì)節(jié)氢卡,整體的思路是按照位置拆分成按照Scene組織的格子(Grid),然后根據(jù)距離做逐步加載晨缴,因為要區(qū)分地表译秦、特物體和細(xì)節(jié)物體等不同粒度,提供了分層拆分的功能击碗。提供一篇找到的博客供需要的同學(xué)參考:《Unity 場景分頁插件 World Streamer 支持無限大地圖的解決方案》筑悴。

WorldStreamer拆分后的場景列表
WorldStreamer拆分后的場景列表

SECTR COMPLETE是我購買并學(xué)習(xí)了一段時間的一個插件,原因之一是這個插件是被Unity官方推薦過的稍途,而且FireWatch游戲就是用的這個插件阁吝,可以參考GDC的演講Making the World of Firewatch。這個COMPLETE是一個售價100美元的插件集合械拍,它包括CORE突勇、STREAM、VIS和AUDIO等幾個部分坷虑。VIS做動態(tài)的遮擋剔除甲馋,動態(tài)的大世界主要是STREAM部分。
SECTR STREAM通過自動或者手動創(chuàng)建Sector的方式迄损,用包圍盒來決定場景中的哪些物體被放置到哪一個Sector中定躏,然后將這些Sector導(dǎo)出為名稱對應(yīng)的分塊場景,加載的時候在攝像機上添加一個Loader,通過Loader與留在場景中的Sector碰撞盒進(jìn)行交互來判斷哪些Sector對應(yīng)的場景組件需要被加載痊远。Loader的類型不同加載方式也不同绑谣,比如包括Neighbor Loader、Region Loader拗引、Trigger Loader甚至DIY Loader等。

SECTR STREAM的拆分界面
SECTR STREAM的拆分界面

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

1.3 UWA技術(shù)分享

在2016年11月份的時候豁护,UWA組織了一場在上海的分享哼凯,其中有一個就是張強的《大規(guī)模場景的資源拆分和動態(tài)加載》,很興奮地去聽了一下楚里,主要是2.5D視角下基于Terrian的實現(xiàn)方案断部,因為當(dāng)時我們的需求方案還是傾向于3D自由視角,所以聽的時候感覺幫助沒有那么大班缎。當(dāng)時在回來之后的博客筆記里說——

“我個人覺得這部分的一個問題是整個工程是基于一個Demo性質(zhì)的實現(xiàn)蝴光,而非正式的項目,因為時間關(guān)系沒有在后面進(jìn)行深入的交流达址,因此也不清楚目前的實現(xiàn)是否在正式的項目中應(yīng)用了蔑祟。”

在現(xiàn)在來看沉唠,其實張強的分享內(nèi)容中有很多是我在后面設(shè)計和實現(xiàn)的過程中沒有去考慮的部分疆虚,比如資源打包策略的制定等,這些問題都是在實際項目中需要去注意的內(nèi)容满葛。而當(dāng)時我想了解但這個分享不包含的內(nèi)容是大世界的制作和維護(hù)流程的部分径簿,鑒于主題是《大規(guī)模場景的資源拆分和動態(tài)加載》,其實針對這一主題已經(jīng)很有實用性了嘀韧。這里也借這篇文章的機會篇亭,給UWA的張強同學(xué)做一個小小的道歉,當(dāng)時的評價過于草率乳蛾,非常抱歉暗赶。
如果想要了解這次分享的同學(xué)可以去UWA官網(wǎng)搜索,這里給一個我自己備份的PPT下載地址肃叶。

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

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


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

與此同時因惭,也了解到幾個需要去注意的技術(shù)點:

  1. 光照貼圖岳锁,整個大世界使用一張Lightmap顯然不合適,SECTR STREM是支持自動拆分的蹦魔,也有一些插件支持光照貼圖的拆分激率,這個貌似不用太擔(dān)心咳燕;
  1. 尋路信息,Unity 5.6之前的版本是不支持動態(tài)的Nav Mesh的乒躺,只能跟隨場景加載/卸載招盲。既然沒有辦法更改,暫時看起來也沒有擔(dān)心的必要嘉冒;
  2. 光照探針曹货,這個也是不支持動態(tài)加載的,但是初步看起來手游項目用這個的可能性不太大讳推,暫時不去擔(dān)心顶籽。

2. Demo實現(xiàn)

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

  1. 現(xiàn)成的插件雖然功能強大但是有各種問題跑慕,比如SECTR STREM需要對每一個Sector創(chuàng)建一個GameObject和對應(yīng)的碰撞盒万皿,在手游上擔(dān)心有比較大的消耗;拆分過程雖然很靈活但是需要美術(shù)進(jìn)行較多的操作核行,當(dāng)拆分完畢之后牢硅,如果想再進(jìn)行編輯,需要再做一遍完整的拆分過程才行;
  2. 九宮格的方案技術(shù)難度比較低芝雪,需要定制的內(nèi)容也相對較少减余,可以按照我們自己的美術(shù)制作流程來進(jìn)行定制,做到最大程度上的自動化惩系;
  3. 最后位岔,自己造輪子不也是程序員的樂趣之一,不是么堡牡?(手動微笑)

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

  1. 美術(shù)完成整個大世界場景的制作纠屋;
  2. 使用自動拆分工具涂臣,根據(jù)設(shè)置好的分塊大小,將場景中的每一個物體根據(jù)位置坐標(biāo)拆分到對應(yīng)的Chunk中售担;
  3. 將Chunk自動保存成規(guī)定路徑下對應(yīng)名稱的場景文件(.scene)肉康,刪除拆分過的Chunk文件,剩下的作為BaseWorld.scene文件灼舍;
  4. 運行時首先加載BaseWorld,然后在角色身上綁定一個DynamicLoader涨薪,根據(jù)角色位置自動按照Additive的方式加載周圍九塊Chunk對應(yīng)的場景骑素。

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

  1. 美術(shù)烘焙場景的單位為一個單獨Chunk的場景文件侠姑,即在確定本塊場景不修改之后再進(jìn)行烘焙工作创橄,如果還需要修改,就需要重新烘焙莽红,烘焙過的場景加入到自動導(dǎo)出工具的覆蓋黑名單中妥畏,完整重新導(dǎo)出時不再進(jìn)行覆蓋;
  1. Navmesh和一些跨Chunk的全局物體(比如大面積的水域)暫時放置在BaseWorld中安吁,運行時BaseWorld.scene為激活的場景醉蚁;
  2. 所有的光照、霧效果等信息一律放置在BaseWorld.scene中鬼店。

按照這個工作流程网棍,需要制作的工具包括場景自動拆分功能和自動加載組件兩個部分。

2.1 場景自動拆分實現(xiàn)

場景自動拆分的功能比較簡單妇智,最終也僅僅實現(xiàn)了如下截圖中的幾個功能滥玷,最為核心的也就是“自動拆分場景”和“導(dǎo)出拆分后的物體”兩個了。


自動拆分工具界面截圖
自動拆分工具界面截圖

代碼也很簡單巍棱,首先遍歷所有需要處理的GameObject惑畴,我們只需要處理包含MeshRender組件和Terrain組件的物體即可。這里給美術(shù)添加了一個限制航徙,有MeshRender的GameObject的孩子節(jié)點不再進(jìn)行拆分遥金,因為為了保持原有的層次結(jié)構(gòu),如果一個GameObject的孩子被分配到了不同Chunk巢价,那個這個作為父節(jié)點的GameObject會被完整拷貝到多個Chunk中。那么泻红,如果父節(jié)點包含了比如MeshRender的組件,就會導(dǎo)致較多的渲染消耗霞掺,也并不合理谊路,因此只要包含MeshRender這樣的組件就會連著其孩子節(jié)點完整地放置到一個Chunk中。

// 首先使用遍歷出所有需要處理的GameObject
GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
List<GameObject> objsToProcess = new List<GameObject>();
foreach (GameObject root in roots)
{
    TraverseHierarchy(root.transform, new ActionTransform((Transform obj) =>
    {
        //如果有MeshRender或者Terrain組件菩彬,并且是靜態(tài)物體缠劝,則認(rèn)為是一個要處理的葉子節(jié)點,不再處理其孩子節(jié)點了
        GameObject tempObj = obj.gameObject;
        if (tempObj.activeSelf == false)
        {
            return false;
        }
        if ((tempObj.GetComponent<MeshRenderer>() || tempObj.GetComponent<Terrain>()) && (!onlyStatic || (onlyStatic && tempObj.isStatic)))
        {
            objsToProcess.Add(tempObj);
            return false;
        }
        else
        {
            return true;
        }
    }), false);
}

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

// 逐個處理可能需要移動的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);
}
/// <summary>
/// 對一個GameObject按照位置進(jìn)行分類,放置到對應(yīng)的根節(jié)點下面耙旦。
/// </summary>
/// <param name="obj"></param>
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é)點下面
    GameObject tempObj = obj;
    List<GameObject> objs2Copy = new List<GameObject>();
    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é)點不進(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;
    // 移動完畢之后發(fā)現(xiàn)父節(jié)點沒有孩子節(jié)點的情況下,向上遍歷將無用節(jié)點刪除免都。
    while (tempParent != null && tempParent.childCount == 0)
    {
        Transform temp = tempParent.parent;
        EngineUtils.Destroy(tempParent.gameObject);
        tempParent = temp;
    }
}

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


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

然后將拆分后的組件保存到對應(yīng)的Scene文件中脓规,這里為了避免遺漏拷貝場景參數(shù),采用了比較trick的方式——生成每一個Chunk文件時险领,將完整場景文件進(jìn)行一次拷貝侨舆,然后刪除掉不需要的GameObject,即比如要生成_worldchunk6_8.scene绢陌,將整個場景文件完整拷貝态罪,然后刪除掉除了_worldchunk6_8這個GameObject之外的所有物件。這樣就做到了所有場景參數(shù)的一致性下面,但是代價是花費的時間稍微久一點复颈。
這樣做的意義在于,比如Ambient Source相關(guān)的參數(shù)會影響烘焙結(jié)果沥割,如果稍微有些不同耗啦,會導(dǎo)致最終烘焙出來的Chunk之間存在明顯的接縫問題。

static void ExportChunksToScenes()
{
    EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
    GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
    List<string> rootsNamesToExport = new List<string>();

    foreach (GameObject root in roots)
    {
        if (root.name.StartsWith(ChunkRootNamePrefix))
        {
            rootsNamesToExport.Add(root.name);
        }
    }

    if (rootsNamesToExport.Count == 0)
    {
        EditorUtility.DisplayDialog("Export Error", "不存在符合導(dǎo)出要求的分組机杜,請先使用自動拆分功能帜讲!", "確定");
        return;
    }

    if (!EditorUtility.DisplayDialog("Info", "導(dǎo)出場景將會刪除之前已經(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();
    }

    // 拷貝出一個刪除了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場景列表
拆分后的Chunk場景列表

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

動態(tài)加載的過程也并不復(fù)雜椒拗,因為涉及到游戲內(nèi)的代碼似将,這里就不放源碼了获黔,整個算下也也就不到500行,邏輯也很簡單在验。綁定一個Transform玷氏,每幀update檢查Transform的位置所對應(yīng)的Chunk的索引是否有變化,如果有則計算出需要卸載的Chunk和需要加載的Chunk執(zhí)行卸載和加載操作腋舌。
在Demo階段盏触,選擇使用Scene來作為Chunk的存儲單元的原因主要有:

  1. 看到的兩款插件都是基于Scene來做的,而且Unity從5.0開始就原生支持Multi-Scenes的場景加載方式块饺,因此預(yù)想問題應(yīng)該不大赞辩;
  2. 考慮到美術(shù)進(jìn)行烘焙的最小單元是Scene,使用Scene作為最小單元可以“偷懶”不用去手動管理每一個Chunk的Lightmap數(shù)據(jù)授艰,對于多個Scene同時進(jìn)行烘焙的方案也是進(jìn)行過實驗辨嗽,證明具有可行性的。

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

2.3 問題總結(jié)

除了一些代碼實現(xiàn)上的bug之外,這里值得記錄的幾個問題有:
1) Static Batching導(dǎo)致的頓卡
在電腦上運行的時候已經(jīng)可以感受到明顯的卡頓来破,打開Profiler看了下發(fā)現(xiàn)是由于Static Batching導(dǎo)致的:

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

解決方法很簡單,在測試項目中關(guān)閉了工程的Static Batching忘古,而在正式工程中徘禁,場景組件不再勾選Static Batching選項,就可以避免Chunk的場景加載時這段CPU消耗的峰值髓堪。當(dāng)然代價也是無法進(jìn)行batching送朱,draw call的消耗比較高。

2) NavMesh分塊測試
因為不死心干旁,所以特意做了一下NavMesh分場景bake之后加載的效果驶沼,果然是不行的——在其中一塊NavMesh上無法移動到另外一個Chunk的NavMesh上:

多塊NevMesh的移動試驗
多塊NevMesh的移動試驗

3) 場景物件導(dǎo)入到Unity的時候中心點需要在原點
這個比較好理解,按照位置把物體劃分到Chunk的時候是按照世界坐標(biāo)來劃分的争群,如果物件的中心點位置并不在中心點的話回怜,可能會造成偏差,這也是自動拆分工具執(zhí)行完畢之后需要美術(shù)進(jìn)行檢查的一部分工作之一换薄。解決方法一方面是要告知美術(shù)場景物件導(dǎo)入到Unity的時候中心點需要在原點這個規(guī)則玉雾,另外一方面是在代碼中使用包圍盒的中心點而非世界坐標(biāo)的位置來作為劃分區(qū)域,這樣可能錯誤的概率更小一點轻要。當(dāng)然复旬,如果物件的形狀太過奇怪,包圍盒的方式也可能會有問題冲泥。

4) 所有場景的Lightmap模式必須一致
在測試應(yīng)用烘焙效果的問題的時候驹碍,出現(xiàn)過Lightmap失效的情況壁涎,檢查后發(fā)現(xiàn)是因為部分場景使用了默認(rèn)的Directional模式,部分場景使用了Non-Directional的模式導(dǎo)致的志秃。

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

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

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

技術(shù)上仍然不算什么難題歧匈,Unity有豐富的插件來做這種事情,而且相比于Unity5之后就不再維護(hù)的T4M砰嘁,似乎官方的Terrain更好用也更穩(wěn)定一點點件炉。Terrain轉(zhuǎn)Mesh的插件有不少,我們使用的是Terrain To Mesh矮湘,后文統(tǒng)一簡稱T2M斟冕。經(jīng)過思考和討論,權(quán)衡一些問題之后缅阳,最后制定了如下圖所示的工作流程磕蛇。

基于Terrain和T2M的工作流程圖
基于Terrain和T2M的工作流程圖

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

3.1 Chunk大小的確定

其實在這個流程開始之前十办,第一件要做的事情是確定Chunk的大小尺寸孤里。在之前Demo中構(gòu)想的流程里,因為視野橘洞、美術(shù)風(fēng)格都未確定捌袜,為了能夠方便地兼容Chunk尺寸更改的情況,所有的組件都是在美術(shù)進(jìn)行了Chunk大小的設(shè)置之后自動拆分的炸枣。這樣如果中途要更改Chunk大小虏等,其實是一件工作量不太大的事情弄唧,只是烘焙過程要重新進(jìn)行。而基于Terrain的方案霍衫,雖然T2M也有自動拆分的功能候引,但是手游上處于性能和省電的要求,我們規(guī)定——

每一個地表所能使用的貼圖層數(shù)不能超過4張敦跌,盡量保證3張的時候也是可看的澄干,低配下程序保留了強制切換為3張的權(quán)利

于是美術(shù)就要求可以更加靈活地使用和分配這幾層貼圖柠傍。由于我們大世界會有不同的地貌和氣候風(fēng)格麸俘,風(fēng)格之間還要有過度的效果,因此經(jīng)過商討惧笛,美術(shù)可以自由分配貼圖的最小單位為一個Chunk从媚。這樣就不太好把很大一塊區(qū)域作為一整個Terrain來制作,因此我們使用了一個Chunk就是一個Terrain的方案患整,讓美術(shù)可以自由分配這個Chunk下的四張Layer貼圖的內(nèi)容拜效。(這里和美術(shù)討論的糾結(jié)過程就不詳細(xì)描述了,這些瑣碎的細(xì)節(jié)可能只有真正使用這種制作方案的人才能有更深的體會各谚。)
那么紧憾,首要的問題就是確定Chunk的大小,而這個一旦確定昌渤,制作工作開展之后赴穗,再修改的代價就非常大了。好在這時候鏡頭的參數(shù)早已確定愈涩,于是作為靈魂畫手的我就經(jīng)過“現(xiàn)場踩點”等精妙操作望抽,畫了這樣一張圖加矛。履婉。。

此處輸入圖片的描述
此處輸入圖片的描述

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

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

這個時候的工作推進(jìn)其實已經(jīng)比較順利了胯究,因為整個大世界的功能需求已經(jīng)確定,尺寸也不會很大躁绸,估計在1000m * 1000m左右的大小裕循。Terrain在Unity中的拷貝也有點煩臣嚣,因為涉及到TerrainData的拷貝,而且這貨會默認(rèn)創(chuàng)建在Assets的根目錄下剥哑,讓美術(shù)去手動創(chuàng)建100多個Terrain對象硅则,人力消耗暫且不說,光是想想位置擺放精準(zhǔn)度株婴、參數(shù)設(shè)定怎虫、資源命名和存放等問題,就覺得可能有很多屁股要擦困介。大审。。
于是半個小時逻翁,寫一段簡單代碼饥努,來自動創(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ì)化和修改

在這個工作流程中,我專門用淺綠色部分畫出了一次性的部分缠诅,即地形生成之后溶浴,會進(jìn)行整個大世界的地形和白模制作。一旦用自動拆分工具拆分出Chunk文件管引,這一過程在之后將不再重復(fù)進(jìn)行士败。一方面因為這一過程代價很大,另外一方面后面基于Chunk和Multi-Scenes的方式也可以對地形等進(jìn)行比較方便的修改褥伴。

美術(shù)最早想在T2M轉(zhuǎn)換之后的mesh上應(yīng)用T4M來進(jìn)行地表的修改谅将,這個方案被我否決了。因為首先兩種插件的Shader是不同的重慢,需要時間整合(雖然到寫這篇文章時饥臂,我們的同事已經(jīng)進(jìn)行了部分整合),其次如果再引入T4M的結(jié)點似踱,使得這個工作流變得太過復(fù)雜——雖然看上去似乎靈活了隅熙,轉(zhuǎn)為Mesh之后仍然可以修改地表貼圖,但這個修改對于Terrain層是不可逆的核芽,如果需要再在Terrain上進(jìn)行修改的時候囚戚,那些在T4M節(jié)點做的修改就會被沖掉。

因此轧简,在這套工作流程中驰坊,美術(shù)進(jìn)行頻繁修改、細(xì)化哮独、迭代的對象拳芙,是基于Terrain的地表和場景組件假勿,轉(zhuǎn)換后的Mesh地表不會進(jìn)行大的改動以保證其修改源的唯一性。

為了處理同時編輯多個Terrain的問題态鳖,比如要保證地表的連續(xù)性转培、貼圖細(xì)節(jié)的連續(xù)性,我們引入了Multiple Terrain Brush這個插件到工作流程中浆竭,結(jié)合Unity原生的Multi-Scenes同時編輯的功能浸须,可以很好地處理多個Chunk需要同時編輯的需求。同時提醒一下邦泄,注意控制相鄰Chunk相同貼圖的Tilling參數(shù)的一致性删窒,來避免一些邊界接縫問題。

基于Demo制作的工具顺囊,在正式的制作流程中雖然引入了T2M插件肌索,但是之前的功能在進(jìn)行較小的修改之后也都可以正常使用。而正式的版本花費精力最多的部分還是在流程的梳理和討論特碳,確定每一步驟的編輯對象和產(chǎn)出結(jié)果诚亚,并驗證整個工作流的證確性。當(dāng)然午乓,正確性得到保證之后站宗,性能上的優(yōu)化也就被推到最前面了。

4. 修改Chunk的存儲方式

在實現(xiàn)完成正式版本的工作流之后益愈,使用正式的美術(shù)資源在設(shè)備上運行之后發(fā)現(xiàn)了一個比較嚴(yán)重的問題——在移動設(shè)備上梢灭,加載Chunk的過程中,會有比較明顯的頓卡感蒸其。

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


Chunk場景加載時頓卡Profiler截圖
Chunk場景加載時頓卡Profiler截圖

經(jīng)過一些思考和方案對比察迟,我作出了將Chunk的存儲方式由Scene修改為Prefab的決定斩狱,原因主要有兩個:

  1. 之前相信插件使用Scene的方式來做加載耳高,應(yīng)該是有比較好效果的,然而調(diào)研的兩個插件雖然都支持mobile所踊,但貌似并沒有找到實際在移動設(shè)備上發(fā)布的項目泌枪,再加上詢問了一些在手游做了場景動態(tài)加載的項目,都是使用了Prefab的方案秕岛,因此覺得Prefab的方案在手游上的坑應(yīng)該更少一些碌燕;
  2. Scene的加載、卸載過程不如Prefab具有可控性继薛,針對Scene對象做緩存也沒有Prefab方便修壕。

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

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

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

美術(shù)是按照單獨的場景進(jìn)行烘焙的,在每個場景內(nèi)都有索引從0開始的Lightmap貼圖瘪松,而如果想要每一個Prefab的烘焙信息都是正確的咸作,在運行時需要所有Lightmap貼圖的索引具有唯一性,即需要提前為它們分配一個整個大世界場景的全局索引宵睦。

我選擇使用一個ScriptableObject對象來做這件事情记罚,把它納入到自動保存光照信息功能中。

[CreateAssetMenu(fileName = "WorldLightmapProfile.asset", menuName = "Custom/DynamicLightMapProfile")]
public class DynamicWorldLightmapProfile : ScriptableObject
{
    public List<string> GlobalLightmaps;

    /// <summary>
    /// 尋找第一個為空的位置索引壳嚎,作為全局光照貼圖的索引值
    /// </summary>
    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;
        }
    }
}

/// <summary>
/// 方便管理大世界對應(yīng)的光照貼圖全局索引文件的輔助類
/// 
/// </summary>
public class DynamicWorldLMProfileHelper
{
    // 存儲全局的光照索引文件路徑
    // Todo 這樣設(shè)置會導(dǎo)致全局只能使用這一份桐智,目前還不打算兼容多個動態(tài)場景,暫時先這樣烟馅。说庭。。
    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信息的配置文件郑趁,自動創(chuàng)建!");
                profile = ScriptableObject.CreateInstance<DynamicWorldLightmapProfile>();
                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();
        }
    }
}

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


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

在每一個Chunk對應(yīng)的Prefab文件中致份,只有一個用于控制光照貼圖加載和刪除的ChunkLightMapSetting對象,它里面除了存儲直接的光照貼圖文件之外础拨,還存儲了局部光照貼圖索引和全局光照貼圖索引的對應(yīng)關(guān)系知举。

public Texture2D[] lightmapLight, lightmapDir;
public LightmapsMode mode;
public int[] globalIndex;       // 存儲局部光照貼圖索引和全局光照貼圖索引的對應(yīng)關(guān)系

在每一個帶有烘焙信息的GameObject身上的RendererLightMapSetting組件中存儲的lightmapIndex,是全局的光照信息太伊。這樣只需要在ChunkLightMapSetting加載和銷毀的時候重新設(shè)置當(dāng)前LightmapSettings的屬性即可雇锡。注意由于其lightmaps屬性為一個數(shù)組,因此需要將其擴展到當(dāng)前存在的全局索引的最大值僚焦,運行時這個數(shù)組中間會有很多貼圖是空著的锰提。

// 擴充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è)置的幾個小坑

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

  1. LightmapSettings的lightmaps屬性直接賦值是無效的芳悲,必須new一個新的對象數(shù)組或者將其賦值給一個臨時數(shù)組對象立肘,修改完畢之后再賦值回去才可以。不知道是我使用的姿勢不對還是什么原因名扛,另外個人覺得這里會有內(nèi)存分配的問題谅年,但是目前也沒有找到更好的解決方法。
  2. 當(dāng)?shù)谝粡坙ightmap為空的時候肮韧,整個場景會變暗很多融蹂。這個問題一開始遇到的時候以為是Lightmap加載的一個bug,反復(fù)觀察了一會才發(fā)現(xiàn)當(dāng)index為0的那個Prefab被卸載了之后弄企,整個場景都變暗了超燃。這個目前依然不知道原因,我們的做法是如果第0張為空的話拘领,則選擇一張已經(jīng)存在的Lightmap貼圖賦值給它意乓,注意這個處理要在任何一個Prefab加載或者卸載時進(jìn)行。

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

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


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

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


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

圖中紅框內(nèi)的是最后要保存的prefab數(shù)據(jù)放祟,其他部分可以用于烘焙和修改用鳍怨,保存在Scene中。需要說明的是跪妥,我們的資源打包采用了拆分美術(shù)工作目錄和游戲運行目錄的方式鞋喇,美術(shù)的工作目錄為Assets/Res,游戲運行目錄為Assets/BundleResource的方式眉撵,Res中存放所有的美術(shù)資源侦香,但是Prefab、Scene等需要被游戲直接使用的文件存儲在BundleResource目錄下纽疟,打包時是根據(jù)BundleResource目錄下的所有文件罐韩,檢索出其引用到的文件進(jìn)行AssetBundle打包。在這種結(jié)構(gòu)下污朽,Chunk拆分后的Scene文件存放在Res目錄下散吵,Terrain數(shù)據(jù)也存放在Res目錄下,只有最后使用的Prefab文件存儲在BundleResource目錄下蟆肆。

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

5. Chunk緩存

使用Prefab代替Scene之后赁温,加載Chunk頓卡的問題得到了一定程度上的緩解,但是仍然存在一點頓卡的感覺淤齐。臨近測試束世,這里只做了一個簡單的優(yōu)化就是使用最近使用的Cache來緩存加載過的場景文件。思路非常簡單床玻,這里直接給出我們實現(xiàn)的LRUCache的代碼毁涉。

public class LRUCache<TKey,TValue>
{
    public delegate void CacheOperation(TValue obj);

    const int DEFAULT_CAPACITY = 255;

    int _capacity;
    IDictionary<TKey, TValue> _dictionary;
    LinkedList<TKey> _linkedList;

    private CacheOperation _putInOper = null;   //當(dāng)放入cache中的時候要做的處理
    private CacheOperation _takeOutOper = null; //當(dāng)從cache中取出來的時候要做的處理
    private CacheOperation _discardOper = null; //當(dāng)由于容量有限要從cache中丟棄的時候要做的處理

    public LRUCache() : this(DEFAULT_CAPACITY) { }

    public LRUCache(int capacity)
    {
        _capacity = capacity > 0 ? capacity : DEFAULT_CAPACITY;
        _dictionary = new Dictionary<TKey, TValue>(_capacity);
        _linkedList = new LinkedList<TKey>();
    }

    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<TKey> tempNode = _linkedList.Find(key);
            _linkedList.Remove(tempNode);
            _dictionary.Remove(key);
            if (_takeOutOper != null)
            {
                _takeOutOper(value);
            }
        }
        return b;
    }

    /// <summary>
    /// 設(shè)置針對緩存對象存取或者丟棄時的處理函數(shù)
    /// </summary>
    /// <param name="putin">放入時的處理函數(shù)</param>
    /// <param name="takeout">取出時的處理函數(shù)</param>
    /// <param name="destroy">丟棄時的處理函數(shù)</param>
    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<TKey> Keys
    {
        get
        {
            return _dictionary.Keys;
        }
    }

    public ICollection<TValue> Values
    {
        get
        {
            return _dictionary.Values;
        }
    }
}

運行的時候開辟了一個大小為5的緩存,因為考慮到會多占用額外內(nèi)存锈死,并且對于九宮格的方案來說贫堰,最壞情況下一次加載和卸載的chunk數(shù)量也就是5個。

private LRUCache<string, LoaderObjectPair> ChunkLRUCache = new LRUCache<string, LoaderObjectPair>(5);

6. 總結(jié)

我們不是第一個在手機上實現(xiàn)九宮格的項目待牵,也肯定不是做得最好的那個其屏。我花了大約兩天時間完成這篇總結(jié),除了給一些正在做這個功能或者想做這個功能的朋友一些經(jīng)驗上分享之外缨该,也是對自己之前很長一段時間斷斷續(xù)續(xù)在做的工作的一個總結(jié)偎行。雖然它包含了很多細(xì)節(jié),但是因為時間跨度實在有點久,一些討論和思考過的細(xì)節(jié)已經(jīng)遺失在了記憶中蛤袒。

前面其實已經(jīng)說了熄云,九宮格的方案原理上非常簡單,可能在需求明確的情況下妙真,算上周邊工具缴允,開發(fā)的代碼量也不過幾千行,加上調(diào)試時間也可能最多2周也能夠搞定珍德。但是在整個工作流程的構(gòu)建上练般,需要和策劃需求對接,和美術(shù)制作方法匹配锈候,要考慮的問題就多了很多薄料,再加上可能不斷變化的需求,才有了這跨度有半年之久的工作內(nèi)容泵琳。

我想借用兩個工業(yè)界的概念來表達(dá)我在整理這篇文章時的感受——“實驗室技術(shù)”和“工廠技術(shù)”都办。制作Demo實現(xiàn)的過程和之前學(xué)習(xí)的兩個Unity插件的內(nèi)容比較像是“實驗室技術(shù)”,它只需要關(guān)注核心的技術(shù)實現(xiàn)虑稼,提供盡量通用的解決方案琳钉,可以做得很快很漂亮;而最終落實到項目中蛛倦,要整個團(tuán)隊可以一起應(yīng)用起整個制作流程歌懒,這里有很多妥協(xié),有很多一點也不優(yōu)美的“臨時解決方案”溯壶,要兼顧更多細(xì)節(jié)及皂,甚至要考慮工具使用者的感受。后者的過程既無法寫論文又不易做分享且改,甚至有些至關(guān)重要的細(xì)節(jié)只存在于已經(jīng)熟練應(yīng)用這一流程的每一個團(tuán)隊成員腦海中验烧。就像富士康公司的流水線,看上去每一個步驟都沒有什么技術(shù)門檻又跛,但是外人模仿的時候卻又發(fā)現(xiàn)有各種各樣的困難碍拆,達(dá)不到同樣的效果,又或者效率低下慨蓝。在游戲開發(fā)中感混,這兩項技術(shù)相輔相成,缺一不可礼烈,“實驗室技術(shù)”負(fù)責(zé)提供詩和遠(yuǎn)方的大方向弧满,“工廠技術(shù)”負(fù)責(zé)腳踏實地地把技術(shù)應(yīng)用到團(tuán)隊生產(chǎn)中。而我此熬,作為一個一線開發(fā)人員庭呜,可能接觸和思考更多的是后者滑进,因此這篇文章涉及到的高大上的“實驗室技術(shù)”很少,更多的是期望把那些開發(fā)中瑣碎的“工廠技術(shù)”的經(jīng)驗盡可能地記錄下來募谎,分享出去扶关。

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

最后,感謝花時間閱讀到這里的朋友地来,希望你可以從這篇文章中有所收獲戳玫,也希望有經(jīng)驗的朋友給一些改進(jìn)的建議和分享~感謝!

2017年7月 于杭州濱江海外高層次人才創(chuàng)業(yè)基地

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末未斑,一起剝皮案震驚了整個濱河市咕宿,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蜡秽,老刑警劉巖府阀,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異芽突,居然都是意外死亡试浙,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進(jìn)店門寞蚌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來田巴,“玉大人,你說我怎么就攤上這事挟秤∫疾福” “怎么了?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵艘刚,是天一觀的道長管宵。 經(jīng)常有香客問我,道長攀甚,這世上最難降的妖魔是什么啄糙? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮云稚,結(jié)果婚禮上隧饼,老公的妹妹穿的比我還像新娘。我一直安慰自己静陈,他們只是感情好燕雁,可當(dāng)我...
    茶點故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布诞丽。 她就那樣靜靜地躺著,像睡著了一般拐格。 火紅的嫁衣襯著肌膚如雪僧免。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天捏浊,我揣著相機與錄音懂衩,去河邊找鬼。 笑死金踪,一個胖子當(dāng)著我的面吹牛浊洞,可吹牛的內(nèi)容都是我干的题翻。 我是一名探鬼主播调违,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼坷衍!你這毒婦竟也來了靶瘸?” 一聲冷哼從身側(cè)響起苫亦,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎怨咪,沒想到半個月后屋剑,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡诗眨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年唉匾,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片辽话。...
    茶點故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡肄鸽,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出油啤,到底是詐尸還是另有隱情典徘,我是刑警寧澤,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布益咬,位于F島的核電站逮诲,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏幽告。R本人自食惡果不足惜梅鹦,卻給世界環(huán)境...
    茶點故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望冗锁。 院中可真熱鬧齐唆,春花似錦、人聲如沸冻河。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至锭弊,卻和暖如春堪澎,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背味滞。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工樱蛤, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人剑鞍。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓昨凡,卻偏偏與公主長得像,于是被迫代替她去往敵國和親攒暇。 傳聞我的和親對象是個殘疾皇子土匀,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,925評論 2 344

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