#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; namespace KnitOfShadows.AssetPreflight { public sealed class AssetPreflightAuditor : EditorWindow { [Serializable] private sealed class Issue { public string Severity; public string AssetPath; public string Type; public string Rule; public string Details; } private readonly List _issues = new List(); private Vector2 _scroll; private DefaultAsset _folder; private bool _scanModels = true; private bool _scanTextures = true; private bool _scanPrefabs = true; private bool _flagReadWriteMeshes = true; private bool _flagHugeTextures = true; private bool _flagMissingPrefabMaterials = true; private bool _flagMissingColliders = false; private int _textureSizeThreshold = 4096; [MenuItem("Tools/Knit of Shadows/Asset Preflight Auditor")] public static void Open() { var w = GetWindow(); w.titleContent = new GUIContent("Asset Preflight"); w.minSize = new Vector2(760, 460); w.Show(); } private void OnGUI() { EditorGUILayout.Space(6); EditorGUILayout.LabelField("Asset Preflight Auditor", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Non-destructive project audit for common asset-import and prefab issues. " + "Nothing is changed automatically.", MessageType.Info); using (new EditorGUILayout.VerticalScope("box")) { _folder = (DefaultAsset)EditorGUILayout.ObjectField( new GUIContent("Folder (optional)", "Leave empty to scan the whole Assets folder."), _folder, typeof(DefaultAsset), false); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Asset Types", EditorStyles.boldLabel); _scanModels = EditorGUILayout.ToggleLeft("Models / FBX", _scanModels); _scanTextures = EditorGUILayout.ToggleLeft("Textures", _scanTextures); _scanPrefabs = EditorGUILayout.ToggleLeft("Prefabs", _scanPrefabs); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Rules", EditorStyles.boldLabel); _flagReadWriteMeshes = EditorGUILayout.ToggleLeft( "Flag models with Read/Write enabled", _flagReadWriteMeshes); _flagHugeTextures = EditorGUILayout.ToggleLeft( "Flag textures above threshold", _flagHugeTextures); using (new EditorGUI.DisabledScope(!_flagHugeTextures)) _textureSizeThreshold = EditorGUILayout.IntPopup( "Texture threshold", _textureSizeThreshold, new[] { "2048", "4096", "8192" }, new[] { 2048, 4096, 8192 }); _flagMissingPrefabMaterials = EditorGUILayout.ToggleLeft( "Flag prefab renderers with missing material slots", _flagMissingPrefabMaterials); _flagMissingColliders = EditorGUILayout.ToggleLeft( "Flag prefabs with Renderer but no Collider (optional)", _flagMissingColliders); } using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Run Audit", GUILayout.Height(30))) RunAudit(); using (new EditorGUI.DisabledScope(_issues.Count == 0)) { if (GUILayout.Button("Export CSV", GUILayout.Height(30), GUILayout.Width(140))) ExportCsv(); if (GUILayout.Button("Clear", GUILayout.Height(30), GUILayout.Width(90))) _issues.Clear(); } } EditorGUILayout.Space(8); EditorGUILayout.LabelField( $"Issues: {_issues.Count} | Errors: {_issues.Count(i => i.Severity == \"Error\")} | Warnings: {_issues.Count(i => i.Severity == \"Warning\")}", EditorStyles.boldLabel); _scroll = EditorGUILayout.BeginScrollView(_scroll); foreach (var issue in _issues) { using (new EditorGUILayout.VerticalScope("box")) { var mt = issue.Severity == "Error" ? MessageType.Error : issue.Severity == "Warning" ? MessageType.Warning : MessageType.Info; EditorGUILayout.HelpBox($"{issue.Rule}\n{issue.Details}", mt); using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.SelectableLabel(issue.AssetPath, GUILayout.Height(18)); if (GUILayout.Button("Ping", GUILayout.Width(60))) { var obj = AssetDatabase.LoadMainAssetAtPath(issue.AssetPath); if (obj != null) { Selection.activeObject = obj; EditorGUIUtility.PingObject(obj); } } } } } EditorGUILayout.EndScrollView(); } private string RootPath() { if (_folder == null) return "Assets"; var p = AssetDatabase.GetAssetPath(_folder); return AssetDatabase.IsValidFolder(p) ? p : "Assets"; } private void RunAudit() { _issues.Clear(); var root = RootPath(); try { if (_scanModels) ScanModels(root); if (_scanTextures) ScanTextures(root); if (_scanPrefabs) ScanPrefabs(root); } catch (Exception ex) { Debug.LogException(ex); EditorUtility.DisplayDialog("Asset Preflight Auditor", "Audit stopped because of an unexpected error. Check Console for details.", "OK"); } Repaint(); } private void ScanModels(string root) { var guids = AssetDatabase.FindAssets("t:Model", new[] { root }); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var importer = AssetImporter.GetAtPath(path) as ModelImporter; if (importer == null) continue; if (_flagReadWriteMeshes && importer.isReadable) { Add("Warning", path, "Model", "MODEL_READ_WRITE", "Read/Write is enabled. This can increase runtime memory usage. " + "Keep it enabled only when runtime mesh access actually needs it."); } if (Mathf.Abs(importer.globalScale - 1f) > 0.0001f) { Add("Info", path, "Model", "MODEL_SCALE_FACTOR", $"Importer Scale Factor is {importer.globalScale}. Confirm that this is intentional and consistent with the project scale convention."); } if (importer.importBlendShapes && Path.GetExtension(path).Equals(".fbx", StringComparison.OrdinalIgnoreCase)) { Add("Info", path, "Model", "MODEL_BLENDSHAPES_ON", "Blend Shape import is enabled. If the model has no blend shapes, disabling it can keep imports simpler."); } } } private void ScanTextures(string root) { var guids = AssetDatabase.FindAssets("t:Texture2D", new[] { root }); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) continue; if (_flagHugeTextures) { importer.GetSourceTextureWidthAndHeight(out int width, out int height); if (width > _textureSizeThreshold || height > _textureSizeThreshold) { Add("Warning", path, "Texture", "TEXTURE_SOURCE_SIZE", $"Source texture is {width}×{height}, above the {_textureSizeThreshold}px audit threshold. " + "Confirm the source size is justified for this asset."); } } if (importer.textureType == TextureImporterType.NormalMap && importer.sRGBTexture) { Add("Warning", path, "Texture", "NORMALMAP_SRGB", "Texture is marked as a Normal Map while sRGB sampling is enabled. Verify the import configuration."); } if (importer.mipmapEnabled && importer.textureShape == TextureImporterShape.Texture2D) { Add("Info", path, "Texture", "TEXTURE_MIPMAPS", "Mip Maps are enabled. Appropriate for most world-space textures; verify for UI and special-purpose textures."); } } } private void ScanPrefabs(string root) { var guids = AssetDatabase.FindAssets("t:Prefab", new[] { root }); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var prefab = AssetDatabase.LoadAssetAtPath(path); if (prefab == null) continue; var renderers = prefab.GetComponentsInChildren(true); if (_flagMissingPrefabMaterials) { foreach (var r in renderers) { var mats = r.sharedMaterials; for (int i = 0; i < mats.Length; i++) { if (mats[i] == null) { Add("Error", path, "Prefab", "PREFAB_MISSING_MATERIAL", $"Renderer '{GetHierarchyPath(r.transform, prefab.transform)}' has an empty material slot at index {i}."); } } } } if (_flagMissingColliders && renderers.Length > 0) { var colliders = prefab.GetComponentsInChildren(true); if (colliders.Length == 0) { Add("Info", path, "Prefab", "PREFAB_NO_COLLIDER", "Prefab contains Renderer components but no Collider. This is only an issue if the object is expected to participate in physics or blocking."); } } var missingScripts = CountMissingScripts(prefab); if (missingScripts > 0) { Add("Error", path, "Prefab", "PREFAB_MISSING_SCRIPT", $"Prefab contains {missingScripts} missing MonoBehaviour reference(s)."); } } } private int CountMissingScripts(GameObject root) { int count = 0; foreach (var t in root.GetComponentsInChildren(true)) { var components = t.GetComponents(); count += components.Count(c => c == null); } return count; } private static string GetHierarchyPath(Transform t, Transform root) { var names = new List { t.name }; while (t.parent != null && t.parent != root) { t = t.parent; names.Add(t.name); } names.Reverse(); return string.Join("/", names); } private void Add(string severity, string path, string type, string rule, string details) { _issues.Add(new Issue { Severity = severity, AssetPath = path, Type = type, Rule = rule, Details = details }); } private void ExportCsv() { var file = EditorUtility.SaveFilePanel( "Export Asset Preflight Report", "", "asset-preflight-report.csv", "csv"); if (string.IsNullOrEmpty(file)) return; var sb = new StringBuilder(); sb.AppendLine("Severity,Asset Path,Type,Rule,Details"); foreach (var i in _issues) { sb.AppendLine(string.Join(",", Csv(i.Severity), Csv(i.AssetPath), Csv(i.Type), Csv(i.Rule), Csv(i.Details))); } File.WriteAllText(file, sb.ToString(), new UTF8Encoding(true)); EditorUtility.RevealInFinder(file); } private static string Csv(string s) { s = s ?? ""; return "\"" + s.Replace("\"", "\"\"") + "\""; } } } #endif