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

在 Unity 使用 AssetBundles 實作進階的遊戲資源打包以及更新機制

Edit icon 沒有留言
Unity

前篇文章中,我們僅是將所有遊戲資源(Assets) 包在同一包 AssetBundle,這會造成小量的資源更新,玩家得重新下載這一大包的 AssetBundle 才行,造成遊戲體驗變得很糟,且也會造成伺服器流量負擔。

在這篇文章裡,我們將考慮開發者所設定的 AssetBundleName,將遊戲資源打包成多個 AssetBundles,讓之後遊戲資源更新時,不需要再載入全部的遊戲資源。

Note: 使用版本 Unity5.3.x or above,本文範例有部分程式函數參考來自於前篇文章
新版的遊戲資源打包示意圖,藍色方框為場景資源,紅色方框為其他遊戲資源 (貼圖材質等等),黑色箭頭為該場景使用該遊戲資源,綠色方塊表示 AssetBundleName,右邊大黑方框表示打包後的資源區塊,分成 APP 主程式資源,scenes、base、ui、level1 以及 level2 等 AssetBundle 資源包,其中 base 放置所有未有 AssetBundleName 的資源

考慮 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:

AssetBundles 彼此相依關係,箭頭表示相依,方框表示打包的 AssetBundle

AssetBundles 彼此相依關係,箭頭表示相依,方框表示打包的 AssetBundle

我們可以從 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);

系列文章

Reference

沒有留言: