-
Notifications
You must be signed in to change notification settings - Fork 11
Features Editor Tools Asset Change Detection
Automatically respond to asset creation and deletion events.
The [DetectAssetChanged] attribute allows you to annotate methods that should execute automatically when specific asset types are created or deleted in the Unity Editor. Perfect for cache invalidation, autoconfiguration, validation, and maintaining derived data.
- Basic Usage
- Attribute Parameters
- Method Signatures
- Inheritance Support
- Asset Change Context
- Best Practices
- Examples
using System.Collections.Generic;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Attributes;
using WallstopStudios.UnityHelpers.Core.Extension;
public class SpriteCache : ScriptableObject
{
private static readonly HashSet<string> TrackedSpritePaths = new();
[DetectAssetChanged(
typeof(Sprite),
AssetChangeFlags.Created | AssetChangeFlags.Deleted
)]
private static void OnSpriteChanged(AssetChangeContext context)
{
foreach (string path in context.CreatedAssetPaths)
{
TrackedSpritePaths.Add(path);
Debug.Log($"New sprite added: {path}");
}
foreach (string path in context.DeletedAssetPaths)
{
TrackedSpritePaths.Remove(path);
Debug.Log($"Sprite removed: {path}");
}
}
}Visual Reference
Automatic method invocation when assets are created or deleted
[DetectAssetChanged(
Type assetType, // Type of asset to monitor (required)
AssetChangeFlags flags, // Created, Deleted, or both (required)
DetectAssetChangedOptions options = None // IncludeAssignableTypes for inheritance
)][Flags]
public enum AssetChangeFlags
{
None = 0,
Created = 1 << 0, // Trigger on asset creation
Deleted = 1 << 1, // Trigger on asset deletion
}[Flags]
public enum DetectAssetChangedOptions
{
None = 0,
IncludeAssignableTypes = 1 << 0, // Also trigger for derived types
SearchPrefabs = 1 << 1, // Search prefabs for MonoBehaviour handlers
SearchSceneObjects = 1 << 2, // Search open scenes for MonoBehaviour handlers
}Important:
SearchPrefabsandSearchSceneObjectsare only applicable to instance methods on MonoBehaviour classes. Static methods work without these options.
The attribute supports three method signatures:
[DetectAssetChanged(typeof(ScriptableObject), AssetChangeFlags.Created)]
private static void OnScriptableObjectCreated()
{
Debug.Log("A ScriptableObject was created - invalidate cache");
}When to use: Simple cache invalidation that doesn't need asset details
[DetectAssetChanged(typeof(AudioClip), AssetChangeFlags.Created | AssetChangeFlags.Deleted)]
private static void OnAudioClipChanged(AssetChangeContext context)
{
Debug.Log($"AudioClip change: {context.Flags}");
foreach (string path in context.CreatedAssetPaths)
{
AudioClip clip = AssetDatabase.LoadAssetAtPath<AudioClip>(path);
ProcessAudioClip(clip);
}
foreach (string path in context.DeletedAssetPaths)
{
Debug.Log($"AudioClip deleted: {path}");
}
}When to use: Need to handle both creation and deletion, or need access to all changed paths
[DetectAssetChanged(typeof(Material), AssetChangeFlags.Created | AssetChangeFlags.Deleted)]
private static void OnMaterialChanged(Material[] createdMaterials, string[] deletedPaths)
{
foreach (Material mat in createdMaterials)
{
Debug.Log($"Material created: {mat.name}");
ValidateMaterial(mat);
}
foreach (string path in deletedPaths)
{
Debug.Log($"Material deleted: {path}");
}
}When to use: Need strongly-typed access to created assets; deleted assets are always paths since the asset no longer exists
By default, the attribute only triggers for exact type matches. Use IncludeAssignableTypes to include derived types:
// Triggers for ScriptableObject and ALL derived types
[DetectAssetChanged(
typeof(ScriptableObject),
AssetChangeFlags.Created,
DetectAssetChangedOptions.IncludeAssignableTypes
)]
private static void OnAnyScriptableObjectCreated(ScriptableObject obj)
{
Debug.Log($"ScriptableObject created: {obj.GetType().Name}");
}
// Only triggers for exact Material type (not derived classes)
[DetectAssetChanged(typeof(Material), AssetChangeFlags.Created)]
private static void OnExactMaterialCreated(Material mat)
{
Debug.Log("Material (exact type) created");
}The AssetChangeContext class provides complete information about the change:
public sealed class AssetChangeContext
{
public Type AssetType { get; } // The type being watched
public AssetChangeFlags Flags { get; } // Created, Deleted, or both
public IReadOnlyList<string> CreatedAssetPaths { get; } // Paths of created assets
public IReadOnlyList<string> DeletedAssetPaths { get; } // Paths of deleted assets
public bool HasCreatedAssets { get; } // True if any created
public bool HasDeletedAssets { get; } // True if any deleted
}- Keep methods fast - They run synchronously during asset import
-
Avoid heavy operations - Consider deferring work with
EditorApplication.delayCall - Use static methods when possible - Faster invocation, no instance required
// ✅ GOOD: Static method for global cache
[DetectAssetChanged(typeof(Sprite), AssetChangeFlags.Created | AssetChangeFlags.Deleted)]
private static void OnSpriteChanged()
{
SpriteManager.InvalidateCache();
}
// ✅ GOOD: Instance method for component-specific logic
[DetectAssetChanged(typeof(AudioClip), AssetChangeFlags.Created)]
private void OnAudioClipCreated(AudioClip clip)
{
if (clip.name.StartsWith(audioPrefix))
{
RegisterClip(clip);
}
}
// ⚠️ CAUTION: Expensive operation during import
[DetectAssetChanged(typeof(Texture2D), AssetChangeFlags.Created)]
private static void OnTextureCreated(Texture2D texture)
{
// Heavy processing - consider deferring
ProcessTexture(texture);
}[DetectAssetChanged(typeof(Material), AssetChangeFlags.Created)]
private static void OnMaterialCreated(string assetPath)
{
// ❌ BAD: Creating assets during asset processing can cause loops
// AssetDatabase.CreateAsset(newMaterial, "Assets/Generated.mat");
// ✅ GOOD: Defer asset creation
EditorApplication.delayCall += () =>
{
AssetDatabase.CreateAsset(newMaterial, "Assets/Generated.mat");
};
}public class TextureAtlas : ScriptableObject
{
private static List<Texture2D> _cachedTextures;
[DetectAssetChanged(typeof(Texture2D), AssetChangeFlags.Created | AssetChangeFlags.Deleted)]
private static void OnTextureChanged()
{
_cachedTextures = null; // Invalidate cache
}
}public class MaterialValidator : ScriptableObject
{
[DetectAssetChanged(typeof(Material), AssetChangeFlags.Created)]
private static void ValidateNewMaterials(Material[] createdMaterials, string[] deletedPaths)
{
foreach (Material material in createdMaterials)
{
if (material.shader.name == "Standard")
{
// Apply project-wide defaults
material.SetFloat("_Metallic", 0.0f);
material.SetFloat("_Glossiness", 0.5f);
EditorUtility.SetDirty(material);
}
}
}
}public abstract class GameData : ScriptableObject { }
public class DataRegistry : ScriptableObject
{
private static readonly HashSet<string> RegisteredPaths = new();
[DetectAssetChanged(
typeof(GameData),
AssetChangeFlags.Created | AssetChangeFlags.Deleted,
DetectAssetChangedOptions.IncludeAssignableTypes
)]
private static void OnGameDataChanged(GameData[] created, string[] deletedPaths)
{
foreach (GameData data in created)
{
string path = AssetDatabase.GetAssetPath(data);
RegisteredPaths.Add(path);
Debug.Log($"Registered: {data.GetType().Name} at {path}");
}
foreach (string path in deletedPaths)
{
RegisteredPaths.Remove(path);
Debug.Log($"Unregistered: {path}");
}
}
}Use SearchPrefabs to invoke instance methods on MonoBehaviours attached to prefabs:
public class SpriteCache : MonoBehaviour
{
[SerializeField] private List<Sprite> _cachedSprites = new();
[DetectAssetChanged(
typeof(Sprite),
AssetChangeFlags.Created | AssetChangeFlags.Deleted,
DetectAssetChangedOptions.SearchPrefabs
)]
private void OnSpriteChanged(AssetChangeContext context)
{
// This instance method is called on the prefab asset
Debug.Log($"SpriteCache on prefab received sprite change: {context.Flags}");
RefreshCache();
}
private void RefreshCache()
{
_cachedSprites.Clear();
// Rebuild cache...
}
}When to use: When your MonoBehaviour needs instance-specific state or serialized fields
Use SearchSceneObjects to invoke instance methods on MonoBehaviours in open scenes:
public class LiveAssetWatcher : MonoBehaviour
{
[SerializeField] private string _watchedFolder;
[DetectAssetChanged(
typeof(Texture2D),
AssetChangeFlags.Created,
DetectAssetChangedOptions.SearchSceneObjects
)]
private void OnTextureCreated(AssetChangeContext context)
{
// Called on every LiveAssetWatcher instance in all open scenes
foreach (string path in context.CreatedAssetPaths)
{
if (path.StartsWith(_watchedFolder))
{
Debug.Log($"{name} detected new texture: {path}");
HandleNewTexture(path);
}
}
}
private void HandleNewTexture(string path) { /* ... */ }
}When to use: For editor tools that need to react to changes based on scene-specific configuration
Use both options together to find handlers in both prefabs and open scenes:
public class UniversalAssetHandler : MonoBehaviour
{
[DetectAssetChanged(
typeof(AudioClip),
AssetChangeFlags.Created | AssetChangeFlags.Deleted,
DetectAssetChangedOptions.SearchPrefabs | DetectAssetChangedOptions.SearchSceneObjects
)]
private void OnAudioClipChanged(AssetChangeContext context)
{
// Called on instances in both prefabs AND scene objects
Debug.Log($"{name} (on {gameObject.name}) received audio change");
}
}Performance Note: Searching prefabs and scenes has overhead. Use these options only when you need instance-specific behavior. For simple notifications, prefer static methods.
The DetectAssetChangeProcessor (Editor assembly) automatically:
- Scans for methods decorated with
[DetectAssetChanged] - Registers callbacks with Unity's
AssetPostprocessor - Invokes methods when matching assets change
- Handles null checks and error cases
- Supports both Edit Mode and Play Mode
Threading: All callbacks execute on the main thread during asset processing
Timing: Methods are called after Unity completes asset import/deletion
Step 1 above is an all-types / all-methods reflection scan. Running it inside Unity's import phase destabilizes the asset pipeline (a native crash on some Unity versions, multi-minute importer stalls on others), so the watcher declines to initialize where it has nothing to do:
| Context | Watcher initializes | Why |
|---|---|---|
| Interactive editor | Yes | This is the authoring workflow the feature exists for |
| Play mode | No | Authoring concern; a play-mode import would recurse into a scan |
Batch mode (-batchmode, CI, headless) |
No | No author is present to act on a callback |
Override the default from an [InitializeOnLoad] static constructor, so it applies before the
watcher's own deferred initialization:
using UnityEditor;
using WallstopStudios.UnityHelpers.Editor.AssetProcessors;
[InitializeOnLoad]
internal static class AssetWatcherPolicy
{
static AssetWatcherPolicy()
{
// Opt a headless asset pipeline back in...
AssetChangeDetectionUtility.Enabled = true;
// ...or keep the watcher off in an interactive editor.
AssetChangeDetectionUtility.Enabled = false;
// Drop the override and restore the defaults in the table above.
AssetChangeDetectionUtility.ResetEnabledToDefault();
}
}Turning the watcher off after it has already initialized stops further initialization but leaves already-discovered subscriptions in place.
For a temporary change, use the scope instead of assigning and restoring by hand; it captures the current state on construction and puts it back on dispose, so an early return or an exception cannot leak the override:
using (AssetChangeDetectionUtility.EnabledScope(false))
{
ImportEverything();
}- Ensure the method is in a type that Unity can discover (not in a generic class)
- Check that the asset type matches exactly (unless using
IncludeAssignableTypes) - Verify the asset change flags match the operation (Created vs. Deleted)
-
For MonoBehaviour instance methods:
- Use
SearchPrefabsif the handler is on a prefab asset - Use
SearchSceneObjectsif the handler is on a GameObject in a scene - Instance methods without these options only work for ScriptableObjects saved as assets
- Use
A prefab is matched by the type of its main asset, which Unity reports as GameObject. It is
never opened to see what it contains, so a watcher on some other type will not fire for it, and
neither will a watcher on the type of a sub-asset nested into a .prefab. (Nested sub-assets in
.asset files are matched normally.)
That is deliberate. Opening a prefab deserializes every component in it, which runs each one's
OnValidate, so your own code runs, on every prefab, on every import, and Unity logs
SendMessage cannot be called during Awake, CheckConsistency, or OnValidate for any OnValidate
that touches an API it relays. Watching a prefab by what it contains is not supported; watch a
GameObject and inspect the prefab yourself if you need it.
SearchPrefabs is a different feature and does not change this: it searches prefabs for
instances of the handler's own type, so that a non-static [DetectAssetChanged] method can be
invoked on them. It has no effect on which assets match a watcher.
If your instance method on a MonoBehaviour isn't being called:
-
On a prefab? Add
DetectAssetChangedOptions.SearchPrefabs -
In a scene? Add
DetectAssetChangedOptions.SearchSceneObjects -
Need both? Combine:
SearchPrefabs | SearchSceneObjects -
Don't need instance state? Use a
staticmethod instead (most efficient)
- Profile with Unity Profiler during asset import
- Consider deferring work with
EditorApplication.delayCall - Use
staticmethods to avoid unnecessary instance lookups -
Avoid
SearchPrefabsin large projects - it loads all prefabs to check for components -
Avoid
SearchSceneObjectswith many open scenes - searches all loaded scenes
If a callback repeatedly creates more matching asset changes, the watcher enters loop protection and skips additional batches until it is reset. After fixing the callback or clearing the bad state, editor tools can resume dispatch without a domain reload:
using WallstopStudios.UnityHelpers.Editor.AssetProcessors;
AssetChangeDetectionUtility.ResetLoopProtection();The reset clears only loop-protection state and queued changes. It preserves discovered watchers and subscriptions.
- Remember: asset parameter is
nullfor deletion events - Always null-check when handling
AssetChangeFlags.Deleted
- Attribute Metadata Cache Generator - Caches attribute metadata for fast lookup
- ScriptableObject Singleton Creator - Auto-creates singleton assets
- Inspector Attributes - Other custom inspector features
📦 Unity Helpers | 📖 Documentation | 🐛 Issues | 📜 MIT License
- Inspector Button
- Inspector Conditional Display
- Inspector Grouping Attributes
- Inspector Inline Editor
- Inspector Overview
- Inspector Selection Attributes
- Inspector Settings
- Inspector Validation Attributes
- Utility Components
- Visual Components
- Data Structures
- Helper Utilities
- Math And Extensions
- Pooling Guide
- Random Generators
- Reflection Helpers
- Singletons
- Asset Change Detection
- Asset Validation
- Editor Tools Guide
- Failed Tests Exporter
- Unity Method Analyzer
Automatic method invocation when assets are created or deleted