Introduction
Starting from Unity version 2022.3, we have a new API called the Properties API, which greatly enhances our capabilities within Unity.
With the Properties API, we gain the ability to dynamically access .NET objects at runtime and extend their functionality without altering them directly.
The Unity.Properties
namespace encompasses a variety of classes tailored for manipulating existing .NET objects. Among these, two pivotal concepts stand out: Property bags and Property visitors.
The Properties API offers immense versatility, providing a wide array of classes suitable for various scenarios. In this post, I aim to offer a concise introduction to property bags and property visitors in Unity. Subsequently, I’ll delve into an example showcasing the creation of a visitor tasked with verifying serialized fields in the editor.
Should these fields remain unassigned or null, the visitor will step in, assigning default values. This ensures our program can continue running, albeit with warnings, rather than crashing.
While this visitor isn’t intended for final production builds, as proper field assignments are crucial for the game’s smooth execution, it proves invaluable during development. It enables us to test and play our game without encountering null reference errors due to overlooked field assignments. Instead, it defaults to specified assets for each field type.
I’ll illustrate this concept further with an example involving Audio Clips and Audio Sources. If an audio clip is missing, a default one will be assigned alongside a warning. Similarly, if an audio source is absent, the visitor will either assign an existing one from the game object or create and assign a new one at runtime, again accompanied by a warning.
I believe this example offers greater utility compared to the standard example found in the Unity manual, which merely prints the names of object fields.
What Are Property Bags
A property bag is essentially a collection of properties associated with an object. It serves as the fundamental collection utilized by property visitors, which are tasked with enhancing the functionality of our objects.
Property bags can be automatically generated in two ways: either through reflection or via source code generation.
A property bag, has properties for public fields, private or internal fields tagged with SerializeField
, SerializeReference
, or CreateProperty
and public, private, or internal properties tagged with the CreateProperty
attribute.
We can avoid the generation of a property in a property bag, if we use the DontCreateProperty
attribute on a field.
Furthermore, by utilizing the CreateProperty(ReadOnly = true)
flag, we can ensure that the generated properties are readonly, thereby preventing their values from being altered by our property visitor.
When it comes to the automatic creation of property bags, we have two options:
Reflection
By default, a property bag is created using reflection.
When reflection is utilized for creating a property bag, it introduces a runtime performance overhead and can allocate garbage.
Since the creation of the property bag through reflection occurs lazily, this approach is preferable when property bags are employed alongside a property visitor primarily for development builds or editor play mode, or when it’s not intended to run every time the game executes, but rather under specific conditions.
Source Generation
Alternatively, a property bag can be generated through code generation during compile-time.
As this process occurs during compilation, it doesn’t impose any runtime performance penalties but may extend compilation times for the game.
To employ the code generation approach, two steps are required:
1) Apply the GeneratePropertyBag
attribute to the desired type. 2) Apply the assembly: GeneratePropertyBagsForAssembly
attribute to the assembly.
Moreover, if we desire the property bag to access private or internal fields of our type, we must declare our type as partial.
What Are Property Visitors
Property visitors are classes we create to enhance the functionality of our existing objects.
Each property visitor utilizes the properties contained within the property bag to introduce new functionality. There are two ways for creating a property visitor, each serving the same purpose. However, one is simpler to write, while the other provides enhanced customization and potentially superior performance.
High Level API
The high-level API uses the PropertyVisitor base class to create a property visitor.
Using this approach, we have to override the VisitProperty
method that has this signature:
protected override void VisitProperty<TContainer, TValue>(Property<TContainer, TValue> property, ref TContainer container, ref TValue value)
The first argument is of type Property<T0,T1> that offers methods to get info or change the property, the second argument is the container of that property and the third its value.
With the high-level API, we can easily implement adapters. Adapters are a generic interface IVisitPropertyAdapter<T>
where T is a type of our choice where we want that type to have a different behavior than the others. If this doesn’t make sense now, don’t worry it will in the example.
Low Level API
The low-level API, needs more code to be implemented by us, because now we don’t derive from the PropertyVisitor
base class, but we implement two interfaces. The IPropertyBagVisitor
and the IPropertyVisitor
interface. The first is responsible for the
Visit<TContainer>(IPropertyBag<TContainer> propertyBag, ref TContainer container)
method, that takes care of our property bags and the second is responsible for the
Visit<TContainer, TValue>(Property<TContainer, TValue> property, ref TContainer container)
method, that takes care of the properties inside our property bag.
The creation of Adapters, is also a little more complicated with the low-level API. Because the IVisitPropertyAdapter
interface needs access to the internals of the visitor, we have to create our own implementations of the adapters that contain the specific behaviors that we need.
These adapters will be examined within the Visit
method of the IPropertyVisitor
interface. If they are implemented, the specific functionality tailored to the respective type property will be invoked prior to the generic functionality.
Assigning Default Values To Null Fields
Let’s see an example, that shows a simple usage of the Properties API.
In this example, I will use a Monobehaviour class, that has the following fields:
public int number = 10;
public AudioClip PublicAudio;
public Sprite MySprite;
public AudioClip[] ArrayAudio;
[SerializeField] private AudioClip serializedAudio;
[SerializeField] private string foo;
[SerializeField] private AudioSource audioSource;
In this example, I will demonstrate how to issue a warning in the console if certain fields have not been assigned in the editor, how to assign a default Audio Clip if one is null, and how to verify if an Audio Source has been assigned, displaying a warning if it hasn’t.
Moreover, if the Audio Source is not assigned, we will inspect the game object to determine if it contains an Audio Source component. If found, it will be assigned to the audioSource
field at runtime. Otherwise, an Audio Source component will be added to the game object and assigned to the field.
All the above will happen at runtime, so any changes won’t persist after we exit the game. This is because we will call the class that is responsible for this functionality in the Awake
method. The purpose of this class, is to allow us to run the game with some default values, so that our game won’t crash and after that, we can assign any fields we have forgotten.
This functionality can be easily adapted by creating a similar script that gets called in the OnValidate
method, or in a method that has been tagged with the InitializeOnEnterPlayMode
attribute depending on specific use cases.
Given that this tool is intended for development, the high-level API suffices, as the low-level’s customization benefits are unnecessary. Additionally, the code generation approach is utilized. Although the reflection-based approach wouldn’t significantly impact performance in this scenario, given the frequent execution during play, the advantage of lazy instantiation provided by the reflection-based approach is negligible.
The first thing is to create a static class with a generic static method, that will be invoked from the ```Awake`` method of any Monobehaviour class requiring verification.
The NullUtilities Class
using Unity.Properties;
using UnityEngine;
namespace Property_Bags
{
public static class NullUtilities
{
public static void ReplaceNull<T>(T value) where T : MonoBehaviour
{
var visitor = new Visitor(value.gameObject);
// main entry point to our visitor.
PropertyContainer.Accept(visitor, ref value);
}
}
}
The Visitor
type here, is a class that we will create and has all the logic we will need for the null checks.
The PropertyContainer.Accept(visitor, ref value)
method from the PropertyContainer is the main entry point to our visitor. The first parameter is our visitor object and the second is the Monobehaviour we are testing.
The ReplaceNull
method will be called from the Monobehaviour scripts like this:
The Square Monobehaviour Class
using Unity.Properties;
using UnityEngine;
[assembly: GeneratePropertyBagsForAssembly]
namespace Property_Bags
{
[GeneratePropertyBag]
public partial class Square : MonoBehaviour
{
public int number = 10;
public AudioClip PublicAudio;
public Sprite MySprite;
public AudioClip[] ArrayAudio;
[SerializeField] private AudioClip serializedAudio;
[SerializeField] private string foo;
[SerializeField] private AudioSource audioSource;
private void Awake() => NullUtilities.ReplaceNull(this);
private void Start() => audioSource.PlayOneShot(serializedAudio);
}
}
Here we call it in the Awake
method.
We also have tagged the assembly with the GeneratePropertyBagsForAssembly
attribute, our class with the GeneratePropertyBag
attribute, and we have made our class partial. All this is so that the property bag can be created at compile time with code generation. If we didn’t / couldn’t do those three things, then the property bag would be created from reflection at run-time.
Now that we have our property bag created and the static method that gets called in the Awake
, all we need is to create the visitor with the functionality we need.
The Visitor Class
Our visitor, has one argument the game object that the script is on, because this is needed for the check and addition of the AudioSource
component. If we didn’t need that, then our visitor would have zero arguments.
The first thing we need to do in our Visitor
class, is to derive from the PropertyVisitor
base class and override its VisitProperty
method, so that it will contain our generic logic for each field/property:
protected override void VisitProperty<TContainer, TValue>(Property<TContainer, TValue> property, ref TContainer container, ref TValue value)
{
// check if value is UnityEngine Object and if it is check the 'Unity' overloaded null
if(value is Object unityObj && unityObj == null)
Debug.LogWarning($"{property.Name} is NULL!!!!");
// visit child properties if any
if (value != null)
PropertyContainer.Accept(this, ref value);
}
The generic functionality we desire, involves issuing a warning whenever a property holds a null value. Subsequently, we verify if the value is not null to determine if the property serves as a container for other properties. For instance, our AudioClip[]
field qualifies as a container since it represents an array containing Audio clips. If the statement:
if (value != null)
PropertyContainer.Accept(this, ref value);
didn’t exist, then the fields inside the array would never get checked.
The Unity Null
The first if
statement is a special Unity case. At first, I had something like this:
Debug.Log(property.Name + " " + value);
if (value == null)
{
Debug.LogWarning($"{property.Name} is NULL!!!!");
}
Although the Debug.Log
would print something like:
MySprite null
the code would never enter inside the if statement. For a moment there it felt like I was taking crazy pills.
Then I remembered that the null in Unity is not actually null. Here’s what is happening.
The value
is not null as it is actually pointing to an object. It is deriving from Unity Object, because it is a Unity component, that has overloaded the equality operator for null. When in fact it returns null, this happens because it points to a Unity Object in the C# space that has not a C++ space object representation. Because of that, the value is not actually null, and we have to cast it to a Unity Object and then check the overloaded equality of this Unity Object for the “Unity null”.
In any case, now we have a warning for any unassigned fields in the inspector. The next step, is to use adapters to create specific functionality for specific types, the AudioClip
and the AudioSource
types.
Implementing the Adapters
In the constructor of our Visitor
we have the following:
private readonly GameObject _go;
private readonly AudioClip _defaultAudioClip;
public Visitor(GameObject go)
{
_go = go;
_defaultAudioClip = Resources.Load<AudioClip>("Default_Audio");
AddAdapter(this);
}
The _go GameObject
is needed as mentioned before for the check and addition of the AudioSource
component.
The _defaultAudioClip
is a default Audio clip that we have in the Resources
folder, and we load, so that we can add to any AudioClip
field that is null.
The AddAdapter
method, is a public method of the base PropertyVisitor
class and is responsible for allowing our adapters’ Visit
methods to take precedence over the VisitProperty
method.
First we implement the Visit
method of the IVisitPropertyAdapter<AudioClip>
adapter:
void IVisitPropertyAdapter<AudioClip>.Visit<TContainer>(in VisitContext<TContainer, AudioClip> context, ref TContainer container, ref AudioClip value)
{
if (value != null) return;
Debug.LogWarning($"{context.Property.Name} is NULL! substituting with default audio clip");
value = _defaultAudioClip;
}
This will override the functionality of our previous VisitProperty
method for any field that is of the AudioClip
type.
We check for null, and if it is not then we print a warning and set the value equal to the _defaultAudioClip
.
Then we implement the Visit
method of the IVisitPropertyAdapter<AudioSource>
adapter:
void IVisitPropertyAdapter<AudioSource>.Visit<TContainer>(in VisitContext<TContainer, AudioSource> context, ref TContainer container, ref AudioSource value)
{
if (value != null) return;
if (_go.TryGetComponent<AudioSource>(out var audioSource))
{
Debug.LogWarning($"{context.Property.Name} is NULL! Adding existing Audio source component!");
value = audioSource;
}
else
{
Debug.LogWarning($"{context.Property.Name} is NULL! Adding new Audio source component!");
var newAudioSource = _go.AddComponent<AudioSource>();
value = newAudioSource;
}
}
Again, if the value is not null then we try to get the AudioSource
component from the GameObject
.
If it exists, then we set the AudioSource
field value equal to that component, if it doesn’t then we add a new AudioSource
component to our game object, and then we assign it to the field value.
Conclusion
Here is the full Visitor
class:
using Unity.Properties;
using UnityEngine;
namespace Property_Bags
{
public class Visitor : PropertyVisitor, IVisitPropertyAdapter<AudioClip>, IVisitPropertyAdapter<AudioSource>
{
private readonly GameObject _go;
private readonly AudioClip _defaultAudioClip;
public Visitor(GameObject go)
{
_go = go;
_defaultAudioClip = Resources.Load<AudioClip>("Default_Audio");
AddAdapter(this);
}
protected override void VisitProperty<TContainer, TValue>
(Property<TContainer, TValue> property, ref TContainer container, ref TValue value)
{
// check if value is UnityEngine Object and if it is check the 'Unity' overloaded null
if(value is Object unityObj && unityObj == null)
Debug.LogWarning($"{property.Name} is NULL!!!!");
// visit child properties if any
if (value != null)
PropertyContainer.Accept(this, ref value);
}
void IVisitPropertyAdapter<AudioClip>.Visit<TContainer>
(in VisitContext<TContainer, AudioClip> context, ref TContainer container, ref AudioClip value)
{
if (value != null) return;
Debug.LogWarning($"{context.Property.Name} is NULL! substituting with default audio clip");
value = _defaultAudioClip;
}
void IVisitPropertyAdapter<AudioSource>.Visit<TContainer>
(in VisitContext<TContainer, AudioSource> context, ref TContainer container, ref AudioSource value)
{
if (value != null) return;
if (_go.TryGetComponent<AudioSource>(out var audioSource))
{
Debug.LogWarning($"{context.Property.Name} is NULL! Adding existing Audio source component!");
value = audioSource;
}
else
{
Debug.LogWarning($"{context.Property.Name} is NULL! Adding new Audio source component!");
var newAudioSource = _go.AddComponent<AudioSource>();
value = newAudioSource;
}
}
}
}
This serves as a straightforward illustration of Unity’s properties API, employing property bags and property visitors. The Visitor
class can be expanded with suitable adapters for each desired type we want to have a default value.
In this scenario, the properties API proves invaluable for scrutinizing field values during development and setting default values. This enables smooth playtesting of our game even if certain values have been overlooked in the editor.
However, the properties API’s utility extends beyond development tools; it can also be useful in production during run-time. It can facilitate the creation of functionalities such as custom serialization or data binding. Refer to the Unity.Properties
namespace in the Unity scripting API for a comprehensive list of classes capable of augmenting existing fields in .NET objects through the PropertyBag
Although this example may seem brief, it ended up being slightly lengthier than anticipated. If you’ve reached this point, I appreciate your attention. Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.