Search Unity

Tips for extending the Unity Editor — Lessons from Project MARS

, Апрель 3, 2019

The Authoring Tools Group in Unity Labs is developing Project MARS — Mixed and Augmented Reality Studio, a Unity extension that enables users to build and test robust AR experiences. This blog post captures some insights and tips for extending the Editor that have come out of the development of MARS. Keep in mind that some of these are generally applicable, but others depend on your specific use case. Many of these have corresponding examples in our SuperScience GitHub repository.

Running in Edit Mode

A MARS Scene generally consists of conditions that real-world data must meet, and digital content that appears when those conditions are met. To support this workflow, MARS has the Simulation View, which lets a user test their setup against different environments and adjust their conditions and content in the context of these environments. Since adjustments made from the Simulation View need to be saved back to the Scene, the simulation must happen in Edit Mode.

There are a couple of different ways to make a MonoBehaviour run in Edit Mode. There are the attributes ExecuteInEditMode and ExecuteAlways, which make all instances of a MonoBehaviour run in Edit Mode. This isn’t ideal for our use case since we only want instances involved in a simulation to run, so instead, we use the property runInEditMode. (Note that ExecuteAlways supports Prefab Mode but ExecuteInEditMode does not. In 2018.3 runInEditMode also supports Prefab Mode.)

The first time a MonoBehaviour’s runInEditMode property is set to true, it starts up the same lifecycle that happens in Play Mode (Awake, Start, etc.). Setting runInEditMode to false after this does stop the object from receiving updates, but it does not trigger OnDisable. It’s only the next time runInEditMode is set to true that the object if it was already enabled, is disabled and reenabled. In MARS we found it handy to make a MonoBehaviour extension method StopRunInEditMode, which disables the MonoBehaviour, sets its runInEditMode to false, and then re-enables it (only if the MonoBehaviour was already enabled). We also have a StartRunInEditMode for consistency, but this just sets runInEditMode to true, which already triggers OnEnable if the MonoBehaviour was enabled.

By default, an object running in Edit Mode only gets an update whenever something in the Scene changes, but you can also use EditorApplication.QueuePlayerLoopUpdate to force an update. If you want objects to continuously update in Edit Mode, you can hook up QueuePlayerLoopUpdate to the delegate EditorApplication.update.

To see in action how runInEditMode works, you can use our RunInEditHelper. This Editor Window lets you modify which objects are running and toggle the Player Loop.

Responding to changes in a Scene

Part of the utility of the Simulation View is that it improves iteration times for setting up and testing an AR Scene — when a user modifies their Scene’s conditions, we restart the simulation process so that the user gets immediate feedback for how their new condition setup works. In MARS the way we detect when a user makes a change to their Scene is by hooking into Undo.postprocessModifications and Undo.undoRedoPerformed.

The postprocessModifications callback takes an array of UndoPropertyModifications, so if you only want to detect a specific type of modification you can examine the currentValue field of each UndoPropertyModification. For example, in MARS we check the Type of currentValue.target so that we only respond to changes involving MARS-specific Components.

Undo.undoRedoPerformed, however, doesn’t take any parameters so it’s harder to know what exactly was changed. It’s likely that when this callback occurs, the modified object(s) will include Selection.activeGameObject or Selection.gameObjects, but this is only guaranteed to be the case when an object is modified through an Inspector that is not locked. It is also always possible for a modification to happen from some arbitrary user code, for example through SerializedProperty.

Delayed responses

In many cases, you may have some response to a change that you don’t want to immediately happen from Undo callbacks. For example, a user dragging a slider in the Inspector will trigger a lot of calls to Undo.postprocessModifications, and in MARS we don’t want to restart the simulation process for each of those calls. If you only want to trigger a response when a user is “done” making some continuous change, a reasonable solution is to have a short timer (say about 0.3 seconds) that is reset and started when a change is detected, and to only trigger the response when the timer finishes.

Here is an example of this pattern in action. You can find the corresponding implementation here.

 

Storing Scene metadata

For each MARS Scene, we need to store certain information as metadata, such as the kinds of real-world data that the Scene requires. There are a couple of different ways you can store metadata for a Scene, each with their own pros and cons:

  1. A ScriptableObject Asset per-Scene
    • Pros
      • You can access metadata without opening the Scene.
      • You can keep Editor-only metadata out of builds (if you have Editor metadata in a separate Asset from Runtime metadata).
      • It’s easy to find if you store it in the same directory as the Scene.
    • Cons
      • You have to make sure it is kept in-sync with the Scene.*
      • You have to check for Scene duplication so that metadata also gets duplicated.
      • It makes the project bulkier (for each Scene you need a new Asset).
  2. One master ScriptableObject Asset that stores metadata for all Scenes
    • Pros
      • You can access metadata without opening the Scene.
      • You can keep Editor-only metadata out of builds (if you have Editor metadata in a separate Asset from Runtime metadata).
      • There’s one metadata Asset per-project rather than one Asset per-Scene.
    • Cons
      • You have to make sure it is kept in-sync with the Scene.*
      • You have to check for Scene duplication so that metadata also gets duplicated.
      • Version control is more difficult — changes to one Scene affect the whole master Asset.
      • Scenes cannot be distributed among different packages since metadata for all Scenes is stored in one place.
  3. A MonoBehaviour in the Scene itself
    • Pros
      • It’s easy to keep in-sync with the Scene — saving the Scene inherently saves the metadata.
      • Duplicating the Scene inherently duplicates the metadata.
      • It’s easy to find since it’s right there in the Scene.
    • Cons
      • You can only access metadata by opening the Scene.
      • It can be worse for user experience (“why is this object in my Scene?”)
        • Depending on your use case, you could hide the metadata Game Object or Component in the hierarchy and have a custom window for viewing/editing it.

For MARS we originally went with the approach of having one master ScriptableObject. We later switched to using MonoBehaviours since the cons of having a master Asset were more trouble than they were worth (especially merge conflicts and keeping metadata in-sync), and also because at that point, we already needed a MARS-specific Component in the Scene for other reasons.

*If you go with a ScriptableObject approach, be careful about when you sync your metadata with the Scene. If you dirty the metadata Asset while the Scene is unsaved, there is potential for the metadata to be saved before the Scene since saving the project doesn’t save the open Scene. If you delay dirtying the metadata Asset until the Scene is saving, then you need to make sure the metadata gets saved as well at that time. If you use OnWillSaveAssets, you can accomplish this by checking if the given paths include the Scene path and, if so, dirtying the metadata Asset and including its path in the string array you return. Here is an example of how to implement this.

Assembly Definitions

It’s important to keep in mind that any extension you write has to play nicely with other extensions and user code. Using assembly definitions means that users don’t have to recompile your extension every time Unity compiles. They also make it easier for users to define their dependencies on your code.

There are three standard assemblies for a package or editor extension.

  1. Runtime
    1. This contains any code that needs to get included in a player build, if any.  An Editor-only extension may not need a Runtime assembly.
    2. Any Component that needs to go on Scene objects, whether it gets included in the build or not, must go in Runtime.
    3. The runtime assembly cannot reference the Editor assembly, just like scripts in an Editor folder with no assembly definition
  2. Editor
    1. This should contain all custom Inspector code as well as any non-Component C# code that is only needed in the Editor.
    2. The Editor assembly definition should only include the Editor as the target platform.
    3. The Editor assembly will almost certainly reference the Runtime assembly
  3. Tests
    1. This should contain all code that is only for testing the extension.  It is not necessary to distribute this folder with extension unless you want others to be able to change it, but some packages include it.
    2. If you have both Edit & Play Mode tests, you need two different assemblies.
      1. Extension.Tests.Editor (Edit Mode)
      2. Extension.Tests.Runtime (Play Mode)

The runtime assembly’s name is the name of the package.
Each of the other assembly definitions should be named like PackageName.{suffix}.

In MARS, this is MARS, MARS.Editor, and MARS.Tests. The top-level namespace of your extension’s code should match the prefix of your assembly definitions.

Using Editor assembly code in runtime

It’s sometimes necessary to reference Editor code in your runtime assembly.  For example, a MonoBehaviour may exist only for the purpose of edit-time functionality, but it must live in a runtime assembly due to the rule against MonoBehaviours in Editor assemblies.
In this case, it is often useful to define some static delegate fields inside of an #if UNTY_EDITOR directive. Your Editor class can then assign its own methods to those delegates, providing access to itself in the runtime assembly.  

You can find an example of this pattern in our SuperScience repo. There is an EditorWindow, a class in the runtime assembly with Editor delegates, and a MonoBehaviour that uses these delegates.

Lifecycle Events & Extensibility

One of the key patterns we follow in order to allow integrating other aspects of a user’s project with an extension is to provide events to hook into for important state changes in each smaller system.  We often refer to these as lifecycle events when they are provided for the important changes in a system or object.

It’s important to remember that it’s not possible to account for everything a user might want to do to integrate with your tool, so ideally your event signatures should not be too restrictive usually returning void.

For instance, when we open or close a scene for simulation in MARS, we have an event to allow for any custom setup or teardown a user’s project requires.  You would use this to add your own features to the MARS simulation. This setup means that we don’t have to account for everything a user might do for their specific use case something which is impossible for us to do but the user is still allowed a high degree of flexibility to implement whatever they want.

In this case, we have the simplest version of a lifecycle: just creation and destruction events, with no arguments, passed to the event functions.

It’s important to unsubscribe from the event once you don’t want to respond to it anymore.  If you don’t, you can get events firing after the object that wanted to use them has been destroyed.

Lifecycle events often need to pass some state change data to the end user. If you wanted to communicate that a set of Components has been destroyed or changed, you would have an event like this.

The above examples use an Action, but if you want to be able to hook up events in the inspector, you can replace Action with UnityEvent.

Wrapping up

We love seeing all the cool and unique ways that the community adds to the Editor. It’s inspiring to see developers enabling success for developers. Our mission as the Authoring Tools Group is to better empower creators to shape the future of 3D, and we are happy to share what we learn along the way. If you have questions or feedback for us, feel free to reach out to labs@unity3d.com!

Комментарии закрыты.

  1. > Version control is more difficult — changes to one Scene affect the whole master Asset.

    This is only a problem if the asset file doesn’t behave nicely. Version control excels at handling local changes to files but Unity has made some strange decisions regarding asset file format that often make this harder than it should be.

    (i.e. for some reason the Unity YAML has been turned into a non-human-readable format and thus has all the disadvantages of a text-based format and none of the advantages)