Unity Addressable Asset System 簡介
在開始介紹 Addressable 系統之前,我們先來簡單回顧一下 AssetBundle 的運作方式。
首先,在 Unity 裡面,資源的載入大該分為靜態加載、Resources.Load、與AssetBundle.LoadAsset三種。
基本上就是直接在 Editor 的 Inspector 視窗中掛 Reference。
例如你可以在某個腳本中宣告 public GameObject myGameObj;
或是 [SerializeField] private Texture2D myTexture;
接著把目標資源拉進 Inspector 中的欄位當作參考。
這樣的方式就屬於靜態加載,在掛著參考的腳本生成實體時,這個參考資源會自動被載入。
Unity 的 Resources 資料夾想必大家都很熟悉,Resources.Load
方法可以很方便的直接載入Resources 資料夾內的資源。然而,根據官方的 教學文件,Resources 系統的最佳使用方式則是:不要使用它。
這邊列出幾個 Resources 系統存在的問題:
當然 Resources 系統既然存在,就有適合使用它的地方,適合使用的情境可以直接參考上述教學文件。
在使用 AssetBundle 載入資源之前,需要先將資源進行打包,打包的方法各式各樣,每個專案都不盡相同,這邊我就舉最基本的例子。
//程式碼指定打包哪些資源
AssetBundleBuild build = new AssetBundleBuild();
build.assetBundleName = "testAB"
build.assetNames = new[] { "Assets/testAsset.prefab" }; //打包這個prefab資源
buildMap.Add(build);
BuildPipeline.BuildAssetBundles(bundlePath, buildMap, buildOption, platform);
//直接打包所有已設定為AssetBundle的資源
BuildPipeline.BuildAssetBundles(bundlePath, buildOption, platform);
打包好資源後,AssetBundle 載入資源分為兩個步驟,先載入 AssetBundle 本身,再載入 AssetBundle 裡面的資源,直接上簡單的程式碼。
IEnumerator Load(){
//載入AssetBundle本身
UnityWebRequest webRequest = UnityWebRequestAssetBundle.GetAssetBundle(url);
webRequest.SendWebRequest();
yield return new WaitUntil(() => webRequest.isDone);
//載入AssetBundle內的資源
AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(webRequest);
AssetBundleRequest request = bundle.LoadAssetAsync(prefab, typeof(GameObject));
yield return request;
}
接下來講講大部分開發者(包含我自己)容易自動忽略的部分,資源的釋放。
AssetBundle 的 UnLoad 方法需要傳入 true 或 false 參數,以下做個簡易表格來比較兩者差異。
AssetBundle.UnLoad(true) | AssetBundle.UnLoad(false) |
---|---|
可釋放AssetBundle本身,無法再使用AssetBundle.LoadAsset等功能 | |
從AssetBundle載出來的資源會一並釋放 | 從AssetBundle載出來的資源不受影響 |
好處是 不會有重複載入資源的問題 |
好處是 不用擔心有某人正在使用的某資源會一起被釋放 |
壞處是 若有其他人使用到AssetBundle載出來的資源 使用到的部分會直接消失 |
壞處是 容易有重複載入資源的問題 導致記憶體空間的浪費 |
某個遊戲物件使用到 AssetBundle 的部分被 UnLoad(true)
這邊舉一個遊戲物件的材質被 UnLoad(true) 的例子,想必各位對這個顏色都非常熟悉吧。
重複載入資源
這邊用幾張圖來說明一下「重複載入資源」是怎麼一回事。
一個 AssetBundle 會長成這樣,裡面有一小部分存有檔案清單與壓縮資訊等內容,然後剩下的部份可能會有各式各樣包進去的資源。
首先,我們先載入了某個 AssetBundle,並從裡面載入一份 Texture 到記憶體中,接著呼叫 AssetBundle.UnLoad(false)
把 AssetBundle 釋放掉,這時候,剛剛載入的 Texture 還會存在記憶體中,儘管沒有人使用它。
接下來,我們再一次的載入剛剛的 AssetBundle,然後再做一次上述的動作,載入 Texture 並釋放 AssetBundle,這時候就會有兩份 Texture 在記憶體內。
如果上面的事情不斷的發生,到最後記憶體會變成下面這張圖的樣子,好幾份一樣的 Texture 堆在記憶體內,導致大量的浪費。
這個問題當然還是有方法可以解決,可以使用Resources.UnloadUnusedAssets
來釋放這些沒人使用的資源,不過這個方法開銷很高,千萬不要隨意使用,通常會在切換場景時使用較為恰當。
複習完 AssetBundle,讓我們回來介紹 Addressable 系統的運作方式。
在 AssetBundle 中,打包資源需要撰寫繁雜的程式碼;而 Addressable 系統有專屬的介面,讓打包這件事變得簡單很多。
在 Addressable 介面裡,資源會以 Group 去做分群,你可以想像一個 Group 就是一包 bundle。
首先,你可以將你想要打包的資源拖拉進 Addressable 介面的某個 Group,或者你可以直接勾選資源的 Addrressable 欄位,它會自動被加進預設的 Group 內。
接著你可以針對每個資源去設定各自的 address 與 label,在完成了所有資源的設定後,按下介面右上角的 Build 按鈕即可打包 bundle。
關於 Addressable 的 bundle 其實還有許多細節的設定,例如打包與載入的路徑、Profile 的設定、客製化的 Build 腳本等等,礙於篇幅,之後有機會再做介紹。
public AssetReference assetRef;
assetRef.InstantiateAsync();
你可以簡單的將public GameObject
取代成public AssetReference
,這個 AssetReference 只能參考是 Addressable 的資源,它會在你對其呼叫實例化時,進行載入並生成。
public AssetLabelReference assetLabelRef;
Addressables.LoadAssetsAsync<GameObject>(assetLabelRef, null);
Addressable 系統提供了一個滿方便的功能,你可以使用 label 進行資源載入。假設有一種情境,你需要載入某個種類的資源,但這個種類的資源散布在各個 bundle 裡面,以傳統的 AssetBundle 做法,你可能會需要載入各個 bundle 再載入你需要的資源。然而在 Addressable 系統中,你可以在 Addressable 介面對這個種類的資源設定同一種 label,然後就可以使用這個 label 來簡單的一次載入你所需的資源。
string assetKey = "myAsset";
Addressables.LoadAssetsAsync<GameObject>(assetKey, null);
List<string> assetKeys = new List<string>() { "keyA", "keyB"};
Addressables.LoadAssetsAsync<GameObject>(assetKeys, null);
在許多情況下,遊戲需要載入的資源可能不見得是某個特定的 asset 或某個固定的 label,因此無法使用拉參考的方式去載入資源,這時候我們可以使用字串作為 key 來載入資源。key 可以是上面的提到的 AssetReference 或 AssetLabelReference,也可以是資源的 address 或 label 字串,你可以使用單一的 key 或者一組 key list 來載入不特定的資源。舉個例子,遊戲 server 平常會傳送 stringA 給 client 去載入某一組資源,但是在春節活動期間,server 則會傳送 stringS 給 client 來載入另一組資源。
資源管理的最高指導原則,「鏡像的載入與釋放」,意思就是,有一個資源載入就需要有一個對應的資源釋放。這邊會簡單介紹 Addressable 系統中載入資源的 API,以及與其對應的釋放資源方法。
Addressables.LoadAssetAsync //載入單一資源
Addressables.LoadAssetsAsync //載入多個資源
資源載入的 API 有很多形式,就像上個章節所提到的,你可以使用 AssetReference、AssetLabelReference、或 key 來作為參數使用。
Addressables.Release //釋放資源
Release方法只會減少資源的 referenceCount(ref-Count),當資源的 ref-Count 歸零時才會"準備"被釋放,並且會減少所有依賴項目的 ref-Count。
你想問這句話是什麼意思?讓我來簡單說明一下 Addressable 系統中,資源什麼時候才會真正被回收。
現在我有一個 bundle,裡面有資源三寶,分別是 coffee、tea、和me。
這個例子應該可以讓大家理解資源什麼時候才會真正被回收,簡單用一句話說明這個規則:我們可以只載入 bundle 內的部分資源,但不能只釋放 bundle 內的部分資源。
雖然在第三步之後,你依然可以使用 Resources.UnloadUnusedAssets 達到釋放 coffee 的目標,但這個方法就像 AssetBundle 章節所說的開銷很大,絕對不能隨便使用!
Addressables.LoadSceneAsync //載入場景
Addressable 系統也有提供載入場景的方法,不過只接受單個 key 作為參數,Addressable 的載入方法皆有提供取得載入進度的 API,因此依然可以實作載入進度條喔!
Addressables.UnloadSceneAsync //釋放場景
SceneManager.LoadScene(LoadSceneMode.Single); //打開新場景(Single mode)也能釋放原場景
打開新場景可用使用 Addressable 的方法,也可以使用 SceneManager 原有的方法,在 Single 模式下,它們都能使原先使用 Addressables 載入的場景正確減少 ref-Count,若 ref-Count 歸零,原場景就會順利被釋放。
Addressables.InstantiateAsync //生成物件
Addressable 的物件生成方法,對還未載入的資源會自動幫你進行載入再做生成,在載入的過程也會自動載入所有依賴的資源,同時使這些資源的 ref-Count 增加。
Addressables.ReleaseInstance //銷毀物件
//關閉包含該物件的場景也能銷毀該物件
ReleaseInstance 方法只對 Addressables.InstantiateAsync 生成出來的物件有效喔!
Addressables.LoadResourceLocationsAsync //載入資源清單
Addressables.GetDownloadSizeAsync //取得下載資料大小
LoadResourceLocationsAsync 方法一樣使用單個或一組 key 作為參數,它載入的資料是資源的"清單",並不會真正載入資源,這個方法可以應用在資源的查詢。舉個例子,你使用這個方法得到一份資源清單,這份清單中的資源有 GameObject、Texture、Material 等各種類型,你可以尋訪這個清單的所有資源,只要它是 GameObject,就使用它的資源路徑(IResourceLocation)作為參數去載入該資源並生成出來。
Addressables.Release(Handle) //釋放資料
資料釋放的 API 一樣使用 Release,不過這邊傳入的參數是載入資料時回傳的 Handle,可參考以下程式碼的簽章。
static AsyncOperationHandle<IList<IResourceLocation>> LoadResourceLocationsAsync(object key, Type type = null)
static void Release<TObject>(AsyncOperationHandle<TObject> handle)
AssetBundle | Addressable | |
---|---|---|
資源打包 | 寫程式管理哪些資源要打包在一起 | 將資源拉進 Addressables 視窗 |
資源載入 | 寫程式向目標位置要求bundle 寫程式載入 bundle 再載入資源 |
在 Group 設定載入位置 寫程式直接載入載入資源 |
資源釋放 | 需要考慮使用 UnLoad(true) 還是使用 UnLoad(false) |
資源的 ref-Count 讓系統可以自動管理資源釋放 |
資源改名/改路徑 | 程式碼要跟著改 | 只要不修改資源的 address 就不需要任何修改 |
資源關聯性 | 相關聯的資源包都要寫程式載入 | 系統會自動處理關聯性自動載入 |
重複打包資源 | 有可能重複打包資源 同個資源可能會重複包進不同bundle |
不會重複打包資源 同個資源不會讓你包進不同bundle |
載入所有"砲塔" | 如果各種砲塔散在 各個 bundle 裡面就很麻煩 |
可以使用 label 載入 不用擔心他們散在不同 bundle 裡面 |
public GameObject
或[SerializedField] Texture
這類拉資源參考的部分,改成public AssetReference
或[SerializedField] AssetReference
。Instantiate()
改用Addressables.InstantiateAsync()
。Addressables.LoadAssetAsync
。直接轉換的內容會比較繁雜,目前並沒有比較好轉移的方式,只能去爬程式碼並手動轉換。
Resources.LoadAsync<GameObject>(“map/city.prefab”);
Addressables.LoadAssetAsync<GameObject>(“map/city.prefab”);
Addressables.LoadAssetAsync
即可。AssetGraph 提供了四種 Addressable 的節點,在這邊做簡單介紹(AssetGraph 能夠講的內容也可以很多,請容許我不詳細介紹><)。
這邊舉一個簡單的 AssetGraph 使用例子。
圖片中灰色的節點主要用來選擇哪些路徑下的資源需要被打包進 bundle,中間藍色的節點是用來將左側連進來的資源進行分類,資源被分類完送出去後連到黃色節點,每個黃色節點就是一個 bundle,可以調整 bundle 的設定,最後再將這些黃色節點連接到紅色的 build 節點。
至於 Addressable 的節點應該如何使用呢,一樣看個簡單的例子。
圖片中灰色與藍色的節點功能是和上面的例子相同的,黃色的節點從左到右依序是「將資源設定成 Addressable」->「設定 label」->「設定 Group」,最後的紅色節點是 Addressable 的 build。
Addressable 的節點使用上有一點需要特別注意,你需要先將資源設定成 Addressable,再對他們設定 label 與 Group 才會有效。
知道了 Addressable 節點後,我們要如何從 AssetGraph 轉移到 Addressable 呢?
其實很簡單,我們只需要把 AssetGraph 中的黃色節點取代成 Addressable 的三個節點就可以了!當然要記得調整各個節點上的設定。
還有就是,既然我們想要轉移到 Addressable 系統,那就不用再使用 build 節點,只要把 Addressable 的三個節點設定好,按下 AssetGraph 介面的執行後,這現資源就會依照你的設定加到 Addressable 介面當中,未來要包 bundle 就使用 Addressable 介面的 build 就可以囉。
文章的最後,我想要特別介紹一些 Addressable 系統中我認為非常好用的功能。
在 Addressable 介面上方的工具列中,有一個"Play Mode Script"欄位,這個欄位的功能是,當你在 Editor 進入 Play Mode 的時候,系統要用哪種模式去使用你的資源,模式有以下三種。
Addressable 系統提供了 Profiler 功能,讓你可以查看資源的 ref-Count。
圖片中,可以看到當我使用Addressables.LoadAssetAsync
載入"BigWin.prefab"時,綠色的區域會往上長;相對的,當我使用Addressables.Release
釋放他們,綠色區域也會往下降。這個綠色的區域代表的正是該資源的 ref-Count。
有了這個功能,當你在開發遊戲時,如果遇到莫名的幀率下降,就可以使用這個功能來查看遊戲中的各項資源是不是有被正確釋放。
Addressable 系統還提供了很有趣的分析功能,其中"Check Duplicate Bundle Dependences"可以幫你檢查你的 bundle 中,是否有哪些相依的資源是重複打包的。
讓我們來看一個例子:
假設我有一個 Cube.prefab 與一個 Sphere.prefab,他們倆都使用同一個材質與同一張貼圖,但是他們被分別打包在不同 bundle 裡面。當我分別載入他們倆之後,記憶體實際上會有兩份同樣的材質與貼圖,導致了記憶體空間的浪費,就像下圖中狀況。
為了避免這種情況,最正確的做法就是,在包 bundle 時,需要先想好哪些是共用資源並將他們抽出來,製作成單獨的一包 bundle,這樣一來,我們無論先載入哪一個 prefab,Addressable 會幫我們自動載入相依資源,也就是獨立抽出來的共用資源包,接著載入另一個 prefab 時,就不會重複載入共用資源了,就像下圖展示的情況。
然而,即使我們在打包 bundle 的時候非常的小心謹慎,百密必有一疏(是這樣用吧?),總是會有不小心漏掉的地方,這時候就是這邊要講的分析功能上場的時候了。
你可以在分析面板中選擇"Check Duplicate Bundle Dependences",然後按下"Analyze Selected Rules",就會看到面板中幫你列出哪些相依資源是被重複打包的,這時候你有兩種方式可以解決這個問題。
第一種方式是你可以按照分析的結果去自己手動調整 bundle。
第二種方式則是按下"Fix Selected Rules",系統就會自動幫你把這些重複的相依資源獨立出來組成一個 Group,是不是非常方便!
你可以在 Addressable 介面中選取 Group,接著在 Inspector 視窗中的 Advanced Options 裡面找到 Bundle Mode 設定值,這個設定值預設是"Pack Together",意思是這個 Group 下的資源會整個打包成一份 bundle。你可以將這個設定值調整成"Pack Separately",意思是將這個 Group 下的資源每個資源包成一個 bundle。
分別打包成多個 bundle 有什麼好處?讓我們再拿出資源三寶(coffee、tea、me)來實驗一下。
事前準備
我們將資源三寶們包在同一個 bundle 中。
實驗一
首先,我們將資源三寶們都載入進記憶體,接著把 tea 釋放掉,然後觀察記憶體的狀態,會發現 tea 仍然存在於記憶體當中,這個現象符合我們前面提到的規則:我們可以只載入 bundle 內的部分資源,但不能只釋放 bundle 內的部分資源。
實驗二
既然有這個規則在,那我們乾脆就把資源三寶們分開打包成三個獨立的 bundle 就好了呀,可是我們又不想要將他們拆成三個 Group,不然就不能稱為三寶了。
這時候我們就可以使用"Pack Separately"的模式,雖然三寶們還在同一個 Group,但他們會被分開打包成三個 bundle,這時候我們就能真正釋放單個資源。
然而,當我們載入 coffee 和 tea 的時候,卻觀察到記憶體有重複載入相同資源的現象。
實驗三
我們把 coffee 和 tea 展開來看看內部的資源,他們使用了一樣的 cup material、cup mash、cup texture,因此如果將他們分開來打包,就會有重複打包這些相依資源的問題,解決這個問題的方法在「Analyze - Check Duplicate Bundle Dependences」小節中有詳細說明過了,我們就使用一樣的方法讓系統自動將重複的相依資源抽取出來組成一個 Group,然後基於同樣的原因,我們不想要讓這個相依資源群組成一大包,所以同樣使用"Pack Separately"模式讓這個 Group 的資源分開打包。
完成之後我們再進行載入三寶的實驗,記憶體會長得像下圖,沒有重複打包相依資源的問題,也能夠獨立釋放單個資源。
實驗四
從上面幾個實驗看下來,"Pack Separately"這個功能看似非常美好,但是當我們將專案中所有的 Group 都設定為 "Pack Separately" 模式後,我們開始意識到一個問題,難道這樣無腦的將所有資源全部分開打包,導致一個專案內會有上百上千個 bundle,這樣對記憶體的開銷也絕對不是好事。
我們知道一個 bundle 除了資源之外,還另外包含了資源壓縮資訊、資源清單、兩個檔案讀取 buffer等等,其中檔案讀取 buffer 佔據了多數的空間,在大部分的平台中,一個 bundle 的檔案讀取 buffer 為 64 KB,因此一個 bundle 的基本開銷至少就是 64x2 = 128 KB。
回到專案的情況,當專案的 bundle 數量來到 1000 上下,這些 bundle 的基本開銷就至少來到將近 128 MB 了,顯然,這樣的打包方式也不完全合理。
實驗結論
從一連串的實驗看起來,"Pack Separately"功能絕對非常有用,但是也不能將這個功能當作網路吃到飽的無限使用,最好的打包策略還是,在打包的時候就考慮好哪些資源屬於同時載入且同時釋放的,那就把這類資源打包在一起,既可以保持 bundle 的細緻度的同時,又能夠使 bundle 數量不會過多。
實驗參考:Tales from the optimization trenches: Saving memory with Addressables
這篇文章的作者提供了自己的做法,它會幫你分析哪些資源屬於同時載入且同時釋放的,並且將它們包在同一包裡面。根據作者提供的數據,他們的做法可以讓一個有 8718 個 bundle 的專案,下降到 5199 個 bundle,同時節省了大約 40% 的記憶體開銷(from 311 MB to 184 MB)。
以上是我花了一些時間研究並整理出來的內容,其實還有部分細節礙於篇幅沒有充分的說明,如果有哪個地方我描述得比較模糊,非常歡迎大家提出來一起討論!