TH1/Unity/Assets/Editor/ResourceTextureOptimizerWindow.cs

1269 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEngine;
[Flags]
internal enum TextureOptimizeIssue
{
None = 0,
CompressionRisk = 1 << 0,
TooLarge = 1 << 1,
TooManyPixels = 1 << 2,
TooTransparent = 1 << 3,
ReadWriteEnabled = 1 << 4,
SpriteMipmapEnabled = 1 << 5,
NoStandaloneOverride = 1 << 6,
}
public class ResourceTextureOptimizerWindow : EditorWindow
{
private enum MainTab
{
ScanRules = 0,
Optimize = 1,
Trim = 2,
Results = 3,
}
private class TextureOptimizeItem
{
public string AssetPath;
public string FileExtension;
public int SourceWidth;
public int SourceHeight;
public long FileSizeBytes;
public float FileSizeMB;
public float EstimatedRawMB;
public bool HasAlpha;
public float TransparentRatio = -1f;
public string TransparentCheckNote = string.Empty;
public TextureImporterCompression TextureCompression;
public bool IsReadable;
public bool IsSprite;
public bool MipmapEnabled;
public bool StandaloneOverridden;
public TextureImporterFormat StandaloneFormat;
public int StandaloneMaxSize;
public TextureOptimizeIssue Issues;
public string IssueText = "无";
public bool IsTrimCandidate;
public string TrimCandidateReason = string.Empty;
public bool Selected = true;
}
private const string DefaultScanFolder = "Assets/Resources";
private const string StandalonePlatformName = "Standalone";
private string _scanFolder = DefaultScanFolder;
private string _search = string.Empty;
private Vector2 _scroll;
private Vector2 _scanTabScroll;
private Vector2 _optimizeTabScroll;
private Vector2 _trimTabScroll;
private bool _isScanning;
private readonly List<TextureOptimizeItem> _items = new List<TextureOptimizeItem>();
private int _lastScannedCount;
private string _selectedAssetPath = string.Empty;
private int _pageIndex;
private int _pageSize = 80;
private bool _showOnlySelected;
private bool _showOnlyTrimCandidates;
private int _itemsVersion;
private int _cachedItemsVersion = -1;
private string _cachedFilterKey = string.Empty;
private readonly List<TextureOptimizeItem> _filteredCache = new List<TextureOptimizeItem>();
private MainTab _mainTab = MainTab.Results;
private readonly string[] _mainTabNames = { "扫描规则", "一键优化", "自动裁切", "结果列表" };
// Scan rules
private bool _scanOnlyIssueItems = true;
private bool _checkTransparentRatio = true;
private int _warnMaxDimension = 2048;
private float _warnMaxMegaPixels = 2.5f;
private float _warnTransparentRatio = 0.60f;
private float _transparentAlphaCutoff = 0.08f; // alpha <= 8% 视为透明
private int _transparentCheckMinDimension = 512;
private float _warnMinFileSizeMBForNoOverride = 1.0f;
private bool _warnReadWriteEnabled = true;
private bool _warnSpriteMipmapEnabled = true;
// Batch operations
private bool _applyStandaloneOverride = true;
private bool _applyTextureCompression = true;
private bool _applyFormatByAlpha = true;
private bool _applyMaxSize = true;
private bool _applyOnlyShrinkMaxSize = true;
private bool _applyDisableReadWrite = true;
private bool _applyDisableSpriteMipmap = true;
private bool _applyCrunch = true;
private bool _applyCompressionQuality = true;
private TextureImporterCompression _targetTextureCompression = TextureImporterCompression.CompressedHQ;
private TextureImporterFormat _targetFormatAlpha;
private TextureImporterFormat _targetFormatOpaque;
private int _targetMaxSize = 1024;
private bool _targetEnableCrunch = false;
private int _targetCompressionQuality = 80;
// Auto trim transparent border (PNG)
private bool _trimCreateBackup = true;
private bool _trimSkipSpriteMultiple = true;
private float _trimAlphaCutoff = 0.05f;
private float _trimCandidateTransparentRatio = 0.85f;
private float _trimCandidateMinRawMB = 4.0f;
[MenuItem("Tools/TH1/Resource Texture Optimizer")]
public static void ShowWindow()
{
var win = GetWindow<ResourceTextureOptimizerWindow>("Resource Texture Optimizer");
win.minSize = new Vector2(980, 620);
}
private void OnEnable()
{
_targetFormatAlpha = ResolveBestFormat(new[] { "BC7", "DXT5", "RGBA32" });
_targetFormatOpaque = ResolveBestFormat(new[] { "BC1", "DXT1", "RGB24" });
}
private void OnGUI()
{
DrawHeader();
DrawMainTabs();
switch (_mainTab)
{
case MainTab.ScanRules:
_scanTabScroll = EditorGUILayout.BeginScrollView(_scanTabScroll);
DrawScanRules();
EditorGUILayout.EndScrollView();
break;
case MainTab.Optimize:
_optimizeTabScroll = EditorGUILayout.BeginScrollView(_optimizeTabScroll);
DrawOptimizeActions();
EditorGUILayout.EndScrollView();
break;
case MainTab.Trim:
_trimTabScroll = EditorGUILayout.BeginScrollView(_trimTabScroll);
DrawTrimActions();
EditorGUILayout.EndScrollView();
break;
case MainTab.Results:
default:
DrawResultToolbar();
DrawResultList();
break;
}
}
private void DrawMainTabs()
{
EditorGUILayout.Space(4);
var newTab = (MainTab)GUILayout.Toolbar((int)_mainTab, _mainTabNames, GUILayout.Height(28));
if (newTab != _mainTab)
{
_mainTab = newTab;
GUI.FocusControl(null);
}
EditorGUILayout.Space(4);
}
private void DrawHeader()
{
EditorGUILayout.LabelField("Resources 贴图优化工具", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"扫描并定位压缩风险、超大图、透明区域过多、Read/Write开启、Sprite开MipMap等问题支持一键批量修复导入设置。",
MessageType.Info);
EditorGUILayout.BeginHorizontal();
_scanFolder = EditorGUILayout.TextField("扫描目录", _scanFolder);
if (GUILayout.Button("重置", GUILayout.Width(60)))
{
_scanFolder = DefaultScanFolder;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
GUI.enabled = !_isScanning;
if (GUILayout.Button("扫描问题贴图", GUILayout.Height(30)))
{
ScanTextures();
}
GUI.enabled = true;
if (GUILayout.Button("导出CSV", GUILayout.Height(30), GUILayout.Width(120)))
{
ExportCsv();
}
if (GUILayout.Button("选中到Project", GUILayout.Height(30), GUILayout.Width(120)))
{
SelectCheckedInProject();
}
EditorGUILayout.EndHorizontal();
}
private void DrawScanRules()
{
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("扫描规则", EditorStyles.boldLabel);
EditorGUILayout.BeginVertical("box");
_scanOnlyIssueItems = EditorGUILayout.ToggleLeft("仅保留命中问题的贴图结果", _scanOnlyIssueItems);
_checkTransparentRatio = EditorGUILayout.ToggleLeft("检测透明区域占比(较慢)", _checkTransparentRatio);
_warnMaxDimension = EditorGUILayout.IntField("超大图阈值(最长边)", _warnMaxDimension);
_warnMaxMegaPixels = EditorGUILayout.FloatField("超大图阈值(百万像素)", _warnMaxMegaPixels);
_warnTransparentRatio = EditorGUILayout.Slider("透明占比告警阈值", _warnTransparentRatio, 0.3f, 0.95f);
_transparentAlphaCutoff = EditorGUILayout.Slider("透明像素Alpha阈值", _transparentAlphaCutoff, 0.01f, 0.2f);
_transparentCheckMinDimension = EditorGUILayout.IntField("透明检测最小分辨率边长", _transparentCheckMinDimension);
_warnMinFileSizeMBForNoOverride = EditorGUILayout.FloatField("无Standalone覆盖告警最小文件(MB)", _warnMinFileSizeMBForNoOverride);
_warnReadWriteEnabled = EditorGUILayout.ToggleLeft("检查 Read/Write Enabled", _warnReadWriteEnabled);
_warnSpriteMipmapEnabled = EditorGUILayout.ToggleLeft("检查 Sprite 开启 MipMap", _warnSpriteMipmapEnabled);
EditorGUILayout.EndVertical();
}
private void DrawOptimizeActions()
{
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("一键操作(对勾选结果生效)", EditorStyles.boldLabel);
EditorGUILayout.BeginVertical("box");
_applyStandaloneOverride = EditorGUILayout.ToggleLeft("强制写入 Standalone 平台设置", _applyStandaloneOverride);
_applyTextureCompression = EditorGUILayout.ToggleLeft("设置全局 Texture Compression", _applyTextureCompression);
_applyFormatByAlpha = EditorGUILayout.ToggleLeft("按透明通道设置格式有Alpha / 无Alpha", _applyFormatByAlpha);
_applyMaxSize = EditorGUILayout.ToggleLeft("设置 Max Texture Size", _applyMaxSize);
_applyOnlyShrinkMaxSize = EditorGUILayout.ToggleLeft("仅缩小,不放大已有 Max Size", _applyOnlyShrinkMaxSize);
_applyDisableReadWrite = EditorGUILayout.ToggleLeft("关闭 Read/Write", _applyDisableReadWrite);
_applyDisableSpriteMipmap = EditorGUILayout.ToggleLeft("关闭 Sprite 的 MipMap", _applyDisableSpriteMipmap);
_applyCrunch = EditorGUILayout.ToggleLeft("设置 Crunch 开关", _applyCrunch);
_applyCompressionQuality = EditorGUILayout.ToggleLeft("设置压缩质量", _applyCompressionQuality);
using (new EditorGUI.IndentLevelScope())
{
_targetTextureCompression = (TextureImporterCompression)EditorGUILayout.EnumPopup("Texture Compression", _targetTextureCompression);
_targetFormatAlpha = (TextureImporterFormat)EditorGUILayout.EnumPopup("有Alpha格式", _targetFormatAlpha);
_targetFormatOpaque = (TextureImporterFormat)EditorGUILayout.EnumPopup("无Alpha格式", _targetFormatOpaque);
_targetMaxSize = EditorGUILayout.IntPopup("目标Max Size", _targetMaxSize,
new[] { "256", "512", "1024", "2048", "4096" },
new[] { 256, 512, 1024, 2048, 4096 });
_targetEnableCrunch = EditorGUILayout.Toggle("Crunch", _targetEnableCrunch);
_targetCompressionQuality = EditorGUILayout.IntSlider("压缩质量", _targetCompressionQuality, 0, 100);
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("全选"))
{
SetSelection(true);
}
if (GUILayout.Button("全不选"))
{
SetSelection(false);
}
if (GUILayout.Button("反选"))
{
InvertSelection();
}
GUILayout.FlexibleSpace();
GUI.enabled = !_isScanning;
if (GUILayout.Button("对勾选项执行一键优化", GUILayout.Height(28), GUILayout.Width(220)))
{
ApplyOptimizationToSelected();
}
GUI.enabled = true;
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
private void DrawTrimActions()
{
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("透明边自动裁切PNG", EditorStyles.boldLabel);
EditorGUILayout.BeginVertical("box");
EditorGUILayout.HelpBox(
"会直接改 PNG 文件尺寸。建议先“快速勾选候选”再执行。默认跳过 SpriteMultiple 以避免切图坐标错乱。",
MessageType.Warning);
_trimCreateBackup = EditorGUILayout.ToggleLeft("写入前创建 .trimbak 备份", _trimCreateBackup);
_trimSkipSpriteMultiple = EditorGUILayout.ToggleLeft("跳过 SpriteMultiple推荐", _trimSkipSpriteMultiple);
_trimAlphaCutoff = EditorGUILayout.Slider("裁切Alpha阈值", _trimAlphaCutoff, 0.01f, 0.2f);
_trimCandidateTransparentRatio = EditorGUILayout.Slider("候选透明占比阈值", _trimCandidateTransparentRatio, 0.5f, 0.98f);
_trimCandidateMinRawMB = EditorGUILayout.FloatField("候选最小估算RGBA32(MB)", _trimCandidateMinRawMB);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("快速勾选高透明大图候选"))
{
SelectTrimCandidates();
}
GUI.enabled = !_isScanning;
if (GUILayout.Button("对勾选项执行自动裁切PNG", GUILayout.Height(26)))
{
TrimTransparentBordersForSelected();
}
GUI.enabled = true;
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
private void DrawResultToolbar()
{
EditorGUILayout.Space(6);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"结果:{_items.Count} 项(本次扫描纹理总数:{_lastScannedCount}", EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
_search = EditorGUILayout.TextField("搜索", _search, GUILayout.Width(320));
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
bool newOnlySelected = EditorGUILayout.ToggleLeft("只看勾选项", _showOnlySelected, GUILayout.Width(120));
bool newOnlyTrimCandidates = EditorGUILayout.ToggleLeft("只看裁切候选", _showOnlyTrimCandidates, GUILayout.Width(140));
if (newOnlySelected != _showOnlySelected || newOnlyTrimCandidates != _showOnlyTrimCandidates)
{
_showOnlySelected = newOnlySelected;
_showOnlyTrimCandidates = newOnlyTrimCandidates;
InvalidateFilterCache();
}
int newPageSize = EditorGUILayout.IntPopup("每页", _pageSize,
new[] { "40", "80", "120", "200" },
new[] { 40, 80, 120, 200 }, GUILayout.Width(120));
if (newPageSize != _pageSize)
{
_pageSize = newPageSize;
_pageIndex = 0;
}
var filtered = GetFilteredItems();
int totalPages = Mathf.Max(1, Mathf.CeilToInt(filtered.Count / (float)Mathf.Max(1, _pageSize)));
_pageIndex = Mathf.Clamp(_pageIndex, 0, totalPages - 1);
GUI.enabled = _pageIndex > 0;
if (GUILayout.Button("上一页", GUILayout.Width(70)))
{
_pageIndex--;
}
GUI.enabled = _pageIndex < totalPages - 1;
if (GUILayout.Button("下一页", GUILayout.Width(70)))
{
_pageIndex++;
}
GUI.enabled = true;
EditorGUILayout.LabelField($"第 {_pageIndex + 1}/{totalPages} 页(筛选后 {filtered.Count} 项)");
EditorGUILayout.EndHorizontal();
}
private void DrawResultList()
{
_scroll = EditorGUILayout.BeginScrollView(_scroll);
if (_items.Count == 0)
{
EditorGUILayout.HelpBox("暂无结果。点击“扫描问题贴图”开始。", MessageType.None);
EditorGUILayout.EndScrollView();
return;
}
var filtered = GetFilteredItems();
if (filtered.Count == 0)
{
EditorGUILayout.HelpBox("当前筛选条件下无结果。", MessageType.None);
EditorGUILayout.EndScrollView();
return;
}
int start = _pageIndex * Mathf.Max(1, _pageSize);
if (start >= filtered.Count) start = 0;
int end = Mathf.Min(filtered.Count, start + Mathf.Max(1, _pageSize));
var selectedItem = GetSelectedItem();
if (selectedItem != null)
{
DrawSelectedItemDetail(selectedItem);
}
DrawListHeader();
for (int i = start; i < end; i++)
{
DrawItemCompactRow(filtered[i]);
}
EditorGUILayout.EndScrollView();
}
private void DrawListHeader()
{
EditorGUILayout.BeginHorizontal("box");
GUILayout.Label("", GUILayout.Width(22));
GUILayout.Label("", GUILayout.Width(40));
GUILayout.Label("尺寸", GUILayout.Width(90));
GUILayout.Label("源MB", GUILayout.Width(58));
GUILayout.Label("估算MB", GUILayout.Width(66));
GUILayout.Label("透明%", GUILayout.Width(58));
GUILayout.Label("裁切候选", GUILayout.Width(74));
GUILayout.Label("问题", GUILayout.Width(250));
GUILayout.Label("AssetPath");
EditorGUILayout.EndHorizontal();
}
private void DrawItemCompactRow(TextureOptimizeItem item)
{
EditorGUILayout.BeginHorizontal("box");
bool oldSelected = item.Selected;
item.Selected = EditorGUILayout.Toggle(item.Selected, GUILayout.Width(22));
if (_showOnlySelected && oldSelected != item.Selected)
{
InvalidateFilterCache();
}
if (GUILayout.Button("Ping", GUILayout.Width(40)))
{
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(item.AssetPath);
if (obj != null) EditorGUIUtility.PingObject(obj);
}
if (GUILayout.Button($"{item.SourceWidth}x{item.SourceHeight}", GUILayout.Width(90)))
{
_selectedAssetPath = item.AssetPath;
}
GUILayout.Label(item.FileSizeMB.ToString("F2"), GUILayout.Width(58));
GUILayout.Label(item.EstimatedRawMB.ToString("F2"), GUILayout.Width(66));
string transparentText = item.TransparentRatio >= 0f ? (item.TransparentRatio * 100f).ToString("F1") : "-";
GUILayout.Label(transparentText, GUILayout.Width(58));
GUILayout.Label(item.IsTrimCandidate ? "是" : "-", GUILayout.Width(74));
GUILayout.Label(item.IssueText, GUILayout.Width(250));
GUILayout.Label(item.AssetPath);
EditorGUILayout.EndHorizontal();
}
private void DrawSelectedItemDetail(TextureOptimizeItem item)
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("当前详情", EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
if (GUILayout.Button("Ping", GUILayout.Width(60)))
{
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(item.AssetPath);
if (obj != null)
{
EditorGUIUtility.PingObject(obj);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.LabelField(item.AssetPath, EditorStyles.wordWrappedLabel);
EditorGUILayout.LabelField($"尺寸={item.SourceWidth}x{item.SourceHeight} | 源文件={item.FileSizeMB:F2}MB | 估算RGBA32={item.EstimatedRawMB:F2}MB");
EditorGUILayout.LabelField($"问题:{item.IssueText}");
EditorGUILayout.LabelField(
$"压缩={item.TextureCompression}, StandaloneOverride={item.StandaloneOverridden}, StandaloneFormat={item.StandaloneFormat}, MaxSize={item.StandaloneMaxSize}, ReadWrite={item.IsReadable}, SpriteMip={item.IsSprite && item.MipmapEnabled}");
if (item.TransparentRatio >= 0f)
{
EditorGUILayout.LabelField($"透明占比(采样)={item.TransparentRatio:P1}");
}
else if (!string.IsNullOrEmpty(item.TransparentCheckNote))
{
EditorGUILayout.LabelField($"透明检测:{item.TransparentCheckNote}");
}
if (!string.IsNullOrEmpty(item.TrimCandidateReason))
{
EditorGUILayout.LabelField($"裁切候选说明:{item.TrimCandidateReason}");
}
EditorGUILayout.EndVertical();
}
private bool FilterBySearch(TextureOptimizeItem item)
{
if (_showOnlySelected && !item.Selected)
{
return false;
}
if (_showOnlyTrimCandidates && !item.IsTrimCandidate)
{
return false;
}
if (string.IsNullOrWhiteSpace(_search))
{
return true;
}
return item.AssetPath.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0 ||
item.IssueText.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0 ||
item.TrimCandidateReason.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0;
}
private List<TextureOptimizeItem> GetFilteredItems()
{
string key = $"{_search}|{_showOnlySelected}|{_showOnlyTrimCandidates}";
if (_cachedItemsVersion == _itemsVersion && string.Equals(_cachedFilterKey, key, StringComparison.Ordinal))
{
return _filteredCache;
}
_filteredCache.Clear();
for (int i = 0; i < _items.Count; i++)
{
if (FilterBySearch(_items[i]))
{
_filteredCache.Add(_items[i]);
}
}
_cachedFilterKey = key;
_cachedItemsVersion = _itemsVersion;
return _filteredCache;
}
private void InvalidateFilterCache()
{
_cachedItemsVersion = -1;
}
private TextureOptimizeItem GetSelectedItem()
{
if (string.IsNullOrEmpty(_selectedAssetPath))
{
return null;
}
for (int i = 0; i < _items.Count; i++)
{
if (string.Equals(_items[i].AssetPath, _selectedAssetPath, StringComparison.Ordinal))
{
return _items[i];
}
}
return null;
}
private void ScanTextures()
{
if (string.IsNullOrWhiteSpace(_scanFolder) || !AssetDatabase.IsValidFolder(_scanFolder))
{
EditorUtility.DisplayDialog("扫描失败", $"无效目录:{_scanFolder}", "确定");
return;
}
_isScanning = true;
_items.Clear();
_selectedAssetPath = string.Empty;
_pageIndex = 0;
try
{
var guids = AssetDatabase.FindAssets("t:Texture2D", new[] { _scanFolder });
_lastScannedCount = guids.Length;
for (int i = 0; i < guids.Length; i++)
{
if (EditorUtility.DisplayCancelableProgressBar("扫描贴图", $"处理中 {i + 1}/{guids.Length}", (float)i / Mathf.Max(1, guids.Length)))
{
break;
}
var assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null)
{
continue;
}
var item = BuildItem(assetPath, importer);
if (_scanOnlyIssueItems && item.Issues == TextureOptimizeIssue.None)
{
continue;
}
_items.Add(item);
}
_items.Sort((a, b) =>
{
int issueCompare = b.Issues.CompareTo(a.Issues);
if (issueCompare != 0) return issueCompare;
return b.EstimatedRawMB.CompareTo(a.EstimatedRawMB);
});
if (_items.Count > 0)
{
_selectedAssetPath = _items[0].AssetPath;
}
_itemsVersion++;
InvalidateFilterCache();
}
finally
{
_isScanning = false;
EditorUtility.ClearProgressBar();
}
}
private TextureOptimizeItem BuildItem(string assetPath, TextureImporter importer)
{
var item = new TextureOptimizeItem();
item.AssetPath = assetPath;
item.FileExtension = Path.GetExtension(assetPath).ToLowerInvariant();
item.HasAlpha = importer.DoesSourceTextureHaveAlpha();
item.TextureCompression = importer.textureCompression;
item.IsReadable = importer.isReadable;
item.IsSprite = importer.textureType == TextureImporterType.Sprite;
item.MipmapEnabled = importer.mipmapEnabled;
TryGetSourceDimensions(importer, assetPath, out item.SourceWidth, out item.SourceHeight);
item.EstimatedRawMB = (item.SourceWidth * item.SourceHeight * 4f) / (1024f * 1024f);
var fullPath = Path.GetFullPath(assetPath);
if (File.Exists(fullPath))
{
item.FileSizeBytes = new FileInfo(fullPath).Length;
item.FileSizeMB = item.FileSizeBytes / (1024f * 1024f);
}
var standalone = importer.GetPlatformTextureSettings(StandalonePlatformName);
item.StandaloneOverridden = standalone.overridden;
item.StandaloneFormat = standalone.format;
item.StandaloneMaxSize = standalone.maxTextureSize;
var issues = TextureOptimizeIssue.None;
if (item.TextureCompression == TextureImporterCompression.Uncompressed ||
(standalone.overridden && IsUncompressedFormat(standalone.format)))
{
issues |= TextureOptimizeIssue.CompressionRisk;
}
int maxDim = Mathf.Max(item.SourceWidth, item.SourceHeight);
float megaPixels = (item.SourceWidth * item.SourceHeight) / 1000000f;
if (maxDim >= _warnMaxDimension)
{
issues |= TextureOptimizeIssue.TooLarge;
}
if (megaPixels >= _warnMaxMegaPixels)
{
issues |= TextureOptimizeIssue.TooManyPixels;
}
if (!standalone.overridden && item.FileSizeMB >= _warnMinFileSizeMBForNoOverride)
{
issues |= TextureOptimizeIssue.NoStandaloneOverride;
}
if (_warnReadWriteEnabled && item.IsReadable)
{
issues |= TextureOptimizeIssue.ReadWriteEnabled;
}
if (_warnSpriteMipmapEnabled && item.IsSprite && item.MipmapEnabled)
{
issues |= TextureOptimizeIssue.SpriteMipmapEnabled;
}
if (_checkTransparentRatio && item.HasAlpha && maxDim >= _transparentCheckMinDimension)
{
if (TryEstimateTransparentRatio(assetPath, _transparentAlphaCutoff, out float ratio, out string note))
{
item.TransparentRatio = ratio;
if (ratio >= _warnTransparentRatio)
{
issues |= TextureOptimizeIssue.TooTransparent;
}
}
else
{
item.TransparentCheckNote = note;
}
}
item.Issues = issues;
item.IssueText = BuildIssueText(issues);
item.IsTrimCandidate = IsAutoTrimCandidate(item, out string trimReason);
item.TrimCandidateReason = trimReason;
return item;
}
private static void TryGetSourceDimensions(TextureImporter importer, string assetPath, out int width, out int height)
{
width = 0;
height = 0;
// 兼容不同 Unity 版本:反射调用 GetSourceTextureWidthAndHeight
var method = typeof(TextureImporter).GetMethod("GetSourceTextureWidthAndHeight",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (method != null)
{
object[] args = { 0, 0 };
method.Invoke(importer, args);
width = (int)args[0];
height = (int)args[1];
if (width > 0 && height > 0) return;
}
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
if (tex != null)
{
width = tex.width;
height = tex.height;
}
}
private static bool IsUncompressedFormat(TextureImporterFormat format)
{
// 使用字符串兜底,避免不同 Unity 版本枚举不一致造成编译风险
string name = format.ToString();
return name.IndexOf("RGBA32", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("ARGB32", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("RGB24", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("Alpha8", StringComparison.OrdinalIgnoreCase) >= 0 ||
string.Equals(name, "R8", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "R16", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "RG16", StringComparison.OrdinalIgnoreCase);
}
private bool IsAutoTrimCandidate(TextureOptimizeItem item, out string reason)
{
if (!string.Equals(item.FileExtension, ".png", StringComparison.OrdinalIgnoreCase))
{
reason = "仅支持PNG自动裁切";
return false;
}
if (item.TransparentRatio < 0f)
{
reason = "未执行透明检测";
return false;
}
if (item.TransparentRatio < _trimCandidateTransparentRatio)
{
reason = $"透明占比<{_trimCandidateTransparentRatio:P0}";
return false;
}
if (item.EstimatedRawMB < _trimCandidateMinRawMB)
{
reason = $"估算RGBA32<{_trimCandidateMinRawMB:F1}MB";
return false;
}
reason = "高透明+大纹理,可优先裁切";
return true;
}
private static TextureImporterFormat ResolveBestFormat(string[] preferredNames)
{
for (int i = 0; i < preferredNames.Length; i++)
{
if (Enum.TryParse(preferredNames[i], true, out TextureImporterFormat format))
{
return format;
}
}
// Automatic
return (TextureImporterFormat)(-1);
}
private static bool TryEstimateTransparentRatio(string assetPath, float alphaCutoff, out float ratio, out string note)
{
ratio = -1f;
note = string.Empty;
string ext = Path.GetExtension(assetPath).ToLowerInvariant();
if (ext != ".png" && ext != ".tga" && ext != ".psd" && ext != ".tif" && ext != ".tiff")
{
note = "格式不支持透明采样";
return false;
}
string fullPath = Path.GetFullPath(assetPath);
if (!File.Exists(fullPath))
{
note = "文件不存在";
return false;
}
Texture2D tmp = null;
try
{
var bytes = File.ReadAllBytes(fullPath);
tmp = new Texture2D(2, 2, TextureFormat.RGBA32, false);
if (!tmp.LoadImage(bytes, false))
{
note = "LoadImage失败";
return false;
}
var pixels = tmp.GetPixels32();
if (pixels == null || pixels.Length == 0)
{
note = "像素读取失败";
return false;
}
int step = Mathf.Max(1, pixels.Length / 200000); // 最多采样约20万像素
int sample = 0;
int transparent = 0;
byte cutoff = (byte)Mathf.Clamp(Mathf.RoundToInt(alphaCutoff * 255f), 0, 255);
for (int i = 0; i < pixels.Length; i += step)
{
sample++;
if (pixels[i].a <= cutoff) transparent++;
}
ratio = sample > 0 ? transparent / (float)sample : 0f;
return true;
}
catch (Exception e)
{
note = e.Message;
return false;
}
finally
{
if (tmp != null)
{
DestroyImmediate(tmp);
}
}
}
private void ApplyOptimizationToSelected()
{
var selected = _items.Where(x => x.Selected).ToList();
if (selected.Count == 0)
{
EditorUtility.DisplayDialog("提示", "没有勾选任何条目。", "确定");
return;
}
int changed = 0;
try
{
for (int i = 0; i < selected.Count; i++)
{
var it = selected[i];
if (EditorUtility.DisplayCancelableProgressBar("应用贴图优化", $"处理中 {i + 1}/{selected.Count}", (float)i / selected.Count))
{
break;
}
var importer = AssetImporter.GetAtPath(it.AssetPath) as TextureImporter;
if (importer == null) continue;
bool dirty = false;
if (_applyTextureCompression && importer.textureCompression != _targetTextureCompression)
{
importer.textureCompression = _targetTextureCompression;
dirty = true;
}
if (_applyDisableReadWrite && importer.isReadable)
{
importer.isReadable = false;
dirty = true;
}
if (_applyDisableSpriteMipmap && importer.textureType == TextureImporterType.Sprite && importer.mipmapEnabled)
{
importer.mipmapEnabled = false;
dirty = true;
}
if (_applyStandaloneOverride || _applyFormatByAlpha || _applyMaxSize || _applyCrunch || _applyCompressionQuality)
{
var ps = importer.GetPlatformTextureSettings(StandalonePlatformName);
ps.name = StandalonePlatformName;
if (_applyStandaloneOverride && !ps.overridden)
{
ps.overridden = true;
dirty = true;
}
if (_applyMaxSize)
{
int newMax = _targetMaxSize;
if (_applyOnlyShrinkMaxSize && ps.maxTextureSize > 0)
{
newMax = Mathf.Min(ps.maxTextureSize, _targetMaxSize);
}
newMax = Mathf.Clamp(newMax, 32, 8192);
if (ps.maxTextureSize != newMax)
{
ps.maxTextureSize = newMax;
dirty = true;
}
}
if (_applyFormatByAlpha)
{
var target = importer.DoesSourceTextureHaveAlpha() ? _targetFormatAlpha : _targetFormatOpaque;
if (ps.format != target)
{
ps.format = target;
dirty = true;
}
}
if (_applyCrunch && ps.crunchedCompression != _targetEnableCrunch)
{
ps.crunchedCompression = _targetEnableCrunch;
dirty = true;
}
if (_applyCompressionQuality)
{
int q = Mathf.Clamp(_targetCompressionQuality, 0, 100);
if (ps.compressionQuality != q)
{
ps.compressionQuality = q;
dirty = true;
}
}
if (dirty)
{
importer.SetPlatformTextureSettings(ps);
}
}
if (dirty)
{
importer.SaveAndReimport();
changed++;
}
}
}
finally
{
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
}
EditorUtility.DisplayDialog("完成", $"已更新 {changed} 个贴图导入设置。", "确定");
ScanTextures();
}
private void SelectTrimCandidates()
{
int count = 0;
for (int i = 0; i < _items.Count; i++)
{
_items[i].IsTrimCandidate = IsAutoTrimCandidate(_items[i], out string reason);
_items[i].TrimCandidateReason = reason;
_items[i].Selected = _items[i].IsTrimCandidate;
if (_items[i].Selected) count++;
}
InvalidateFilterCache();
EditorUtility.DisplayDialog("完成", $"已勾选 {count} 个高透明大图候选。", "确定");
}
private void TrimTransparentBordersForSelected()
{
var selected = _items.Where(x => x.Selected).ToList();
if (selected.Count == 0)
{
EditorUtility.DisplayDialog("提示", "没有勾选任何条目。", "确定");
return;
}
if (!EditorUtility.DisplayDialog(
"确认自动裁切",
$"将尝试对 {selected.Count} 张已勾选贴图执行透明边裁切仅PNG。\n" +
"这会改动源图片尺寸可能影响UI布局/锚点。\n" +
"建议先处理一小批验证。",
"继续", "取消"))
{
return;
}
int processed = 0;
int trimmed = 0;
int skipped = 0;
long savedBytes = 0;
var logs = new List<string>();
try
{
for (int i = 0; i < selected.Count; i++)
{
var item = selected[i];
if (EditorUtility.DisplayCancelableProgressBar("自动裁切透明边", $"处理中 {i + 1}/{selected.Count}", (float)i / selected.Count))
{
break;
}
processed++;
if (TryTrimPngTransparentBorder(item.AssetPath, _trimAlphaCutoff, _trimCreateBackup, _trimSkipSpriteMultiple,
out long beforeBytes, out long afterBytes, out string message))
{
trimmed++;
savedBytes += Math.Max(0, beforeBytes - afterBytes);
}
else
{
skipped++;
logs.Add($"{item.AssetPath} -> {message}");
}
}
}
finally
{
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
}
if (logs.Count > 0)
{
Debug.LogWarning("[ResourceTextureOptimizerWindow] 部分贴图未裁切:\n" + string.Join("\n", logs.Take(30)));
}
EditorUtility.DisplayDialog(
"裁切完成",
$"处理: {processed}\n成功裁切: {trimmed}\n跳过/失败: {skipped}\n磁盘减少约: {savedBytes / (1024f * 1024f):F2}MB",
"确定");
ScanTextures();
}
private static bool TryTrimPngTransparentBorder(
string assetPath,
float alphaCutoff,
bool createBackup,
bool skipSpriteMultiple,
out long beforeBytes,
out long afterBytes,
out string message)
{
beforeBytes = 0;
afterBytes = 0;
message = string.Empty;
if (!assetPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
message = "仅支持PNG";
return false;
}
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null)
{
message = "TextureImporter为空";
return false;
}
if (skipSpriteMultiple &&
importer.textureType == TextureImporterType.Sprite &&
importer.spriteImportMode == SpriteImportMode.Multiple)
{
message = "SpriteMultiple已跳过";
return false;
}
string fullPath = Path.GetFullPath(assetPath);
if (!File.Exists(fullPath))
{
message = "源文件不存在";
return false;
}
byte[] srcBytes = File.ReadAllBytes(fullPath);
beforeBytes = srcBytes.Length;
Texture2D srcTex = null;
Texture2D dstTex = null;
try
{
srcTex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
if (!srcTex.LoadImage(srcBytes, false))
{
message = "LoadImage失败";
return false;
}
int width = srcTex.width;
int height = srcTex.height;
var pixels = srcTex.GetPixels32();
if (pixels == null || pixels.Length == 0)
{
message = "读取像素失败";
return false;
}
byte cutoff = (byte)Mathf.Clamp(Mathf.RoundToInt(alphaCutoff * 255f), 0, 255);
int minX = width;
int minY = height;
int maxX = -1;
int maxY = -1;
for (int y = 0; y < height; y++)
{
int row = y * width;
for (int x = 0; x < width; x++)
{
if (pixels[row + x].a > cutoff)
{
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX || maxY < minY)
{
message = "全透明,跳过";
return false;
}
if (minX == 0 && minY == 0 && maxX == width - 1 && maxY == height - 1)
{
message = "无可裁切透明边";
return false;
}
int newWidth = maxX - minX + 1;
int newHeight = maxY - minY + 1;
var cropped = srcTex.GetPixels(minX, minY, newWidth, newHeight);
dstTex = new Texture2D(newWidth, newHeight, TextureFormat.RGBA32, false);
dstTex.SetPixels(cropped);
dstTex.Apply(false, false);
byte[] dstBytes = dstTex.EncodeToPNG();
afterBytes = dstBytes.Length;
if (createBackup)
{
string backupPath = fullPath + ".trimbak";
if (!File.Exists(backupPath))
{
File.Copy(fullPath, backupPath, false);
}
}
File.WriteAllBytes(fullPath, dstBytes);
if (importer.textureType == TextureImporterType.Sprite &&
importer.spriteImportMode == SpriteImportMode.Single)
{
Vector2 oldPivot = importer.spritePivot;
float oldPivotX = oldPivot.x * width;
float oldPivotY = oldPivot.y * height;
float newPivotX = Mathf.Clamp(oldPivotX - minX, 0, newWidth);
float newPivotY = Mathf.Clamp(oldPivotY - minY, 0, newHeight);
var tis = new TextureImporterSettings();
importer.ReadTextureSettings(tis);
tis.spriteAlignment = (int)SpriteAlignment.Custom;
importer.SetTextureSettings(tis);
importer.spritePivot = new Vector2(newPivotX / newWidth, newPivotY / newHeight);
}
importer.SaveAndReimport();
message = $"裁切成功 {width}x{height} -> {newWidth}x{newHeight}";
return true;
}
catch (Exception e)
{
message = e.Message;
return false;
}
finally
{
if (srcTex != null) DestroyImmediate(srcTex);
if (dstTex != null) DestroyImmediate(dstTex);
}
}
private static string BuildIssueText(TextureOptimizeIssue issues)
{
if (issues == TextureOptimizeIssue.None) return "无";
var list = new List<string>();
if ((issues & TextureOptimizeIssue.CompressionRisk) != 0) list.Add("压缩格式风险");
if ((issues & TextureOptimizeIssue.TooLarge) != 0) list.Add("最长边超阈值");
if ((issues & TextureOptimizeIssue.TooManyPixels) != 0) list.Add("像素总量过大");
if ((issues & TextureOptimizeIssue.TooTransparent) != 0) list.Add("透明区域过多");
if ((issues & TextureOptimizeIssue.ReadWriteEnabled) != 0) list.Add("Read/Write开启");
if ((issues & TextureOptimizeIssue.SpriteMipmapEnabled) != 0) list.Add("Sprite开MipMap");
if ((issues & TextureOptimizeIssue.NoStandaloneOverride) != 0) list.Add("未覆盖Standalone平台");
return string.Join(" | ", list);
}
private void SetSelection(bool value)
{
foreach (var item in _items) item.Selected = value;
InvalidateFilterCache();
}
private void InvertSelection()
{
foreach (var item in _items) item.Selected = !item.Selected;
InvalidateFilterCache();
}
private void SelectCheckedInProject()
{
var objs = new List<UnityEngine.Object>();
foreach (var item in _items.Where(x => x.Selected))
{
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(item.AssetPath);
if (obj != null) objs.Add(obj);
}
Selection.objects = objs.ToArray();
}
private void ExportCsv()
{
if (_items.Count == 0)
{
EditorUtility.DisplayDialog("提示", "当前没有可导出的结果。", "确定");
return;
}
string defaultName = $"TextureOptimizeReport_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
string path = EditorUtility.SaveFilePanel("导出CSV", Application.dataPath, defaultName, "csv");
if (string.IsNullOrEmpty(path))
{
return;
}
var sb = new StringBuilder();
sb.AppendLine("Selected,AssetPath,Width,Height,FileSizeMB,EstimatedRawMB,HasAlpha,TransparentRatio,TextureCompression,StandaloneOverridden,StandaloneFormat,StandaloneMaxSize,Readable,SpriteMipmap,Issues");
foreach (var item in _items)
{
string transparent = item.TransparentRatio >= 0f ? item.TransparentRatio.ToString("F4", CultureInfo.InvariantCulture) : "";
sb.Append(item.Selected ? "1" : "0").Append(",");
sb.Append(Csv(item.AssetPath)).Append(",");
sb.Append(item.SourceWidth).Append(",");
sb.Append(item.SourceHeight).Append(",");
sb.Append(item.FileSizeMB.ToString("F3", CultureInfo.InvariantCulture)).Append(",");
sb.Append(item.EstimatedRawMB.ToString("F3", CultureInfo.InvariantCulture)).Append(",");
sb.Append(item.HasAlpha ? "1" : "0").Append(",");
sb.Append(transparent).Append(",");
sb.Append(item.TextureCompression).Append(",");
sb.Append(item.StandaloneOverridden ? "1" : "0").Append(",");
sb.Append(item.StandaloneFormat).Append(",");
sb.Append(item.StandaloneMaxSize).Append(",");
sb.Append(item.IsReadable ? "1" : "0").Append(",");
sb.Append(item.IsSprite && item.MipmapEnabled ? "1" : "0").Append(",");
sb.Append(Csv(item.IssueText)).AppendLine();
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
Debug.Log($"[ResourceTextureOptimizerWindow] Report exported: {path}");
EditorUtility.RevealInFinder(path);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}