關於 web service, unity, blogger 等軟體工程筆記

Unity AssetBundle 資料列表載入以及打包架構思考,使用 Pokémon 作為範例

Edit icon 沒有留言
Unity

最近社群有看到有人詢問,關於如何在 Unity 中,設計敵人列表的 AssetBundle 資源加載架構設計,例如之前釋出一版本包含敵人 A, B, 以及 C 種類,在之後又追加敵人 D 以及 E 的資料,要如何處理這多出來的資源載入,使得原先的資源可以不用重新下載,只需要下載新的資源包即可呢?

針對這個議題,在仔細思考後,其實沒有一開始想像的那麼複雜,不需要特地自己實作一個複雜的架構來處理,而是使用原本 AssetBundle 載入的方式即可。

以下使用 Pokémon 寶可夢做為範例,要維護一份寶可夢清單,記錄共有多少個寶可夢使用,以及個別的模型貼圖資料。

這個範例原則也可以延伸是處理道具列表 (Item list)、角色列表 (Avatar list)、或是卡牌列表 (Card list) 等等,那種需要定義資料清單的架構,都可以依循這樣的想法去調整。

首先,先定義寶可夢資料結構,記錄名稱、屬性、以及使用到的模型以及圖示等等資源,考慮以下簡單範例。

using UnityEngine;

[CreateAssetMenu]
public class Pokemon : ScriptableObject
{
   public int No;
   public int Generation;
   public string DisplayName;
   public int Attack;
   public int Defense;
   public int Health;
   public Texture2D Preview;
   public SkinnedMeshRenderer Renderer;
}

當然這也可以繼承 Mono Behavior,只是使用 ScriptableObject 單純記錄資料比較省 (不需要建立 Prefab),而且限制一個寶可夢資料就是一份 Asset 比較單純,方便設計師管理以及調整,也能具備資料擴充性。

此外不管是繼承 MonoBehavior 或是 ScriptableObject,都可以撰寫自訂的 Unity 編輯器 ([CustomEditor(typeof(Pokemon))]),確保資料欄位在設計階段不會填錯,或是各種方便設計師,好填資料的良好編輯環境。

附註:實作上會希望封裝所有的資料欄位 (field),使用屬性 (property),雖然這樣有點囉嗦。

using UnityEngine;

[CreateAssetMenu]
public class Pokemon : ScriptableObject
{
   [SerializeField]
   int no;

   [SerializeField]
   int generation;

   [SerializeField]
   string displayName;

   [SerializeField]
   int attack;

   [SerializeField]
   int defense;

   [SerializeField]
   int health;

   [SerializeField]
   Texture2D preview;

   [SerializeField]
   SkinnedMeshRenderer renderer;

   public int No {
      get { return this.no; }
   }

   public int Generation {
      get { return this.generation; }
   }


   public string DisplayName {
      get { return this.displayName; }
   }

   public int Attack {
      get { return this.attack; }
   }

   public int Defense {
      get { return this.defense; }
   }

   public int Health {
      get { return this.health; }
   }

   public Texture2D Preview {
      get { return this.preview; }
   }

   public SkinnedMeshRenderer Renderer {
      get { return this.renderer; }
   }
}

接著定義項目列表,記錄總共有多少寶可夢的列表,並且封裝其資料,提供函數讓其他程式存取。

using UnityEngine;

[CreateAssetMenu]
public partial class PokemonList : ScriptableObject
{
   [SerializeField]
   Pokemon[] data = new Pokemon[0];
   
   public Pokemon GetPokemon(int no)
   {
      // Assume No = index + 1
      var index = no - 1;
      if (no < 0 || no >= this.data.Length)
      {
         return null;
      }
      
      return this.data[index];
   }
   
   public int GetCount()
   {
      return this.data.Length;
   }
}
編輯 Pokémon 資料

編輯 Pokémon 資料

編輯 Pokémon 資料列表

編輯 Pokémon 資料列表

對於程式來說,只要能拿到這份列表,便可以存取整份清單資料,後面再提如何讓主程式能拿到這份清單,且能夠透過 AssetBundle 載入的方式。

接著,考慮如何打包 AssetBundle 資料,能滿足一開始載入的需求,能夠加載新的寶可夢的載入架構。

通常,一開始可能會思考這樣的打包方式,將 MainAsset (PokemonList) 打包進 pokemon.assetbundle,Unity 會自動尋找到相依的資源,把 Pokemon assets 以及對應的模型 (M: model) 以及貼圖 (T: texture),一起收集打包近 pokemon.assetbundle,並且產生 assetbundles 下載清單 (download list),記錄載入的 AssetBundle 位置、Hash、以及 CRC。

一開始打包的架構,全部打包再一起

一開始打包的架構,全部打包再一起

考慮到之後追加新的寶可夢 (例如第二代),在原先的 PokemonList 加入新的寶可夢參照 (reference),按照之前的打包方式,會產生不一樣的 Hash 以及 CRC。

追加新的寶可夢後…

追加新的寶可夢後…

這樣會有什麼問題?考慮到這包 pokemon.asdetbundle 因包含眾多資料,可能會相當肥大 (很多貼圖以及模型)。若玩家之前已經下載過,而因為加入新的寶可夢導致的 Hash 更換,又得重新下載一次,而不能使用原先下載的快取資料 (cache),而新的這包有有大部分資料與之前相同,卻得全部重新下載,這是非常浪費頻寬 (bandwidth) 的設計。

那該如何調整設計,讓更新新的寶可夢時,玩家可以只下載有更新的部分,其他仍然使用之前下載並存在在快取的資源呢?

不需要自己實作,只需要調整打包方式即可 (設定 AssetBundleName)。考慮下圖,PokemonList 打包近進 pokemon.list.assetbundle,其他 Pokemon assets 打包進 pokemon.gen1.assetbundle,Unity 會幫忙處理參照,可以得知 assetbundles 間的相依性,進而產生新的載入清單。

新版本的打包架構,拆成兩個 assetbundles

新版本的打包架構,拆成兩個 assetbundles

然後加入新的寶可夢,將他們打包進 pokemon.gen2.assetbundle,建立新的載入清單。

新增新的寶可夢,包成新的 assetbundle

新增新的寶可夢,包成新的 assetbundle

假設玩家之前以及下載過前份的 aeetbundles,在這份新的,玩家只需要重新下載 pokemon.list.assetbundle (因為 Hash 更改,不能使用之前載入那份) 以及 pokemon.gen2.assetbundle (因為快取沒有) 即可,而 pokemon.gen1.assetbundle 可以直接使用快取的資料 (已載入到玩家本地儲存控件),相較於原先的設計,省下重新向伺服器載入相同資源的步驟。

因此對於 Client 的載入來說,每次都會重新拿到 assetbundles 下載清單 (download list),根據清單上的資料,逐一按照相依順序使用 UnityWebRequest.GetAssetBundle 下載其資源,Unity 底層實作會自動根據 url、hash 以及快取狀態,決定是否要向伺服器下載新的 assetbundle 使用,至於 asset 內部的參照問題,Unity 內部會全部處理好。

進一步思考打包 assetbundles 要如何切分,以這次範例來說,可視為是一個世代的寶可夢 (152隻) 包成相同同一包 assetbundle,但其實也可以一個寶可夢資料包成一包 assetbundle,或是十個寶可夢包成一包 assetbundle,要怎麼包取決於應用程式的需求

當然也不能過度切割,要注意過於細碎切割所衍伸出的議題:

  • 檔案議題,每包 assetbundle 都會占用些空間存放 assetbundle headers 資訊,過多的 assetbundles,這些 headers 所佔據的空間也不可小覷
  • 網路議題,不能忽視經由 HTTP 下載資源占用問題,若不是 HTTP1.1 長連線機制,每次下載一個檔案就得重新連線,過多 assetbundles 載入,那會花上許多時間在處理 HTTP handshake
  • 載入問題,注意 Unity 載入 assetbundles 占用的記憶體資源問題,載入超過上萬個 assetbundles,沒有適當釋放載入 assetbundle 所建立的記憶體 (buffer for leading),會不會炸掉呢……?

至於,主流程要怎麼拿到 PokemonList……,這又是一個很長的話題了。原則上不建議自己寫存取機制,而是使用參照的方式讓 Unity 幫忙處理這塊,這部分細節留到下一篇筆記 (待定)。

如果問載入 assetbundles 程式碼範例,請參考系列文章。

系列文章

沒有留言: