Created
March 21, 2026 07:30
-
-
Save adammyhre/506971715750b03252cdf720999a5f2d to your computer and use it in GitHub Desktop.
Unity Editor Tool: Finding What Marks Scenes Dirty
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.Diagnostics; | |
| using System.Text; | |
| using UnityEditor; | |
| using UnityEditor.SceneManagement; | |
| using UnityEngine; | |
| using UnityEngine.SceneManagement; | |
| using Debug = UnityEngine.Debug; | |
| /// <summary> | |
| /// Helps track why the active scene becomes dirty (e.g. after exiting Play Mode). | |
| /// Enable logging in <b>Tools / Scene Dirty Diagnostics</b>. | |
| /// </summary> | |
| public static class SceneDirtyDiagnostics { | |
| const string PrefKeyEnabled = "SceneDirtyDiagnostics.Enabled"; | |
| const string PrefKeyLogStack = "SceneDirtyDiagnostics.LogStack"; | |
| const string PrefKeyScanOnExitPlay = "SceneDirtyDiagnostics.ScanOnExitPlay"; | |
| static bool Subscribed; | |
| [MenuItem("Tools/Scene Dirty Diagnostics/Window", priority = 100)] | |
| static void OpenWindow() { | |
| EditorWindow.GetWindow<SceneDirtyDiagnosticsWindow>(false, "Scene dirty", true); | |
| } | |
| [InitializeOnLoadMethod] | |
| static void Bootstrap() { | |
| RefreshSubscriptions(); | |
| } | |
| internal static void RefreshSubscriptions() { | |
| var want = EditorPrefs.GetBool(PrefKeyEnabled, false); | |
| if (want == Subscribed) return; | |
| if (want) { | |
| EditorSceneManager.sceneDirtied += OnSceneDirtied; | |
| EditorApplication.playModeStateChanged += OnPlayModeStateChanged; | |
| Subscribed = true; | |
| } else { | |
| EditorSceneManager.sceneDirtied -= OnSceneDirtied; | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| Subscribed = false; | |
| } | |
| } | |
| static void OnPlayModeStateChanged(PlayModeStateChange state) { | |
| if (!EditorPrefs.GetBool(PrefKeyEnabled, false)) return; | |
| if (state != PlayModeStateChange.EnteredEditMode) return; | |
| if (!EditorPrefs.GetBool(PrefKeyScanOnExitPlay, true)) return; | |
| // Let Unity finish restoring the scene before scanning. | |
| EditorApplication.delayCall += () => { | |
| EditorApplication.delayCall += () => { | |
| EditorApplication.delayCall += RunScanAfterPlayMode; | |
| }; | |
| }; | |
| } | |
| static void RunScanAfterPlayMode() { | |
| var scene = EditorSceneManager.GetActiveScene(); | |
| if (!scene.IsValid() || !scene.isLoaded) return; | |
| if (!scene.isDirty) { | |
| Debug.Log("[SceneDirtyDiag] Entered Edit Mode: active scene is <b>not</b> dirty."); | |
| return; | |
| } | |
| Debug.LogWarning($"[SceneDirtyDiag] Entered Edit Mode: active scene <b>{scene.name}</b> is <b>DIRTY</b>. Running component scan…"); | |
| LogSerializedObjectScan(scene, maxLog: 80); | |
| } | |
| static void OnSceneDirtied(Scene scene) { | |
| if (!EditorPrefs.GetBool(PrefKeyEnabled, false)) return; | |
| var sb = new StringBuilder(); | |
| sb.Append("[SceneDirtyDiag] <b>sceneDirtied</b>: "); | |
| sb.Append(scene.name); | |
| sb.Append(" path: "); | |
| sb.Append(string.IsNullOrEmpty(scene.path) ? "(unsaved)" : scene.path); | |
| Debug.Log(sb.ToString()); | |
| if (EditorPrefs.GetBool(PrefKeyLogStack, true)) { | |
| // Managed code that calls Undo / EditorUtility.SetDirty often appears here. | |
| Debug.Log("[SceneDirtyDiag] Stack trace (managed calls only):\n" + new StackTrace(2, true)); | |
| } | |
| } | |
| /// <summary> | |
| /// Menu: scan current scene for SerializedObject.hasModifiedProperties (heuristic for unsaved inspector state). | |
| /// </summary> | |
| [MenuItem("Tools/Scene Dirty Diagnostics/Scan active scene now", priority = 101)] | |
| public static void ScanActiveSceneMenu() { | |
| var scene = EditorSceneManager.GetActiveScene(); | |
| if (!scene.IsValid() || !scene.isLoaded) { | |
| Debug.LogWarning("[SceneDirtyDiag] No valid active scene."); | |
| return; | |
| } | |
| LogSerializedObjectScan(scene, maxLog: 200); | |
| } | |
| /// <summary> | |
| /// Heuristic: components whose SerializedObject reports modified properties after Update(). | |
| /// Not 100% (Unity internals can dirty the scene without this), but catches many scripts. | |
| /// </summary> | |
| public static void LogSerializedObjectScan(Scene scene, int maxLog) { | |
| var logged = 0; | |
| var roots = scene.GetRootGameObjects(); | |
| var seen = new HashSet<long>(); | |
| foreach (var root in roots) { | |
| var components = root.GetComponentsInChildren<Component>(true); | |
| foreach (var c in components) { | |
| if (c == null) continue; | |
| long eid = c.GetEntityId(); | |
| if (!seen.Add(eid)) continue; | |
| SerializedObject so = null; | |
| try { | |
| so = new SerializedObject(c); | |
| so.Update(); | |
| if (!so.hasModifiedProperties) continue; | |
| } catch { | |
| continue; | |
| } | |
| if (logged < maxLog) { | |
| Debug.LogWarning( | |
| "[SceneDirtyDiag] <b>hasModifiedProperties</b>: " + BuildHierarchyPath(c.gameObject) + | |
| " → <b>" + c.GetType().Name + "</b>", | |
| c); | |
| } | |
| logged++; | |
| } | |
| } | |
| if (logged == 0) | |
| Debug.Log("[SceneDirtyDiag] Scan: no components reported SerializedObject.hasModifiedProperties (heuristic empty)."); | |
| else if (logged > maxLog) | |
| Debug.LogWarning($"[SceneDirtyDiag] Scan: {logged} components matched; only first {maxLog} were logged."); | |
| else | |
| Debug.Log($"[SceneDirtyDiag] Scan complete: <b>{logged}</b> component(s) with hasModifiedProperties."); | |
| } | |
| static string BuildHierarchyPath(GameObject go) { | |
| if (go == null) return "(null)"; | |
| var parts = new List<string>(); | |
| var t = go.transform; | |
| while (t != null) { | |
| parts.Add(t.name); | |
| t = t.parent; | |
| } | |
| parts.Reverse(); | |
| return string.Join("/", parts); | |
| } | |
| sealed class SceneDirtyDiagnosticsWindow : EditorWindow { | |
| void OnEnable() => SceneDirtyDiagnostics.RefreshSubscriptions(); | |
| void OnGUI() { | |
| EditorGUILayout.HelpBox( | |
| "When enabled, logs when Unity raises sceneDirtied (with optional stack trace) and optionally " + | |
| "scans components after exiting Play Mode using SerializedObject.hasModifiedProperties " + | |
| "(heuristic — not every dirty scene will show matches).", | |
| MessageType.Info); | |
| EditorGUI.BeginChangeCheck(); | |
| var en = EditorGUILayout.ToggleLeft("Enable scene dirty logging", EditorPrefs.GetBool(PrefKeyEnabled, false)); | |
| var st = EditorGUILayout.ToggleLeft("Log stack trace on sceneDirtied", EditorPrefs.GetBool(PrefKeyLogStack, true)); | |
| var sc = EditorGUILayout.ToggleLeft("Scan after exiting Play Mode (EnteredEditMode)", EditorPrefs.GetBool(PrefKeyScanOnExitPlay, true)); | |
| if (EditorGUI.EndChangeCheck()) { | |
| EditorPrefs.SetBool(PrefKeyEnabled, en); | |
| EditorPrefs.SetBool(PrefKeyLogStack, st); | |
| EditorPrefs.SetBool(PrefKeyScanOnExitPlay, sc); | |
| SceneDirtyDiagnostics.RefreshSubscriptions(); | |
| } | |
| GUILayout.Space(8); | |
| using (new EditorGUI.DisabledScope(!EditorSceneManager.GetActiveScene().isLoaded)) { | |
| if (GUILayout.Button("Scan active scene now")) { | |
| SceneDirtyDiagnostics.ScanActiveSceneMenu(); | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment