UWA

每天一点UWA:第六周

Posted by 蔡华的博客 on September 4, 2017

AssetBundle

这是一个很长的问题:

字体作为多个资源的依赖包,会在游戏中被加载多次。我们现在有个问题,AssetBundle A资源依赖于这个字体,加载A的时候加载了一份字体,然后B资源也依赖这字体,而后加载B的时候我们没有去重复加载字体,这时候发现B资源上出现了字体丢失的现象。

请问加载资源的时候,Unity会自动去识别内存里是否有它的资源依赖包吗?如果有的话,为什么B加载的时候找不到已经存在内存中的字体?这里需要手动去做些什么处理吗?

同时我发现依赖包资源如果进行了bundle.m_AssetBundle.Unload(false)以后,其他依赖于这个包的资源就引用不到了。我们流程上对于每个读进来的AssetBundle,都会加载完后马上进行Unload(false),请问如果是依赖包的话,是不是不能对其进行这步操作?

  • 这个问题其实是典型的一个AssetBundle使用的问题,其中牵扯的细节在unity的文档中都有。
  • 总结来说就是对于公共资源的字体,在A和B加载前要提前加载好,这样unity根据依赖关系会自动的找到字体asset。
  • 但是如果加载A完成后直接卸载了字体的AssetBundle,那么加载B时肯定会出错。此时字体asset虽然还在(因为用的是Unload(false)),但是unity已经无法依靠依赖关系来作用在B上了。同时如果测试再次加载字体AssetBundle,则原有的字体asset出现内存泄漏问题。

自己下载了shader包,如何替换buildin资源?

  • 需要完全的替换,shader自己不会关联,而且很多时候用的是buildin的material,测试用的就是buildin的shader。
  • 还有一个情况是prefab中用的shader的GUID和自己导入的不同,可以用通过如下脚本进行替换。
using UnityEngine;
using System.Collections;
using UnityEditor;
using System.IO;

public class BuildScript {

    [MenuItem("Build/RefreshMat", false, 501)]
    static void RefreshMat() {
        var guids = AssetDatabase.FindAssets("t:Material");
        foreach (var guid in guids) {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            if (path.ToLower().EndsWith("mat")) {
                var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
                if (mat && mat.shader) {
                    Debugger.Log("{0}\n{1}\n{2}\n{3}\n", path, mat.shader.name,
                        mat.shader.GetInstanceID(),
                        Shader.Find(mat.shader.name).GetInstanceID());
                    mat.shader = Shader.Find(mat.shader.name);
                }
            }
        }
    }
}

Font

一个字体打包到AssetBundle中,加载后在profiler中看到两份

  • 通常TTF文件会包含一个字体的多个字型,如可能包含正常字型、加粗字型、斜体字型等。而在Unity中会将其分为不同的Font资源,且他们之间会相互依赖。所以,如果项目中确实需要加粗字型的话,内存里出现两个Font是正常的,但如果实际上不需要加粗,那么可以尝试寻找一个不包含加粗字型的字体文件来替换该TTF文件。

在用Profiler真机查看iPhone App时,发现第一次打开某些UI时,Font.CacheFontForText占用时间超过2s,这块主要是由什么影响的?若iPhone5在这个接口消耗2s多,是不是问题很大?这个消耗和已经生成的RenderTexture的大小有关吗?

  • Font.CacheFontForText主要是指生成动态字体Font Texture的开销, 一次性打开UI界面中的文字越多,其开销越大。
  • 这个消耗也与已经生成的Font Texture有关系。简单来说,它主要是看目前Font Texture中是否有地方可以容下接下来的文字,如果容不下才会进行一步扩大Font Texture,从而造成了性能开销。

Texture

游戏里重复的特效较多,有些只是图案相同但改变了颜色参数,如果都打成独立AssetBundle,则内存里面会有多份Texture。关于这样的打包一般有什么推荐的方法呢?

  • 只保留一份初始纹理资源,并通过Shader在运行时改变其整体配色,从而达到不同的效果。
  • 如果是局部配色不同,那么可以在原始纹理的基础上加一种或几种Mask纹理,用来负责颜色的自适应调配,然后再通过Shader来达到不同的展示效果。

UI

GameObject.Deactivate消耗大

  • GameObject.Activate/Deactivate本身通常不会产生很高的开销,产生高开销的是其OnEnable/OnDisable操作。
  • 如果是自定义脚本,可以考虑优化OnEnable/OnDisable函数中的操作。
  • 如果是UI可以考虑通过移出屏幕或者改摄像机的culling layer来实现隐藏。前面有讲过相关的内容。

动静分离或者多Canvas带来性能提升的理论基础是什么呢?如果静态部分不变动,整个Canvas就不刷新了?

  • 在UGUI中,网格的更新或重建(为了尽可能合并UI部分的DrawCall)是以Canvas为单位的,且只在其中的UI元素发生变动(位置、颜色等)时才会进行。因此,将动态UI元素与静态UI元素分离后,可以将动态UI元素的变化所引起的网格更新或重建所涉及到的范围变小,从而降低一定的开销。而静态UI元素所在的Canvas则不会出现网格更新和重建的开销。
  • PS:位置和颜色变化应该会造成更新,如果出现血条那样的会造成mesh变化从而重建。

UI优化

UGUI参考的函数

  • 1、Canvas.SendWillRenderCanvases() 该API为UI元素自身发生变化(比如被Enable或者被缩放,移动并不算)时所产生的调用。发生在canvas被渲染之前。

  • 2、Canvas.BuildBatch 该API为UI元素合并的Mesh需要改变时所产生的调用。通常之前所提到的Canvas.SendWillRenderCanvases()的调用都会引起Canvas.BuildBatch的调用。另外,Canvas中的UI元素发生移动也会引起Canvas.BuildBatch的调用。

NGUI参考的函数

  • UICamera.Update() 该函数通常在点击时出现开销。因此,当该函数的CPU开销较高时,通常都是因为调用了其他的较为耗时的函数引起。

  • UIRect.Update() 该函数通常在需要更新锚点位置时出现开销。因此,当该函数的CPU开销持续较高时,通常是因为当前场景中有较多的UI元素绑定了OnUpdate模式的锚点。

  • UIPanel.LateUpdate() 该函数为NGUI最主要的CPU开销,包含了对所有UI界面包括其下UI元素的状态更新、网格重建、DrawCall合并等操作。大量的UI变动或者不合理的UIPanel布局都有可能导致该函数出现较高的峰值。

  • UIRect.Start() 该函数主要涉及到UI元素的初始化操作,通常在UI界面被实例化时出现并产生一定的CPU开销。

优化建议:

  • 动静分离,动的频率不同也分离,减少Mask组件的使用,使用Mask不仅会增加GPU端渲染的压力,同时也会造成CPU端DrawCall的明显上升。可尝试用RectMask2D来进行替换。
  • 减少OnEnable和OnDisable,通过移动位置、改变摄像机culling mask。后者可能会一定程度地提高内存的开销(UIDrawCall中存储的Mesh)

NGUI的图集在内存里存了多份,求问怎么清理?

image

  • 游戏运行中,UI Mesh出现多份不同内存的情况,是正常的,因为随着UI widget使用的增加或减少,创建的UI Mesh是会随着变化的。
  • 同时,如果不同UIPanel中存在相同Atlas的Widgets,则也会出现上图中的情况。因此,建议大家遇到这种情况时,查看单帧中NGUI UI Mesh重名的是否有多份重名资源。如果存在,则说明相同Atlas中的资源被多个不同的UIPanel所使用,这种情况是需要尽可能避免的。

工具

Profiler中Other下System.ExecutableAndDlls的内存很大

  • 一般来说,在移动游戏中,该选项经常在iOS真机运行时会看到,System.ExecutableAndDlls该项显示的是执行文件和所调用的库(物理、渲染、IO等系统库)的总和。
  • 开发团队不用太担心该选项的数值,因为很多应用均在共用这些库,并且它对于真实项目的内存压力非常小,几乎没有影响,而且OS也不会因为该内存而杀掉游戏或应用。

Loading.UpdatePreloading

  • 这是Unity引擎最主要的加载函数。该项一般在切换场景时或主动动态加载资源时较大。 一般来说,加载资源越多、越复杂,则其反映的Loading.UpdatePreloading耗时则越大。

Profiler中Not Saved是指什么?

image

  • Profiler中的Not Saved指的是项目中通过代码生成的各种资源记录。如上图所示,其Mesh均为NGUI插件通过脚本生成的UI界面Mesh资源。

脚本

判断怪物是否在相机视野范围有没有什么好方法?

  • 可以在脚本中添加 OnWillRenderObject 或者 OnBecameVisible/OnBecameInvisible 的回调函数,这些函数分别会在对应的Renderer可见或者变为可见/不可见时被调用。

我们测试了下发现,在名为A的MonoBehaviour中,有个数组来存放名为B的 MonoBehaviour对象的引用。当我们其他的逻辑去Destroy了B对象所在的GameObject后,在A对象中的数组里,遍历打印,它们(B的引用)都为Null,在Inspector面板上看是missing。而这时候进行GC,堆内存其实并未释放这些B对象。只有当A对象中的数组被清空后,再调用GC,才可释放这些对象所占内存。这种现象是否正常?为什么值为Null但却还是被引用着,无法通过GC释放呢?

  • 首先这种现象是正常的。这是Unity中对Null的检测做了特殊的处理所致,在Unity中MonoBehaviour对象除了存在于Managed Heap中(作为“壳”),在Native内存中还会有一个相对应的“实体”,在调用Destroy时,真正被释放的正是这个“实体”。而在判断一个MonoBehaviour对象是否为Null时,Unity会首先检测“实体”是否已经被销毁,如果是则返回为true,但此时Managed Heap中的“壳”实际上依然是被引用的,从而就会出现对象的Null判断为true,但实际上还是被引用着,无法被GC释放的问题。 .
  • 相关的细节可见官方blog对Unity中Null判断的解释: http://blogs.unity3d.com/2014/05/16/custom-operator-should-we-keep-it/
  • PS:本周最佳收获。

粒子

真机下关闭粒子使用的FBX的Read/Write Enable属性后crash,Editor模式下没有。

  • 将FBX上的Read/Write Enabled关闭后,内存中便不再保存其Mesh的副本(只存在显存中),因此其属性就不可再被访问和修改。
  • 粒子系统通常需要动态地修改其粒子的顶点属性。因此,理论上来说,供粒子系统使用的Mesh是需要开启Read/Write Enabled的,而在Editor下Mesh和Texture都是强制开启的,所以在真机上就会出现问题。

粒子的优化

  • 减量。总的来说就是减少粒子的数量,减少粒子的范围,如果离摄像机远可以考虑不显示粒子。最后升级到5.3之后,因为粒子系统有优化。
  • 粒子系统拼合。是指引擎会将若干个材质相同且深度相同的粒子系统在渲染前进行合批(Batch),从而通过一个Draw Call来对其粒子系统进行渲染,进而降低粒子系统的渲染开销。至于合并机制之前也写到过。

粒子系统所使用的纹理并不是Sprite类型的,因此不需要设置Packing Tag。

模型&动画

SkinnedMeshRenderer.BakeMesh这个函数一般是什么时候调用呢?在Instantiate后调用么?

  • SkinnedMeshRenderer.BakeMesh 的作用在于:将一个蒙皮动画的某个时间点上的动作,Bake成一个不带蒙皮的Mesh,从而可以通过自定义的采样间隔,将一段动画转成一组Mesh序列帧。而后在播放动画时只需选择最近的采样点(即一个Mesh)进行赋值即可,从而省去了骨骼更新与蒙皮计算的时间。
  • 该方法的优点是用内存换计算时间,在场景中大量出现同一个带动画的模型时,效果会非常明显。该方法的缺点是内存的占用极大地受到模型顶点数、动画总时长及采样间隔的限制。因此,该方法只适用于顶点数较少,且动画总时长较短的模型。同时,Bake的时间较长,因此需要在加载场景时完成。比较经典的适用场景为MOBA游戏中的小兵。
  • PS:关于skinnedmesh转mesh这个问题比较大,可以参考网上的资源。

为什么尽可能使用Mecanim

  • Mecanim动画系统的多线程计算性能较之老版本的单线程计算性能要高;

  • Mecanim动画系统可以对GameObject开启 “Optimize Game Object” 选项。该选项为Unity引擎在4.3版本中加入的新功能,旨在优化Mecanim动画系统的底层计算开销。开启该选项,Animator.Update和MeshSkinning.Update的CPU占用均存在一定程度的降低;

  • Mecanim动画系统的Retargeting功能可以让多个不同的角色使用同一套的AnimationClip资源,比如主城中的NPC角色,其大部分共性动画可尝试使用一套Idle、Wave等动画片段,从而进一步降低动画资源的内存开销;

  • Unity引擎已经不再对老版本动画系统进行维护。

动画的优化

  • 动画模块的开销主要来自于MeshSkinning.Update(蒙皮网格每帧更新)和Animation.Update and Animator.Update(骨骼动画的更新开销)两个函数。

  • 研发团队在尽可能保证动画效果的同时,对模型的网格进行简化,建议尝试使用Unity Asset Store中的SimpleLOD插件来对骨骼蒙皮网格进行简化;

  • 关于MeshSkinning.Update, 一般来说该值的大小取决于蒙皮网格(Skinned Mesh)的面片数和骨骼数。所以如果该值过高,我们的建议是减面。同时我们建议开发团队勾选“GameObject.Optimize”,该方法是将fbx生成的GameObject的层级关系移除,使动画系统不用每帧再去更新这些骨骼节点(GameObject)的Transform,因此能一定程度上降低CPU开销,此优化选项默认是关闭的),该方法特别适合于在配置较低的手机上运用骨骼角色多的情况。
  • 开启Optimize GameObject默认情况下会将动画网格下的所有骨骼结点隐藏,但是,可以通过“Extra Transform to Expose”查看想通过脚本获取的骨骼结点,这样既可以提升该角色的动画模块性能,又可以达到获取某个关键结点的需求。
  • Optimize Game Objects对于老版本的Animation无效。

  • 使用SkinnedMeshRenderer.BakeMesh

  • 控制场景中具有Animator Controller组件的GameObject。

材质实例数量过多有何影响

  • Material的内存占用一般很小,所以大量的Material资源对于内存的压力其实很小的。
  • 它本身对于场景的切换时间是有影响的,即资源冗余得越多,切换场景时,UnloadUnusedAssets的开销越大,进而增加了场景的切换时间。
  • 会影响DrawCall的拼合。

物理

Profiler中会碰到Static Collider.Move(Expensive delay cost),有什么办法可以优化掉呢?

  • 建议给需要移动的静态Collider加上RigidBody并勾选is Kinematic复选框,从而将其变为动态碰撞体。

引起 Physics.Simulate 开销较大的几个因素

  • Rigidibody。
    • 该组件可使得游戏对象在物理系统的控制下运动。
    • 对于移动设备而言,建议Rigidibody数量控制在50以下。同时需要注意的是,大家常常会用Rigidbody组件配合CapsuleCollider,通过RigidBody.velocity来移动。这些会造成物理计算,特别是网格有很多Mesh Colider的时候,物理计算相当高。
    • 因此,建议尽量用Transform.Position代替物理计算。如果大家的地形是凹凸不平又要有重力的表示,也可以用Navmesh去做,它所引起的工作量在于烘焙Navmesh,并且尽可能地贴合地表 ,但是可以完全不用物理计算。
  • Contacts & Colider
    • Contacts数量为碰撞对(Contact Pair)数量。任意两个发生碰撞的碰撞体都会产生一个“碰撞对”。
    • 一般来说,Contacts数量越大,则表明碰撞物体的数量越多,即物理系统的CPU开销越大。
    • 碰撞体数量(静态碰撞体和动态碰撞体两者)均控制在100以下,当然越低越好。
  • PS:动态碰撞体是指带有RigidBody的Collider,而静态碰撞体指不带有RigidBody的Collider。

NGUI减少物理计算

  • 在NGUI界面打开后,往往会有Physics一下涨高的情况。这是因为NGUI为了实现点击事件的检测,在每个UI上都设有Rigidbody,所以当UI Widgets摆在同一深度并存在相互叠加的情况时,会造成较多不必要的Contacts。

定位到OntriggerXXX(如OntriggerEnter)消耗高

  • 这种情况一般是在脚本中触发了其他的逻辑调用,例如在主角被碰撞从而受到伤害时,创建一个伤害数字的UI,这些均有些实例化的逻辑计算,当然这些也会算到Physics开销中。

渲染

我用内建的Shader渲染场景,深度图里有内容。而用自己的Shader,取到的深度图什么都没有,都是1,什么原因导致的呢?我已经打开ZWrite了。

  • 4.x中是靠depth buffer直接获取或者通过shader replacement来实现。
  • 5.x后统一考ShadowCaster来实现。

Shader.Parse 和 Shader.CreateGpuProgram 到底是做什么的?他们什么时候执行?

  • Shader.Parse体现的是Shader的加载和解析, Shader.CreateGpuProgram 是将Shader传入GPU的一次提交,GPU驱动会对其进行编译,以适应于特定的设备或平台。在Unity 5.x版本中,Shader.Parse在Shader资源加载时进行执行,而 Shader.CreateGpuProgram在所在GameObject第一渲染时进行执行。