Files
5.24BuYu/Assets/Editor/TextureDimensionChecker.cs
T
2026-10-02 00:44:33 +08:00

638 lines
22 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
/// <summary>
/// 纹理尺寸检查器
/// 扫描指定目录下的所有 Texture2D,检查宽/高是否都是 4 的倍数
/// (BC7 等块压缩格式要求宽高必须为 4 的倍数,否则 Unity 会回退到 RGBA32 等未压缩格式,包体与内存都会暴涨)。
/// 支持一键修复:在 PNG 的右侧/上方补齐像素凑满 4 的倍数(原图可选备份)。
/// 菜单:Tools → 纹理尺寸检查器 (BC7 4倍数)
/// </summary>
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<TextureCheckInfo> results = new List<TextureCheckInfo>();
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<TextureDimensionChecker>("纹理尺寸检查器");
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<Texture2D>(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<TextureCheckInfo> targets = new List<TextureCheckInfo>();
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();
}
/// <summary>把 PNG 补边到 4 的倍数;原图贴左下角,新增像素补在右侧/上方</summary>
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<Texture2D>(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 备份
/// <summary>备份目录放在工程根目录(Assets 之外),避免被 Unity 导入</summary>
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 工具
/// <summary>把绝对路径转成 Assets 相对路径;不在 Assets 内返回 null</summary>
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);
}
/// <summary>把 Assets 相对路径转成绝对路径</summary>
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<Object>(path);
if (asset == null) return;
EditorGUIUtility.PingObject(asset);
}
private void Select(string path)
{
Object asset = AssetDatabase.LoadAssetAtPath<Object>(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
}