Automate the boring parts
date
Sep 1, 2023
type
Post
year
slug
automate-the-boring-parts
status
Published
tags
Unity
Talk
EditorWindow
summary
A practical guide to building your own helpful tools in Unity (Write-up + Slides)
This is a write-up of my talk at NZGDC 2023 → ▶️ Slides
Intro
No matter how exciting the project you’re working on - it will inevitably contain dull, repetitive tasks. Assigning items to lists, sifting through endless amounts of assets, finding specific bits and pieces in the hierarchy - And what’s worse is that some of those boring tasks require your full attention and are even pretty exhausting to do! So you wear yourself out by doing something that could just as well be done by a monkey.
Well, I think we want to use our brain power on the important stuff instead. On the things that make a difference for our game! So whenever we find ourselves in the face of a boring task - let’s take a minute and see if we can write a small reusable tool to automate it!
Guiding Principles
I’ve been thinking about what’s been the most useful to me over the years and I’ve narrowed it down to these guiding principles: Convenience, Speed, Clarity - each one is good, if you manage do all 3 at once you’re golden.
Convenience
Nothing important should be more than a click or a shortcut away!
- This can be as simple as a window with shotcut buttons to your most often needed folders and objects.
- Or a menu item item that does something convenient for you
It doesn’t have to be pretty, some of these things are just for you.
- Or a window with buttons for aligning selected objects. Align them, space them out.
- Or this window - I got the idea from a handy little feature in Modo. If you’re tweaking values, then you don’t want to go sift through the hierarchy and jump from one thing to the next again and again. With Channel Focus you can add individual channels from different objects and tweak them easily, as well as monitor their values. But most importantly you can tweak at utmost efficiency! No time wasted, all your focus on the task at hand!
Speed
Save time (and your mental health) by automating things you have to do again and again.
Over the years I’ve taken several attempts to find a more general solution to the problem of having to do boring, repetitive stuff - the best one so far is BatchProcessor - Often times you have to find 20 little things in a scene or want to clean up and rename a few things and it’s always tedious. So I tried to build a general purpose tool to help with all that.
BatchProcessor lets you search for items, then apply “processors” to the resulting list of objects. So for example you could search for all UI elements in a specific colour and change it. Or search for all objects called Column with a boxCollider that’s less than a meter high and rename and number them to Small Column 1,2,3, etc.
Clarity
Make it easy to see what’s going on, what’s missing, etc. (Custom Inspectors, custom setup tools, etc.)
- For my long-time side-project Synthspace I built a custom Editor Window that makes setting up new modules easy. It guides me through the process, shows me what’s still missing and wherever possible offers a way to solve it or at least shows a shortcut to where my attention is needed!
But let’s get to it, let me show you how to build some useful bits and pieces!
What can we do in Unity?
What can we make, what can we customize?
As it turns out - pretty much everything.
We can create custom Editor Windows, Wizards, Custom Inspectors, add menu items, context menu options, create custom Scene Tools, draw lines and place gizmos in the scene view, we can add Settings, hook into asset importing and the build process, we can create custom previews, manipulate selections, etc., etc.
Everything that’s non-visual is just an Editor script and all you need to do is figure out what Editor API to hook into.
Everything that’s visual can be done with either IMGUI or UI Toolkit
The interface of the Unity Editor itself is done with IMGUI and UI Toolkit, so it’s quite easy to expand.
Basics
So what’s an Editor Script? In Unity there’s a fundamental difference between “normal” scripts and “Editor” scripts.
To make a script an Editor script you simply put it in a folder called “Editor” (you can have multiple of those all over your project)
Editor scripts
- won’t be included in builds
- can use stuff in the
UnityEditor
namespace!
A word of warning: nothing prevents you from using Editor-only APIs in normal scripts, but your builds will fail.
Menu Items
Simplest example: Let’s make a menu item that zeroes out the rotation of all selected objects!
We need a static class with a static function that we attribute with
MenuItem
using UnityEditor; using UnityEngine; public static class Zero { [MenuItem( "Tools/Zero Rotation %&z" )] public static void ZeroRotation() { foreach( var g in Selection.gameObjects ) { Undo.RecordObject( g.transform, "Zero Rotation" ); g.transform.rotation = Quaternion.identity; } } }
Now we have a menu item under Tools and we even added a keyboard shortcut!
%
is for CTRL
/ CMD
#
is for SHIFT
&
is for ALT
Inside we step through all the GameObjects inside the current Selection, record an Undo step and change the rotation to zero. - note how this is one way of handling Undo - you record an object, then change it. We’ll see a different way later on.
Further Reading: Supporting Undo in Unity Editor scripts
Validate Function
There’s more things we can do with the
MenuItem
attribute. While we can’t change the name of a menu item at runtime, we can add a validation function that determines if our menu item should be enabled or disabled. In our case, it only makes sense to use our menu item if there are items selected, so let’s check if our selection is empty or not.[MenuItem( "Tools/Zero Rotation %&z", true )] public static bool ZeroRotationValidation() { return Selection.gameObjects.Length > 0; }
As you see, I created a function that returns true when we have selected at least one item. By giving it the same attribute with
true
for the isValidateFunction
argument, we turn this function into a validate function for our menu itemLastly we can determine where in the menu our function will be shown. If we add a number as the last argument - that will be used to figure out the order of things. If there’s a difference bigger than 10, it will add a divider in between menu items.
Here’s the finished script:
using UnityEditor; using UnityEngine; public static class Zero { [MenuItem( "Tools/Zero Rotation %&z", false, 901 )] public static void ZeroRotation() { foreach( var g in Selection.gameObjects ) { Undo.RecordObject( g.transform, "Zero Rotation" ); g.transform.rotation = Quaternion.identity; } } [MenuItem( "Tools/Zero Rotation %&z", true )] public static bool ZeroRotationValidation() { return Selection.gameObjects.Length > 0; } [MenuItem( "Tools/Zero Local Rotation %&#z", false, 900 )] public static void ZeroLocalRotation() { foreach( var g in Selection.gameObjects ) { Undo.RecordObject( g.transform, "Zero Local Rotation" ); g.transform.localRotation = Quaternion.identity; } } [MenuItem( "Tools/Zero Local Rotation %&#z", true )] public static bool ZeroLocalRotationValidation() { return Selection.gameObjects.Length > 0; } }
There’s more places where we can add menu items: If you want to add something to the context menu of a component, just do this:
[MenuItem ("CONTEXT/Rigidbody/Double Mass")] static void DoubleMass (MenuCommand command) { Rigidbody body = (Rigidbody)command.context; Undo.RecordObject( body, "Double Rigidbody Mass" ); body.mass = body.mass * 2; Debug.Log ("Doubled Rigidbody's Mass to " + body.mass + " from Context Menu."); }
Attributes and PropertyDrawers
So what was this
MenuItem
command in square braces really? Well, it was an Attribute! Attributes are classes that inherit from the Attribute base class. They can be used as sort of tags on other pieces of code. The cool thing is there’s a bunch of useful ones for customizing the looks of inspectors and we can make our own too!So even before we talk about IMGUI and UI Toolkit in a minute, we can already start customizing our inspectors!
Let’s say we have a very simple Spaceship class
using UnityEngine; public class Spaceship : MonoBehaviour { public float HullStrength = 100f; public float BoostStrength = 5f; public float TurnSpeedDegPerSec = 180f; public Rigidbody Rigidbody; public ParticleSystem BoosterParticles; public float CurrentDamage; }
Bit boring, you can’t really tell what’s what. Is anything wrong with this setup, is something missing or out of range? Is everything exactly as it should be? Who knows?
Let’s spice it up a bit with some attributes:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class Spaceship : MonoBehaviour { [Header( "Requirements" )] public Rigidbody Rigidbody; public ParticleSystem BoosterParticles; [Space( 4f )] [Header( "Config" )] [ContextMenuItem("Player Default", "SetPlayerDefault", order = 1)] //calls named function [ContextMenuItem("Enemy Default", "SetEnemyDefault", order = 2)] public float HullStrength = 100f; [Range(1f, 50f), Tooltip("Strength of the Engine")] public float BoostStrength = 5f; [Range(45f, 360f)] public float TurnSpeedDegPerSec = 180f; [Space( 4f )] [Header( "Runtime" )] public float CurrentDamage; private void OnValidate() { // called when a value changes in the inspector if( m_Rigidbody == null ) m_Rigidbody = GetComponent<Rigidbody>(); } [ContextMenu("Reset Damage")] private void ResetDamage() { CurrentDamage = 0f; } private void SetPlayerDefault() { HullStrength = 100f; } private void SetEnemyDefault() { HullStrength = 50f; } }
It’s better than before and adding some attributes only took a minute!
Custom Attributes and PropertyDrawers
Did I mention you can make custom ones too? Here’s one I’ve made that I find immensely useful: it’s called
Highlight
. Custom Attribute
This is the
HighlightAttribute
class, deriving from PropertyAttribute
using UnityEngine; public class HighlightAttribute : PropertyAttribute { public Color color; public bool highlightOnlyIfNull; public HighlightAttribute() { this.color = Color.red; this.highlightOnlyIfNull = false; } public HighlightAttribute( bool highlightOnlyIfNull ) { this.color = new Color( 0.5f, 0f, 0f ); this.highlightOnlyIfNull = highlightOnlyIfNull; } public HighlightAttribute( float r, float g, float b, bool highlightOnlyIfNull = false ) { this.color = new Color( r, g, b ); this.highlightOnlyIfNull = highlightOnlyIfNull; } public HighlightAttribute( float r, float g, float b, float a, bool highlightOnlyIfNull = false ) { this.color = new Color( r, g, b, a ); this.highlightOnlyIfNull = highlightOnlyIfNull; } public HighlightAttribute( string hexColor, bool highlightOnlyIfNull = false ) { this.color = HexToColor( hexColor ); this.highlightOnlyIfNull = highlightOnlyIfNull; } private Color HexToColor( string hexColor ) { hexColor = hexColor.Trim( char.Parse( "#" ) ); float r = (float)byte.Parse( hexColor.Substring( 0, 2 ), System.Globalization.NumberStyles.HexNumber ); float g = (float)byte.Parse( hexColor.Substring( 2, 2 ), System.Globalization.NumberStyles.HexNumber ); float b = (float)byte.Parse( hexColor.Substring( 4, 2 ), System.Globalization.NumberStyles.HexNumber ); float a = hexColor.Length == 8 ? (float)byte.Parse( hexColor.Substring( 6, 2 ), System.Globalization.NumberStyles.HexNumber ) : 1f; return new Color( r, g, b, a ); } }
It does nothing except offer a few constructors to fill its two variables
color
and highlightOnlyIfNull
Custom PropertyDrawer
The second piece of the Puzzle is a
PropertyDrawer
- When I first heard about this class I struggled to understand what it was for. No it’s not a drawer you put properties into, no, it draws a property.This one needs to be in an Editor folder!
using UnityEngine; using UnityEditor; [CustomPropertyDrawer( typeof( HighlightAttribute ) )] public class HighlightPropertyDrawer : PropertyDrawer { public override float GetPropertyHeight( SerializedProperty property, GUIContent label ) { return EditorGUI.GetPropertyHeight( property, label, true ); } public override void OnGUI( Rect position, SerializedProperty property, GUIContent label ) { HighlightAttribute highlightAttribute = attribute as HighlightAttribute; bool doColor = true; if( highlightAttribute.highlightOnlyIfNull ) { //if colorOnlyIfNull, then we need to figure out if the property is null/0! doColor = IsPropertyNull( property ); } Color rememberColor = GUI.contentColor; if( doColor ) EditorGUI.DrawRect( position, highlightAttribute.color ); EditorGUI.PropertyField( position, property, label, true ); GUI.contentColor = rememberColor; } private bool IsPropertyNull( SerializedProperty property ) { if( property.type.StartsWith( "PPtr<" ) ) { return property.objectReferenceValue == null; } else { if( property.type == "string" ) return string.IsNullOrEmpty( property.stringValue ); else if( property.type == "int" ) return property.intValue == 0; else if( property.type == "float" ) return property.floatValue == 0f; else if( property.type == "bool" ) return property.boolValue; else if( property.type == "double" ) return property.doubleValue == 0d; else if( property.type == "AnimationCurve" ) return property.animationCurveValue.keys.Length == 0; else Debug.LogWarning( "ColorAttribute doesn't work with the type " + property.type ); } return false; } }
Note how it’s got an attribute telling Unity that this is a custom property drawer for the
Highlight
attribute and how it derives from the PropertyDrawer
class.Most of the code is actually spent trying to figure out if the property is null or zero or empty or whatever.
Now watch what happens if we add this to the
BoosterParticles
field in our Spaceship
class with the colorOnlyIfNull
option:[Highlight(true)] // colorOnlyIfNull true = highlight only if null or empty public ParticleSystem BoosterParticles;
It goes red when the field is empty - Now I can tell if something important is missing!
Custom UIs - IMGUI vs UI Toolkit
There are 2 UI systems we can use:
Immediate Mode GUI (IMGUI)
IMGUI has been around since Unity 2.0. It’s a nice and simple way to build nice and simple interfaces. If you try to build something more complex, it’s possible, but be prepared for tears.
It’s called Immediate Mode because you code layout and functionality all in the same script.
IMGUI only works in special places though (OnGUI for runtime, OnInspectorGUI for inspectors, OnGUI for EditorWindows) because it needs to look at the same code from multiple angles:
- First it runs a layout pass that figures out the sizing and positioning of all elements
- then it handles all events that have happened during the previous frame
- and finally it does a repaint pass where it actually draws everything onto the screen.
It’s simple to use. If you want a button, you write :
GUILayout.Button("Click me");
The
Button
function returns a bool, so if you want your button-click to do something, just wrap it in an if
:if( GUILayout.Button("Click me")) { Debug.Log("Clicked!"); }
Note how the
GUILayout
contains all the things you can use at runtime, but EditorGUILayout
contains a whole lot more for use only in the Editor! 🌐 → Unity Docs on EditorGUILayoutYou can do basic layouting by wrapping things in
GUILayout.BeginVertical
and GUILayout.EndVertical
, etc.GUILayout.BeginVertical( ); GUILayout.BeginHorizontal( ); if( GUILayout.Button( "Button A" ) ) Debug.Log( "A" ); if( GUILayout.Button( "Button B" ) ) Debug.Log( "B" ); GUILayout.EndHorizontal(); GUILayout.BeginHorizontal( ); m_Color = EditorGUILayout.ColorField( m_Color ); m_Val = EditorGUILayout.ToggleLeft( "Check me", m_Val ); GUILayout.EndHorizontal(); GUILayout.EndVertical();
UI Toolkit
UI Toolkit is the new kid on the GUI block and based around web development paradigms. It’s a retained mode toolkit and based on UXML markup and USS style sheets. It’s great for more complex projects, but it’s more unwieldy right out of the box. Where IMGUI usually allows you to have everything in one compact script, with UI Toolkit you’re usually looking at least at a longer script and a UXML file. On the positive side, Unity has been working on tooling for a few years now and the most useful thing that’s come out of that is the UI Builder - a graphical drag and drop way of creating a UXML UI.
Then you just have to awkwardly bind it to your functionality and you’re good to go!
public void CreateGUI() { var root = new VisualElement(); m_Uxml.CloneTree( root ); // Clone UXML into VisualElement rootVisualElement.Add( root ); // Add VisualElement with UXML to rootVisualElement // Set up Button Callbacks rootVisualElement.Q<Button>( "importantButton" ).clicked += () => { Debug.Log( "Clicked important button!" ); }; rootVisualElement.Q<Button>( "normalButton" ).clicked += () => { m_Value = Random.Range( 0.0f, 100.0f ); Debug.Log( $"Clicked normal button. {m_Value}" ); }; // Bind FloatField and register ValueChanged-Callback rootVisualElement.Bind( new SerializedObject( this ) ); FloatField valField = rootVisualElement.Q<FloatField>( "val" ); valField.RegisterValueChangedCallback( (v) => Debug.Log( $"Changed: {v.newValue}" )); }
It’s all a bit awkward in my opinion, but then again all web development is a bit awkward, so…
Custom Inspectors
Let’s make some custom inspectors for a simple class!
I want to track the health of an Entity - player, monsters, NPC, whatever - so I made a simple class for that.
We have Max Health, Current Health and some ways to access those plus ways to apply damage or heal as well as resurrect the entity.
using UnityEngine; public class Entity : MonoBehaviour { public delegate void HealthChangeHandler( float currentHealth ); public event HealthChangeHandler onHealthChanged; [SerializeField] private float m_MaxHealth = 100f; [SerializeField] private float m_CurrentHealth = 100f; public float MaxHealth => m_MaxHealth; public float CurrentHealth => m_CurrentHealth; public bool IsDead => m_CurrentHealth <= 0f; private void Start() { onHealthChanged?.Invoke( m_CurrentHealth ); } public void ApplyDamage( float amount ) { // ... } public void Heal( float amount ) { // ... } private void Die() { // ... } public void Resurrect() { // ... } }
Now what I want to do is add some debug buttons
IMGUI Inspector
We start by making an Editor class that derives from
Editor
and add the CustomEditor
attribute and tell it which class we’re making a custom editor for - in this case: for the class Entity
.[CustomEditor( typeof(Entity), true )] public class EntityEditorIMGUI : Editor { }
In there we override
OnInspectorGUI()
and inside there that’s where we can go wild with IMGUI code.A first step would be to simply draw the default inspector with
DrawDefaultInspector();
and add our custom buttons at the bottom.But before we do that we need to know that we’re provided with 2 very useful things here:
A
target
object that contains a direct reference to the instance of the class that our custom inspector is shown for.And we get a
serializedObject
for the same instance. (A serializedObject basically holds all the serializedProperties of the instance. It’s a generic way of editing serialized fields on Unity objects, more on this in a bit! Or check out this post here: Supporting Undo in Unity Editor scripts)Ok. Draw Default Inspector, add buttons:
public override void OnInspectorGUI() { DrawDefaultInspector(); Entity entity = (Entity)target; if( GUILayout.Button( "Damage" ) ) { entity.ApplyDamage( 10f ); } if( GUILayout.Button( "Heal" ) ) { entity.Heal( 10f ); } }
Note how I cast the target to
Entity
, then I can use it to call it’s methods from the Buttons!Now I want the buttons to Display next to each other, so I wrap them in
GUILayout.BeginHorizontal()
and GUILayout.EndHorizontal()
- remember how the layouting pass will run through and figure out the positions of everything?And I add a bit of logic to make the buttons be disabled if we’re outside play mode, I make sure Undo is handled and switch the two buttons out for a single Resurrect button if the entity is dead.
public override void OnInspectorGUI() { DrawDefaultInspector(); Entity entity = (Entity)target; EditorGUI.BeginDisabledGroup( !Application.isPlaying ); { Undo.RecordObject( entity, "Change Health" ); if( !entity.IsDead ) { GUILayout.BeginHorizontal(); { if( GUILayout.Button( "Damage" ) ) { entity.ApplyDamage( 10f ); } if( GUILayout.Button( "Heal" ) ) { entity.Heal( 10f ); } } GUILayout.EndHorizontal(); } else { if( GUILayout.Button( "Resurrect" ) ) { entity.Resurrect(); } } } EditorGUI.EndDisabledGroup(); }
If we wanted to draw the entire contents ourselves we could do that as well of course - I’m adding a slider (yes! that could have been done via an attribute in the Entity class!)
public override void OnInspectorGUI() { // Draw Script Field EditorGUI.BeginDisabledGroup( true ); EditorGUILayout.PropertyField( serializedObject.FindProperty( "m_Script" ) ); EditorGUI.EndDisabledGroup(); // Draw properties serializedObject.Update(); EditorGUILayout.PropertyField( serializedObject.FindProperty( "m_MaxHealth" ) ); SerializedProperty currentHealthProperty = serializedObject.FindProperty( "m_CurrentHealth" ); currentHealthProperty.floatValue = EditorGUILayout.Slider( new GUIContent( "Current Health" ), currentHealthProperty.floatValue, 0, serializedObject.FindProperty( "m_MaxHealth" ).floatValue ); serializedObject.ApplyModifiedProperties(); GUILayout.Space( 8f ); Entity entity = (Entity)target; EditorGUI.BeginDisabledGroup( !Application.isPlaying ); // Disabled outside play mode { if( !entity.IsDead ) { // NOT DEAD? Show Damage/Heal GUILayout.BeginHorizontal(); if( GUILayout.Button( "Damage" ) ) { entity.ApplyDamage( 10f ); } if( GUILayout.Button( "Heal" ) ) { entity.Heal( 10f ); } GUILayout.EndHorizontal(); } else { // DEAD? Show Resurrect if( GUILayout.Button( "Resurrect" ) ) { entity.Resurrect(); } } } EditorGUI.EndDisabledGroup(); } }
UI Toolkit Inspector
Let’s build the same thing with UI Toolkit!
Create the UI in UI Builder - select the canvas and set it to Editor Extension Authoring, then you get access to all the Editor-only elements and can select the Editor Theme at the top of the viewport.
Fill in Binding Paths for Controls
Create an Editor script deriving from the Editor class and add the
CustomEditor
attribute[CustomEditor( typeof(Entity) )] public class EntityEditor : Editor { }
To get our UXML in we need to add a field of type VisualTreeAsset that we can assign the file to in the inspector of the script file.
And instead of OnInspectorGUI, we override a different function:
CreateInspectorGUI
We clone the UXML into the root, query our buttons and make them do stuff:
[CustomEditor( typeof(Entity) )] public class EntityEditor : Editor { [SerializeField] private VisualTreeAsset m_Uxml; public override VisualElement CreateInspectorGUI() { var root = new VisualElement(); m_Uxml.CloneTree( root ); Entity entity = (Entity) target; // the instance this inspector is shown for // Query buttons and sign up for "clicked" callbacks root.Q<Button>( "healButton" ).clicked += () => entity.Heal( 10f ); root.Q<Button>( "damageButton" ).clicked += () => entity.ApplyDamage( 10f ); // etc. return root; } }
Basics are done! Now let’s spice it up and make it show the resurrect button if dead and disable everything while not in play mode:
using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; [CanEditMultipleObjects] [CustomEditor( typeof(Entity) )] public class EntityEditor : Editor { [SerializeField] private VisualTreeAsset m_Uxml; private GroupBox m_HealDamageGroup; private Button m_ResurrectButton; private Slider m_CurrentHealthSlider; public override VisualElement CreateInspectorGUI() { //InspectorElement.FillDefaultInspector( root, serializedObject, this ); EditorApplication.playModeStateChanged += OnPlayModeChanged; var root = new VisualElement(); m_Uxml.CloneTree( root ); Entity entity = (Entity) target; // the instance this inspector is shown for root.Q<ObjectField>( "scriptField" ).SetEnabled( false ); var maxHealthField = root.Q<FloatField>( "maxHealth" ); maxHealthField.RegisterValueChangedCallback( OnMaxHealthChanged ); var currentHealthField = root.Q<FloatField>( "currentHealth" ); currentHealthField.RegisterValueChangedCallback( OnCurrentHealthChanged ); m_ResurrectButton = root.Q<Button>( "resurrectButton" ); m_ResurrectButton.clicked += () => entity.Resurrect(); m_ResurrectButton.style.display = entity.IsDead ? DisplayStyle.None : DisplayStyle.Flex; m_CurrentHealthSlider = root.Q<Slider>( "currentHealthSlider" ); m_HealDamageGroup = root.Q<GroupBox>( "healDamageGroup" ); m_HealDamageGroup.style.display = entity.IsDead ? DisplayStyle.Flex : DisplayStyle.None; root.Q<Button>( "healButton" ).clicked += () => entity.Heal( 10f ); root.Q<Button>( "damageButton" ).clicked += () => entity.ApplyDamage( 10f ); OnPlayModeChanged( EditorApplication.isPlaying ? PlayModeStateChange.EnteredPlayMode : PlayModeStateChange.EnteredEditMode ); return root; } private void OnPlayModeChanged( PlayModeStateChange playModeStateChange ) { if( playModeStateChange == PlayModeStateChange.EnteredPlayMode ) { m_HealDamageGroup.SetEnabled( true ); m_ResurrectButton.SetEnabled( true ); } else { m_HealDamageGroup.SetEnabled( false ); m_ResurrectButton.SetEnabled( false ); } } private void OnMaxHealthChanged( ChangeEvent<float> evt ) { m_CurrentHealthSlider.highValue = evt.newValue; } private void OnCurrentHealthChanged( ChangeEvent<float> evt ) { if( evt.newValue <= 0f ) { // DEAD m_ResurrectButton.style.display = DisplayStyle.Flex; m_HealDamageGroup.style.display = DisplayStyle.None; } else { m_ResurrectButton.style.display = DisplayStyle.None; m_HealDamageGroup.style.display = DisplayStyle.Flex; } } } #endif
Done! Looks the same as the IMGUI one, but for simple stuff like this maybe a bit overkill, because the script is noticeably longer and we need a separate UXML file, so I’d say IMGUI wins this one, but this is about to change as we continue and make custom EditorWindows…
Custom EditorWindow
Editor Window (IMGUI)
Let’s build an editor window for the little spaceships prototype! Wouldn’t it be nice to have a little dockable window that gives us all the information for what’s currently going on? A list of all the ships with the most important controls for tweaking, and a list of all the players?
What we need:
- Static class deriving from
EditorWindow
- Static function with
MenuItem
attribute to open the window viaGetWindow
- Add our custom UI in
OnGUI
using UnityEditor; using UnityEngine; public class SpaceshipWindow : EditorWindow { [MenuItem( "Tools/Spaceships Overview" )] public static void Init() { var win = EditorWindow.GetWindow<SpaceshipWindow>(); } private void OnGUI() { GUILayout.Label( "WIP" ); } }
Now we’ve got an empty window. Let’s add some content!
First we need to find all relevant things in the scene, so
using UnityEditor; using UnityEngine; using System.Collections.Generic; using System.Linq; public class SpaceshipWindow : EditorWindow { [MenuItem( "Tools/Spaceships Overview" )] public static void Init() { var win = EditorWindow.GetWindow<SpaceshipWindow>(); win.RefreshData(); } [SerializeField] private List<Spaceship> m_Spaceships = new(); // Find all the relevant objects in the scene private void RefreshData() { var ships = FindObjectsByType<Spaceship>( FindObjectsInactive.Include, FindObjectsSortMode.None ); m_Spaceships = new List<Spaceship>( ships.OrderBy( s => s.name ) ); Repaint(); // Repaint the window's UI } private void OnHierarchyChange() { RefreshData(); // Update data whenever anything in the hierarchy changes } private void OnGUI() { foreach( var ship in m_Spaceships ) { // step through all the ships // get SerializedObjects for the ship and Rigidbody - to handle undo, etc. var shipSO = new SerializedObject( ship ); var rbSO = new SerializedObject( ship.Rigidbody ); GUILayout.BeginHorizontal(); if( GUILayout.Button( ship.name, GUILayout.Width( 120f ) ) ) { EditorGUIUtility.PingObject( ship ); Selection.activeGameObject = ship.gameObject; } EditorGUILayout.PropertyField( shipSO.FindProperty( "BoostStrength" ) ); EditorGUILayout.PropertyField( rbSO.FindProperty( "m_Drag" ) ); GUILayout.EndHorizontal(); // Apply values via SerializedObjects (conveniently handles undo as well) shipSO.ApplyModifiedProperties(); rbSO.ApplyModifiedProperties(); } } }
A bit more tweaking of sizes with
EditorGUIUtility.labelWidth
and GUILayout.MaxWidth()
and a bit more work to add current Players and it looks like this:using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; public class SpaceshipOverviewIMGUI : EditorWindow { [MenuItem( "Tools/Spaceships Overview IMGUI" )] public static void Init() { var win = GetWindow<SpaceshipOverviewIMGUI>(); win.RefreshData(); } [SerializeField] private List<Spaceship> m_Spaceships = new(); [SerializeField] private List<SpaceshipControl> m_SpaceshipControls = new(); private void OnHierarchyChange() { RefreshData(); } [SerializeField] private bool m_ShowShips = true; [SerializeField] private bool m_ShowPlayers = true; private void OnGUI() { // SHIPS m_ShowShips = EditorGUILayout.BeginFoldoutHeaderGroup( m_ShowShips, "Ships" ); if( m_ShowShips ) { if( m_Spaceships.Count == 0 ) { GUILayout.BeginHorizontal(); GUILayout.Space( 16f ); EditorGUILayout.HelpBox( "No ships found in scene", MessageType.Info, true ); GUILayout.EndHorizontal(); } for( int i = m_Spaceships.Count - 1; i >= 0; i-- ) { if(m_Spaceships[i] == null) m_Spaceships.RemoveAt( i ); } foreach( var ship in m_Spaceships ) { var shipSO = new SerializedObject( ship ); var rbSO = new SerializedObject( ship.Rigidbody ); GUILayout.BeginHorizontal(); GUILayout.Space( 16f ); EditorGUIUtility.labelWidth = 90f; if( GUILayout.Button( ship.name, GUILayout.Width( 120f ) ) ) { EditorGUIUtility.PingObject( ship ); Selection.activeGameObject = ship.gameObject; } GUILayout.Space( 8f ); EditorGUILayout.PropertyField( shipSO.FindProperty( "BoostStrength" ) ); GUILayout.Space( 8f ); EditorGUIUtility.labelWidth = 34f; EditorGUILayout.PropertyField( rbSO.FindProperty( "m_Drag" ), GUILayout.MaxWidth( 70f ) ); var foundPlayer = false; foreach( var player in m_SpaceshipControls ) { if( player.Spaceship == ship ) { if( GUILayout.Button( $"P{player.PlayerId}", GUILayout.MaxWidth( 30f ) ) ) { EditorGUIUtility.PingObject( player ); Selection.activeGameObject = player.gameObject; } foundPlayer = true; break; } } if( !foundPlayer ) GUILayout.Space( 33f ); GUILayout.EndHorizontal(); shipSO.ApplyModifiedProperties(); rbSO.ApplyModifiedProperties(); } GUILayout.Space( 8f ); } EditorGUILayout.EndFoldoutHeaderGroup(); // PLAYERS m_ShowPlayers = EditorGUILayout.BeginFoldoutHeaderGroup( m_ShowPlayers, "Players" ); if( m_ShowPlayers ) { if( m_SpaceshipControls.Count == 0 ) { GUILayout.BeginHorizontal(); GUILayout.Space( 16f ); EditorGUILayout.HelpBox( "No players found in scene", MessageType.Info, true ); GUILayout.EndHorizontal(); } foreach( var player in m_SpaceshipControls ) { var playerSO = new SerializedObject( player ); GUILayout.BeginHorizontal( ); GUILayout.Space( 16f ); if( GUILayout.Button( player.gameObject.name, GUILayout.MaxWidth( 120f ) ) ) { EditorGUIUtility.PingObject( player ); Selection.activeGameObject = player.gameObject; } EditorGUI.BeginDisabledGroup( true ); EditorGUIUtility.labelWidth = 54f; EditorGUILayout.PropertyField( playerSO.FindProperty( "m_Boosting" ), GUILayout.MaxWidth( 74f ) ); EditorGUIUtility.labelWidth = 84f; EditorGUILayout.PropertyField( playerSO.FindProperty( "m_BoostFactor" ) ); EditorGUIUtility.labelWidth = 34f; EditorGUILayout.PropertyField( playerSO.FindProperty( "m_Turn" ) ); EditorGUI.EndDisabledGroup(); GUILayout.EndHorizontal(); } } EditorGUILayout.EndFoldoutHeaderGroup(); EditorGUIUtility.labelWidth = 0; //reset to default } private void Update() { Repaint(); } private void RefreshData() { var ships = FindObjectsByType<Spaceship>( FindObjectsInactive.Include, FindObjectsSortMode.None ); m_Spaceships = new List<Spaceship>( ships.OrderBy( s => s.name ) ); foreach( var player in m_SpaceshipControls ) { player.onShipChanged -= RefreshData; } var controls = FindObjectsByType<SpaceshipControl>( FindObjectsInactive.Exclude, FindObjectsSortMode.None ); m_SpaceshipControls = new List<SpaceshipControl>( controls.OrderBy( p => p.name ) ); foreach( var player in m_SpaceshipControls ) { player.onShipChanged += RefreshData; } Repaint(); } }
And yes - I could have also made a version of this that ran in the game view - that would have looked like this:
And in that case the buttons would not focus an object, but switch player control to a different ship. (Because we can’t use any Editor functionality here)
EditorWindow (UI Toolkit)
Let’s build the same thing with UI Toolkit!
Again we make a class deriving from
EditorWindow
and add a static function + MenuItem
attribute to open the window.BUT now we’re using
CreateGUI
instead of OnGUI to add our window UI.using UnityEditor; using UnityEngine; using UnityEngine.UIElements; public class SpaceshipOverviewUIToolkit : EditorWindow { [MenuItem( "Tools/Spaceships Overview UI Toolkit" )] public static void Init() { GetWindow<SpaceshipOverviewUIToolkit >(); } private void CreateGUI() { var ui = new Label("WIP"); // Create a Label... ui.style.color = Color.yellow; // Change some styles... ui.style.paddingLeft = ui.style.paddingTop = 4f; rootVisualElement.Add( ui ); // Add it to the tree } }
Let’s make a UXML UI in UI Builder and bring that in…
I create the UI for a single spaceship and saved it as a UXML file.
Then I add a field where we can assign the UXML file we created and clone the UXML tree into a
VisualElement
and add that to the rootVisualElement
:using UnityEditor; using UnityEngine; using UnityEngine.UIElements; public class SpaceshipOverviewUIToolkit : EditorWindow { [MenuItem( "Tools/Spaceships Overview UI Toolkit" )] public static void Init() { GetWindow<SpaceshipOverviewUIToolkit>(); } [SerializeField] private VisualTreeAsset m_Uxml; private void CreateGUI() { var ui = new VisualElement(); m_Uxml.CloneTree( ui ); rootVisualElement.Add( ui ); } }
I add a way to find all the spaceships in the scene, and add a copy of the UXML tree for each one of them.
Then bind the
Slider
to the Spaceship
(since I set up the Binding Path in UI Builder, it will find the correct field on there) and bind the drag FloatField
to the Spaceship’s Rigidbody
using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; public class SpaceshipOverviewUIToolkit : EditorWindow { [MenuItem( "Tools/Spaceships Overview UI Toolkit" )] public static void Init() { GetWindow<SpaceshipOverviewUIToolkit>(); } [SerializeField] private VisualTreeAsset m_Uxml; [SerializeField] private List<Spaceship> m_Spaceships = new(); private void OnHierarchyChange() { RefreshSpaceshipListUI(); } private void CreateGUI() { rootVisualElement.Add( new VisualElement() { name = "Ships" } ); RefreshSpaceshipListUI(); } private void RefreshSpaceshipListUI() { var shipsRoot = rootVisualElement.Q<VisualElement>( "Ships" ); var ships = FindObjectsByType<Spaceship>( FindObjectsInactive.Include, FindObjectsSortMode.None ); if( ships.Length != m_Spaceships.Count || ( ships.Length > 0 && shipsRoot.childCount == 0 ) ) { m_Spaceships = new List<Spaceship>( ships.OrderBy( s => s.name ) ); shipsRoot.Clear(); foreach( var spaceship in m_Spaceships ) { var shipUxml = new VisualElement(); m_Uxml.CloneTree( shipUxml ); var nameButton = shipUxml.Q<Button>( "shipNameButton" ); nameButton.text = spaceship.name; nameButton.clicked += () => { Selection.activeGameObject = spaceship.gameObject; EditorGUIUtility.PingObject( Selection.activeGameObject ); }; shipUxml.Q<Slider>( ).BindProperty( new SerializedObject( spaceship ) ); shipUxml.Q<FloatField>( "drag" ).Bind( new SerializedObject( spaceship.Rigidbody ) ); shipsRoot.Add( shipUxml ); } } } }
I add a
Foldout
by turning the VisualElement
that we add everything to with a Foldout
:private void CreateGUI() { rootVisualElement.Add( new Foldout() { name = "Ships", text = "Ships"} ); RefreshSpaceshipListUI(); }
For the Players, I create a second UXML file with the UI for a single player
Add a second field and assign the second UXML to that
Clone the player UXML into the tree for each player, bind it to the relevant things:
Done!
This should get you started on your journey to making your own useful tools in Unity. Let me know what you come up with!