Solutions for changing shared/instanced materials runtime (possibly) without modifying materials

Hi there,

I’ve been testing real-time material changing setup.

It is the typical “level change” effect - for example if level changes, then material looks change.

So I probably have a set of materials like ground, sky and some other objects. I don’t want to reassign material to all objects, as certain objects already share a material, I can just change one instance.

Default way of how materials work, seems to be this - if I have “ground” material, I assign material asset to ground to prefabs and that gets instanced run-time. So all ground objects will have material “ground” and it is shared instance.

However, there is negative downside by default Unity seems to modify material parameters of asset if modified run-time. If I make ground material red run-time, ground material will be red afterwards… when I only needed all ground blocks to turn red run-time, and revert back to prefab colors afterwards. This naturally is only problem for editing/testing.

Because of this I built a test setup:

  1. Create a central list of materials that change.
  2. Fill list with materials that will change and init them with clones of original materials
    For any and all objects that are going to have shared material that changes run-time:
  3. When initing them, get a instance material from central list. (Replace ground with run-time shared instance)

This feels a bit counter intuitive but it works. It’s a bit of hassle to assign new instances of material to all elements in level that will change, only to prevent assets in editor being modified.

So my questions are, is there a some good solution/pattern to this? Using materials assigned to prefabs is dead simple to do, but is it OK to modify material this way? Does it actually matter in build, as there is no editor with original material?

Most of the time it doesn’t matter if material gets modified in editor, when it does plan b seems to be needed.

I’d be interested to know if there are some solutions for this available!

1 Like

It might not be exactly what you’re looking for, but MaterialPropertyBlocks allow you to set material properties without creating a new instance of a material or modifying the material in the Assets folder. This also allows many objects to have the same exact material but use different properties.

MaterialPropertyBlocks get set on Renderers not materials. An example for a MeshRenderer would be “myMeshRenderer.SetPropertyBlock()”

4 Likes

Perfect, and thank you.

To save future Googlers time here’s a reference implementation that can be used to tune values at both runtime and the editor:

Example Shader:

Shader "Custom/PropertyBlockExample"
{
    Properties{
        //PerRendererData - https://docs.unity3d.com/ScriptReference/MaterialProperty.PropFlags.PerRendererData.html
      
        [PerRendererData][MaterialToggle] _isAwake("IsAwake", Int) = 1
        [PerRendererData] _meaning("The meaning", Float) = 42.0
        [PerRendererData] _color("A Color", Vector) = (0, 0, 0, 0)
    }

    SubShader{
        //Normal shader stuff here...
    }
}

Example Component:

public class PropertyBlockExample: MonoBehaviour {

    [SerializeField]
    bool _isAwake = false;
    public bool isAwake {
        get { return _isAwake; }
        set {
            _isAwake = value;

            PopulateMaterialPropertyBlock();
        }
    }

    MaterialPropertyBlock _propBlock;
    Renderer _renderer;

    void Awake() {
        _propBlock = new MaterialPropertyBlock();
        _renderer = GetComponent<Renderer>();

        PopulateMaterialPropertyBlock();
    }

#if UNITY_EDITOR
    [ExecuteInEditMode]
    void OnValidate() {
        if(_propBlock == null) {
            _propBlock = new MaterialPropertyBlock();
        }

        if(_renderer == null) {
            _renderer = GetComponent<Renderer>();
        }

        PopulateMaterialPropertyBlock();
    }
#endif

    void PopulateMaterialPropertyBlock() {
        //Get the current value of the material properties in the renderer to avoid loosing an existing property on re-asignment
        _renderer.GetPropertyBlock(_propBlock);

        //Assign our new values.
        _propBlock.SetInt("_isAwake", _isAwake ? 1 : 0);
        _propBlock.SetFloat("_meaning", 6 * 7);
        _propBlock.SetVector("_color", Color.blue);
      
      
        //Assign the edited values to the renderer.
        _renderer.SetPropertyBlock(_propBlock);
    }
}
1 Like

Hello, I’ve ran into this annoying issue as well and devised a semi-crappy solution.
Instead of modifying the material properties directly, I modify them through extension methods where I stash the initial value of the property in dictionaries so when play mode stops and OnDestroy is called on my singleton monobehaviour that collects these dictionaries, the initial values of those properties can be reverted back to all the modified materials. Hope it helps someone, it solved my issue with properties of materials being modified in git but I still have the problem with a field called m_InvalidKeywords which changes from empty array [ ] to _LIGHTS_ON… I didn’t figure this out or how I can revert that back. If anyone knows, I’d be grateful.

Anyway, here is my code that I use to modify material properties:

public static class MaterialSetterAndResetterExtensions
    {
        public static void SetColorTemp(this Material material, string property, Color value)
        {
            MaterialSetterAndResetter.SetColorTemp(material, property, value);
        }

        public static void SetFloatTemp(this Material material, string property, float value)
        {
            MaterialSetterAndResetter.SetFloatTemp(material, property, value);
        }

        public static void SetTextureScaleTemp(this Material material, string property, Vector2 val)
        {
            material.SetTextureScale(property, val);
        }

        public static void SetTextureTemp(this Material material, string property, Texture val)
        {
            material.SetTexture(property, val);
        }

        public static void SetVectorTemp(this Material material, string property, Vector2 vec)
        {
            material.SetVector(property, vec);
        }
    }

    /// <summary>
    /// This class tries to solve the issue of modifying material assets at runtime and having those changes affect the git repository.
    /// OnDestroy, this script reverts back any material that was modified through its extension methods SetTempColor, SetTempFloat, etc.
    ///
    /// In any script you want to modify a material, instead of
    /// using mat.SetFloat("_lights",1),
    /// use
    /// mat.SetTempFloat(mat, "_lights", 1)
    ///
    /// This can be modified to reset materials at different convenient times for the client like scene change, etc.
    /// </summary>
    public class MaterialSetterAndResetter : SingletonMonoSelfGeneratingNonPersistentPrivateInstance<MaterialSetterAndResetter>
    {
        private Dictionary<Material, MaterialChanges> materialsAndTheirChanges = new();

        internal static void SetColorTemp(Material material, string property, Color value)
        {
            Instance.SetColor(material, property, value);
        }

        internal static void SetFloatTemp(Material material, string property, float value)
        {
            Instance.SetFloat(material, property, value);
        }

        protected override void OnCreation()
        {
        }

        private void SetColor(Material material, string property, Color value)
        {
            var materialChanges = GetExistentOrNewMaterialChangesFor(material);

            materialChanges.AddColorInitialValue(material, property);

            material.SetColor(property, value);
        }

        private void SetFloat(Material material, string property, float value)
        {
            var materialChanges = GetExistentOrNewMaterialChangesFor(material);
            materialChanges.AddFloatInitialValue(material, property);

            material.SetFloat(property, value);
        }

        private void OnDestroy()
        {
            foreach (var pair in materialsAndTheirChanges)
            {
                pair.Value.ResetMaterialValues();
            }
        }

        private MaterialChanges GetExistentOrNewMaterialChangesFor(Material material)
        {
            if (!materialsAndTheirChanges.TryGetValue(material, out MaterialChanges materialChanges))
            {
                materialChanges = new MaterialChanges(material);

                materialsAndTheirChanges.Add(material, materialChanges);
            }
            return materialChanges;
        }

        private class MaterialChanges
        {
            private Dictionary<string, float> floatsAndInitialValues = new();
            private Dictionary<string, Color> colorsAndInitialValues = new();

            private Material material;

            public MaterialChanges(Material material) => this.material = material;

            internal void AddFloatInitialValue(Material material, string property)
            {
                if (floatsAndInitialValues.ContainsKey(property)) return;
                float intialValue = 0;
                if (material.HasProperty(property)) intialValue = material.GetFloat(property);
                floatsAndInitialValues.Add(property, intialValue);
            }

            internal void AddColorInitialValue(Material material, string property)
            {
                if (colorsAndInitialValues.ContainsKey(property)) return;
                var initialColor = Color.black;
                if (material.HasProperty(property)) initialColor = material.GetColor(property);
                colorsAndInitialValues.Add(property, initialColor);
            }

            internal void ResetMaterialValues()
            {
                foreach (var pair in floatsAndInitialValues)
                {
                    material.SetFloat(pair.Key, pair.Value);
                }

                foreach (var pair in colorsAndInitialValues)
                {
                    material.SetColor(pair.Key, pair.Value);
                }
            }
        }
    }

    public abstract class SingletonMonoSelfGeneratingNonPersistentPrivateInstance<T> : MonoBehaviour
       where T : SingletonMonoSelfGeneratingNonPersistentPrivateInstance<T>
    {
        private static T instance = null;

        protected static T Instance
        {
            get
            {
                if (instance == null)
                {
                    //Debug.LogFormat("Creating singleton {0}", typeof(T));
                    instance = Object.FindObjectOfType<T>();
                    if (instance == null)
                    {
                        instance = new GameObject(typeof(T).ToString()).AddComponent<T>();
                    }

                    instance.OnCreation();
                }
                return instance;
            }
        }

        protected abstract void OnCreation();
    }