UnityのVariant機能をつかってちょっと躓いた話 - Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

UnityのVariant機能をつかってちょっと躓いた話

はじめに

この記事は Akatsuki Advent Calendar 2019 13日目の記事です...でした。 まだUTC-8くらいまでは13日なのでセーフったらセーフです。

挨拶が遅れました。アカツキでクライアントエンジニアをしている shairo_jp こと下村です。

師も開発者も忙しく走り回る年末に皆さんいかがお過ごしでしょうか。

UnityのAddressablesからpreviewが外れてそろそろ半年ほども経つようですね。 巷のUnityプロジェクトはもうAssetBundleを脱出する算段をつけている頃合いかと思います。

私もAddressableに関する記事を投稿する腹積もりでしたが、少々アテが外れたためAssetBundleに関する小さなTipsを共有することにしました。 もうAssetBundleはだいぶ触り尽くしたと思っていたのですが、Variantの取り扱いで躓いた点があったので紹介します。

Variantとは

VariantはUnityのAssetBundleの機能の一つで、AssetBundleの参照関係を壊さないようにアセットを置き換えるための仕組みです。 もっぱらSD/HDアセットや言語の切り替えといった用途に利用されます。 ここではVariantについて詳しい説明はしません。後の説明に必要な部分だけに留めます。

f:id:shairo_jp:20191214163827p:plain

BundleA に含まれるPrefabは BundleB のImageアセットを参照しています。 今回はこのImageを切り替えられるようにしたいので、 BundleB にXとYのVariantを用意します。

f:id:shairo_jp:20191214165532p:plain

Variant違いのアセットは同じ名前と内部IDを持つため、 BundleB.X の代わりに BundleB.Y をロードすればPrefabが参照するアセットが自然に切り替わります。

VariantをサポートするUnity公式のAssetBundleManagerを見てみましょう。

// Get dependecies from the AssetBundleManifest object..
string[] dependencies = m_AssetBundleManifest.GetAllDependencies(assetBundleName);
if (dependencies.Length == 0)
    return;

for (int i = 0; i < dependencies.Length; i++)
    dependencies[i] = RemapVariantName(dependencies[i]);

// Record and load all dependencies.
m_Dependencies.Add(assetBundleName, dependencies);
for (int i = 0; i < dependencies.Length; i++)
    LoadAssetBundleInternal(dependencies[i], false);

Unity-Technologies / assetbundledemo / demo / Assets / AssetBundleManager / AssetBundleManager.cs — Bitbucket より引用

GetAllDependenciesで取得したAssetBundle名に対してVariant名のリマップを行っているようです。 これでめでたく読み込むImageアセットを切り替えることができました。

問題点

しかしVariantのAssetBundleに含まれるアセットもさらに他のAssetBundleのアセットを参照しているかもしれません。 もう少し複雑な次の例を考えてみましょう

f:id:shairo_jp:20191214164021p:plain

この時 GetAllDependencies("BundleA")["BundleB.X", "BundleC"] を返します。 しかしこのリストの BundleB.XBundleB.Y に置き換えても、 BundleB.Y に必要な BundleD が不足してしまいます。

このように、実はVariantを利用する場合にはGetAllDependenciesを利用することができません。 Variant名の解決の解決は、AssetBundleが 直接 依存するAssetBundleに対して行う必要があります。

Variantを指定してDependenciesを取得する

気づいてしまえばあとは簡単です。ここは再帰呼び出しを利用して簡単にGetAllDependenciesの代替スクリプトを書いてみます。

public static string[] GetAllDependenciesWithVariant(this AssetBundleManifest manifest, string assetBundleName,
    IReadOnlyDictionary<string, string> variantMap)
{
    var dependencies = new HashSet<string>();
    GetDependencies(assetBundleName);
    return dependencies.ToArray();

    void GetDependencies(string name)
    {
        if (variantMap.TryGetValue(name.Split('.')[0], out var trueAssetBundleName))
        {
            name = trueAssetBundleName;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            if (dependencies.Add(dependency))
            {
                GetDependencies(dependency);
            }
        }
    }
}

Variantの具体的な使い方は人それぞれなので、非Variant名からVariant名を取得できるようなテーブルを用意するのが柔軟でよいです。 今回の例では variantMap に以下のようなテーブルを渡します。

{
    {"BundleB", "BundleB.Y"}
}

AssetBundle名を受け取ったら、まずはSplitで末尾のVariantを取り除いて先程のテーブルを引きます。 Variant名を取り除いたらGetDirectDependenciesで依存するAssetBundleを取得し、既出でなければ再帰的に依存関係を調べます。

普段GetAllDependenciesを使っていると気づきませんが、AssetBundleの依存関係は循環することがため既出かどうかを判定しなければなりません。

なお、AssetBundle名にはピリオドを含めれないので、Variant以外でSplitに引っかかることはありません。

全てのVariantを含むDependenciesを取得する

突然ですが、AssetBundleの欠点の一つにそれをResourcesと同様に扱う事ができないという問題があります。 そのために主要な全てのアセットをAssetBundleに含め、起動から初回ダウンロードまでに必要なAssetBundleをStreamingAssetsに格納する、という設計がしばしば採用されます。

このときStreamingAssetsに格納するAssetBundleは、当然全ての依存関係を完全に含まなければなりません。 つまり、今度は全てのVariantを含む依存関係の解決を行う必要があります。

public static string[] GetAllRequirementsWithVariant(this AssetBundleManifest manifest, string assetBundleName)
{
    var allVariantsMap = manifest.GetAllAssetBundlesWithVariant()
        .GroupBy(n => n.Split('.')[0])
        .ToDictionary(g => g.Key, g => g.ToList());

    var requirements = new HashSet<string>();
    GetDependenciesWithAllVariant(assetBundleName);
    return requirements.ToArray();

    void GetDependenciesWithAllVariant(string name)
    {
        name = name.Split('.')[0];

        if (allVariantsMap.TryGetValue(name, out var variants))
        {
            foreach (var variant in variants)
            {
                GetDependencies(variant);
            }
        }
        else
        {
            GetDependencies(name);
        }
    }

    void GetDependencies(string name)
    {
        if (!requirements.Add(name))
        {
            return;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            GetDependenciesWithAllVariant(dependency);
        }
    }
}

まずはGetAllAssetBundlesWithVariantで全てのVariantの対応表を作ります。 例では allVariantsMap は以下のようなテーブルになります。

{
    {"BundleB", {"BundleB.X", "BundleB.Y"}
}

あとはAssetBundle名からVariantを取り除き、改めてVariantを列挙しながら依存AssetBundleを列挙するだけです。 今回はルートのAssetBundleのVariantも含めたいので、 requirements にルート自身を含めるようにしています。

おわりに

もう4ヶ月もすればUnity2019もLTSがリリースされ、Addressablesの実戦投入もグッと現実化するでしょう。 このTipsはもはや過去のノウハウですが、この間隙にまだAssetBundleに悩まされている開発者の助けになれば幸いです。

明日...いや今日は s-capybara さんの番になります。