using System.Collections.Generic; using System.IO; using LibMMD.Model; using UnityEngine; namespace LibMMD.Unity3D { // Caches the most-recently-loaded model's parsed MmdModel, built Mesh(es)/blend-shape // index, and loaded MaterialLoader/Materials, keyed by (model path, mtime). MmdGameObject // reads through this instead of re-parsing the model file, rebuilding the mesh, or // re-decoding every texture from disk whenever a reload turns out to be the exact same model // file as last time - eg. toggling PhysicsMode and clicking Load again in // MmdSettingsController, which always tears down and rebuilds the whole MmdGameObject since // physics components can't be swapped live. // // Bone GameObjects/Transforms, physics components, and the Animator/PlayableGraph are never // part of this - those are per-instance and PhysicsMode-dependent, not derived from the // model file alone, and are always rebuilt fresh by MmdGameObject/MmdAnimator. // // Only a single entry slot is kept (this exists for the reload-and-tweak-settings workflow, // not for caching many distinct models at once) - but an entry can still be held by more than // one live MmdGameObject at a time, eg. MmdSettingsController keeps the outgoing model // rendering while its replacement loads in the background. Entries are reference-counted // (see TryGet/Store vs. Release) so a Store() for a new key doesn't destroy Mesh/Materials // out from under a holder that's still actively using them - destruction of a superseded // ("stale") entry is deferred until its last holder calls Release(). internal static class MmdModelResourceCache { // Mutable so MaterialLoader/Materials/blend-shape indexes can be filled in lazily after // the model/mesh are first cached - LoadMaterials() and the blend-shape build only run // later in MmdGameObject.LoadModel, not inside DoLoadModel where the model/mesh get // cached. public class Entry { public MmdModel Model; public Mesh Mesh; public List PartMeshes; public List> PartIndexes; public MaterialLoader MaterialLoader; public UnityEngine.Material[] Materials; public int[] MorphBlendShapeIndex; public int[][] PartMorphBlendShapeIndex; internal int RefCount; internal bool Stale; } private static string _key; private static Entry _entry; // SubsystemRegistration runs for every Play session even when Domain Reload is // disabled. Never carry UnityEngine.Object references across that boundary. [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetForPlaySession() { _key = null; _entry = null; } public static string BuildKey(string modelPath) { if (string.IsNullOrEmpty(modelPath)) { return null; } try { var info = new FileInfo(modelPath); if (!info.Exists) return null; // Length closes the common coarse-timestamp hole (notably on copied files). return $"{info.FullName}|{info.Length}|{info.LastWriteTimeUtc.Ticks}"; } catch (IOException) { return null; } } // Returns the cached entry when key matches (bumping its ref count), null on a cache // miss (including key==null, ie. no modelPath was available to key on). Every non-null // return must eventually be passed to Release() once the caller is done with it. public static Entry TryGet(string key) { if (key == null || key != _key) { return null; } // With Enter Play Mode Options / Domain Reload disabled, this static entry can // survive between play sessions while Unity destroys its Mesh objects. Treat that // state as a cache miss; otherwise a destroyed single Mesh looks null and the // loader incorrectly enters the multipart path with PartMeshes == null. if (!HasLiveMeshResources(_entry) || !_entry.MaterialLoader.AreSourcesCurrent()) { // Another MmdGameObject may still be rendering this entry while its replacement // loads. Detach it from the cache immediately, but defer destruction until the // final holder releases it. _entry.Stale = true; if (_entry.RefCount <= 0) DestroyEntry(_entry); _entry = null; _key = null; return null; } _entry.RefCount++; return _entry; } private static bool HasLiveMeshResources(Entry entry) { if (entry == null || entry.Model == null || entry.MaterialLoader == null) { return false; } if (entry.Mesh != null) { return true; } if (entry.PartMeshes == null || entry.PartMeshes.Count == 0) { return false; } foreach (var mesh in entry.PartMeshes) { if (mesh == null) return false; } return true; } // Stores freshly built model/mesh resources as the new cache entry. If this replaces a // different entry that's still held by someone (RefCount > 0), the old entry is marked // stale instead of being destroyed immediately - it's destroyed once its last holder // calls Release(). The returned entry's ref count already accounts for this caller; it // must eventually be passed to Release() once the caller is done with it. public static Entry Store(string key, MmdModel model, Mesh mesh, List partMeshes, List> partIndexes, MaterialLoader materialLoader) { if (key == null) { return null; } if (_key != key) { if (_entry != null) { _entry.Stale = true; if (_entry.RefCount <= 0) DestroyEntry(_entry); } _entry = new Entry { Model = model, Mesh = mesh, PartMeshes = partMeshes, PartIndexes = partIndexes, MaterialLoader = materialLoader, RefCount = 1, }; _key = key; } else { _entry.RefCount++; } return _entry; } // Called by MmdGameObject once it's done with an entry it acquired via TryGet/Store // (see MmdGameObject.Release()). Only actually destroys Mesh/Materials/MaterialLoader // once the entry has been superseded by a newer Store() call *and* nothing else still // holds it. public static void Release(Entry entry) { if (entry == null) { return; } entry.RefCount--; if (entry.Stale && entry.RefCount <= 0) { DestroyEntry(entry); } } private static void DestroyEntry(Entry entry) { if (entry == null) { return; } if (entry.Mesh != null) { Object.Destroy(entry.Mesh); } if (entry.PartMeshes != null) { foreach (var mesh in entry.PartMeshes) { if (mesh != null) Object.Destroy(mesh); } } if (entry.Materials != null) { foreach (var mat in entry.Materials) { if (mat != null) Object.Destroy(mat); } } entry.MaterialLoader?.Dispose(); } } }