LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

VRMImporterContextはDisposeしないとメモリリークする

TL;DR1

  • VRMをランタイムでロードするのに必要なVRMImporterContextIDisposableを実装しており、Disposeしないとメモリリークする
  • VRMImporterContextは生成済みのアバターGameObjectについているComponentからは取得できなさそう
  • VRMのインスタンスを管理するときはGameObjectではなくVRMImporterContextで管理する
  • もしくは拡張メソッドで自作クラスをAddしてOnDestroyのときにDisposeする

環境

UniVRM v0.56.3

説明と検証

人間牧場

わたしは人間牧場を経営したいのですが、残念ながら資金も権力もありません。
なのでかわいいアバターをいっぱい飼育することで心の慰めにしたいと思います。
というわけで、以下のようなスクリプトを書きました。
StreamingAssets/vrm/から.vrmを読み込んでぴょんぴょんさせます。これで好きなアバターを牧場に入れられます!

HumanRanchそのいち

using System;
using System.Collections.Generic;
using System.IO;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VRM;

public class HumanRanch : MonoBehaviour
{
    private void Start()
    {
        InitializeCage().ContinueWith(Rearing);
    }

    #region Ranch

    [SerializeField]
    private Transform[] _cage;

    [SerializeField]
    private RuntimeAnimatorController _animator;

    private GameObject[] _liveStocks;

    private async UniTask InitializeCage()
    {
        // wait for first update
        await UniTask.Yield(PlayerLoopTiming.Update);

        _liveStocks = new GameObject[_cage.Length];
    }

    private const float ProductionCycle = 3f;
    private readonly Vector3 CageGap = new Vector3(0, -1, 0);

    private async UniTask Rearing()
    {
        var token = this.GetCancellationTokenOnDestroy();
        while (!token.IsCancellationRequested)
        {
            var changeIndex = UnityEngine.Random.Range(0, _liveStocks.Length);
            if (_liveStocks[changeIndex] == null)
            {
                var context = await LoadVRM();
                var avatar = context.Root;
                avatar.GetComponent<Animator>().runtimeAnimatorController = _animator;
                avatar.transform.SetPositionAndRotation(
                    _cage[changeIndex].position + CageGap,
                    _cage[changeIndex].rotation
                );
                _liveStocks[changeIndex] = avatar;
                context.ShowMeshes();
            }
            else
            {
                Destroy(_liveStocks[changeIndex]);
                _liveStocks[changeIndex] = null;
            }

            await UniTask.Delay(TimeSpan.FromSeconds(ProductionCycle), cancellationToken: token);
        }
    }

    #endregion

    #region LoadVRM

    private readonly Dictionary<string, byte[]> _VRMBufferCache = new Dictionary<string, byte[]>();

    private string[] _VRMFullPaths = null;

    private static string[] SearchStreamingAssetsVRM()
    {
        var dirPath = Path.Combine(Application.streamingAssetsPath, "vrm");
        if (!Directory.Exists(dirPath))
        {
            Directory.CreateDirectory(dirPath);
        }

        return Directory.GetFiles(dirPath, "*.vrm");
    }

    private async UniTask<VRMImporterContext> LoadVRM()
    {
        if (_VRMFullPaths == null)
        {
            _VRMFullPaths = SearchStreamingAssetsVRM();
        }

        var path = _VRMFullPaths[UnityEngine.Random.Range(0, _VRMFullPaths.Length)];
        if (!_VRMBufferCache.ContainsKey(path))
        {
            await UniTask.SwitchToThreadPool();
            var buffer = File.ReadAllBytes(path);
            await UniTask.SwitchToMainThread();
            _VRMBufferCache[path] = buffer;
        }

        var context = await LoadVRMFromBuffer(_VRMBufferCache[path]);
        return context;
    }

    private static async UniTask<VRMImporterContext> LoadVRMFromBuffer(byte[] buffer)
    {
        var context = new VRMImporterContext();
        context.ParseGlb(buffer);
        await context.LoadAsyncTask();
        return context;
    }

    #endregion
}

経営の行き詰まり

適度に増えて適度に減って、永遠に眺めていられますね。
ところがだんだん動きがカクついてきて、遂にはエディタがクラッシュしてしまいます。誰の仕業でしょうか。
Profilerで調べたところ、どうやらメモリリークしているようです。心当たりがあるとすれば破棄処理とファイル読み込みの部分ですが、特におかしいところはありません。

破棄処理
Destroy(_liveStocks[changeIndex]);
ファイル読み込み
var buffer = File.ReadAllBytes(path);

となると残っているのはロードするときに使っているVRMImporterContextです。
IDisposableを実装している。実にあやしい。というわけで使い終わったらDisposeします。

context.ShowMeshes();
// 即座にDispose
context.Dispose();

スクリーンショット 2020-07-19 4.13.24.png

そして後には誰もいない牧場が残りました。

経営再開

当然ですが、contextDisposeしたタイミングで生成したアバターも破棄されてしまうみたいです。
そして困ったことに、生成済みのアバターはVRMImporterContextを取得できるクラスがくっついていません。2
なのでアバターをGameObjectではなくVRMImporterContext単位で管理することにします。


// アバターを管理する配列
// before
private GameObject[] _liveStocks;
// after
private VRMImporterContext[] _liveStocks;

// アバターの破棄処理
// before
Destroy(_liveStocks[changeIndex]);
// after
_liveStocks[changeIndex].Dispose();

できました!
人間牧場の復活です!
いろんなアバターが増えたり減ったりしながら永遠にぴょんぴょんします!

更にもう一歩

とはいえリソースの開放忘れはよくあるバグの原因の一つです。
なので管理して自分でDisposeするのではなく、
拡張メソッドでアバターがOnDestroyされたとき、自動的にDisposeされるようにします。

VRMAutoDisposer
using UnityEngine;

namespace VRM.Extension
{
    public class VRMAutoDisposer : MonoBehaviour
    {
        public VRMImporterContext Context;

        private void OnDestroy()
        {
            Context?.Dispose();
        }
    }

    public static class VRMAutoDisposerExtension
    {
        public static void AutoDispose(this VRMImporterContext context)
        {
            var disposer = context.Root.AddComponent<VRMAutoDisposer>();
            disposer.Context = context;
        }
    }
}

// 拡張メソッドなので、こんな感じで読んでおけば自動でDisposeしてくれる
context.AutoDispose();
context.ShowMeshes();

まとめ

自分がざっと見た限りではそれらしい情報が見当たらなかったのでこの記事を書きました。
本来ならば公式にissueでも立てて要望を出すかなにかしようかとも思ったんですが、もうすぐ1.0.0版とかが出るみたいだし、いいかなって。
個人的には拡張メソッドの方式が好きです。AssetBundleでもそうですが、リソース管理とかめんどくさすぎて自分でやりたくないんですよね。

拡張Dispose人間牧場のコードはgistにあげておいたので好きに使ってください。
あと、gistに置いてあるコードのライセンスをつけておきました3。CC0ですが、作者を表記してくれたらうれしいです。

おしまい。

参考記事など

記事

ランタイムインポーター

使用アセット

ニコニ立体ちゃん (VRM)
ヴィータ
Bird Cage created by Poly by Google
Basic Motions FREE Pack

UniTask v2


  1. やってみたかったやつ。 

  2. もしあったら教えて下さい。 

  3. 昔すぎて今更リプライするのもあれなのでこの場で謝ります。ごめんなさい。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4