// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Collections.Generic; using UnityEngine; using Object = UnityEngine.Object; #if UNITY_EDITOR using UnityEditor; #endif namespace Microsoft.MixedReality.Toolkit.Rendering { /// /// The MaterialInstance behavior aides in tracking instance material lifetime and automatically destroys instanced materials for the user. /// This utility component can be used as a replacement to Renderer.material or /// Renderer.materials. When invoking Unity's Renderer.material(s), Unity /// automatically instantiates new materials. It is the caller's responsibility to destroy the materials when a material is no longer needed or the game object is /// destroyed. The MaterialInstance behavior helps avoid material leaks and keeps material allocation paths consistent during edit and run time. /// [HelpURL("https://microsoft.github.io/MixedRealityToolkit-Unity/Documentation/Rendering/MaterialInstance.html")] [ExecuteAlways, RequireComponent(typeof(Renderer))] [AddComponentMenu("Scripts/MRTK/Core/MaterialInstance")] public class MaterialInstance : MonoBehaviour { /// /// Returns the first instantiated Material assigned to the renderer, similar to Renderer.material. /// If any owner is specified the instanced material(s) will not be released until all owners are released. When a material /// is no longer needed ReleaseMaterial should be called with the matching owner. /// /// An optional owner to track instance ownership. /// The first instantiated Material. public Material AcquireMaterial(Object owner = null, bool instance = true) { if (owner != null) { materialOwners.Add(owner); } if (instance) { AcquireInstances(); } if (instanceMaterials?.Length > 0) { return instanceMaterials[0]; } return null; } /// /// Returns all the instantiated materials of this object, similar to Renderer.materials. /// If any owner is specified the instanced material(s) will not be released until all owners are released. When a material /// is no longer needed ReleaseMaterial should be called with the matching owner. /// /// An optional owner to track instance ownership. /// Should this acquisition attempt to instance materials? /// All the instantiated materials. public Material[] AcquireMaterials(Object owner = null, bool instance = true) { if (owner != null) { materialOwners.Add(owner); } if (instance) { AcquireInstances(); } return instanceMaterials; } /// /// Relinquishes ownership of a material instance. This should be called when a material is no longer needed /// after acquire ownership with AcquireMaterial(s). /// /// The same owner which originally acquire ownership via AcquireMaterial(s). /// Should this acquisition attempt to instance materials? /// When ownership count hits zero should the MaterialInstance component be destroyed? public void ReleaseMaterial(Object owner, bool autoDestroy = true) { materialOwners.Remove(owner); if (autoDestroy && materialOwners.Count == 0) { DestorySafe(this); } } /// /// Returns the first instantiated Material assigned to the renderer, similar to Renderer.material. /// public Material Material { get { return AcquireMaterial(); } } /// /// Returns all the instantiated materials of this object, similar to Renderer.materials. /// public Material[] Materials { get { return AcquireMaterials(); } } private Renderer CachedRenderer { get { if (cachedRenderer == null) { cachedRenderer = GetComponent(); } return cachedRenderer; } } private Renderer cachedRenderer = null; [SerializeField, HideInInspector] private Material[] defaultMaterials = null; private Material[] instanceMaterials = null; private bool initialized = false; private bool materialsInstanced = false; private readonly HashSet materialOwners = new HashSet(); private const string instancePostfix = " (Instance)"; #region MonoBehaviour Implementation private void Awake() { Initialize(); } private void Update() { // If the materials get changed via outside of MaterialInstance. var sharedMaterials = CachedRenderer.sharedMaterials; if (!MaterialsMatch(sharedMaterials, instanceMaterials)) { // Re-create the material instances. var newDefaultMaterials = new Material[sharedMaterials.Length]; var min = Math.Min(newDefaultMaterials.Length, defaultMaterials.Length); // Copy the old defaults. for (var i = 0; i < min; ++i) { newDefaultMaterials[i] = defaultMaterials[i]; } // Patch in the new defaults. for (var i = 0; i < newDefaultMaterials.Length; ++i) { var material = sharedMaterials[i]; if (!IsInstanceMaterial(material)) { newDefaultMaterials[i] = material; } } defaultMaterials = newDefaultMaterials; CreateInstances(); // Notify owners of the change. foreach (var owner in materialOwners) { (owner as IMaterialInstanceOwner)?.OnMaterialChanged(this); } } } private void OnDestroy() { if (CachedRenderer != null && defaultMaterials != null) { CachedRenderer.sharedMaterials = defaultMaterials; } DestroyMaterials(instanceMaterials); instanceMaterials = null; } #endregion MonoBehaviour Implementation private void Initialize() { if (!initialized && CachedRenderer != null) { // Cache the default materials if ones do not already exist. if (!HasValidMaterial(defaultMaterials)) { defaultMaterials = CachedRenderer.sharedMaterials; } else if (!materialsInstanced) // Restore the clone to it's initial state. { CachedRenderer.sharedMaterials = defaultMaterials; } initialized = true; } } private void AcquireInstances() { if (CachedRenderer != null) { if (!MaterialsMatch(CachedRenderer.sharedMaterials, instanceMaterials)) { CreateInstances(); } } } private void CreateInstances() { // Initialize must get called to set the defaultMaterials in case CreateInstances get's invoked before Awake. Initialize(); DestroyMaterials(instanceMaterials); instanceMaterials = InstanceMaterials(defaultMaterials); if (CachedRenderer != null && instanceMaterials != null) { CachedRenderer.sharedMaterials = instanceMaterials; } materialsInstanced = true; } private static bool MaterialsMatch(Material[] a, Material[] b) { if (a?.Length != b?.Length) { return false; } for (int i = 0; i < a?.Length; ++i) { if (a[i] != b[i]) { return false; } } return true; } private static Material[] InstanceMaterials(Material[] source) { if (source == null) { return null; } var output = new Material[source.Length]; for (var i = 0; i < source.Length; ++i) { if (source[i] != null) { if (IsInstanceMaterial(source[i])) { Debug.LogWarning($"A material ({source[i].name}) which is already instanced was instanced multiple times."); } output[i] = new Material(source[i]); output[i].name += instancePostfix; } } return output; } private static void DestroyMaterials(Material[] materials) { if (materials != null) { for (var i = 0; i < materials.Length; ++i) { DestorySafe(materials[i]); } } } private static bool IsInstanceMaterial(Material material) { return ((material != null) && material.name.Contains(instancePostfix)); } private static bool HasValidMaterial(Material[] materials) { if (materials != null) { foreach (var material in materials) { if (material != null) { return true; } } } return false; } private static void DestorySafe(UnityEngine.Object toDestroy) { if (toDestroy != null) { if (Application.isPlaying) { Destroy(toDestroy); } else { #if UNITY_EDITOR // Defer the destructing in case the object is in the act of being destroyed. EditorApplication.delayCall += () => { if (toDestroy != null) { DestroyImmediate(toDestroy); } }; #endif } } } } }