Unique Material Properties per Renderer

Hi there!

Here’s another post about a topic I feel isn’t well enough covered: Material Property Blocks.

I’ve read here and there the following question in different forms:
How do I manage to give renderers unique material values without manually creating material variants?

So, let’s consider the following example: a Sprite Unlit Shader Graph, featuring Properties to fill the Sprite with a Radial Gradient.
It’s really just an example, the following will work with any Renderer type.

I’m using Unity 6 (6000.0.0b16) here, so I made the properties Local, but unchecked Show In Inspector, since I don’t want to set those properties from the Material Inspector, but from a C# component on the Sprite Renderer.
In earlier version, you may just set them as Global. It’ll have a similar effect as Global Properties (Uniforms) can still be overridden locally with a Material Property Block.

We can then assign the generated Material to several Sprites. I don’t even need to create a Material Asset, since it has no parameters other than those we’ll set from C#.

Now, how do we go about setting unique values per Renderer?

First, we need to make a new MonoBehaviour with parameters we can set in the Inspector.

using UnityEngine;

[RequireComponent(typeof(Renderer))] // <--- this ensures we have a Renderer
public class MaterialPropertyBlockDemo : MonoBehaviour
{
    [SerializeField, ColorUsage(true, true)] Color _colorA = Color.yellow;
    [SerializeField, ColorUsage(true, true)] Color _colorB = Color.cyan;

    [SerializeField] Vector2 _center;
    [SerializeField] float _radius = .5f;

    [SerializeField, Range(.1f, 5f)] float _power = 1f;

    [SerializeField] bool _useTexture;
    [SerializeField] Texture2D _texture;
}

We then need to hold a reference to the Renderer, and a MaterialPropertyBlock.
We can declare two methods, one to initialize references, and another to update properties, that we can call from within Unity Messages like OnValidate() which gets called whenever you make a change to the values.

    Renderer _renderer;
    MaterialPropertyBlock _materialPropertyBlock;

    private void Awake()
    {
        Init();
    }

    private void Start()
    {
        UpdatePropertyBlock();
    }

    private void OnValidate()
    {
        Init();
        UpdatePropertyBlock();
    }

    private void Reset()
    {
        Init();
        UpdatePropertyBlock();
    }

    private void Init()
    {
       if (_renderer == null)
           _renderer = GetComponent<Renderer>();

       if (_materialPropertyBlock == null)
           _materialPropertyBlock = new MaterialPropertyBlock();
   }

   void UpdatePropertyBlock()
   {
 
   }

Now, to update the values, all we have to do is set them in the MaterialPropertyBlock and set the Renderer’s Property Block.

void UpdatePropertyBlock()
{
    _materialPropertyBlock.SetColor("_colorA", _colorA);
    _materialPropertyBlock.SetColor("_colorB", _colorB);

    _materialPropertyBlock.SetVector("_Center", _center);
    _materialPropertyBlock.SetFloat("_Radius", _radius);
    _materialPropertyBlock.SetFloat("_Power", _power);

    _materialPropertyBlock.SetFloat("_UseTexture", _useTexture?1:0);

    _materialPropertyBlock.SetTexture("_Texture", _texture);

    _renderer.SetPropertyBlock(_materialPropertyBlock);
}

Since materialPropertyBlock.SetTexture() cannot be passed a null reference, we want to make sure it is not null.
We can do this with a C# Property like :

    [SerializeField] bool _useTexture;
    [SerializeField] Texture2D _texture;

    public bool UseTexture { get => _useTexture && _texture != null; }

And use that property to set the “_UseTexture” Property, and not assign a texture if it’s null or not needed.

        _materialPropertyBlock.SetFloat("_UseTexture", UseTexture?1:0);

        if (UseTexture)
            _materialPropertyBlock.SetTexture("_Texture", _texture);

Now each Sprite can share the same Material, yet use unique values.

Should we want to animate those properties, we could call UpdatePropertyBlock() from Update()

    private void Update()
    {
        UpdatePropertyBlock(); // this allows for animated properties
    }

And shall we want to preview the animation when not in Play mode, scrubbing a Timeline for example, we’d need to add the [ExecuteAlways] attribute to our component.

[ExecuteAlways] // this allows for animated properties preview in Timeline when in Edit Mode
public class MaterialPropertyBlockDemo : MonoBehaviour

One last optimization we’d want to do then, is to store nameIDs using Shader.PropertyToID().

    int _colorA_id, _colorB_id, _center_id, _radius_id, _power_id, _useTexture_id, _texture_id;

    private void Init()
    {
        if (_renderer == null)
            _renderer = GetComponent<Renderer>();

        if (_materialPropertyBlock == null)
            _materialPropertyBlock = new MaterialPropertyBlock();

        _colorA_id = Shader.PropertyToID("_ColorA");
        _colorB_id = Shader.PropertyToID("_ColorB");
        _center_id = Shader.PropertyToID("_Center");
        _radius_id = Shader.PropertyToID("_Radius");
        _power_id = Shader.PropertyToID("_Power");
        _useTexture_id = Shader.PropertyToID("_UseTexture");
        _texture_id = Shader.PropertyToID("_Texture");
    }

    void UpdatePropertyBlock()
    {
        _materialPropertyBlock.SetColor(_colorA_id, _colorA);
        _materialPropertyBlock.SetColor(_colorB_id, _colorB);

        _materialPropertyBlock.SetVector(_center_id, _center);
        _materialPropertyBlock.SetFloat(_radius_id, _radius);
        _materialPropertyBlock.SetFloat(_power_id, _power);

        _materialPropertyBlock.SetFloat(_useTexture_id, UseTexture?1:0);

        if (UseTexture)
            _materialPropertyBlock.SetTexture(_texture_id, _texture);

        _renderer.SetPropertyBlock(_materialPropertyBlock);
    }

I hope this helps. Your feedback is welcome.

9 Likes

Working with the SRP Batcher.

As mentioned in the docs, MaterialPropertyBlocks are not compatible with SRP Batcher.
Using this in the Universal Render Pipeline (URP), High Definition Render Pipeline (HDRP) or a custom render pipeline based on the Scriptable Render Pipeline (SRP) will likely result in a drop in performance.

Thanks for the heads up @Remy_Unity !

Luckily, Shaders made with Shader Graph are compatible with SRP Batcher, as they meet those requirements:

  • The shader must declare all built-in engine properties in a single constant buffer named UnityPerDraw. For example, unity_ObjectToWorld, or unity_SHAr.
  • The shader must declare all material properties in a single constant buffer named UnityPerMaterial.

Note that for properties to be declared into the UnityPerMaterial constant buffer, they have to be made Local (Per Material).

So, when using URP or HDRP, it’s recommended not to use Material Property Blocks, but rather make unique instances of Materials.
Doing so in Edit Mode would leak Materials. Actually, calling renderer.material when in Edit Mode will issue a Warning.

In this case, we want to use a hybrid solution, using Material Property Blocks when in Edit Mode, but making unique Material Instances at Runtime or in Play Mode.
Here’s how this can be done:

First, we need to know if we’re using a Render Pipeline and if the SRP Batcher is enabled.
We can do that with a property:

    static bool SrpBatcherEnabled { get => GraphicsSettings.isScriptableRenderPipelineEnabled && GraphicsSettings.useScriptableRenderPipelineBatching; }

Then, we need to know if we’re in Play Mode or Runtime.
If so, we won’t create a Material Property Block, but rather hold a reference to a unique instance of the Material.

    private void Init()
    {
        if (_renderer == null)
            _renderer = GetComponent<Renderer>();

        //Debug.LogFormat("SRP Batcher {0}", SrpBatcherEnabled ? "Enabled": "Disabled");

        if (SrpBatcherEnabled && Application.isPlaying)
        {
            if (_material == null)
                _material = _renderer.material;
        }
        else
        {
            if (_materialPropertyBlock == null)
                _materialPropertyBlock = new MaterialPropertyBlock();
        }
        ...
}

And finally, when updating the properties, we’ll either update the Material (if using the SRP Batcher and in Play Mode), or keep using the Material Property Block otherwise.

    void UpdateMaterialProperties()
    {
        if (SrpBatcherEnabled && Application.isPlaying)
        {
            //Debug.Log("Updating Material Properties with Material Instance");
            _material.SetColor(_colorA_id, _colorA);
            _material.SetColor(_colorB_id, _colorB);

            _material.SetVector(_center_id, _center);
            _material.SetFloat(_radius_id, _radius);
            _material.SetFloat(_power_id, _power);

            _material.SetFloat(_useTexture_id, UseTexture ? 1 : 0);

            if (UseTexture)
                _material.SetTexture(_texture_id, _texture);
        }
        else
        {
            //Debug.Log("Updating Material Properties with Material Property Block");
            _materialPropertyBlock.SetColor(_colorA_id, _colorA);
            _materialPropertyBlock.SetColor(_colorB_id, _colorB);

            _materialPropertyBlock.SetVector(_center_id, _center);
            _materialPropertyBlock.SetFloat(_radius_id, _radius);
            _materialPropertyBlock.SetFloat(_power_id, _power);

            _materialPropertyBlock.SetFloat(_useTexture_id, UseTexture?1:0);

            if (UseTexture)
                _materialPropertyBlock.SetTexture(_texture_id, _texture);

            _renderer.SetPropertyBlock(_materialPropertyBlock);
        }
    }

Complete source attached.

9786447–1404108–MaterialPropertyBlockDemo.cs (3.96 KB)

4 Likes

This is a goofy solution. Material property blocks were the way to go for a while (even though there are problems that were never addressed, like being unable to tweak the material at runtime after you’ve set a property block on it once, which I often do when I try to find what color I like), now they are not the way and we go back to leaking materials all over the place.

The proposed solution here is not… elegant. Can’t you provide users with a straight forward way to tweak materials that is clean, performant and does not cause issues like invalidating parts of your UI and breaking people’s workflows?

3 Likes

Hi @AcidArrow ,
I appreciate your concerns.

I hear you. It’s probably why there’s no clear answer to this question.
My goal with this thread is to share some findings from experiments to help users achieve their intents until there is a better way.

This is also the point here, sharing some good practice, to avoid allocation and leaks.

You can always remove a property from a block with

materialPropertyBlock.SetColor("_Color", null);
renderer.SetPropertyBlock(materialPropertyBlock);

If by “you”, you mean Unity, then I can’t really answer this question. But I’m happy to take feedback.
If you meant “me”, then it’s different. I’m trying my best here ^^

2 Likes

I always mean Unity, I usually clarify it too.

1 Like

@FredMoreau

I am happy to read to this is a topic being on someone’s mind at Unity.
The un-availability of a flexible per object property solution for URP (in general SRP) has been one of the reoccuring problems for me for the past 3 years. By now I think I have commented on every single thread touching that topic and exhausted every piece of knowledge that is publicly available and the entire situation is … as AcidArrow put it… not very elegant.

I agree that the only sensible way to deal with the lack of per object properties is instanciating new materials. I’ve tried other methods and they all have too hard drawbacks (missusing the existing renderer per material props, putting everything into vertex data, additional global arrays, etc. ).
Making unique material instances is surprisingly okay, even though it is the naive solution. I profiled it and came out at about 3m per 1000 SimpleLit instances if I remember correctly (which is really not a big deal).

Nevertheless I have to say: Uuuurghhgw this makes me so angry.
The dots hybrid renderer seems to have a solution for the problem. So it is definitely possible and Unity has code that deals with this issue. But only dots has it and no-one would port a game which otherwise works fine just for that feature.
I admit expecting material property blocks to just work would be asking a bit too much, from how much I understand about the SRP batcher it would obviously need a bit more mapping logics that maps the renderers using a specific per property instance to another index in the material cbuffer array but apart from the fact that it is sooo deep down in the bowels that sounds doable and again… it works in dots.

Why would one need this. Simple, tinting objects like the sprite renderer does it but with 3d meshes. Artists on the team kept asking for it, repeatedly.

What I have now works and I am very much happy with it and I would like to share the following insight from it.
I instanciate new materials in OnEnable of a component, even during edit mode (ExecuteAlways), as opposed to the hybrid approach proposed, and I clean them up in OnDisable too. In the editor, wrapped in conditional compilation, the instanciated materials are material variants of the original material. This keeps all properties except for the overriden one connected to the base material and any changes on the base are reflected on the variants. This works prefabs too but in order for the asset preview to work and static helper class was necessary to make sure the init functions are called in case the component is on a deeper level child object.
This way does keep the same behaviour between edit mode and play mode concerning the srp batcher / frame debugger which is nice.

The sad part here is that although I poured a lot of time and energy into this topic all “better” solutions still seem unattainable. Even if I am willing the rewrite an entire SRP from scratch I still can not change which properties the mesh renderer sends to the gpu, neither can we easily write a renderer component in which this would be possible with a certain performance reliablity (at least when I started working on this topic in 2021, DrawMeshInstanced (no culling) / Graphics.RenderMesh (no per object properties) yes sure, but those are not simple solution to that problem at all)

all in all, I would be happy if there was a better mechanism for all of this in non-dots unity too.

3 Likes

Hi @fleity ,

thanks for this detailed feedback that I’m going to share with engineering.

Using Material Variants and overrides is a great idea.
Did you manage to make this generic? In a way that you don’t have too much to rewrite whenever you need a different property modifying component?

Also, you mentioned coloring 3D objects like Sprite Renderer does. Is there any other use case?

I’ll keep you posted about future improvements on that topic.

Thank you so much, I’d be very happy to help improve this.

any other use case:
Well tinting is the most obvious. I could have also used this for atlas UV coordinates (but this turned into full asset-level material instances instead). We animate a few parameters to make enemies flash a bright emission color / pattern when they are hit (through timeline, custom track + monobehaviour, uses a chached material instance for the animation and then return to the original when the track is finished), that uses a similar approach as well.

I am currently working on dissolve effects and I reached a point where it becomes very difficult without property blocks because our inverted hull outline shader is rendered by a renderer feature and there is no way to instanciate that material properly for just one or two specific renderers. Hence I think I will have to use mpb here and break the srp batching for those instances.

Did you manage to make this generic?:
Well yes pretty much. It’s one mono behaviour that handles instanciating the material variant for a renderer. Currently for simplicity I have setup exactly one shader property (_Color) and there is only the color field in the inspector but that is the only thing keeping this from working on any property. Picking the correct property name and type and displaying a suitable property drawer would be required for a really generic solution, but that is doable I guess (but was’t prioritised so far). I could wrap the shader property selection in a small class that could handle vector, color and float values and then make the component use a list of those for example.
The code that sets the value on the material is as basic as it could be.

edit: I found one area where this approach lacks. When overriding the material with rendering features it obviously overwrites the material and opposed to propblocks the overriden values don’t carry over to the override material.

1 Like

I hope Hybrid renderer’s customizable per renderer property (works with SRP batcher) will come to Unity6’s GPU resident drawer, because it’s DOTS instancing too.

DOTS instancing looks basically “additional global arrays” route, so I’m using similar route for not instancing object. Fetch additional global array with per renderer ID based on unity_RenderingLayer value that I don’t use for renderLayer purpose.

I packed these three values to unity_RenderingLayer.
assetScale (0 - 1023): (4th root of The meshRenderer’s rough surface area}*50. This is useful to scale uv for detail map to arrange pixel per unit for various meshes with same material.
systemID(0 - 8191): Using like Instance ID for additional global array to fetch color animation value etc.
localID(0 - 255): Using for object selection effect, damaged effect, etc.

  • If unity_RenderingLayer is 0, the meshRenderer will be culled anyway. To prevent it I set bit21 to one.

Set the id from script

meshRenderer.renderingLayerMask = (assetScale << 22) + (systemID << 8) + localID + 2097152;

And get those id in hlsl

struct SystemId
{
    float assetScale;
    uint systemID;
    uint localID;
};

SystemId GetSystemID()
{
    SystemId output;
    uint theNum = asuint(unity_RenderingLayer.x);
    uint assetScale_u = theNum >> 22;
    output.assetScale = (float)assetScale_u * (float)assetScale_u * 0.0005;
    output.systemID = (theNum >> 8) & 8191;
    output.localID = theNum & 255;
    return output;
}

I hope there will come a simple serializable way for those purpose on SRP.

In the case where you use GPU instancing with MaterialPropertyBlocks and lots of renderers are drawn in a single batch (in the frame debugger I see that in RenderLoop.Draw, all objects are drawn in a single call). Does it really matter if the SRP batcher is broken? If it’s a single call, it’s still fast right?

It’s possible do just use SRP batcher for your regular game object drawing, but rely on GPU instancing with MaterialPropertyBlocks for custom graphical effects?

Thanks for the blog post btw @FredMoreau , appreciated!

1 Like

Is this really impossible to made it natively inside unity itself?

I mean, maybe add some API such as

renderer.SetPropertyBlockSRPBatcherCompatible(_materialPropertyBlock);

Or anything similarly, and internally just transfer value from materialPropertyBlock to the renderer directly if the condition are met

Sorry for being this direct but this sounds a bit naive. Just because you name a function in the API it doesn’t magically become possible. The problem is the concept of material property block seems to be incompatible with the SRP batcher. What that function would need to do is replace the entry in the large constant buffer of properties on the gpu and there is probably no way currently for the renderer to know which index in which shader’s buffer it should write to. And to do that without constantly overwriting the value from the material and renderer back and forth too.

Although it sounds like that should be possible to figure out… again especially since the hybrid renderer does it somehow but that works pretty differently I guess anyway.

Being able to overwrite specific shader properties would be great, but I guess many of us would already be super happy if there were 2 or so generic vector4s that the renderer could fill for us (a bit like the particle system’s custom vertex streams do it, but not per vertex obviously).

I mean you can see

This block

    void UpdateMaterialProperties()
    {
        if (SrpBatcherEnabled && Application.isPlaying)
        {
            //Debug.Log("Updating Material Properties with Material Instance");
            _material.SetColor(_colorA_id, _colorA);
            _material.SetColor(_colorB_id, _colorB);

            _material.SetVector(_center_id, _center);
            _material.SetFloat(_radius_id, _radius);
            _material.SetFloat(_power_id, _power);

            _material.SetFloat(_useTexture_id, UseTexture ? 1 : 0);

            if (UseTexture)
                _material.SetTexture(_texture_id, _texture);
        }

And this block

        
        else
        {
            //Debug.Log("Updating Material Properties with Material Property Block");
            _materialPropertyBlock.SetColor(_colorA_id, _colorA);
            _materialPropertyBlock.SetColor(_colorB_id, _colorB);

            _materialPropertyBlock.SetVector(_center_id, _center);
            _materialPropertyBlock.SetFloat(_radius_id, _radius);
            _materialPropertyBlock.SetFloat(_power_id, _power);

            _materialPropertyBlock.SetFloat(_useTexture_id, UseTexture?1:0);

            if (UseTexture)
                _materialPropertyBlock.SetTexture(_texture_id, _texture);

            _renderer.SetPropertyBlock(_materialPropertyBlock);
        }
    }

Is completely identical. The logic to check should be internal because unity itself know it all about what should we used. So we should only know about setting per renderer data and unity should handle it internally

Seriously, we should not even have MaterialPropertyBlock. The renderer should always support per instance data in and of itself and internally it can use MaterialPropertyBlock or batcher or anything

2 Likes

Similar syntax on the user facing side.
The material SrpBatcherEnabled && Application.isPlaying part assumes there is already a unique material instance which is what material property block tries to avoid to do in the first place.
Ultimately I agree I want this feature just as much.

I’ve converted a component last week, that utilized a MaterialPropertyBlock, to using a material instance. With the intent of fully supporting the SRP Batcher.

What seems to work swimmingly is to create a copy of a base/template material, assign it to the renderer, and set any unique values on this material.

public void UpdateMaterial()
{
    if (!templateMaterial) return;
    
    //Material is non-serialized, but ought to be created only once
    if (!material)
    {
        material = new Material(templateMaterial);
        material.name += " (Instance)";
        renderer.material = material;

        if (particleSystemRenderer) particleSystemRenderer.material = material;
    }
    
    material.CopyPropertiesFromMaterial(templateMaterial);

    //Per-instance property overrides
    var height = heightScale * (scaleHeightByTransform ? this.transform.lossyScale.y : 1f);
    material.SetFloat(_HeightScale, height);
}

I’m not a fan of calling CopyPropertiesFromMaterial, since this probably involves a lot of redundant work, parsing the entire property sheet. But it safeguards against any changes made to the “template” material not being reflected in this unique instance.

Agree very much, for my application I’ve written another CopyMaterialProperties function because I happen to have a list of the changed shader properties lying around anyway and wanted to avoid calling that function due to the garbage allocation is has.
But oddly enough I do not have to call CopyMaterialProperties after instanciating a copy of the material at all. The initial copy definitly has all the right properties set (2022.3.25f1).

By the way the usage of material variants I suggested while working in the editor (during runtime it does not work) would be like this:

Using material variants makes a lot of sense! For the case of a MPB-replacement, it’s a shame this’ll only work in the editor. The whole concept of “property overrides” aligns with what MPB do on a functional level, so I hope a SRP-batcher compatible replacement comes around.

My main concern is making sure that any changes to “global” material properties (ie. a texture field) are propagated onto any material instances. I suppose in my case, this happening during runtime in a build will be an edge-case.

I’ve prototyped a generic solution to easily map material properties to a MonoBehaviour’s fields and properties.
It handles several renderers and materials, uses MPB when in the Editor, but unique material instances at Runtime/Play mode.

Usual disclaimer:
this is personal investigation work, in which Unity has no liability.

You’ll find it here on my github.

Your feedback is welcome.

3 Likes