using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; /// /// 纹理尺寸检查器 /// 扫描指定目录下的所有 Texture2D,检查宽/高是否都是 4 的倍数 /// (BC7 等块压缩格式要求宽高必须为 4 的倍数,否则 Unity 会回退到 RGBA32 等未压缩格式,包体与内存都会暴涨)。 /// 支持一键修复:在 PNG 的右侧/上方补齐像素凑满 4 的倍数(原图可选备份)。 /// 菜单:Tools → 纹理尺寸检查器 (BC7 4倍数) /// public class TextureDimensionChecker : EditorWindow { // ============================ 日志开关 ============================ // 启用: Player Settings → Scripting Define Symbols 添加 ENABLE_EDITOR_LOG // 关闭: 删除该符号。未定义时所有 Log/LogError 调用被编译器自动移除,零开销 [System.Diagnostics.Conditional("ENABLE_EDITOR_LOG")] private static void Log(string msg) { Debug.Log(msg); } #region 内部类 private class TextureCheckInfo { public string path; public int width; public int height; public bool isMultipleOf4; public bool loadFailed; public string fixState; } #endregion #region 字段 private Vector2 scroll; private string rootFolder = "Assets"; private bool onlyShowInvalid = true; private bool backupBeforeFix = true; private bool scanned; private readonly List results = new List(); private int totalCount; private int validCount; private int invalidCount; private int failedCount; #endregion #region 入口 [MenuItem("Tools/纹理尺寸检查器 (BC7 4倍数)")] public static void Open() { TextureDimensionChecker window = GetWindow("纹理尺寸检查器"); window.minSize = new Vector2(760f, 360f); window.Show(); } #endregion #region GUI private void OnGUI() { DrawToolbar(); DrawFixToolbar(); DrawSummary(); DrawResultList(); } private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("扫描目录:", EditorStyles.label, GUILayout.Width(65f)); rootFolder = EditorGUILayout.TextField(rootFolder, EditorStyles.toolbarTextField, GUILayout.Width(240f)); if (GUILayout.Button("选择", EditorStyles.toolbarButton, GUILayout.Width(45f))) { string abs = EditorUtility.OpenFolderPanel("选择要扫描的目录(必须在 Assets 内)", rootFolder, string.Empty); if (!string.IsNullOrEmpty(abs)) { string rel = ToAssetRelativePath(abs); if (string.IsNullOrEmpty(rel)) { EditorUtility.DisplayDialog("目录不合法", "请选择 Assets 目录或其子目录。", "确定"); } else { rootFolder = rel; } } } if (GUILayout.Button("开始扫描", EditorStyles.toolbarButton, GUILayout.Width(70f))) { Scan(); } GUILayout.Space(10f); onlyShowInvalid = GUILayout.Toggle(onlyShowInvalid, "仅显示不合规", EditorStyles.toolbarButton, GUILayout.Width(90f)); GUILayout.FlexibleSpace(); using (new EditorGUI.DisabledScope(invalidCount == 0)) { if (GUILayout.Button("复制不合规路径", EditorStyles.toolbarButton, GUILayout.Width(100f))) { CopyInvalidPaths(); } if (GUILayout.Button("导出CSV", EditorStyles.toolbarButton, GUILayout.Width(60f))) { ExportCsv(); } } EditorGUILayout.EndHorizontal(); } private void DrawFixToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); backupBeforeFix = GUILayout.Toggle(backupBeforeFix, "修复前备份原图", EditorStyles.toolbarButton, GUILayout.Width(110f)); using (new EditorGUI.DisabledScope(invalidCount == 0)) { if (GUILayout.Button($"修复全部不合规 ({invalidCount})", EditorStyles.toolbarButton, GUILayout.Width(140f))) { FixAll(); } } GUILayout.Space(6f); if (GUILayout.Button("打开备份目录", EditorStyles.toolbarButton, GUILayout.Width(90f))) { if (Directory.Exists(BackupRoot)) EditorUtility.RevealInFinder(BackupRoot); else EditorUtility.DisplayDialog("备份目录不存在", $"还没有任何备份:\n{BackupRoot}", "确定"); } GUILayout.FlexibleSpace(); GUILayout.Label("修复方式:PNG 右侧/上方补像素到 4 的倍数(最多各 3px),不改导入设置", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } private void DrawSummary() { if (!scanned) { EditorGUILayout.HelpBox( "扫描指定目录下所有 Texture2D,检查宽/高是否均为 4 的倍数。\n" + "BC7 等块压缩格式要求宽高为 4 的倍数,不满足时 Unity 会自动回退到未压缩格式,导致包体和内存增大。\n\n" + "修复:在 PNG 的右侧/上方补齐像素(最多各 3px)凑满 4 的倍数,补边不改变 Sprite 图集的矩形坐标;\n" + "平铺(Repeat)纹理补透明边会出现接缝,改为复制边缘像素;仅支持 PNG,其他格式需手动处理。", MessageType.Info); return; } EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); GUILayout.Label($"总计:{totalCount}", GUILayout.Width(110f)); GUILayout.Label($"合规:{validCount}", GUILayout.Width(110f)); GUILayout.Label($"不合规:{invalidCount}", GUILayout.Width(120f)); GUILayout.Label($"加载失败:{failedCount}", GUILayout.Width(120f)); EditorGUILayout.EndHorizontal(); } private void DrawResultList() { if (!scanned) return; if (results.Count == 0 || (onlyShowInvalid && invalidCount == 0 && failedCount == 0)) { EditorGUILayout.HelpBox($"没有符合条件的纹理(共扫描 {totalCount} 张)。", MessageType.Info); return; } // 表头 EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("状态", EditorStyles.label, GUILayout.Width(60f)); GUILayout.Label("尺寸", EditorStyles.label, GUILayout.Width(120f)); GUILayout.Label("路径", EditorStyles.label); GUILayout.Label("修复", EditorStyles.label, GUILayout.Width(60f)); GUILayout.Label("操作", EditorStyles.label, GUILayout.Width(185f)); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int rowIndex = 0; for (int i = 0; i < results.Count; i++) { TextureCheckInfo info = results[i]; if (onlyShowInvalid && info.isMultipleOf4) continue; int i2 = rowIndex++; Rect rowRect = EditorGUILayout.BeginHorizontal(); // 斑马纹 if (Event.current.type == EventType.Repaint && i2 % 2 == 1) { EditorGUI.DrawRect(rowRect, new Color(0f, 0f, 0f, 0.06f)); } Color oldColor = GUI.color; if (info.loadFailed) GUI.color = new Color(0.7f, 0.7f, 0.7f); else if (!info.isMultipleOf4) GUI.color = new Color(1f, 0.5f, 0.5f); GUILayout.Label(info.loadFailed ? "加载失败" : (info.isMultipleOf4 ? "OK" : "不合规"), EditorStyles.label, GUILayout.Width(60f)); GUI.color = oldColor; GUILayout.Label(info.loadFailed ? "-" : $"{info.width} x {info.height}", EditorStyles.label, GUILayout.Width(120f)); GUILayout.Label(info.path, EditorStyles.label); GUILayout.FlexibleSpace(); GUILayout.Label(info.fixState ?? string.Empty, EditorStyles.miniLabel, GUILayout.Width(60f)); using (new EditorGUI.DisabledScope(info.loadFailed || info.isMultipleOf4)) { if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(55f))) { FixOne(info); } } if (GUILayout.Button("定位", EditorStyles.miniButton, GUILayout.Width(55f))) { Ping(info.path); } if (GUILayout.Button("选中", EditorStyles.miniButton, GUILayout.Width(55f))) { Select(info.path); } EditorGUILayout.EndHorizontal(); // 双击定位 if (rowRect.Contains(Event.current.mousePosition) && Event.current.type == EventType.MouseDown && Event.current.clickCount == 2) { Ping(info.path); Event.current.Use(); } } EditorGUILayout.EndScrollView(); } #endregion #region 扫描 private void Scan() { results.Clear(); totalCount = validCount = invalidCount = failedCount = 0; if (!AssetDatabase.IsValidFolder(rootFolder)) { EditorUtility.DisplayDialog("目录不存在", $"目录不存在或不是 Assets 内的目录:\n{rootFolder}", "确定"); return; } string[] guids; try { guids = AssetDatabase.FindAssets("t:Texture2D", new[] { rootFolder }); } catch (System.Exception e) { Log($"FindAssets 失败: {e.Message}"); EditorUtility.DisplayDialog("扫描失败", e.Message, "确定"); return; } try { for (int i = 0; i < guids.Length; i++) { if (EditorUtility.DisplayCancelableProgressBar("扫描纹理尺寸", $"{i + 1}/{guids.Length}", (float)i / guids.Length)) { Log("扫描被用户取消"); break; } string path = AssetDatabase.GUIDToAssetPath(guids[i]); if (string.IsNullOrEmpty(path)) continue; totalCount++; TextureCheckInfo info = new TextureCheckInfo { path = path }; Texture2D tex = AssetDatabase.LoadAssetAtPath(path); if (tex == null) { info.loadFailed = true; failedCount++; } else { info.width = tex.width; info.height = tex.height; info.isMultipleOf4 = (tex.width % 4 == 0) && (tex.height % 4 == 0); if (info.isMultipleOf4) validCount++; else invalidCount++; } results.Add(info); } } finally { EditorUtility.ClearProgressBar(); } // 不合规 / 加载失败排前面,其余按路径排序 results.Sort((a, b) => { int pa = a.loadFailed ? 0 : (a.isMultipleOf4 ? 2 : 1); int pb = b.loadFailed ? 0 : (b.isMultipleOf4 ? 2 : 1); if (pa != pb) return pa.CompareTo(pb); return string.CompareOrdinal(a.path, b.path); }); scanned = true; Log($"扫描完成: 总计 {totalCount}, 合规 {validCount}, 不合规 {invalidCount}, 加载失败 {failedCount}"); Repaint(); } #endregion #region 修复 private void FixOne(TextureCheckInfo info) { string error; if (!TryFix(info, out error)) { info.fixState = "失败"; EditorUtility.DisplayDialog("修复失败", $"{info.path}\n\n原因:{error}", "确定"); Log($"修复失败 {info.path}: {error}"); Repaint(); } } private void FixAll() { List targets = new List(); for (int i = 0; i < results.Count; i++) { TextureCheckInfo info = results[i]; if (!info.loadFailed && !info.isMultipleOf4) targets.Add(info); } if (targets.Count == 0) { EditorUtility.DisplayDialog("无需修复", "当前没有不合规的纹理。", "确定"); return; } string msg = $"将修复 {targets.Count} 张不合规纹理:\n\n" + "· 在 PNG 的右侧/上方补齐像素,最多各 3px,凑满 4 的倍数\n" + "· 不改动任何导入设置(压缩格式、mipmap 等保持原样)\n" + (backupBeforeFix ? $"· 原图备份到 {BackupRoot}\n" : "· 不备份原图(原图将被直接覆盖)\n") + "· 仅支持 PNG,其他格式会跳过\n" + "· 九宫格 / 自动切图的 Sprite 补边后建议抽查一下\n\n是否继续?"; if (!EditorUtility.DisplayDialog("修复确认", msg, "开始修复", "取消")) return; int ok = 0; int fail = 0; try { for (int i = 0; i < targets.Count; i++) { TextureCheckInfo info = targets[i]; if (EditorUtility.DisplayCancelableProgressBar("修复纹理尺寸", $"{i + 1}/{targets.Count} {info.path}", (float)i / targets.Count)) { Log("修复被用户取消"); break; } string error; if (TryFix(info, out error)) ok++; else { fail++; info.fixState = "失败"; Log($"修复失败 {info.path}: {error}"); } } } finally { EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); } EditorUtility.DisplayDialog("修复完成", $"成功 {ok} 张,失败/跳过 {fail} 张。\n" + (backupBeforeFix ? $"\n原图备份目录:\n{BackupRoot}" : string.Empty), "确定"); Repaint(); } /// 把 PNG 补边到 4 的倍数;原图贴左下角,新增像素补在右侧/上方 private bool TryFix(TextureCheckInfo info, out string error) { error = null; string ext = Path.GetExtension(info.path).ToLowerInvariant(); if (ext != ".png") { error = "仅支持自动修复 PNG"; return false; } string absPath = ToAbsolutePath(info.path); if (!File.Exists(absPath)) { error = "源文件不存在"; return false; } byte[] srcBytes; try { srcBytes = File.ReadAllBytes(absPath); } catch (System.Exception e) { error = $"读取失败:{e.Message}"; return false; } Texture2D src = null; Texture2D dst = null; try { src = new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!src.LoadImage(srcBytes)) { error = "PNG 解码失败"; return false; } int w = src.width; int h = src.height; int extraW = (4 - w % 4) % 4; int extraH = (4 - h % 4) % 4; if (extraW == 0 && extraH == 0) { error = "尺寸已是 4 的倍数"; return false; } TextureImporter importer = AssetImporter.GetAtPath(info.path) as TextureImporter; // 平铺(Repeat)纹理补透明边会出现接缝,改为复制边缘像素 bool extendEdge = importer != null && importer.textureType != TextureImporterType.Sprite && importer.wrapMode == TextureWrapMode.Repeat; int dstW = w + extraW; int dstH = h + extraH; Color32[] srcPixels = src.GetPixels32(); Color32[] dstPixels = new Color32[dstW * dstH]; if (extendEdge) { for (int y = 0; y < dstH; y++) { int srcRow = Mathf.Clamp(y, 0, h - 1) * w; int dstRow = y * dstW; for (int x = 0; x < dstW; x++) { dstPixels[dstRow + x] = srcPixels[srcRow + Mathf.Clamp(x, 0, w - 1)]; } } } else { for (int y = 0; y < h; y++) { System.Array.Copy(srcPixels, y * w, dstPixels, y * dstW, w); } } dst = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false); dst.SetPixels32(dstPixels); dst.Apply(); byte[] pngBytes = dst.EncodeToPNG(); if (pngBytes == null || pngBytes.Length == 0) { error = "PNG 编码失败"; return false; } if (backupBeforeFix) { try { BackupFile(info.path, srcBytes); } catch (System.Exception e) { error = $"备份失败(文件未修改):{e.Message}"; return false; } } try { File.WriteAllBytes(absPath, pngBytes); } catch (System.Exception e) { error = $"写入失败:{e.Message}"; return false; } AssetDatabase.ImportAsset(info.path, ImportAssetOptions.ForceUpdate); Texture2D reloaded = AssetDatabase.LoadAssetAtPath(info.path); if (reloaded == null) { error = "重新导入后加载失败,请检查该资源"; return false; } info.width = reloaded.width; info.height = reloaded.height; info.isMultipleOf4 = (reloaded.width % 4 == 0) && (reloaded.height % 4 == 0); if (!info.isMultipleOf4) { error = $"源图已补边为 {dstW}x{dstH},但导入尺寸为 {reloaded.width}x{reloaded.height}(可能被 Max Size 缩放),仍不合规"; return false; } info.fixState = "已修复"; if (invalidCount > 0) invalidCount--; validCount++; Log($"已修复 {info.path}: {w}x{h} -> {dstW}x{dstH}"); return true; } finally { if (src != null) DestroyImmediate(src); if (dst != null) DestroyImmediate(dst); } } #endregion #region 备份 /// 备份目录放在工程根目录(Assets 之外),避免被 Unity 导入 private static string BackupRoot { get { return Path.Combine(Directory.GetParent(Application.dataPath).FullName, "TextureFixBackup"); } } private static void BackupFile(string assetPath, byte[] bytes) { string stamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss"); string dst = Path.Combine(BackupRoot, stamp, assetPath.Replace('/', Path.DirectorySeparatorChar)); string dir = Path.GetDirectoryName(dst); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllBytes(dst, bytes); } #endregion #region 工具 /// 把绝对路径转成 Assets 相对路径;不在 Assets 内返回 null private static string ToAssetRelativePath(string absolutePath) { string dataPath = Application.dataPath.Replace('\\', '/'); string full = absolutePath.Replace('\\', '/'); if (full == dataPath) return "Assets"; if (!full.StartsWith(dataPath + "/")) return null; return "Assets" + full.Substring(dataPath.Length); } /// 把 Assets 相对路径转成绝对路径 private static string ToAbsolutePath(string assetPath) { string projectRoot = Directory.GetParent(Application.dataPath).FullName; return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar)); } private void Ping(string path) { Object asset = AssetDatabase.LoadAssetAtPath(path); if (asset == null) return; EditorGUIUtility.PingObject(asset); } private void Select(string path) { Object asset = AssetDatabase.LoadAssetAtPath(path); if (asset == null) return; Selection.activeObject = asset; EditorGUIUtility.PingObject(asset); } private void CopyInvalidPaths() { StringBuilder sb = new StringBuilder(); foreach (TextureCheckInfo info in results) { if (!info.isMultipleOf4) sb.AppendLine(info.path); } EditorGUIUtility.systemCopyBuffer = sb.ToString(); Log($"已复制 {invalidCount} 条不合规路径到剪贴板"); } private void ExportCsv() { string file = EditorUtility.SaveFilePanel("导出纹理尺寸检查结果", Application.dataPath, "TextureDimensionReport", "csv"); if (string.IsNullOrEmpty(file)) return; StringBuilder sb = new StringBuilder(); sb.AppendLine("状态,宽度,高度,路径"); foreach (TextureCheckInfo info in results) { if (onlyShowInvalid && info.isMultipleOf4) continue; string state = info.loadFailed ? "加载失败" : (info.isMultipleOf4 ? "合规" : "不合规"); sb.AppendLine($"{state},{(info.loadFailed ? "-" : info.width.ToString())},{(info.loadFailed ? "-" : info.height.ToString())},{info.path}"); } File.WriteAllText(file, sb.ToString(), new UTF8Encoding(true)); Log($"报告已导出: {file}"); EditorUtility.RevealInFinder(file); } #endregion }