AssetBundle 打包格式:FlatBuffers 在 AssetManifest 中的应用

AssetBundle 打包格式:FlatBuffers 在 AssetManifest 中的应用

大家好,今天我们来深入探讨AssetBundle打包格式,并重点关注FlatBuffers在AssetManifest中的应用。AssetBundle作为Unity中重要的资源管理方式,其打包格式直接影响加载速度、内存占用以及更新效率。而AssetManifest作为AssetBundle的索引文件,其高效的序列化和反序列化至关重要。FlatBuffers则是一种高效的跨平台序列化库,非常适合用于AssetManifest的存储。

一、AssetBundle与AssetManifest概述

AssetBundle是Unity引擎提供的一种将游戏资源(如模型、纹理、材质、场景等)打包成独立文件的机制。通过AssetBundle,我们可以将资源从主包中分离出来,按需加载,从而减小游戏初始包体大小,并支持热更新。

AssetManifest则是AssetBundle的元数据文件,它包含了AssetBundle中所有资源的信息,例如资源名称、资源路径、依赖关系、哈希值等。在加载AssetBundle时,我们需要先加载AssetManifest,然后根据AssetManifest中的信息定位并加载需要的资源。

AssetBundle的优势:

  • 减小初始包体大小: 将不常用的资源打包成AssetBundle,用户在首次安装时只需要下载必要的资源。
  • 热更新: 可以通过更新AssetBundle来实现游戏内容的更新,无需重新下载整个游戏。
  • 资源复用: 多个场景或项目可以共享同一个AssetBundle。
  • 资源管理: 方便地管理和控制游戏资源,例如按需加载、卸载等。

AssetManifest的重要性:

  • 资源定位: AssetManifest提供了资源在AssetBundle中的准确位置信息,使得我们可以快速定位并加载所需资源。
  • 依赖关系管理: AssetManifest记录了资源之间的依赖关系,确保在加载资源时能够正确地加载其依赖的资源。
  • 版本控制: AssetManifest中通常包含资源的版本信息,用于判断资源是否需要更新。
  • 校验: AssetManifest中包含资源的哈希值,用于验证资源是否被篡改。

二、AssetManifest的常见存储格式

AssetManifest的存储格式直接影响其加载速度和内存占用。常见的存储格式包括:

  • XML: 可读性好,但解析速度慢,占用空间大。
  • JSON: 可读性较好,解析速度比XML快,但仍然存在空间浪费问题。
  • Binary: 占用空间小,解析速度快,但可读性差。
  • FlatBuffers: 高效的跨平台序列化库,占用空间小,解析速度极快,支持零拷贝访问。
存储格式 可读性 解析速度 占用空间
XML
JSON 较高 较慢 较大
Binary
FlatBuffers 较低 极快 极小

三、FlatBuffers简介

FlatBuffers是由Google开源的一种高效的跨平台序列化库,其主要特点包括:

  • 零拷贝访问: FlatBuffers数据以二进制形式存储,可以直接在内存中访问,无需额外的解析步骤。
  • 内存效率: FlatBuffers数据结构紧凑,占用空间小。
  • 跨平台支持: FlatBuffers支持多种编程语言,包括C++, C#, Java, Python等。
  • 向前/向后兼容性: 可以方便地添加或删除字段,而不会影响旧版本的代码。

FlatBuffers的优势:

  • 极高的性能: 由于零拷贝访问,FlatBuffers的解析速度非常快,尤其是在大型数据结构中。
  • 节省内存: FlatBuffers数据结构紧凑,占用空间小,可以有效减少内存占用。
  • 方便的数据访问: 可以直接通过生成的代码访问FlatBuffers数据,无需手动解析。

四、使用FlatBuffers构建AssetManifest

接下来,我们将演示如何使用FlatBuffers构建AssetManifest。

1. 定义FlatBuffers Schema

首先,我们需要定义FlatBuffers Schema,用于描述AssetManifest的数据结构。创建一个名为 asset_manifest.fbs 的文件,并添加以下内容:

namespace MyGame.AssetManagement;

table Asset {
  name:string (required); // 资源名称
  path:string (required); // 资源路径
  hash:string (required); // 资源哈希值
  dependencies:[string]; // 依赖的资源名称列表
}

table AssetBundleManifest {
  assets:[Asset]; // Asset列表
  bundleName:string (required); // AssetBundle 名称
}

root_type AssetBundleManifest;

这个Schema定义了两个Table:

  • Asset: 描述单个Asset的信息,包括名称、路径、哈希值和依赖关系。
  • AssetBundleManifest: 描述整个AssetBundle的Manifest信息,包括Asset列表和AssetBundle名称。

2. 生成C#代码

使用 FlatBuffers 编译器 (flatc) 将 .fbs 文件编译成 C# 代码。 你需要从 FlatBuffers 的官方 GitHub 仓库下载对应平台的编译器。 编译命令如下:

flatc -c -n asset_manifest.fbs

这将生成 AssetManifest.cs 文件,其中包含了定义在Schema中的C#类。确保将生成的 AssetManifest.cs 和 FlatBuffers 的 C# 运行时库 ( FlatBuffers.dll ) 导入到你的 Unity 项目中。

3. 创建AssetBundleManifest数据

现在,我们可以使用生成的C#类来创建AssetBundleManifest数据。

using UnityEngine;
using System.Collections.Generic;
using FlatBuffers;
using MyGame.AssetManagement;
using System.IO;

public class AssetBundleManifestBuilder
{
    public static byte[] BuildManifest(string bundleName, List<AssetInfo> assets)
    {
        var builder = new FlatBufferBuilder(1024); // 初始大小,可以根据实际情况调整

        // 创建Asset列表
        var assetOffsets = new Offset<Asset>[assets.Count];
        for (int i = 0; i < assets.Count; i++)
        {
            var asset = assets[i];
            var nameOffset = builder.CreateString(asset.Name);
            var pathOffset = builder.CreateString(asset.Path);
            var hashOffset = builder.CreateString(asset.Hash);

            // 创建依赖列表
            Offset<VectorOffset> dependenciesOffset = default(Offset<VectorOffset>);
            if (asset.Dependencies != null && asset.Dependencies.Count > 0)
            {
                var dependenciesVector = new StringOffset[asset.Dependencies.Count];
                for (int j = 0; j < asset.Dependencies.Count; j++)
                {
                    dependenciesVector[j] = builder.CreateString(asset.Dependencies[j]);
                }
                dependenciesOffset = Asset.CreateDependenciesVector(builder, dependenciesVector);
            }

            Asset.StartAsset(builder);
            Asset.AddName(builder, nameOffset);
            Asset.AddPath(builder, pathOffset);
            Asset.AddHash(builder, hashOffset);
            if (asset.Dependencies != null && asset.Dependencies.Count > 0)
            {
                Asset.AddDependencies(builder, dependenciesOffset);
            }
            assetOffsets[i] = Asset.EndAsset(builder);
        }

        // 创建Asset列表的Vector
        var assetsVector = AssetBundleManifest.CreateAssetsVector(builder, assetOffsets);

        // 创建AssetBundle名称
        var bundleNameOffset = builder.CreateString(bundleName);

        // 创建AssetBundleManifest
        AssetBundleManifest.StartAssetBundleManifest(builder);
        AssetBundleManifest.AddAssets(builder, assetsVector);
        AssetBundleManifest.AddBundleName(builder, bundleNameOffset);
        var manifestOffset = AssetBundleManifest.EndAssetBundleManifest(builder);

        // 完成构建
        builder.Finish(manifestOffset);

        // 返回byte数组
        return builder.SizedByteArray();
    }

    public class AssetInfo
    {
        public string Name { get; set; }
        public string Path { get; set; }
        public string Hash { get; set; }
        public List<string> Dependencies { get; set; }
    }
}

这个类包含一个 BuildManifest 方法,它接收 AssetBundle 名称和一个 AssetInfo 列表,并使用 FlatBuffers 构建 AssetBundleManifest 数据,最后返回一个 byte 数组。

// 示例用法
public class ExampleUsage : MonoBehaviour
{
    void Start()
    {
        // 模拟一些Asset信息
        var assets = new List<AssetBundleManifestBuilder.AssetInfo>()
        {
            new AssetBundleManifestBuilder.AssetInfo()
            {
                Name = "MyModel",
                Path = "Assets/Models/MyModel.fbx",
                Hash = "e10adc3949ba59abbe56e057f20f883e",
                Dependencies = new List<string>() { "MyMaterial" }
            },
            new AssetBundleManifestBuilder.AssetInfo()
            {
                Name = "MyMaterial",
                Path = "Assets/Materials/MyMaterial.mat",
                Hash = "d41d8cd98f00b204e9800998ecf8427e",
                Dependencies = null
            }
        };

        // 构建AssetBundleManifest
        byte[] manifestData = AssetBundleManifestBuilder.BuildManifest("my_bundle", assets);

        // 将Manifest数据保存到文件
        File.WriteAllBytes(Application.dataPath + "/my_bundle.manifest", manifestData);

        Debug.Log("AssetBundleManifest created successfully!");
    }
}

4. 加载AssetBundleManifest数据

现在,我们可以使用生成的C#类来加载AssetBundleManifest数据。

using UnityEngine;
using FlatBuffers;
using MyGame.AssetManagement;
using System.IO;

public class AssetBundleManifestLoader
{
    public static AssetBundleManifest LoadManifest(byte[] data)
    {
        ByteBuffer bb = new ByteBuffer(data);
        return AssetBundleManifest.GetRootAsAssetBundleManifest(bb);
    }

    public static AssetBundleManifest LoadManifestFromFile(string filePath)
    {
        byte[] data = File.ReadAllBytes(filePath);
        return LoadManifest(data);
    }
}

这个类包含一个 LoadManifest 方法,它接收一个 byte 数组,并使用 FlatBuffers 解析 AssetBundleManifest 数据,最后返回一个 AssetBundleManifest 对象。

// 示例用法
public class ExampleUsage : MonoBehaviour
{
    void Start()
    {
        // 加载AssetBundleManifest
        string manifestPath = Application.dataPath + "/my_bundle.manifest";
        AssetBundleManifest manifest = AssetBundleManifestLoader.LoadManifestFromFile(manifestPath);

        // 访问Asset信息
        for (int i = 0; i < manifest.AssetsLength; i++)
        {
            Asset asset = manifest.Assets(i);
            Debug.Log("Asset Name: " + asset.Name);
            Debug.Log("Asset Path: " + asset.Path);
            Debug.Log("Asset Hash: " + asset.Hash);

            // 访问依赖列表
            if (asset.DependenciesLength > 0)
            {
                Debug.Log("Dependencies:");
                for (int j = 0; j < asset.DependenciesLength; j++)
                {
                    Debug.Log("  " + asset.Dependencies(j));
                }
            }
        }
    }
}

五、优化AssetBundleManifest

为了进一步优化AssetBundleManifest,我们可以考虑以下几点:

  • 字符串池: 将重复出现的字符串(如资源路径、资源名称)存储在一个字符串池中,然后在AssetManifest中引用字符串池中的索引,可以有效减少空间占用。
  • 增量更新: 只存储AssetBundleManifest的变更部分,而不是每次都重新生成整个AssetManifest。
  • 压缩: 使用压缩算法(如LZ4、Zstd)压缩AssetManifest数据,可以进一步减少空间占用。

1. 字符串池的实现

using UnityEngine;
using System.Collections.Generic;
using FlatBuffers;
using MyGame.AssetManagement;
using System.IO;

public class AssetBundleManifestBuilder
{
    private Dictionary<string, StringOffset> stringPool = new Dictionary<string, StringOffset>();
    private FlatBufferBuilder builder;

    public byte[] BuildManifest(string bundleName, List<AssetInfo> assets)
    {
        builder = new FlatBufferBuilder(1024); // 初始大小,可以根据实际情况调整
        stringPool.Clear();

        // 创建Asset列表
        var assetOffsets = new Offset<Asset>[assets.Count];
        for (int i = 0; i < assets.Count; i++)
        {
            var asset = assets[i];
            var nameOffset = GetStringOffset(asset.Name);
            var pathOffset = GetStringOffset(asset.Path);
            var hashOffset = GetStringOffset(asset.Hash);

            // 创建依赖列表
            Offset<VectorOffset> dependenciesOffset = default(Offset<VectorOffset>);
            if (asset.Dependencies != null && asset.Dependencies.Count > 0)
            {
                var dependenciesVector = new StringOffset[asset.Dependencies.Count];
                for (int j = 0; j < asset.Dependencies.Count; j++)
                {
                    dependenciesVector[j] = GetStringOffset(asset.Dependencies[j]);
                }
                dependenciesOffset = Asset.CreateDependenciesVector(builder, dependenciesVector);
            }

            Asset.StartAsset(builder);
            Asset.AddName(builder, nameOffset);
            Asset.AddPath(builder, pathOffset);
            Asset.AddHash(builder, hashOffset);
            if (asset.Dependencies != null && asset.Dependencies.Count > 0)
            {
                Asset.AddDependencies(builder, dependenciesOffset);
            }
            assetOffsets[i] = Asset.EndAsset(builder);
        }

        // 创建Asset列表的Vector
        var assetsVector = AssetBundleManifest.CreateAssetsVector(builder, assetOffsets);

        // 创建AssetBundle名称
        var bundleNameOffset = GetStringOffset(bundleName);

        // 创建AssetBundleManifest
        AssetBundleManifest.StartAssetBundleManifest(builder);
        AssetBundleManifest.AddAssets(builder, assetsVector);
        AssetBundleManifest.AddBundleName(builder, bundleNameOffset);
        var manifestOffset = AssetBundleManifest.EndAssetBundleManifest(builder);

        // 完成构建
        builder.Finish(manifestOffset);

        // 返回byte数组
        return builder.SizedByteArray();
    }

    private StringOffset GetStringOffset(string str)
    {
        if (!stringPool.TryGetValue(str, out StringOffset offset))
        {
            offset = builder.CreateString(str);
            stringPool[str] = offset;
        }
        return offset;
    }

    public class AssetInfo
    {
        public string Name { get; set; }
        public string Path { get; set; }
        public string Hash { get; set; }
        public List<string> Dependencies { get; set; }
    }
}

在这个改进后的版本中,我们引入了一个 stringPool 字典,用于存储已经创建的字符串偏移量。 GetStringOffset 方法首先检查字符串是否已经在字符串池中,如果在,则直接返回已有的偏移量;否则,创建一个新的字符串偏移量,并将其添加到字符串池中。

2. 增量更新的简单思路

增量更新需要记录上一个版本的AssetManifest,并与当前版本的AssetManifest进行比较,找出差异部分。然后,将差异部分存储在一个增量更新文件中。在加载AssetBundle时,先加载基础的AssetManifest,然后加载增量更新文件,将差异部分应用到基础的AssetManifest上。

3. 压缩的简单实现

可以使用Unity提供的System.IO.Compression命名空间下的GZipStream或第三方库(如LZ4, Zstd)来实现压缩和解压缩。

六、FlatBuffers的局限性

虽然FlatBuffers在AssetManifest中有很多优势,但也存在一些局限性:

  • 修改困难: FlatBuffers数据是不可变的,修改需要重新构建整个数据结构。
  • Schema更新: Schema的更新需要谨慎处理,避免破坏向后兼容性。
  • 学习曲线: FlatBuffers需要学习其Schema定义和代码生成方式,有一定的学习成本。

七、其他序列化方案的比较

除了FlatBuffers,还有其他一些序列化方案可以用于AssetManifest的存储,例如:

  • Protocol Buffers: 也是Google开源的一种序列化库,与FlatBuffers类似,但Protocol Buffers更适合于网络传输,而FlatBuffers更适合于本地存储和零拷贝访问。
  • MessagePack: 一种高效的二进制序列化格式,支持多种编程语言,易于使用,但性能不如FlatBuffers。
  • BinaryFormatter (C#): C#自带的二进制序列化器,使用简单,但性能较差,且存在安全风险。
序列化方案 性能 空间占用 易用性 跨平台性
FlatBuffers 极高 极小 较低
Protocol Buffers 较小 较高
MessagePack 较高 较小
BinaryFormatter

八、案例分析

假设我们有一个大型游戏项目,包含了大量的模型、纹理和材质。如果不使用AssetBundle,整个游戏资源都会被打包到主包中,导致初始包体非常大。

通过使用AssetBundle,我们可以将不常用的资源打包成AssetBundle,按需加载。例如,我们可以将不同场景的资源分别打包成AssetBundle,只在需要的时候加载对应的AssetBundle。

同时,使用FlatBuffers作为AssetManifest的存储格式,可以有效提高AssetBundle的加载速度和减少内存占用。

例如,一个包含1000个Asset的AssetManifest,如果使用JSON格式存储,可能需要几百KB甚至几MB的空间,而使用FlatBuffers存储,可能只需要几十KB的空间。在加载AssetBundle时,FlatBuffers的零拷贝访问可以大大减少解析时间。

AssetBundle和FlatBuffers结合使用的优势:

  • 更小的包体: 将资源分离到AssetBundle中,减少主包大小。
  • 更快的加载速度: FlatBuffers的零拷贝访问提高AssetManifest的解析速度。
  • 更低的内存占用: FlatBuffers数据结构紧凑,减少内存占用。
  • 更好的热更新体验: 可以通过更新AssetBundle来实现游戏内容的更新。

九、总结一些想法

FlatBuffers 在 AssetBundle 的 AssetManifest 中是一个非常有价值的选择,尤其是在追求高性能和低内存占用的场景下。通过合理地设计 Schema 和优化数据结构,可以充分发挥 FlatBuffers 的优势,提升游戏的整体性能和用户体验。选择合适的序列化方案需要综合考虑项目需求、性能要求、易用性和团队熟悉程度。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注