Editor callbacks for GameObject creation, deletion, duplication by user or user script

As someone who writes a lot of editor tools, I often would like to know when a GameObject in the scene was created, deleted or duplicated by the user. More specifically I would like to cover the following cases:

  • GameObject is created and placed in the scene
  • GameObject is deleted and removed from the scene
  • GameObject is has been duplicated (probably needed after the creation callback for the same object)

These events can be invoked by multiple inputs:

  • Keyboard shortcuts
  • Context menus
  • User editor scripts

For this to work, there must be a unified system through which all objects in Unity are handled: The Undo system. This seems to be the crucial point. I don’t want to receive a callback from the engine side when an object is allocated or loaded, because this is also triggered when the scene is closed or play mode entered/exited. Instead, I would like to receive events that have user intent. The Undo system seems like the perfect fit for this case, since undoable actions are everything that the user actively controls.

Here is a quick rundown of my past research and experiences and why I believe that the existing options do not suffice:

MonoBehaviour ExecuteInEditMode calls Awake and OnDestroy, but this happens whenever the object is loaded or goes out of scope, for example, when the scene is closed. Handling unnecessary callbacks results in a lot of boilerplate code and is always error prone to accidentally setting the scene dirty unintentionally.

IMGUI CommandEvent, e.g. polling the GUI loop for event names such as “SoftDelete”, however these do not happen when a user uses the ContextMenu.

Trying to track the active selection in combination with certain commands, comparing instance IDs, etc: While some users have success up to a certain degree by trying to interpret several sources of information, these methods often fall short at some point, e.g. when the inspector is locked or multiple windows are open.

Undo.postprocessModifications goes into a nice direction, but it only provides feedback for changes made to properties on objects, but not about their lifetime, no callback when a GameObject is created, duplicated or deleted.

EditorApplication.hierarchyChanged seems like another good options for a unified event, but it doesn’t provide any information, so it would be up to me, to parse the entire scene and figure out what has changed, which seems like a lot of work and overhead for something that Unity already does under the hood.

4 Likes

Seconded. That would be very useful.

Also, I noticed that Undo.postprocessModifications is called when an object is created, but isn’t called when an object is created by draping and dropping an asset into the scene view (eg: dragging an dropping a sprite on the scene view to create a new GameObject with a SpriteRenderer). Is this a bug or is this by design?

Bump, as this would be very useful!

There is ObjectChangeEvents

1 Like

@karl_jones - there is no documentation anywhere on how to use this.

This is super confusing. How do I turn this event payload information into something useful?

8263587--1082034--upload_2022-7-8_11-57-1.png

8263590--1082043--upload_2022-7-8_12-0-0.png

Yes, the documentation is poor for this. I’ll try and improve it next week. For now, here is an example to show how to use each event

using System.Text;
using UnityEditor;
using UnityEngine;
[InitializeOnLoad]
public class ObjectChangeEventsExample
{
    static ObjectChangeEventsExample()
    {
        ObjectChangeEvents.changesPublished += ChangesPublished;
    }
    static void ChangesPublished(ref ObjectChangeEventStream stream)
    {
        for (int i = 0; i < stream.length; ++i)
        {
            switch (stream.GetEventType(i))
            {
                case ObjectChangeKind.ChangeScene:
                    stream.GetChangeSceneEvent(i, out var changeSceneEvent);
                    Debug.Log($"Change Scene Event: {changeSceneEvent.scene}");
                    break;
                case ObjectChangeKind.CreateGameObjectHierarchy:
                    stream.GetCreateGameObjectHierarchyEvent(i, out var createGameObjectHierarchyEvent);
                    var newGameObject = EditorUtility.InstanceIDToObject(createGameObjectHierarchyEvent.instanceId) as GameObject;
                    Debug.Log($"Create GameObject: {newGameObject} in scene {createGameObjectHierarchyEvent.scene}.");
                    break;
                case ObjectChangeKind.ChangeGameObjectStructureHierarchy:
                    stream.GetChangeGameObjectStructureHierarchyEvent(i, out var changeGameObjectStructureHierarchy);
                    var gameObject = EditorUtility.InstanceIDToObject(changeGameObjectStructureHierarchy.instanceId) as GameObject;
                    Debug.Log($"Change GameObject hierarchy: {gameObject} in scene {changeGameObjectStructureHierarchy.scene}.");
                    break;
                case ObjectChangeKind.ChangeGameObjectStructure:
                    stream.GetChangeGameObjectStructureEvent(i, out var changeGameObjectStructure);
                    var gameObjectStructure = EditorUtility.InstanceIDToObject(changeGameObjectStructure.instanceId) as GameObject;
                    Debug.Log($"Change GameObject structure: {gameObjectStructure} in scene {changeGameObjectStructure.scene}.");
                    break;
                case ObjectChangeKind.ChangeGameObjectParent:
                    stream.GetChangeGameObjectParentEvent(i, out var changeGameObjectParent);
                    var gameObjectChanged = EditorUtility.InstanceIDToObject(changeGameObjectParent.instanceId) as GameObject;
                    var newParentGo = EditorUtility.InstanceIDToObject(changeGameObjectParent.newParentInstanceId) as GameObject;
                    var previousParentGo = EditorUtility.InstanceIDToObject(changeGameObjectParent.previousParentInstanceId) as GameObject;
                    Debug.Log($"GameObject change parent from {previousParentGo} to {newParentGo} from scene {changeGameObjectParent.previousScene} to scene {changeGameObjectParent.newScene}.");
                    break;
                case ObjectChangeKind.ChangeGameObjectOrComponentProperties:
                    stream.GetChangeGameObjectOrComponentPropertiesEvent(i, out var changeGameObjectOrComponent);
                    var goOrComponent = EditorUtility.InstanceIDToObject(changeGameObjectOrComponent.instanceId);
                    if (goOrComponent is GameObject go)
                    {
                        Debug.Log($"GameObject {go} change properties in scene {changeGameObjectOrComponent.scene}.");
                    }
                    else if (goOrComponent is Component component)
                    {
                        Debug.Log($"Component {component} change properties in scene {changeGameObjectOrComponent.scene}.");
                    }
                    break;
                case ObjectChangeKind.DestroyGameObjectHierarchy:
                    stream.GetDestroyGameObjectHierarchyEvent(i, out var destroyGameObjectHierarchyEvent);
                    var destroyGo = EditorUtility.InstanceIDToObject(destroyGameObjectHierarchyEvent.instanceId) as GameObject;
                    var destroyParentGo = EditorUtility.InstanceIDToObject(destroyGameObjectHierarchyEvent.parentInstanceId) as GameObject;
                    Debug.Log($"Destroy GameObject hierarchy. GameObject: {destroyGo} with parent {destroyParentGo} in scene {destroyGameObjectHierarchyEvent.scene}.");
                    break;
                case ObjectChangeKind.CreateAssetObject:
                    stream.GetCreateAssetObjectEvent(i, out var createAssetObjectEvent);
                    var createdAsset = EditorUtility.InstanceIDToObject(createAssetObjectEvent.instanceId);
                    var createdAssetPath = AssetDatabase.GUIDToAssetPath(createAssetObjectEvent.guid);
                    Debug.Log($"Created asset {createdAsset} at {createdAssetPath} in scene {createAssetObjectEvent.scene}.");
                    break;
                case ObjectChangeKind.DestroyAssetObject:
                    stream.GetDestroyAssetObjectEvent(i, out var destroyAssetObjectEvent);
                    var destroyAsset = EditorUtility.InstanceIDToObject(destroyAssetObjectEvent.instanceId);
                    var destroyAssetPath = AssetDatabase.GUIDToAssetPath(destroyAssetObjectEvent.guid);
                    Debug.Log($"Destroy asset {destroyAsset} at {destroyAssetPath} in scene {destroyAssetObjectEvent.scene}.");
                    break;
                case ObjectChangeKind.ChangeAssetObjectProperties:
                    stream.GetChangeAssetObjectPropertiesEvent(i, out var changeAssetObjectPropertiesEvent);
                    var changeAsset = EditorUtility.InstanceIDToObject(changeAssetObjectPropertiesEvent.instanceId);
                    var changeAssetPath = AssetDatabase.GUIDToAssetPath(changeAssetObjectPropertiesEvent.guid);
                    Debug.Log($"Change asset {changeAsset} at {changeAssetPath} in scene {changeAssetObjectPropertiesEvent.scene}.");
                    break;
                case ObjectChangeKind.UpdatePrefabInstances:
                    stream.GetUpdatePrefabInstancesEvent(i, out var updatePrefabInstancesEvent);
                    var ss = new StringBuilder();
                    ss.AppendLine($"Update Prefabs in scene {updatePrefabInstancesEvent.scene}");
                    foreach (var prefabId in updatePrefabInstancesEvent.instanceIds)
                    {
                        ss.AppendLine(EditorUtility.InstanceIDToObject(prefabId).ToString());
                    }
                    Debug.Log(ss.ToString());
                    break;
            }
        }
    }
}
7 Likes

Now that’s helpful! You could probably paste that code sample into the function documentation and save a lot of headaches for future devs.

1 Like

That’s the plan :wink:
I’ll give it a little cleanup and update it Monday although it takes a little longer to filter through to the website.

1 Like

@karl_jones the “Asset” ObjectChangeKind do not seem to work for ScriptableObjects.

This stream is great, however, this snippet here does not work because the GameObject is Null at this point:

case ObjectChangeKind.DestroyAssetObject:
    stream.GetDestroyAssetObjectEvent(i, out var destroyAssetObjectEvent);
    var destroyAsset = EditorUtility.InstanceIDToObject(destroyAssetObjectEvent.instanceId);
    var destroyAssetPath = AssetDatabase.GUIDToAssetPath(destroyAssetObjectEvent.guid);
    Debug.Log($"Destroy asset {destroyAsset} at {destroyAssetPath} in scene {destroyAssetObjectEvent.scene}.");
    break;

You should look at the link version instead. I updated it https://gist.github.com/karljj1/f8adaf0024288052f991191777110f3d

The updated version is also in the docs now https://docs.unity3d.com/2023.1/Documentation/ScriptReference/ObjectChangeEvents-changesPublished.html

2 Likes

Does it make sense to Push our own events through the ObjectChangeEventStream.Builder struct?

I mean, does any Unity’s internal tools actually listen to this Stream to react? Why is it public?

How would you push your own events? There’s no public API for this.

We do use it internally and it’s used by some of our packages.
I don’t know the details about why this particular API was added but it seems useful for anyone building tooling that needs to know when a change occurs. We used it in the localization package for this.

2 Likes

No, I don’t think so. I think you would have to use something like Unity - Scripting API: AssetPostprocessor.OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths, bool didDomainReload)

Hi, I removed my comment when I thought I had found an answer, using the same callback that you linked.
It’s detecting when something is made but I don’t know how to detect if it was duplicated or just created normally.

It says that “importedAssets contains paths of all assets used in the operation.” but I’m only getting a path to the object that was created and not the original.
Any tips on where to look?

There’s no simple way to detect asset duplication however I have seen people use OnWillCreateAsset to do this.
It won’t tell you if the new asset is a duplicate, but it will fire just before the asset is created and I guess you can implement some logic that can filter on assets that already exist or something similar.

As someone who nearly exclusively makes editor tools, this is pretty huge. I spend hours sometimes digging through assemblies and packages for any little odds and ends that might be helpful/useful. How could I have missed this? Thanks a ton for sharing this.

2 Likes

This is very useful, but … WHY is it Editor-only?

We still have to each write and maintain our own hacky code to shadow-detect this at runtime, when Unity has all the information and could just expose it.

What do you mean? We don’t have access to that at runtime either. Most of these events don’t happen outside of the editor, they are either part of the asset database or the Undo system, and both are editor-only.

If you look at the docs you will see that most of these events come from the Undo system. https://docs.unity3d.com/ScriptReference/ObjectChangeKind.html

1 Like