【Unity】アセットバンドルのすゝめ その2【AssetBundles】

アプリビルド時にアセットバンドルを乗せ換える

 前回でアセットバンドルの作成は完了しましたので、アプリのビルド時にプラットフォーム別にアセットバンドルを入れ替えます。
 このアプローチは散々悩まされ苦肉の策となります。将来的には Unity が自動でやってくれるのを期待してます。

Editor フォルダにスクリプトを追加します。

using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

public class AssetBundleMover : UnityEditor.Build.IPreprocessBuildWithReport, UnityEditor.Build.IPostprocessBuildWithReport
{
	const string AssetBundlePath = "AssetBundles"; // 対象フォルダ
	public int callbackOrder { get { return 0; } }
	//----------------------------------------------------------------------------
	// ビルドする前に実行される
	public void OnPreprocessBuild( UnityEditor.Build.Reporting.BuildReport report)
	{
		var target = report.summary.platform;
		string dstPath = System.IO.Path.Combine( UnityEngine.Application.streamingAssetsPath, AssetBundlePath);
		// StreamingAssetsディレクトリ作成
		System.IO.Directory.CreateDirectory( UnityEngine.Application.streamingAssetsPath);

		string srcPath = System.IO.Path.Combine( AssetBundlePath, target.ToString());
		if( !System.IO.Directory.Exists( srcPath))
		{
			UnityEngine.Debug.LogError( $"{srcPath} がありません!");
			return;
		}

		// StreamingAssetsに移動
		UnityEditor.FileUtil.ReplaceDirectory( srcPath, dstPath);
		string[] files = System.IO.Directory.GetFiles( dstPath, "*.manifest", System.IO.SearchOption.AllDirectories);
		foreach( var file in files)
		{
			UnityEditor.FileUtil.DeleteFileOrDirectory( file);
		}
	}
	//----------------------------------------------------------------------------
	// ビルドした後に実行される
	public void OnPostprocessBuild( UnityEditor.Build.Reporting.BuildReport report)
	{
		string srcPath = System.IO.Path.Combine( UnityEngine.Application.streamingAssetsPath, AssetBundlePath);
		// もとに戻す
		UnityEditor.FileUtil.DeleteFileOrDirectory( srcPath);
	}
} // class AssetBundleMover

UnityEditor.Build.IPreprocessBuildWithReport インターフェイスを追加すると ビルド前に void OnPreprocessBuild( UnityEditor.Build.Reporting.BuildReport report) が呼ばれるようになります。

//----------------------------------------------------------------------------
// ビルドする前に実行される
public void OnPreprocessBuild( UnityEditor.Build.Reporting.BuildReport report)
{
    略
}

このタイミングで Assets/StreamingAssets に対象のプラットフォームのアセットバンドルをコピーします。
 ついでに 不要な *.manifest を削除してます。

UnityEditor.Build.IPostprocessBuildWithReport インターフェイスを追加すると ビルド後に void OnPostprocessBuild( UnityEditor.Build.Reporting.BuildReport report) が呼ばれます。

//----------------------------------------------------------------------------
// ビルドした後に実行される
public void OnPostprocessBuild( UnityEditor.Build.Reporting.BuildReport report)
{
    string srcPath = System.IO.Path.Combine( UnityEngine.Application.streamingAssetsPath, AssetBundlePath);
    // もとに戻す
    UnityEditor.FileUtil.DeleteFileOrDirectory( srcPath);
}

単純に コピーした Assets/StreamingAssets/AssetBundles を削除しています。

ちなみに何かしらの理由でビルドが失敗すると Assets/StreamingAssets/AssetBundles が残ってしまうので注意が必要です。

アセットバンドルの利用

アセットバンドルをロード・アンロードする部分になります。
簡易的ですがローカライズに対応してます。
エラー処理などは未実装 WindowsとMacのみ対応版

using System;
using System.Collections.Generic;
using UnityEngine;

public class AssetBundles
{
	 // 日本語のバリアント
	const string LanguageCode = "jp";
	// AssetBundle のバリアント
	const string AssetBundleVariant = "assetbundle";

#if UNITY_EDITOR
#if UNITY_EDITOR_OSX
	const string root_path = "AssetBundles/StandaloneOSX/";
#elif UNITY_EDITOR_WIN
	const string root_path = "AssetBundles/StandaloneWindows64/";
#endif
#else
	static readonly string root_path = System.IO.Path.Combine( UnityEngine.Application.streamingAssetsPath, "AssetBundles");
#endif
	// ルートパス
	public static string RootPath{ get{ return root_path; }}

	// 読み込み済みのマップ
	private static Dictionary<string,UnityEngine.AssetBundle> assetBundleMap = new Dictionary<string,UnityEngine.AssetBundle>();

	//----------------------------------------------------------------------------
	// アセットバンドルロード
	public static System.Collections.IEnumerator CoLoad(
		string assetBundleName,
		System.Action<UnityEngine.AssetBundle> onDone
	)
	{
		UnityEngine.AssetBundle assetBundle = null;
        assetBundleName = assetBundleName.ToLower();
		if( assetBundleMap.ContainsKey( assetBundleName))
		{
			// すでに読み込まれている
			UnityEngine.Debug.Log( $"Multiple read AssetBundle {assetBundleName}");
			assetBundle = assetBundleMap[assetBundleName];
		}
		else
		{
	        string path = System.IO.Path.Combine( RootPath, $"{assetBundleName}.{LanguageCode}.{AssetBundleVariant}");
			if( !System.IO.File.Exists( path))
			{
				path = System.IO.Path.Combine( RootPath, $"{assetBundleName}.{AssetBundleVariant}");
				if( !System.IO.File.Exists( path))
				{
					// ファイルがない
					UnityEngine.Debug.LogError( $"Not Found AssetBundle {assetBundleName}");
					onDone?.Invoke( null);
					yield break;
				}
			}

			// アセットバンドル読み込み開始
			var assetBundleCreateRequest = UnityEngine.AssetBundle.LoadFromFileAsync( path);

			// 読み終わるまで待機
			while( !assetBundleCreateRequest.isDone) yield return null;

			// 読み込み完了
			assetBundle = assetBundleCreateRequest.assetBundle;
			if( assetBundle == null)
			{
				UnityEngine.Debug.LogError( $"Failed to load AssetBundle! {path}");
				onDone?.Invoke( null);
				yield break;
			}
			assetBundleMap[assetBundleName] = assetBundle;
		}

		onDone?.Invoke( assetBundle);
	}
	//----------------------------------------------------------------------------
	// アンロード
	public static void Unload( string assetBundleName, bool unloadAllLoadedObject = true)
	{
		if( assetBundleMap.ContainsKey( assetBundleName))
		{
			var assetBundle = assetBundleMap[assetBundleName];
			assetBundle.Unload( unloadAllLoadedObject);
			assetBundleMap.Remove( assetBundleName);
		}
	}
	//----------------------------------------------------------------------------
	// 検索
	public static UnityEngine.AssetBundle Find( string assetBundleName)
	{
		if( !assetBundleMap.ContainsKey( assetBundleName)) return null;
		return assetBundleMap[assetBundleName];
	}
} // class AssetBundles

アセットバンドルのロード

System.Collections.IEnumerator Start() や StartCoroutine() で利用します。

yield return AssetBundles.CoLoad(
	"maps/map00",
	( assetBundle) =>
	{
		if( assetBundle != null)
		{
			// 処理
		}
	}
);

アセットバンドルのアンロード

不要になったらアンロードします。

AssetBundles.Unload( "maps/map00");

アセットを取り出す

このままでは利用しにくいので、便利関数を追加しておきます。

//----------------------------------------------------------------------------
// アセットバンドル → アセット生成
public static System.Collections.IEnumerator CoLoadAsset<Type>(
	UnityEngine.AssetBundle assetBundle,
	string assetName,
	System.Action<Type> onLoaded
) where Type : class
{
	// アセットバンドルからアセットをロードする
	UnityEngine.AssetBundleRequest assetBundleRequest = assetBundle?.LoadAssetAsync<Type>( assetName);
	if( assetBundleRequest == null)
	{
		// 失敗しました
		UnityEngine.Debug.LogError( "Failed to load LoadAsset! ");
		onLoaded?.Invoke( null);
		yield break;
	}
	while( !assetBundleRequest.isDone) yield return null;

	// アセット作成
	Type asset = assetBundleRequest.asset as Type;
	if( asset == null)
	{
		// 失敗しました
		UnityEngine.Debug.LogError( "Failed to created asset! " + assetName);
		onLoaded?.Invoke( null);
		yield break;
	}

	onLoaded?.Invoke( asset);
}
//----------------------------------------------------------------------------
// アセットバンドル名 → アセット生成
public static System.Collections.IEnumerator CoLoadAsset<Type>(
	string assetBundleName,
	string assetName,
	System.Action<Type> onLoaded
) where Type : class
{
	UnityEngine.AssetBundle assetBundle = Find( assetBundleName);
	if( assetBundle == null)
	{
		yield return CoLoad(
			assetBundleName,
			( UnityEngine.AssetBundle loadedAssetBundle) =>
			{
				assetBundle = loadedAssetBundle;
			}
		);
	}

	// アセットバンドルからアセットをロードする
	UnityEngine.AssetBundleRequest assetBundleRequest = assetBundle?.LoadAssetAsync<Type>( assetName);
	if( assetBundleRequest == null)
	{
		// 失敗しました
		UnityEngine.Debug.LogError( $"Failed to load LoadAsset! {assetBundleName}");
		onLoaded?.Invoke( null);
		yield break;
	}
	while( !assetBundleRequest.isDone) yield return null;

	// アセット作成
	Type asset = assetBundleRequest.asset as Type;
	if( asset == null)
	{
		// 失敗しました
		UnityEngine.Debug.LogError( $"Failed to created asset! {assetName}");
		onLoaded?.Invoke( null);
		yield break;
	}

	onLoaded?.Invoke( asset);
}

使い方は下記のように アセットバンドル名と取り出したい アセットを引数に入れて、完了後にインスタンス化してクローンしています。(エラーなどは省略)
Plane が GameObject のプレハブでmaps/map00 アセットバンドルに入っている前提です。

// ロードとアセット生成を一度にする
yield return AssetBundles.CoLoadAsset<UnityEngine.GameObject>(
	"maps/map00",
	"Plane",
	( prefab) =>
	{
    // 読み込み完了
		var instance = UnityEngine.GameObject.Instantiate( prefab);
		instance.transform.SetParent( transform, false);
	}
);

最後に アンロードするのを忘れずに。

public void OnDestroy()
{
    AssetBundles.Unload( "maps/map00");
}

シーンのアセットバンドルのロード

シーンファイルもアセットバンドル化できます。ただし、シーンファイル以外のアセットは組み込めません。シーンに必要なすべてのアセットがバンドル化します。

//----------------------------------------------------------------------------
// シーンアセットバンドルロード
public static System.Collections.IEnumerator CoLoadScene(
	string assetBundleName,
	string sceneName,
	UnityEngine.SceneManagement.LoadSceneMode loadSceneMode = UnityEngine.SceneManagement.LoadSceneMode.Additive,
	System.Action<UnityEngine.AsyncOperation> onLoaded = null,
	System.Action<UnityEngine.SceneManagement.Scene> onInstanced = null
)
{
#if UNITY_EDITOR
	var scene = new UnityEngine.SceneManagement.Scene();
	string targetName = System.IO.Path.GetFileName( $"{sceneName}.unity");
	var assetBundleNameArray = UnityEditor.AssetDatabase.GetAllAssetBundleNames();
	foreach( var name in assetBundleNameArray)
	{
		if( assetBundleName.Equals( name))
		{
			var paths = UnityEditor.AssetDatabase.GetAssetPathsFromAssetBundle( name);
			foreach( var path in paths)
			{
				if( System.IO.Path.GetFileName( path).Equals( targetName))
				{
					// 発見
					var param = new UnityEngine.SceneManagement.LoadSceneParameters( loadSceneMode);
					scene = UnityEditor.SceneManagement.EditorSceneManager.LoadSceneInPlayMode( path, param);
					break;
				}
			}
			break;
		}
	}
#else
	string path = System.IO.Path.Combine( RootPath, assetBundleName);
	path = path.ToLower();
	UnityEngine.AssetBundle sceneAssetBundle = null;
	yield return CoLoad(
		assetBundleName,
		( UnityEngine.AssetBundle assetBundle) =>
		{
			sceneAssetBundle = assetBundle;
		}
	);

	if( sceneAssetBundle == null)
	{
		// 失敗
		UnityEngine.Debug.LogError( $"Failed to load AssetBundle! {path}");
		onInstanced?.Invoke( new UnityEngine.SceneManagement.Scene());
		yield break;
	}
	if( !sceneAssetBundle.isStreamedSceneAssetBundle)
	{
		// 失敗
		UnityEngine.Debug.LogError( $"Not a scene asset bundle {path}");
		onInstanced?.Invoke( new UnityEngine.SceneManagement.Scene());
		yield break;
	}

	UnityEngine.AsyncOperation asyncOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync( sceneName, loadSceneMode);
	onLoaded?.Invoke( asyncOperation);

	if( asyncOperation == null)
	{
		// 失敗
		UnityEngine.Debug.LogError( $"Failed not found scene {sceneName}");
		onInstanced?.Invoke( new UnityEngine.SceneManagement.Scene());
		yield break;
	}
	while( !asyncOperation.isDone) yield return null;

	var scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName( sceneName);
#endif

	onInstanced?.Invoke( scene);
	yield break;
}
public static System.Collections.IEnumerator CoLoadScene(string assetBundleName,string sceneName,UnityEngine.SceneManagement.LoadSceneMode loadSceneMode,System.Action<UnityEngine.AsyncOperation> onLoaded,System.Action<UnityEngine.SceneManagement.Scene> onInstanced)
assetBundleName アセットバンドル名
sceneName シーン名
loadSceneMode
    Single 現在ロードされているすべてのシーンを閉じて、シーンをロードします。
    Additive 現在ロードされているシーンにシーンを追加します。
onLoaded シーンのロードが完了時に呼ばれるデリゲート
onInstanced シーンのインスタンス化が完了すると呼ばれるデリゲート

シーンのアセットバンドルはかなり便利で、シーンごとに小分けして管理でき更に Build Settings の Scene In Build に追加することなくシーンを読み込めるので、ビルド時間の短縮になります。

yield return AssetBundles.CoLoadScene(
	"scenes/maingame",
	"SampleScene",
	UnityEngine.SceneManagement.LoadSceneMode.Additive,
	( UnityEngine.AsyncOperation asyncOperation) =>
	{
		if( asyncOperation != null) asyncOperation.allowSceneActivation = true;
	},
	( scene) =>
	{
		if( scene.IsValid)
		{
			// シーンロード成功
		}
		else
		{
			// シーンロード失敗
		}
	}
);

allowSceneActivation を有効にすることで ロード完了時にシーンを有効にしています。Start() が呼ばれる。

シーンを終了するときは UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync などでアンロードします。シーンはアンロードされますがアセットバンドルは読み込まれたままなので、AssetBundles.Unload( “scenes/maingame”); でアンロードします。

UnityEngine.AsyncOperation asyncLoad = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync( "SampleScene");
while( !asyncLoad.isDone) yield return null;
AssetBundles.Unload( "scenes/maingame");

注意点は、リリース版とエディットの挙動を同じくしたいため、シーンのパスとアセットバンドルの名前を揃えないといけません。

最後に

 後半走り書きになりましたが、これで Resources クラスとおさらばできます。
プロジェクトを分けてシーンアセットバンドルをやり取りすればビルド時間も短縮できるのではないでしょうか?(試してませんが:p)


AddressableAssetsネタも書きました。

2 Comments

Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です