汪华健,汪志锋
(上海第二工业大学工学部,上海 201209)
Unity引擎由Unity Technologies 开发,是一个能够多平台开发的综合型专业游戏引擎。其从2005年发布到今天的十几年的时间里,Unity 游戏引擎已经演变成一个方便易用的、可跨平台的高度集成开发环境,成为游戏市场的主流的开发引擎之一[1]。随着Unity愈发火热,Unity引擎在三维视频游戏、建筑可视化、实时三维动画、仿真系统开发等各个方面都有着广泛的应用。
随着Unity应用越来越广泛,Unity在进行开发时也发现一些问题,如使用Unity引擎对大型三维场景进行处理时,经常出现卡顿、内存不足等问题;针对这些问题,学者们也开始针对Unity内存管理机制中进行一些研究,并提出了在编程方式、三维模型管理以及动态内存加载机制等几个方面的优化方法[2]。而在三维模型动态调度方面最典型的应用是将四叉树算法运用到地形加载中[3],此外,还有基于Docker容器的调度优化策略等[4]。
本文主要通过资源动态调度来完成使用Unity进行虚拟仿真系统开发中内存问题的优化,通过引入四叉树算法,并对当前算法中的一些问题进行优化,实验证明,优化后的算法在Unity内存占用上产生了良好的反馈,使应用在不损失效果的情况下将内存的消耗更低。
Unity3D 引擎使用的内存类型共有三种:程序代码段、托管堆( Managed Heap) 以及本机堆(Native Heap)[2]。“代码段”存储可执行文件的指令;也有可能包含一些运行的依赖库文件等。“本机堆”是Unity指的是对本地资源进行申请和操作,其中资源指的是音效、图片和三维模型等一些文件。“托管堆”是一段内存,由项目脚本运行时(Mono或IL2CPP)的内存管理器自动管理。
Unity官方说明中明确指出内存全部都是由系统托管,其内存管理机制的基本理念是:如果某个场景(scenes)里需要某个资源(Resources),那么在运行时加载到内存。而将资源加载至场景中有多种调用方式,且不同的调用方式在内存的占用过程也不一致;其中加载预设的方式有三种,第一种是声明公有变量,其中变量的值可通过在Inspector中修改;二是采用Resources.Load()方法加载;三是采用AssetBundle.Load()方法;三种加载方式在加载后都需要然后在相应脚本中用Instantiate()方法实例化。其中第三种加载方式在加载过程中会直接加载预设全部依赖资源,在实例化过程中只是进行克隆操作。
而在Unity资源的卸载和销毁过程中,Destory()函数释放资源有限,只对资源的引用或复制,不释放已加载至内存的纹理、材质等资源。UnloadAsset()释放区域中指定的资源。此外还有UnloadUnusedAssets()方法能够卸载当前所有没有被占用的资源。
通过上述分析可知在虚拟仿真时采用AssetBundle()加载方式能同时加载预设的纹理、材质信息,此方法能够避免加载和卸载中掉帧的现象。卸载时使用Destory()函数销毁物体对象后,在资源卸载完成后调用UnloadUnusedAssets()函数将未被占用的纹理、贴图资源彻底卸载以实现内存回收。
四叉树算法的基本思想是将空间递归划分为不同层次的树结构。如图2所示它将已知范围的空间分成四个子空间,如此递归下去,递归的次数称为四叉树的层数,直至树的层数达到一定深度后停止分割。
图2 四叉树结构
四叉树算法的应用方向很多,常见的如:稀疏数据、图像处理、空间数据索引以及物体碰撞检测等,而将其运用到虚拟仿真中进行动态调度是属于空间数据索引方面的一个应用;四叉树算法的结构比较简单,并且当空间数据对象分布比较均匀时,具有比较高的查询效率和空间数据插入,因此四叉树算法也是常用的空间索引算法之一。
假设虚拟仿真场景如图3所示,整个空间内(矩形内)存在三维模型a-j。
图3 Unity场景内三维模型分布
首先对空间区域(类别)进行划分,然后给每一个子节点都编号,那么每个子节点会继承父节点的编号为前缀,并在此基础上有相对其兄弟节点的独特编号。如果给左上、右上、左下、右下四个子节点分别编号为1、2、3、4,那么1节点内的左上、右上、左下、右下四个子节点分别编号为11、12、13、14.以此类推,当节点内模型资源较少时,可以不划分;则可得到如图4所示的区域划分图。
图4 区域划分图
对应上述三维模型进行区域划分后,将四叉树中所有的三维模型对象都存储在各个最底层的子节点上,如图5所示,此时,如果2节点有划分,则c、f三维模型不能存储在2上,应该存储在对应的子节点上,该模型也称为满四叉树。
图5 四叉树示意图
满四叉树结构是自父节点(根)向下逐步划分的一种树状的层次结构,每次索引的深度将随着四叉树的层数大大的增多,但随着四叉树的层数会不断地加深,三维模型资源不断的增加,四叉树索引的缺点也更加明显,首先,索引查询的时候效率会比较低下;其次存储空间的浪费;最后树的结构会变得不平衡。
而针对满四叉树结构的不足,也有相应的改进:将数据信息可以存储在它的每一个节点中,如图6所示,并且将同时存在于两个节点的三维模型存储到这两个节点的父节点;如12和14节点都有三维模型b,将其存储到1节点,每个三维模型资源只在树中存储一次,通过改进可以加快插入的速度,并且不存在一个三维模型资源存储在多个节点的现象,这种结构为非满四叉树结构。
图6 非满四叉树
非满四叉树为每个节点(包括父节点)需要添加一个“容量”的属性,在四叉树初始化时只有一个父节点,并且该根节点也可以存储,在插入数据时,如果一个节点内的数据量大于了节点“容量”,再将节点进行分裂。在查询时,只有找到了位置对应的节点,那么节点下的所有点都会是此位置的附近点,更小的“容量”意味着每个节点内点越少,也就意味着查询的精度会越高。并且可以保证每个节点内都存储着数据,避免了内存空间的浪费。
实验中将场景中的摄像机和Player绑定,使得摄像机始终处于中心位置,而周围仿真模型始终随着摄像机视野移动而实时进行动态调度。而针对Unity内存管理机制,通过对三维模型进行预设实例化和预设销毁完成相应的内存管理。设计了如图7所示的流程的动态调度方法。
图7 实验流程图
1)程序初始化时将资源建立四叉树,并根据摄像机初始位置加载初始视野内的仿真模型,并将载入的模型资源存入列表CurrentList(保存当前已加载三维模型)。
2)Unity生命周期表到达Update帧刷新阶段,判断摄像机位置是否改变,若未发生变化则不处理,否则进入三维模型动态调度。
3)运用四叉树算法通过变化后摄像机位置来计算视野内三维模型资源,将其存入列表RefreshList(保存帧更新后三维模型)。
4)根据每一帧帧更新前后的三维模型列表差值(CurrentList和RefreshList的差值)计算出需要加载和卸载的三维模型。
5)完成调度后,将RefreshList赋值给CurrentList,即更新当前仿真模型资源列表。
结合图7知,列表RefreshList比CurrentList的多出来的三维模型则代表需要加载的三维模型,而CurrentList比RefreshList的多出来的三维模型则代表需要卸载的三维模型。完成地形块的动态加载和卸载之后,执行UnloadUnusedAssets()函数释放其它纹理、材质等内存三维模型,并更新当前列表后进入下一帧循环,由此则完成整个场景中三维模型的动态调度过程。在整个帧循环过程中,内存中只需要加载摄像机视野中的三维模型从而占用更少的内存。
实验中三维模型需要通过四叉树查找摄像机(Player)周围的编码块不断刷新,但是加载视野周围的资源时需要搜索周围资源是否在范围内,若范围内某个点周围的资源都加载的话,还是会占用大量的内存空间,并且现率提升不明显。而启发式搜索就是在状态空间中的搜索对每一个搜索的位置进行评估,得到最好的位置,再从这个位置进行搜索直到目标。这样可以省略大量无谓的搜索路径,从而进一步提升效率。用于评价节点重要性的函数称为估价函数,其一般形式为
f(n)=g(n)+h(n)
在该算法中,根据估价函数值,按由小到大的次序对Open表(被考虑视野内有效资源的位置集合)中的节点进行重新排序,此时的Open表是一个按节点的启发估价函数值的大小为序排列的一个优先队。
该算法首先需要将初始摄像机节点S0放入Open表中;如果Open表为空,则退出,当其不为空,把Open表的第一个节点取出,放入到Closed表(不被考虑视野内模型资源的位置集合)中,并把该资源节点记为节点n;如果节点n是视野边缘节点,则搜索成功,求得一个解,退出;再对节点n进行拓展,生成一组子节点,对不在Open表和Closed表中的资源节点计算出相应的估价函数值;再把节点n的子节点放到Open表中并进行排序;最后重复步骤直到退出。
实验中在四叉树的基础上引入A*算法,A*算法一般用于最佳路径问题,而用于此处主要作用找出摄像机当前位置所需要加载的最佳模型组合;由于四叉树已经将地图分割,主要考虑通过估价函数找出最佳的三维模型资源组合,称之为可采纳性。A*算法是一个可采纳的最好优先算法。A*算法的估价函数为:
f′(n)=g′(n)+h′(n)
式中:g′ (x)为从摄像机节点到视野内节点x的最佳资源组合所付出的代价;h′ (x)是从视野内节点x到边缘节点的最佳资源组合所付出的代价;f′ (x)是从初始节点开始一直加载节点x到达边缘节点的最佳路径的总代价。
对于每个节点的资源,都有自己的g′ (x)、h′ (x)、f′ (x)。其中g(x)是对g*(x)的估计,且g(x)>0;h(x)是h*(x)的下界,即对任意节点x均有h(x)≤h*(x)
A星算法并不去遍历整个场景,而是只遍历了视野内的点和其周围的点,所以得到的是一种近似最优解的集合。
实验中电脑的配置如表1。
表1 电脑配置表
由于摄像机的视野为四棱锥状,首先在Unity中创建一个矩形空间,并在矩形空间中预设一定量的三维模型文件,建立场景如图8所示。
图8 Unity场景
研究中将三维空间降为二维研究,则三维模型资源可理想化为点集,如图9所示,图中O点为摄像机,三角形OAB为俯视视图中摄像机的视野,为更加直观看出四叉树的视觉效果,在Untiy空间中使用Gizmos.DrawWireCube()来绘制四叉树划分的区域。
图9 Unity中建立四叉树
在整个场景中摄像机绑定在玩家Player上,可通过键盘鼠标控制Player移动,即键盘上WASD控制Player前后左右移动、空格跳跃以及鼠标控制摄像机的上下左右旋转;其实现方法为在Update()方法中通过判断Input.GetKey() 获取按键信息,并使用进行摄像机的移动(WASD控制摄像机移动),部分代码如下:
void Update () {
if(Input.GetKey (KeyCode.W)){
transform.Translate(Vector3.forward * Time.deltaTime * Speed);}}
在加载过程中完成四叉树的创建,主要通过一下代码(部分)完成四叉树的创建,如果还可以拥有下一层子节点且未创建,则创建下一层子节点,且只有一个子节点时则可以包含该物体,即该物体属于子节点:
public void InsertObj(ObjData obj){
Node node=null;
bool bChild=false;
if(depth < belongTree.maxDepth && childList==null) {
CerateChild();}
if(childList!=null){
for (int i=0; i < childList.Length;++i)
Node item=childList[i];
if (item==null){
break;}}}
if (bChild){
node.InsertObj(obj);}
else{
objList.Add(obj);}}}}
通过A*寻找边缘节点代码如下
while (openList.Count > 0) {
AStarNode curNode=openList [0];
for (int i=0; i < openList.Count; i++) {
if (openList [i].CostF < curNode.CostF && openList [i].costH < curNode.costH) {
curNode=openList [i];}}
openList.Remove (curNode);
closeList.Add (curNode);
已经找到边缘节点后获取当前点的周围点,
if (curNode==end) {
Debug.Log (″>>″);
GetPathWithPos (startPos,endPos);
return;}
List
最后遍历当前点周围的NodeItem 对其进行过滤并且计算f(n)、g(n)、h (n)并进行赋值。
foreach (AStarNode nodeCell in nodeItemGroup) {
if (nodeCell.isWall || closeList.Contains (nodeCell)) {
continue;}
if (newCostg <=nodeCell.costG
||!openList.Contains (nodeCell)) {
nodeCell.costG=newCostg;
nodeCell.costH=GetDistance (nodeCell,end);
nodeCell.parentNode=curNode;
if (!openList.Contains (nodeCell)) {
openList.Add (nodeCell); }}
在实验中将建立不同层数的四叉树来进行实验对比验证,在Unity中设定层数后,通过Unity Profile性能分析工具来观察Unity中各种参数的变化,如图10所示,由性能分析器的内存占用曲线可以看出,在进行动态调度时,仿真模型的加载和卸载过程占用时间很短,而且内存始终维持在稳定水平。
图10 性能分析工具
实验中三维模型中的部分包含PNG格式贴图,模型的预制体为24个,地形文件1个,在加载时随机实例化预制体,使得场景中模型数量到10000,运行场景后随机运动Player不断刷新场景,将场景的帧数、内存占用以及首次加载时间进行数据记录,主要控制的变量为四叉树深度,根据改变深度得到的数据来判断应用四叉树算法后的效果,通过多次实验并整理得到的数据如表2所示。
表2 实验数据
由上表可见,本实验中采用四叉树进行动态调度之后,程序中的内存占用均大大降低,而且帧率也有所提升。但是随着四叉树的深度的增大,该算法的引入仍然具有优势,但优化效率却会下降,优化并不理想并且会增加加载时间;实验表明,四叉树深度在4-7为最佳(其它自变量不变时)优化效率(帧数)可达38.21%。
虚拟仿真的相关引擎极大地方便了虚拟仿真的相关开发,而Unity其强大的功能特性、跨平台支持等优势更是受到无数开发者的青睐,而本研究针对使用Unity进行开发中的内存在用问题,
引入四叉树算法进行动态调度,本文的研究具有以下特点:
1)从资源加载的角度进行优化内存占用,在大场景漫游过程能避免一次性加载、卸载过多导致的一些问题,并保持浏览流畅度的同时降低计算机性能消耗。
2)详细分析了在Unity中的仿真模型的加载和卸载过程,对于控制仿真对象的三维模型占用和优化有重要作用
3)通过实验证明该算法的引入能够显著提升Unity在虚拟仿真时的帧率;同时,能维持内存消耗在较低水平。
4)四叉树节点的分裂与合并比较频繁,如果直接 new、delete,相对比较慢;简单的实例化一些对象,并组成一个对象池的话,内存碎片会比较严重,在后续研究中需要时,每次预先分配一大块内存。再在大块内存上,切出一个个对象地址,这样可以比较有效的减缓内存碎片现象。