什么是 ScriptableObject?

ScriptableObject 是一个可序列化的Unity类,能够让你在脚本实例之外存储大量共享数据。ScriptableObject 可以避免复制大量数值从而减少内存。使用脚本化对象可以更轻松地管理更改和调试。不同于使用Prefab挂载脚本保存数据,使用时实例化多个。使用 ScriptableObject 存储数据,可通过引用从任何Prefab访问数据,而内存中只有一个数据副本。

如 MonoBehaviours 一样,ScriptableObject 也继承自 Unity Object。但不同于 MonoBehaviours,不能把 ScriptableObject 附加于 GameObject 上,相反而是当做 资产 存放与项目内。

使用Editor时,可在编辑时和运行时将数据保存到 ScriptableObjects,因为 ScriptableObjects 使用 Editor命名空间 和 Editor脚本。但是,在已发布的构建中,不能向 Scriptable Objects 保存数据,但可以在运行期间读取其中保存的数据。编辑器工具保存到 ScripableObjects 资产的数据会写入磁盘,因此在会话之间是持久的。

使用 ScriptableObject

要使用 ScriptableObject,需要先在Asset目录下创建一个脚本,然后让其继承 ScriptableObject类。 可以使用CreateAssetMenu 属性来快速创建自定义资源,比如:

  
using UnityEngine;

[CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/SpawnManagerScriptableObject", order = 1)]
public class SpawnManagerScriptableObject : ScriptableObject
{
    public string prefabName;

    public int numberOfPrefabsToCreate;
    public Vector3[] spawnPoints;
}

你现在可以通过 Assets > Create > ScriptableObjects > SpawnManagerScriptableObject 来创建该 ScriptableObject。

也可以通过脚本创建:

  
[MenuItem("Assets/Create SO")]
public static void CreateSO(){
    var asset = ScriptableObject.CreateInstance<SpawnManagerScriptableObject>();
    AssetDatabase.CreateAsset(asset, "Resources/ScriptableObjects/mySO.asset");
    AssetDatabase.SaveAssets();
    AssetDatabase.Refresh();
}

要在运行时使用SO的数据,只要在自定义类中引用该 SO 类型,并在Editor拖动赋值或通过脚本获得该 SO资产 即可。

项目工程的三大支柱

下面我们介绍一下使用 ScriptableObject 优化游戏结构的一些方法,在此之前,先了解一些基础理论知识。首先介绍下项目工程的三个支柱:

  • 模块化
    • 避免创建直接相互依赖的系统。例如,库存系统应该能够与游戏中的其他系统通信,但最好不要在它们之间创建硬引用,因为这会使得重组织系统的配置和关系变得很困难。
    • 从头开始创建场景:避免在场景之间存在瞬态数据。每当点击一个场景时,场景都应是干净独立的关闭和加载。这可以保证你的每个场景都有其他场景没有的唯一行为。
    • 设置Prefab,使其独立工作。尽可能地保证,拖入场景的每个Prefab其功能都只在其内部实现。这有助于大型团队进行源码控制,其中场景就是一堆Prefab组成的列表,而每个Prefab包含单独的功能。这样,您的大部分操作都处于Prefab级别,从而减少了场景中的冲突。
    • 让每个组件专注于只解决一个问题。这样可以更轻松地将多个组件拼凑在一起以构建新的组件。
  • 可编辑
    • 尽可能多利用数据驱动游戏:当你将游戏系统设计成好像 将数据作为指令处理的机器一样时,即使在游戏运行期间,也能高效地对游戏进行更改。
    • 如果尽可能将系统设置为模块化和基于组件的,系统将更容易进行编辑,即使对于美术和策划来说。如果策划能在游戏中拼凑东西,而无需提出明确功能需求,这在很大程度上要归功于实现了每个小组件只做一件事,那么就可以不同的方式组合这些组件,以找到新的游戏玩法或机制。
    • 团队可以在运行时更改游戏是非常重要的。运行时可对游戏进行的更改越多,就可越多地找到平衡和数值。如果还能够将运行时状态保存回来(比如 ScriptableObject),那就更好了。
  • 可调试
    • 这更像前面两个的子支柱。游戏模块化程度越高,就越容易测试其中的任何一个部分。也就是说,游戏越是可编辑,在Inspector视图中拥有的功能就越多,调试也就越容易。请确保你可以在Inspector中查看调试状态,并且实现一个功能时,在没有调试计划之前永远不要考虑去完成它。

Singleton 的利弊

Singleton 相信大家都不陌生了,应该是最常用也最简单的一种模式。我们来探讨一下它的利弊:

利:

  • 可以从任何地方获取任何东西
  • 持久化状态
  • 便于理解
  • 统一的模式
  • 便于“计划”

虽然优点不少,但滥用Singleton对项目结构来说是很不好的,Singleton 问题有:

  • 固定的连接,非模块化
  • 没有多态性
  • 不可测试
  • 依赖关系混乱
  • 单一实例

解决的办法:

  • 减少全局管理器的需求
  • 控制反转
    • 比如:依赖注入
    • 对象被赋予依赖关系
    • 单一责任原则

模块化数据

ScriptableObject 变量

先考虑一个例子,现在有一个 Enemy Prefab,它需要知道自身的移动速度,你可以直接在Prefab上修改其 MoveSpeed 属性,但是假如我们有20个 Enemy Prefab,每个都有一些区别但它们移动速度是一样的,那你要为每个都赋值一遍。
一个解决方法是通过一个 Singleton来获取: EnemyManager.Instance.MoveSpeed 。
但这个方法有上述我们说过的缺点: 这是硬连接;这个属性完全依赖于加载的 Manager。比如我们要测试敌人的速度,就要完全从头运行场景,等待 Manager 加载配置完成,然后拖入 Prefab 测试,而不能直接在一个测试场景快速的拖入Prefab直接测试。
这时候你可能会想到使用 ScriptableObject,创建一个 EnemyConfig 的 ScriptableObject,其中保存敌人的配置数据,20个Prefab都可以获取这个引用读取数据。但这也有一些问题: 会将敌人各部分属性都混到一起;限制扩展性和模块化。比如这时候有一个敌人是范围攻击,它需要 MinFireRange 和 MaxFireRange 两个属性,这是两个新属性,加入EnemyConfig的话会产生冗余,因为其他敌人并用不到,不加入的话又打破了模块化和扩展性。

下面引入 ScriptableObject 变量 的概念。

ScriptableObject 用于架构游戏的最简单的方法之一是,自包含的基于资源的变量。以下是FloatVariable的示例,但该变量也可扩展到任何其他可序列化类型。

  
[CreateAssetMenu]
public class FloatVariable : ScriptableObject
{
    public float Value;
}

使用该变量,团队中的每个人无论技术如何,都可以通过创建一个 新的 FloatVariable Asset 来定义新游戏变量。任何 MonoBehaviour 或 ScriptableObject 都可以使用 public FloatVariable(而不是 public float),以引用这个新的共享值。

更好的是,如果一个 MonoBehaviour 改变了 FloatVariable 的值,其他的M onoBehaviour 就可以看到这个改变。这在系统之间创建了一种消息传递层,从而不需要彼此引用。

一个示例情况就是,玩家的HP。游戏中有一个本地玩家,其HP值是名为 PlayerHP 的 FloatVariable。当玩家受伤,PlayerHP减少,治疗时增加。现在想象场景中有一个 生命指示条,实时更新显示玩家的HP。不需更改任何代码,它可以轻松地指向不同的对象,如PlayerMP变量。生命指示条对场景种的玩家一无所知,它只是一直读取玩家写入的一个变量。

一旦这样设置,我们就可以简单的增加更多观察 PlayerHP 的系统。如音乐系统可以在PlayerHP过低时改变BGM,敌人可能改变攻击策略当检测到玩家血量过低。其中的关键就是,Player 不需要向这些系统发送消息,而这些系统也不需要知道 Player GameObject的信息。你也可以在游戏运行时,在Inspector修改 PlayerHP 的值来进行测试。

编辑FloatVariable的值时,最好是将数据复制到运行时值,而不更改存储在磁盘上的ScriptableObject的值。如果这样做,MonoBehaviour应访问RuntimeValue,以防止编辑了保存到磁盘的初始值。

  
[CreateAssetMenu]
public class FloatVariable : ScriptableObject, ISerializationCallbackReceiver
{
    public float InitialValue;
    [NonSerialized]
    public float RuntimeValue;

    public void OnAfterDeserialize()
    {
        RuntimeValue = InitialValue;
    }
    
    public void OnBeforeSerialize(){}
}


事件

事件架构可在彼此不了解的系统之间发送消息来帮助模块化代码。它们允许事件对状态的更改作出响应,而无需在Update循环中进行持续监控。

Unity有 UnityEvent,可以方便的实现事件系统,我们直接在Inspecor就可以对其赋值。但很明显它也有很多问题:固定的绑定(如一个按钮事件硬引用它要响应的对象);有限的序列化;垃圾分配。所以我们要使用自己的Events。

你可以通过 ScriptableObject 构建一个 Event 系统,包含两个部分:GameEvent ScriptableObject 和 GameEventListener MonoBehaviour。可以在项目中创建任意数量的GameEvent来表示可以发送的重要消息。GameEventListener等待特定GameEvent被引发,并通过调用UnityEvent作出响应(这不是一个真正的事件,而是序列化函数调用)。

  
public class GameEvent : ScriptableObject
{
    private readonly List<GameEventListener> listeners = new List<GameEventListener>();

    public void Raise()
    {
        for (int i = listeners.Count; i >= 0; i--)
        {
            listeners[i].OnEventRaised();
        }
    }

    public void RegisterListener(GameEventListener listener)
    {
        listeners.Add(listener);
    }

    public void UnregisterListener(GameEventListener listener)
    {
        listeners.Remove(listener);
    }
}

public class GameEventListener : MonoBehaviour
{
    public GameEvent Event;
    public UnityEvent Response;

    private void OnEnable()
    {
        Event.RegisterListener(this);
    }

    private void OnDisable()
    {
        Event.UnregisterListener(this);
    }

    public void OnEventRaised()
    {
        Response.Invoke();
    }
}

一个实例是,在游戏中出来玩家死亡。在这一点上,很多执行都可对其更改,但很难确定具体在何处对所有逻辑进行编码。Player脚本是否应该触发 游戏结束UI、改变音乐?如果玩家还处于活着状态,敌人是否应该每一帧进行检查?事件系统可让我们避免类似这种有问题的依赖关系。

如果玩家死亡,Player脚本会在 OnPlayerDied事件 上调用 Raise。Player脚本不需要知道哪些系统与之相关,因为它只是一个广播。游戏结束UI 侦听 OnPlayerDied事件 并开始显示动画,摄像机脚本可以侦听它并开始淡化为黑色,音乐系统可以作出响应来改变BGM。我们也可以让每个敌人侦听 OnPlayerDied,触发讽刺动画或更改状态机到Idle。

这种模式可以非常容易地为玩家死亡添加新的响应。此外,还可以通过从Inspector中的某些测试代码或按钮事件调用Raise,轻松地对玩家死亡响应进行测试。

以Asset为基础的系统

ScriptableObject 不一定只是数据。你可以考虑把任何你在 MonoBehaviour 中实现的系统转移到 ScriptableObject 中实现。相比于在 DontDestroyOnLoad 的 MonoBehaviour 上实现 InventoryManager,试试将其放在 ScriptableObject 上。

因为它不绑定于场景,所以不需要Transform也不需要Update函数,就可以在各个场景间维持状态,也不需要任何场景初始化设置。相比于 Singleton,当一个脚本在需要获取Inventory,只要使用一个public参数引用 Inventory Object 即可。将使得更换 测试Inventory 还是 教程Inventory 都变得很容易。

可以想像一个引用 Inventory系统 的Player脚本。当玩家生成时,它可以向库存询问所有拥有的对象并生成任何装备。装备UI 也可以引用这个 Inventory系统 并循环遍历物品以确定要绘制的内容。

Github实例

https://github.com/roboryantron/Unite2017

https://github.com/DanielEverland/ScriptableObject-Architecture

https://github.com/Begounet/scriptableobjects-oriented

1. 资产、对象和序列化

1.1 资产和对象

了解Unity如何识别和序列化数据,对于正确地管理数据非常重要,而其中的关键就是 Assets 和 UnityEngine.Objects 的区别。

  • Asset 是存储在硬盘,位于Unity项目Asset目录下的文件。 如 贴图、模型、音频文件等。
  • Object 是一组序列化的数据,是共同描述某一资源的特定实例。其描述的可以是Unity使用的任意类型资源,所有Objects都是 UnityEngine.Object 的子类。

虽然大部分的Object类型都是内置的,但是有两类特殊类型:

  • ScriptableObject:开发者可实现自定义数据类型。这些类型可以通过Unity进行本地序列化和反序列化,并在Unity Editor中进行操作。
  • MonoBehavior:提供了一个链接到MonoScript的封装器。MonoScript 是一种内部数据类型,用来在特定程序集和命名空间内保存对特定脚本类的引用。MonoScript 不包含任何实际的可执行代码。

资产和对象之间存在一对多关系;也就是说,任何给定的资产文件都包含一个或多个对象。

1.2 内部对象引用

所有对象都可以包含对其他对象的引用。如一个材质对象包含一或多个对贴图文件的引用。
序列化时,这些引用由两部分独立数据构成: File GUID 和 Local ID。GUID 标识存放目标资源的资产文件。本地唯一的ID标识资产文件中的每个对象,因为资产文件可能包含多个对象。
GUID存储于 .meta文件中。打开 .meta文件开头即可看到:

  
fileFormatVersion: 2
guid: 49f36dba4ddaa42399594d6630b143f6
...

用文本编辑器打开某个文件如一个material,可以看到:

  
--- !u!21 &2100000
Material:
  serializedVersion: 6
  ...

其中 &开头的 &2100000 就是Local ID。
假设有一个Cube使用了这个Material,并制作了一个Prefab,用文本编辑器打开该Prefab文件,可以看到:

  
MeshRenderer:
  ....  
  m_Materials:
  - {fileID: 2100000, guid: 49f36dba4ddaa42399594d6630b143f6, type: 2}
  ....

1.3 为什么使用GUID和本地ID

使用这种机制,可以提升健壮性,并提供一个灵活且与平台无关的工作流程。

文件的GUID提供文件特定位置的抽象表达,只要有与特定文件关联的GUID,那么这个文件在磁盘上的位置就无关紧要了,该文件可以自由移动,而无需更新引用该文件的对象。
而由于资产文件可能包含多个 UnityEngine.Object 资源,因此,需要本地ID来明确区分每个不同的对象。

如果GUID与资产文件的关联丢失,那么对该资产文件中的所有对象的引用也将丢失,所以要保证 .meta 文件与资产同名并在同一文件夹内。

Unity编辑器拥有 已知文件GUIDs 和 资源文件路径 的映射。如果Unity编辑器在打开状态下,meta文件0丢失,但是资源路径没有更改,编辑器可以确保保留相同的GUID。
如果在Untiy编辑器关闭时丢失了meta文件,或者资产的路径发生变化,而meta文件没有和资产一起移动,那么所有对该资产中对象的引用都会丢失。

1.4 复合资产和导入器

非原生类型的资产必须导入Unity才能使用,这是通过资产导入器完成。可以使用API AssetImporter 在脚本手动调用。

导入的结果会产生一个或多个UnityEngine.Objects。如导入一张PNG图集,会生成一个父资产的多个自资产,每个对象共享一个文件GUID,因为它们的源数据储存在相同的资产文件中,而使用本地ID可以将其区分。

导入会把资源转换为适合平台的格式,可能还会包括许多耗时的操作,例如纹理压缩,因为这个过程很长,所以导入的资源会缓存在项目根目录下的 Library/metadta 文件夹下(以文件GUID前两位命名),无需在下次启动时重新导入。

1.5 序列化和实例化

虽然GUID 和 Local ID 系统健壮性强,但 GUID 对比比较慢,所以Unity 内部会维护一个缓存(PersistentManager),将 GUID 和 Local ID 转换为简单唯一的整数,即 Instance IDs,当新对象注册到缓存时以简单的单调递增方式分配ID。

缓存会维护给定的Instance ID、GUID、本地ID,和内存中对象的实例(如果有的话)之间的映射。这允许UnityEngine.Objects健壮的维护彼此的引用,解析Instance ID引用可以快速返回由Instance ID表示的加载对象,如果目标对象尚未加载,则可以将文件GUID和本地ID解析为对象的源数据,从而使Unity能够及时加载对象。

启动时,Instance ID 缓存会使用项目马上需要的 Objects 及 Resources 文件夹内 Objects 的所有数据初始化。当运行时有新资源加入(如代码创建贴图)及 AB 加载对象时,会在缓存中增加记录。只有当 对特定文件 GUID 和 Local ID 提供使用权的AB被卸载时,Instance ID 记录会被删除。相应的映射也被删除以节省内存,如果重新加载 AB,则将重新为其加载的每个对象创建新的 Instance ID。

在特定平台上,一些事件可能导致 Objects 从内存移除。如 iOS上,当APP挂起 图形资源会被从显存卸载。如果加载这些 Objects 的 AB已经被卸载,那么Unity将不能重新为这些 Objects 加载源数据。任何对这些 Objects 现有的引用也将失效。这会导致场景中出现网格不可见或紫色纹理。

1.6 MonoScripts

需要理解 MonoBehaviour 包含对 MonoScript 的引用,并且 Monoscript 仅包含了定位特定脚本类所需的信息。这两种 Ojbect 类型都不包含可执行脚本类。

MonoScript包含三个字符串:程序集名,类名 和 命名空间。

当构建项目时,Unity会将Assets文件夹中的所有分散的脚本文件,编译为Mono程序集。这些程序集以及预构建的程序集DLL文件都包含在Unity最终包中。他们也是MonoScript引用的程序集,与其他资源不同,包含在Unity中的所有程序集都在引用程序启动时加载(StreamingAsset下的程序集除外)。

MonoScript Object 的存在,让 AssetBundle(或 Scene、prefab)中的 MonoBehaviour 组件 并不包含可执行代码。这样保证了 不同的 MonoBehavior 引用特定的共享类,即使这些 MonoBehavior 位于不同的AssetBundle中。

1.7 资源生命周期

一个 Object 会自动加载,当:

  • 映射到该对象的 Instance ID 被间接引用
  • 该对象目前还没有加载到内存中
  • 对象的源数据可以被定位

Objects 也可以使用脚本显示加载。当 Object 被加载,Unity会尝试通过将每个引用的文件GUID 和 本地ID 转换为 Instance ID 来解析任何引用。如果两个条件为真,则一个 Ojbect 的 Instance ID 第一次被引用时,其将被按需加载:

  • Instance ID 引用的对象当前未加载
  • Instance ID 有有效的 文件GUID 和 本地ID 注册在缓存中

这个情况通常发生在引用本身被加载并解析之后的很短时间内。

如果 文件GUID 和 本地ID 没有 Instance ID,或者如果一个 已卸载的对象的 Instance ID 引用了无效的 文件GUID 和 本地ID,那么引用被保留,但实际的对象不会被加载。这在Unity编辑器中显示为 Missing,在运行的时候,或在场景视图中,Missing对象将以不同的方式可见,例如,网格丢失,会不可见,纹理丢失会显示紫色等。

在三种特定情况下,Object 会被卸载:

  • 当 Unused Asset 被清理时 Ojbects 会被自动卸载。这个过程会在以下情况自动触发,场景强制切换(非递增地调用 SceneManager.LoadScene),或是 脚本调用 Resources.UnloadUnusedAssets。这个过程只卸载未被引用的 Objects,没有 Mono变量 或 其他 Objects 引用它。另外,被标志为 HideFlags.DontUnloadUnusedAsset 和 HideFlags.HideAndDontSave 的对象也不会被卸载。
  • 来自于Resources的 Objects 可以被 Resources.UnloadAsset 显示地卸载。这些 Objects 的 Instance ID 仍然有效,并且依然包含有效的文件GUID和本地ID。如果有变量或其他对象引用了 Resources.UnloadAsset 卸载的对象,一旦这些引用被间接引用,对象将再次被加载。
  • 来自于 AssetBundles 的 Ojbects 通过调用 AssetBundle.Unload(true) 会马上被自动卸载。这会使对象 Instance ID 的 文件GUID 和 本地ID 变为无效,任何引用卸载的 Objects 的引用将变为 missing。

如果调用了AssetBundle.Unload(false),那么来自这个 AssetBundle 的 Objects 不会被卸载,但Unity会使其 Instance ID 的 文件GUID 和 本地ID 无效。如果这些对象从内存中卸载,但是有其他对象对它的引用,那么Unity是无法重新加载这些对象的(最常见的情况是,在运行时将对象从内存中移除而未被卸载时,Unity会失去对其图形上下文的控制权。 当移动应用程序被暂停并且该应用程序被强制置于后台时,可能会发生这种情况。 在这种情况下,移动操作系统通常会从GPU内存中清除所有图形资源。 当应用程序返回到前景时,Unity必须在场景渲染恢复之前将所有需要的纹理,着色器和网格重新加载到GPU)。

1.8 加载大层次结构

在序列化 Unity GameObjects 的层次结构时,如在Prefab序列化期间,整个层次结构将完全序列化。也就是说,层次中的每个 GameObject 和 Component 将分别在序列化数据中描述。这对加载和实例化 GameObject 的层次结构所需的时间有很大影响。

在创建任何GameObject层次结构时,CPU时间用于几种不同的方式:

  • 读取源数据(来自存储设备,来自AssetBundle,来自另一个GameObject等)
  • 在新的Transform之间设置父子关系
  • 实例化新的GameObjects和组件
  • 在主线程中唤醒新的GameObjects和组件

后三种时间成本通常是不变的,无论该层次结构是从现有结构克隆的,还是从存储加载的。但是,读取源数据的时间会随着 组件和GameObjects 序列化到层次结构中的数量的增加而线性增加,并且还要乘以数据源的加载速度。

在目前所有的平台上,从内存中的其他位置读取数据比从存储设备加载数据要快得多。此外,存储介质的性能表现在不同平台之间差异很大。因此,在低速存储设备的平台上加载Prefab时,从存储中读取Prefab序列化数据的时间可能会大大超过实例化Prefab的时间。也就是说,加载操作的成本与存储 I / O 时间有关。

如上所述,当序列化一个整体的Prefab时,每个GameObject和组件的数据都会被单独序列化,这可能会使数据重复。比如,一个有30个相同元素的UI界面将会序列化相同元素30次,产生大量大量二进制数据。加载时,这30个重复元素中,每一个元素的GameObjects和组件数据必须在传输到新实例化对象之前从磁盘读取,增加读取时间。

对于大层次结构,应该将其模块化,分别实例化,然后运行时再组装起来。

Unity 5.4注意:Unity 5.4改变了内存中transforms的排列。每个根transforms的整个子层次结构都存储在紧凑,连续的内存区域中。在实例化新的GameObjects时,这些新的GameObjects会立即重新排列到另一个父层次结构中,请考虑使用带有parent参数的 GameObject.Instantiate 重载变体。使用此重载可以避免为新的GameObject分配根transform 结构。在测试中,这加快了实例化操作所需的时间约5-10%。

2. Resources文件夹

2.1 Resources 系统最佳实践

不要使用它!
这个建议主要有以下几个原因:

  • 使用Resources文件夹会使细粒度的内存管理变得更加困难。
  • Resources 文件夹的使用不当会增加程序启动时间和构建时间。
  • 随着Resources文件夹数量的增加,管理其中的资源将会变得更困难。
  • Resources 系统降低了项目传递自定义内容到特定平台的能力,并且无法增量更新内容。
  • AssetBundle变体是Unity用于按设备调整内容的主要工具。

2.2 正确使用 Resources 系统

在不妨碍良好的开发实践前提下,Resources系统在两种用途下是有益的:

  • Resources文件夹的简易性可以用来快速构建项目原型。但是,当项目进入全面生产阶段时,应取消使用Resources文件夹。
  • Resources文件夹在一些琐碎的案例下也有用处,如果其内容:
  • 在整个游戏的生命周期都需要使用
  • 内存压力不大
  • 不需要补丁更新,或者在不同平台之间不需要单独处理
  • 需要构建最小包

第二种情况的例子包括用于保存 Prefab 的 MonoBehaviour单例,或者包含第三方配置数据(比如Facebook应用程序ID)的 ScriptableObjects。

2.3 Resources序列化

在构建工程的时候,名为Resources的所有文件夹中的 Asset 和 Object 都合并为一个序列化文件。这个文件还包含 metadata 和 索引信息,与 AssetBundle 类似。此索引包含了一个序列化的查找树,用于将给定 Object 的名字解析为适当的 文件GUID和本地ID,它也用于在序列化文件中根据特定字节偏移定位对象。

在大多数平台上,查找数据结构都是使用平衡搜索树,其查找时间以 0(n log(n)) 的速率增长,随着Resources文件夹中对象数量的增加,这种增长也会导致索引的加载时间增长超过线性增长。

此操作是不可跳过的,发生在应用启动的时候,这个时候应用会显示splash界面,根据观察,初始化一个包含10000个资产的资源系统在低端移动设备上会耗时很多秒,就算是Resources文件夹中包含的大多数对象不会加载到第一个场景中。

3. AssetBundle 基本原理

3.1 综述

AssetBundle系统提供了一种方法来 存储一个或多个文件到一个Unity可以索引和序列化的档案格式。AssetBundles 是用于在应用安装后,为非代码内容提供加载或更新的主要工具。 这允许开发人员提交更小的应用程序包,减少运行时内存压力,并有选择地加载针对用户设备优化的内容。

3.2 AssetBundle 设计

总的来说,一个AssetBundle由两部分组成:头部和数据段。

头部包含有关AssetBundle的信息,例如标识符,压缩类型和清单(manifest)。清单是一个由 Object名 作为 key 的查找表。每个记录都提供一个字节索引,用于指示在AssetBundle数据段中在哪里找到给定的 Object。在大多数平台上,这个查找表被实现为一个平衡搜索树。Windows和OSX派生的平台(包括iOS)采用红黑树。因此,构建清单所需的时间将随着AssetBundle内资产数量的增长而线性增加。

数据段包含通过序列化AssetBundle中资产而生成的原始数据。如果将LZMA指定为压缩方案,则所有序列化资产的完整字节数组将被压缩。如果指定LZ4,每个资源的字节将被单独压缩。如果不使用压缩,数据段将保持为原始字节流。

在Unity 5.3之前,对象无法在AssetBundle中单独压缩。因此,如果用5.3版本的Unity版本从压缩的AssetBundle中读取一个或多个对象,那么Unity必须解压缩整个AssetBundle。通常,Unity会缓存AssetBundle的解压的副本,以提高同一AssetBundle上后续加载请求的加载性能。

3.3 加载AssetBundle

AssetBundles可以通过四个不同的API加载。 这四个API的具体行为取决于两个条件:

  • AssetBundle是否是LZMA压缩,LZ4压缩或未压缩
  • AssetBundle正在加载的平台

这些API是:

  • AssetBundle.LoadFromMemory(可选异步)
  • AssetBundle.LoadFromFile(可选异步)
  • UnityWebRequest 的 DownloadHandlerAssetBundle
  • WWW.LoadFromCacheOrDownload(在Unity 5.6或更早版本上)

3.3.1 AssetBundle.LoadFromMemory(Async)

Unity的建议不要使用这个API。

AssetBundle.LoadFromMemoryAsync 从托管代码的字节数组(C#中的byte[])中加载一个AssetBundle。它总会把 来自托管代码字节数组的源数据 拷贝到 新分配的连续内存块中。如果AssetBundle是LZMA压缩的,它将在复制时解压缩AssetBundle。未压缩的和LZ4压缩的AssetBundles将被逐字复制。

此API所需的最大内存量至少为AssetBundle的两倍:由API创建的一个副本位于本地内存中,以及托管字节数组中的一个副本用于传递给API。因此,通过此API创建的AssetBundle在加载资产时,将在内存中复制三次:一次位于托管代码字节数组中,一次位于AssetBundle的本地内存副本中,第三次位于GPU或系统内存中用于资产本身。

在Unity 5.3.3之前,这个API被称为AssetBundle.CreateFromMemory。它的功能没有改变。

3.3.2. AssetBundle.LoadFromFile(Async)

AssetBundle.LoadFromFile 是一个高效的API,用来从本地存储(如硬盘或SD卡)加载未压缩或LZ4压缩的AssetBundle。

在桌面平台,控制台和移动平台上,此API只会加载AssetBundle的头部,并将剩余的数据保留在磁盘上。AssetBundle的 Object 将在以下情况按需加载,调用了加载方法(例如AssetBundle.Load)或 Object 的InstanceID 被引用。在这种情况下不会消耗额外的内存。在Unity编辑器中,API会将整个AssetBundle加载到内存中,就好像从磁盘读取字节并使用 AssetBundle.LoadFromMemoryAsync 一样。如果在Unity编辑器中对项目进行性能分析,此API可能导致在AssetBundle加载期间出现内存峰值。但不影响实际设备性能,在采取补救措施之前应该先在实际设备上对这些峰值进行重新测试。

注意:在Unity 5.3或更早版本的Android设备上,尝试从StreamingAssets路径加载AssetBundles时,此API将失败。 Unity 5.4中已解决该问题。
在Unity 5.3之前,这个API被称为AssetBundle.CreateFromFile,其功能尚未更改。

3.3.3. AssetBundleDownloadHandler

UnityWebRequest API 允许开发人员准确指定Unity如何处理下载数据,并消除不必要的内存消耗。使用UnityWebRequest下载AssetBundle的最简单方法是调用 UnityWebRequestAssetBundle.GetAssetBundle。

在此我们重点关注 DownloadHandlerAssetBundle。这个类使用工作线程,它会将下载的数据 流式传输到固定大小的缓冲区,然后根据 Download Handler 的配置将缓冲的数据缓冲到 临时存储 或 AssetBundle缓存。所有这些操作都发生在本地代码中,消除了扩展托管堆的风险。此外,Download Handler 不会保留下载字节的 native code 副本,从而进一步减少下载AssetBundle的内存开销。

LZMA压缩的AssetBundles将在下载过程中进行解压缩并使用LZ4压缩进行缓存。通过设置 Caching.CompressionEnabled 可以更改此行为。

下载完成后,Download Handler 的 assetBundle属性 可以获取下载的AssetBundle,就像已经对 下载了的AssetBundle调用了 AssetBundle.LoadFromFile 一样。

如果将缓存信息提供给 UnityWebRequest 对象,并且所请求的 AssetBundle 已经存在于Unity的缓存中,则AssetBundle将立即变为可用,并且此API将以与AssetBundle.LoadFromFile 相同的方式运行。

在Unity 5.6之前,UnityWebRequest 系统使用固定的工作线程池和内部作业系统来防止过度的并发下载。线程池的大小不可配置。在Unity 5.6中,这些保护措施已被删除,以适应更多现代硬件,并允许更快地访问HTTP响应代码和头部。

3.3.4. WWW.LoadFromCacheOrDownload

注意:从Unity 2017.1开始,WWW.LoadFromCacheOrDownload只是包装UnityWebRequest。因此,使用Unity 2017.1或更高版本的开发人员应迁移到UnityWebRequest。 WWW.LoadFromCacheOrDownload将在未来版本中弃用。

以下信息适用于Unity 5.6或更早版本。

WWW.LoadFromCacheOrDownload 允许从远程服务器和本地存储加载 Objects。文件可以通过 file:// URL 的方式从本地存储中加载。如果AssetBundle存在于Unity缓存中,则此API的行为与AssetBundle.LoadFromFile完全相同。

如果AssetBundle尚未被缓存,则 WWW.LoadFromCacheOrDownload 将从其源位置读取AssetBundle。如果AssetBundle是压缩的,它将使用工作线程解压缩并写入缓存。否则,它将通过工作线程直接被写入缓存。一旦AssetBundle被缓存,WWW.LoadFromCacheOrDownload 将从已缓存且解压缩的AssetBundle中加载头信息。然后,对加载的 AssetBundle 采取与 AssetBundle.LoadFromFile 相同的行为。此缓存共享于 WWW.LoadFromCacheOrDownload 和 UnityWebRequest 之间。通过其中一个API下载的AssetBundle也可通过另一个API获得。

虽然数据将被解压缩并通过固定大小写入缓存,但 WWW对象 将在本地内存中保留AssetBundle字节的完整副本。 这个额外副本是为了 WWW.bytes 属性。由于在 WWW对象 中缓存AssetBundle字节的内存开销,AssetBundles应该保持很小 - 最多几兆字节。

与UnityWebRequest不同,每次调用此API都会产生一个新的工作线程。因此,在移动设备等内存有限的平台上,每次只能使用此API下载一个AssetBundle,以避免内存高峰。多次调用此API时,请小心创建过多的线程。如果需要下载超过5个AssetBundle,请使用脚本代码创建和管理下载队列,以确保同时运行的AssetBundle下载不要太多。

3.3.5 建议

一般情况下,应尽可能使用 AssetBundle.LoadFromFile。此API 在 速度、磁盘使用和运行时内存使用等方面都是最高效的。

对于必须下载或补丁 AssetBundles 的项目,强烈建议对于使用 Unity 5.3或之后版本的项目使用 UnityWebRequest,对于使用Unity 5.2或更早版本的项目,则强烈建议使用WWW.LoadFromCacheOrDownload。

使用 UnityWebRequest 或 WWW.LoadFromCacheOrDownload 时,请确保下载器代码在加载AssetBundle后正确调用 Dispose。或者,使用C#的 using 表达式,以确保便捷的处置 WWW 或UnityWebRequest。

对于需要 独特缓存或下载 的大型工程,可以考虑使用自定义下载器。任何自定义下载程序都应与 AssetBundle.LoadFromFile 兼容。

3.4 从AssetBundle中加载资产

从AB中加载 Object 可以使用三个不同的API:

  • LoadAsset (LoadAssetAsync)
  • LoadAllAssets (LoadAllAssetsAsync)
  • LoadAssetWithSubAssets (LoadAssetWithSubAssetsAsync)

这些API的同步版本总是比异步版本快,至少快一帧。
异步加载将每帧加载多个对象,取决于它们的时间片限制。

加载多个独立的 UnityEngine.Objects 时应该使用 LoadAllAssets。最好只在需要加载AssetBundle中的大部分或全部对象时才能使用它。相比于其他两个API,LoadAllAssets比多次单独调用 LoadAssets 稍快。因此,如果要加载的Asset数很大,但需要一次性加载的AssetBundle的比例不到66%,请考虑将AssetBundle拆分为多个较小的包并使用 LoadAllAssets。

LoadAssetWithSubAssets 应该用于加载复合资源,其中包含多个嵌入的 Objects,如 嵌入了动画的 FBX模型,或包括多个精灵的图集。如果需要加载的对象全部来自同一个资产,但与许多其他不相关的对象一起存储在AssetBundle中,则使用此API。

对于任何其他情况,请使用LoadAsset或LoadAssetAsync。

3.4.1 底层加载细节

UnityEngine.Object 加载是在主线程之外执行的:从存储中读取 Object数据 是在工作线程上。 任何不涉及Unity系统的线程敏感部分(脚本,图形)的内容,都在工作线程上进行转换。 例如,从网格创建VBOs,纹理解压缩等。

从Unity 5.3开始,对象加载已经并行化。多个对象在工作线程上被反序列化,处理和集成。 当一个对象完成加载时,它的Awake回调将被调用,并且该对象将在下一帧期间可用于Unity Engine的其余部分。

同步的 AssetBundle.Load 方法将暂停主线程,直到对象加载完成。 它们还会对对象加载进行时间片分割,以便对象集成不会占用超过一定数量的毫秒帧时间。毫秒数由属性Application.backgroundLoadingPriority 设置:

  • ThreadPriority.High: 每帧最多50ms
  • ThreadPriority.Normal: 每帧最多10ms
  • ThreadPriority.BelowNormal: 每帧最多4ms
  • ThreadPriority.Low: 每帧最多2ms

从Unity 5.2开始,多个对象被加载,直到达到对象加载的帧时间限制。 假设所有其他因素相同,由于发出异步调用和引擎可用对象之间的最小一帧延迟,资产加载API的异步变体的完成时间总是比对应的同步版本要长。

3.4.2 AssetBundle依赖

AssetBundles 之间的依赖关系使用两个不同的API自动跟踪,具体取决于运行时环境。在Unity编辑器中,可以通过 AssetDatabase API 查询 AssetBundle 依赖关系。AssetBundle的分配和依赖关系,可以通过 AssetImporter API 访问和更改。在运行时,Unity提供了一个可选的API,通过基于 ScriptableObject 的 AssetBundleManifest API,来加载 AssetBundle 构建期间生成的依赖信息。

当一个或多个父AssetBundle的UnityEngin.Objects引用另一个或多个AssetBundle的UnityEngin.Objects时,AssetBundle依赖于另一个AssetBundle。上面已经介绍了内部引用的一些内容。

如 1.5 序列化和实例化 所述,AssetBundles 充当源数据的作用,此源数据是AssetBundle中每个对象的 FileGUID&LocalID 标识的源数据的源数据。

因为一个对象在其 Instance ID 被第一次引用时被加载,并且因为一个对象在其AssetBundle 被加载时 被分配了有效的 Instance ID,所以 AssetBundles 加载的顺序并不重要。相反,在加载Object之前,应该先加载包含 该Object所有依赖的 所有AssetBundles。加载父级AssetBundle时,Unity不会尝试自动加载任何子AssetBundles。

例子: 假设 材质A 引用了 贴图B。材质A 被打包到 AssetBundle 1 中,贴图B 被打包到 AssetBundle 2 中。

加载AB2 必须先于 从 AB1 中加载 Mat A。 这不表示 AB2 必须先于 AB1 加载,或者 Texture B 必须从 AB2 中显示加载。优先加载 AB2 对于从 AB1 中加载 Mat A 已经足够了。

但是,在加载 AB1 时,Unity并不会自动加载 AB2。需要在脚本中手动完成。 对此,前一篇文章 已经介绍过。

3.4.3 AssetBundle manifests

当使用 BuildPipeline.BuildAssetBundles API 执行 AssetBundle 构建管线时,Unity会序列化一个对象,其中包含每个A ssetBundle的依赖信息。此数据存储在一个独立的 AssetBundle 中,其中包含一个单独的 AssetBundleManifest 对象。

此资源将存储在一个 与存放AssetBundles父目录同名的 AssetBundle 中。该AssetBundle可以像其他任何AssetBundle一样加载,缓存和卸载。

AssetBundleManifest 对象提供 GetAllAssetBundles API 来列出与清单并发构建的所有AssetBundles,以及两个方法来查询特定AssetBundle的依赖关系:

请注意,这两个API都会分配字符串数组。相应地,它们只应该谨慎使用,而不应该在应用程序生命周期的性能敏感部分中使用。

3.4.4 建议

在大多情况下,在玩家进入程序的性能关键区(如主场景或世界)之前,最好加载尽可能多的所需对象。 这在移动平台上尤其重要,因为访问本地存储的速度很慢,而且在运行时加载和卸载对象的内存流失可能会触发GC。

4 AssetBundle使用模式

4.1 管理已加载的资产

在内存敏感的环境下小心控制加载对象的大小和数量非常重要。当对象从当前激活的场景中移除时,Unity不会自动将其卸载。资产清理只在特定时间触发,当然也可以手动触发。

AssetBundles本身必须小心管理。由本地存储上的文件(在Unity缓存中或通过AssetBundle.LoadFromFile 加载)支持的 AssetBundle 具有最小的内存开销(这里应该是说,如果AB来自本地,加载AB只会加载头文件,相较于从网络上下载的AB),很少超过几万字节。但是,如果存在大量的 AssetBundles,则此项开销仍是一个大问题。

由于大多数项目允许用户重新体验内容(例如重新开始),因此知道何时加载或卸载AssetBundle非常重要。如果AssetBundle卸载不当,可能会导致内存中的对象重复。在某些情况下,不当卸载 AB 还会导致不可测行为,例如导致纹理丢失。

管理 资产 和 AssetBundles 时最要了解的是,调用 AssetBundle.Unload 时,unloadAllLoadedObjects 参数设置为 true 和 false 的差异。

该API会卸载已加载的AssetBundle的头信息。 unloadAllLoadedObjects 参数决定是否也卸载从此AssetBundle实例化的所有 Objects。如果设置为true,那么从该AssetBundle实例化的所有对象也将立即被卸载 - 即使它们当前正在活动场景中使用。

例如,假设 材质M 是从 AssetBundle AB 加载的,并且 假设M 当前处于活动场景中。

如果调用了 AssetBundle.Unload(true),那么M将从场景中移除,销毁并卸载。 但是,如果调用 AssetBundle.Unload(false),则AB的头信息将被卸载,但M仍将保留在场景中并且仍然有效。调用 AssetBundle.Unload(false) 会中断M和AB之间的链接。 如果稍后再次加载AB,则AB中对象的新副本将被加载到内存中。

如果稍后再次加载AB,则AssetBundle头信息的新副本将被加载。但是,M并未从AB的这个新副本中加载。Unity不会在 AB的新副本 和 M 之间建立任何关联。

如果调用 AssetBundle.LoadAsset() 来重新加载M,则Unity不会将M的旧副本解释为AB中数据的一个实例。 因此,Unity将加载M的新副本,并且在场景中将有两个相同的M副本。

对于大多数项目来说,这种行为是不可取的。大多数项目应该使用 AssetBundle.Unload(true) 并采用一种方法来确保对象不重复。两种常用方法是:

  • 在应用生命周期中有 特定时间点(如在关卡切换或转场读条)卸载 AssetBundles。这是更简单和最常见的选择。
  • 为每个对象维护一个引用计数,仅在所有实例对象都不使用的时候,卸载AssetBundle。这允许程序卸载并重新加载单个对象而不重复消耗内存。

如果应用程序必须使用 AssetBundle.Unload(false),那么单个对象只能通过两种方式卸载:

  • 对不需要的对象,在场景和代码中同时消除对其所有引用。然后,调用Resources.UnloadUnusedAssets。
  • 非叠加地加载场景。这将销毁当前场景中的所有对象并自动调用Resources.UnloadUnusedAssets。

如果一个项目有明确定义的时间点,使用户可以等待对象加载和卸载,那么在这些时间点应该尽量多地 卸载对象 并 加载新的对象。

最简单的方法是将项目的离散块打包到场景中,然后将这些场景及其所有依赖项构建到AssetBundles中。然后,程序可以进入 “Loading” 场景,完全卸载包含旧场景的AssetBundle,然后加载包含新场景的AssetBundle。

虽然这是最简单的流程,但有些项目需要更复杂的AssetBundle管理。由于每个项目都不同,因此没有通用的AssetBundle设计模式。

在决定如何将 Objects 分组到 AssetBundles 时,如果一些 Objects 必须同时加载或更新,通常最好先将它们捆绑到AssetBundles中。例如,一个角色扮演游戏,每个地图和过场动画可按场景分组为AssetBundles,但在大多数场景中都需要一些对象。AssetBundles 可以被构建来提供肖像,UI 以及 角色模型和纹理。后者这些对象和资产可以被分组到第二组 AssetBundles 中,这些 AssetBundles 在启动时加载并在应用的整个生命周期内保持加载状态。

如果Unity必须在一个AssetBundle卸载后从其中重新加载 Object,则可能会出现另一个问题。在这种情况下,重新加载将失败,对象将作为(missing)对象出现在Unity编辑器中。

这主要发生在Unity丢失并恢复对其图形上下文的控制时,例如,当移动应用程序被暂停或用户锁定其PC时。在这种情况下,Unity必须将纹理和着色器重新上传到GPU。如果这些资产的源AssetBundle不可用,则应用程序将以洋红色呈现场景中的对象。

4.2 分发

有两种基本方法把一个项目的 AssetBundle 分发给客户端:与项目同时安装或在安装后下载。选择哪种方式主要取决于平台。移动项目通常选择后一种,主机和PC项目通常选择前一种。
正确的程序框架允许在安装后将新内容或修补的内容作为补丁更新,而不用关心初始的AssetBundles。

4.2.1 跟随项目发布

将AssetBundles与项目一起发布是最简单的发布方式,因为它不需要额外的下载管理代码。 跟随项目发布主要有两个原因:

  • 减少项目构建时间并允许更简单的迭代开发。 如果这些AssetBundles不需要与应用程序本身分开更新,那么AssetBundles可以存储在 StreamingAsset 文件夹中从而包含在应用中。
  • 发布可更新内容的初始修正版。通常这样做是为了节省终端用户在初次安装后的时间,或者作为以后打补丁的基础。 SteamingAsset 对于这种情况并不是最理想的方案。 但是,如果不愿意编写一个自定义下载和缓存的系统,那么可以使用这种方法,从StreamingAssets 将可更新内容的初始修正版加载到Unity缓存中。

4.2.1.1 StreamingAsset

让应用在安装时 包含任何类型的内容(包括AssetBundles),最简单方法是在构建项目之前将内容构建到 /Assets/StreamingAssets 文件夹中。构建时包含在StreamingAssets文件夹中的任何内容都将被复制到最终的应用程序中。

通过属性 Application.streamingAssetsPath 可以获得本地存储上的 StreamingAssets 文件夹完整路径。然后在大多数平台上,可通过 AssetBundle.LoadFromFile 加载AssetBundles。

Android开发人员:在Android上,StreamingAssets文件夹中的资源文件会存储到APK中,并且如果它们是压缩的,可能需要更多时间加载,因为存储在APK中的文件可能使用不同的存储算法。使用的算法可能会因Unity版本而异。您可以使用7-zip等解压工具打开APK以确定这些文件是否被压缩。如果被压缩,AssetBundle.LoadFromFile() 将执行得更慢。在这种情况下,作为变通方案您可以使用 UnityWebRequest.GetAssetBundle 来检索已经缓存的版本。通过使用UnityWebRequest,AssetBundle将在第一次运行时解压缩并缓存,从而使后续执行速度更快。请注意,这将需要更多的存储空间,因为AssetBundle将被复制到缓存中。或者,您可以导出您的Gradle项目,并在构建时向您的AssetBundles添加扩展名。然后,您可以编辑 build.gradle 文件并将该扩展名添加到noCompress 部分。完成后,您应该可以使用AssetBundle.LoadFromFile() 而无需消耗解压缩的性能成本。

注意:StreamingAsset在某些平台上不是可写文件。如果安装后需要更新项目的AssetBundles,则可以使用 WWW.LoadFromCacheOrDownload 或编写自定义下载程序。

4.2.2 下载后安装

将AssetBundles部署到移动设备的最佳方法是在安装后下载。这也允许不强制用户更新应用而在安装后更新内容。许多平台上,应用二进制文件必须经过昂贵且冗长的复审过程。因此,开发一个良好的安装后下载系统至关重要。

交付AssetBundles的最简单方法是将它们放置在Web服务器上并通过 UnityWebRequest 部署。Unity会自动将下载的 AssetBundles 缓存在本地存储上。如果下载的AssetBundle是LZMA压缩的,则AssetBundle将以未压缩或重新压缩为LZ4(取决于Caching.compressionEnabled设置)的形式存储在缓存中,以便将来加载更快。如果下载的捆绑包压缩了LZ4,则AssetBundle将被压缩存储。如果缓存填满,Unity将从缓存中删除最近最少使用的AssetBundle。

通常建议尽可能使用 UnityWebRequest,或者仅在使用Unity 5.2或更早版本时使用 WWW.LoadFromCacheOrDownload。对于特定项目,如果内置API的内存消耗、缓存行为或性能影响很大,或者项目必须运行于特定平台,那么只能自定制下载系统了。

使用 UnityWebRequest 或 WWW.LoadFromCacheOrDownload 可能不理想的情况示例:

  • 当需要对AssetBundle缓存进行细粒度控制时
  • 当项目需要实施自定义压缩策略时
  • 当项目希望使用平台特定的API来满足某些要求时,例如需要在后台传输数据。
    • 示例:使用iOS的 Background Tasks API 在后台下载数据。
  • 如果AssetBundles必须通过SSL下载,但是Unity没有正确的SSL支持(如PC)。

4.2.3 内置缓存

Unity有一个内置的AssetBundle缓存系统,可用于缓存通过 UnityWebRequest API 下载的 AssetBundles,该API包含一个接受AssetBundle版本号作为参数的重载。此版本号不存储在AssetBundle内部,并且不是由AssetBundle系统生成。

缓存系统持续跟踪传递给 UnityWebRequest 的最新版本号。当使用一个版本号调用此API,缓存系统通过比较版本号来检查是否存在已缓存的AssetBundle。如果这些数字匹配,系统将加载缓存的AssetBundle。如果版本号不匹配,或没有缓存的AssetBundle,Unity将下载一个新副本。这个新副本将与新版本号相关联。

缓存系统中的 AssetBundles 仅由其文件名来标识,而不是由其下载的完整URL标识。这意味着 一个AssetBundle的同名文件 可以存储在多个不同的位置,例如CDN。只要文件名称相同,缓存系统就会将它们识别为相同的AssetBundle。

每个应用确定 将版本号分配给 AssetBundles 的适当策略,并将这些版本号传递给UnityWebRequest。这些数字可能来自各种唯一标识符,例如CRC值。请注意,虽然AssetBundleManifest.GetAssetBundleHash() 也可用于此目的,但我们不建议使用此功能进行版本控制,因为它仅提供估算值,而不是真正的Hash值计算。

在Unity 2017.1以后,缓存API已经扩展到提供更精细的控制,允许开发人员从多个缓存中选择一个活动缓存。以前的Unity版本只能修改 Caching.expirationDelay 和Caching.maximumAvailableDiskSpace 来删除缓存的资源(Unity 2017.1中这些属性保留在 Cache 类中)。

expirationDelay 是自动删除AssetBundle之前必须经过的最小秒数。如果在此期间没有访问AssetBundle,它将被自动删除。

maximumAvailableDiskSpace 指定本地存储空间量(以字节为单位),这个量是指删除已超过 expirationDelay时间的AssetBundle之前可以使用的空间量。达到限制时,Unity将删除最近最少打开的缓存中的AssetBundle(或通过 Caching.MarkAsUsed 标记为已使用)。 Unity会删除缓存的AssetBundles,直到有足够的空间完成新的下载为止。

4.2.3.1 缓存填充

由于AssetBundles使用文件名作为标识,所以可以使随用程序附带的AssetBundles“准备”缓存。 为此,请将每个AssetBundle的初始或基本版本存储在 /Assets/StreamingAssets/ 中。

第一次运行应用程序时,可以通过从Application.streamingAssetsPath 加载AssetBundles 来填充缓存。 此后,应用程序可以正常调用UnityWebRequest(UnityWebRequest也可用于最初从StreamingAssets路径加载AssetBundles)。

4.2.4 自定义下载器

编写自定义下载程序可以让应用程序完全控制 AssetBundles 的下载、解压缩和存储方式。由于所涉及的工程量是很复杂的,所以只推荐大型团队使用此方法。编写自定义下载器时有四个主要考虑事项:

  • 下载机制
  • 存储位置
  • 压缩类型
  • 补丁

4.2.4.1 下载

对于大多数应用程序,HTTP是下载AssetBundles最简单的方法。然而,实现基于HTTP的下载程序并不简单。自定义下载程序必须避免 过多的内存分配、过多的线程使用 和 过多的线程唤醒。Unity的 WWW 类不适合的原因在上面 3.3.4 已介绍。

在编写自定义下载器时,有三个选择:

  • C#的 HttpWebRequest 和 WebClient 类
  • 自定义原生插件
  • Asset Store 包

4.2.4.1.1 c#类

如果应用程序不需要使用 HTTPS/SSL,那么C#的 WebClient 类提供了下载AssetBundles最简单的机制。它能够将任何文件直接异步下载到本地存储,而无需过多管理内存分配。

要使用 WebClient 下载AssetBundle,请分配该类的一个实例,并将 AssetBundle的下载URL 和 目标路径 传递给它。如果需要对请求参数进行更多控制,可以使用C#的 HttpWebRequest 类编写下载程序:

  • 从 HttpWebResponse.GetResponseStream 获取一个字节流。
  • 在堆上分配一个固定大小的字节缓冲区。
  • 从响应流(reponse)中读入缓冲区。
  • 使用C#的 File.IO API 或任何其他流式IO系统将缓冲区写入磁盘。

4.2.4.1.2 Asset Store包

很多插件包提供了原生代码的实现,以通过HTTP,HTTPS和其他协议下载文件。 在为Unity编写自定义本机代码插件之前,建议您先查找可用的Asset Store包。

4.2.4.1.3 自定义原生插件

编写自定义原生插件是在Unity中下载数据最耗时,但最灵活的方法。由于编程时间花费高且技术风险高,只有在没有其他方法能够满足应用程序的要求时才推荐此方法。例如,如果应用程序必须在Unity中没有C#SSL支持的平台上使用SSL通信,则可能需要定制原生插件。

自定义原生插件通常会封装目标平台的原生下载API。比如,iOS上的 NSURLConnection 和 Android上的 java.net.HttpURLConnection。请查阅每个平台的文档以获取有关使用这些API的详细信息。

4.2.4.2 存储

在所有平台上,Application.persistentDataPath 指向一个可写的位置,适合用来存储应用运行时要保持的数据。在编写自定义下载器时,强烈建议使用 Application.persistentDataPath 的子目录来存储下载的数据。

Application.streamingAssetPath 不可写,对于AssetBundle缓存来说是一个糟糕的选择。 streamingAssetsPath的位置包括:

  • OSX:在.app包内; 不可写
  • Windows:在安装目录中(例如Program Files); 通常不可写
  • iOS:在.ipa包内; 不可写
  • Android:在.apk文件中; 不可写

4.3 资产分配策略

如何将项目资产划分为AssetBundles并不简单。经常采用简单的策略,比如将所有对象都各自生成一个AssetBundle 或 仅使用一个AssetBundle,但这些解决方案具有明显的缺点。相关内容前一篇文章已经介绍过。

4.4 常见陷阱

本节介绍使用AssetBundles时常见的几个问题。

4.4.1 资产重复

Unity 5的AssetBundle系统,当Objects构建到AssetBundle中时会查找对象的所有依赖关系。此依赖项信息用于确定将包含在AssetBundle中的对象集。

显式分配给AssetBundle的对象将仅构建到该AssetBundle中。当 Object 的 AssetImporter 的 assetBundleName属性 设置为非空字符串时,对象将被“显式分配”。这可以通过在 Inspector中选择一个AssetBundle进行设置 或 从脚本中完成。

也可以将对象定义为 AssetBundle building map 的一部分 来将其分配给AssetBundle,方法是,该map要与重载的 BuildPipeline.BuildAssetBundles() 函数一起使用,该函数接受一组AssetBundleBuild。

任何未在AssetBundle中显式分配的对象,若有 一个或多个对象 引用此未标记对象,该对象都将包含在所有含有这些引用它的对象的AssetBundles中。

例如,如果将两个不同的对象分配给两个不同的AssetBundles,但都具有对公共依赖项Object的引用,则该依赖Object将被复制到两个AssetBundles中。重复的依赖关系也将被实例化,这意味着依赖对象的两个副本将被视为具有不同标识符的不同对象。这将增加应用程序AssetBundles的总大小。如果应用程序加载它的父项,这也会导致Object的两个不同副本被加载到内存中。

有几种方法可以解决这个问题:

  • 确保构建到不同AssetBundles中的对象不共享依赖关系。任何共享依赖关系的对象都可以放置到同一AssetBundle中,而无需复制它们的依赖项。
    • 对于具有许多共享依赖的项目,此方法通常不可行。为了方便和高效的使用,它将导致频繁地重建和重新下载 一个庞大的 AssetBundles。
  • 分段AssetBundles,以便不会同时加载共享依赖项的两个AssetBundles。
    • 此方法可能适用于某些类型的项目,例如基于关卡的游戏。但是,它仍然会不必要地增加项目的AssetBundles的大小,并增加构建时间和加载时间。
  • 确保所有依赖项资产都生成一个它们自己的AssetBundles中。这完全消除了重复资产的风险,但也带来了复杂性。应用必须在AssetBundles之间跟踪依赖关系,并确保在调用任何 AssetBundle.LoadAsset API 之前加载了正确的AssetBundles。

对象依赖关系是 通过 UnityEditor名称空间 中的 AssetDatabase API 来跟踪。正如命名空间所示,这个API仅在Unity编辑器中可用,而不是在运行时。 AssetDatabase.GetDependencies 可用于查找特定对象或资产的所有直接依赖关系。请注意,这些依赖关系可能有其自己的依赖关系。此外,AssetImporter API 可用于查询分配有任何指定对象的AssetBundle。

通过组合 AssetDatabase 和 AssetImporter API,可以编写一个编辑器脚本,以确保将所有 AssetBundle 的直接或间接依赖关系都生成AssetBundle,或者没有两个 AssetBundles 共享尚未分配给AssetBundle的依赖项。由于重复资产会产生的内存成本,建议所有项目都有这样的脚本。

4.4.2 图集重复

任何自动生成的图集将被分配给一个AssetBundle,该AB包含生成自图集的Sprite对象。如果Sprite对象被分配给多个AssetBundles,那么图集将不会只分配给一个AssetBundle,而是多个。如果Sprite对象未分配给AssetBundle,则图集也不会被分配给AssetBundle。

为了确保图集没有重复,请检查在同一图集中的所有Sprite都分配到同一个AssetBundle。

请注意,在 Unity 5.2.2p3 及更早版本中,自动生成的图集将永远不会分配给AssetBundle。因此,它们将被包含在使用了该Sprite的任何AssetBundles中,以及任何引用其组成Sprite的AssetBundles中。由于这个问题,强烈建议所有使用Unity的sprite打包程序的Unity 5项目升级到Unity 5.2.2p4,5.3或任何更新版本的Unity。

4.4.3 Android纹理

由于Android环境的设备碎片化严重,通常需要将纹理压缩为多种不同的格式。虽然所有Android设备都支持ETC1,但ETC1不支持带alpha通道的纹理。如果应用程序不需要OpenGL ES 2支持,则最简单解决方法是使用所有 Android OpenGL ES 3 设备支持的ETC2。

大多数应用程序需要在不支持ETC2的旧设备上发布。解决此问题的一种方法是使用 Unity 5 的 AssetBundle变体(有关其他选项的详细信息,请参阅Unity的Android优化指南)。

要使用AssetBundle变体,所有无法使用ETC1进行压缩的纹理必须分离为仅包含纹理的AssetBundles。接下来,使用特定于供应商的纹理压缩格式(如DXT5,PVRTC和ATITC)创建相应的这些AssetBundles变体以支持Android中不支持ETC2的设备。对于每个AssetBundle变体,将包含的纹理的 TextureImporter 设置更改为适合Variant的压缩格式。

在运行时,可以使用 SystemInfo.SupportsTextureFormat API 检测对不同纹理压缩格式的支持。并使用此信息来选择和加载包含以受支持格式压缩的纹理的AssetBundle变体。

有关Android纹理压缩格式的更多信息可以在 这里 找到。

4.4.4 iOS文件句柄过度使用

当前版本Unity不受此问题影响。

在Unity 5.3.2p2之前的版本中,Unity会在AssetBundle加载的整个过程中持有一个打开的文件句柄。这在大多数平台上都不是问题。 但是,iOS将进程可以同时打开的文件句柄数限制为255,如果加载AssetBundle会导致超出限制,则加载调用将失败,并显示 “Too Many Open File Handles” 错误。

对于尝试将内容分成数百或数千个AssetBundles的项目,这是一个常见问题。

对于无法升级到补丁版本的Unity的项目,临时解决方案是:

  • 通过合并相关的AssetBundles来减少使用的AssetBundles的数量。
  • 使用AssetBundle.Unload(false) 关闭AssetBundle的文件句柄,并手动管理加载的对象的生命周期。

4.5 AssetBundle 变体(Variants)

AssetBundle系统的一个关键特性是引入了AssetBundle变体。变体的目的是允许应用调整其内容以更好地适应其运行时环境。当 加载对象 和 解析 Instance ID 引用时,变体允许 不同的 AssetBundle文件中的 不同 UnityEngine.Objects 显示为 “相同” 的对象。概念上讲,它允许两个 UnityEngine.Objects 显示为共享同样的 File GUID & Local ID,并标识实际的 UnityEngine.Object 以字符串变体ID加载。

这个系统有两个主要用例:

  • 变体简化了适用于指定平台的AssetBundles加载。
    • 示例:构建系统可能会创建一个AssetBundle,其中包含适用于 DirectX11 Windows 版本的高分辨率纹理和复杂着色器,以及适用于 Android 的具有较低保真度内容的第二个AssetBundle。在运行时,项目的资源加载代码可以为其平台加载相应的AssetBundle Variant,并且传递到 AssetBundle.Load API 的对象名称不需要更改。
  • 变体允许应用程序在同一平台但不同硬件上,加载不同的内容。
    • 这是支持各种移动设备的关键。在现实的应用程序中,iPhone 4 不能像最新的iPhone一样显示相同的清晰度。
    • 在Android上,AssetBundle 变体可用于解决设备间屏幕纵横比和DPI之间巨大的分裂问题。

4.5.1 变体 限制

AssetBundle 变体系统的一个关键限制是它需要使用不同的资产来构建变体。即使这些资产之间的唯一差异是其导入设置,也适用此限制。如果内置到 变体A 和 变体B 中的纹理之间,唯一区别就是在Unity纹理导入器中选择的特定纹理压缩算法,变体A 和 变体B 依然是完全不同的资产。这意味着变体A和变体B是磁盘上的两个文件。

这种限制使大型项目的管理复杂化,因为特定资产的多个副本必须保存在源代码管理中。当开发人员希望更改资产的内容时,必须更新资产的所有变体。这个问题没有内置的解决方法。

大多数团队都有他们自己的AssetBundle变体形式。这是通过使用明确定义的文件后缀名 来构建AssetBundles 完成的,以便识别给定AssetBundle所代表的特定变体。在构建这些AssetBundles时,通过代码更改包含的资产的导入器设置。一些开发者已经扩展了他们的定制系统,以便能够改变 Prefab 上组件 的参数。

4.6 压缩还是不压缩?

是否压缩 AssetBundles 需要一些重要的考虑因素,其中包括:

  • 加载时间:从本地存储或本地缓存加载时,未压缩的AssetBundles比加载压缩的AssetBundles要快得多。
  • 构建时间:在压缩文件时,LZMA和LZ4非常缓慢,Unity Editor按顺序处理AssetBundles。具有大量AssetBundles的项目将花费大量的时间压缩它们。
  • 应用程序大小:如果AssetBundles包含在应用程序中发布,则压缩它们将减少应用程序的总大小。或者,可以在安装后下载AssetBundles。
  • 内存使用情况:在Unity 5.3之前,所有Unity的解压缩机制都要求在解压缩之前将整个压缩的AssetBundle加载到内存中。如果内存使用率很重要,请使用未压缩或LZ4压缩的AssetBundles。
  • 下载时间:如果AssetBundles很大,或者用户处于带宽受限的环境中,例如在低速或流量上下载,那么可能需要压缩。如果只有几兆字节的数据通过高速连接传送到PC,则可能会忽略压缩。

4.6.1 Crunch压缩

如果Bundles中,主要由使用Crunch压缩算法的DXT压缩纹理组成,则应该被构建为未压缩。

4.7 AssetBundles和WebGL

由于Unity的WebGL导出选项当前不支持辅助线程,因此WebGL项目中的所有AssetBundle解压缩和加载都必须在主线程上进行。AssetBundles的下载委托给浏览器使用 XMLHttpRequest,XMLHttpRequest在Unity的主线程上执行。一旦下载完,压缩的AssetBundles将在Unity的主线程上解压,因此根据包的大小将不同程度地延迟Unity内容的执行。

Unity建议最好使用更小的 AssetBundle,以避免出现性能问题。与使用大 AssetBundle 相比,此方法还具有更高的内存效率。Unity WebGL只支持LZ4压缩和未压缩的 AssetBundle,但是,可以对Unity生成的 bundles 使用 gzip/brotli 压缩。在这种情况下,您需要相应地配置Web服务器,以便文件在下载时被浏览器解压。有关更多详细信息,请参见 here。

如果您使用的是Unity 5.5或更高版本,请考虑避免使用LZMA压缩您的AssetBundles,而使用LZ4。Unity 5.6删除了LZMA作为WebGL平台的压缩选项。

虽然之前已经写过AssetBundle的内容了,不过都比较久了,最近打算再把相关内容总结一下,主要都是Unity官方文档和Best Practice的内容。

介绍

  1. AssetBundle是一个压缩包,包含模型、材质、Prefab、音效、整个场景,可以在游戏运行的时候载入。
  2. AssetBundle有互相依赖的关系。
  3. AssetBundle可以使用LZMA、LZ4的压缩演算法,减少资源包大小,增加网路传输的速度。
  4. AssetBundles用于可下载内容(DLC),减少安装包大小,针对行动平台下载优化后的资源,可减少运行时的内存压力。

AssetBundle是什么

  1. 它是存在于硬盘上的文档,可以称为一个压缩包。压缩包内包含有资料夹、各种档案,主要分为两种类:Serialized File、Resource File。
  2. Serialized File:可序列化文档,资源会被打碎后统一放入一个单一文档中。
  3. Resource File:资源文档,比如声音、图片材质会被单独保存,方便快速加载。
  4. 我们可以通过代码从特定的AssetBundle中,加载所有我们当初加入到AssetBundle中的内容。


普通 Asset Bundle 结构

Streamed Scene Asset Bundle 结构

AssetBundle 工作流

  1. 指定资源到AssetBundles
  2. 创建AssetBundles
  3. 上传AssetBundles到其他服务器
  4. 加载AssetBundles与内部资源

指定资源

选择需要打包的资源,右下角指定 AssetBundle name 和 Variant name(可选)。AssetBundle names支持文件夹格式,使用 / 分隔。 也可以使用脚本指定:

  
[MenuItem("Tools/BuildAssetbundle")]
static void BuildAssetbundle(){
    string outPath = Path.Combine(Application.dataPath, "StreamingAssets");
    //如果目录已经存在删除它

    if (Directory.Exists (outPath)) {
        Directory.Delete (outPath,true);
    }
    Directory.CreateDirectory (outPath);
    
    List<AssetBundleBuild> builds = new List<AssetBundleBuild> ();
    //设置bundle名,和多少资源打在同一个Bundle内

    builds.Add (new AssetBundleBuild (){assetBundleName="Cube.unity3d",assetNames =new string[]{"Assets/Cube1.prefab","Assets/Cube2.prefab"}});
    builds.Add (new AssetBundleBuild (){assetBundleName="CubeMatarial.unity3d",assetNames =new string[]{"Assets/CubeMatarial.mat"}});
    //构建Assetbundle

    BuildPipeline.BuildAssetBundles (outPath,builds.ToArray(),BuildAssetBundleOptions.ChunkBasedCompression, BuildTarget.StandaloneOSX);
    //刷新

    AssetDatabase.Refresh ();
}


分组策略

错误的分组策略: 有一种很简单的分组策略方式,比如将资源分别放置在单独的AssetBundle中,或将所有资源都打包于一个AssetBundle中,但这样的分组策略具有明显的缺点:
拥有太少的AssetBundles:1. 增加运行时内存使用量。2. 增加加载时间。3. 需要更大的下载量。
拥有太多的AssetBundles:1. 增加构建时间。2. 可能会使开发复杂化。3. 增加总下载时间。

好的分组策略:

  1. 逻辑实体分组
  2. 资源类型分组
  3. 并发内容分组

逻辑实体分组

  1. 对于一个UI界面,其包含的所有贴图与布局数据。
  2. 对于一个角色,其包含的角色模型与动画。
  3. 多个场景所共享的场景碎片(材质与模型)资源打一个包。

逻辑实体分组对于可下载内容(DLC)非常理想,因为通过这种方式可以分离各个内容,可以对单个实体进行更改,而不需要下载其他未更改的资产。
能够正确实施这一策略的最大诀窍是,开发人员必须熟悉项目何时何地使用每种资产。

类型分组

按照类型分组,比如所有音频一个包,所有模型一个包,然后材质一个包。
这是为多平台使用构建AB的较好策略。 例如,如果您的音频压缩在Windows和Mac平台之间相同,则可以将所有音频打包到AssetBundles中。而shader倾向于使用更多特定平台的选项进行编译,因此您为Mac构建的shader包可能会不能在windows上重用。
这种方法非常适合让您的AssetBundles与更多的游戏版本兼容,毕竟随着游戏版本更新,更多修改的是代码脚本与prefab,而如同纹理压缩格式和设置一类的修改频率低,可都放于同一个AB。

并发内容分组

这一策略是把同一时间将会加载和使用的资源打包到一起。
可以按照关卡分类,一个关卡所需要的资源包括角色、贴图、声音打包成一个AssetBundle。也可以按照场景分类,一个场景所需的资源打包在一起。

策略总结

请注意,一个项目应该根据需要混合上述的三种策略。
例如,项目可能决定将其用于不同平台的用户界面(UI)元素分组到他们自己的Platform-UI特定包中,但另外按照关卡/场景对其交互式内容进行详细的分组。
一些应注意的:

  1. 把经常更新的资源放在同一个包,跟不经常更新的包分离。
  2. 把需要同时加载的放在同一个包。
  3. 把其他多个包里的对象依赖的资源,分离出来放在一个单独的包。
  4. 有些资源不可能同时加载,应分离出来,如标准解析度与高解析度的资源。
  5. 如果有一个包只有50%的内容经常加载,应该要进行分割。
  6. 把小的AssetBundle(少于5到10个资源)合并成同一个包。
  7. 如果同一个资源有两个版本,可以考虑使用后缀名来区分。

依赖关系实例:两个prefab都依赖了同一份材质和和贴图文件。如果两个prefab,分别打包,会得到两个AB大小都为500KB(假设)。但是如果将它们的材质和贴图单独打一个包,再把两个prefab分别打包,即三个包,会得到:材质贴图包450KB,两个prefab的包 600 byte。即两个prefab会自动依赖材质贴图。

构建AssetBundles

打包 Editor 中所有指定了的AB:
public static AssetBundleManifest BuildAssetBundles(string outputPath, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

从给定的 building map 打包:
public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

BuildAssetBundleOptions

BuildAssetBundleOptions.None:
使用LZMA算法压缩,压缩的包更小,但是加载时间更长。使用之前需要全部解压缩。
一旦包被解压缩,它将使用LZ4在磁盘上重新压缩并储存到本地端,所以使用资源的时候不需要全部解压缩。

BuildAssetBundleOptions.UncompressedAssetBundle:
不压缩,容量大,但加载时间最快。

BuildAssetBundleOptions.ChunkBasedCompression:
使用LZ4算法压缩,容量比LZMA压缩后大,但好处是使用资源时不用全部解压缩,只要解压缩所需的『Chunk』部分即可,因此又被称为Chunk-Based演算法。

如果使用LZ4算法压缩,加载速度与不加缩的方法一样快,但容量比不压缩还小。

Manifest 文件

对于生成的每个包(包括附加的Manifest包),都会生成关联的清单文件。 清单文件可以用任何文本编辑器打开,并包含诸如循环冗余校验(CRC)数据和依赖性数据等信息。 Manifest文件是以YAML格式储存,可以从Assets栏位看见该AssetBundle内部储存了哪些资源。Dependencies则会显示是否有依赖其他的AssetBundle。

  
ManifestFileVersion: 0
CRC: 2422268106
Hashes:
  AssetFileHash:
    serializedVersion: 2
    Hash: 8b6db55a2344f068cf8a9be0a662ba15
  TypeTreeHash:
    serializedVersion: 2
    Hash: 37ad974993dbaa77485dd2a0c38f347a
HashAppended: 0
ClassTypes:
- Class: 91
  Script: {instanceID: 0}
Assets:
  Asset_0: Assets/Mecanim/StateMachine.controller
Dependencies: {}
  • CRC:循环冗余校验。
  • AssetFileHash:AssetBundle中所有资产文档的Hash,只用来做增量创建时的检查。
  • TypeTreeHash:AssetBundle中所有类型的Hash,只用来做增量创建时的检查。
  • ClassTypes:AssetBundle中包含的所有类型,这些数据用来得到一个新的单独的Hash。当typetree做增量构建检测。
  • Assets:所有在AssetBundle中的资产的名称。
  • Dependencies:AssetBundle所依赖的其它AssetBundle的名字。

这个清单文档只是用来做增量构建的,非运行时必须。

Dependencies还有一个作用,就是当AssetBundle因分组策略的关系,将部分『共享的资源』另外打包成一个AssetBundle,这时就需要载入这个有Dependencies的AssetBundle。从AssetBundle.manifest文件可以看见所有AssetBundle与Dependencies。

例子:Bundle1中的Material引用了Bundle2中的Texture: 在从Bundle 1加载Material之前,您需要将Bundle 2加载到内存中。 加载Bundle 1和Bundle 2的顺序无关紧要,重要的是在从Bundle 1加载Material之前加载Bundle 2。

本地使用AssetBundles

根据加载平台和压缩方式的不同,有很多API可以加载AB:

  • AssetBundle.LoadFromMemoryAsync
  • AssetBundle.LoadFromFile
  • WWW.LoadfromCacheOrDownload
  • UnityWebRequest’s DownloadHandlerAssetBundle (Unity 5.3 or newer)

AssetBundle.LoadFromMemoryAsync

以AB数据的 bytes数组 为参数。
如果包是LZMA压缩的,它将在加载时对AssetBundle进行解压缩。LZ4压缩包以压缩状态加载。

从内存加载AssetBundle,比方说从网络上下载的文件是bytes数组,可以直接使用LoadFromMemoryAsync转换成AssetBundle。Unity官方不建议使用此API,原理上至少会占用三倍AssetBundle的容量于内存内。

AssetBundle.LoadFromFile

对于从本地加载未压缩AB时很高效。
如果包是未压缩的或块(LZ4)压缩的,LoadFromFile将直接从磁盘加载包。
如果包是完全压缩(LZMA)的将首先解压缩包,然后再将其加载到内存中。

从本地加载AssetBundle。如AssetBundle为LZ4压缩格式,则只会加载 AssetBundle 的档头,其余的数据仍留存在硬盘上,将会基于「按需求 (on-demand)」的模式来加载。

*注意: Android设备上,Unity5.3及之前版本,使用此API 从 Streaming 加载会出错,因为那个路径是压缩的.jar文件。Unity5.4 及之后版本可正常使用。

WWW.LoadFromCacheOrDownload (将抛弃)

从远程服务器或本地下载AssetBundle后,在本地缓存并载入。
如果AB是压缩的,会有一个工作线程进行解压缩并写入缓存。一旦压缩并缓存完,将像调用AssetBundle.LoadFromFile 一样加载。

*注意:从Unity 2017.1开始,此方法包装自UnityWebRequest。 推荐使用UnityWebRequest。
与UnityWebRequest不同,每次调用此API都会产生一个新的工作线程。因此,在移动设备等内存有限的平台上,每次只能使用此API下载一个AssetBundle,以避免内存高峰。如果需要下载超过5个AssetBundle,请使用代码创建和管理下载队列,以确保只有少量AssetBundle下载正在同时运行。

UnityWebRequest

首先,需要使用 UnityWebRequest 创建Web请求。 返回请求后,将请求对象传递给DownloadHandlerAssetBundle.GetContent(UnityWebRequest)。 此GetContent调用将返回您的AssetBundle对象。
下载AB后,您还可以在DownloadHandlerAssetBundle类上使用assetBundle属性,以使用AssetBundle.LoadFromFile的效率加载AssetBundle。
使用DownloadHandlerAssetBundle下载时,下载的数据会传输到固定大小的缓冲区,然后根据不同的配置方式,决定将缓冲的数据放到暂存空间或AssetBundle缓存空间。 以上操作都以native-code形式进行,消除了扩展heap的风险。

  
IEnumerator Start()
{
    string url = "https://website.com/assetbundle";
    using (var uwr = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET))
    {
        uwr.downloadHandler = new DownloadHandlerAssetBundle(url, 0);
        yield return uwr.SendWebRequest();
        AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(uwr);
    }
}

需要注意,通过UnityWebRequest下载的 AB 并不会保存到本地缓存。你要根据需要自己管理下载的AB,可通过 uwr.downloadHandler.data 获得 byte 数组,再使用 File.WriteAllBytes 写入本地。

从 AssetBundle 加载资源

通用代码

  
// 单个

T objectFromBundle = bundleObject.LoadAsset<T>(assetName);
// 所有

Unity.Object[] objectArray = loadedAssetBundle.LoadAllAssets();

异步

  
AssetBundleRequest request = loadedAssetBundleObject.LoadAssetAsync<GameObject>(assetName);
yield return request;
var loadedAsset = request.asset;


加载 AssetBundle Manifests

加载 Manifests 很有用,尤其对于处理依赖关系。 要获得可用的AssetBundleManifest对象,需要加载附加的AssetBundle(与所在的文件夹同名)并从中加载 AssetBundleManifest类型 的对象。 然后你就可以通过 manifest 获得 AssetBundles 的信息,如依赖内容等。

  
AssetBundle assetBundle = AssetBundle.LoadFromFile(manifestFilePath);
AssetBundleManifest manifest = assetBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
string[] dependencies = manifest.GetAllDependencies("assetBundle"); //Pass the name of the bundle you want the dependencies for.

foreach(string dependency in dependencies)
{
    AssetBundle.LoadFromFile(Path.Combine(assetBundlePath, dependency));
}   


管理加载的 AB

如果将游戏对象从场景中Destroy,Unity也不会自动卸载AssetBundle,即便你后面都不使用,已加载的AssetBundle仍占用内存。
除非你更换场景(Scene),更换的时候Unity会自动Destroy所有场景中的游戏对象,并接着调用Resources.UnloadUnusedAssets,这个函数会将没有用到的Asset从内存中卸载。

AB管理最重要的就是理解何时调用 AssetBundle.Unload(bool),以及该传递true还是false。该参数表示 是否还卸载从此AssetBundle实例化的所有对象。

AssetBundle.Unload(true) 会卸载所有从AB加载的 GameObjects (及其依赖)。但是并不包括拷贝的GO,比如 Instantiated 的GO,因为它们已经不属于AB。

对于一个加载于AB的材质M,如果调用 AB.Unload(true) ,当前场景所有M的实例都会被Unload并销毁。如果调用 AB.Unload(false) ,将会断开M实例和AB的连接,当再次加载AB并调用 AB.LoadAsset()后,并不会重新连接M和新的材质,而是会生成两份材质的拷贝。

Unity官方並不建議使用AssetBundle.Unload(false),因為管理不當的話,有可能導致場景中有多份以上的相同Asset。

大多数项目应该使用AssetBundle.Unload(true)。 两种常用方法是:

  • 在整个生命周期中有明确使用Unload的时机,例如在关卡之间或在加载期间卸载临时的AssetBundles。
  • 对每个对象进行引用计数,仅当对象都没有任何引用时才卸载AssetBundles,这允许卸载后重新加载对象而不会造成内存重复的问题。

如果必须使用AssetBundle.Unload(false),那么对象只能通过两种方式卸载:

  • 在场景和代码中消除对不需要的对象的所有引用后,调用Resources.UnloadUnusedAssets。
  • 不使用Additive方式加载新场景。 Unity将销毁当前场景中的所有对象并自动调用Resources.UnloadUnusedAssets。

Resources 和 AssetBundle 无缝切换

有时候开发初期使用Resources,后期发布时要换成 AB,就很麻烦。可以封装一个类,无缝切换二者。使用 scriptableObject 关联资源和AB的引用关系。

资源描述 ScriptableObject:

  
[System.Serializable]
public class BundleList : ScriptableObject {    
   public List<BundleData> bundleDatas = new List<BundleData>();

   //保存每个res路径对应的Bundle路径

   [System.Serializable]
   public class BundleData
   {
      public string resPath = string.Empty;
      public string bundlePath = string.Empty;
   }
}

构建AB:

  
public class BuildAB  
{
   [MenuItem("Tools/BuildAssetbundle")]
   static void BuildAssetbundle()
   {
      string outPath = Path.Combine(Application.dataPath, "StreamingAssets");
      
      if (Directory.Exists(outPath)) Directory.Delete(outPath, true);
      Directory.CreateDirectory(outPath);

      List<AssetBundleBuild> builds = new List<AssetBundleBuild>
      {
         new AssetBundleBuild()
         {
            assetBundleName = "Cube.unity3d",
            assetNames = new[] {"Assets/Resources/Cube1.prefab", "Assets/Resources/Cube2.prefab"}
         }
      };

      //构建Assetbundle

      BuildPipeline.BuildAssetBundles(outPath, builds.ToArray(),
         BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DeterministicAssetBundle, BuildTarget.StandaloneWindows);

      //生成资源信息文件

      BundleList bundleList = ScriptableObject.CreateInstance<BundleList>();
      foreach (var item in builds)
      {
         foreach (var res in item.assetNames)
         {
            bundleList.bundleDatas.Add(new BundleList.BundleData()
               {resPath = res, bundlePath = item.assetBundleName});
         }
      }

      AssetDatabase.CreateAsset(bundleList, "Assets/Resources/bundleList.asset");
      AssetDatabase.Refresh();
   }
}

资源调用类:

  
public static class Assets
{
   private static readonly Dictionary<string, string> BundleDic = new Dictionary<string, string>();
   public static readonly Dictionary<string, AssetBundle> BundleCache = new Dictionary<string, AssetBundle>();

   static Assets()
   {
      //读取依赖关系

      BundleList list = Resources.Load<BundleList>("bundleList");
      foreach (var bundleData in list.bundleDatas)
      {
         Debug.Log("res: " + bundleData.resPath);
         BundleDic[bundleData.resPath] = bundleData.bundlePath;
      }
   }

   public static T LoadAsset<T>(string resName) where T : Object
   {
      string bundlePath;
      string resPath = "Assets/Resources/" + resName;
      if (typeof(T) == typeof(GameObject))
      {
         resPath = Path.ChangeExtension(resPath, "prefab");
      }
      
      //如果Bunble有这个资源从Bundle中加载.

      if (BundleDic.TryGetValue(resPath, out bundlePath))
      {
         AssetBundle assetbundle;
         if (!BundleCache.TryGetValue(bundlePath, out assetbundle))
         {
            assetbundle = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, bundlePath));
            BundleCache[bundlePath] = assetbundle;
         }
         
         return assetbundle.LoadAsset<T>(resPath);
      }

      //如果Bundle中没有,从Resources目录中加载

      return Resources.Load<T>(resName);
   }
}


资源加载策略

  1. 从 Application.persistentDataPath 查找读写目录下是否有需要加载的 AB。
  2. 如果1步没有加载到资源,接着在 Application.streamingAssetsPath 中查找本地是否有需要加载的AB。
  3. 如果2没加载到资源,在Resources 中加载文件。 如此即可保证加载的资源永远是最新的。

资源更新

将CDN上的资源下载并保存在 persistentDataPath 目录,然后按上面加载顺序加载资源。需要维护一个下载资源列表,里面记录每个资源的散列值,应用启动时,包体内资源散列值和CDN中的散列值比较,决定是否需要下载。 为避免重复下载,须保证每次相同资源创建的AB是一致的。创建方法中须设置 BuildAssetBundleOptions.DeterministicAssetBundle,可以保证打包资源的一致性。 另外,AB不能信任 MD5,Unity 提供了一个散列值来约束一致性,调用 AssetBundleManifest.GetAssetBundleHash() 来获得散列值,比较是否需要更新。

前言

相信对于经常使用UGUI的人来说,Layout Element 是一个刚开始非常难理解的东西,添加该组件后总是不按我们预想的那样改变 RectTransform 的大小,而且官方文档也写得比较含糊。
其实搞明白它的原理,会发现这个东西还是很简单且实用的。

基本概念

自动布局系统,对于嵌套结构的UI,或者不能预先确定大小的组件是很实用的,比如 ScrollView 的 Content 自动根据内容调节大小,或是 Text 根据文字长度改变尺寸。
自动布局都是基于 RectTransform 来构建的,可以应用于任何包含 RectTransform 的元素上。
其中主要基于两个概念: Layout Elements 和 Layout Element Controllers。一个是用于控制每个子元素大小的,一个是用于调节所有元素的父元素的。

Layout Elements

在 Unity 中,除了狭义的 Layout Element 组件,每个 RectTransform 其实都有 Layout Element 的概念,我们可以在组件预览窗口查看到(要在下拉菜单选择)

我们可以看到其中包含下面6个属性:
Minmum width
Minmum height
Preferred width
Preferred height
Flexible width
Flexible height

它们默认值都是0,一些组件添加上后会自动改变它们的值,如Text、Image等。
除此,还有一个 Layout Element 组件,可以手动设置这些属性的值,添加上后优先度会高于其他组件。而且也可以添加多个,通过 Layout Priority 来调节互相之间的优先度。

那么这些属性到底是干什么用的呢?

以Text为例,我们会发现尽管其改变了 Layout Priorities,但文字的组件还是我们手动设置的,和这些属性还是没关系啊。这时候就要 Layout Element Controller 登场了,只有父类添加了Controller 组件,其子类的 Layout Priorities 才会发挥作用。它们相互配合才能实现自动布局的效果。
Layout Element Controller 包括 Horizontal Layout Group, Vertical Layout Group 和 Grid Layout Group 等组件。

下面先分别介绍一下各个属性的作用:
1. Minmum width/height 最先被分配,不带任何妥协。
2. 如果父类容器中仍有多余的空间,那么 Preferred width/height 会被分配。
3. 如果上面两条分配完了之后仍有额外的空间,那么 flexible width/height 会被继续分配。

这样说还是难以理解,以 Horizontal Layout Group 为例来详细说明一下。

实例

我们构建如下场景,其中Parent的 size 为 (100, 50),两个 Image 为 (50, 50),Parent 添加 Horizontal Layout Group 组件。

这时候观察两个子图片的 Layout Priorities,会发现 Minmum width/height 和 Preferred width/height 都是 0,如果 Layout Controller 真的生效了,为什么两张图片的大小不是0?待会解释这个问题,我们先来修改一下 Horizontal Layout Group 的设置,改为如下:

刚设置完你会发现两个子图片大小都变为0了,这是因为只有改为 Child Controls Size 后,Layout Controller 才会根据子物体的 Layout Priorities 对其进行设置。而此时两个图片的 Minmum width/height 和 Preferred width/height 都是 0,所以大小变为了0。

这时我们就可以为两个 Image 添加 Layout Element 来覆盖其默认属性。

Min Width/Height

首先将它们的 Min Width/Height 都设置为50,会发现图片大小都变为了 (50,50),正好覆盖父物体大小。在调整为30,60等值,会发现不管缩小还是放到,两个子物体都不管父物体的小,只设置为其 Min Width/Height 的值,这就是 Min Width/Height 的作用,它们限制组件大小的最低标准,组件绝不会小于此值。

Preferred Width/Height

接着先把 Min Width/Height 恢复为50,然后设置 Preferred Width/Height 为80(注意,Preferred 的值要比 Min 的大,因为组件尺寸无论如何都不能小于 Min 设置的值,Preferred比其小就没有意义了)。此时父物体大小是(100, 50),接下来我们调整 Parent 的大小,会观察到一下结果:

Parent size Child Size
(100, 50) (50, 50)
(160, 80) (80, 80)
(200, 100) (80, 80)
(50, 20) (50, 50)

由此可得:
无论父容器如何变化,子物体始终不小于 Min Size。
如果子物体在满足Min Size的情况下,父容器还有多余的空间,那么所有拥有 Preferred Size 的子类会平分多余的空间。
当父容器空间分配给单个子物体的空间超过Preferred Size后,该子类的大小不会继续增长(仅限未设置flexible size的情况)。

Flexible Width/Height

接下来了解一下 Flexible Width/Height,不同于上面两种属性直接根据值调整大小,Flexible Width/Height是根据每个子物体的相对比例来调整的。对于每个 Flexible 大于0的子物体,都会用父物体剩余的空间将其填满,而填满的大小,就是根据子物体各自 Flexible 占的比例来分配的。

为了便于观察,我们先关闭两个子物体的 Min 和 Preferred 设置,父物体大小重设为 (100, 50),同时开启Child Force Expand。先知调整Flexible Width,不同的值可得:

Flexible Width 1 Flexible Width 2 Width 1 Width 2
1 1 50 50
2 2 50 50
1 2 33.33 66.66
1 4 20 80

这样就很好理解了,以第三组 Flexible 分别为 1 和 2 为例,子物体1 占了 1/3 * 100 = 33.333,子物体2 占了 2/3 * 100 = 66.666。

下面我们在设置回 Min Width/Height 为50,Preferred Width/Height 为80,Flexible 为 1:2,这时候调整父物体宽度,当其宽度小于160(满足 Preferred 的值)时,Flexible是不会生效的。小于100时,Preferred 也不会生效(如上所述此时为Min)。而当父物体宽度大于160时,Flexible就开始生效了,比如宽度为200,可得 93.333 和 106.666 的两个子物体宽度,这是怎么来的呢?

Parent Width - (C1 Preferred Width + C2 Preferred Width) = 200 - (80 + 80) = 40
即分配完 Preferred 的值还剩40,而这40就会根据比例再分配各两个子物体:
C1 Width = 80 + 1/3 * 40 = 93.3333
C2 Width = 80 + 2/3 * 40 = 106.6666

最后说回父物体上的 Child Controls Size 和 Child Force Expand 选项,这也解释了我们之前的问题。前一个选项屏蔽掉这个Layout Controller对布局属性的影响,完全使用子容器的布局属性进行设置。后一项则是当子物体没有设置Flexibel时,将所有子容器的Flexible都设置为1,这样就可以让所有子容器以填充的方式平分父容器。


UI 置灰

UI置灰也是常用的效果,直接改颜色并不能达到很好的效果,此时可以直接更换一个实现置灰效果的Shader。 而对于文字,一般都已经是灰度的了,可以跟美术商量置灰效果时具体改成要改成什么颜色。

替换材质脚本如下:

  
using UnityEngine;
using UnityEngine.UI;

[DisallowMultipleComponent]
public class UIGray : MonoBehaviour
{
	private static Material _grayMaterial;
	private static Material GrayMaterial
	{
		get
		{
			if (_grayMaterial == null)
				_grayMaterial = new Material(Shader.Find("UI/Gray"));
			return _grayMaterial;
		}
	}
	
	private bool isGray;
	public bool IsGray
	{
		get { return isGray; }
		set
		{
			if (isGray != value)
			{
				isGray = value;
				SetGray(isGray);
			}
		}
	}

	private Graphic[] graphics;
	void SetGray(bool gray) 
	{
		if (graphics == null || graphics.Length == 0)
		{
			graphics = transform.GetComponentsInChildren<Graphic>();
		}
		if (graphics == null) return;
		foreach (var graphic in graphics)
		{
			graphic.material = gray ? GrayMaterial : null;
		}
	}
}

#if UNITY_EDITOR
[UnityEditor.CustomEditor (typeof(UIGray))]
public class UIGrayInspector : UnityEditor.Editor 
{
	public override void OnInspectorGUI()
	{
		base.OnInspectorGUI();
		UIGray gray = target as UIGray;
		gray.IsGray = GUILayout.Toggle(gray.IsGray, " IsGray");
		if(GUI.changed)
		{
			UnityEditor.EditorUtility.SetDirty(target);
		}
	}
}
#endif

置灰shader也比较简单,仍然需要更改默认UI shader,在 frag函数中利用一个常用灰度公式转换颜色

1
Gray = R * 0.299 + G * 0.587 + B * 0.114

  
fixed4 frag(v2f IN) : SV_Target
{
    ...
    float gray = dot(color.xyz, float3(0.299, 0.587, 0.114));
    color.xyz = float3(gray, gray, gray);
    return color;
}


粒子裁切

有时在Mask遮罩上会有粒子效果,但是粒子并没有被限制在遮罩范围内,这时候就要通过 shader 剔除遮罩外部的粒子。 以下的脚本实现获取遮罩边界,并赋值给shader的功能。
需要注意的是,粒子特效有很多种shader,实际使用中可能需要先实现许多不同种的粒子遮罩shader,然后根据不同情况更换对应的。

  
using UnityEngine;
using UnityEngine.UI;

[DisallowMultipleComponent]
[RequireComponent(typeof(Image))]
[AddComponentMenu("UI/UI Particles Mask")]
public class UIParticlesMask : Mask
{
	private float minX, minY, maxX, maxY;
	private readonly Vector3[] corners = new Vector3[4];

	protected override void OnEnable()
	{
		base.OnEnable();
		Refresh();
	}

	protected override void OnRectTransformDimensionsChange()
	{
		base.OnRectTransformDimensionsChange();
		Refresh();
	}

	public void Refresh()
	{
		if (!Application.isPlaying) return;
		
		// get mask corners

		rectTransform.GetWorldCorners(corners);
		minX = corners[0].x;
		minY = corners[0].y;
		maxX = corners[2].x;
		maxY = corners[2].y;

		foreach (ParticleSystemRenderer psr in transform.GetComponentsInChildren<ParticleSystemRenderer>(true))
		{
			SetRenderer(psr);
		}
	}

	void SetRenderer(Renderer render)
	{
		if (render.sharedMaterial)
		{
			// set shader properties

			Material mat = render.material;
			mat.shader = Resources.Load<Shader>("UI Particles Mask/Alpha Blended");
			mat.SetFloat("_MinX", minX);
			mat.SetFloat("_MinY", minY);
			mat.SetFloat("_MaxX", maxX);
			mat.SetFloat("_MaxY", maxY);
		}
	}
}

接着是shader内容,我们需要根据四个范围点判断像素是否显示。

  
// 定义属性

Properties {	
    ...   
    _MinX ("Min X", Float) = -10
    _MaxX ("Max X", Float) = 10
    _MinY ("Min Y", Float) = -10
    _MaxY ("Max Y", Float) = 10
    ...
}

Pass{
    ...
    // 声明属性

    float _MinX;
    float _MaxX;
    float _MinY;
    float _MaxY;
    ...

    struct v2f {
        ...
        // 顶点着色器输出顶点位置

        float3 vpos : TEXCOORD2;
    };
    
    v2f vert (appdata_t v)
    {
        ...
        // 设置顶点位置

        o.vpos = v.vertex.xyz;        
        return o;
    }

    fixed4 frag (v2f i) : SV_Target
    {
        ...
        // 判断是否显示

        col.a *= (i.vpos.x >= _MinX );
        col.a *= (i.vpos.x <= _MaxX);
        col.a *= (i.vpos.y >= _MinY);
        col.a *= (i.vpos.y <= _MaxY);
        col.rgb *= col.a;        
        return col;
    } 
    ... 
}  


Scroll 嵌套

有时需要实现Scroll嵌套功能,如外部是一个横向滚动的界面,内部每个元素又是竖向滚动的元素。
我们可以重写ScrollRect方法,在每个事件内,检测是否当前手指滑动方向与设置的滚动方向一致,如果不一致则将事件传递到父界面去。
注意,此功能仅适用于父和子的滚动方向不一致,当一致时,有一种方法就是根据 normalizedPosition 判断子滚动界面是否到边界,然后传递给父物体,但此时事件状态仍属于子物体,直接传递给父物体会出现错误,还要进行额外的重置或者备份恢复,比较麻烦,而且一般也不会有同方向的嵌套界面需求,就不考虑此情况了。

给父和子滚动界面都使用此脚本即可:

  
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class CustomScrollRect : ScrollRect
{
    //父CustomScrollRect对象

    private CustomScrollRect m_Parent;

    public enum Direction
    {
        Horizontal,
        Vertical
    }

    //滑动方向

    private Direction m_Direction = Direction.Horizontal;

    //当前操作方向

    private Direction m_BeginDragDirection = Direction.Horizontal;

    protected override void Awake()
    {
        base.Awake();
        //找到父对象

        Transform parent = transform.parent;
        if (parent)
        {
            m_Parent = parent.GetComponentInParent<CustomScrollRect>();
        }

        m_Direction = this.horizontal ? Direction.Horizontal : Direction.Vertical;
    }


    public override void OnBeginDrag(PointerEventData eventData)
    {
        if (m_Parent)
        {
            m_BeginDragDirection = Mathf.Abs(eventData.delta.x) > Mathf.Abs(eventData.delta.y)
                ? Direction.Horizontal
                : Direction.Vertical;
            if (m_BeginDragDirection != m_Direction)
            {
                //当前操作方向不等于滑动方向,将事件传给父对象

                ExecuteEvents.Execute(m_Parent.gameObject, eventData, ExecuteEvents.beginDragHandler);
                return;
            }
            normalizedPosition
        }

        base.OnBeginDrag(eventData);
    }

    public override void OnDrag(PointerEventData eventData)
    {
        if (m_Parent)
        {
            if (m_BeginDragDirection != m_Direction)
            {
                //当前操作方向不等于滑动方向,将事件传给父对象

                ExecuteEvents.Execute(m_Parent.gameObject, eventData, ExecuteEvents.dragHandler);
                return;
            }
        }

        base.OnDrag(eventData);
    }

    public override void OnEndDrag(PointerEventData eventData)
    {
        if (m_Parent)
        {
            if (m_BeginDragDirection != m_Direction)
            {
                //当前操作方向不等于滑动方向,将事件传给父对象

                ExecuteEvents.Execute(m_Parent.gameObject, eventData, ExecuteEvents.endDragHandler);
                return;
            }
        }

        base.OnEndDrag(eventData);
    }

    public override void OnScroll(PointerEventData data)
    {
        if (m_Parent)
        {
            if (m_BeginDragDirection != m_Direction)
            {
                //当前操作方向不等于滑动方向,将事件传给父对象

                ExecuteEvents.Execute(m_Parent.gameObject, data, ExecuteEvents.scrollHandler);
                return;
            }
        }

        base.OnScroll(data);
    }
}


不规则点击区域

有时会有需要点击区域为非矩形的不规则区域,对此,我们可以利用 PolygonCollider2D 组件来代替UI射线检测。 取消按钮与子元素的 RaycastTarget 选项, 然后增加一个子物体,挂上PolygonCollider2D组件,并编辑自己需要的形状。接着挂载检测脚本,此脚本继承Image,重写其 IsRaycastLocationValid 方法来检测当前射线是否点击中了当前的 PolygonCollider2D。

  
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(PolygonCollider2D))]
public class UIPolygon : Image
{
    private PolygonCollider2D _polygon;
    private PolygonCollider2D Polygon
    {
        get
        {
            if (_polygon == null)
                _polygon = GetComponent<PolygonCollider2D>();
            return _polygon;
        }
    }

    //设置只响应点击,不进行渲染

    protected UIPolygon()
    {
        useLegacyMeshGeneration = true;
    }

    protected override void OnPopulateMesh(VertexHelper vh)
    {
        vh.Clear();
    }

    public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
    {
        return Polygon.OverlapPoint(eventCamera.ScreenToWorldPoint(screenPoint));
    }

#if UNITY_EDITOR
    protected override void Reset()
    {
        //重置不规则区域

        base.Reset();
        transform.position = Vector3.zero;
        float w = (rectTransform.sizeDelta.x * 0.5f) + 0.1f;
        float h = (rectTransform.sizeDelta.y * 0.5f) + 0.1f;
        Polygon.points = new[]
        {
            new Vector2(-w, -h),
            new Vector2(w, -h),
            new Vector2(w, h),
            new Vector2(-w, h)
        };
    }
#endif
}

#if UNITY_EDITOR
[UnityEditor.CustomEditor(typeof(UIPolygon), true)]
public class UIPolygonInspector : UnityEditor.Editor
{
    public override void OnInspectorGUI()
    {
        //什么都不写用于隐藏面板的显示

    }
}
#endif