Skip to main content

Command Palette

Search for a command to run...

Unity URP - Changing shared material input values at runtime

Updated
โ€ข11 min read
Unity URP - Changing shared material input values at runtime
E

๐Ÿ‘‹๐Ÿพ Hello! ๐Ÿ‘‹๐Ÿพ My name is Esteban and I love video games and learning about making games. I'm sharing here my progress as I build up my skills and learn new technologies.

Context

Recently, while I've been visiting some Unity communities, I've been asked about how to deal with materials, specifically on how to modify input values at run-time (through C# scripts) in Unity. This tutorial is an introduction on how to do this, and you'll see how we can modify color values while your scene is playing, like this example:

Note: We see here a 10 by 10 grid of cubes that get a new random color every second.

Although it's a very basic example, what you'll learn here can be useful when you're rendering multiple objects that share a material, applying a different visual result per object. Think of Unity's own "Tanks!" demo and Unity Learn project:

Defining our project

For this tutorial, we're using a project being rendered by the Universal Render Pipeline (URP), and we 're using this simple scene where a game object will spawn a 10 x 10 grid of cube instances across a particular area:

Note: in this image, we're not applying any material modification. Just creating the instances.

And here are all of the assets we're using in this tutorial, our simple scene, 2 prefabs, 2 scripts, and 1 material:

Our cube instance scatterer

Our prefab scatterer is an empty game object with this very simple setup:

None of the material input modification happens here, so I won't go into much detail of the Prefab Scatterer script. But all you need to know is that it's responsible for creating those 100 instances of our cube prefab, and laying them in a 10 by 10 grid like we saw earlier. But don't worry, you can apply what we'll cover about material input value modification regardless of the way you instantiate your prefab instances.

The simple cube

Our cube prefab is simply a mesh renderer, a transform, and a script called Material Base Color Modifier. This is where our material input modification will happen, and we'll cover it as we continue in this tutorial.

And lastly, our TutorialMaterial is a simple material, using the Universal Render Pipeline's (URP) own built-in Lit shader with all default input values. Nothing fancy here:

Material input modification

We'll explore how you can do this in 3 different ways:

  • Modifying the root material file, which applies any change to all objects using the material

  • Creating a new copy/instance of the object's material, (the most memory consuming way).

  • Using Material Property Blocks to apply a different set of input values, while not creating any additional material copies/instances.

Note: none of the methods we'll see are necessarily better than the other. Each of these methods have their pros/cons, and it's best to understand when to apply them.

Materials and shaders can have various types of input values or properties like colors, floats, textures, Vector3, etc. And Unity's built-in shaders have multiple pre-defined properties.

For simplicity, today we're focusing on just modifying the "_BaseColor" property, which as the name implies, is a solid base color of the material. But you can apply what you learn here to any kind of shader property.

I found this useful online resource containing a list of many properties in Unity built-in materials.

With that established, here's the Material Base Color Modifier script that's attached to our cube prefab, that will be responsible of changing the base color of the cube's material at runtime, by assigning it a new random color value:

using UnityEngine;

[RequireComponent(typeof(Renderer))]
public class MaterialBaseColorModifier : MonoBehaviour
{
    public enum ModificationMethod
    {
        none,             // No modification
        SharedMaterial,   // Modifies the root asset, affects all
        MaterialInstance, // Creates a new instance of the material 
        PropertyBlock    // Per-instance input value modification
    }

    [Header("Settings")]
    public ModificationMethod modificationMethod;
    public bool updateContinuously = false;

    [Range(0f, 10f)]
    public float _continuousUpdateCooldown = 1f;
    private float _currentContinousUpdateTime = 0f;

    private Renderer _renderer;

    private MaterialPropertyBlock _propertyBlock;

    private int _baseColorMaterialPropertyID;
    private const string k_baseColorMaterialPropertyName = "_BaseColor";

    private Material _materialInstance = null;

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

        // Caching the string ID is faster than using string names later
        _baseColorMaterialPropertyID = Shader.PropertyToID(k_baseColorMaterialPropertyName);
    }

    private void Start()
    {
        if(modificationMethod == ModificationMethod.none)
        {
            // if no modification needed, then disable the component
            this.enabled = false;
            return;
        }

        ApplyModification(Random.ColorHSV());
    }

    private void Update()
    {
        if (!updateContinuously)
            return;

        _currentContinousUpdateTime += Time.deltaTime;

        if(_currentContinousUpdateTime >= _continuousUpdateCooldown)
        {
            ApplyModification(Random.ColorHSV());
            _currentContinousUpdateTime = 0f;
        }
    }

    public void ApplyModification(Color colorValue)
    {
        if (_renderer == null)
            return;

        switch (modificationMethod)
        {
            case ModificationMethod.SharedMaterial:
                // Accessing .sharedMaterial modifies the root material file
                _renderer.sharedMaterial.SetColor(_baseColorMaterialPropertyID, colorValue);
                break;

            case ModificationMethod.MaterialInstance:
                if(_materialInstance == null)
                {
                    // Accessing .material automatically creates a new local instance
                    _materialInstance = _renderer.material;

                    // ^^not grabbing the reference to the material creates a memory leak!
                }
                _materialInstance.SetColor(_baseColorMaterialPropertyID, colorValue);
                break;

            case ModificationMethod.PropertyBlock:
                _propertyBlock.SetColor(_baseColorMaterialPropertyID, colorValue);
                _renderer.SetPropertyBlock(_propertyBlock);
                break;
        }
    }

    private void OnDestroy()
    {
        if (_materialInstance == null)
            return;

        Debug.Log("Destroying the material instance");
        Destroy(_materialInstance);
    }
}

Yep, please feel free to borrow this if you'd like to test it out!

We'll now start testing the different modification methods inside of the ApplyModification(...) function, which is where the base color property value changes are applied:

 public void ApplyModification(Color colorValue)
 {
     if (_renderer == null)
         return;

     switch (modificationMethod)
     {
         case ModificationMethod.SharedMaterial:
             ...
             break;

         case ModificationMethod.MaterialInstance:
             ...
             break;

         case ModificationMethod.PropertyBlock:
             ...
             break;
     }
 }

Test 1 - Modifying the root material

Let's first test with the SharedMaterial mode, like so:

And as you can see that since we're modifying the root material file, all instances display the same base color:

This is what happens when you use the renderer's .sharedMaterial property:

case ModificationMethod.SharedMaterial:

// Accessing .sharedMaterial modifies the root material file
_renderer.sharedMaterial.SetColor(_baseColorMaterialPropertyID, colorValue);

break;

One thing to keep in mind is that when you modify the root material at runtime, even if just testing from the editor, those changes will persist after you stop play mode:

Note: This is the last random color that was applied to the base color, it's still there after I stopped playing the scene.

Pros and cons

Pros:

  • Highly Memory Efficient: It does not allocate any new memory or create copies of the material.

  • Maintains Standard Batching: Because you are not creating new materials, systems that rely on strict material sharing like static batching, and standard GPU instancing remain completely intact.

Cons:

  • Global Impact: Because it modifies the shared asset, any change you make will instantly apply to every single object in your scene that uses this material. You cannot use this for per-object variations.

  • Editor Persistence: Modifying .sharedMaterial at runtime can permanently alter the material asset file in the Unity Editor, meaning your changes might stick around even after you exit Play Mode.

Test 2 - Creating new material instances

One-time update on start

Let's now test with MaterialInstance mode, but first we'll test with the Update Continuously property set to false. So we'll only apply the change 1 time at the Start method.

And we indeed get a whole new set of colors per cube, but there's a cost associated to using this method.

Notice that when we select one of the cubes, we see that the assigned material is a new instance of the tutorial material. So this is an entirely new copy created at runtime, that lives in memory:

Here's the part of the code that's using the renderer's .material property:

case ModificationMethod.MaterialInstance:

if(_materialInstance == null)
{
    // Accessing .material automatically creates a new local instance
    _materialInstance = _renderer.material;
    // ^^not grabbing the reference to the material creates a memory leak!
}

_materialInstance.SetColor(_baseColorMaterialPropertyID, colorValue);

break;

And as the Unity documentation says "It is your responsibility to destroy the materials when the game object is being destroyed.", so I'm doing that on the OnDestroy function:

private void OnDestroy()
{
    if (_materialInstance == null)
        return;

    Destroy(_materialInstance);
}

Continuous updates

Let's now test with MaterialInstance mode, but with a continuous update:

And we get the same result as the beginning. We're applying a new change to the base color every second:

You might be wondering, "Wait, is this creating a new material instance EVERY time we change colors?!", and that's a very valid question, but thankfully that's not the case. Essentially, it generates a new instance on the first time. For more info. on the .material property, please refer to the documentation.

Pros and cons

Pros:

  • Per-Object Uniqueness: It's the easiest way to give a single object a unique look, (think of the "Tanks!" example I mentioned at the beginning) without affecting anything else.

  • SRP Batcher Compatibility: It's the ideal method when relying on the SRP Batcher, which groups by Shader variant, not by the material asset, so it will still efficiently batch all your newly cloned .material instances together as long as they run the exact same underlying shader code.

Cons:

  • Memory and CPU Overhead: Every time you call .material for the first time on an object, it allocates memory to create that new copy, which can cause frame rate drops and trigger garbage collection.

  • Breaks Instancing and Static Batching: Because the renderer no longer shares an identical material asset with the other objects, it immediately breaks standard GPU instancing and static batching.

Test 3 - Material Property Blocks (MPBs)

Lastly, let's test with the Property Block mode, with continuous update. We can go ahead and skip to the continuous update, since the non-continuous change is essentially the same as the previous method:

And look, the result is still the same as if we were creating new material instances, but no new material has been created:

And what's happening in the code is doing when we choose this method. In every cube's Awake function we crate a new property block, which stores the different modified values of our material for that cube instance. But it's just that, a smaller block of data that is sort of "tagged" temporarily onto the material, when the cube is being rendered.

private void Awake()
{
    ...
    _propertyBlock = new MaterialPropertyBlock();
    ...
}

And in then on the Update function we modify a new value to the property block, not the material per se.

case ModificationMethod.PropertyBlock:
    _propertyBlock.SetColor(_baseColorMaterialPropertyID, colorValue);
    _renderer.SetPropertyBlock(_propertyBlock);
break;

Pros and cons

Pros:

  • Best of both worlds for memory: You get the per-object visual uniqueness of the .material method, but without the heavy memory allocations of cloning the material.

  • Best for legacy Built-in Pipeline projects: It's best for when you're using the older Built-in pipeline, which some projects still use, although Unity has announced it'll deprecate this pipeline in the near future.

  • Essential for the GPU Instancing: If you are using standard GPU instancing, MPBs can be the workaround for applying visual variations across many objects.

Cons:

  • Conflicts with the modern Unity 6 solutions: If your project relies heavily on the SRP Batcher and/or the GPU Resident Drawer for performance, adding MPBs to those objects will break them out of the batch. MPBs are really more of a legacy feature from the Built-in pipeline.

  • Slightly More Complex to Code: It requires a bit more boilerplate code to set up and apply, compared to simply grabbing a .material reference.

Lessons learned

  • We can save some amount of memory by applying MPBs when needed, but it unfortunately it's an older feature, and most likely will conflict with Unity 6's modern rendering solutions like the GPU Resident Drawer, and the SRP Batcher.

  • It can be scary to think that you're creating many material instances might mean using a lot more memory, but it depends on what your platform's resources are, and whether something like this affects the "fun" or overall experience you want to bring to your players.

Please let me know what you think!

  • Have you applied any other methods to modify materials or input values at runtime?

  • Have you used MPBs before?

  • Have you applied multiple of these techniques in a same game?

๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰ Happy building! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰