1. 从一次包体超限说起:为什么我要自己写一套 Unity 编辑器扩展
上个月帮朋友收拾一个 2D 项目,提审时包体卡在 268MB,硬生生超了二十多兆。美术说能砍的都砍了,程序说图集早就打过了。我把工程拉下来翻了两小时,发现真正浪费的地方跟"资源够不够"关系不大——两百多张 UI 图还挂着默认的无压缩 TrueColor,三张 4096 的大图集全开着 mipmap,模型自带的待机动画每帧都有独立关键帧,一根都没删。
这三个问题,恰恰对应包体优化里性价比最高的三件事:图片压缩、图集与图集变体的批量生成、动画压缩。它们有个共同特点——手工改很烦,但规则高度统一,完全适合交给一套 Unity编辑器扩展 去批量处理。于是我花了两天写了三个工具模块,把它们塞进同一个编辑器窗口,之后的项目里反复复用,每次都能稳稳拿下 10% 到 30% 的包体收益。
这篇文章就是这套工具的完整拆解。我会讲清楚每个模块背后的 API 是怎么用的、参数为什么这么定、哪些地方是我想当然结果被打脸的。内容偏向已经写过一点 UnityEditor 脚本、但没系统做过资源批处理的同学;如果你只会点点 Inspector,也能照着抄,因为所有代码都是可以直接贴进Editor目录跑起来的完整片段。
先把结论摆前面:包体优化这件事,规则比工具重要,验收比优化重要。我见过太多人一键压完图,心里爽了,然后发现低端机上 UI 全糊了、某台安卓机上图集直接变黑。所以下面每个环节我都会带上"压完之后怎么确认没压坏"这一步。
2. 图片压缩:吃透 TextureImporter 的平台覆盖机制
2.1 TextureImporter 里真正影响包体的只有几个字段
Unity 里所有纹理的导入设置,最终都落在TextureImporter上。这个类字段多到眼花,但真正决定包体大小的其实只有一小撮。我把它们分成两类:一类是通用设置,一类是平台覆盖设置。
通用设置里最关键的:
| 字段 | 作用 | 包体影响 |
|---|---|---|
textureType | 纹理类型(Sprite / Default / NormalMap) | 决定是否走图集、是否能压缩 |
maxTextureSize | 最大边长,超出会降采样 | 直接决定像素总量,收益最大 |
mipmapEnabled | 是否生成 mipmap 链 | 开启后纹理数据增加约 1/3 |
crunchedCompression | 是否用 crunched 压缩 | 磁盘变小的主要来源之一 |
isReadable | 是否保留 CPU 侧副本 | 不影响包体,但吃运行内存 |
alphaIsTransparency | 是否按透明通道处理 | 影响压缩格式选择与边缘发黑 |
平台覆盖设置(TextureImporterPlatformSettings)才是大头。默认情况下,一个纹理在 Android、iOS、WebGL 上如果没被 override,就会退回到通用设置,而通用设置里的格式通常是Automatic——Unity 会自己猜,猜出来的结果经常是 TrueColor,也就是几乎不压缩。这就是我朋友项目里那两百张图的问题来源。
正确做法是显式给每个目标平台指定格式。常见的选型如下:
| 平台 | 推荐格式 | 适用场景 | 备注 |
|---|---|---|---|
| Android | ASTC 6x6 | 通用 UI、道具图标 | 6x6 是画质和体积的平衡点 |
| Android | ASTC 8x8 | 背景图、大尺寸低细节图 | 体积更小,色块边缘可能糊 |
| Android | ASTC 4x4 | 高精度角色贴图 | 体积明显变大,慎用 |
| iOS | ASTC 6x6 | 与 Android 保持一致 | A 系列芯片全支持 |
| WebGL | ASTC 6x6 + ETC2 兜底 | 需要兼容老浏览器 | 具体看目标浏览器支持度 |
| Standalone | DXT5 / DXT1 | 桌面端 | 有 alpha 用 DXT5,无 alpha 用 DXT1 |
这里有个我踩过的坑:ASTC 的块尺寸不是越小越好,也不是越大越省。8x8 相比 6x6 体积确实能再降约 44%,但 UI 上那些 1 像素描边、文字图标会直接糊成一团。我后来定的规矩是:图标类一律 6x6,纯色背景和渐变遮罩才敢用 8x8。
2.2 用路径规则自动匹配压缩策略
手工一张张选格式不现实。我的做法是维护一张规则表,用资源路径关键字去匹配。规则表是个普通数组,改起来比写代码舒服得多。
using System.Collections.Generic; using UnityEditor; using UnityEngine; [System.Serializable] public class TextureRule { public string PathKeyword; // 路径里包含这个关键字就命中 public int MaxSize; public TextureImporterFormat AndroidFormat; public TextureImporterFormat IosFormat; public TextureImporterFormat WebGLFormat; public bool Crunch; public bool Mipmap; public bool ForceAlpha; } public static class TextureCompressTool { static readonly List<TextureRule> Rules = new List<TextureRule> { new TextureRule { PathKeyword = "UI/Atlas", MaxSize = 2048, AndroidFormat = TextureImporterFormat.ASTC_6x6, IosFormat = TextureImporterFormat.ASTC_6x6, WebGLFormat = TextureImporterFormat.ASTC_6x6, Crunch = true, Mipmap = false }, new TextureRule { PathKeyword = "UI/Background", MaxSize = 2048, AndroidFormat = TextureImporterFormat.ASTC_8x8, IosFormat = TextureImporterFormat.ASTC_8x8, WebGLFormat = TextureImporterFormat.ASTC_8x8, Crunch = true, Mipmap = false }, new TextureRule { PathKeyword = "Character", MaxSize = 1024, AndroidFormat = TextureImporterFormat.ASTC_6x6, IosFormat = TextureImporterFormat.ASTC_6x6, WebGLFormat = TextureImporterFormat.ASTC_6x6, Crunch = false, Mipmap = true }, }; [MenuItem("Tools/PackOptimize/压缩选中目录下的图片")] public static void CompressSelection() { var folders = new List<string>(); foreach (var obj in Selection.objects) { var path = AssetDatabase.GetAssetPath(obj); if (AssetDatabase.IsValidFolder(path)) folders.Add(path); } if (folders.Count == 0) { Debug.LogWarning("请先选中至少一个文件夹"); return; } var guids = AssetDatabase.FindAssets("t:Texture2D", folders.ToArray()); AssetDatabase.StartAssetEditing(); try { int index = 0; foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); if (EditorUtility.DisplayCancelableProgressBar("图片压缩", path, (float)index / guids.Length)) break; index++; ApplyTo(path); } } finally { AssetDatabase.StopAssetEditing(); AssetDatabase.Refresh(); EditorUtility.ClearProgressBar(); } } }AssetDatabase.StartAssetEditing()这个调用非常关键。它会把中间的导入操作攒起来,等StopAssetEditing()之后一次性刷新。我实测在两千张图的规模下,比逐张SaveAndReimport()快了将近四倍。代价是中途抛异常会留下脏状态,所以必须放在try/finally里。
规则命中的逻辑我简化成"第一个命中的规则胜出",这样规则表的顺序就代表优先级。实际项目里如果你有更复杂的判断需求,比如按图集名字而不是路径,可以把PathKeyword扩展成正则或者委托,但我建议别过度设计,路径约定本身就是一种廉价的规范。
2.3 单张处理函数里的几个必要动作
真正改 importer 的函数不长,但每一步都有理由:
static void ApplyTo(string path) { var importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) return; var rule = MatchRule(path); bool hasAlpha = rule.ForceAlpha || importer.DoesSourceTextureHaveAlpha(); importer.textureType = TextureImporterType.Sprite; importer.maxTextureSize = rule.MaxSize; importer.mipmapEnabled = rule.Mipmap; importer.alphaIsTransparency = hasAlpha; importer.npotScale = TextureImporterNPOTScale.ToNearest; importer.wrapMode = TextureWrapMode.Clamp; importer.filterMode = FilterMode.Bilinear; importer.isReadable = false; importer.crunchedCompression = rule.Crunch; importer.compressionQuality = rule.Crunch ? 50 : 100; SetPlatform(importer, "Android", rule.AndroidFormat, rule.MaxSize); SetPlatform(importer, "iPhone", rule.IosFormat, rule.MaxSize); SetPlatform(importer, "WebGL", rule.WebGLFormat, rule.MaxSize); SetPlatform(importer, "Standalone", hasAlpha ? TextureImporterFormat.DXT5 : TextureImporterFormat.DXT1, rule.MaxSize); EditorUtility.SetDirty(importer); importer.SaveAndReimport(); } static void SetPlatform(TextureImporter importer, string platform, TextureImporterFormat format, int maxSize) { var s = importer.GetPlatformTextureSettings(platform); s.overridden = true; s.format = format; s.maxTextureSize = maxSize; s.compressionQuality = 100; importer.SetPlatformTextureSettings(s); }DoesSourceTextureHaveAlpha()是我很推荐的一个调用。它会真的去读一遍源图判断有没有透明像素,比"sprite 就一定带 alpha"这种拍脑袋判断准确得多。UI 里大量按钮其实是纯不透明图,硬按 DXT5 走就是白白浪费一半体积。
npotScale = ToNearest是为了避免非 2 的幂尺寸纹理在压缩时被异常处理。2D 图集场景下这条不算重,但如果项目里还有散图在用,建议保留。
compressionQuality在开启 crunched 时我一般设 50。crunched 压缩本质是在常规块压缩之上再做一层类 JPEG 的有损处理,质量参数越低越省磁盘但会在渐变色上出现块状伪影。50 是我试过一圈之后觉得在 UI 上基本看不出来的位置。
2.4 压完之后怎么确认没压坏
这一步最容易被跳过。我现在的固定流程是三条:
- 批处理结束后自动生成 CSV 报告,列出每张图的平台格式、最大尺寸、压缩前后磁盘大小,按收益从高到低排序。看一眼就知道有没有漏网的。
- 抽查三张:一张带渐变的背景、一张带细描边的图标、一张带半透明的遮罩,在 Game 视图里放大到 200% 肉眼过一遍。
- 真机跑一遍最低端机型,重点看有没有图集渲染成纯黑的。这个后面讲图集变体时会再说。
提示:crunched 压缩会明显增加首次解码耗时。如果项目里有大量"进入战斗瞬间加载几百张 UI"的场景,建议对战斗用图集关掉 crunched,用普通块压缩换加载速度。
3. 批量生成图集与图集变体:SpriteAtlas 的自动化装配
3.1 图集的组织约定比代码更重要
写代码之前得先定约定,否则工具会变成一堆需要手工输入的参数。我固定的目录结构是这样:
Assets/Art/UI/Atlas/ ├── Common/ -> 生成 Common.spriteatlas │ ├── btn_common.png │ └── icon_common.png ├── Battle/ -> 生成 Battle.spriteatlas └── Login/ -> 生成 Login.spriteatlas规则简单到不用解释:Atlas 目录下的一级子文件夹,各生成一张同名图集。这条约定带来两个好处,一是美术新增素材时天然知道往哪放,二是工具完全不需要配置文件。
图集能不能合并,我一般按这几条判断:
- 同一时间出现在同一界面上的图放一起,减少 Draw Call。
- 生命周期接近的放一起,避免为了加载一个小图标把一整张战斗图集拉进内存。
- 单张图集控制在 2048x2048 以内,低端设备上 4096 的图集在某些机型的纹理尺寸上限会直接翻车。
3.2 用 SpriteAtlasExtensions 装配图集
SpriteAtlas的运行时类在UnityEngine.U2D命名空间,编辑器侧的扩展方法在UnityEditor.U2D里,通过SpriteAtlasExtensions提供。配置项分三块:打包设置、纹理设置、平台设置。
using System.Linq; using UnityEditor; using UnityEditor.U2D; using UnityEngine; using UnityEngine.U2D; public static class AtlasBuilder { public static SpriteAtlas CreateAtlas(string folder) { var atlasName = System.IO.Path.GetFileName(folder); var atlasPath = $"{folder}/{atlasName}.spriteatlas"; var atlas = new SpriteAtlas { name = atlasName }; // 1. 打包设置 atlas.SetPackingSettings(new SpriteAtlasPackingSettings { padding = 4, // 图与图之间的留白,防止采样溢出 blockOffset = 1, enableRotation = false, // 旋转打包会破坏 UI 的九宫格语义 enableTightPacking = false, // 2D UI 关闭紧密打包,避免 mesh 顶点数暴涨 }); // 2. 纹理设置 atlas.SetTextureSettings(new SpriteAtlasTextureSettings { readable = false, generateMipMaps = false, sRGB = true, filterMode = FilterMode.Bilinear, anisoLevel = 1, }); // 3. 平台设置 var platform = new SpriteAtlasPlatformSettings(); platform.SetPlatformSettings("Android", TextureImporterFormat.ASTC_6x6, 2048, TextureImporterCompression.Compressed); platform.SetPlatformSettings("iPhone", TextureImporterFormat.ASTC_6x6, 2048, TextureImporterCompression.Compressed); atlas.SetPlatformSettings(platform); // 4. 收集 packables var guids = AssetDatabase.FindAssets("t:Sprite", new[] { folder }); var packables = guids .Select(AssetDatabase.GUIDToAssetPath) .Where(p => !p.EndsWith(".spriteatlas")) .Select(AssetDatabase.LoadAssetAtPath<Sprite>) .Where(s => s != null) .Cast<Object>() .ToArray(); atlas.Add(packables); AssetDatabase.CreateAsset(atlas, atlasPath); AssetDatabase.SaveAssets(); return atlas; } }几个参数值得单独说:
padding = 4是防止相邻图采样的溢出。2D 图集默认的 Bilinear 采样会往相邻像素借颜色,padding 太小会在图边缘出现一条来自隔壁图的杂色。我一开始用 2,在某些斜切旋转的 UI 上出现过细蓝边,改到 4 就没了。代价是图集面积利用率略降,但这点浪费换稳定很值。
enableTightPacking = false对 2D UI 特别重要。紧密打包会把透明区域裁掉,看起来省空间,但生成的 mesh 顶点数会飙升,UI 上的 quad 从 4 个顶点涨到几十个很常见。UI 量一大,顶点数就上去了,反而得不偿失。
enableRotation = false也是同理。旋转打包在 3D 场景的贴图集里是好事,但 UI 的九宫格拉伸一旦遇到旋转过的 sprite,拉伸方向就错了。这个坑我在一个弹窗背景上撞过,图集一打,背景拉伸得一塌糊涂。
关于写资产这一步,我要给个诚实的提醒:AssetDatabase.CreateAsset(atlas, atlasPath)在多数版本上能直接生成.spriteatlas资产,但不同版本对这个 API 的支持不完全一致。如果你跑下来报类型不匹配,改成先建空资产、再用SpriteAtlasAsset.Load(atlasPath)拿到 atlasAsset 走SpriteAtlasAsset.Save组合即可。我自己在 2021 和 2022 两个大版本上都验证过CreateAsset这条路,暂时没出问题。
3.3 图集变体:不是简单地把图缩小一半
图集变体(Sprite Atlas Variant)是很多人知道概念但没真正用起来的功能。它的机制是:变体图集不单独维护自己的资源列表,而是引用一张 master 图集,然后按一个缩放系数在打包时生成一张整体缩放后的纹理。
这对多档位设备特别有用。比如高配机用原尺寸图集(2048),中低配机用 0.5 倍变体(1024),内存占用直接降到四分之一。运行时通过SpriteAtlasManager.atlasRequested事件配合资源加载系统动态换用不同变体,UGUI 里的 Sprite 引用不需要改。
创建变体的代码很短:
public static SpriteAtlas CreateVariant(string masterPath, string variantPath, float scale) { var master = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(masterPath); if (master == null) { Debug.LogError($"找不到 master 图集:{masterPath}"); return null; } var variant = new SpriteAtlas { name = System.IO.Path.GetFileNameWithoutExtension(variantPath) }; variant.SetPackingSettings(new SpriteAtlasPackingSettings { padding = 2, blockOffset = 1, enableRotation = false, enableTightPacking = false, }); variant.SetTextureSettings(new SpriteAtlasTextureSettings { readable = false, generateMipMaps = false, sRGB = true, filterMode = FilterMode.Bilinear, anisoLevel = 1, }); variant.SetIsVariant(true); variant.SetMasterAtlas(master); variant.SetVariantScale(scale); AssetDatabase.CreateAsset(variant, variantPath); AssetDatabase.SaveAssets(); return variant; }这里有几个必须知道的限制,都是我在实际项目里撞出来的:
第一,变体不拥有自己的 packables。变体的内容完全由 master 决定,你往变体里Add任何东西都不会生效。所以素材增减只需要维护 master,变体会自动跟随。
第二,变体分辨率仍然受 maxTextureSize 约束。如果 master 是 2048,变体 scale 是 0.5,结果就是 1024,符合预期。但如果 master 打包后实际只有 1024,变体 scale 0.5 得到 512,这时候 UI 上的文字图标会糊得非常明显。所以变体 scale 的选取要基于 master 的实际打包尺寸,而不是源图尺寸。
第三,变体和 Repeat 模式的交互很差。如果图集里有一张平铺的贴图,变体缩放后会出现接缝。我的处理方式是:所有平铺类素材一律不放进图集,单独走散图。
第四,变体要有明确的加载策略。变体本身不会自动生效,你得在运行时决定加载哪一个。我一般把变体放进 Addressables 的一个 label 组,游戏启动时读取设备内存等级,再决定请求哪一组图集。变体图集应该把 "Include in Build" 关掉,避免和 Addressables 的加载重复。
3.4 图集打包的三个高频坑
第一个坑是Read/Write Enabled。图集纹理的readable我固定设false。设成 true 会在内存里多留一份 CPU 可见的副本,对 2D 项目来说毫无必要,纯粹浪费内存。
第二个坑是sRGB。UI 图集的 sRGB 要跟着项目色彩空间走。如果项目用的是 Linear 色彩空间而图集 sRGB 设错,UI 颜色会整体偏亮或偏暗。这个问题的麻烦之处在于它不报错,只是"看着有点奇怪",很容易被忽略。
第三个坑是图集拆分粒度太粗。我见过一个项目把整个游戏的 UI 塞进两张图集,结果打开登录界面就要把包含战斗 UI 的整张图集加载进内存。图集本身省了 Draw Call,但把内存压力全推给了加载阶段。合理的做法是图集数量控制在 10 到 20 张之间,按界面模块拆。
4. 动画压缩:从导入设置到逐曲线精简
4.1 先分清两种动画资源,处理方式完全不同
动画压缩这块,最容易被忽略的前提是:模型内嵌动画和独立 .anim 资产的处理路径完全不同。
模型内嵌的动画(.fbx里的 clip)在 Unity 里是只读的,你没法直接改曲线数据,只能通过ModelImporter在导入时施加压缩。独立.anim资产则可以逐条曲线去改。所以工具必须能识别这两种情况,走不同分支。
对 FBX 内嵌动画,核心是这几个属性:
var importer = AssetImporter.GetAtPath(fbxPath) as ModelImporter; importer.importAnimation = true; importer.animationCompression = ModelImporterAnimationCompression.Optimal; importer.animationPositionError = 0.5f; // 位置误差容忍度 importer.animationRotationError = 0.5f; // 旋转误差容忍度 importer.animationScaleError = 0.5f; // 缩放误差容忍度 importer.optimizeGameObjects = true; // 精简骨骼层级 importer.SaveAndReimport();animationCompression有三个值,我一般这样选:
| 压缩模式 | 机制 | 我的使用场景 |
|---|---|---|
Off | 不压缩,保留全部关键帧 | 需要逐帧精确的表演动画,极少用 |
KeyframeReduction | 只做关键帧剔除 | 需要保留原始曲线形态的动画 |
Optimal | 关键帧剔除 + 曲线拟合 | 绝大多数常规动画 |
Optimal在底层做的事情,是把关键帧剔除之后再做一层曲线近似,用更少的采样点拟合出接近原始形态的曲线。对角色待机、走路这类循环动画,压缩率通常在 50% 到 80% 之间,画质损失很难用肉眼分辨。
三个误差容忍度的单位分别是米、度、倍。默认值都是 0.5,实际上有点保守。我的经验值:
- 位置误差 0.1 到 1.0,看角色尺寸。一个身高 1.8 米的角色,0.2 米的位置误差在远景下完全看不出来。
- 旋转误差 0.5 到 2.0 度。这个可以放得比想象中宽,1 度的旋转误差在快速动作里几乎不可见。
- 缩放误差 0.5 一般够用,缩放动画本来就少。
optimizeGameObjects = true这条要谨慎。它会把没有被动画引用的骨骼节点从层级里剔除,能显著减少 Transform 数量,但如果代码里通过transform.Find("Bip001/Spine")这类路径去取骨骼挂点,就会直接找不到。如果项目里有挂点需求,要么用extraExposedTransformPaths显式暴露,要么老老实实关掉。
4.2 逐曲线精简:给 .anim 资产做减法
对独立.anim资产,我实现了一个基于误差累积的简化算法。思路很直白:从第一个关键帧出发,尝试跳过后续关键帧,只要被跳过的帧其实际值与该区间线性插值的偏差都在容忍度之内,就继续往后跳;一旦超过容忍度,就保留当前帧作为新的锚点。
using System.Collections.Generic; using UnityEditor; using UnityEngine; public static class AnimationCompressor { public static int Reduce(AnimationClip clip, float positionTol = 0.05f, float rotationTol = 0.5f, float scaleTol = 0.01f, float floatTol = 0.1f) { int removed = 0; var bindings = AnimationUtility.GetCurveBindings(clip); foreach (var binding in bindings) { var curve = AnimationUtility.GetEditorCurve(clip, binding); if (curve == null || curve.length <= 2) continue; float tol = PickTolerance(binding.propertyName, positionTol, rotationTol, scaleTol, floatTol); var simplified = Simplify(curve, tol); if (simplified.length >= curve.length) continue; removed += curve.length - simplified.length; AnimationUtility.SetEditorCurve(clip, binding, simplified); } if (removed > 0) { clip.EnsureQuaternionContinuity(); EditorUtility.SetDirty(clip); } return removed; } static float PickTolerance(string propertyName, float posTol, float rotTol, float scaleTol, float floatTol) { if (propertyName.StartsWith("m_LocalPosition")) return posTol; if (propertyName.StartsWith("m_LocalRotation")) return rotTol; if (propertyName.StartsWith("m_LocalScale")) return scaleTol; return floatTol; // 材质参数、BlendShape 等 } static AnimationCurve Simplify(AnimationCurve curve, float tol) { var keys = curve.keys; var kept = new List<Keyframe> { keys[0] }; int anchor = 0; for (int i = 1; i < keys.Length - 1; i++) { if (!WithinTolerance(keys, anchor, i, tol)) { kept.Add(keys[i]); anchor = i; } } kept.Add(keys[keys.Length - 1]); var result = new AnimationCurve(kept.ToArray()); for (int i = 0; i < result.length; i++) { AnimationUtility.SetKeyLeftTangentMode(result, i, AnimationUtility.TangentMode.Auto); AnimationUtility.SetKeyRightTangentMode(result, i, AnimationUtility.TangentMode.Auto); } return result; } static bool WithinTolerance(Keyframe[] keys, int anchor, int end, float tol) { float t0 = keys[anchor].time, t1 = keys[end].time; if (Mathf.Approximately(t1, t0)) return false; for (int i = anchor + 1; i < end; i++) { float t = (keys[i].time - t0) / (t1 - t0); float approx = Mathf.Lerp(keys[anchor].value, keys[end].value, t); if (Mathf.Abs(approx - keys[i].value) > tol) return false; } return true; } }EnsureQuaternionContinuity()这一行是必须的。四元数曲线在编辑之后可能出现符号翻转,导致角色在播放时突然"闪一下"或者朝反方向拧。这个坑非常隐蔽,因为出问题的是插值路径而不是关键帧本身,光看关键帧列表完全看不出来。
SetKeyLeftTangentMode/SetKeyRightTangentMode设成 Auto 是为了让 Unity 重新计算切线。如果你保留了原关键帧的切线数据,简化后的曲线在保留帧之间会出现明显的"拐弯",看起来像卡顿。
4.3 误差阈值怎么定:从画质反推
阈值定得太松,动作品质会崩;定得太紧,压缩率上不去。我的做法是从最小的可感知位移反推。
假设游戏镜头最近距离角色 3 米,屏幕高度对应现实世界的 2 米,纵向 1080 像素。那么 1 像素对应 2 / 1080 ≈ 0.00185 米。人能察觉到的位移抖动大约是 2 到 3 像素,也就是 0.004 到 0.006 米。位置误差阈值取这个量级就已经足够保守了,我在实际项目里常用 0.01 到 0.05,效果肉眼无差别。
旋转误差同理。角色头部直径按 0.3 米算,1 度的旋转在头部表面产生的位移约 0.3 × π / 180 ≈ 0.005 米,跟上面算出来的可感知位移同量级。所以 0.5 到 1 度的旋转误差也足够。
我实测的压缩率参考:
| 动画类型 | 原始关键帧数 | 位置误差 0.05 / 旋转误差 0.5 | 体积变化 |
|---|---|---|---|
| 角色待机(2秒循环) | 每帧一帧,约 60 帧 | 剩 8 到 12 帧 | 降约 80% |
| 角色跑步(1秒循环) | 约 30 帧 | 剩 10 到 15 帧 | 降约 60% |
| UI 弹窗缩放 | 约 20 帧 | 剩 4 到 6 帧 | 降约 75% |
| 相机推拉 | 约 120 帧 | 剩 10 到 20 帧 | 降约 85% |
循环动画还有个额外注意点:简化之后要重新确认首尾帧是否一致。如果第一个和最后一个关键帧的误差超了容忍度被剔掉,循环时就会出现"跳帧"。我的处理是在简化前先把首尾帧强制保留,上面代码里的keys[0]和keys[keys.Length - 1]就是这个作用。
5. 把三个模块装进一个窗口:编辑器 UI 的组织方式
5.1 用 EditorWindow 搭一个批处理面板
三个功能分散在菜单里点来点去很烦,我把它们收进一个EditorWindow,用 IMGUI 画(IMGUI 虽然老,但胜在写起来快,不想为了一个内部工具去搭 UI Toolkit 的 UXML)。
using UnityEditor; using UnityEngine; public class PackOptimizeWindow : EditorWindow { bool doCompress = true; bool doAtlas = true; bool doAnim = true; float posTol = 0.05f, rotTol = 0.5f, scaleTol = 0.01f; [MenuItem("Tools/PackOptimize/打开优化面板")] static void Open() => GetWindow<PackOptimizeWindow>("包体优化"); void OnGUI() { EditorGUILayout.LabelField("1. 图片压缩", EditorStyles.boldLabel); doCompress = EditorGUILayout.ToggleLeft("批量压缩选中目录下的纹理", doCompress); if (GUILayout.Button("执行图片压缩")) TextureCompressTool.CompressSelection(); EditorGUILayout.Space(8); EditorGUILayout.LabelField("2. 图集生成", EditorStyles.boldLabel); doAtlas = EditorGUILayout.ToggleLeft("按 UI/Atlas 子目录批量生成图集", doAtlas); if (GUILayout.Button("执行图集生成")) AtlasBatchRunner.RunAll(); EditorGUILayout.Space(8); EditorGUILayout.LabelField("3. 动画压缩", EditorStyles.boldLabel); doAnim = EditorGUILayout.ToggleLeft("精简选中目录下的 .anim 曲线", doAnim); posTol = EditorGUILayout.Slider("位置误差(米)", posTol, 0.001f, 1f); rotTol = EditorGUILayout.Slider("旋转误差(度)", rotTol, 0.01f, 5f); scaleTol = EditorGUILayout.Slider("缩放误差(倍)", scaleTol, 0.001f, 0.5f); if (GUILayout.Button("执行动画压缩")) AnimationBatchRunner.RunSelection(posTol, rotTol, scaleTol); EditorGUILayout.Space(12); if (GUILayout.Button("一键全流程", GUILayout.Height(32))) RunAll(); } }界面刻意做得很平,因为这类工具的使用者是程序自己,不需要漂亮,需要的是参数可见、按钮明确、执行有反馈。滑块调的阈值直接对应压缩强度,比藏在代码里的常量直观得多。
5.2 批量执行时的进度、取消与安全边界
批处理最怕两件事:一是跑到一半不知道卡在哪,二是不小心点了取消留下半改状态。
进度我用EditorUtility.DisplayCancelableProgressBar,注意它的返回值是"用户是否点了取消"。我的习惯是每处理一个资源检查一次,一旦取消就跳出循环,让finally里的清理逻辑正常跑完。千万别把取消直接做成throw,那样StopAssetEditing可能执行不到,AssetDatabase 会一直卡在批处理状态里,之后所有导入操作都不生效,只能重启编辑器。
public static void RunAll() { bool hasChanges = false; AssetDatabase.StartAssetEditing(); try { hasChanges |= TextureCompressTool.ApplyAll(); hasChanges |= AtlasBatchRunner.RunAll(); hasChanges |= AnimationBatchRunner.RunAll(0.05f, 0.5f, 0.01f); } catch (System.Exception e) { Debug.LogError($"批处理中断:{e.Message}"); } finally { AssetDatabase.StopAssetEditing(); if (hasChanges) AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); EditorUtility.ClearProgressBar(); } }注意:不要在看
git status有一堆改动的时候跑批处理。这套工具会改动大量资源的导入设置,一旦跑出问题想回滚,你会分不清哪些改动是工具的、哪些是自己的。建议先提交一次,跑完再对比。
另一个安全边界是排除掉不该动的目录。我的规则里硬编码了一份黑名单,插件目录、StreamingAssets、Editor 默认资源全部跳过。特别是第三方插件目录,里面很多纹理依赖默认导入设置,乱改会直接让插件报错。
5.3 报告落盘:优化前后的差异必须能看见
没有报告的优化等于没做。我在每个模块结束时都会写一份 CSV 到工程根目录的PackOptimizeReports/下,文件名带时间戳。
static void WriteReport(string name, System.Text.StringBuilder sb) { var dir = System.IO.Path.Combine( System.IO.Directory.GetParent(Application.dataPath).FullName, "PackOptimizeReports"); System.IO.Directory.CreateDirectory(dir); var file = System.IO.Path.Combine(dir, $"{name}_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv"); System.IO.File.WriteAllText(file, sb.ToString(), System.Text.Encoding.UTF8); Debug.Log($"报告已生成:{file}"); }CSV 里我固定写这几列:资源路径、优化前格式、优化后格式、优化前最大尺寸、优化后最大尺寸、优化前磁盘大小、优化后磁盘大小、收益百分比。用 Excel 打开按收益排序,一眼就能看出哪条规则收益最高、哪条规则几乎没用。
图片那部分的磁盘大小我通过new System.IO.FileInfo(path).Length拿源文件大小来近似对比。注意这不是最终包体里的实际大小,只是源文件级别的近似。真想看准确数值,得对比打出来的 AssetBundle,成本高很多。实际工作里源文件级别的对比已经足够指导决策。
6. 实测收益与几个必须记住的坑
6.1 一个 2D 项目的真实数据
我在一个中轻度 2D 项目上完整跑了一遍,数据如下(工程约 1800 张纹理、14 张图集、约 640 条独立动画):
| 优化项 | 处理前 | 处理后 | 变化 |
|---|---|---|---|
| 纹理资产总量 | 214 MB | 78 MB | 降 63.5% |
| 图集数量 | 9 张(人工维护) | 14 张(自动生成) | 数量增加但内存峰值降低 |
| 动画资产总量 | 46 MB | 11 MB | 降 76.1% |
| 整体包体 | 268 MB | 183 MB | 降 31.7% |
纹理那一项的收益主要来自两个动作:把Automatic改成显式 ASTC 6x6,以及关掉 mipmap。其中 mipmap 一项就贡献了大约 20% 的体积下降,因为 UI 图集压根不需要 mipmap。动画那一项的收益几乎全部来自关键帧精简,ModelImporter的 Optimal 压缩只贡献了不到三成。
还有个附带收益:图集数量从 9 张变成 14 张之后,登录场景的内存峰值从 92MB 降到了 61MB,因为进登录界面时不用再把战斗 UI 一起拉进来。这是拆分粒度带来的,跟压缩无关,但收益比压缩还明显。
6.2 我踩过的几个坑,写下来给你省时间
第一个,图集变体加载顺序。变体图集要靠SpriteAtlasManager.atlasRequested事件在运行时替换。如果事件注册晚了,UI 会先用 master 图集渲染一次,然后再被换掉,表现是界面闪一下。我的做法是在游戏启动最早期、任何 UI 创建之前就注册好事件,并且把变体资源预加载完成之后再打开第一个界面。
第二个,crunched 压缩和 WebGL 的关系。WebGL 平台的纹理压缩格式支持跟浏览器和显卡驱动有关。我在一个 WebGL 小项目上开了 crunched,某些浏览器上出现纹理错位。后来改成 WebGL 平台用非 crunched 的 ASTC/ETC2,问题消失。WebGL 项目的纹理策略我建议单独维护一套,不要跟移动端共用。
第三个,动画精简不能对共享 clip 反复跑。我第一次写的时候没做幂等处理,结果一键全流程按钮被点了两次,曲线被精简了两轮,第二次是在已经线性化的曲线基础上再简化,动作直接僵成了机器人。解决办法很简单:跑之前先判断 clip 的AssetImporter.userData里有没有打过标记,或者干脆加一个二次确认弹窗。
第四个,SaveAndReimport的频率。单张SaveAndReimport在几百张图的时候还能忍,到两千张就是十几分钟。必须走StartAssetEditing。但StartAssetEditing期间AssetDatabase.LoadAssetAtPath拿到的是旧对象,如果你在处理过程中依赖重新加载资源来判断状态,会读到脏数据。这个行为在官方文档里只有一句话,踩的时候挺懵。
第五个,别忽略真机灰度。所有压缩相关的改动,最终都要在真机上过一遍。我在编辑器里看过无数遍都没问题的图集,到某台国产安卓机上因为 ASTC 支持不完整直接渲染成纯黑。工具里可以做的一件事是自动给 Android 平台设置 ETC2 兜底格式,但要不要开、开了之后体积涨多少,需要项目自己权衡。
6.3 接到 CI 里跑
这套工具最后我是接到了构建流程里。Unity 支持命令行执行编辑器静态方法:
Unity.exe -quit -batchmode -nographics \ -projectPath "D:/Project" \ -executeMethod PackOptimize.BatchEntry.RunAll \ -logFile "D:/Project/BuildLogs/optimize.log"BatchEntry.RunAll是个静态方法,内部调用前面的三个模块,最后检查报告文件里的收益是否低于阈值,低于就返回非零退出码,让 CI 直接把这次构建标红。这样美术提交素材之后,如果误传了一张 4096 的未压缩贴图,下一次构建就会被拦住,不用等到提审才发现。
要提醒的一点是-nographics模式下某些依赖图形设备的操作会失败,比如EditorUtility.DisplayCancelableProgressBar就没意义了。批处理入口里应该把这部分逻辑去掉,改成纯日志输出,否则在 CI 上会报一堆无关的警告干扰排查。
推进一步的话,可以把PackOptimizeReports里的 CSV 上传到构建系统做趋势图,连续几个版本的纹理体积曲线一看就知道有没有人在偷偷塞大图。这个链路我搭过一次,投入不大,但确实让"包体又涨了"这种扯皮少了很多。