知乎链接(部分内容没有更新过来):https://zhuanlan.zhihu.com/p/28042244
0. 前言
项目第一次对外技术测试落下帷幕,终于有时间来填大世界动态加载这样一个大坑。
从去年11月份开始,在需求改变、制作方案更改等各种影响下,断断续续地制作维护这个功能,估算下来花费在它上面的有效时间也得有1个月左右。目前我们游戏大世界的制作进入铺量阶段,已经制作好的功能也经过了第一次技术测试的验证,静下心来写这篇《Unity手游开发札记——2.5D大世界动态加载实战》。
需要说明的是:一方面任何技术方案都有其适用范围,相对应的也就是它们有着自身的局限性,因此这篇文章肯定不是一颗万能的“银弹”;另外一方面,在实际工程中,实现一段代码、一个技术功能点往往是最为简单的那步,设计适合团队工作的工作流程,让功能可以快速高效的产出结果,并且便于维护,才是工作量更大的部分。因此,正在阅读这篇文章的你,不必抱着多大的希翼可以通过学习它实现你们自己项目的大世界动态加载架构,它是一篇“实战记录”——意味着这里的经验经历过一个真实项目的洗礼,也意味着它们可能更适用于我们项目而已。
1. 需求分析和技术调研
一切都在变化,没有东西会消失。——奥维德:《变形记》
1.1 需求分析
“需求一直都在变化,没有需求会消失。。。”回头来看我们游戏整个大世界的制作方案的确定过程,我要把改编自奥维德名言的这句话送给我们团队的策划和美术同学,这里饱含了一个程序的吐(fen)槽(hen)。我们项目的需求变更主要体现在大世界制作方案的改变上,从2016年11月份开始,经历过传统3D制作方案、基于六边形的风格化方案、比例缩小版写实风格,最后到基于Terrain的沙盘风格。每次变更都意味着美术制作流程的变化,随之而来的就是程序需要开发的工具集调整。
回到项目立项初期讨论的时候,当时我们就确定了大世界的方向。其实从程序的角度能够预估这其中的技术难度,毕竟团队中从策划到程序再到美术谁都没做过完整的大世界项目。带着初创团队初生牛犊不怕虎的劲,再加上策划同学拍着胸脯说“实在不行我们就用2D地图也能接受”的允诺,就往这个方向来努力。
第一个版本美术预研的大世界效果出来之后,纠结在视角使用3D还是2.5D——2.5D的大世界制作成本和技术难度会比较低,但是从当时的设计来看,3D的体验会更好,而且看得越远越好……因此最初的技术预研方向也是在往自由3D视角的目标来做。
1.2 Unity插件调研
从程序角度,无论什么视角,针对Unity引擎做初步的技术调研是最基础的工作。这时候有那么一点怀念之前自己掌握引擎代码的日子,即使没有引擎组的支持,自己在引擎C++底层来做是方法明确而且效率更高的方式。好在Unity也有自身的优势——Asset Store。搜索加询问,最后找到看着还比较靠谱的两个插件—— SECTR COMPLETE 和 World Streamer。
World Streamer这个我没有非常仔细去看实现细节,整体的思路是按照位置拆分成按照Scene组织的格子(Grid),然后根据距离做逐步加载,因为要区分地表、特物体和细节物体等不同粒度,提供了分层拆分的功能。提供一篇找到的博客供需要的同学参考:《Unity 场景分页插件 World Streamer 支持无限大地图的解决方案》。
SECTR COMPLETE是我购买并学习了一段时间的一个插件,原因之一是这个插件是被Unity官方推荐过的,而且FireWatch游戏就是用的这个插件,可以参考GDC的演讲Making the World of Firewatch。这个COMPLETE是一个售价100美元的插件集合,它包括CORE、STREAM、VIS和AUDIO等几个部分。VIS做动态的遮挡剔除,动态的大世界主要是STREAM部分。
SECTR STREAM通过自动或者手动创建Sector的方式,用包围盒来决定场景中的哪些物体被放置到哪一个Sector中,然后将这些Sector导出为名称对应的分块场景,加载的时候在摄像机上添加一个Loader,通过Loader与留在场景中的Sector碰撞盒进行交互来判断哪些Sector对应的场景组件需要被加载。Loader的类型不同加载方式也不同,比如包括Neighbor Loader、Region Loader、Trigger Loader甚至DIY Loader等。
由于最终我们并没有使用这两个插件,因此在此不进行更详细的描述,有兴趣的朋友可以自己买来玩一下。
1.3 UWA技术分享
在2016年11月份的时候,UWA组织了一场在上海的分享,其中有一个就是张强的《大规模场景的资源拆分和动态加载》,很兴奋地去听了一下,主要是2.5D视角下基于Terrian的实现方案,因为当时我们的需求方案还是倾向于3D自由视角,所以听的时候感觉帮助没有那么大。当时在回来之后的博客笔记里说——
“我个人觉得这部分的一个问题是整个工程是基于一个Demo性质的实现,而非正式的项目,因为时间关系没有在后面进行深入的交流,因此也不清楚目前的实现是否在正式的项目中应用了。”
在现在来看,其实张强的分享内容中有很多是我在后面设计和实现的过程中没有去考虑的部分,比如资源打包策略的制定等,这些问题都是在实际项目中需要去注意的内容。而当时我想了解但这个分享不包含的内容是大世界的制作和维护流程的部分,鉴于主题是《大规模场景的资源拆分和动态加载》,其实针对这一主题已经很有实用性了。这里也借这篇文章的机会,给UWA的张强同学做一个小小的道歉,当时的评价过于草率,非常抱歉。
如果想要了解这次分享的同学可以去UWA官网搜索,这里给一个我自己备份的PPT下载地址。
1.4 调研结果
通过对这两个插件和UWA分享沙龙的学习,基本确定了在Unity中制作动态大世界的基本思路:美术制作完整场景 -> 自动/手动拆分场景 -> 运行时根据规则自动加载角色周围的部分。
与此同时,也了解到几个需要去注意的技术点:
- 光照贴图,整个大世界使用一张Lightmap显然不合适,SECTR STREM是支持自动拆分的,也有一些插件支持光照贴图的拆分,这个貌似不用太担心;
- 寻路信息,Unity 5.6之前的版本是不支持动态的Nav Mesh的,只能跟随场景加载/卸载。既然没有办法更改,暂时看起来也没有担心的必要;
- 光照探针,这个也是不支持动态加载的,但是初步看起来手游项目用这个的可能性不太大,暂时不去担心。
2. Demo实现
在进行一系列的技术调研之后,也迎来了一大波的需求调整。通过美术工作量、项目时间限制和技术难度评估的综合考量之后,我们终于妥协为了2.5D视角,但是镜头高度会相对普通的2.5D要高不少。2.5D视角的确定让整个功能实现的技术难度降低了很大一部分,也确定了自己来开发动态加载核心功能的技术方向。经历一些纠结和试验之后,最终选择最为通用的基于九宫格的动态加载方案,主要原因包括:
- 现成的插件虽然功能强大但是有各种问题,比如SECTR STREM需要对每一个Sector创建一个GameObject和对应的碰撞盒,在手游上担心有比较大的消耗;拆分过程虽然很灵活但是需要美术进行较多的操作,当拆分完毕之后,如果想再进行编辑,需要再做一遍完整的拆分过程才行;
- 九宫格的方案技术难度比较低,需要定制的内容也相对较少,可以按照我们自己的美术制作流程来进行定制,做到最大程度上的自动化;
- 最后,自己造轮子不也是程序员的乐趣之一,不是么?(手动微笑)
九宫格的方案其实很简单也很好理解,将完整的大世界按照固定大小拆分成小的Chunk,然后运行时根据角色位置和约定好的Chunk尺寸判断角色所在的Chunk和周围八块的索引,加载对应的Chunk文件即可。当角色移动的时候,判断是否需要加载新的Chunk和卸载老的Chunk文件。在这个阶段美术还在做效果预研,所以自己先制作一个Demo来模拟整个功能。首先还是先设想了一下整个大世界的制作流程,大致如下:
- 美术完成整个大世界场景的制作;
- 使用自动拆分工具,根据设置好的分块大小,将场景中的每一个物体根据位置坐标拆分到对应的Chunk中;
- 将Chunk自动保存成规定路径下对应名称的场景文件(.scene),删除拆分过的Chunk文件,剩下的作为BaseWorld.scene文件;
- 运行时首先加载BaseWorld,然后在角色身上绑定一个DynamicLoader,根据角色位置自动按照Additive的方式加载周围九块Chunk对应的场景。
在这个工作流程下,美术制作的永远都是完整的大世界场景,约定好分块大小,只需要使用自动拆分工具就可以更新拆分后的场景文件。这里关于寻路信息和光照贴图信息的处理如下:
- 美术烘焙场景的单位为一个单独Chunk的场景文件,即在确定本块场景不修改之后再进行烘焙工作,如果还需要修改,就需要重新烘焙,烘焙过的场景加入到自动导出工具的覆盖黑名单中,完整重新导出时不再进行覆盖;
- Navmesh和一些跨Chunk的全局物体(比如大面积的水域)暂时放置在BaseWorld中,运行时BaseWorld.scene为激活的场景;
- 所有的光照、雾效果等信息一律放置在BaseWorld.scene中。
按照这个工作流程,需要制作的工具包括场景自动拆分功能和自动加载组件两个部分。
2.1 场景自动拆分实现
场景自动拆分的功能比较简单,最终也仅仅实现了如下截图中的几个功能,最为核心的也就是“自动拆分场景”和“导出拆分后的物体”两个了。
代码也很简单,首先遍历所有需要处理的GameObject,我们只需要处理包含MeshRender组件和Terrain组件的物体即可。这里给美术添加了一个限制,有MeshRender的GameObject的孩子节点不再进行拆分,因为为了保持原有的层次结构,如果一个GameObject的孩子被分配到了不同Chunk,那个这个作为父节点的GameObject会被完整拷贝到多个Chunk中。那么,如果父节点包含了比如MeshRender的组件,就会导致较多的渲染消耗,也并不合理,因此只要包含MeshRender这样的组件就会连着其孩子节点完整地放置到一个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组件,并且是静态物体,则认为是一个要处理的叶子节点,不再处理其孩子节点了
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);
}
找到所有需要拆分的物体之后,直接按照位置进行拆分即可。
// 逐个处理可能需要移动的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按照位置进行分类,放置到对应的根节点下面。
/// </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);
}
//复制层次关系到Chunk的节点下面
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命名规则的父节点不进行拷贝过程。
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;
// 移动完毕之后发现父节点没有孩子节点的情况下,向上遍历将无用节点删除。
while (tempParent != null && tempParent.childCount == 0)
{
Transform temp = tempParent.parent;
EngineUtils.Destroy(tempParent.gameObject);
tempParent = temp;
}
}
拆分完毕之后的场景如下图所示。这一步需要美术进行一个大致的检查,保证拆分结果的正确性。
然后将拆分后的组件保存到对应的Scene文件中,这里为了避免遗漏拷贝场景参数,采用了比较trick的方式——生成每一个Chunk文件时,将完整场景文件进行一次拷贝,然后删除掉不需要的GameObject,即比如要生成_worldchunk6_8.scene,将整个场景文件完整拷贝,然后删除掉除了_worldchunk6_8这个GameObject之外的所有物件。这样就做到了所有场景参数的一致性,但是代价是花费的时间稍微久一点。
这样做的意义在于,比如Ambient Source相关的参数会影响烘焙结果,如果稍微有些不同,会导致最终烘焙出来的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", "不存在符合导出要求的分组,请先使用自动拆分功能!", "确定");
return;
}
if (!EditorUtility.DisplayDialog("Info", "导出场景将会删除之前已经导出过的场景Chunk目录,是否继续?", "继续", "取消"))
{
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 = "导出拆分后的场景";
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场景列表如下:
2.2 动态加载组件
动态加载的过程也并不复杂,因为涉及到游戏内的代码,这里就不放源码了,整个算下也也就不到500行,逻辑也很简单。绑定一个Transform,每帧update检查Transform的位置所对应的Chunk的索引是否有变化,如果有则计算出需要卸载的Chunk和需要加载的Chunk执行卸载和加载操作。
在Demo阶段,选择使用Scene来作为Chunk的存储单元的原因主要有:
- 看到的两款插件都是基于Scene来做的,而且Unity从5.0开始就原生支持Multi-Scenes的场景加载方式,因此预想问题应该不大;
- 考虑到美术进行烘焙的最小单元是Scene,使用Scene作为最小单元可以“偷懒”不用去手动管理每一个Chunk的Lightmap数据,对于多个Scene同时进行烘焙的方案也是进行过实验,证明具有可行性的。
这样,我就基于设想中的美术制作流程实现了第一版本的动态加载Demo。
2.3 问题总结
除了一些代码实现上的bug之外,这里值得记录的几个问题有:
1) Static Batching导致的顿卡
在电脑上运行的时候已经可以感受到明显的卡顿,打开Profiler看了下发现是由于Static Batching导致的:
解决方法很简单,在测试项目中关闭了工程的Static Batching,而在正式工程中,场景组件不再勾选Static Batching选项,就可以避免Chunk的场景加载时这段CPU消耗的峰值。当然代价也是无法进行batching,draw call的消耗比较高。
2) NavMesh分块测试
因为不死心,所以特意做了一下NavMesh分场景bake之后加载的效果,果然是不行的——在其中一块NavMesh上无法移动到另外一个Chunk的NavMesh上:
3) 场景物件导入到Unity的时候中心点需要在原点
这个比较好理解,按照位置把物体划分到Chunk的时候是按照世界坐标来划分的,如果物件的中心点位置并不在中心点的话,可能会造成偏差,这也是自动拆分工具执行完毕之后需要美术进行检查的一部分工作之一。解决方法一方面是要告知美术场景物件导入到Unity的时候中心点需要在原点这个规则,另外一方面是在代码中使用包围盒的中心点而非世界坐标的位置来作为划分区域,这样可能错误的概率更小一点。当然,如果物件的形状太过奇怪,包围盒的方式也可能会有问题。
4) 所有场景的Lightmap模式必须一致
在测试应用烘焙效果的问题的时候,出现过Lightmap失效的情况,检查后发现是因为部分场景使用了默认的Directional模式,部分场景使用了Non-Directional的模式导致的。
在Demo完成之后,进行打包和手机上的简单测试,基本满足了设想的要求。这段时间场景美术也进入了美术效果和制作方案的频繁更改阶段,这块工作也就搁置了很长一段时间。
3. 正式版本实现
最初的Demo版本没有去考虑的一个内容是像地表这样的大块Mesh是如何拆分的,原因也主要是当时美术的制作方案是按照六边形作为一个单元,每一个单元都不会很大,自然可以正确地被分割到不同的Chunk中。而后面改为T4M刷地表贴图来表现更多细节的制作方案之后,就有了可能需要让美术手动拆分或者程序来做Mesh分割的需求。想来也不是很难,按照顶点的位置来做判断,确定要分割的边界之后把这些边界上的顶点复制多份分别放到对应的Chunk下似乎也就可以了。但当这块预研工作刚刚开始推进的时候,美术又改了主意,为了表现地面的高低起伏,想用Terrain的方式来进行地表的制作。
技术上仍然不算什么难题,Unity有丰富的插件来做这种事情,而且相比于Unity5之后就不再维护的T4M,似乎官方的Terrain更好用也更稳定一点点。Terrain转Mesh的插件有不少,我们使用的是Terrain To Mesh,后文统一简称T2M。经过思考和讨论,权衡一些问题之后,最后制定了如下图所示的工作流程。
我们分几步来说明一下这个流程图的几个关键步骤的设置原因和具体的制作方式。
3.1 Chunk大小的确定
其实在这个流程开始之前,第一件要做的事情是确定Chunk的大小尺寸。在之前Demo中构想的流程里,因为视野、美术风格都未确定,为了能够方便地兼容Chunk尺寸更改的情况,所有的组件都是在美术进行了Chunk大小的设置之后自动拆分的。这样如果中途要更改Chunk大小,其实是一件工作量不太大的事情,只是烘焙过程要重新进行。而基于Terrain的方案,虽然T2M也有自动拆分的功能,但是手游上处于性能和省电的要求,我们规定——
每一个地表所能使用的贴图层数不能超过4张,尽量保证3张的时候也是可看的,低配下程序保留了强制切换为3张的权利。
于是美术就要求可以更加灵活地使用和分配这几层贴图。由于我们大世界会有不同的地貌和气候风格,风格之间还要有过度的效果,因此经过商讨,美术可以自由分配贴图的最小单位为一个Chunk。这样就不太好把很大一块区域作为一整个Terrain来制作,因此我们使用了一个Chunk就是一个Terrain的方案,让美术可以自由分配这个Chunk下的四张Layer贴图的内容。(这里和美术讨论的纠结过程就不详细描述了,这些琐碎的细节可能只有真正使用这种制作方案的人才能有更深的体会。)
那么,首要的问题就是确定Chunk的大小,而这个一旦确定,制作工作开展之后,再修改的代价就非常大了。好在这时候镜头的参数早已确定,于是作为灵魂画手的我就经过“现场踩点”等精妙操作,画了这样一张图。。。
考虑到我们的地表还有高低起伏,再加上为了兼容策划后面一些变动的可能,我们最终把一个Chunk大小定义为70m * 70m。由于我们的美术风格还比较特殊,偏抽象沙盘的风格,因此面数和Draw Call方面相比于3D的视角或者更加写实的2.5D具有更多的可压空间,这种比较远的视野范围在性能方面目前还可以接受。
3.2 为美术自动生成Chunk
这个时候的工作推进其实已经比较顺利了,因为整个大世界的功能需求已经确定,尺寸也不会很大,估计在1000m * 1000m左右的大小。Terrain在Unity中的拷贝也有点烦,因为涉及到TerrainData的拷贝,而且这货会默认创建在Assets的根目录下,让美术去手动创建100多个Terrain对象,人力消耗暂且不说,光是想想位置摆放精准度、参数设定、资源命名和存放等问题,就觉得可能有很多屁股要擦。。。
于是半个小时,写一段简单代码,来自动创建:
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);
// 可以在此设置默认贴图
//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");
}
}
}
我只能说,虽然那天白天就制作方案各种讨论纠结,但是写完这段代码之后,美术更加爱我了呢~~(可惜我们美术中没有妹子=_=)
3.3 场景细化和修改
在这个工作流程中,我专门用浅绿色部分画出了一次性的部分,即地形生成之后,会进行整个大世界的地形和白模制作。一旦用自动拆分工具拆分出Chunk文件,这一过程在之后将不再重复进行。一方面因为这一过程代价很大,另外一方面后面基于Chunk和Multi-Scenes的方式也可以对地形等进行比较方便的修改。
美术最早想在T2M转换之后的mesh上应用T4M来进行地表的修改,这个方案被我否决了。因为首先两种插件的Shader是不同的,需要时间整合(虽然到写这篇文章时,我们的同事已经进行了部分整合),其次如果再引入T4M的结点,使得这个工作流变得太过复杂——虽然看上去似乎灵活了,转为Mesh之后仍然可以修改地表贴图,但这个修改对于Terrain层是不可逆的,如果需要再在Terrain上进行修改的时候,那些在T4M节点做的修改就会被冲掉。
因此,在这套工作流程中,美术进行频繁修改、细化、迭代的对象,是基于Terrain的地表和场景组件,转换后的Mesh地表不会进行大的改动以保证其修改源的唯一性。
为了处理同时编辑多个Terrain的问题,比如要保证地表的连续性、贴图细节的连续性,我们引入了Multiple Terrain Brush这个插件到工作流程中,结合Unity原生的Multi-Scenes同时编辑的功能,可以很好地处理多个Chunk需要同时编辑的需求。同时提醒一下,注意控制相邻Chunk相同贴图的Tilling参数的一致性,来避免一些边界接缝问题。
基于Demo制作的工具,在正式的制作流程中虽然引入了T2M插件,但是之前的功能在进行较小的修改之后也都可以正常使用。而正式的版本花费精力最多的部分还是在流程的梳理和讨论,确定每一步骤的编辑对象和产出结果,并验证整个工作流的证确性。当然,正确性得到保证之后,性能上的优化也就被推到最前面了。
4. 修改Chunk的存储方式
在实现完成正式版本的工作流之后,使用正式的美术资源在设备上运行之后发现了一个比较严重的问题——在移动设备上,加载Chunk的过程中,会有比较明显的顿卡感。
通过Profiler工具进行排查,首先看到的问题之一是Shader.Parse()函数的消耗,在每一个Chunk的加载时占用到了200ms以上的时间,检查了一下是由于美术在部分组件上错误使用了Diffuse等系统材质,并且每一个Chunk场景中都保留了默认的天空盒,以及在FBX上的Default-Material中引用了Standard Shader,这些都导致在设备上有Shader编译的过程花费较多的时间。在解决完这一问题之后,发现依然有顿卡的问题,尤其当角色在Chunk边界来回行走的时候,由于初期没有做缓存,帧率的降低非常明显。下图是在设备上截取的顿卡点的时间消耗数据。
经过一些思考和方案对比,我作出了将Chunk的存储方式由Scene修改为Prefab的决定,原因主要有两个:
- 之前相信插件使用Scene的方式来做加载,应该是有比较好效果的,然而调研的两个插件虽然都支持mobile,但貌似并没有找到实际在移动设备上发布的项目,再加上询问了一些在手游做了场景动态加载的项目,都是使用了Prefab的方案,因此觉得Prefab的方案在手游上的坑应该更少一些;
- Scene的加载、卸载过程不如Prefab具有可控性,针对Scene对象做缓存也没有Prefab方便。
这其实是工作量还比较最大的一次改动了,主要原因是需要针对Lightmap进行存储。这里使用的也是Unity中动态更改光照贴图设置的做法,即在每一个进行了烘焙的GameObject上添加一个Component用于存储它的lightmapIndex和lightmapScaleOffset,核心的代码参考:《Unity5.x场景优化之动态设置光照贴图lightmap》。具体实现细节就不说了,直接可以参考文章中的源码,这里只说明下将这一方案用于动态加载大世界的时候需要进行的修改和遇到的问题。
4.1 全局光照贴图索引的建立
在通常的动态切换场景光照贴图的实现方案中,只需要在更换的瞬间遍历所有的需要更改贴图的组件进行更改即可,光照贴图的索引在一套光照贴图内也是不变的。但是动态加载Prefab的时候就有一个很严重的问题。
美术是按照单独的场景进行烘焙的,在每个场景内都有索引从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>
/// 方便管理大世界对应的光照贴图全局索引文件的辅助类
///
/// </summary>
public class DynamicWorldLMProfileHelper
{
// 存储全局的光照索引文件路径
// Todo 这样设置会导致全局只能使用这一份,目前还不打算兼容多个动态场景,暂时先这样。。。
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("没有默认的大世界lightmap信息的配置文件,自动创建!");
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对象中只有一个数组,下标即全局的光照贴图索引,值为光照贴图的路径。选择exr文件的完整路径是为了兼容Lightmap共用或者一个场景中存在多张lightmap的情况。(目前推荐美术一个Chunk场景只使用一张Lightmap,因此这种情况并不多见,但程序结构上是完整支持的。)一个简单的示例截图如下:
在每一个Chunk对应的Prefab文件中,只有一个用于控制光照贴图加载和删除的ChunkLightMapSetting对象,它里面除了存储直接的光照贴图文件之外,还存储了局部光照贴图索引和全局光照贴图索引的对应关系。
public Texture2D[] lightmapLight, lightmapDir;
public LightmapsMode mode;
public int[] globalIndex; // 存储局部光照贴图索引和全局光照贴图索引的对应关系
在每一个带有烘焙信息的GameObject身上的RendererLightMapSetting组件中存储的lightmapIndex,是全局的光照信息。这样只需要在ChunkLightMapSetting加载和销毁的时候重新设置当前LightmapSettings的属性即可。注意由于其lightmaps属性为一个数组,因此需要将其扩展到当前存在的全局索引的最大值,运行时这个数组中间会有很多贴图是空着的。
// 扩充lightmap的数量到最大索引值
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设置的几个小坑
在使用LightmapSettings的时候感觉有几个跟预期不太一样的小坑。
-
LightmapSettings的lightmaps属性直接赋值是无效的,必须
new
一个新的对象数组或者将其赋值给一个临时数组对象,修改完毕之后再赋值回去才可以。不知道是我使用的姿势不对还是什么原因,另外个人觉得这里会有内存分配的问题,但是目前也没有找到更好的解决方法。 - 当第一张lightmap为空的时候,整个场景会变暗很多。这个问题一开始遇到的时候以为是Lightmap加载的一个bug,反复观察了一会才发现当index为0的那个Prefab被卸载了之后,整个场景都变暗了。这个目前依然不知道原因,我们的做法是如果第0张为空的话,则选择一张已经存在的Lightmap贴图赋值给它,注意这个处理要在任何一个Prefab加载或者卸载时进行。
4.3 改进后的工作流程
使用Prefab代替Scene来存储Chunk,不但需要把之前已经制作好的Scene转换为Prefab,而且对于整个工作流也增加了一点工作量。改进之后的工作流程如下:
对于美术来说影响不大,只是多了一个要创建Prefab和修改之后应用到Prefab上的过程。这个修改同时带来的一个好处是在场景中可以同时存在最后使用的prefab和之前的Terrain、光照等数据了,避免了需要删除再次修改不方便,或者隐藏掉导致打包的时候带入包体等问题。一个Chunk场景的结构大致如下图所示:
图中红框内的是最后要保存的prefab数据,其他部分可以用于烘焙和修改用,保存在Scene中。需要说明的是,我们的资源打包采用了拆分美术工作目录和游戏运行目录的方式,美术的工作目录为Assets/Res,游戏运行目录为Assets/BundleResource的方式,Res中存放所有的美术资源,但是Prefab、Scene等需要被游戏直接使用的文件存储在BundleResource目录下,打包时是根据BundleResource目录下的所有文件,检索出其引用到的文件进行AssetBundle打包。在这种结构下,Chunk拆分后的Scene文件存放在Res目录下,Terrain数据也存放在Res目录下,只有最后使用的Prefab文件存储在BundleResource目录下。
经过修改为Prefab的迭代,其实使得整个工作流程更加合理。付出的一个小代价是美术在保存光照信息之后,在编辑模式下无法正常预览烘焙的效果,需要运行游戏来预览。但这也可以通过添加ExecuteInEditor相关的逻辑来实现。(感谢钱康来同学提供这个思路~)
5. Chunk缓存
使用Prefab代替Scene之后,加载Chunk顿卡的问题得到了一定程度上的缓解,但是仍然存在一点顿卡的感觉。临近测试,这里只做了一个简单的优化就是使用最近使用的Cache来缓存加载过的场景文件。思路非常简单,这里直接给出我们实现的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; //当放入cache中的时候要做的处理
private CacheOperation _takeOutOper = null; //当从cache中取出来的时候要做的处理
private CacheOperation _discardOper = null; //当由于容量有限要从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>
/// 设置针对缓存对象存取或者丢弃时的处理函数
/// </summary>
/// <param name="putin">放入时的处理函数</param>
/// <param name="takeout">取出时的处理函数</param>
/// <param name="destroy">丢弃时的处理函数</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的缓存,因为考虑到会多占用额外内存,并且对于九宫格的方案来说,最坏情况下一次加载和卸载的chunk数量也就是5个。
private LRUCache<string, LoaderObjectPair> ChunkLRUCache = new LRUCache<string, LoaderObjectPair>(5);
6. 总结
我们不是第一个在手机上实现九宫格的项目,也肯定不是做得最好的那个。我花了大约两天时间完成这篇总结,除了给一些正在做这个功能或者想做这个功能的朋友一些经验上分享之外,也是对自己之前很长一段时间断断续续在做的工作的一个总结。虽然它包含了很多细节,但是因为时间跨度实在有点久,一些讨论和思考过的细节已经遗失在了记忆中。
前面其实已经说了,九宫格的方案原理上非常简单,可能在需求明确的情况下,算上周边工具,开发的代码量也不过几千行,加上调试时间也可能最多2周也能够搞定。但是在整个工作流程的构建上,需要和策划需求对接,和美术制作方法匹配,要考虑的问题就多了很多,再加上可能不断变化的需求,才有了这跨度有半年之久的工作内容。
我想借用两个工业界的概念来表达我在整理这篇文章时的感受——“实验室技术”和“工厂技术”。制作Demo实现的过程和之前学习的两个Unity插件的内容比较像是“实验室技术”,它只需要关注核心的技术实现,提供尽量通用的解决方案,可以做得很快很漂亮;而最终落实到项目中,要整个团队可以一起应用起整个制作流程,这里有很多妥协,有很多一点也不优美的“临时解决方案”,要兼顾更多细节,甚至要考虑工具使用者的感受。后者的过程既无法写论文又不易做分享,甚至有些至关重要的细节只存在于已经熟练应用这一流程的每一个团队成员脑海中。就像富士康公司的流水线,看上去每一个步骤都没有什么技术门槛,但是外人模仿的时候却又发现有各种各样的困难,达不到同样的效果,又或者效率低下。在游戏开发中,这两项技术相辅相成,缺一不可,“实验室技术”负责提供诗和远方的大方向,“工厂技术”负责脚踏实地地把技术应用到团队生产中。而我,作为一个一线开发人员,可能接触和思考更多的是后者,因此这篇文章涉及到的高大上的“实验室技术”很少,更多的是期望把那些开发中琐碎的“工厂技术”的经验尽可能地记录下来,分享出去。
至于未来的工作,大世界动态加载这块还有很多问题要解决,比如第一次加载Chunk时的顿卡,为了降低DrawCall是否需要在加载时进行一次合批过程(目前我们大世界场景的DrawCall在100~150左右)等等。这些问题等到解决后会再补充一篇后续的文章进行记录和分享。
最后,感谢花时间阅读到这里的朋友,希望你可以从这篇文章中有所收获,也希望有经验的朋友给一些改进的建议和分享~感谢!
2017年7月 于杭州滨江海外高层次人才创业基地