Unity+NGUI性能优化

2017年04月18日 09:18 0 点赞 0 评论 更新于 2025-11-21 21:22

1、资源分离打包与加载

在游戏中,同一份资源往往会在多个地方被使用。例如,部分界面可能共用同一份字体、同一张图集;某些场景可能共用同一张贴图;一些怪物可能使用同一个Animator等。在制作游戏安装包时,可将这些公用资源从其他资源中分离出来,单独进行打包。比如,若资源A和B都引用了资源C,就将C分离出来单独打一个bundle。

在游戏运行时,若要加载A,需先加载C;之后若要加载B,由于C的实例已存在于内存中,只需直接加载B,并让B指向C即可。若打包时未将C从A和B中分离出来,A的包里会包含一份C,B的包里也会有一份C,这会使安装包体积增大。并且在运行时,若A和B都被加载进内存,内存中就会存在两个C实例,从而增加内存占用。

资源分离打包与加载是减小安装包体积和运行时内存占用的有效手段。通常情况下,打包粒度越细,安装包体积和运行时内存占用这两个指标就越小。而且当两个相邻renderQueue的DrawCall使用了相同的贴图、材质和shader实例时,这两个DrawCall可以合并。然而,打包粒度并非越细越好。若运行时需要同时加载大量小bundle,加载速度会变得非常慢,因为时间都浪费在了协程之间的调度和多批次的小I/O上。此外,DrawCall合并并不一定能提高性能,有时反而会降低性能,后续会详细说明。因此,需要有策略地控制打包粒度,一般只分离字体和贴图这类体积较大的公用资源。

可以使用AssetDatabase.GetDependencies来了解一份资源使用了哪些其他资源。

2、贴图透明通道分离,压缩格式设为ETC/PVRTC

最初,我们采用DXT5作为贴图压缩格式,期望减小贴图的内存占用。但很快发现,移动平台的显卡不支持硬件解压DXT5。对于一张1024x1024大小的RGBA32贴图,虽然DXT5可将其从4MB压缩到1MB,但系统在将其送进显卡之前,会先用CPU在内存里将它解压成4MB的RGBA32格式(软件解压),然后再将这4MB送进显存。在这段时间内,这张贴图会占用5MB内存和4MB显存。而移动平台通常没有独立显存,需要从内存中划分一块作为显存,所以原本以为只占1MB内存的贴图实际却占用了9MB。

所有不支持硬件解压的压缩格式都存在这个问题。经过调研,我们发现安卓上硬件支持最广泛的格式是ETC,苹果上则是PVRTC。但这两种格式都不带透明(Alpha)通道。因此,我们将每张原始贴图的透明通道分离出来,写入另一张贴图的红色通道。这两张贴图都采用ETC/PVRTC压缩。在渲染时,将两张贴图都送进显存。同时,我们修改了NGUI的shader,在渲染时将第二张贴图的红色通道写到第一张贴图的透明通道里,恢复原来的颜色,代码如下:

fixed4 frag (v2f i) : COLOR
{
fixed4 col;
col.rgb = tex2D(_MainTex, i.texcoord).rgb;
col.a = tex2D(_AlphaTex, i.texcoord).r;
return col * i.color;
}

这样,一张4MB的1024x1024大小的RGBA32原始贴图,会被分离并压缩成两张0.5MB的ETC/PVRTC贴图(我们使用的是ETC/PVRTC 4 bits)。它们渲染时的内存占用为2x0.5 + 2x0.5 = 2MB。

3、关闭贴图的读写选项

在Unity中,导入的每张贴图都有一个启用可读可写(Read/Write Enabled)的开关,对应的程序参数是TextureImporter.isReadable[docs.Unity3d.com/ScriptReference/TextureImporter-isReadable.html]。选中贴图后,可在Import Setting选项卡中看到这个开关。只有打开这个开关,才可以对贴图使用Texture2D.GetPixel,读取或改写贴图资源的像素,但这需要系统在内存里保留一份贴图的拷贝,以供CPU访问。一般游戏运行时不会有这样的需求,因此我们对所有贴图都关闭了这个开关,只在编辑中做贴图导入后处理(比如对原始贴图分离透明通道)时打开它。这样,上文提到的1024x1024大小的贴图,其运行时的2MB内存占用又可以减少一半,降至1MB。

4、减少场景中的GameObject数量

有一次,我们将场景中的GameObject数量减少了近2万个,游戏在iPhone 3S上的内存占用立即减少了20MB。这些GameObject虽然基本处于隐藏状态(activeInHierarchy为false),但仍然会占用不少内存。并且这些GameObject上挂载了许多脚本,每个GameObject中的每个脚本都需要实例化,这也是一笔不小的内存开销。因此,后来我们规定场景中的GameObject数量不得超过1万,并将GameObject数量列为每周版本的性能监测指标。

5、整理图集

整理图集的主要目的是节省运行时内存(有时也能起到合并DrawCall的作用)。从这个角度来看,显示一个界面时送进显存的图集尺寸之和越小越好。以下是一些有助于实现这一目标的方法:

(1)界面设计采用九宫格拉伸

在界面设计上,尽量让美术将控件设计为可以做九宫格拉伸,即UISprite的类型为Sliced。这样美术只需切出一张小图,我们在Unity中将其拉大。当然,一个控件做九宫格拉伸意味着其顶点数量从4个增加到至少16个(若九宫格的中心格子采用Tiled做平铺类型,顶点数会更多),构建DrawCall的开销会更大(见第6点),但只要DrawCall安排合理(同样见第6点),一般不会有问题。

(2)界面设计采用对称图案

同样在界面设计上,尽量让美术将图案设计成对称的形式。这样切图时,美术只需切出一部分,我们在Unity中将完整的图案拼出来。例如,对于一个圆形图案,美术可以只切出四分之一;对于一张脸,美术可以只切出一半。不过,与第(1)点类似,这个方法也有其他性能代价,即一个图案所对应的顶点数和GameObject数量会增多。第4点已经提到,GameObject数量的增多有时会显著占用更多内存,因此一般只对尺寸较大的图案采用这个方法。

(3)避免不必要的贴图素材驻留内存

要确保不要让不必要的贴图素材驻留内存,更不要在渲染时将无关的贴图素材送进显存。为此,需要将图集按照界面分开,一般一张图集只放一个界面的素材,一个界面中的UISprite也不要使用别的界面的图集。假设界面A和界面B上都有一个一模一样的小金币图标,不要为了制作方便,让界面A的UISprite直接引用界面B中的金币素材。否则,界面A显示时,会将整个界面B的图集也送进显存,而且只要A还在内存中,B的图集也会驻留内存。对于这种情况,应该在A和B的图集中各放一个一模一样的金币图标,A中的UISprite只使用A的图集,B中的UISprite只使用B的图集。

不过,如果两个界面之间存在大量相同的素材,那么这两个界面可以共用同一张图集,这样可以减少所有界面的总内存占用量。具体操作时需要根据美术的设计进行权衡。一般来说,界面之间相同的通用素材越多,程序的内存负担就越小,但界面之间相同的东西太多,美术效果可能会不够生动,这需要美术和程序之间寻求平衡。

另外,数量庞大的图标资源(如物品图标)不要做在图集里,而应该采用UITexture。

(4)减少图集中的空白地方

图集中完全透明的像素和不透明的像素所占的内存空间是一样的。因此,在素材量不变的情况下,要尽量减少图集中的空白。有时一张1024x1024的图集中,素材所占的面积还未超过一半,这时可以考虑将这张图集切成两张512x512的图集。(可能有人会问为什么不能做成一张1024x512的图集,这是因为iOS平台似乎要求送进显存的贴图一定是方形。)当然,两张不同图集的DrawCall无法合并,但这并不是什么问题(见第6点)。

应该说,图集的整理在具体操作时并没有固定的标准,很多时候需要权衡利弊来最终决定如何整理,因为每种措施都会有其他性能代价。

6、根据各个UI控件的设计安放Panel,隔开DrawCall

有一次,我们发现NGUI的UIPanel.LateUpdate函数的CPU开销非常大。经过仔细研究,发现是合并了过多的DrawCall所致,尤其是将运行时会运动变化的UI控件和静止不变的UI控件的DrawCall合并在了一起。当一个UI控件(UIWidget)的位置、大小或颜色等属性发生变化时,UIPanel需要重建这个控件所用的DrawCall,某些情况下还要重建Panel上的所有DrawCall。有时重建一个DrawCall会消耗不少CPU开销,它需要重新计算这个DrawCall上所有控件的顶点信息,包括顶点位置、UV和颜色等。如果很多控件都集中在同一个DrawCall上,那么只要一个控件有一点变化,这个DrawCall上的所有控件的顶点就都要重新遍历一遍。而我们的UI大量采用了九宫格拉伸,使控件的顶点数量增多,因此重建一个DrawCall的开销更大。

因此,我们将UI控件进行分组,将一段时间内会发生变化的控件(如怪物头顶的血条和伤害跳字)放在同一个Panel上,并且这个Panel上只放置这些控件,其余基本不变化的控件则放在其他Panel上。这样,两类控件就被隔开到不同的DrawCall和不同的Panel中,当一个控件发生变化导致DrawCall重建时,就不需要遍历那些没有变化的控件。由于在美术设计上,一段时间内变化的控件总是少数,所以优化效果十分明显,节省的CPU占用率能达到25%。

这种方法会增加一些DrawCall,但不会有太大影响。我们项目前期过于重视DrawCall数量的压缩,但后来发现增加几个DrawCall并不是那么可怕的事情。主程曾用Cocos2d - x做过试验,即使在500个DrawCall的情况下,动画依然可以流畅运行,相比之下,贴图大小对流畅度的影响要大得多。

7、优化锚点内部逻辑,使其只在必要时更新

在优化了Panel的DrawCall重建效率之后,我们发现NGUI锚点自身的更新逻辑也会消耗不少CPU开销。即使控件静止不动,控件的锚点也会每帧更新(见UIWidget.OnUpdate函数),而且它的更新是递归式的,这使得CPU占用率更高。因此,我们修改了NGUI的内部代码,使锚点只在必要时更新,一般只在控件初始化和屏幕大小发生变化时更新即可。不过,这个优化的代价是,当控件的顶点位置发生变化(如控件运动或大小改变等)时,上层逻辑需要自己负责更新锚点。

8、降低贴图素材分辨率

这一方法实际上就是减小贴图素材的尺寸。例如,对于一张原画尺寸为100x80的贴图,我们在导入Unity后将其缩小到50x40,即缩小两倍,游戏实际使用缩小后的贴图。不过,这种方法必然会显著降低美术品质,美术人员会明显发现画面变得更模糊,因此一般在程序性能撑不住时才会采用。

9、界面的延迟加载和定时卸载策略(暂未实施)

如果一些界面的重要性较低,并且不常被使用,可以等到界面需要打开显示时才从bundle加载资源,并且在关闭时将其卸载出内存,或者等待一段时间后再卸载。不过,这个方法有两个代价:一是会影响玩家体验,玩家要求打开界面时,界面的显示会有延迟;二是更容易出现bug,上层写逻辑时需要考虑异步情况,当程序员要访问一个界面时,这个界面可能不在内存中。因此,目前我们尚未实施该方案,目前只是在进入一个新场景时,卸载上一个场景用到但新场景不会用到的界面。

综上所述,以上9个方法中,第4、5、6点需要在一定程度上从策划和美术的角度考虑问题,并且需要持续监控以维护优化状态(因为设计上总会有新界面的需求或改动老界面的需求);其他方法都是一劳永逸的解决方案,只要实施稳定后,就无需再投入精力。不过,第2和8点会降低美术品质,尤其是第8点。如果美术人员无法忍受品质降低的程度,可能不会允许采用这两个方法。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章