在 Unity 使用 AssetBundles 實作進階的遊戲資源打包以及更新機制
在前篇文章中,我們僅是將所有遊戲資源(Assets) 包在同一包 AssetBundle,這會造成小量的資源更新,玩家得重新下載這一大包的 AssetBundle 才行,造成遊戲體驗變得很糟,且也會造成伺服器流量負擔。
在這篇文章裡,我們將考慮開發者所設定的 AssetBundleName,將遊戲資源打包成多個 AssetBundles,讓之後遊戲資源更新時,不需要再載入全部的遊戲資源。
考慮 AssetBundleName 進行打包
首先針對要打包的場景,找出所有相依的資源 (Assets),排除路徑不是在 /Assets 的資源,排除 Scripts 檔案 (MonoImporter):
// using System.IO;
// using System.Linq;
// using UnityEditor;
var scenePaths = CollectScenesPathWithoutEntry();
foreach (var assetPath in AssetDatabase.GetDependencies(scenePaths))
{
if (assetPath.StartsWith("Assets/") &&
!(AssetImporter.GetAtPath(v) is MonoImporter) &&
!scenePaths.Contains(assetPath))
{
// TODO: assetPath 表示需打包的相依資源
}
}
取得每個資源的 AssetBundleName,若沒有設定 AssetBundleName 則給與預設的 AssetBundleName 進行分類,主因是不希望這些沒有 AssetBundleName 資源,因沒有分類打包,最後會跟場景包在一起(Unity 打包機制預設會自動將相依資源打包在同一包):
// using UnityEditor;
const string AssetBundleExtension = ".assetbundle";
const string DefaultAssetBundleName = "base";
var importer = AssetImporter.GetAtPath(assetPath);
var bundleName = (string.IsNullOrEmpty(importer.assetBundleName) ? DefaultAssetBundleName : importer.assetBundleName) + AssetBundleExtension;
使用 Dictionary 資料結構將相同的 AssetBundleName 的資源分類在一起,針對每一個資源呼叫 collectAsset(assetPath, bundleName)
:
// using System;
// using System.Collections.Generic;
var buildAssetBundles = new Dictionary<string, List<string>>();
var isAssetCollected = (Func<string, bool>)((assetPath) => {
foreach (var assetBundle in buildAssetBundles)
{
if (assetBundle.Value.Contains(assetPath))
{
return true;
}
}
return false;
});
var collectAsset = (Action<string, string>)((assetPath, assetBundleName) =>
{
if (!isAssetCollected(assetPath))
{
var assetsList = (List<string>)null;
if (!buildAssetBundles.TryGetValue(assetBundleName, out assetsList))
{
assetsList = new List<string>();
buildAssetBundles.Add(assetBundleName, assetsList);
}
assetsList.Add(assetPath);
}
});
之後將資源分類的資料結構轉換 AssetBundleBuild
,並與場景的資源包建置資訊串在一起,呼叫 Unity API 進行建置 AssetBundle:
// using System.Collection
// using UnityEditor;
var toAssetBundleBuilds = (Func<AssetBundleBuild[]>)(() =>
{
var builds = new AssetBundleBuild[buildAssetBundles.Count];
var i = 0;
foreach (var itr in buildAssetBundles)
{
builds[i] = new AssetBundleBuild()
{
assetBundleName = itr.Key,
assetNames = itr.Value.ToArray(),
};
i += 1;
}
return builds;
});
var assetBundleBuilds = new List<AssetBundleBuild>(toAssetBundleBuilds());
assetBundleBuilds.Add(new AssetBundleBuild
{
assetBundleName = ScenesAssetBundleName + AssetBundleExtension,
assetNames = CollectScenesPathWithoutEntry(),
});
BuildPipeline.BuildAssetBundles(outputPath, assetBundleBuilds.ToArray(), buildAssetBundleOptions, buildTarget);
因此完成一個較完整考慮 AssetBundleName 的打包的範例:
using System;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
static partial class BuildExample
{
const string AssetBundleExtension = ".assetbundle";
const string DefaultAssetBundleName = "base";
const string ScenesAssetBundleName = "scenes";
static string[] CollectScenesPathWithoutEntry()
{
var paths = new List<string>(EditorBuildSettings.scenes.Where(v => v.enabled).Select(v => v.path));
paths.RemoveAt(0);
return paths.ToArray();
}
static AssetBundleManifest BuildAssetBundles(string outputPath, BuildTarget buildTarget, BuildAssetBundleOptions buildAssetBundleOptions = BuildAssetBundleOptions.None)
{
// Data strcuture & function define
var buildAssetBundles = new Dictionary<string, List<string>>();
var isAssetCollected = (Func<string, bool>)((assetPath) => {
foreach (var assetBundle in buildAssetBundles)
{
if (assetBundle.Value.Contains(assetPath))
{
return true;
}
}
return false;
});
var collectAsset = (Action<string, string>)((assetPath, assetBundleName) =>
{
if (!isAssetCollected(assetPath))
{
var assetsList = (List<string>)null;
if (!buildAssetBundles.TryGetValue(assetBundleName, out assetsList))
{
assetsList = new List<string>();
buildAssetBundles.Add(assetBundleName, assetsList);
}
assetsList.Add(assetPath);
}
});
var toAssetBundleBuilds = (Func<AssetBundleBuild[]>)(() =>
{
var builds = new AssetBundleBuild[buildAssetBundles.Count];
var i = 0;
foreach (var itr in buildAssetBundles)
{
builds[i] = new AssetBundleBuild()
{
assetBundleName = itr.Key,
assetNames = itr.Value.ToArray(),
};
i += 1;
}
return builds;
});
// For each dependencies assets
var scenePaths = CollectScenesPathWithoutEntry();
foreach (var assetPath in AssetDatabase.GetDependencies(scenePaths))
{
if (assetPath.StartsWith("Assets/") && Path.GetExtension(assetPath) != ".cs")
{
var importer = AssetImporter.GetAtPath(assetPath);
var bundleName = string.IsNullOrEmpty(importer.assetBundleName) ? DefaultAssetBundleName : importer.assetBundleName;
collectAsset(assetPath, bundleName + AssetBundleExtension);
}
}
var assetBundleBuilds = new List<AssetBundleBuild>(toAssetBundleBuilds());
assetBundleBuilds.Add(new AssetBundleBuild
{
assetBundleName = ScenesAssetBundleName + AssetBundleExtension,
assetNames = CollectScenesPathWithoutEntry(),
});
return BuildPipeline.BuildAssetBundles(outputPath, assetBundleBuilds.ToArray(), buildAssetBundleOptions, buildTarget);
}
}
遊戲專案通常會根據其特殊需求,在自行調整其打包方式,例如場景 Level1 & Level2 獨立成一包,或是場景 Level1 & Level2 應該跟其相依資源 Asset5~Asset9 一起打包等等,變化非常多種。
除此之外,可以考慮將資源所在的資料夾路徑做為打包參考,將指定目錄夾以及其子資料夾內,所有用到的遊戲資源都打包一起,而不用額外設定 AssetBundleName,讓約定優於配置 (Convention over configuration)。
本文範例就此打住,打包的關鍵就是產生 AssetBundleBuild
,告訴 Unity 怎麼打包遊戲的 AssetBundles。
另外每次遊戲資源更新,都需要重新執行如此的建置流程,Unity 會自動比對資源有沒有改變,來決定是否需要重新打包,亦或是使用以下建置參數,強迫重新打包:
BuildAssetBundleOptions.ForceRebuildAssetBundle
注意 Unity 會使用資源的 GUID 作為其參考,確保每次重新打包後,資源會擁有相同的參考 id,使用參考的連結不會遺失。更多細節可參考官方說明。
建置 AssetBundles 載入設定
相較於前篇文章中只有一個 AssetBundle 需要載入,修改打包機制後可能會多數個 AssetBundles 需要被載入,如何決定載入的順序以及載入所需的 Name/ Hash/ CRC 設定檔的產生,便會是一個需要解決的問題,我們並沒有使用 Unity 所提供的 AssetBundleManifest 作為載入設定檔,而是因應伺服器架構自己建套機制來處理。
考慮建置完 AssetBundle 後的主要 manifest 檔案:
ManifestFileVersion: 0
CRC: 3474251144
AssetBundleManifest:
AssetBundleInfos:
Info_0:
Name: scenes
Dependencies:
Dependency_0: base
Dependency_1: ui
Dependency_2: level1
Dependency_3: level2
Info_1:
Name: base
Dependencies: {}
Info_2:
Name: ui
Dependencies:
Dependency_0: base
Info_3:
Name: level1
Dependencies:
Dependency_0: base
Dependency_1: ui
Info_4:
Name: level2
Dependencies:
Dependency_0: base
從中可以畫出相依圖,並得知載入順序應該為 base > ui > level2 > level1 > scenes:
我們可以從 BuildPipeline.BuildAssetBundles
的回傳值,取得建置後的主要 manifest,藉由相依性來計算載入順序,當一個 AssetBundle 被相依的次數越多時,表示它應該優先被載入:
// using System.Linq;
// using UnityEngine;
// using UnityEditor;
static string[] GetAssetBundleNamesInLoadOrder(AssetBundleManifest manifest)
{
// Get all assetbundles
var assetBundleDefs = new Dictionary<string, int>();
foreach (var assetBundle in manifest.GetAllAssetBundles())
{
assetBundleDefs.Add(assetBundle, 0);
}
// Compute dependency count
foreach (var assetBundle in manifest.GetAllAssetBundles())
{
foreach (var depAssetBundle in manifest.GetAllDependencies(assetBundle))
{
assetBundleDefs[depAssetBundle] += 1;
}
}
// Sort and output
return assetBundleDefs.OrderByDescending(kp => kp.Value).Select(v => v.Key).ToArray();
}
決定 AssetBundles 載入順序後,順便計算取得每一個 AssetBundle 的 Hash/ CRC:
// using UnityEditor;
// using System.IO;
static bool GetAssetBundleHashAndCRC(string outputPath, string assetBundleName, out Hash128 hash, out uint crc)
{
var assetBundlePath = Path.GetFullPath(outputPath) + Path.DirectorySeparatorChar + assetBundleName;
if (!BuildPipeline.GetHashForAssetBundle(assetBundlePath, out hash))
{
crc = 0;
return false;
}
if (!BuildPipeline.GetCRCForAssetBundle(assetBundlePath, out crc))
{
return false;
}
return true;
}
將這些資訊存成專案用的載入設定檔,建立一套資料結構來儲存:
// using System;
// using UnityEngine;
[Serializable]
class AssetBundleLoadConfigs
{
public AssetBundleLoadData[] AssetBundles;
}
[Serializable]
struct AssetBundleLoadData
{
public string Name;
public string Hash;
public uint CRC;
public Hash128 Hash128
{
get
{
return Hash128.Parse(this.Hash);
}
set
{
this.Hash = value.ToString();
}
}
}
從建置資源包所產生的主要 manifest,建立之後載入 AssetBundles 設定的完整範例,注意我們將上述的設定檔使用 JSON 格式另外儲存輸出:
using System;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
static partial class BuildExample
{
static string[] GetAssetBundleNamesInLoadOrder(AssetBundleManifest manifest)
{
var assetBundleRefs = new Dictionary<string, int>();
foreach (var assetBundle in manifest.GetAllAssetBundles())
{
assetBundleRefs.Add(assetBundle, 0);
}
foreach (var assetBundle in manifest.GetAllAssetBundles())
{
foreach (var depAssetBundle in manifest.GetAllDependencies(assetBundle))
{
assetBundleRefs[depAssetBundle] += 1;
}
}
return assetBundleRefs.OrderByDescending(kp => kp.Value).Select(v => v.Key).ToArray();
}
static bool GetAssetBundleHashAndCRC(string outputPath, string assetBundleName, out Hash128 hash, out uint crc)
{
var assetBundlePath = Path.GetFullPath(outputPath) + Path.DirectorySeparatorChar + assetBundleName;
if (!BuildPipeline.GetHashForAssetBundle(assetBundlePath, out hash))
{
crc = 0;
return false;
}
if (!BuildPipeline.GetCRCForAssetBundle(assetBundlePath, out crc))
{
return false;
}
return true;
}
static AssetBundleLoadConfigs GenerateLoadConfigs(string manifestDir, AssetBundleManifest manifest, string configsName = "configs.json")
{
var assetBundleNames = GetAssetBundleNamesInLoadOrder(manifest);
var assetBundleLoads = new List<AssetBundleLoadData>(assetBundleNames.Length);
foreach (var assetBundleName in assetBundleNames)
{
Hash128 hash;
uint crc;
if (!GetAssetBundleHashAndCRC(manifestDir, assetBundleName, out hash, out crc))
{
throw new Exception("Cannot find assetBundle - " + assetBundleName);
}
assetBundleLoads.Add(new AssetBundleLoadData()
{
Name = assetBundleName,
Hash128 = hash,
CRC = crc,
});
}
var configs = new AssetBundleLoadConfigs()
{
AssetBundles = assetBundleLoads.ToArray(),
};
// 輸出載入設定檔
var configsPath = Path.GetFullPath(manifestDir) + Path.DirectorySeparatorChar + configsName;
File.WriteAllText(configsPath, JsonUtility.ToJson(configs));
return configs;
}
}
結合以上 AssetBundle 打包以及產生載入設定檔的範例,改寫前篇文章的建置流程,將專案資源依照 AssetBundleName 進行分類打包,並且產生載入設定檔,描述載入 AssetBundles 順序以及參數 (Hash & CRC):
static void BuildProject(string outputPath, BuildTarget buildTarget = BuildTarget.Android, BuildOptions buildOptions = BuildOptions.None , BuildAssetBundleOptions buildAssetBundleOptions = BuildAssetBundleOptions.None)
{
// Check output path
var bundleOutputDir = Path.GetFullPath(Path.GetDirectoryName(outputPath));
var playerOutputPath = outputPath;
if (!Directory.Exists(bundleOutputDir))
{
Directory.CreateDirectory(bundleOutputDir);
}
// Build assetbundles
var manifest = BuildAssetBundles(bundleOutputDir, buildTarget, buildAssetBundleOptions);
GenerateLoadConfigs(bundleOutputDir, manifest);
// Build Player
var bundleManifestPath = bundleOutputDir + Path.DirectorySeparatorChar + Path.GetFileName(bundleOutputDir) + ".manifest";
var buildPlayerOptions = new BuildPlayerOptions()
{
target = buildTarget,
scenes = new string[] { EditorBuildSettings.scenes.Where(v => v.enabled).Select(v => v.path).First() },
options = buildOptions,
locationPathName = playerOutputPath,
assetBundleManifestPath = bundleManifestPath,
};
var result = BuildPipeline.BuildPlayer(buildPlayerOptions);
if (!string.IsNullOrEmpty(result))
{
throw new Exception(result);
}
// Open folder if not batch mode
if (!UnityEditorInternal.InternalEditorUtility.inBatchMode)
{
System.Diagnostics.Process.Start(bundleOutputDir);
}
}
完整的打包範例 BuildExample
,可參考放在 Gist 上的檔案。
載入設定檔以及 AssetBundles
透過任何手段讀取上節所產生的設定檔,如果設定檔放在網路則使用 UnityWebRequest
,如果放在實體檔案路徑則使用 File
開檔讀取。
之後 JsonUtility.FromJson<AssetBundleLoadConfigs>
取得設定檔資料,針對每一個 AssetBundleLoadData
,使用上篇文章中的載入 AssetBundle 範例,使用 UnityWebRequest
加載其資源,然後按照其正常流程繼續遊戲即可。
若遊戲在下載該些 AssetBundles 過程中,需要顯示 AssetBundles 檔案大小的話,在下載前先送出 HEAD 要求,取得回應中的標頭 (Header) 中的 CONTENT-LENGTH,來取得檔案大小(HTTP 通訊協定可參考這篇文章):
var headRequest = UnityWebRequest.Head(url);
yield return headRequest;
if (headRequest.isError || headRequest.responseCode != 200)
{
// TODO: Handle errors (or responseCode != 200)
}
// 取得 AssetBundle 檔案大小 in bytes
var size = 0L;
long.TryParse(headRequest.GetResponseHeader("CONTENT-LENGTH"), out size);
系列文章
- Unity AssetBundle 快取機制與載入範例
- 在 Unity 使用 AssetBundles 實作簡易的遊戲資源打包以及更新機制
- 在 Unity 使用 AssetBundles 實作進階的遊戲資源打包以及更新機制
- Unity AssetBundle Variants 機制研究筆記
- Unity AssetBundle 資料列表載入以及打包架構思考,使用 Pokémon 作為範例
沒有留言: