Exploring UIElements Part 5: Snapshots

Snapshots

Exploring UIElements Part 5: Snapshots

In this tutorial, we’ll continue exploring UIElements by building a custom editor window that can save and restore the position, rotation, and scale of groups of GameObjects. This tool can help you experiment with moving various things around the scene with the peace of mind that all your changes can be reverted with the click of a button, even after closing and reopening the Unity editor.


Prerequisites

This is a beginner-level tutorial that assumes you have gone through the previous parts of the Exploring UIElements series (Part 1, Part 2, Part 3, and Part 4).

The Unity version used throughout this tutorial is 2019.4.1f1, but any version 2019.1 or newer should work.


What We’re Building

We’re going to build a custom editor window that can save and restore snapshots of groups of GameObjects – that is, record and restore the properties of their Transform components: position, rotation, and scale. We’ll learn how to:

  • Use a ScriptableObject as a simple, ad-hoc database to store custom editor window data for long-lived editor extensions.
  • Create a custom persistent unique id system to identify objects between entering/exiting play mode and restarting the Unity editor.
  • Minimize our editor extension footprint by dynamically adding and removing components to/from GameObjects. … and more! Our goal is to end up with something that looks like this:

A preview of the tool’s final form.


Getting Started

We’ll start with a new 3D project. Create an Editor folder, and inside it, create a custom editor window (right click and choose Create 🠚 UIElements 🠚 Editor Window) named SnapshotsWindow.

There is a new editor window and 3 files were auto-generated generated.

Cleaning Up the Defaults

Like we’ve done in the previous parts, let’s clean up the defaults by…

  • Removing default “Hello World!” type content from the files.
  • Embedding the USS file in the UXML template with the <Style> tag.
  • Refactoring the C# file to be more concise and have better naming.

SnapshotsWindow.uss

Label {
    font-size: 20px;
    -unity-font-style: bold;
    color: rgb(68, 138, 255);
}

SnapshotsWindow.uxml

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
    <engine:Label text="Hello World! From UXML" />

  <engine:VisualElement>
    <engine:Style src="SnapshotsWindow.uss" />
  </engine:VisualElement>
</engine:UXML>

SnapshotsWindow.cs

using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;

public class SnapshotsWindow : EditorWindow
{
    [MenuItem("Window/UIElements/SnapshotsWindow")]
    public static void ShowExample()
    [MenuItem("ExploringUnity/SnapshotsWindow %#q")]
    public static void OpenWindow()
    {
        SnapshotsWindow wnd = GetWindow<SnapshotsWindow>();
        wnd.titleContent = new GUIContent("SnapshotsWindow");
    }

    public void OnEnable()
    {
        // Each editor window contains a root VisualElement object
        VisualElement root = rootVisualElement;

        // VisualElements objects can contain other VisualElement following a tree hierarchy.
        VisualElement label = new Label("Hello World! From C#");
        root.Add(label);

        // Import UXML
        var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotsWindow.uxml");
        VisualElement labelFromUXML = visualTree.CloneTree();
        root.Add(labelFromUXML);

        var uiTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotsWindow.uxml");
        var ui = uiTemplate.CloneTree();
        rootVisualElement.Add(ui);

        // A stylesheet can be added to a VisualElement.
        // The style will be applied to the VisualElement and all of its children.
        var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor/SnapshotsWindow.uss");
        VisualElement labelWithStyle = new Label("Hello World! With Style");
        labelWithStyle.styleSheets.Add(styleSheet);
        root.Add(labelWithStyle);
    }
}

If you refresh (close and reopen) the snapshots window, it should now be empty.

The editor window is now empty.


Gathering Info: What’s Selected?

Our goal is to save (and later restore) the position, rotation, and scale of some group of selected GameObjects. Let’s collect this information and display it in our editor window.

We then need to call the new function when our window is enabled, and also register to receive notifications of selection changes by implementing the OnSelectionChange message defined by the parent class, EditorWindow. 1

    public void OnEnable()
    {
        var uiTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotsWindow.uxml");
        var ui = uiTemplate.CloneTree();
        rootVisualElement.Add(ui);

        HandleSelectionChange();
    }

    void OnSelectionChange() { HandleSelectionChange(); }

    void HandleSelectionChange()
    {
        var selected = Selection.gameObjects;
        var numSelected = selected.Length;

        Debug.Log($"[Snapshots]: {numSelected} selected");
        foreach (var go in selected)
        {
            var tf = go.transform;
            var (pos, rot, scale) = (tf.position, tf.rotation.eulerAngles, tf.localScale);
            Debug.Log($"{go.name}: {pos}, {rot}, {scale}");
        }
    }

The position, rotation, and scale of selected objects are now logged to the console.


Selected Info Header

Let’s move the information about the selected GameObjects from the Debug Console to our new editor window. Since this information can take up quite a bit of space to display if several objects are selected, we’ll make an expand/contract feature similar to the native Unity Inspector interface, like we did in Part 4, Monster Inspector. Here’s what we’ll be doing:

  • Structure (UXML) Changes
    • Add a <Label> for the selected info header
    • Add a <Label> for the selected info details
  • Functionality (C#) Changes
    • Add fields for the labels and the detail label visibility
    • Modify the selection changed function to populate the new labels
    • Add function to toggle the label visibility and swap the arrows
    • Register a callback to call the function above when the header label is clicked

First, we’ll take care of the structure changes. Let’s reopen SnapshotWindow.uxml and add the labels:

<engine:VisualElement>
    <engine:Style src="SnapshotsWindow.uss" />
    <engine:Label name="selectedInfoHeader" />
    <engine:Label name="selectedInfoDetails" />
  </engine:VisualElement>

And now we’ll go back to SnapshotWindow.cs to implement the functionality changes. We need to store references to the two new labels, and we also need a boolean to track whether we should be showing/hiding the details label.

public class SnapshotsWindow : EditorWindow
{
    Label selectedInfoHeader;
    Label selectedInfoDetails;
    bool showSelectedInfo;

    [MenuItem("ExploringUnity/SnapshotsWindow %#q")]

Next, we need to assign the label references in OnEnable.

        rootVisualElement.Add(ui);

        selectedInfoHeader = ui.Q<Label>("selectedInfoHeader");
        selectedInfoDetails = ui.Q<Label>("selectedInfoDetails");

        HandleSelectionChange();
    }

Now we need a function to update the header label. We’ll use the Unicode characters â–º (U+25BA) and â–¼ (U+25BC) to indicate whether the details are being hidden or shown.

            Debug.Log($"{go.name}: ({pos}), ({rot}), ({scale})");
        }
    }

    void UpdateSelectedInfoHeader(int numSelected)
    {
        var prefix = showSelectedInfo ? "\u25BC" : "\u25BA";
        selectedInfoHeader.text = $"{prefix} Selected GameObjects ({numSelected})";
    }
}

And we need to call this new function everytime the selection is changed in HandleSelectionChange. We can also get rid of the Debug message that was outputting the number of selected items.

        var numSelected = selected.Length;

        Debug.Log($"[Snapshots]: {numSelected} selected");
        UpdateSelectedInfoHeader(numSelected);
        foreach (var go in selected)

Let’s test out what we have so far. Refresh the snapshots window, and try selecting objects in the scene to verify that the count stays in sync with the selection. You should see something like this:

The header label is in sync with the selection.


Selected Info Details

Next let’s populate the details label. First, we need to write a function to set the details label based on the selected GameObjects. We’ll create a multiline string similar to what we were logging to the Debug console earlier.

using System.Linq;
using UnityEditor;

...

        selectedInfoHeader.text = $"{prefix} Selected GameObjects ({numSelected})";
    }

    void UpdateSelectedInfoDetails(GameObject[] selected)
    {
        var details = from x in selected
                      let name = x.name
                      let tf = x.transform
                      let pos = tf.position
                      let rot = tf.rotation.eulerAngles
                      let scale = tf.localScale
                      select $"{name}: {pos}, {rot}, {scale}";
        selectedInfoDetails.text = string.Join("\n", details);
    }
}

Then we just need to replace our loop where we were logging to the console with our new function.

        UpdateSelectedInfoHeader(numSelected);
        UpdateSelectedInfoDetails(selected);
        Debug.Log($"[Snapshots]: { numSelected} selected");
        foreach (var go in selected)
        {
            var tf = go.transform;
            var (pos, rot, scale) = (tf.position, tf.rotation.eulerAngles, tf.localScale);
            Debug.Log($"{go.name}: {pos}, {rot}, {scale}");
        }
    }

This is a good time to test again, so let’s check what we’ve got. After selecting a couple objects, you should see something like this:

The details label shows transform information on the two selected objects.

It’s not pretty, but it works – good enough for now 2.


Toggling the Selected Info Details

To toggle the selected info details, we need to write a function that does the toggling and register a click handler on the header label to call it. But first, let’s modify UpdateSelectedInfoDetails to respect the showSelectedInfo flag by using DisplayStyle.Flex/None to show/hide the label.

    void UpdateSelectedInfoDetails(GameObject[] selected)
    {
        if (showSelectedInfo)
        {
            selectedInfoDetails.style.display = DisplayStyle.Flex;
                var details = from x in selected
                              let name = x.name
                              let tf = x.transform
                              let pos = tf.position
                              let rot = tf.rotation.eulerAngles
                              let scale = tf.localScale
                              select $"{name}: {pos}, {rot}, {scale}";
                selectedInfoDetails.text = string.Join("\n", details);
        }
        else
        {
            selectedInfoDetails.style.display = DisplayStyle.None;
        }
    }

HandleSelectionChange calls both UpdateSelectedInfoHeader and UpdateSelectedInfoDetails with the current selection, so all we need to do is flip showSelectedInfo and call HandleSelectionChange when the header label is clicked.

Like in Part 4, we’ll use RegisterCallback<MouseDownEvent>, which means our toggle function needs to take a MouseDownEvent parameter, even though we won’t be using the event data.

        selectedInfoDetails = ui.Q<Label>("selectedInfoDetails");

        selectedInfoHeader.RegisterCallback<MouseDownEvent>(ToggleSelectedInfo);

        HandleSelectionChange();

...

            selectedInfoDetails.style.display = DisplayStyle.None;
        }
    }

    void ToggleSelectedInfo(MouseDownEvent evt)
    {
        showSelectedInfo = !showSelectedInfo;
        HandleSelectionChange();
    }
}

Let’s test it out. After selecting some items, you should be able to click the header label to show/hide the details. It should look something like this when the details are visible:

The details label is hidden.

And like this after toggling:

The details label is shown.

Again, it’s not very pretty, but we’ll style things later.


Uniquely Identifying Objects

Before we can continue working on our Snapshots, we need some way of uniquely identifying a GameObject that can survive restarting the Unity editor. GameObject names are NOT necessarily unique, so we’ll need something different.

The hierarchy window shows several objects all named “Cube”.

A Failed Attempt - GetInstanceId()

Unity’s base Object class has a function, int GetInstanceID(), whose description says that it “Returns the instance id of the object. The instance id of an object is always guaranteed to be unique.” This sounds promising, but it turns out that while the id that it returns is indeed unique, it is not constant – restarting Unity causes the object to get a new unique id.

To demonstrate, here is a tiny script that logs an object’s instance id when the object is enabled:

using UnityEngine;
public class LogInstanceId : MonoBehaviour
{
    void OnEnable() { Debug.Log($"{name} - InstanceId: {GetInstanceID()}"); }
}

Using this, I logged the id of an object from my scene, yielding an id of -8674:

An instance id is logged.

The id stays the same after leaving and re-entering Play Mode, but after restarting Unity and repeating the process, the id changed to 13140:

A different instance id is logged.

So, GetInstanceId() alone won’t work for our purposes – we need persistence. There is an alternative struct, GlobalObjectId, which also sounds promising 3, but we’ll instead roll our own persistent unique id system as a learning experience.


A Custom Unique Id System

For this, we’ll create a script we can attach to objects that creates and maintains a unique id. Since this script will be added as a component to objects, it cannot be in an Editor folder. Let’s create a script named SnapshotId in our Assets folder and clean it up by deleting the System imports and functions that were automatically added.

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

public class SnapshotId : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}

Now we’ll add an id field that gets set in the Awake function, if it isn’t already set. We’ll rely on System.GUID to actually generate a sufficiently unique id.

using UnityEngine;

public class SnapshotId : MonoBehaviour
{
    public string id;

    void Awake()
    {
        if (id == null)
        {
            id = NewId();
        }
    }

    string NewId() { return System.Guid.NewGuid().ToString(); }
}

Since we’ll be using this tool in Edit Mode, we need to add the ExecuteInEditMode attribute to our new class – otherwise the Awake function would only be called when entering Play Mode.

using UnityEngine;

[ExecuteInEditMode]
public class SnapshotId : MonoBehaviour

If you manually add this new script to an object in your scene, the id field should autopopulate with a random GUID, like this:

A Snapshot Id component on a GameObject with an example GUID, c2e485dd-07f7-4473-b573-fc5eea387e63.

There is a problem with the script above. If you duplicate a GameObject that has a SnapshotId, it will get a copy of the SnapshotId component, with the same id – so much for being unique! To solve this, we’ll create a central registry (a static Dictionary) to keep track of GameObjects and their ids.

When first generating an id, we should add it to the dictionary. And when the object is destroyed, we should remove it from the dictionary to reclaim the memory.

    public string id;

    public static Dictionary<string, GameObject> idToObj = new Dictionary<string, GameObject>();

    void Awake()
    {
        if (id == null)
        {
            id = NewId();
            idToObj[id] = gameObject;
        }
    }

    void OnDestroy()
    {
        idToObj.Remove(id);
    }

    string NewId() { return System.Guid.NewGuid().ToString(); }
}

Unity guarantees that Awake is only called once during the lifetime of a script instance. Therefore, if an awakening object already has an id, and that id is in the dictionary, then the object must be a duplicate of another GameObject that had a SnapshotId component. Since this is a brand new object, it can’t be in any existing snapshots, thus it doesn’t need a SnapshotId component yet. We can abort and remove the component using the DestroyImmediate function.

The dictionary is not being serialized, so it is blown away when we make code changes, enter/leave Play Mode, and when we restart the Unity editor. If an object already has an id, but that id is not in the dictionary, that is an indication that the dictionary was destroyed and recreated, so we should re-register the object.

    public string id;

    static readonly Dictionary<string, int> idMap = new Dictionary<string, int>();

    void Awake()
    {
        if (id == null)
        {
            id = NewId();
            idToObj[id] = gameObject;
        }
        else if (idToObj.ContainsKey(id))
        {
            DestroyImmediate(this);
        }
        else
        {
            idToObj[id] = gameObject;
        }
    }

    void OnDestroy()
    {
        idToObj.Remove(id);
    }

    string NewId() { return System.Guid.NewGuid().ToString(); }
}

Now we have a way of uniquely identifying GameObjects that survives changing editor modes as well as restarting Unity and also handles duplicating objects – we just need to add the SnapshotId component above to any object in the scene that we want to track.

Note: Dictionary NOT Serialized

To help avoid some potential annoying testing issues down the road, I want to repeat that the idToObj dictionary is not being serialized – it is blown away every time we make code changes (because Unity is rebuilding/reloading our entire Assembly). If you later try to do something that requires the dictionary, you’ll probably get a KeyNotFoundException. Also, the duplicate prevention code won’t work! A quick and easy way to force a rebuild of the dictionary is to enter and immediately exit play mode.

Instead of always having to remember to enter/exit play mode after every code change (yuck!), we can add a bit of code to our snapshots window that automatically reinitializes the idToObj map after code changes, as long as we have our snapshot window open.

A code change (and assembly rebuild) will trigger our window’s OnEnable function, if it is open. With that in mind, we can write a function to loop over all existing SnapshotId components and register them with the idToObj map and call it in OnEnable. Lucky for us, Unity has a handy built-in function for getting all existing components of a given type, Resources.FindObjectsOfTypeAll<T>().

public void OnEnable()
    {
        InitializeSnapshotIdMap();
        var uiTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotsWindow.uxml");
...

        HandleSelectionChange();
    }

    void InitializeSnapshotIdMap()
    {
        var uniqueIds = Resources.FindObjectsOfTypeAll<SnapshotId>();
        foreach (var uniqueId in uniqueIds)
        {
            SnapshotId.idToObj[uniqueId.id] = uniqueId.gameObject;
        }
    }
}

As long as you leave the Snapshot Window open (it can be docked and doesn’t have to be focused), the id to GameObject map will always be initialized. Also, in case it’s closed when you make a code change, reopening the snapshots window now has the same effect of reinitializing the map as entering/exiting play mode.


Snapshots Data Classes

Now that we’ve got unique ids figured out, we can get back to Snapshots. We’ll create two simple classes to store the information:

  1. Snap will have the id and transform info (pos/rot/scale) for a single object.
  2. Snapshot will have a title (from user input) and Snap list.

We’ll start with Snap. Create a new C# script in the Editor folder named Snap.

This isn’t a script we’ll be attaching to GameObjects, so we should remove all the MonoBehaviour-related code. We can also delete the using System... statements, since we won’t be needing them.

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

public class Snap : MonoBehaviour
public class Snap
{
    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}

For each object, we want to store the object’s unique id, the position, rotation, and scale, so let’s add those as public fields, with a constructor to initialize the snapshot given an id and a transform. Let’s also override ToString to give us a friendler debug output. Since we will eventually be saving these snaps to disk, let’s also add the System.Serializable attribute to the class.

[System.Serializable]
public class Snap
{
    public string id;
    public Vector3 position;
    public Quaternion rotation;
    public Vector3 scale;

    public Snap(string id_, Transform tf)
    {
        id = id_;
        position = tf.position;
        rotation = tf.rotation;
        scale = tf.localScale;
    }

    public override string ToString()
    {
        var rotDegrees = rotation.eulerAngles;
        return $"[Snap] {id} : {position}, {rotDegrees}, {scale}";
    }
}

That’s it for Snap, let’s move on to Snapshot. Create a new C# script in the Editor folder named Snapshot. Like we did with Snap, delete all the MonoBehaviour related code. We can also delete all the using statements except for System.Collections.Generic, which we need since the class will use a list.

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

public class Snapshot : MonoBehaviour
public class Snapshot
{
    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}

Now let’s add fields for the title and list of snaps, as well as a ToString override for debugging. Snapshot also needs to be marked as Serializable in order for persistence to work later.

[System.Serializable]
public class Snapshot
{
    public string title;
    public List<Snap> snaps;

    public Snapshot(string title, List<Snap> snaps)
    {
        this.title = title;
        this.snaps = snaps;
    }

    public override string ToString()
    {
        return $"[Snapshot] {title} -- # Snaps: {snaps.Count}";
    }
}

Back to the Editor Window

Now that we’re done with our snapshot data classes, we can get back to work on our editor window. In this section, we’ll tackle all of the basic snapshot UI and functionality – everything except persisting the snapshots. At a very high level, we can split this into two tasks:

  1. UI to create a new snapshot
  2. UI to display existing snapshots

Creating a New Snapshot

Our interface for creating a new snapshot will be simply a text box (TextField) for the user to enter the snapshot title and a button. We’ll also add a generic <VisualElement> container to hold detail UIs for the snapshots that we create. Let’s add those to our main UXML template, SnapshotsWindow.uxml.

    <engine:Label name="selectedInfoDetails" />
    <engine:TextField name="newSnapshotTitle" label="Snapshot Title" />
    <engine:Button name="createSnapshotBtn" text="Create Snapshot" />
    <engine:VisualElement name="snapshotDetailsContainer" />

  </engine:VisualElement>

Refresh the Snapshots editor window to see the changes above take effect.

There is a title input and create button in the UI now.

That’s all the structural changes we need for now, so let’s hop back over to SnapshotsWindow.cs to wire up the new elements.

public class SnapshotsWindow : EditorWindow
{
    TextField newSnapshotTitle;
    Button createSnapshotBtn;
    VisualElement snapshotDetailsContainer;
    Label selectedInfoHeader;

...

        selectedInfoDetails = ui.Q<Label>("selectedInfoDetails");
        newSnapshotTitle = ui.Q<TextField>("newSnapshotTitle");
        createSnapshotBtn = ui.Q<Button>("createSnapshotBtn");
        snapshotDetailsContainer = ui.Q("snapshotDetailsContainer");

        selectedInfoHeader.RegisterCallback<MouseDownEvent>(ToggleSelectedInfo);

Next, we need a function to create a new snapshot from the selected items, which we’ll register as a click callback on the create snapshot button. We also need some sort of container to store our snapshots, so we’ll add a list for now.

using System.Collections.Generic;
using System.Linq;

...

public class SnapshotsWindow : EditorWindow
{
    List<Snapshot> snapshots;

    TextField newSnapshotTitle;

...

    public void OnEnable()
    {
        snapshots = new List<Snapshot>();
        InitializeSnapshotIdMap();


...

        snapshotDetailsContainer = ui.Q("snapshotDetailsContainer");

        createSnapshotBtn.clicked += CreateSnapshot;
        selectedInfoHeader.RegisterCallback<MouseDownEvent>(ToggleSelectedInfo);

        HandleSelectionChange();
    }

    void CreateSnapshot()
    {
    }

    void OnSelectionChange() { HandleSelectionChange(); }

First, we’ll short-circuit and return immediately if there aren’t any selected objects. Then we’ll get the user title from the text field. If the field is blank, we’ll fall back to a default title. Finally, we’ll log the title to the Debug console to verify that our UI is wired up correctly.

    void CreateSnapshot()
    {
        var selected = Selection.gameObjects;
        var numSelected = selected.Length;
        if (numSelected == 0) { return; }

        var titleRaw = newSnapshotTitle.text;
        var title = string.IsNullOrEmpty(titleRaw) ? "Untitled" : titleRaw;
        Debug.Log($"[CreateSnapshot] title={title}");
    }

At this point, you can try entering something in the text box (or not) and clicking the “Create Snapshot” button. Verify that the Debug output matches your input.

The debug log message agrees with the text field in the editor window.

Now we can loop over the selected objects to create a list of Snap objects. We’ll then use that snap-list to create a Snapshot and add the new snapshot to our editor window’s list of snapshots.

The Snap constructor expects a unique id, which should come from the object’s UniqueId component that we created earlier. However, the selected object may not have a UniqueId component yet. In that case, we’ll simply give it one using the AddComponent function.

        var title = string.IsNullOrEmpty(titleRaw) ? "Untitled" : titleRaw;
        Debug.Log($"[CreateSnapshot] title={title}");

        var snaps = new List<Snap>();
        foreach (var obj in selected)
        {
            var uniqueId = obj.GetComponent<SnapshotId>();
            if (uniqueId == null)
            {
                uniqueId = obj.AddComponent<SnapshotId>();
            }

            snaps.Add(new Snap(uniqueId.id, obj.transform));
        }
        var snapshot = new Snapshot(title, snaps);
        snapshots.Add(snapshot);
    }

To check our work, we can temporarily add some logging to the end of the function.

        snapshots.Add(snapshot);

        Debug.Log($"# of Snapshots: {snapshots.Count}");
        foreach (var snapshot in snapshots)
        {
            Debug.Log($"{snapshot}");
            foreach (var snap in snapshot.snaps)
            {
                Debug.Log($"{snap}");
            }
        }
    }

Try creating and arranging some objects in your scene. Then, select some groups of objects and create some snapshots. You should see something like this:

The snapshot debug output matches info in the inspector.

We’ll come back to persisting the snapshot list later. For now, let’s work on moving snapshot information from the Debug console into our snapshots window.


Displaying Snapshot Details

Our next task is to create a UI to display information about our snapshots. Here’s what we need to do:

  • Create a UXML template for a snapshot, similar to the selected info display
  • Instantiate the template above in CreateSnapshot
  • Fill in the labels with the snapshot info
  • Add a click handler to show/hide the details
  • Add the new SnapshotDetail element to the main UI

First, let’s create a new UXML template to display the details of a snapshot. Right click in the Editor folder and choose Create 🠚 UIElements 🠚 UXML Template. Name the file SnapshotDetail.

We’ll mimic the way we handled selected object info earlier – we’ll use two labels, a header and details label.

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
  <engine:Label name="snapshotHeader" />
  <engine:Label name="snapDetails" />
</engine:UXML>

Next we’ll set up the show/hide system for the snapshot details. We had a global (to our window) bool showSelectedInfo, but that won’t work for snapshots since we can have multiple, all independently showing or hiding their details. We could store a map of the snapshot ui object to a show/hide flag (i.e. Dictionary<VisualElement, bool>), but I’d rather not have to maintain another data structure if we don’t have to.

Instead, we’ll have the SnapshotDetail itself store whether or not we should be displaying the snap details by adding a boolean (Toggle) element.

  <engine:Label name="snapDetails" />
  <engine:Toggle name="showDetails" value="true"  />
</engine:UXML>

That’s all the structure we need. Let’s go back to the editor window class, SnapshotWindow.cs, and load up the new SnapshotDetail template during initialization in OnEnable. Since we’ll be using the template outside of the OnEnable function where we do the loading, we should add a field to store the template.

    List<Snapshot> snapshots;

    VisualTreeAsset snapshotDetailTemplate;
    TextField newSnapshotTitle;

...

    public void OnEnable()
    {
        snapshots = new List<Snapshot>();
        InitializeSnapshotIdMap();

        snapshotDetailTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotDetail.uxml");
        var uiTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotsWindow.uxml");

Now we have a template that we can instantiate in CreateSnapshot and add to the snapshot detail UI container, snapshotDetailsContainer. Since we’re going to be displaying the snapshot details in our editor window, we can remove the Debug.Log calls while we’re at it.

        snapshots.Add(snapshot);

        Debug.Log($"# of Snapshots: {snapshots.Count}");
        foreach (var snapshot in snapshots)
        {
            Debug.Log($"{snapshot}");
            foreach (var snap in snapshot.snaps)
            {
                Debug.Log($"{snap}");
            }
        }

        var detailUi = snapshotDetailTemplate.CloneTree();
        snapshotDetailsContainer.Add(detailUi);
    }

We can test now, but the results aren’t very exciting, because we don’t have any default values for our labels, and we’re not initializing our instantiated template yet.

The only thing visible where the snapshot details should be are some checkboxes.

Let’s write a new function that sets snapshot detail UI elements (labels + show/hide toggle) from a snapshot. It will be very similar to the UpdateSelectedInfoDetails function.

        HandleSelectionChange();
    }

    void UpdateSnapshotDetails(Snapshot snapshot,
                               Label headerLbl,
                               Label detailsLbl,
                               Toggle showDetails)
    {
        var prefix = showDetails.value ? "\u25BC" : "\u25BA";
        headerLbl.text = $"{prefix} {snapshot.title} ({snapshot.snaps.Count})";

        if (showDetails.value)
        {
            detailsLbl.style.display = DisplayStyle.Flex;
            var snapDetails = from snap in snapshot.snaps
                              let id = snap.id
                              let pos = snap.position
                              let rot = snap.rotation.eulerAngles
                              let scale = snap.scale
                              select $"{id}: {pos}, {rot}, {scale}";
            detailsLbl.text = string.Join("\n", snapDetails);
        }
        else
        {
            detailsLbl.style.display = DisplayStyle.None;
        }
    }

    void InitializeSnapshotIdMap()

Then we’ll call the new function after instantiating the template in CreateSnapshot.

        var detailUi = snapshotDetailTemplate.CloneTree();
        var snapshotHeader = detailUi.Q<Label>("snapshotHeader");
        var snapDetails = detailUi.Q<Label>("snapDetails");
        var showDetails = detailUi.Q<Toggle>("showDetails");
        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);
        snapshotDetailsContainer.Add(detailUi);

Toggling Snapshot Details

Now that we’re initializing everything, we should be able to see the details of the snapshots we create, like this:

The snapshot details are now correct.

Time to implement toggling. Since UpdateSnapshotDetails respects the Toggle, our show/hide function just need to flip the Toggle and call UpdateSnapshotDetails to refresh the UI.

            detailsLbl.style.display = DisplayStyle.None;
        }
    }

    void ToggleSnapshotDetails(Snapshot snapshot,
                               Label headerLbl,
                               Label detailsLbl,
                               Toggle showDetails)
    {
        showDetails.value = !showDetails.value;
        UpdateSnapshotDetails(snapshot, headerLbl, detailsLbl, showDetails);
    }

    void InitializeSnapshotIdMap()

To show/hide the snap details when we click the header, we’ll create a local function as a click handler and register it with the header label.

        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);
        void onClick(MouseDownEvent evt) => ToggleSnapshotDetails(snapshot,
                                                                  snapshotHeader,
                                                                  snapDetails,
                                                                  showDetails);
        snapshotHeader.RegisterCallback<MouseDownEvent>(onClick);
        snapshotDetailsContainer.Add(detailUi);
    }

Now our snapshot details should expand/contract when the header labels are clicked like the selected object info does.

There are two snapshots.  The details of the first are hidden, and the second are shown.

It’s time to get rid of that checkbox, visually at least. All we need to do to hide it is set its style to hidden. There are several ways we could do this:

  • Create a new USS file (SnapshotDetail.uss, perhaps), add a rule for the Toggle, and embed it in the snapshot detail UXML template (SnapshotDetail.uxml).
  • Put a rule in our main USS file (SnapshotWindow.uss), which would cascade down to the Toggle since the snapshot details are children of the snapshot window.
  • Inline the style in the UXML file (SnapshotDetail.uxml)
  • Programmatically set the style after creating the UI from the template.

The first method is a bit more work than the others, but can be a good idea for larger projects if you want to keep styles strictly organized and separated from structure/functionality. The latter three options are all one-line changes that have their own minor pros/cons. We’ll go with the last one since we already have a reference to the Toggle in CreateSnapshot, and it isn’t much work to change later if we want to.

        var showDetails = detailUi.Q<Toggle>("showDetails");
        showDetails.style.display = DisplayStyle.None;
        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);

And with that, the toggles should be gone:

The toggle checkboxes are now invisible.

We’ll leave further styling for later. It’s time to make our snapshots useful!


Restoring From Snapshots

We’ve got snapshots, but we can’t do anything with them yet. In this section, we’ll add a “Restore” button to each snapshot that restores each of the objects’ position/rotation/scale to what they were when the snapshot was taken. To accomplish, we need to:

  • Add a button to the snapshot detail UXML template.
  • Create a function to restore a given GameObject’s position/rotation/scale from a snap.
  • Create a function to loop over all the snaps that a snapshot refers to, applying the function above for each snap.
  • When we are creating a snapshot and instantiate a new snapshot details template, register a click handler for the new button that calls the function above.

First up, the button. This one is easy – just reopen SnapshotDetail.uxml, and toss in a button. We can also remove the default value of true for the showDetails Toggle while we’re in there so the snapshot details start out hidden by default instead.

  <engine:Label name="snapshotHeader" />
  <engine:Label name="snapDetails" />
  <engine:Button name="restoreBtn" text="Restore" />
  <engine:Toggle name="showDetails" value="true" />
  <engine:Toggle name="showDetails" />
</engine:UXML>

A quick check to make sure everything seems ok, remembering that we have to refresh the snapshots window after making UXML changes:

There is now a restore button below each snapshot.

Next up, a function to restore a GameObject’s transform properties from a snap. Since we’re dealing with a Snap, we can put the function there. Let’s reopen Snap.cs and add a simple restore function. We can look up the GameObject using the SnapshotId.idToObj map. Then, we can grab the object’s transform and apply the Snap’s values.

        scale = tf.localScale;
    }

    public void Restore(GameObject go)
    {
        var go = SnapshotId.idToObj[id];
        var tf = go.transform;
        tf.position = position;
        tf.rotation = rotation;
        tf.localScale = scale;
    }

    public override string ToString()

We’re nearly done. The last two things we need to do are to create a function to loop over the snaps calling the snap restore function, and to wire up the restore button to call this new function in CreateSnapshot.

        var snapDetails = detailUi.Q<Label>("snapDetails");
        var restoreBtn = detailUi.Q<Button>("restoreBtn");
        restoreBtn.clicked += () => RestoreSnapshot(snapshot);
        var showDetails = detailUi.Q<Toggle>("showDetails");

...

        UpdateSnapshotDetails(snapshot, headerLbl, detailsLbl, showDetails);
    }

    void RestoreSnapshot(Snapshot snapshot)
    {
        Debug.Log($"Restoring: {snapshot}");
        foreach (var snap in snapshot.snaps) { snap.Restore(); }
    }
}

Time to test! Save your changes and enter/exit play mode to ensure the SnapshotId dictionaries are initialized, then try creating and restoring some snapshots.

Here, I made a snapshot of 4 unrotated cubes in a row, then moved and rotated them:

There are four cubes scattered about the scene.  The snapshots window has a snapshot named “Cubes in a Row (4)

After clicking “Restore”, the cubes are restored to their previous state:

The debug log shows that a restore occurred, and the cubes are lined up to match when the snapshot was taken.

Undo Support

Let’s quickly add Undo support, like we’ve done in earlier posts (first introduced in Part 2). We just need to collect the Transforms we are modifying and tell Undo about them before we actually change them. To make this easier, we’ll add a function to Snap to get the Transform corresponding to the snap.


        tf.localScale = scale;
    }

    public Transform GetTransform()
    {
        return SnapshotId.idToObj[id].transform;
    }

    public override string ToString()

Now back in SnapshotsWindow.cs, we can use this new function to collect all the transforms for the snapshot. We can also remove the debug logging while we’re at it.

    void RestoreSnapshot(Snapshot snapshot)
    {
        Debug.Log($"Restoring: {snapshot}");
        var transforms = snapshot.snaps.Select(x => x.GetTransform()).ToArray();
        Undo.RecordObjects(gameObjs, $"Restoring: {snapshot}");
        foreach (var snap in snapshot.snaps)
        {
            snap.Restore(SnapshotId.objMap[snap.id]);
        }
    }

Now we should have Undo/Redo support when restoring snapshots:

The Unity editor Edit menu is open, and there are entries to undo/redo restoring snapshots.

Now that we have a functional tool, we can work on snapshot persistence.


Snapshot Persistence

For GameObjects in a scene, Unity automatically persists public fields on MonoBehaviour scripts, as well as properties marked with the SerializeField attribute. However, our snapshots do not live in a component on a GameObject in any scene. Right now, they are just in an ephemeral List in SnapshotsWindow. What we need is something sort of persistent container for our snapshots, like a database.

ScriptableObject as a Database

Unity has a built-in data-oriented class we can use for this called ScriptableObject. To quote the Unity docs on the ScriptableObject class, “a ScriptableObject is a data container that you can use to save large amounts of data, independent of class instances.” A ScriptableObject is like a MonoBehaviour, but instead of being attached to a GameObject, it lives inside of a file somewhere in your Assets folder.

We’ll use a ScriptableObject for our snapshot database. Create a new C# script in the Editor folder named SnapshotDatabase. The class should inherit from ScriptableObject instead of MonoBehaviour. Remove the lifecycle functions and add a field to store a list of Snapshot items.

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

public class SnapshotDatabase : MonoBehaviour
public class SnapshotDatabase : ScriptableObject
{
    public List<Snapshot> snapshots;

    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}

That’s actually all the data that our database needs. Now we need a function to load our snapshot database, or create one if it doesn’t exist. For this, we need to get familiar with a few functions for interacting with Assets and ScriptableObjects:

  • AssetDatabase.LoadAssetAtPath<T>(filepath) – this loads an asset located at filepath of type T, returning null if it doesn’t exist.
  • ScriptableObject.CreateInstance<T> – this creates an instance (Object) of some ScriptableObject subclass, T.
  • AssetDatabase.CreateAsset(Object asset, string path) – This saves asset to path
  • AssetDatabase.SaveAssets() – This saves changed assets.
  • AssetDatabase.Refresh() – This refreshes the asset database to reflect the most recently saved changes.

Armed with the functions above, we can write a function to load our snapshot database (scriptable object asset), or create a new one if it doesn’t exist.

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

public class SnapshotDatabase : ScriptableObject
{
    public List<Snapshot> snapshots;

    public static SnapshotDatabase GetSnapshotDB()
    {
        const string dbFilepath = "Assets/Editor/SnapshotDB.asset";
        var snapshotDB = AssetDatabase.LoadAssetAtPath<SnapshotDatabase>(dbFilepath);

        if (snapshotDB == null)
        {
            snapshotDB = CreateInstance<SnapshotDatabase>();
            snapshotDB.snapshots = new List<Snapshot>();
            AssetDatabase.CreateAsset(snapshotDB, dbFilepath);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();
        }

        return snapshotDB;
    }
}

That’s it for the SnapshotDatabase class. Let’s move over to SnapshotWindow.cs and swap out our temporary snapshot list with our new persistent database! We’ll add a SnapshotDatabase field which we load up in OnEnable, and change our snapshots field to a get-only property that returns the database’s list.

public class SnapshotsWindow : EditorWindow
{
    SnapshotDatabase snapshotDB;
    List<Snapshot> snapshots;
    List<Snapshot> snapshots => snapshotDB.snapshots;

    VisualTreeAsset snapshotDetailTemplate;


    public void OnEnable()
    {
        InitializeSnapshotIdMap();
        snapshots = new List<Snapshot>();
        snapshotDB = SnapshotDatabase.GetSnapshotDB();
        snapshotDetailTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotDetail.uxml");

The last thing we need to do is ensure that our changes to the snapshot database are persisted to disk. All we need to do is tell Unity that the database has been changed and needs to be saved, using the functions EditorUtility.SetDirty(asset) and AssetDatabase.SaveAssets(). We’ll add a sync function and call it when creating a new snapshot.

        snapshots.Add(snapshot);
        SyncDatabase();
        snapshotDetailsContainer.Add(CreateSnapshotDetailUI(snapshot));
    }

    void SyncDatabase()
    {
        EditorUtility.SetDirty(snapshotDB);
        AssetDatabase.SaveAssets();
    }
}

That’s it! We’ve got our own little custom database now. Saving your changes and refreshing the snapshots window should cause a new asset, Assets/Editor/SnapshotDB.asset, to be automatically created.

A new snapshot database asset has been created in the editor folder.

If you test right now, however, it might look like it doesn’t work. This is because we are not creating SnapshotDetail UI elements for existing snapshots in the database on startup (in our window’s OnEnable). Let’s fix that.

Displaying Saved Snapshots

In CreateSnapshot, we’re actually creating and snapshot AND creating a snapshot detail UI 5. Let’s refactor the detail UI part into a separate function, then use that to loop over our saved snapshots in OnEnable.

        selectedInfoHeader.RegisterCallback<MouseDownEvent>(ToggleSelectedInfo);

        foreach (var snapshot in snapshots)
        {
            snapshotDetailsContainer.Add(CreateSnapshotDetailUI(snapshot));
        }

        HandleSelectionChange();
    }

    void CreateSnapshot(VisualElement ui)

...

        snapshots.Add(snapshot);

        var detailUi = snapshotDetailTemplate.CloneTree();
        var snapshotHeader = detailUi.Q<Label>("snapshotHeader");
        var snapDetails = detailUi.Q<Label>("snapDetails");
        var restoreBtn = detailUi.Q<Button>("restoreBtn");
        restoreBtn.clicked += () => RestoreSnapshot(snapshot);
        var showDetails = detailUi.Q<Toggle>("showDetails");
        showDetails.style.display = DisplayStyle.None;
        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);
        void onClick(MouseDownEvent evt) => ToggleSnapshotDetails(snapshot,
                                                                  snapshotHeader,
                                                                  snapDetails,
                                                                  showDetails);
        snapshotHeader.RegisterCallback<MouseDownEvent>(onClick);
        snapshotDetailsContainer.Add(detailUi);
        snapshotDetailsContainer.Add(CreateSnapshotDetailUI(snapshot));
    }

    VisualElement CreateSnapshotDetailUI(Snapshot snapshot)
    {
        var detailUi = snapshotDetailTemplate.CloneTree();
        var snapshotHeader = detailUi.Q<Label>("snapshotHeader");
        var snapDetails = detailUi.Q<Label>("snapDetails");
        var restoreBtn = detailUi.Q<Button>("restoreBtn");
        restoreBtn.clicked += () => RestoreSnapshot(snapshot);
        var showDetails = detailUi.Q<Toggle>("showDetails");
        showDetails.style.display = DisplayStyle.None;
        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);
        void onClick(MouseDownEvent evt) => ToggleSnapshotDetails(snapshot,
                                                                  snapshotHeader,
                                                                  snapDetails,
                                                                  showDetails);
        snapshotHeader.RegisterCallback<MouseDownEvent>(onClick);

        return detailUi;
    }

    void OnSelectionChange() { HandleSelectionChange(); }

Now our saved snapshots will show up when we open our editor window, and changes survive enter/exiting play mode and restarting the editor.

The snapshots survived transitioning to play mode.


Editing Snapshots

One benefit of using a ScriptableObject for our database is that we can edit it using Unity’s default inspector. To view or edit your snapshots, open an inspector tab and select the database. Expand the snapshot list and edit away as you see fit. Careful not to edit any ids, though, unless you really know what you’re doing!

SnapshotDB.asset is selected in the project view, and the inspector shows the snapshot list as editable.

It’s not super convenient for editing the angle, because it’s stored in Quaternion form 6, but position and scale are easy to change. Since we still have several other things to cover, I’ll leave it as an exercise for the reader to create an improved editing experience for snapshots.


Deleting Snapshots

It’s easy to delete snapshots via the Inspector for the SnapshotsDB.asset by either adjusting the length of the list to delete items from the end, or clicking an element and then pressing Shift + Delete, but it isn’t convenient to have to leave our snapshots window to do so.

It’s not much work for us to create a delete button, so let’s do so. First, we need to add a button to the details UI template, SnapshotDetail.uxml.

  <engine:Button name="restoreBtn" text="Restore" />
  <engine:Button name="deleteBtn" text="Delete" />
  <engine:Toggle name="showDetails" />

Then, in SnapshotsWindow.cs, we need to write a function to remove a snapshot from the database and UI. Finally, we need to wire up a callback for the delete button to call the new function when we create a new details UI in CreateSnapshotDetailUI.

        restoreBtn.clicked += () => RestoreSnapshot(snapshot);
        var deleteBtn = detailUi.Q<Button>("deleteBtn");
        deleteBtn.clicked += () => DeleteSnapshot(snapshot);
        var showDetails = detailUi.Q<Toggle>("showDetails");
        showDetails.style.display = DisplayStyle.None;
        UpdateSnapshotDetails(snapshot, snapshotHeader, snapDetails, showDetails);
        void onClick(MouseDownEvent evt) => ToggleSnapshotDetails(snapshot,
                                                                  snapshotHeader,
                                                                  snapDetails,
                                                                  showDetails);
        snapshotHeader.RegisterCallback<MouseDownEvent>(onClick);

        return detailUi;
    }

    void DeleteSnapshot(Snapshot snapshot, VisualElement detailUi)
    {
        snapshots.Remove(snapshot);
        SyncDatabase();
        detailUi.RemoveFromHierarchy();
    }

    void CreateSnapshot(VisualElement ui)

To test, re-enter/exit play mode to make sure dictionaries are initialized and then create and delete some snapshots. You can check the snapshot database scriptable object in the inspector to verify your changes.

Each of the snapshots now has a delete button underneath their restore buttons.


Finishing Touches

We have a functional tool now for saving/restoring the posisiton, rotation, and scale of groups of GameObjects with a single click. It’s pretty awesome, but there’s a lot more we can do to make it even better.

Garbage Collection

We’ve done good so far about cleaning up after ourselves – A SnapshotId removes itself from the idToObj map to avoid a memory leak, and we don’t add useless SnapshotId components to objects that don’t need them (duplicates). One more thing we can do to clean up is to remove the SnapshotId component from objects if there are no longer any snapshots referring to it.

Since SnapshotsWindow is in charge of creating and deleting snapshots, we’ll have it keep a counter for each id (Dictionary<string, int>) that keeps track of how many snapshots refer to the underlying GameObject. We’ll write a function to initialize the counts from the snapshots database, and call it after loading the database in OnEnable.

    List<Snapshot> snapshots => snapshotDB.snapshots;
    Dictionary<string, int> idToNumSnapshots;

    VisualTreeAsset snapshotDetailTemplate;

...

    public void OnEnable()
    {
        InitializeSnapshotIdMap();
        snapshotDB = SnapshotDatabase.GetSnapshotDB();
        InitializeSnapshotCounter();
        snapshotDetailTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/SnapshotDetail.uxml");


...

        AssetDatabase.SaveAssets();
    }

    void InitializeSnapshotCounter()
    {
        idToNumSnapshots = new Dictionary<string, int>();
        foreach (var snapshot in snapshots)
        {
            foreach (var snap in snapshot.snaps)
            {
                idToNumSnapshots.TryGetValue(snap.id, out var count);
                idToNumSnapshots[snap.id] = count + 1;
            }
        }
    }
}

We need to increment the count when snapshots new snapshots are made in CreateSnapshot. Let’s also refactor incrementing the count into a function to avoid duplicating the two lines in InitializeSnapshotCounter:

                uniqueId = obj.AddComponent<SnapshotId>();
            }

            snaps.Add(new Snap(uniqueId.id, obj.transform));
            var snap = new Snap(uniqueId.id, obj.transform);
            snaps.Add(snap);
            IncrementSnapshotCount(snap);
        }
        var snapshot = new Snapshot(title, snaps);

...

    void InitializeSnapshotCounter()
    {
        idToNumSnapshots = new Dictionary();
        foreach (var snap in snapshot.snaps)
        {
            idToNumSnapshots.TryGetValue(snap.id, out var count);
            idToNumSnapshots[snap.id] = count + 1;
            IncrementSnapshotCount(snap);
        }
    }

    private void IncrementSnapshotCount(Snap snap)
    {
        idToNumSnapshots.TryGetValue(snap.id, out var count);
        idToNumSnapshots[snap.id] = count + 1;
    }
}

We need a corresponding function to reduce the count that we can call in DeleteSnapshot. We’ll loop over the snaps in a snapshot decrementing the count. If the count drops to zero, we can look up the SnapshotId component for the object and use DestroyImmediate to remove it. We should also delete the counter for the item to avoid leaking memory over time.

    void DeleteSnapshot(Snapshot snapshot, VisualElement detailUi)
    {
        snapshots.Remove(snapshot);
        SyncDatabase();
        detailUi.RemoveFromHierarchy();

        foreach (var snap in snapshot.snaps)
        {
            DecrementSnapshotCount(snap);
            if (idToNumSnapshots[snap.id] == 0)
            {
                var gameObj = SnapshotId.idToObj[snap.id];
                var snapshotId = gameObj.GetComponent<SnapshotId>();
                DestroyImmediate(snapshotId);
                idToNumSnapshots.Remove(snap.id);
            }
        }
    }

...

        idToNumSnapshots[snap.id] = count + 1;
    }

    private void DecrementSnapshotCount(Snap snap)
    {
        idToNumSnapshots[snap.id] = idToNumSnapshots[snap.id] - 1;
    }
}

Now the SnapshotId component will be dynamically added and removed such that it only exists on a GameObject when there is some Snapshot of the object in our snapshot database.


Minimizing Runtime Overhead

Our Snapshots tool is only useful while developing the game, not when the game is actually finished and running. Optimally, the tool would have zero runtime overhead. Most of our Snapshots tool lives in the Editor folder, which has no runtime overhead, because Editor code is automatically stripped from release builds.

However, our SnapshotId component lives in the Assets folder, because it is a MonoBehaviour that must be attached to GameObjects. And if we don’t delete all of our snapshots before building our game, then some GameObjects will still have a SnapshotId component attached to them in the release build of the game.

We can reduce the impact of this by using platform dependent compilation directives to tell Unity we want some code to compile differently based on whether we are in the editor, on iOS, on Windows, etc.

The one we need is UNITY_EDITOR. Code wrapped with #if UNITY_EDITOR/#endif will exist when you are working in the editor, but will not be present in release builds. Let’s wrap the entire body of SnapshotId, so that even if we’re sloppy and leave a SnapshotId component on a GameObject, none of the component logic will be executed in the release build.

[ExecuteInEditMode]
public class SnapshotId : MonoBehaviour
{
#if UNITY_EDITOR
    public static Dictionary<string, GameObject> idToObj = new Dictionary<string, GameObject>();

...

    string NewId() { return System.Guid.NewGuid().ToString(); }
#endif
}

That’s all it takes to make SnapshotId an empty component for release builds.


Styling the Snapshots Window

It’s finally time to make our Snapshots window a little nicer looking. Let’s take a look again at what our goal is:

The same whiteboard drawing preview from the beginning of the post.

And here’s where our tool currently is:

The deleting snapshots image from earlier.

Not that much left to do! First, let’s put the buttons for each snapshot on the same line as the header to save vertical space. This entails putting them under a parent element whose style has its flex-direction property set to row.

Let’s take care of the structure changes first, in SnapshotDetail.uxml, and we’ll come back to the style in just a bit. To keep the header row from being too cramped after the buttons join the party, we can replace the button text with icons – We’ll use the Unicode character ↺ (\u21BA) for restore and just a regular ASCII ‘X’ for delete.

    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
  <engine:VisualElement name="snapshotHeaderRow">
    <engine:Label name="snapshotHeader" />
    <engine:Button name="restoreBtn" text="↺" />
    <engine:Button name="deleteBtn" text="X" />
  </engine:VisualElement>
  <engine:Label name="snapDetails" />
  <engine:Button name="restoreBtn" text="Restore" />
  <engine:Button name="deleteBtn" text="Delete" />
  <engine:Toggle name="showDetails" />

The buttons have icons now, and they have swapped places with the details to be directly below the header text.

And now, lonely SnapshotsWindow.uss, who has been hitherto ignored, finally gets a line of code:

#snapshotHeaderRow { flex-direction: row; }

Now the three elements in the snapshotHeaderRow will be arranged horizontally instead of vertically:

The snapshot buttons are now small and to the right of the title instead of below it.

Try using some of the techniques from the previous parts of this series to make the rest of the UI look better.


Making IDs Ready-Only

We shouldn’t be editing the id field of a SnapshotId component by hand, but it’s easy to accidentally do.

The snapshot id has been changed to “oh no, we can accidentally change this!

We can create a custom PropertyAttribute to mark the id field as readonly, and then a custom PropertyDrawer to customize the display of fields that are marked with the new attribute.

Create a new C# script in the Assets folder named ReadonlyInInspectorAttribute and delete everything except the UnityEngine import and class declaration. The class should inherit from PeopertyAttribute, which also requires importing UnityEditor.

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

public class ReadonlyInInspectorAttribute : MonoBehaviour
public class ReadonlyInInspectorAttribute : PeopertyAttribute { }
{

    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}

That’s it for the attribute. We just need it to exist so that we can apply it to fields. The real readonly magic is in the corresponding PropertyDrawer subclass that we need to create. We’ll add it to the same file, since it’s short, simple, and only relevant to ReadonlyInInspectorAttribute.

Our PropertyDrawer subclass needs an OnGUI(Rect position, SerializedProperty property, GUIContent label) function to tell Unity how to render the field. There is a magic boolean, GUI.enabled that affects how fields render. Setting it to false disables GUI elements until it is set back to true. We’ll do exactly that, delegating to EditorGUI.PropertyField to draw the field while the GUI is disabled.

public class ReadonlyInInspectorAttribute : PropertyAttribute { }

public class ReadonlyInInspectorDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position,
                               SerializedProperty property,
                               GUIContent label)
    {
        GUI.enabled = false;
        EditorGUI.PropertyField(position, property, label, true);
        GUI.enabled = true;
    }
}

To link our custom attribute to our custom property drawer, we need to use the CustomPropertyDrawer attribute.

public class ReadonlyInInspectorAttribute : PropertyAttribute { }

[CustomPropertyDrawer(typeof(ReadonlyInInspectorAttribute))]
public class ReadonlyInInspectorDrawer : PropertyDrawer

With our new attribute and property drawer complete, we just need to add the ReadonlyInInspector attribute to the id field in SnapshotId.cs:

    public static Dictionary<string, GameObject> idToObj = new Dictionary<string, GameObject>();

    [ReadonlyInInspector]
    public string id;

The snapshot id field is now disabled and cannot be edited.

Note that this uses the old IMGUI UI framework instead of UIElements – it’s an older script (but it still works). There’s probably a new way to do this using UIToolkit/UIElements, but I’ll leave that as one more exercise for the reader.


Wrap Up

Final Code: https://github.com/exploringunity/snapshots

As usual, we started with another whiteboard drawing and ended up with another useful editor extension. Here are some of the things we covered:

  • Using a ScriptableObject as a simple, ad-hoc database.
  • Creating a custom persistent unique id system.
  • Dynamically adding and removing components to/from GameObjects.

What’s Next?

???

Feel free to contact me if you have any suggestions!


  1. This is equivalent to registering with the Selection class via the selectionChanged Action (i.e. - we could have written Selection.selectionChanged += HandleSelectionChange instead, which is what I think I did in previous tutorials). ↩︎

  2. One of my general software development philosophies is: “Make it work, make it fast, make it pretty – in that order.” ↩︎

  3. Digging through the documentation, I also found the UnityEditor.GlobalObjectId struct, whose description says it “provides an API for stable, project-global object identifiers… The ID is persistent and unique for a given Unity Object.” I have yet to experiment with it, and based on search results, virtually no one else has either. I’ll try to remember to revisit this post and update it if I explore GlobalObjectId in the future. ↩︎

  4. I love/hate autocomplete. I encourage you to read (at least) the the section on Intellisense from Charles Petzold’s Does Visual Studio Rot the Mind? ↩︎

  5. Having one function do multiple things, especially when one of the things it does is not obvious from the function name, is a violation of the Single Responsibility Pricinple. ↩︎

  6. In general, you probably shouldn’t be modifying a quaternion’s xyzw components manually. However, if you’re interested in how to do it anyway, see the Wiki page on Conversion between quaternions and Euler angles. ↩︎