UnloadUnusedAssets()って必要なの?

こんにちは。技術部平山です。

今回はUnityのResources.UnloadUnusedAssets()について書きます。

結論は 「呼ばないといけないっぽいので呼ぼう。頻度はいい感じで」 という何ら面白味のないものですので、そこに至る経緯に興味がある方のみお読みください。

動機

最近、非常な高頻度でUnloadUnusedAssets()を呼んでいるコードに出会いました。 「操作によって画面に何か出た時には大抵呼んでいる」というくらいの高頻度です。

「いや、こんなに呼ばなくてもいいんじゃないの?」と思ったわけですが、 考えてみれば、「そもそも呼ばないといけないものなのかどうか」 を知りません。いろいろな人がいろいろなことを言っていますが、 中の人による公式な発言ではなく、 調べた上で書いている記事であっても、所詮は「特定のバージョンの話」です。

とはいえ、こんな基本的な所は納得していないと怖くて仕方がありませんので、 私も「特定のバージョンで調べてみた」をやることにしました。

この記事では2018.4.11を用いています。 また、エディタとmacOSのStandaloneビルドのみの調査となっています。

呼ばなかったらどうなるのか?

実験は簡単です。

デカいテクスチャをロードしてUI.RawImageに貼って表示することを繰り返します。

public class Main : MonoBehaviour
{
    [SerializeField] RawImage rawImage;
    int index;
    int count = 100;
    UnityWebRequest req;
    string message;

    void Update()
    {
        if (req == null)
        {
            var appDir = System.IO.Path.GetFullPath(".");
            var url = string.Format("file://{0}/TestData/{1}.png", appDir, index.ToString());
            Debug.Log(url);
            message = url;
            req = UnityWebRequestTexture.GetTexture(url);
            req.SendWebRequest();
            index = (index + 1) % count;
        }
        else if (req.isDone)
        {
            rawImage.texture = DownloadHandlerTexture.GetContent(req);
            req.Dispose(); // これ忘れちゃダメよ!!(実は最初忘れてた)
            req = null;
        }
    }
}

プロジェクトフォルダにTestDataというフォルダを作り、そこに0.pngから99.pngまで 4096x4096の巨大pngファイルを配置しておきます。 あとはこれを実行です。UnityWebRequestでPNGをロードしてRawImageに貼ることを繰り返します。 新しいテクスチャをRawImageに差した所で古いテクスチャへの参照はなくなるので、 Unityが自動解放してくれるなら使用メモリ量は一定以上増えないはずです。

この、テストプログラムはプロジェクトごとGithubに置いてあります。 テスト用のpngはメニューから生成できます(アップロードすると大きいので)。

エディタでは

f:id:hirasho0:20191120105931p:plain f:id:hirasho0:20191120105921p:plain

Unityのプロファイラと、macOS標準のアクティビティモニターで見たのが上の画像です。 プロファイラではテクスチャのメモリ量が増え続けており、Unityの使用メモリ量が5GBを超えていますね。

さらにアクティビティモニターでも見てみますが、やはりデカいです。12GBを超えています。 プロファイラの画面写真よりも少し時間が経った頃のものなので、Unityのプロファイラよりも大きな値になっていますが、 「止まる気配がない」ということでは同じです。

実際、放っておくと、

f:id:hirasho0:20191120105924p:plain

OSがメモリ不足に陥って警告を出します。110GB以上使ってますね。こりゃダメです。

なお、この状態でUnloadUnusedMemory()を呼ぶと、メモリが一気に解放されて、 1GBくらいまで落ちます。少なくともエディタではこの関数を手動で呼ばないとダメだ、 ということがわかりました。

PCビルドでは

ではPCビルドで試してみましょう。エディタでは解放を指示しても解放されない、 といった話は調べると結構出てきます。ビルドでは自動解放される、 ということであれば、エディタでだけ呼べばいい、ということにもなるでしょう。

f:id:hirasho0:20191120105928p:plain

変わりませんね。どんどん増え続けています。プロファイラで見ても、アクティビティモニターで見ても同じです。 そして、UnlodaUnusedAssets()を手動で呼べば、一気に解放されます。

つまり、ビルドでも呼ばないとダメということがわかりました。

結論は?

呼ばないとダメだってことです。

もしかしたら他のバージョン、他の機械向けのビルドでは 自動解放されるかもしれませんが、保険を考えるなら呼ぶべきでしょう。

なお、Unityが内部で自動的に呼ぶこともある、という記述 も見つかりましたが、公式のマニュアルに書かれているわけでもなく、 しかも昔のバージョンの話ですから、今もそうかはわかりません。

確かに、「AdditiveでなくSceneをロードした時は中で呼ばれる」が本当であれば、 シーンロード後に自力で呼ぶことは無駄であり、 お客さんに余計なスパイクを見せることになります。 省いて良いなら省きたいところです。 しかし公式情報と言うには弱いですし、 弊社の製品の場合は基本Additive でシーンをロードして、 いらなくなったものを手動でUnloadSceneAsync()しているケースが多く、 やはり手動での呼び出しは必要と考える方が無難かと思います。

考えてみてください。 「呼ばなくていいのに呼んだ時」に起こることはせいぜいスパイクですが、 「呼ばないとけないのに呼ばなかった時」に起こることはクラッシュです。 重みが違いすぎます。

じゃあどれくらい呼ぶの?

となると実際に問題になるのは「いつ呼ぶの?どれくらい呼ぶの?」ですよね。

呼ばずにいる時間が長くなるほど、アプリが使うメモリ量は増えることになります。 しかし、大して解放できない時に呼んでも、無駄なスパイクを発生させるだけです。

製品の性質や作り方によるとは思うのですが、平山の個人的な考えとしては、

  • 客が操作できる時間、画面で何かがアニメーションしている時間には呼びたくない。
  • 一回画面が暗転する、NowLoading表示が出る、といった場所では念の為呼んでおく。

というくらいな気がします。 今のUnityは昔と違って非同期処理なので、「呼んだら即スパイク決定」というわけでもなく、 実際どれくらい処理時間がかかるかはわかりません。 メモリ内にあるアセットの数や、機械の性能、スレッドの状況にも影響されるでしょう。 しかし、できれば無駄なことはしたくありません。

例えばステージクリア型のアクションゲームであれば、ステージとステージの間には呼んだ方が良さそうです。 また、カードゲームで「デッキ編集画面」「カード購入画面」みたいなものが 分かれていて、それぞれがそれなりなアセット量があってロード時間が0.2秒以上あるようであれば、 そのタイミングで呼んでおいてもいい気がします。

ただ、もし「エンドレスなゲームで何十分も操作可能な時間が続き切れ目がない」 となったらどうしましょうかね。 その場合は、何らかの「必ずある程度の時間で訪れる何かのイベント」 に付随してUnloadUnusedAssets()しておかないとマズいことになるでしょう。 あるいは、「ゲームが始まったらInstantiateもロードもしない」 という設計にしてしまえば、呼ぶ必要がなくなります。 それも一つの選択肢でしょう。 私は「ある程度は開始時生成、足りなくなった分だけ動的生成」の方が好きですけどね。

おまけ: エディタ拡張とUnloadUnusedAsset()

このUnloadUnusedAssets()、エディタでアセットを編集している最中に呼ぶと、編集中のアセットがいきなりnullになることがありました。

例えば、AssetDatabase.LoadAssetAtPath() でマテリアルをロードして、値を書き換えてから、AssetDatabase.SaveAssets() でセーブ、という処理をすることがあるかと思いますが(最近遭遇したのではNGUIのアトラス生成でした)、 この途中でUnloadUnusedAssets()の呼び出しが紛れ込むと、 いきなりアセット(ここではマテリアル)がnullに化けることになります。

マニュアルにあるように、EditorUtility.SetDirty を呼んでいれば問題なく、実際呼んでいるのですが、呼ぶタイミングが遅かったのです。 値を書き換えてから呼ぶのが自然に思えますが、書き換える前にnullにされてしまえば、 書き換える時に死ぬことになります。 SetDirtyされていない場合、どのGameObjectからも参照されておらず、static変数からも辿れないアセットは、 「使われていない」とみなされて消されるのです。

「いや、エディタ拡張の処理してる時にUnlaodUnusedAssetsなんて呼ばないでしょ」 と思われるかもしれませんが、罠があります。 AssetDatabase.Refresh() が中でこれを呼んでいるのです。いかにもエディタ拡張から呼びそうですよね。

結局、EditorUtility.SetDirty をロード直後に呼ぶようにした上で、良く見たらRefreshが不要だったので呼ばなくしました。 SaveAssets()やRefresh()は0.5秒以上かかることもある重い処理でして、できれば避けたいのです。

なお、この件は5.6.5でのお話なので、今はまた挙動が違うかもしれません。