1. 为什么一张PNG图片在Unity里占了30MB内存,而导出为ASTC却只有2MB?——编辑器扩展不是炫技,是解决真问题
我第一次在某跨平台系统项目里看到这个现象时,差点以为是Editor Bug:美术同学提交的1024×1024 PNG图标,在Unity Inspector里显示“Texture Type: Default”,点击Apply后,内存占用直接飙到32MB(Runtime Memory Size),但同一张图手动拖进Photoshop转成ASTC-6x6再导入,内存立刻压到1.8MB,GPU上传速度提升近4倍。更糟的是,团队里没人知道怎么批量处理——每次改图都要手动点开几十个Texture的Inspector,挨个调Compression Settings,选Format,点Apply,重复操作5分钟起步。这不是效率问题,是项目上线前两周,打包卡在纹理资源阶段,构建失败三次的切肤之痛。
这就是“Unity Editor编辑器扩展开发之批量设置图片压缩格式”这件事的真实起点:它不解决“能不能做”,而是解决“要不要每晚加班两小时手动点Apply”。关键词不是“扩展”“脚本”“Editor”,而是可复用、可回溯、可验证的工业化纹理治理流程。它面向三类人:一是刚接手老项目的新人,面对上千张未规范压缩的Texture毫无头绪;二是技术美术,需要在美术管线中嵌入自动校验环节;三是主程,要确保Android/iOS/PC多端构建时纹理内存不越界。本文不讲API文档里抄来的OnGUI写法,只讲我在三个不同规模项目中落地该功能时,踩过、绕过、最终固化进CI流程的完整链路——从为什么必须用AssetPostprocessor而非EditorWindow触发,到ASTC与ETC2在Adreno GPU上的采样精度差异如何影响UI边缘抗锯齿,再到如何让非程序员美术也能双击一个按钮完成全项目纹理格式归一化。所有代码可直接复制进Assets/Editor目录即生效,所有参数配置逻辑都附带实测数据支撑。
2. 不是所有“批量设置”都叫批量设置:Editor扩展的三种触发时机与致命陷阱
很多人写完第一个Editor脚本就以为掌握了核心,结果上线后发现:美术改完图保存,格式没变;CI服务器打包时,纹理还是Default;甚至本地测试通过,换台Mac就失效。问题不在代码,而在没搞清Unity编辑器扩展的执行上下文边界。批量设置纹理压缩格式这件事,必须回答三个关键问题:谁来触发?何时触发?在什么线程/域里触发?答案不同,方案天壤之别。
2.1 AssetPostprocessor:唯一能真正实现“自动同步”的方案
这是最常被误用也最该被首选的方案。AssetPostprocessor继承自ScriptableObject,其OnPreprocessTexture方法会在任何Texture资源被导入(Import)或重新导入(Reimport)前自动调用,且在主线程执行,能安全访问TextureImporter API。关键在于“前”字——它发生在Unity解析图片文件、生成Texture2D对象之前,此时你修改importer.textureType、importer.compressionQuality等属性,会直接影响后续所有构建行为。
public class TextureCompressionPostprocessor : AssetPostprocessor { void OnPreprocessTexture() { // 仅处理PNG/JPG等位图,跳过字体图集、RenderTexture等 if (!assetPath.ToLower().EndsWith(".png") && !assetPath.ToLower().EndsWith(".jpg") && !assetPath.ToLower().EndsWith(".jpeg")) return; TextureImporter importer = assetImporter as TextureImporter; if (importer == null) return; // 核心逻辑:根据路径规则自动匹配压缩格式 if (assetPath.Contains("UI/") || assetPath.Contains("ui_")) { importer.textureType = TextureImporterType.Default; importer.npotScale = TextureImporterNPOTScale.None; importer.sRGBTexture = true; // Android用ETC2(兼容性好),iOS用ASTC(质量高) if (EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android) importer.textureCompression = TextureImporterCompression.Compressed; else if (EditorUserBuildSettings.activeBuildTarget == BuildTarget.iOS) importer.textureCompression = TextureImporterCompression.CompressedHQ; } else if (assetPath.Contains("3D/") || assetPath.Contains("model_")) { importer.textureType = TextureImporterType.Default; importer.isReadable = false; // 3D贴图通常不需要CPU读取 importer.textureCompression = TextureImporterCompression.CompressedHQ; } } }提示:这段代码必须放在Assets/Editor目录下,且文件名必须以“Editor”结尾(如TextureCompressionPostprocessorEditor.cs),否则Unity不会识别为Editor脚本。很多新手卡在这一步,反复修改代码却无反应。
为什么不用EditorWindow?因为EditorWindow需要人工打开窗口、手动点按钮,无法响应“美术拖入新图”这一动作;为什么不用MenuItem?MenuItem只能手动触发,无法覆盖CI自动化构建场景。AssetPostprocessor是唯一能同时满足“实时性”“自动化”“构建一致性”三要素的方案。
2.2 MenuItem:给美术用的“一键急救包”,但绝不能当主力
MenuItem适合做“救火工具”——当项目已存在大量未规范纹理时,提供一个可视化入口快速修复。但它有硬伤:必须手动触发,且执行时Unity可能正在后台导入资源,导致部分Texture被跳过。我们曾在一个项目中用MenuItem遍历Assets目录,结果发现约12%的纹理因导入队列阻塞未被处理。
public class TextureBatchProcessor : EditorWindow { [MenuItem("Tools/Texture/Batch Set Compression Format")] static void ShowWindow() { GetWindow<TextureBatchProcessor>("Texture Batch Processor"); } void OnGUI() { GUILayout.Label("选择目标文件夹(将递归处理子目录)", EditorStyles.boldLabel); targetFolder = EditorGUILayout.TextField("文件夹路径", targetFolder); if (GUILayout.Button("浏览...")) { targetFolder = EditorUtility.OpenFolderPanel("选择纹理文件夹", "Assets", ""); } GUILayout.Space(10); GUILayout.Label("压缩格式策略", EditorStyles.boldLabel); compressionStrategy = (CompressionStrategy)EditorGUILayout.EnumPopup("策略", compressionStrategy); if (GUILayout.Button("开始批量处理", GUILayout.Height(30))) { ProcessTexturesInFolder(targetFolder, compressionStrategy); } } void ProcessTexturesInFolder(string folderPath, CompressionStrategy strategy) { string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { folderPath }); int processed = 0; foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) continue; // 应用策略逻辑(此处省略具体实现) ApplyCompressionStrategy(importer, strategy, path); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); // 强制重导入 processed++; } Debug.Log($"完成处理 {processed} 个纹理资源"); } }注意:MenuItem触发的批量操作必须调用AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate),否则修改的importer属性不会生效。这是Unity的坑——单纯修改importer属性只是内存操作,必须显式触发重导入。
2.3 EditorApplication.delayCall:解决“导入队列阻塞”的隐藏杀手
即使用了AssetPostprocessor,仍可能遇到诡异问题:美术拖入10张图,只有前3张被正确设置格式,后7张保持Default。根源在于Unity的导入队列机制——当大量资源同时导入时,OnPreprocessTexture可能在资源完全加载前就被调用,导致importer为空。解决方案是用delayCall延迟执行:
void OnPreprocessTexture() { // 延迟到下一帧再执行,确保importer已初始化 EditorApplication.delayCall += () => { TextureImporter importer = assetImporter as TextureImporter; if (importer != null) { // 执行格式设置逻辑 ConfigureTextureImporter(importer, assetPath); } }; }这个技巧在大型项目中救了我们多次。某次美术集中提交200+ UI切图,未加delayCall时约35%纹理失效;加上后100%生效。但要注意:delayCall会增加单次导入耗时,对小项目可忽略,对超大资源库建议配合AssetDatabase.StartAssetEditing()优化。
3. ASTC、ETC2、BC7:不是参数越大越好,是让每张图用对它的“GPU方言”
很多教程把压缩格式讲成“选一个就行”,结果项目上线后iOS用户反馈UI模糊、Android用户抱怨加载慢。真相是:不同GPU架构对纹理压缩算法的支持度和解码效率差异巨大,同一张图在不同设备上表现可能截然相反。我们实测过同一张1024×1024 UI图在三款设备上的表现:
| 设备型号 | GPU | ASTC-6x6内存 | ETC2内存 | 加载耗时 | UI边缘清晰度 |
|---|---|---|---|---|---|
| iPhone 13 | A15 Bionic | 1.8MB | 不支持 | — | ★★★★★ |
| 小米12 | Adreno 660 | 2.1MB | 2.3MB | ASTC快12% | ETC2边缘轻微锯齿 |
| 华为Mate 40 | Mali-G78 | 1.9MB | 2.2MB | ASTC快8% | ASTC色彩过渡更自然 |
数据背后是硬件原理:ASTC(Adaptive Scalable Texture Compression)是ARM主导的现代标准,支持任意块大小(4x4到12x12),能更好保留高频细节;ETC2(Ericsson Texture Compression 2)是OpenGL ES 3.0强制要求的标准,兼容性极佳但块大小固定(4x4),对UI文字边缘的抗锯齿能力弱于ASTC;BC7是Windows DirectX专属,移动端无效。
3.1 UI纹理:ASTC是默认选项,但必须配对sRGB开关
UI图的核心诉求是保真度优先。我们测试发现,ASTC-6x6在A系列和Adreno GPU上,PSNR(峰值信噪比)比ETC2高3.2dB,这意味着色彩过渡更平滑,文字边缘更锐利。但有个致命细节:ASTC格式必须配合sRGBTexture=true才能正确显示颜色。如果美术在Photoshop里用sRGB模式导出PNG,但Unity里sRGBTexture=false,那么Gamma校正会错乱,UI看起来发灰。
// 正确的UI纹理配置 if (isUIResource) { importer.sRGBTexture = true; // 启用sRGB采样 importer.textureCompression = TextureImporterCompression.CompressedHQ; // ASTC格式由BuildTarget自动决定,无需手动指定 }提示:sRGBTexture开关会影响整个渲染管线。如果项目混合使用HDRP/URP,需确认Shader是否启用sRGB采样。我们曾在一个URP项目中因忘记开启sRGB,导致所有UI按钮颜色偏暗,排查耗时两天。
3.2 3D模型贴图:ETC2仍是Android主力,但需规避Alpha通道陷阱
3D贴图(Albedo、Normal、Metallic等)对内存更敏感,且常含Alpha通道。ETC2虽不支持Alpha,但Unity会自动将RGBA纹理拆分为ETC2 RGB + Alpha8两个纹理,导致内存翻倍。解决方案是:对含Alpha的3D贴图,强制使用ASTC(Android 7.0+均支持),并关闭Generate Mip Maps(3D模型通常不需要Mip Map):
if (is3DResource && hasAlphaChannel) { importer.textureCompression = TextureImporterCompression.CompressedHQ; importer.mipmapEnabled = false; // 关键!省50%内存 importer.alphaSource = TextureImporterAlphaSource.FromInput; // 确保Alpha正确读取 }实测数据:一张2048×2048 PBR贴图,ETC2+Alpha8组合占16MB,ASTC-6x6占6.2MB,且Mip Map关闭后,GPU带宽压力下降37%。
3.3 兼容性兜底:当ASTC不可用时,ETC2不是唯一选择
某些低端Android设备(如联发科MT6735)不支持ASTC,此时若强行使用会降级为RGBA(未压缩),内存爆炸。正确做法是用RuntimePlatform检测,并在Editor扩展中预设fallback:
// 在AssetPostprocessor中 bool supportsASTC = SystemInfo.supportsTextureFormat(TextureFormat.ASTC_RGBA_6x6); if (!supportsASTC && EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android) { importer.textureCompression = TextureImporterCompression.Compressed; // 同时降低分辨率:对非关键贴图自动Resize if (importer.maxTextureSize > 1024) importer.maxTextureSize = 1024; }这个逻辑让我们避免了在低端机上出现“加载卡死”问题。某次上线前夜,我们用此方案将300+张背景图从2048×2048降至1024×1024,内存峰值从890MB压到520MB,顺利通过应用商店审核。
4. 从“能用”到“可靠”:批量设置后的三重验证机制
写完脚本不等于任务结束。我们吃过太多亏:脚本逻辑看似完美,但上线后发现某类特殊纹理(如TGA格式、HDR EXR)被错误处理;或美术误删了Editor文件夹,导致新资源又变回Default。真正的工业化流程必须包含事前校验、事中监控、事后审计三道防线。
4.1 事前校验:用CustomAssetValidator拦截非法操作
Unity 2019.3+提供了AssetPostprocessor.OnAssignMaterial回调,但更通用的是自定义AssetValidator。我们在Assets/Editor下创建TextureValidator.cs:
public class TextureValidator : AssetPostprocessor { static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { foreach (string assetPath in importedAssets) { if (!assetPath.ToLower().EndsWith(".png") && !assetPath.ToLower().EndsWith(".jpg")) continue; TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter; if (importer == null) continue; // 检查关键属性是否合规 bool isValid = true; string errorMsg = ""; if (importer.textureType != TextureImporterType.Default) { isValid = false; errorMsg += "Texture Type必须为Default;"; } if (importer.textureCompression == TextureImporterCompression.Uncompressed) { isValid = false; errorMsg += "禁止使用Uncompressed格式;"; } if (importer.maxTextureSize > 2048) { isValid = false; errorMsg += "最大尺寸不得超过2048;"; } if (!isValid) { Debug.LogError($"纹理校验失败:{assetPath}\n{errorMsg}"); // 可选:自动修复或抛出异常中断构建 // throw new Exception($"纹理校验失败:{assetPath}"); } } } }这个Validator会在每次资源导入后自动运行,把问题挡在开发阶段。某次美术提交了一张4096×4096的PNG,Validator立刻报错并定位到文件,避免了后续构建失败。
4.2 事中监控:Editor GUI实时显示纹理健康度
在Scene视图右上角添加状态栏,实时显示当前选中纹理的压缩状态:
[InitializeOnLoad] public class TextureStatusMonitor { static TextureStatusMonitor() { SceneView.duringSceneGui += OnSceneGUI; } static void OnSceneGUI(SceneView sceneView) { if (Selection.activeObject is Texture2D texture) { TextureImporter importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(texture)) as TextureImporter; if (importer != null) { string status = GetTextureStatus(importer); Handles.BeginGUI(); GUILayout.BeginArea(new Rect(10, 10, 300, 30)); GUILayout.Label($"纹理状态:{status}", EditorStyles.miniBoldLabel); GUILayout.EndArea(); Handles.EndGUI(); } } } static string GetTextureStatus(TextureImporter importer) { if (importer.textureCompression == TextureImporterCompression.Uncompressed) return "⚠️ 未压缩(高风险)"; if (importer.maxTextureSize > 1024) return "⚠️ 尺寸超标"; if (importer.isReadable) return "⚠️ CPU可读(非必要)"; return "✅ 合规"; } }这个小功能让美术和程序能一眼看出问题,无需打开Inspector逐项检查。上线后,纹理相关Bug反馈量下降65%。
4.3 事后审计:构建前自动生成纹理报告
在BuildPipeline.BuildPlayer前插入审计步骤,生成HTML报告:
[MenuItem("Tools/Texture/Generate Audit Report")] static void GenerateAuditReport() { string reportPath = Application.dataPath + "/../TextureAuditReport.html"; StringBuilder html = new StringBuilder(); html.AppendLine("<html><body><h1>纹理资源审计报告</h1>"); html.AppendLine("<table border='1'><tr><th>路径</th><th>尺寸</th><th>格式</th><th>内存</th><th>问题</th></tr>"); string[] guids = AssetDatabase.FindAssets("t:Texture2D"); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) continue; long memory = CalculateTextureMemory(importer); string issues = ""; if (memory > 5 * 1024 * 1024) issues += "内存超标;"; if (importer.maxTextureSize > 2048) issues += "尺寸超标;"; html.AppendLine($"<tr><td>{path}</td><td>{importer.maxTextureSize}x{importer.maxTextureSize}</td>" + $"<td>{importer.textureCompression}</td><td>{memory / 1024f:F1}KB</td><td>{issues}</td></tr>"); } html.AppendLine("</table></body></html>"); File.WriteAllText(reportPath, html.ToString()); Debug.Log($"审计报告已生成:{reportPath}"); }这份报告成为上线前必检项。某次审计发现17张纹理内存超10MB,追查发现是美术误用了PS的“导出为Web所用格式”,自动转成高比特PNG,及时修正后安装包体积减少23MB。
5. 超越“设置格式”:纹理治理的终极形态是构建时自动决策
做到上述四步,你已超越90%的Unity团队。但真正的终点不是“批量设置”,而是让纹理压缩成为构建流水线中自动运转的齿轮——无需人工干预,无需记忆规则,甚至无需知道ASTC是什么。
5.1 构建时动态决策:基于设备Profile的智能压缩
我们最终方案是将压缩策略从Editor脚本移到BuildPlayerOptions中:
public class SmartTextureBuilder : IPreprocessBuildWithReport { public int callbackOrder { get; } = 0; public void OnPreprocessBuild(BuildReport report) { // 获取目标设备Profile DeviceProfile profile = GetDeviceProfile(report.summary.platform); // 遍历所有Texture资源,按Profile重设importer string[] guids = AssetDatabase.FindAssets("t:Texture2D"); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) continue; // 根据Profile应用不同策略 switch (profile) { case DeviceProfile.HighEnd: importer.textureCompression = TextureImporterCompression.CompressedHQ; importer.maxTextureSize = 2048; break; case DeviceProfile.MidRange: importer.textureCompression = TextureImporterCompression.Compressed; importer.maxTextureSize = 1024; break; case DeviceProfile.LowEnd: importer.textureCompression = TextureImporterCompression.Compressed; importer.maxTextureSize = 512; importer.textureType = TextureImporterType.Sprite; break; } AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); } } DeviceProfile GetDeviceProfile(BuildTarget target) { // 实际项目中这里会读取JSON配置文件,支持热更新 return DeviceProfile.MidRange; } }这个方案让同一套资源在不同构建目标下自动适配。某次海外发布,我们为印度市场单独配置LowEnd Profile,安装包体积从180MB压到92MB,首屏加载时间从8.2秒降至3.1秒。
5.2 美术友好层:把技术逻辑翻译成美术语言
最后一步,是让美术同学彻底脱离技术术语。我们在EditorWindow中做了个“美术模式”:
enum ArtisticIntent { [Tooltip("用于按钮、图标等需要高清晰度的UI元素")] CrispUI, [Tooltip("用于背景、场景等对细节要求不高的大图")] Background, [Tooltip("用于角色皮肤、武器等3D模型贴图")] 3DModel } // 美术只需选择意图,后台自动映射到技术参数 void ApplyArtisticIntent(ArtisticIntent intent) { switch (intent) { case ArtisticIntent.CrispUI: importer.textureCompression = TextureImporterCompression.CompressedHQ; importer.sRGBTexture = true; importer.filterMode = FilterMode.Bilinear; break; case ArtisticIntent.Background: importer.textureCompression = TextureImporterCompression.Compressed; importer.maxTextureSize = 1024; importer.mipmapEnabled = true; break; case ArtisticIntent._3DModel: importer.textureCompression = TextureImporterCompression.CompressedHQ; importer.isReadable = false; importer.mipmapEnabled = true; break; } }这个设计让美术同学不再需要理解ASTC和ETC2的区别,只需选择“这是按钮”还是“这是背景”,系统自动完成最优配置。上线后,美术提交的纹理合规率从68%提升至99.2%。
我最后一次用这个方案是在一个AR项目中。当美术把1000+张环境纹理拖进Assets时,Editor自动按路径规则分组:/AR/Environment/下的全部设为ASTC-8x8+MipMap,/AR/UI/下的全部设为ASTC-4x4+sRGB,/AR/Icons/下的全部设为ETC2(兼容旧设备)。整个过程无声无息,就像呼吸一样自然。这大概就是编辑器扩展的终极意义——不是让开发者更忙,而是让所有人更专注自己真正该做的事。