Material Property Drawers and Serialization

I feel like I’m missing some fundamental understanding of how Material Property Drawers and the Serialization of their information works.

No matter how long I scroll through documentation, where the information lives and how to make it flow from one thing to another, just never seems to click. So just to double check my rapidly decreasing sanity…

Let’s say I have a Shader, it’s assigned to my Main Material…

  • I want to make a Material Property Drawer that displays the Color from another Referenced Material.
  • I want to close out Unity, and come back in, and none of these References have been forgotten
  • I want to use this Material Property Drawer multiple times in a Shader, Referencing different Materials

The below can get me part of the way there, but as soon as I reload in any way the Referenced Materials are lost. I understand to some abstract degree Serialization is involved here, but for the life of me I can’t seem to wrap my head around it.

Which of all the things that say Serialization in Unity documentation is it, and why does [SerializedField] not work? Where does the information live? Does the Serialized information from two different instances of the drawer live in two different places? How can a single drawer go find information if it lives in two different places? What the implications of this across multiple instances of the Shader?

Any direction you kind folks could give me would be greatly appreciated.

Material Property Drawer:

using UnityEngine;
using System.Collections;
using UnityEditor;

public class MatPropertyDrawer : MaterialPropertyDrawer
{
public Material refMat;

public override void OnGUI(Rect position, MaterialProperty prop, string label, MaterialEditor editor)
{
//assign the Reference Material just once in an event-like way. This is pressing a button when you have a Material in the Resources folder selected
//(Need to lock Inspector on Main Material to use properly).
if (GUILayout.Button("Reference Material"))
{
Debug.Log("Referenced Material has been assigned...");
foreach (Object o in Selection.objects)
{
if (o.GetType() == typeof(Material))
{
refMat = Resources.Load<Material>(o.name);
}
}
}

//Check if null to emphasize when I lose it and avoid any null reference exceptions...
if (refMat == null)
{
Debug.Log("Referenced Material is lost..");
}
//Display Color from referenced Material
else
{
Color matColor = refMat.color;
EditorGUI.ColorField(position, refMat.color);
}
}

}

Shader:

Shader "URPUnlitShaderBasic"
{
// The properties block of the Unity shader. In this example this block is empty
// because the output color is predefined in the fragment shader code.
Properties
{
[MatPropertyDrawer]
_MainTex ("Texture", 2D) = "white" {}

[MatPropertyDrawer]
_MainTex2 ("Texture", 2D) = "white" {}
}

// The SubShader block containing the Shader code. 
SubShader
{
// SubShader Tags define when and under which conditions a SubShader block or
// a pass is executed.
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalRenderPipeline" }

Pass
{
// The HLSL code block. Unity SRP uses the HLSL language.
HLSLPROGRAM
// This line defines the name of the vertex shader. 
#pragma vertex vert
// This line defines the name of the fragment shader. 
#pragma fragment frag

// The Core.hlsl file contains definitions of frequently used HLSL
// macros and functions, and also contains #include references to other
// HLSL files (for example, Common.hlsl, SpaceTransforms.hlsl, etc.).
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" 

// The structure definition defines which variables it contains.
// This example uses the Attributes structure as an input structure in
// the vertex shader.
struct Attributes
{
// The positionOS variable contains the vertex positions in object
// space.
float4 positionOS : POSITION; 
};

struct Varyings
{
// The positions in this struct must have the SV_POSITION semantic.
float4 positionHCS : SV_POSITION;
}; 

// The vertex shader definition with properties defined in the Varyings 
// structure. The type of the vert function must match the type (struct)
// that it returns.
Varyings vert(Attributes IN)
{
// Declaring the output object (OUT) with the Varyings struct.
Varyings OUT;
// The TransformObjectToHClip function transforms vertex positions
// from object space to homogenous space
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
// Returning the output.
return OUT;
}

// The fragment shader definition. 
half4 frag() : SV_Target
{
// Defining the color variable and returning it.
half4 customColor;
customColor = half4(0.5, 0, 0, 1);
return customColor;
}
ENDHLSL
}
}
}

You’re storing the material reference in the property drawer itself. Drawers are just temporary objects created to edit properties, they don’t remember any of their fields. There’s some caching in the editor that causes your material reference to stick around but as soon as the drawer is recreated, the reference is lost.

Since you’re working with a material, there’s no good way to store references to generic objects. If you look at MaterialProperty, you can see that it can only store color, float, int, texture and vector – you cannot save a reference to another material within a material.

This makes what you want to do tricky, you need to store the material reference somewhere where it will be persisted but there’s no simple, straight-forward way to do that. This is complicated by the fact that materials can be persisted as assets but also as part of scenes and they both require different persistence mechanisms.

Some workarounds:

  • Instead of having references to materials, create a color library asset. Then save an int together with the color in the material that is used as an ID to look up the color in the library.
  • If you can live with only supporting material assets, you could save a custom scriptable object as a sub-asset of the material that contains the material references. Similar to the library, you save an ID in the material itself and then look it up in the child asset.
  • Reverse this and use a scriptable object as the main asset that then generates the material as a sub-asset. The scriptable object can contain the colors and material references and then updates them on the material.
1 Like

Thank you for this perspective, it does help outline why these things are happening.

Let me make sure I understand the general gist of the second option, just to be sure I’m really grasping the flow of information.

The SubAsset option means the Material Reference ultimately lives in the .meta file associated with the Material file?

So the angle of attack is to have this sort of a file just chilling in the Asset folder somewhere that’s essentially:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

[Serializable]
public class MaterialReferenceSO : ScriptableObject
{
public int numberStoredAsShaderParameterThatIdentifiesThisSO;
public Material materialReference;
}

and then more or less replace the animationClip in this Example…

…with an instantiation of the Scriptable Object by way of:

But I can’t use material.materialReference. Because while that’s where it lives, I can’t access it that way?

So I have to check all Scriptable Objects in the Project via AssetDatabase, to get all SubAsset Scriptable Objects I’ve made, and check their numberStoredAsShaderParameterThatIdentifiesThisSO to an int I’ve stored in the Shader’s Material .meta file via a Parameter, to defacto get my material reference for that particular Drawer?

You got it mostly right. But you don’t need to check all instances of your scriptable object, you’re drawer is already editing a specific material asset, so you only need to get/add the scriptable object on this specific asset.

In MaterialPropertyDrawer.OnGUI you get an instance to the MaterialEditor. You can use its base class’ target property to get the material instance you’re editing. Then, using the various methods on AssetDatabase, you can get the asset path, try loading an existing instance of your scriptable object and add one if it doesn’t exist yet.

It’s a bit long-winded but you then end up with your MaterialReferenceSO instance tied to the material being edited inside of your material property drawer. Then you need to figure out a way to associate color properties with the material references in your scriptable object. You can use MaterialEditor.GetMaterialProperty to get/set other properties of the material being edited.

Note: This won’t properly handle multi-editing, for which you need to use Editor.targets and handle the case when MaterialProperty.hasMixedValue is true. But that’s maybe something to tackle in a later step.