Administrator
发布于 2026-01-27 / 1 阅读
0
0

简单游戏架构

Unity架构

(二) 单例模块

单例 

全局只有一个实例,其需要提供一个访问的接口供其他模块调用。

其实说得再简单点,单例的实例其实就是一个特殊的全局变量。

  单例模式作为一种基础设计模式,相信很多同学都用过,因为写起来很爽,可以在任何的地方直接获取到目标实例做一些操作,在一定程度上提高了开发效率。但如果使用不得当,也很容易带来一些风险,让项目变得混乱起来。接下来列举下单例的优缺点。

优点

1、全局唯一,且可以全局访问

  在游戏的一个完整生命周期内,只存在一个实例,不会被重复创建。一些单例类保存有某些数据,而错误的使用则可能会创建出多个实例,并分别存有某些数据,导致出现不知名的Bug。单例使用一个简单的写法可以保证不会出现重复创建的问题(多线程的话就得慎重了!!)。   在面向对象编程中,很多时候新手程序员都会很苦恼,这个对象实例要从哪里拿到,才能去调用对应的方法。而引入单例可以降低他们的学习难度,在一定程度上提高了开发效率。

2、大部分情况是使用的时候才初始化

  如果一个单例在游戏过程中一直没有被使用,那它就不会被创建,比如我们写了一个聊天系统的存储单例,而玩家进游戏一直没有进过聊天系统,那就不会去创建这个存储的单例,也就不会去读取大量的聊天数据,这在一定程度上减轻了内存和CPU的压力。

  单例可以在运行的时候初始化,这意味着这个类可以访问到一些额外的数据,并且可以自行控制初始化顺序,自己能把控的东西才会安心。   单例通常可以使用一个静态类来代替,但是后者在编译的时候做初始化,很多数据是拿不到的;而且因为静态类的编译顺序是依赖于编译器的,所以如果类与类有依赖关系就很容易出问题。(当然一般也不推荐让这种类在初始化的时候互相依赖)。

3、可以继承

  这一点其实目前我用到的不多,不过《游戏编程模式》有提到,这里也放上来,感兴趣的可以直接去微信读书上阅读电子档。

缺点

1、可以被全局访问

  但凡是个程序员,多多少少应该都被告知过,少用全局变量吧。全局变量会让项目变得很混乱,一个数值的改变可能有几百几千个修改的地方,在出现问题的时候简直是噩梦。并且也会加强代码的耦合性。一个类的字段被其他很多个类引用着。

2、在存在多线程的地方有很大风险

  通常单例我们会在全局访问的入口处判断这个单例是否已经被实例化,如果没有被实例化才会实例化出来并返回,否则直接返回。而如果是多线程访问时,很可能在同一时间被两个地方同时访问,并且同时判断成功,并实例化出两个类。(这个好像叫非原子操作,可参考这个博客2)

3、延迟实例化导致卡顿

  这里还是用之前聊天存储单例的例子,因为在调用的时候才做的实例化,而调用的时候可能是在战斗的时候才打开聊天频道呼叫支援,这可能会导致你的战斗卡顿。(这种情况一般是特殊处理,如果一个单例实例化代价可能会比较大,则将这个实例化的时间提前,比如在加载的界面或者是通过其他方式提前手动实例化,避免关键时刻掉链子)

使用

我们要实现的是一套Manage of Managers的框架,即用不同的Manager来管理不同的系统,而这些Manager大部分都是一个单例。我们也将引入一些其他的东西来尽可能减少耦合性,比如后一节要讲的事件模块。如果耦合无可避免,那就把耦合的地方都集中到一个类里吧。

具体实现

单例的实现可以分为两种:一种是只存在于内存中的单例,例如存储模块和资源加载模块。而另一种则是依赖于Unity Mono的单例,其需要和Unity的一些物体做交互。例如对象池模块和音频管理模块。下面我们来具体实现这两种单例,并逐步完善。

1、只存在于内存的单例

  在前面有提到,我们会有很多个Manager,如果每个Manager都写一个单例,那可能是下面这样的:

public class ClassA
{
    private static ClassA _instance;
    public static ClassA Instance
    {
      get
        {
         if(_instance==null)
         {
            _instance=new ClassA();
        }
      return _instance;
        }
    }
    
}

  仔细观察不难发现,上面的代码很大一部分是一样的,由于我们使用的是C#,它有一个泛型的概念,因此我们可以把这个单例模板变成一个泛型类,这样不同的单例只需要传入自己的类就能创建对应的单例了。因此我们代码变成下面这样:

public class Singleton<T> where T : new()
{
    private static T _instance;
​
    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new T();
            }
​
            return _instance;
        }
    }
}

  上面的<T>则表示这是一个泛型类,后面的where T 则是定义了一个约束关系,表示这个T必须是可以被new出来的。泛型的具体使用这里不多涉及了。   而要变成单例的类则只需要继承这个泛型类即可。如下:

public class ClassA : Singleton<ClassA>
{
}

  上面的代码便创建了一个单例。通过ClassA.Instance即可访问到这个ClassA的实例了。不过上面的代码还不够,我们很多单例在创建的时候还需要做实例化操作。因此我们给它加入初始化操作。这里我选择增加一个IInitable的接口,用来修饰所有需要实例化的类。这个接口很简单,就声明一个Init的方法,如果一个类要实现接口,则必须实现接口中的Init方法。

public interface IInitable
{
    void Init();
}

  同时,我们修改泛型单例的代码,在实例化的时候判断下这个类是否实现了IInitable接口,如果实现了则在实例化完调用一次。所以泛型单例最终的代码如下:

public class Singleton<T> where T : new()
{
    private static T _instance;
​
    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new T();
                (_instance as IInitable)?.Init();
            }
​
            return _instance;
        }
    }
}

让初始化单独做一个接口去判断这个类初始化的时候需不需new完再接一个Init()

2、Mono单例

  一些Manager依赖于Unity Mono的一些东西,所以我们也可以为这些Manager写一个单例模板便于使用。这个和前面的不同的是,我们这个Manager是挂载到一个游戏物体身上存在于场景中的,所有我们要保证场景跳转时不被销毁。代码如下:

using UnityEngine;
​
public class MonoSingleton<T> : MonoBehaviour where T: Component
{
    private T _instance;
    public T Instance
    {
        get
        {
            if( _instance == null)
            {
                _instance= FindObjectOfType<T>();
                if( _instance == null)
                {
                    GameObject obj=new GameObject(typeof(T).Name, new[] {typeof(T)});
                    DontDestroyOnLoad(obj);
                    _instance = obj.GetComponent<T>();
                    (_instance as IInitable)?.Init();
                }
                else
                {
                    Debug.Log("单例已经存在");
                }
            }
                return _instance;
        }
    }
    /// <summary>
    /// 继承Mono单例的类如果写了Awake方法,需要在Awake方法最开始的地方调用一次base.Awake(),来给_instance赋值
    /// </summary>
    private void Awake()
    {
        _instance = this as T;
        DontDestroyOnLoad(this);
    }
}
// 如果没有调用base.Awake():
// 情况A:场景中已经存在GameManager对象
// - Instance属性会找到这个对象
// - 但是_instance字段是null
// - 另一个对象调用Instance时,会创建新的实例!
​
// 情况B:场景中没有GameManager对象
// - 第一个调用Instance的地方会创建一个
// - 场景中加载的GameManager对象的Awake会运行
// - 由于没有调用base.Awake(),_instance仍然是null
// - 第二个调用Instance的地方又会创建一个新的!
  1. 确保_instance正确赋值:在对象创建时就设置静态引用

  2. 保证DontDestroyOnLoad执行:防止场景切换时对象被销毁

  3. 实现重复实例检查:自动销毁多余的实例

  4. 维护单例一致性:确保所有访问都返回同一个实例

  5. 避免僵尸引用:防止静态变量指向已销毁的对象

  6. 统一初始化流程:为子类提供清晰的初始化时机

核心思想base.Awake() 实现了单例模式的核心机制,包括实例管理、生命周期控制和重复检查。如果不调用它,就相当于绕过了所有单例保护机制,回到普通的MonoBehaviour行为,完全失去了单例的意义。

(三)高效的全局消息系统

思考:如果我们游戏里只用单例模式,那么就会有过强的耦合。比如:GameManager中引用了HudManager和管道生成的脚本,用来在游戏刚开始做初始化工作。

public void BeginGame()
{
    HudManager.Instance.BeginGame();
    TubeSpawn.Instance.BeginSpawnTube();
    FindObjectOfType<BirdController>().Init();
}
​
public void BirdDie()
{
    HudManager.Instance.OnBirdDie();
    TubeSpawn.Instance.StopSpawnTube();
}

  而控制鸟的脚本里还引用了HudManager用来告知分数增加,引用GameManager用来告知游戏结束等等。这违背了设计模式的迪米特法则即一个对象应该尽可能少得去了解另一个对象,控制鸟的脚本不需要知道显示的脚本有一个AddScore的方法。

我们需要一个解开这种耦合,让移动只关心移动,而不关心移动的后果。需要有某种消息从当前类发出给另外一个类,并且之间是解开的关系,那就要用到全局的消息系统。

    // 装箱的实际过程:
    // 1. 在托管堆上分配内存
    // 2. 将值类型的值复制到堆中
    // 3. 返回对象的引用

不同类要做同一件事情用接口,同一个类做同一件事情不同效果用委托

Unity自带的消息

unity自身提供了一个消息的接口SendMessage,但在实际项目中我们往往很少去调用这个接口。

SendMessageGameObject类的方法,用于在游戏对象及其所有子对象上调用指定名称的方法。

void SendMessage(string methodName, object value, SendMessageOptions options);
  • methodName:要调用的方法名(字符串)

  • value:传递给方法的参数(任意类型)

  • options:选项枚举

    • SendMessageOptions.RequireReceiver:如果没有找到接收者,会报错(默认)

    • SendMessageOptions.DontRequireReceiver:没找到接收者也不报错

这个方法使用的是反射(运行时确认数据类型),速度比较慢,其次传递参数是使用object类型,即传参和调用会带来装箱和拆箱的操作,前者容易导致GC,后者速度慢。跑1e5次大概会有1.9M的GC

[SerializeField] private int loopCount = 100000;
//object o=1; 这里把1去替换少了装箱和拆箱的过程也会块很多
void Update()
{
​
    if (Input.GetKeyDown(KeyCode.A))
    {
        for (int i = 0; i < loopCount; i++)
        {
            SendMessage("TestUnityMessage", 1, SendMessageOptions.DontRequireReceiver);
        }
    }
}
​
void TestUnityMessage(int obj)
{
    obj = obj + 1;
}

image-20260115025037020

简单定义自己的消息系统

我的消息系统需要有什么东西?

我们需要能够发出数据,接收数据,还有数据本身。

MessageManager:负责收到消息后帮它分发给对应的对象或者是方法(接线员)。

MessageDefine:静态类,用来定义消息的Key

MessageData,用来保存发送的消息内容

MessageManager.cs

MessageManager.cs 提供注册、移除、触发消息和清空的方法。一个对象使用一个特有的Key和对应的触发消息的方法来注册,当Manager收到某个消息时,找到消息Key有哪些注册过,并逐一通知他们。

从字典(string,Action<DataType>)存下有那些消息

using System.Collections.Generic;
using UnityEngine.Events;
​
public class MessageManager : Singleton<MessageManager>
{
    private Dictionary<string, UnityAction<MessageData>> dictionaryMessage;
​
    public MessageManager()
    {
        InitData();
    }
​
    private void InitData()
    {
        dictionaryMessage = new Dictionary<string, UnityAction<MessageData>>();
    }
​
    public void Register(string key, UnityAction<MessageData> action)
    {
        if (!dictionaryMessage.ContainsKey(key))
        {
            dictionaryMessage.Add(key, action);
        }
        else
        {
            dictionaryMessage[key] += action;
        }
    }
​
    public void Remove(string key, UnityAction<MessageData> action)
    {
        if (dictionaryMessage.ContainsKey(key))
        {
            dictionaryMessage[key] -= action;
        }
    }
​
    public void Send(string key, MessageData data)
    {
        UnityAction<MessageData> action = null;
        if (dictionaryMessage.TryGetValue(key, out action))
        {
            action(data);
        }
    }
​
    public void Clear()
    {
        dictionaryMessage.Clear();
    }
}

MessageData.cs

MessageData.cs 定义了几个基础的数据类型,并提供了对应的构造方法,便于不同消息的传递。内部的数据是只读的,防止某个注册的方法修改类中的值出现异常。

using UnityEngine.Events;
​
public interface IMessageData
{
}
​
public class MessageData<T> : IMessageData
{
    public UnityAction<T> MessageEvents;
​
    public MessageData(UnityAction<T> action)
    {
        MessageEvents += action;
    }
}
​
public class MessageData : IMessageData
{
    public UnityAction MessageEvents;
​
    public MessageData(UnityAction action)
    {
        MessageEvents += action;
    }
}

MessageDefine.cs

MessageDefine.cs 一个静态的类,内部定义各种消息的Key,用来做唯一标识,同样的,Key也是只读的,防止被误修改。有些人写的消息系统直接使用的字符串,那种不利于修改,最好是通过MessageDefine.someKey的方式进行,这样一个是修改方便,例如我要删除一个Key,直接把这里的删掉,其他的地方就都报错了;另外免去手误的可能,我相信IDE

public static class MessageDefine
{
    //UI Event
    public static readonly string ON_BEGIN_GAME_CLICK = "ON_BEGIN_GAME_CLICK"; //点击开始游戏
​
​
    //Game Event
    public static readonly string BEGIN_GAME = "BEGIN_GAME"; //开始游戏
    public static readonly string BIRD_DIE = "BIRD_DIE"; //小鸟死亡
    public static readonly string ADD_SCORE = "ADD_SCORE"; //分数增加
}

 郑重的按下A键,Profiler信息如下,可以看到,首先是0GC Alloc,因为我们为数据类写了几个不同的构造函数,避免了装箱,同时在使用的时候我们明确我们这个消息会传递过来什么类型,我们直接通过data的字段名字去取,也没有额外的拆箱操作(你丫的不废话吗!都没装哪来的拆!!) 其次速度上也略微比之前的快一丢丢

优化:

 由于我们想要使用泛型数据来作为data,而MessageManager中消息注册保存的那个字典数据类型是没办法设置为泛型的,其在定义的时候就确定好了,我们没法中途修改它。为了实现这种一对多的关系,我们我们可以加一个接口,让一个类去实现接口,并在类中使用泛型;然后MessageManager这边直接使用这个接口类型作为Value。由于原来我们是在保存消息的字典里同时存了对应触发的事件,而现在字典改成了一个接口,所以我们把事件放到实现类中。下面是我们的新代码:

using System.Collections.Generic;
using UnityEngine.Events;
​
public class MessageManager : Singleton<MessageManager>
{
    private Dictionary<string, IMessageData> dictionaryMessage;
​
    public MessageManager()
    {
        InitData();
    }
​
    private void InitData()
    {
        dictionaryMessage = new Dictionary<string, IMessageData>();
    }
​
    public void Register<T>(string key, UnityAction<T> action)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            if (previousAction is MessageData<T> messageData)
            {
                messageData.MessageEvents += action;
            }
        }
        else
        {
            dictionaryMessage.Add(key, new MessageData<T>(action));
        }
    }
​
    public void Remove<T>(string key, UnityAction<T> action)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            if (previousAction is MessageData<T> messageData)
            {
                messageData.MessageEvents -= action;
            }
        }
    }
​
    public void Send<T>(string key, T data)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            (previousAction as MessageData<T>)?.MessageEvents.Invoke(data);
        }
    }
​
    public void Clear()
    {
        dictionaryMessage.Clear();
    }
}

关于装箱和拆箱的补充

消息系统的Key到底要使用什么类型?

 针对枚举类型作为Dictionary的key速度慢的问题,当使用枚举类型做为Key时(枚举是基类,而不是有了属性的枚举的类型),会发生装箱操作,主要是因为在Dictionary在去查找时,会使用this.comparer去计算Hash值和比较相等,而枚举类型没有实现IEquatable接口,所以在比较的时候会使用System.Object.EqualsGetHashCode,这就造成了装箱操作。

public enum MessageType
{
  a,a1,a2
}
private Dictionary<MessageType,ImessageData> dic1;//不会装箱
private Dictionary<Enum,IMessageData> dic2;//会

当值类型转换成引用类型,就是装箱。而将引用类型转换成值类型,就是拆箱3。装箱需要为值类型分配一块内存空间,并将值类型的地址写入到那块空间里,所以装箱会导致内存分配,频繁的内存分配则更容易导致GC,而且会让内存碎片化,使得利用效率降低;而拆箱需要将引用类型保存的地址里的值复制出来,所以也会拖慢速度

  GC全称是Garbage Collection,我用自己的理解,就是在某些时候(内存不够,或者手动调用时,或者被定期执行),CLR(不严谨得说,就可以认为是系统)会检查内存,把一些没有被使用的内存给清理掉,让那块内存空间重新变成可用状态,同时可能会将离散的内存整理好,让内存块变得更连续,这样就能更高效得利用内存(比如两个三个内存块中间都有三个单位空间,那我申请六个空间的内存就没法在这里申请到,但是如果它们是连续的,那我们就能成功在这里申请到),而整理内存也就涉及数据的拷贝和移动了,所以可能会导致卡顿等情况。

图片

  而上文中提到的GC其实是Profiler中的堆内存分配,刚解释装箱我们也提到了,当频繁分配这种碎小的内存会更容易导致GC,所以我们要尽量避免这个内存分配。

小结:

1.Unity自带的消息使用的是反射,而且参数传递有装箱操作。

2.自己写的消息机制,可以使用接口、实现类加泛型,拓展消息参数,既便于拓展,也没有装箱操作。如果数据类内置几种基础类型并提供对应的实例化方法,没有装箱操作(每次new一个的话还是有内存分配的),但是拓展性不强;使用Object类型,通用性强,但可能会有装箱操作。

3.消息的Key最好使用string类型,为了方便维护,可以使用一个静态类去定义不同的静态字段作为Key。使用Enum类型会有装箱操作,使用自定义枚举类型没有装箱,但是比string类型慢。使用int类型比string类型快,但是不利于维护。

(四)资源加载与管理模块

Asset的一生

1.如何产生asset?

一个asset大概有两部分,asset的数据内容

一个fbx文件数据内容(原始数据),导入后产生一个meta文件(额外信息),

无论是unity引擎生产的,还是美术给的(Maya3D等等资源),都会导入到Lib文件夹下面,在运行时使用的是Lib文件下的东西。

不同的平台导入的过程不一样,可能会产生差异,切换一个平台就会有一个很长的Load过程,就是在重新导入一遍。

按像是我们游戏的音频,渲染用的纹理是要打到包里给玩家看的叫运行时产生的Asset(Runtime)编辑期产生的Asset(Editor),这些Asset不会打到包中,但是会参与编辑和生产,包的过程,还有是生产运行期的Asset会有一部分信息用于build。

Asset导入设置

1.重要的Meta文件,

每一个meta文件有一个唯一的Guid,Guid又与lib中的数据相关,这个id又叫文件Id

image-20260116023201378

在导入时会经常使用AssetImporter这个类,处理的内容就是xxxImporter内容,这些内容也反映在Inspector面板上,去常看一下meta文件。这个文件格式叫做YAML。

另一个文件,这里的!u!1后的id叫做物体id,每一个object都带有这个唯一的id,这个id前还有一个!1这样的数字,这个数字表示这个物体是什么,unity内部的枚举id,与Inpector面板里一一对应,在m_Component下面有四个component,与挂的component属性是对应的,在fileID中去找,像是与脚本miss了,就可以通过前meta文件去找,这样生产prefab和scene。(为什么脚本会报miss,这里涉及unity的数据库保护,unity路径迁移)

有fileId就是指向另一个文件(指针的地址)。同样的打包的时候不想去掉一些属性,不用Editor去写,可以直接操作meta文件,把对应属性和对应属性的指针去掉就行。

image-20260116023731154

2.拥挤的Library文件(所有的资源文件,MP3,wav的类型)unity不会管源文件,而是修改导出的格式,使用wav原声会比Mp3少压缩和解压缩,让音乐更好听,所以用的实际上是unity修改后的资源

AssetPipline Verson1版本Unity的文件格式

image-20260116030433225

最重要的是MetaData这个文件,在MetaData下里面的Guid和文件里的id是对应的(仅限V1版本),在算AssetBudle的时候,可以根据这个文件生成的时间来判断要不要重载。

V2的时候会把部分数据放到DB中,即内存数据库中。LMDB还有Chache系统

image-20260116031056600

3.神奇的StreamingAssets文件夹(不压缩):

3.1这个文件下的物品可以原封不动的打进包里

3.2在安卓系统上可以直接被读出来(理论上整体是一个apk一个整体的包)

4.害羞的波浪线

在文件名称后面加上一个~可以直接隐藏掉不想打包的文件,有时候开发放东西到Res下可以使用

兄弟姐妹 Asset与AssetBundle

1.AssetBundle的原理

AssetBundle是一个压缩包,问题是我直接用一个文件不好吗?

1.1 可以用文件,但是AssetBundle的好处在于可以理清依赖关系 ,依赖关系很复杂,甚至可能会有环形依赖

1.2可以跨平台,Bundle能够打出不同的包来适应不同的环境

1.3可以做一个快速的索引比如压缩/省内存,延展了unity跨平台性,使我们的,BuildTypeLine,使用一份代码调一些参数就可以随意的去打包了。去加载一个AssetBundle的时候,他的头会立刻加载进来(在profaly里看到的firesever)

1.4 Bundle里的Asset是按需加载的,不去加载,就不会从包体里加载到内存中的。有一个例外是LZMA

1它是一个存在于硬盘上的文件。可以称之为压缩包。这个压缩包可以认为是一个文件夹,里面包含了多个文件。这些文件可以分为两类:serialized file 和 resource files。(序列化文件和源文件) serialized file:资源被打碎放在一个对象中,最后统一被写进一个单独的文件(只有一个) resource files:某些二进制资源(图片、声音)被单独保存,方便快速加载 2,它是一个AssetBundle对象,我们可以通过代码从一个特定的压缩包加载出来的对象。这个对象包含了所有我们当初添加到这个压缩包里面的内容,我们可以通过这个对象加载出来使用。

实际是unity一套虚拟文件系统

2.AssetBundle的参数

BuildPopline.BuildAssetBundles(Application.StreamingAssetsPath,BuildAssetBundleOptions.ChunkBasedCompression|BUildAssetBundleOptions.DisableWriteTypeTree,BuildTarget.StandaloneOSX);
//输出路径//压缩格式(可以省略一些不必要的操作)//目标平台
BuildAssetBundleOptions.ChunkBasedCompression
这个参数压缩AssetBundle,安卓的AssetBundle是不压缩的,我们就可以自己去使用这样的参数,是一个改良过的Lz4
可以对这个做加密
BuildAssetBundleOptions.None:使用LZMA算法压缩,压缩的包更小,但是加载时间更长。使用之前需要整体解压。一旦被解压,这个包会使用LZ4重新压缩。使用资源的时候不需要整体解压。在下载的时候可以使用LZMA算法,一旦它被下载了之后,它会使用LZ4算法保存到本地上。
BuildAssetBundleOptions.UncompressedAssetBundle:不压缩,包大,加载快
BuildAssetBundleOptions.ChunkBasedCompression:使用LZ4压缩,压缩率没有LZMA高,但是我们可以加载指定资源而不用解压全部。
注意使用LZ4压缩,可以获得可以跟不压缩想媲美的加载速度,而且比不压缩文件要小。
​
BUildAssetBundleOptions.DisableWriteTypeTree
这个参数减少包体/内存/加载的CPU时间,关掉TypeTree
​
BuildAssetBundleOptions.DusableLoadAssetByFileName|BuildAssetBundleOptions.DusableLoadAssetByFileNameWithExtension
加载Bundle的时候,给名字/全路径/扩展名不同的寻址是有不同的代价的,使用chunkBasedCompression实际上是写了一个哈希进去的,确定是纯路径加载可以把别底都关闭掉,CPUTime和运行时的内存
​

未使用DisablewriteTypeTree

image-20260116044732561

不带TypeTree

image-20260116044857426

3.AssetBundle的识别

可以算打包之前的体积,mainifest中的数据也行

4.AssetBundle的策略

1.过大的情况,根本下不下来,下载失败没有续传的话就寄了,东西很多的话会让SerializedFile中的信息摘要也很多

2.包很小的情况,就是头很多,实际上在代码跑的数据量少,大部分数据量都变成了头文件

(1M~2M)一个包,本包(5M-10M),打包前看Lib的大小

1,把经常更新的资源放在一个单独的包里面,跟不经常更新的包分离 2,把需要同时加载的资源放在一个包里面 3,可以把其他包共享的资源放在一个单独的包里面 4,把一些需要同时加载的小资源打包成一个包 5,如果对于一个同一个资源有两个版本,可以考虑通过后缀来区分 v1 v2 v3 unity3dv1 unity3dv2

1,逻辑实体分组 a,一个UI界面或者所有UI界面一个包(这个界面里面的贴图和布局信息一个包) b,一个角色或者所有角色一个包(这个角色里面的模型和动画一个包) c,所有的场景所共享的部分一个包(包括贴图和模型) 2,按照类型分组 所有声音资源打成一个包,所有shader打成一个包,所有模型打成一个包,所有材质打成一个包 3,按照使用分组 把在某一时间内使用的所有资源打成一个包。可以按照关卡分,一个关卡所需要的所有资源包括角色、贴图、声音等打成一个包。也可以按照场景分,一个场景所需要的资源一个包

依赖打包

意思就是例如有两个模型使用的都是同一个材质和贴图,那么模型和材质贴图之间就是依赖关系。如果我们这两个模型都单独打包出来那么就会打包出两份材质和贴图,这样包就会变大,那么我们如何解决呢,这里Unity里面自带有一种方式,那就是首先先把所依赖的材质和贴图单独打包到一个文件夹中,然后再分别打包两个需要依赖这个材质和贴图的模型。这样Unity就会去查找这个材质贴图,发现这个材质和贴图已经打包了出来,那么它就不会去重复的打包材质和贴图了,这样就大大减小了包的大小

image-20260116043202728

5.AssetBundle的加载

1.Editor和Runtime加载机制不同

Editor下unity优先保证了开发时的流程度,一般会提前的把资源加载进去

Runtime严格保守按需求加载,来保证CPU和内存

一定要去跑真机!!!

2.序列化和反序列化

在场景中的prefab和gameobject在运行时看起来没区别,区别在

image-20260116051051864

打开预制体场景的.unity文件,其中的PrefabInstance,里面记录prefab指向一个一个的文件和guid

image-20260116051220883

用gameObject的话会很多,当Unity解析打到版本里的场景时,解析prefab会比gameobject更快,并且让场景中相同的prefab引用指向同一个内存,所以在内存中会很深,因为它只解析了一边。对于gameobject会认为是不同的物品,能用prefab就用prefab。unity在做序列化的时候可以简单理解,meta文件里的内容越多执行越慢,与c++反射,序列化相关

3.兼容性之树TypeTree

设计是为了给unity跨版本兼容性做的,有不少meta文件中有一个serializedVersion,这种数值和unity的版本相关,数据格式

通过读meta文件下数据的结构把版本控制的兼容做出来了,加载时会额外便利一边TypeTree,同时会在内存中生成一个TypeTree的数据结构,磁盘/内存/cpu时间。确定打出来的Bundler和Apk和打出的AssetBundle是一致的,可以关,除非做跨版本兼容。

image-20260116051833373

4.同步和异步:是策略问题。

同步主要是快,这一帧里全部的CPU给我用,会造成主线程卡顿。

异步:经量不卡,异步至少比同步漫一帧,我当前这一帧的异步至少下一帧才会执行,还有时间片,会总体时间长

对卡顿敏感的地方,可以再加一些兼容方式,在没加载完前有一些处理。

可以分帧使用同步,混合使用会有问题,下面这两个

5.PreLoad和Presistent

在unity引擎内部会有PreLoadManger和PresistentManger

PreLoadManger:负责调度任务,operitionprefrom。当上层有一个任务要加载,会形成一个option,option会给到preloadManger,其中有一个队列,每一帧,preLoadManger会从里面取一个任务option去执行,如果是并行的,会一直往队列里塞去执行。但是在并行执行任务的过程中,会用到presisentManger。作用是把文件从磁盘读取到内存,同时分配一个ID,在异步和同步的同时在跑,两个线程会去抢同一个presisentManger分配的ID,它分配ID(时序性),读取I/O是要阻断线程的会block,异步可能会被同步阻断,反过来也可能,纯同步也会有,两个去res一个manger。

加载方法,下载使用URL网址去下,获取资源服务器地址,通过资源对比检测需要哪些更新

AssetBundle的加载有以下几种方式,从内存加载使用LoadFromMemoryAsync,从本地文件加载可以使用LoadFromFile,从服务器上Web上加载可以使用UnityWbRequest。下面我们来看看这几种加载的方式。 首先可以先把我们Unity里面的两个模型的Prefab Cube和Capsule删除了,然后创建一个脚本挂在Camera上,打开脚本 第一种加载方式(LoadFromMemoryAsync)从内存加载

在真正去做一个游戏并 Build出来时,你可能首先就得考虑资源加载的问题:如何处理游戏内的不同资源,使用什么方式加载,什么时候加载什么时候卸载等等。

using UnityEngine;
using System.IO;
using System.Collections;
public class LoadFromFileExample : MonoBehaviour {
​
    IEnumerator  Start () {
        string path = "AssetBundles/scene/model.ab";
        //第一种加载AB的方式 LoadFromMemoryAsync
        //异步加载
        AssetBundleCreateRequest request = AssetBundle.LoadFromMemoryAsync(File.ReadAllBytes(path));
        yield return request;
        AssetBundle ab = request.assetBundle;
        //同步方式
        //AssetBundle ab=  AssetBundle.LoadFromMemory(File.ReadAllBytes(path));
​
         //使用里面的资源
        Object[] obj = ab.LoadAllAssets<GameObject>();//加载出来放入数组中
        // 创建出来
        foreach (Object o in obj)
        {
            Instantiate(o);
        }
    }
}
​

第二种方式(LoadFromFile)从本地加载

using UnityEngine;
using System.Collections;
​
public class LoadFromFileExample : MonoBehaviour {
​
    IEnumerator  Start () {
        string path = "AssetBundles/scene/model.ab";
        //第二种加载方式 LoadFromFile
        //异步加载
        AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path);
        yield return request;
        AssetBundle ab = request.assetBundle;
        //同步加载
        //AssetBundle ab = AssetBundle.LoadFromFile(path);
​
        //使用里面的资源
      Object[] obj = ab.LoadAllAssets<GameObject>();//加载出来放入数组中
        // 创建出来
        foreach (Object o in obj)
        {
            Instantiate(o);
        }
     }
}

第三种方式(UnityWbRequest)从服务器或者本地加载

using UnityEngine;
using System.Collections;
using UnityEngine.Networking;
​
public class LoadFromFileExample : MonoBehaviour {
​
    IEnumerator  Start () {
        //第三种加载方式   使用UnityWbRequest  服务器加载使用http本地加载使用file
        //string uri = @"file:///C:\Users\Administrator\Desktop\AssetBundleProject\AssetBundles\model.ab";
        string uri = @"http://localhost/AssetBundles\model.ab";
        UnityWebRequest request = UnityWebRequest.GetAssetBundle(uri);
        yield return request.Send();
        AssetBundle ab = DownloadHandlerAssetBundle.GetContent(request);
​
        //使用里面的资源
      Object[] obj = ab.LoadAllAssets<GameObject>();//加载出来放入数组中
        // 创建出来
        foreach (Object o in obj)
        {
            Instantiate(o);
        }
    }
}

卸载

卸载有两个方面 1,减少内存使用 2,有可能导致丢失 所以什么时候去卸载资源 AssetBundle.Unload(true)卸载所有资源,即使有资源被使用着 (1,在关卡切换、场景切换的时候 (2,资源没被调用的时候 AssetBundle.Unload(false)卸载所有没用被使用的资源 个别资源怎么卸载 (1,通过 Resources.UnloadUnusedAssets. (2,场景切换的时候

UnloadUnusedAssets是一个option也归preLoadManger去管理,必须独自完成,不能并行,unity在一次LoadOption的时候,就i已经确定哪些Asset要被load,在一次load中要是使用了unLoad就会使之前确定使用,Load的资源被卸载掉,就会出错,在切换scene时会去掉用一次

AssetBundle.Unload(true/false),这不是一个option不归preloadManger管理,是一个erase操作,会便利过已经加载的东西然后去删掉,是true的会带着AssetBundle一块扔了,false则不会扔掉已经加载的Asset,第二次加载同一个AssetBundle的时候会加载两边,Asset和AssetBunlde之间的关系没用

文件校验

文件校验可以在文件传输的时候保证文件的完整性,例如A在给我传输了一个文件之前会生成一个校验码,对于这个文件只会生成这一个唯一的校验码,只要传输给我的文件有一点不一样那么校验码就会完全不同。所以A在传输给我文件的时候会把文件和校验码都传输给我,当我取到这个文件的时候我也会使用和A同样一个算法去生成这个文件的校验码,然后拿这个值和A传输给我的校验码比对,如果一样说明这个文件是完整的,如果不一样那么就重新传输。下面是几个算法生成的校验值 CRC MD5 SHA1

CRC、MD5、SHA1都是通过对数据进行计算,来生成一个校验值,该校验值用来校验数据的完整性。 不同点:

  1. 算法不同。CRC采用多项式除法,MD5和SHA1使用的是替换、轮转等方法;

  2. 校验值的长度不同。CRC校验位的长度跟其多项式有关系,一般为16位或32位;MD5是16个字节(128位);SHA1是20个字节(160位);

  3. 校验值的称呼不同。CRC一般叫做CRC值;MD5和SHA1一般叫做哈希值(Hash)或散列值;

  4. 安全性不同。这里的安全性是指检错的能力,即数据的错误能通过校验位检测出来。CRC的安全性跟多项式有很大关系,相对于MD5和SHA1要弱很多;MD5的安全性很高,不过大概在04年的时候被山东大学的王小云破解了;SHA1的安全性最高。

  5. 效率不同,CRC的计算效率很高;MD5和SHA1比较慢。

  6. 用途不同。CRC一般用作通信数据的校验;MD5和SHA1用于安全(Security)领域,比如文件校验、数字签名等。

在Unity中,加载资源主要有四种方式,序列化到 prefab中、 AssetDatabaseResourcesAssetBundle。这四种各有优缺点,也有自己适用的场合。一般需要根据自身项目需求来选择。

1、序列化到Prefab

直接序列化到 Prefab指的是,我们 Prefab上挂的脚本公共字段直接引用了一个资源。通过这种方式引用资源的,最终会序列化到这个资源内部去。如果是 prefab则是序列化到这个 prefab资源文件,如果是放在场景中保存的则是序列化到场景资源文件里。

文件

红点树

(39 封私信) 【Unity手游实战】Unity红点系统框架设计 - 知乎

三某无极缩放

1.7级LOD

lod0单个地块覆盖3.2m*3.2m,每增加一级尺寸 * 2

lod6单个地块覆盖204.8m204.8m(3.2m*2^6)

全地图尺寸是1200m*1200m包含1500 * 1500 个格子

image-20260116124508281

经过减面后,模型的精度会低很多

地块的加载,一个地图会被分为3.2 * 3.2的网格

lod chunk:位置,数量都是固定的,数量降低至1/4,面积扩大四倍

image-20260116124709748

image-20260116124808995

我们每一级Lod都会加载距离视野中心点最近的5 5 个chunk的地块进入内存,一共7个层级就是 7 25=175个地块。

1.不同的Lod加载的区域有一部分是重叠的,相机大概要移动一个grid的距离,才会触发Lod0的刷新,Lod2的刷新就是四个grid,级别越低刷新频率越低。

2.相机有一个初始高度,相机高度大于h0就会隐藏所有0级别地块,2倍率h0,就一级,这样当相机高度高的时候,就不会刷新低级地块,高相机移动速度很快,小地快会频繁刷新

image-20260116125511286

这里我们需要异步加载Lod chunk需要的资源,使用runtime-vertrueTexture,在gpu中混合

GPU中烘培地块G-BufferRT(Albedo,Normal,Roughness),normal和roughness存在rgb通道的b通道,大概的流程是

1.首先渲染相机的投影矩阵,让相机的方向自顶向下,并且使用正交投影,保证当前烘焙的地块正好覆盖整个RT

2.然后就是渲染地形将Albedo,Normal,Roughness,分别输出到RT1,RT2,RT3.

3.第三步是渲染和当前地块相交,贴画,道路,逻辑网格线,这些地图上的静态元素,这样可以实现山体和贴花做材质融合

4.在GPU上做压缩,Gpu中压缩G-Buffer成ASTC 6 * 6的格式,可以减少VT产生的带宽和内存

为了支持后面地表建筑做材质融合,是不能直接使用的,而是将两个G-buffer贴图放到一共全局的TextureArray上,100M,低端机25M内存。ROIT

最后并不能genertMinMap去自动生产,贴花只在Lod3小于三的时候才有,然后避免重复感觉lod大于2时候paikege会很大

更新minmap

image-20260116130816020

并非所有GO创建出来就会显示

1.parent chunk没有加载不显示,因为它的minMap没有更新

2 4个child chunk全部加载完成之前,.已完成加载的Child Chunk 不显示,parent chunk的mesh会和自己的mesh重叠,

3Child Chunk 全部加载完成后,滋生不显示

4.相机高度超过一定阈值后,不显示低级的Lod

image-20260116131236440

地表材质融合,

1.世界坐标中如何对应到shader中的一个像素

使用lookup texture(很大375*375)

2.使用Hierachial query in shader,把位置传给shader,chunk坐标转为UV坐标

image-20260116131544131

image-20260116131719062

image-20260116131857723

边缘会有lod2和lod0相交,地表贴图的精度不够,

Xlua

1.什么样的代码执行能够支持热更新?

两种模式:

1.模式一:我们讲



评论