Skip to content

Instantly share code, notes, and snippets.

@alexanderameye
Last active December 1, 2023 22:37
Show Gist options
  • Save alexanderameye/c1f99c6b84162697beedc8606027ed9c to your computer and use it in GitHub Desktop.
Save alexanderameye/c1f99c6b84162697beedc8606027ed9c to your computer and use it in GitHub Desktop.
A small scene switcher utility for Unity
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.Overlays;
using UnityEditor.SceneManagement;
using UnityEditor.Toolbars;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;
public static class EditorSceneSwitcher
{
public static bool AutoEnterPlaymode = false;
public static readonly List<string> ScenePaths = new();
public static void OpenScene(string scenePath)
{
if (EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
if (AutoEnterPlaymode) EditorApplication.EnterPlaymode();
}
public static void LoadScenes()
{
// clear scenes
ScenePaths.Clear();
// find all scenes in the Assets folder
var sceneGuids = AssetDatabase.FindAssets("t:Scene", new[] {"Assets"});
foreach (var sceneGuid in sceneGuids)
{
var scenePath = AssetDatabase.GUIDToAssetPath(sceneGuid);
var sceneAsset = AssetDatabase.LoadAssetAtPath(scenePath, typeof(SceneAsset));
ScenePaths.Add(scenePath);
}
}
}
[Icon("d_SceneAsset Icon")]
[Overlay(typeof(SceneView), OverlayID, "Scene Switcher Creator Overlay")]
public class SceneSwitcherToolbarOverlay : ToolbarOverlay
{
public const string OverlayID = "scene-switcher-overlay";
private SceneSwitcherToolbarOverlay() : base(
SceneDropdown.ID,
AutoEnterPlayModeToggle.ID
)
{
}
public override void OnCreated()
{
// load the scenes when the toolbar overlay is initially created
EditorSceneSwitcher.LoadScenes();
// subscribe to the event where scene assets were potentially modified
EditorApplication.projectChanged += OnProjectChanged;
}
// Called when an Overlay is about to be destroyed.
// Usually this corresponds to the EditorWindow in which this Overlay resides closing. (Scene View in this case)
public override void OnWillBeDestroyed()
{
// unsubscribe from the event where scene assets were potentially modified
EditorApplication.projectChanged -= OnProjectChanged;
}
private void OnProjectChanged()
{
// reload the scenes whenever scene assets were potentially modified
EditorSceneSwitcher.LoadScenes();
}
}
[EditorToolbarElement(ID, typeof(SceneView))]
public class SceneDropdown : EditorToolbarDropdown
{
public const string ID = SceneSwitcherToolbarOverlay.OverlayID + "/scene-dropdown";
private const string Tooltip = "Switch scene.";
public SceneDropdown()
{
var content =
EditorGUIUtility.TrTextContentWithIcon(SceneManager.GetActiveScene().name, Tooltip,
"d_SceneAsset Icon");
text = content.text;
tooltip = content.tooltip;
icon = content.image as Texture2D;
// hacky: the text element is the second one here so we can set the padding
// but this is not really robust I think
ElementAt(1).style.paddingLeft = 5;
ElementAt(1).style.paddingRight = 5;
clicked += ToggleDropdown;
// keep track of panel events
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
protected virtual void OnAttachToPanel(AttachToPanelEvent evt)
{
// subscribe to the event where the play mode has changed
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
// subscribe to the event where scene assets were potentially modified
EditorApplication.projectChanged += OnProjectChanged;
// subscribe to the event where a scene has been opened
EditorSceneManager.sceneOpened += OnSceneOpened;
}
protected virtual void OnDetachFromPanel(DetachFromPanelEvent evt)
{
// unsubscribe from the event where the play mode has changed
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
// unsubscribe from the event where scene assets were potentially modified
EditorApplication.projectChanged -= OnProjectChanged;
// unsubscribe from the event where a scene has been opened
EditorSceneManager.sceneOpened -= OnSceneOpened;
}
private void OnPlayModeStateChanged(PlayModeStateChange stateChange)
{
switch (stateChange)
{
case PlayModeStateChange.EnteredEditMode:
SetEnabled(true);
break;
case PlayModeStateChange.EnteredPlayMode:
// don't allow switching scenes while in play mode
SetEnabled(false);
break;
}
}
private void OnProjectChanged()
{
// update the dropdown label whenever the active scene has potentially be renamed
text = SceneManager.GetActiveScene().name;
}
private void OnSceneOpened(Scene scene, OpenSceneMode mode)
{
// update the dropdown label whenever a scene has been opened
text = scene.name;
}
private void ToggleDropdown()
{
var menu = new GenericMenu();
foreach (var scenePath in EditorSceneSwitcher.ScenePaths)
{
var sceneName = Path.GetFileNameWithoutExtension(scenePath);
menu.AddItem(new GUIContent(sceneName), text == sceneName,
() => OnDropdownItemSelected(sceneName, scenePath));
}
menu.DropDown(worldBound);
}
private void OnDropdownItemSelected(string sceneName, string scenePath)
{
text = sceneName;
EditorSceneSwitcher.OpenScene(scenePath);
}
}
[EditorToolbarElement(ID, typeof(SceneView))]
public class AutoEnterPlayModeToggle : EditorToolbarToggle
{
public const string ID = SceneSwitcherToolbarOverlay.OverlayID + "/auto-enter-playmode-toggle";
private const string Tooltip = "Auto enter playmode.";
public AutoEnterPlayModeToggle()
{
var content = EditorGUIUtility.TrTextContentWithIcon("", Tooltip, "d_preAudioAutoPlayOff");
text = content.text;
tooltip = content.tooltip;
icon = content.image as Texture2D;
value = EditorSceneSwitcher.AutoEnterPlaymode;
this.RegisterValueChangedCallback(Toggle);
}
private void Toggle(ChangeEvent<bool> evt)
{
EditorSceneSwitcher.AutoEnterPlaymode = evt.newValue;
}
}
@onefifth
Copy link

I've been digging into Overlay stuff recently and wanted to point out a potential pitfall with:

EditorApplication.playModeStateChanged += PlayModeStateChanged;
EditorApplication.projectChanged += OnProjectChanged;
EditorSceneManager.sceneOpened += OnSceneOpened;

Because of the way this whole API works, all the child VisualElements of an Overlay are detached and recreated during many of the layout operations. These detached children are not explicitly cleaned up, so when these kinds of hooks are added in a constructor, duplicate orphaned VisualElements end up getting leaked and keep on running their callbacks forever.

To see this yourself, try docking and undocking the overlay toolbar a few times and notice the PlayModeStateChanged/OnProjectChanged/OnSceneOpened handlers being called multiple times per action.

In this specific case, the extra calls aren't causing any real problems, but you can probably imagine cases where it would.

In terms of a solution... it's unfortunately really annoying? At the risk of rambling:

First thing I tried was RegisterCallback on the AttachToPanelEvent/DetachFromPanelEvent events and adding/removing callbacks there. This mostly works. It cleans up our handlers, but:

  1. Those Attach/Detach events get called multiple times in some cases. Undocking a ToolbarOverlay will; detach children, reattach them, detach them again, then create new ones and attach those. I couldn't find out a way to know if a VisualElement was going to be immediately reattached, or was being left in orphaned-child purgatory. (DetachFromPanelEvent.destinationPanel looks useful, but is never set afaik)
  2. When a ToolbarOverlay is put into a "collapsed" state, all its children are detached and orphaned. They're recreated when the overlay is expanded/uncollapsed, but by removing callbacks in detach, we're left with nothing handling events while the overlay remains collapsed.

Next I went to add the callbacks in ToolbarOverlay.OnCreated, then pass those calls down to children. But again; the children aren't parented while the overlay is collapsed. Also, as far as I can tell the list of children for an Overlay is internal, so iterating them is a one-way trip to reflection land :/

So uh... what now?

I think the "correct" way to do this is by using some kind of context object owned by the parent overlay. The child VisualElements read/write to it, and the overlay/toolbar/whatever system reads changes and actually performs logic. Maybe leverage a SeralizedObject and dig into the Bind()/bindingPath bits of VisualElement? I dunno. I gave up and made my overlay uncollapsible with a hacky collapseChanged+=.

As a final note, I'm on Unity 2021.3, but glancing at 2022.x and beyond, there's now ToolbarOverlay and OverlayToolbar classes. The naming choice there does not inspire confidence, but maybe this whole situation is improved? I'd love for Unity to change something here, even if it's just a better example in the docs. (at the time of writing, it also has this problem)

tl;dr
The EditorApplication += in the VisualElement callbacks leak. I don't have a perfect example of how to not do that.
The above wall of text will hopefully be useful for anyone curious. If you've read all this in the year 2022 and think I'm missing something, (or you've got a "good" example of the "correct" approach) feel free to @ me.

@alexanderameye
Copy link
Author

alexanderameye commented Jul 28, 2022

Hey @onefifth thanks for sharing, I really appreciate it! While I was adding the callbacks, I thought to myself that I should be unsubscribing to them but then just ignored it because I thought 'eh, it's UI Toolkit so not called every frame, shouldn't be too bad'.

So the first change I made is to make use of OnWillBeDestroyed in the ToolbarOverlay to unsubscribe from the events. Afaik OnCreated and OnWillBeDestroyed will only be called whenever the Scene View is created/destroyed, which is something I never do, but still good to handle this of course.

public override void OnCreated()
{
    EditorSceneSwitcher.LoadScenes();
    EditorApplication.projectChanged += OnProjectChanged;
}

// Called when an Overlay is about to be destroyed.
// Usually this corresponds to the EditorWindow in which this Overlay resides closing. (Scene View in this case)
public override void OnWillBeDestroyed()
{
    EditorApplication.projectChanged -= OnProjectChanged;
}

Then for the ToolbarElements, using the panel events like you said looks good to me? From some testing there seem to be no leaks like you said. About your second point about the collapsed state, for this use case it does not seem to be an issue because the events don't need to be handled in collapsed state, and when you expand the overlay again or click on it to 'open' it, the scene changes have been propagated fine because the ToolbarOverlay itself still is handling the EditorApplication.projectChanged events and updating the list of scenes. So I'm actually really happy with this solution that you proposed!

So the changes are like this (some code not shown here):

public SceneDropdown()
{
    RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
    RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}

protected virtual void OnAttachToPanel(AttachToPanelEvent evt)
{
    EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
    EditorApplication.projectChanged += OnProjectChanged;
    EditorSceneManager.sceneOpened += OnSceneOpened;
}

protected virtual void OnDetachFromPanel(DetachFromPanelEvent evt)
{
    EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
    EditorApplication.projectChanged -= OnProjectChanged;
    EditorSceneManager.sceneOpened -= OnSceneOpened;
}

One additional thing is this file from the AR Foundation package that showcases how Unity does things themselves (it's a fairly recent commit, May 2022 from what I can see). They seem to be using the panel events as well for handling the callbacks.

https://github.com/needle-mirror/com.unity.xr.arfoundation/blob/b289533a255b6c10848f9f39c7f6e1ab0c2bd070/Editor/Simulation/AREnvironmentToolbarOverlay.cs

Do let me know if you have additional thoughts! If this looks good to you, I'll update the gist. Thanks again :)

@onefifth
Copy link

Those changes look good to me! It's nice to know I wasn't way off base with the attach/detach handlers. Thanks for the com.unity.xr.arfoundation link.

OnCreated and OnWillBeDestroyed are almost certainly also called when enabling/disabling the overlay/toolbar completely. Using them here makes sense to me!

For the ToolbarElements, in my case I was (perhaps foolishly) trying to use a SceneView.duringSceneGui += hook to have a simple ToolbarToggle draw stuff in the scene view. My initial approach was nearly identical to the way the DropdownToggleExample works on docs page for ToolbarOverlay. If you change that example to use the AttachToPanel/DetachFromPanel pattern, it does fix the leak issue, but the overlay collapsed state becomes a more obvious/real problem. The button's duringSceneGui handler gets removed and the stuff being drawn stops getting drawn.

In that specific case, separating the draw logic from the toggle button is probably the way to go? Instead it'd notify some other object when the toggle state changed, and that other object would manage its own duringSceneGui hook and draw stuff into the scene view.
But, my whole ramble is basically; it's real easy to accidentally cross the complexity threshold where jamming functionality directly into the button starts causing hard-to-spot problems. (the example in the docs leads you directly off that cliff imo) It'd be great to have better sample code to reference, or for the API to make it more clear how that kind of complexity should be dealt with.

Either way... you certainly don't need my permission to update your gist (haha), but for this specific case those changes look good to me! Thanks for responding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment