Addressable 是本文編寫時 Unity 新的資料管理工具系統,它把我們會用到的 Asset 都打包起來,然後在遊戲中需要 Asset 時,再統一去跟 Addressable 要。
專案有個需求是因為美術的模型會有上百上千個,希望主程式出去後,只要下載需要的獨立資料 .catalog及 .bundle 就好。
這次我們會把模型的 mesh 及 texture 分開打包,最後會得到四個檔案,用這四個檔案代表一個模型。
- mesh.bundle
- mesh.catalog
- texture.bundle
- 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 即可。
再建立我們自己的 Profile(例:ArkDA)。
AddressableAssetSettings 裡,選擇使用自訂的 Profile,勾選 Build Remote Catalog。
但這樣子 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 出分離的獨立資源包了(.catalog 及 .bundle)
非公開 .bundle
一般 .bundle 的資源都是公開由自下載的,Addressable 預設的 Provider 也是用公開的方式下載 .bundle;但如果我們不希望資源公開,防止被不相干的有心人亂下載時,我們要跟 Addressable 說我們要使用自訂的 Provider (例:Ark AssetBundleProvider)
這樣子,當 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 就能修改它
//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 的方法
//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
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;
}
}
這樣能能把 .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 區分了一小包一小包的獨立包,也許可以任意抽換腳本熱更不同的功能,有機會再試看看吧。
想請問Arc大 是怎麼把Bundle清除的呢?
我是用1.16.7(最新版)
然後Call Addressables.ClearDependencyCacheAsync(AssetLabelReference label);
然後無法清除有關此標籤的資源。
還是說 我只能照之前的方法 用Caching.ClearCache 來清除呢?
謝謝!
讚讚
我的 bundle 不是用內建快取的方式,是我自己發 request 下載、自己命名檔案、自己指定存放位置,所以我自己知要去那清除、覆蓋資料。所以大大遇到的 cache 情境我沒處理過耶。
讚讚
好的 瞭解 感謝 arc大回覆!
讚讚
讚哦!
讚讚