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:
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
.
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.
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}");
}
}
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:
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:
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:
And like this after toggling:
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.
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:
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:
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:
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:
Snap
will have the id and transform info (pos/rot/scale) for a single object.Snapshot
will have a title (from user input) andSnap
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:
- UI to create a new snapshot
- 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.
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.
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:
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.
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:
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.
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:
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:
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:
After clicking “Restore”, the cubes are restored to their previous state:
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:
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 atfilepath
of typeT
, returningnull
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
topath
- 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.
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.
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!
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.
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:
And here’s where our tool currently is:
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" />
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:
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.
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;
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!
-
This is equivalent to registering with the
Selection
class via the selectionChanged Action (i.e. - we could have writtenSelection.selectionChanged += HandleSelectionChange
instead, which is what I think I did in previous tutorials). ↩︎ -
One of my general software development philosophies is: “Make it work, make it fast, make it pretty – in that order.” ↩︎
-
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. ↩︎ -
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? ↩︎
-
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. ↩︎
-
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. ↩︎