Unity Addressable 獨立資源包

Addressable 是本文編寫時 Unity 新的資料管理工具系統,它把我們會用到的 Asset 都打包起來,然後在遊戲中需要 Asset 時,再統一去跟 Addressable 要。

專案有個需求是因為美術的模型會有上百上千個,希望主程式出去後,只要下載需要的獨立資料 .catalog及 .bundle 就好。

這次我們會把模型的 mesh 及 texture 分開打包,最後會得到四個檔案,用這四個檔案代表一個模型。

  1. mesh.bundle
  2. mesh.catalog
  3. texture.bundle
  4. texture.catalog

我們為此建立了三個專案

BuildAssetProject:用來把放在此專案裡的美術檔 Build 成 Addressable Asset,支援 -batchmode 命令模型,可以直接下命令去執行專案 build 出我們要的 bundle/Catalog。

MainAppProject:主應程式,下載 bundle/Catalog 回來使用。

DotNetToolProject:工具專案,用來 copy 美術檔至 BuildAssetProject,並用命令模式 -batchmode 執行 BuildAssetProject ,產生 bundle/Catalog,並將 bundle/Catalog 上傳至 Server 。(本文不說明它,與 Unity 無關)

Build Asset

要 Build 出獨立的 Addressable Asset 很簡單,先建 Group (例 ArkRemoteAsset)。

每次 Build Addressable 時,Group 裡只放需要的 Asset 即可。

例:代號 1680_637021963818784962 的模型 Asset 資料

再建立我們自己的 Profile(例:ArkDA)。

AddressableAssetSettings 裡,選擇使用自訂的 Profile,勾選 Build Remote Catalog。

勾選 BuildRemoteCatalog

但這樣子 Build 出來的 .bundle 是一組 id , 我們可以自己寫一個 BuildScriptPackedMode 去修改最後輸出的檔名。

專門用來處理 Texture bundle 的範例:

[CreateAssetMenu(fileName = "ArkBuildTextureScript.asset", menuName = "Addressable Assets/Data Builders/Ark Build Texture")]
public class ArkBuildTextureScript : BuildScriptPackedMode
{
    public override string Name => "Ark Build Texture";

    protected override string ConstructAssetBundleName(AddressableAssetGroup assetGroup, BundledAssetGroupSchema schema, BundleDetails info, string assetBundleName)
    {
        
        return ArkBuildAssetCmd.GUID + ".texture.bundle";
    }

}
Build 選單會出現選項

這樣子就能 Build 出分離的獨立資源包了(.catalog 及 .bundle)

非公開 .bundle

一般 .bundle 的資源都是公開由自下載的,Addressable 預設的 Provider 也是用公開的方式下載 .bundle;但如果我們不希望資源公開,防止被不相干的有心人亂下載時,我們要跟 Addressable 說我們要使用自訂的 Provider (例:Ark AssetBundleProvider)

例:AssetBundle Provider 設為自訂的 Ark AssetBundle Provider

這樣子,當 Addressable 要需要 Asset 檔案時,會先呼叫自訂的 Provider ,然後交由它發 Request 給 Server 證証的方式把檔案下載下來。

Provider 範例 Code:

[DisplayName("Ark AssetBundle Provider")]
public class ArkAssetBundleProvider : ResourceProviderBase
{

	/// <inheritdoc/>
	public override void Provide(ProvideHandle providerInterface)
	{
		Debug.Log("ArkAssetBundleProvider.Provide");
		new ARKWebReqOp().Start(providerInterface);
	}
	/// <inheritdoc/>
	public override Type GetDefaultType(IResourceLocation location)
	{
		return typeof(IAssetBundleResource);
	}
	
}

ARKWebReqOp 是另外一個 Class,我把下載資源的部分抽出來另外實作。

ARKWebReqOp 繼承 IAssetBundleResource 實作 GetAssetBundle() function

public class ARKWebReqOp : IAssetBundleResource
{
	ProvideHandle provideHandle;
	AssetBundle AssetBundleContent;
    public AssetBundle GetAssetBundle()
    {
        return AssetBundleContent;
    }
    public void Start(ProvideHandle p)
	{
		provideHandle = p;
        Debug.Log("ARKWebReqOP InternalId: " + provideHandle.Location.InternalId);
        Debug.Log("ARKWebReqOP PrimaryKey: " + provideHandle.Location.PrimaryKey);
}

在 Start Function 裡有一個 ProvideHandle 參數,我們能由它得知 Addressable 這時需求的檔案的資訊,再依這資訊去跟我們自己的 Server 要檔案,最後也是透過它告知 Addressable 我們下載結束了。

Webrequest 範例:這個下載的 Request 需要 Accestoken

        JObject rootobj = new JObject();
        rootobj.Add("id", provideHandle.Location.PrimaryKey);

        string jstr = rootobj.ToString();
        byte[] bodyRaw = Encoding.UTF8.GetBytes(jstr);

        string url = "https://myserver/download/url";
        UnityWebRequest req = new UnityWebRequest(url, "POST");
        req.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
        req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();

        req.SetRequestHeader("Accept", "application/json");
        req.SetRequestHeader("Content-Type", "application/json");
        req.SetRequestHeader("Authorization", "Bearer " + ArkBaseGameData.Instance.AccessToken());

        UnityWebRequestAsyncOperation reqop = req.SendWebRequest();
        reqop.completed += DownlaodAssetCompleted;

下載完的資料要把 WebRequest 裡的資料載入變成 AssetBundle 類別。

AssetBundleContent = AssetBundle.LoadFromMemory(webReq.downloadHandler.data);

或存下來變成檔案再載入成 AssetBundle 類別。

AssetBundleContent = AssetBundle.LoadFromFile(path);

載入成 AssetBundle 後,呼叫 ProvideHandle 的 Complete Function,因為 ARKWebReqOp 有實作 IAssetBundleResource 介面,所以 Addressable 能由 GetAssetBundle() 介面取得 AssetBundle。

public class ARKWebReqOp : IAssetBundleResource
{
    AssetBundle AssetBundleContent;
    public AssetBundle GetAssetBundle()
    {
        return AssetBundleContent;
    }

    void GetAssetFromDAComplete(bool success, ArkPublicDADownloader dd)
    {
        provideHandle.Complete(this, true, null);
    }
}

Build Asset 自動化

前面我們己能手動 Build 出 Addressable asset 了,為了讓這動作更輕鬆,我們加了入些自動化的處理。

我們先該 BuildAssetProject 能支援 Command 模式,可以直接下命令去執行專案 build 出我們要的 Asset。

public static class ArkBuildAssetCmd
{
    public static string GUID;

    [MenuItem("Ark/StartBuildAsset", false, 50)]
    static public void StartBuildAsset()
    {
    }
}

我們就能用命令列去啓動 Unity,-batchmode 命令模式,呼叫 ArkBuildAssetCmd.StartBuildAsset 這個靜態的 Function

命令範例:

E:\Progrem\2019.3.3f1\Editor\Unity.exe -batchmode -target Standalone -GUID 1680_637021963818784962 -TextureCompression 1 -projectPath E:\BuildAssetProject -executeMethod ArkBuildAssetCmd.StartBuildAsset -logFile BuildAssetStandalone.log

上面的命令參數有幾個是我們自己加的

-target:決定我們要 Build 出那個平台的 Asset

-GUID:模型的 ID

-TextureCompression:貼圖的壓縮

我們在 ArkBuildAssetCmd.StartBuildAsset 裡分析命令,命令的內容可以由下列方式取得

System.Environment.GetCommandLineArgs();

分析命令範例:

public static class ArkBuildAssetCmd
{

    public static string GUID;
    public static TextureImporterCompression TextureCompressMode = TextureImporterCompression.Compressed;

    public static Dictionary<string, string> EnvArgs = new Dictionary<string, string>();


    [MenuItem("Ark/StartBuildAsset", false, 50)]
    static public void StartBuildAsset()
    {
        try
        {
            settings = AddressableAssetSettingsDefaultObject.Settings;
            ParseCmdArg();

            string platform = GetArgValue("-target");
            string compress = GetArgValue("-TextureCompression")
            GUID = GetArgValue("-GUID");
        }
    }

    static void ParseCmdArg()
    {
        EnvArgs.Clear();
        string[] cmdArguments = System.Environment.GetCommandLineArgs();

        for (int i = 0; i < cmdArguments.Length; i++)
        {
            string arg = cmdArguments[i];
            if (arg.StartsWith("-"))
            {
                string value = null;

                if ((i + 1) < cmdArguments.Length)
                {
                    value = cmdArguments[i + 1];
                    if (value.StartsWith("-"))
                    {
                        value = null;
                    }
                }

                EnvArgs.Add(arg, value);
                Debug.Log("getarg: " + arg + "=" + value);
            }
        }
    }

    static string GetArgValue(string argname)
    {
        if (EnvArgs.ContainsKey(argname))
        {
            return EnvArgs[argname];
        }

        return null;
    }

}

這樣字串處理一下就能抓到我們要的設定值了,然後自己處理。

設定平台

    static void BuildTargetPlatform(string platform)
    {
        Debug.Log("BuildTargetPlatform Start :" + platform);

        if (platform == "Standalone")
        {
            EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64);
        }
        else if (platform == "Android")
        {
            EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Android, BuildTarget.Android);
        }
        else if (platform == "iOS")
        {
            EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.iOS, BuildTarget.iOS);
        }
        else if (platform == "WebGL")
        {
            EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.WebGL, BuildTarget.WebGL);
        }
        else
        {
            if (Application.isBatchMode)
            {
                Debug.Log("target platform NOT support :" + platform);
                return;
            }
        }
}

取出 Addressable 的 setting 出來設定。

AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.Settings;

取出 Profile ID,我們在 Editor 裡有設定了一組 Profile ,以下用程式碼把它 ID 取出來,有 ID 就能修改它

Editor 裡原先設好一個 Profile
//ARK_PROFILE_NAME = "ArkDA"
string profildid = settings.profileSettings.GetProfileId(ARK_PROFILE_NAME);

我們就能設定 Profile 裡的值。

settings.profileSettings.SetValue(profildid, "RemoteBuildPath", RemoteBuildPath);
settings.profileSettings.SetValue(profildid, "LocalBuildPath", LocalBuildPath);

也可以取出 Profile 裡的值。

        LocalBuildPath = settings.profileSettings.GetValueByName(profildid, "LocalBuildPath");
        LocalBuildPath = settings.profileSettings.EvaluateString(profildid, LocalBuildPath);

        RemoteBuildPath = settings.profileSettings.GetValueByName(profildid, "RemoteBuildPath");
        RemoteBuildPath = settings.profileSettings.EvaluateString(profildid, RemoteBuildPath);

Impoart Asset

把所有要打包的美術檔都 Copy 到 BuildAssetProject 專案,然後再使用相對於 ProjectDir 的路徑(例: string assetpath =Assets\ModelSource\1680_637021963818784962\1680_637021963818784962_solid_1_baseColor.png),去一個一個 Import 它們。

AssetDatabase.ImportAsset(assetpath);

Asset 要透過 Import 才能打包使用。這裡會觸發 AssetPostprocessor

AssetPostprocessor

AssetPostprocessor 範例,看我們想做什麼處理可以寫在這裡

public class ArkAssetImportProc : AssetPostprocessor
{

    void OnPreprocessTexture()
    {
    }

    static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
    {
        foreach (string assetPath in importedAssets)
        {
        }

    }
}

Textrue 的部份,我們是依不同類型的貼圖去設定 sRGBTexture(Shader 算法會不一樣)

        if (assetPath.ToLower().Contains("basecolor"))
        {
            Debug.Log("sRGBTexture");
            TextureImporter textureImporter = (TextureImporter)assetImporter;
            textureImporter.sRGBTexture = true;
            textureImporter.textureCompression = ArkBuildAssetCmd.TextureCompressMode;
        }
        else
        {

            Debug.Log("LInear");
            TextureImporter textureImporter = (TextureImporter)assetImporter;
            textureImporter.sRGBTexture = false;
        }

Mesh 的部份,我們是用 GLTF,把它變成 Prefab 後再做成 .bundle

                //create prefab.
                GameObject modelRootGO = AssetDatabase.LoadMainAssetAtPath(meshassetPath) as GameObject;

                if (modelRootGO != null)
                {

                    GameObject instanceRoot = (GameObject)PrefabUtility.InstantiatePrefab(modelRootGO);

                    string savepath = path + Path.DirectorySeparatorChar + filenamenoext + extname + ".prefab";
                    Debug.Log("save prefab:" + savepath);
                    GameObject variantRoot = PrefabUtility.SaveAsPrefabAsset(instanceRoot, savepath);

設定 Asset 的 Addressable Group

找出 Group 的方法

Editor 裡原先設好一個 Group
//ARK_REOMTE_GROUP_NAME = "ArkRemoteAsset"
AddressableAssetGroup group = GetOrCreateGroup(settings, ARK_REOMTE_GROUP_NAME);
static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName)
     {
        AddressableAssetGroup group = null;
        if (!TryGetGroup(settings, groupName, out group))
        {
            group = CreateAssetGroup<BundledAssetGroupSchema>(settings, groupName);
        }
        
        return group;
    }

    static bool TryGetGroup(AddressableAssetSettings settings, string groupName, out AddressableAssetGroup group)
    {
        if (string.IsNullOrWhiteSpace(groupName))
        {
            group = settings.DefaultGroup;
            return true;
        }
        return ((group = settings.groups.Find(g => string.Equals(g.Name, groupName.Trim()))) == null) ? false : true;
    }

    static AddressableAssetGroup CreateAssetGroup<SchemaType>(AddressableAssetSettings settings, string groupName)
    {
        return settings.CreateGroup(groupName, false, false, false, new List<AddressableAssetGroupSchema> { settings.DefaultGroup.Schemas[0] }, typeof(SchemaType));
    }

用 Asset 的路徑(例:Assets\ModelSource\1680_637021963818784962\1680_637021963818784962_solid_1_baseColor.png)找出 Asset 的 guid,把 Asset 加入 Group

string guid = AssetDatabase.AssetPathToGUID(assetpath);
AddressableAssetEntry entry = settings.CreateOrMoveEntry(guid, group);

找出 Builder

我們之前寫好的 Buider
        int builderidx = -1;
        int targetbuilderidx = -1;
        foreach (var bu in settings.DataBuilders)
        {
            builderidx++;
            BuildScriptPackedMode bspm = bu as BuildScriptPackedMode;
            if (bspm != null)
            {
//ARK_TEXTURE_BUILDER_NAME = "Ark Build Texture"
                if (bspm.Name == ARK_TEXTURE_BUILDER_NAME)
                {
                    targetbuilderidx = builderidx;
                    break;
                }
            }
        }

設定要使用的 Builder 的 Index

AddressableAssetSettingsDefaultObject.Settings.ActivePlayerDataBuilderIndex = targetbuilderidx;

開始 Build Player Content

AddressableAssetSettings.BuildPlayerContent();

Build 出來的結果是這樣

整理 Bundle/Catalog

目前只有 .bundle 變成我們要的檔名(透過 BuildScriptPackedMode)。再來是把 .catalog 找出來,變成我們要的檔名方便日後識別。

我們拼出 setting.json 的路徑讀取它,在裡面找出這次 build 到 remote 資料夾的名稱,再拼成最後 .catalog 的檔名。

//ARK_BUILD_SETTING_JSON_NAME  = "settings.json"   
string settingpath = Path.GetFullPath(Path.Combine(LocalBuildPath, ".."));
        settingpath = settingpath + Path.DirectorySeparatorChar + ARK_BUILD_SETTING_JSON_NAME;
        string settingtext = File.ReadAllText(settingpath);
        JObject assetsettingjobj = JObject.Parse(settingtext);
        JArray loactions = (JArray)assetsettingjobj["m_CatalogLocations"];

        string oldcatloghashname = "";
        for (int i = 0; i < loactions.Count; i++)
        {
            JObject loaction = (JObject)loactions[i];

            string m_InternalId = (string)loaction["m_InternalId"];

            if (m_InternalId.ToLower().Contains(".hash"))
            {
                oldcatloghashname = Path.GetFileNameWithoutExtension(m_InternalId) + ".json";
                break;
            }
        }
setting.json 內容長這樣

這樣能能把 .catalog copy 出來改成我們要的檔名了。

    string targetfilepath = RemoteBuildPath + Path.DirectorySeparatorChar + oldcatloghashname;
    string newfilename = RemoteBuildPath + Path.DirectorySeparatorChar + GUID + ".texture.catalog";

    Debug.Log("BuildTexture Copy catalog ..." + newfilename);
    File.Copy(targetfilepath, newfilename, true);

我們最後就能得到我們需要的檔案了(本例是把 Mesh 跟 Texture 分成二個 bundle。)

這些檔案就代表了 1680_637021963818784962 這個模型。 未來我們要顯示這個模型時,只要下載這四個檔案就可以了。之後有新模型時,再做一次打包的動作,只包入新模型至 Group 裡,這樣就不用更新主程式了。

主程式 Addressable 設定

因為主程式沒有要做分離的資源包,所以 Build Remote Catalog 不用勾選,不然變成執行檔後,主程式會試著去下載 Catalog 造成程式啓動變慢。

沒打包,不用另建 Profile 及 Group

Builder 也用預設的就好

包版本前要先執行一次 Addressable Build Script (Addressable Group > Build > New Build > Default Build Script),這樣子 Unity 才會把 Addressable Library 包進主程式裡,避免在執行時呼叫到 Addressables 就造成當機。

加入自訂的 Provider (例:ArkAssetBundleProvider),再 Initialize addressable

    Addressables.ResourceManager.ResourceProviders.Add(new ArkAssetBundleProvider());
    //Addressables.ResourceManager.ResourceProviders.Add(new ArkJsonBundleProvider());
    yield return Addressables.InitializeAsync();

ArkAssetBundleProvider 的 Code 記得主程式裡也要有一份哦

下載及載入.Catalog

使用時,我們只需要依目前要載入什麼模型,手動跟 Server 下載 .catalog 檔,下載回來的 .catalog 要透過 Addressables.LoadContentCatalogAsync 載入,這樣子 Addressable 才能知道 Asset 在那個 bundle 裡及 bundle 要怎麼取得。

AsyncOperationHandle op = Addressables.LoadContentCatalogAsync(catalogpath);

我們目前用的版本(1.8.4) LoadContentCatalogAsync 只有實體路徑的介面,沒有 MemoryStream 的介面,所以只能先存起來再給它路徑。

載入 Asset

當我們需要某 Asset 時(例如:呼叫 InstantiateAsync、LoadAssetAsync ), Addressable 就會依己載入的 .cagalog 裡的資訊,在裡面找 Asset 的相關資訊,去決定用什麼 Provider 去下載那一包 .bundle 回來。

AsyncOperationHandle<GameObject> op = Addressables.InstantiateAsync(assetname, transform);
AsyncOperationHandle<Texture2D> op = Addressables.LoadAssetAsync<Texture2D>(assetname);

Material 呢?

因為這些模型我有寫另外的工具去設定要用什麼材質,能用的材質種類是事先規劃好的,跟著主程式發佈出去。所以就只包了 Mesh 及 Texture,這樣子的做法能統一管理/修改材質設定,不用重打包,如果有上千個模型因為 material 要重包真的會累死人。

結語

原本的方案是 GLTF 與 PNG 直接上傳 Server,不包成 Addressable Asset,然後在主程式裡直接動態把 GLTF 轉成 Mesh,PNG 轉成 Texture,且 Linear Texture 又無法非同步轉換,這些動態的轉換都會花費很長的時間,讓使用者體驗不佳;所以才變成了預先做成 Addressable Asset 了。

目前專案用這方式運作了很久了,Unral / Unity 版都是用類似的概念來做,主程式就不用包 Model 輕量了不少。未來若配合了 Unity Bolt ,把 Bolt Asset 區分了一小包一小包的獨立包,也許可以任意抽換腳本熱更不同的功能,有機會再試看看吧。

5 thoughts on “Unity Addressable 獨立資源包

  1. 想請問Arc大 是怎麼把Bundle清除的呢?
    我是用1.16.7(最新版)
    然後Call Addressables.ClearDependencyCacheAsync(AssetLabelReference label);
    然後無法清除有關此標籤的資源。
    還是說 我只能照之前的方法 用Caching.ClearCache 來清除呢?
    謝謝!

    1. 我的 bundle 不是用內建快取的方式,是我自己發 request 下載、自己命名檔案、自己指定存放位置,所以我自己知要去那清除、覆蓋資料。所以大大遇到的 cache 情境我沒處理過耶。

發表留言