Treasure chest of Unity developer tools. Professional inspector tooling, high-performance utilities, spatial queries, and 20+ editor tools.
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.
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
}
EditorApplication.delayCall// ✅ 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:
[DetectAssetChanged]AssetPostprocessorThreading: All callbacks execute on the main thread during asset processing
Timing: Methods are called after Unity completes asset import/deletion
IncludeAssignableTypes)SearchPrefabs if the handler is on a prefab assetSearchSceneObjects if the handler is on a GameObject in a sceneIf your instance method on a MonoBehaviour isn’t being called:
DetectAssetChangedOptions.SearchPrefabsDetectAssetChangedOptions.SearchSceneObjectsSearchPrefabs | SearchSceneObjectsstatic method instead (most efficient)EditorApplication.delayCallstatic methods to avoid unnecessary instance lookupsSearchPrefabs in large projects - it loads all prefabs to check for componentsSearchSceneObjects with many open scenes - searches all loaded scenesnull for deletion eventsAssetChangeFlags.Deleted