
做 Unity 项目做久了包体这件事迟早会找上门来。我手上这个项目从最初的 200 多兆一路涨到接近 900 兆渠道那边开始卡审核玩家下载转化率也在掉这时候才回头去翻资源才发现——图片、图集、动画这三块几乎把整个包体吃干净了。手工在 Inspector 里一张张改纹理设置四百多张贴图改到第三天我就放弃了。所以后来花了大概两周时间写了一套跑在 Unity 编辑器里的批量优化扩展把图片压缩、图集与图集变体生成、动画压缩这三件事全流程自动化。这套东西的核心逻辑不复杂用TextureImporter批量改写导入设置、用SpriteAtlasExtensions批量建图集和变体、用ModelImporter加曲线关键帧剔除来处理动画最后配上一套体积核对面板改完前后一对比就知道省了多少。适合谁看吃过包体亏的中小型团队、做微信小游戏和移动端发布的朋友以及刚接手资源管理这块活儿、不知道从哪下手的同学都能直接抄走。1. 为什么包体优化要优先动图片、图集和动画1.1 三类资源在包体里的真实占比先把账算清楚。我拿自己项目做过一次归类统计把一个 870MB 的 Android 包按资源类型拆开结果是这样的纹理Texture2D 加 SpriteAtlas占了大概 61%动画AnimationClip、模型内的动画数据占 14%音频 8%Shader 和材质 5%剩下的才是代码、场景、预制体、字体这些。也就是说只要把纹理和动画这两块压下去包体基本就稳了。纹理之所以这么重根子在于默认导入格式太奢侈。一张 1024×1024 的图如果按 RGBA32 存一像素 4 字节算下来正好 4MB。你要是有 200 张这样的图光这一项就是 800MB。换成 ASTC 6x6 呢每像素约 3.56 bit同尺寸只要约 456KB直接压到原来的九分之一。就算是保守一点的 ETC2_RGBA88bpp也只要 1MB是原来的四分之一。这个量级的差距靠美术出图出小一点是补不回来的必须在导入环节动手。动画这边的情况稍微隐蔽一点。很多人以为动画就是几 KB 的曲线数据实际上一个带 60 根骨骼、跑 3 秒的角色动作如果关键帧不做任何精简每条曲线每秒 30 帧采样位置、旋转、缩放加起来接近 200 条曲线算下来单条动画的原始数据能到 1MB 上下。一个角色十几二十个动作再加一堆 NPC 和特效动画十几个 G 的原始数据压进包里也不是没可能。1.2 手动改为什么撑不住编辑器扩展的价值在哪有人会说Unity 的 Inspector 不是能改吗挨个改一遍不就行了。我试过问题是三个。第一是量。四百张纹理每张要改 textureType、maxTextureSize、纹理压缩方式、Android 和 iOS 两个平台的覆盖设置、mipmap 开关、read/write 开关平均一张点七八次还要小心别点错。这不是效率问题是出错概率问题——改错一张线上可能就是一片糊。第二是重复劳动。每次美术加一批新图、每次换项目的默认压缩参数、每次切到另一个渠道包都得再来一遍。人肉流程没法复用。第三是图集和动画根本没法手工批量做。图集要用 SpriteAtlas 资源几十个 UI 模块就得建几十个图集还得配变体动画的曲线精简更不可能手动拖着关键帧删。编辑器扩展解决的正是这三件事。它把你应该怎么改的规则固化成配置然后一键作用于成百上千个资源还能反复执行、能对比效果、能写进 CI。这才是包体优化的正确姿势——不是一次性的猛药而是一套能长期跑、可回归的机制。2. 扩展的整体架构与工程落地方式2.1 目录结构与程序集划分这套东西我建议不要一股脑塞进 Scripts 目录按功能拆开后面维护会轻松很多。我自己的目录长这样Assets/ Editor/ ResOptimizer/ Core/ TextureOptimizer.cs AtlasBuilder.cs AtlasVariantBuilder.cs AnimationOptimizer.cs ResourceScanner.cs SizeReporter.cs Config/ OptimizeRules.asset (ScriptableObject) Window/ ResOptimizerWindow.cs ResOptimizer.Editor.asmdef关键是那个ResOptimizer.Editor.asmdef一定要建。原因有两个一是编辑器程序集不会被打进 Player避免代码污染包体二是程序集隔离以后你改这些代码不会触发整个项目的全量重编译迭代速度快得多。asmdef 里的 Platform 只勾 EditorReferences 里挂上UnityEditor.UI和Unity.2D.Sprite.Editor图集相关 API 在这个程序集里。注意图集相关的SpriteAtlasExtensions位于UnityEditor.U2D命名空间必须引用Unity.2D.Sprite.Editor程序集才能编译通过否则会报找不到类型。这个坑我在 2019 版本上踩过一次翻了半天文档。2.2 统一入口窗口与配置数据所有功能收敛到一个EditorWindow上用MenuItem挂到菜单栏[MenuItem(Tools/资源优化/包体优化面板 %#o)] static void Open() { var win GetWindowResOptimizerWindow(包体优化); win.minSize new Vector2(560, 620); win.Show(); }%#o是快捷键Ctrl/Cmd Shift O用起来顺手。窗口里我做了四个折叠区纹理规则、图集规则、动画规则、体积对比每个区下面一个预览按钮和一个执行按钮。预览只统计会改多少资源、预计能省多少不落盘执行才真正写入。配置数据我用一个 ScriptableObject 存字段大概是这样[CreateAssetMenu(menuName 资源优化/优化规则)] public class OptimizeRules : ScriptableObject { [System.Serializable] public class TextureRule { public string folder Assets/Art/Textures; public TextureImporterType textureType TextureImporterType.Default; public int maxSize 1024; public TextureImporterCompression compression TextureImporterCompression.Compressed; public TextureImporterFormat androidFormat TextureImporterFormat.ASTC_6x6; public TextureImporterFormat iosFormat TextureImporterFormat.ASTC_6x6; public TextureImporterFormat standaloneFormat TextureImporterFormat.DXT5; public bool mipmap false; public bool readable false; } public ListTextureRule textureRules new ListTextureRule(); // 图集规则、动画规则略 }用 SO 存规则的好处是它可以进版本管理团队每个人的默认参数一致新人拉下来直接用。2.3 扫描、批处理与进度反馈扫描我统一走AssetDatabase.FindAssets按类型加目录过滤public static Liststring FindAssetsT(IEnumerablestring roots) where T : Object { var filter $t:{typeof(T).Name}; var guids AssetDatabase.FindAssets(filter, roots.ToArray()); var list new Liststring(guids.Length); foreach (var g in guids) { var p AssetDatabase.GUIDToAssetPath(g); // 排除插件目录和 Packages if (p.StartsWith(Packages/)) continue; if (p.Contains(/Plugins/)) continue; list.Add(p); } return list; }批处理一定要用AssetDatabase.StartAssetEditing()/StopAssetEditing()包起来不然每改一个资源 Unity 就重新导入一次四百张贴图能让你等到下班。写法上必须带 try/finallyAssetDatabase.StartAssetEditing(); try { foreach (var item in targets) { ApplyOne(item); } } finally { AssetDatabase.StopAssetEditing(); AssetDatabase.Refresh(); }try/finally不是可选项。我见过有人图省事不写结果中间抛了个空引用异常StopAssetEditing没被调用编辑器直接卡在正在导入资源的假死状态只能删 Library 重开项目重来。进度条用EditorUtility.DisplayProgressBar但别每改一个资源就刷一次那样反而拖慢速度。我一般每处理 20 个刷一次配上一句正在处理 xxx120/430。3. 图片压缩TextureImporter 批量改写实战3.1 关键参数逐项拆解与取舍纹理优化说到底就是调TextureImporter上那几个字段但每一项背后的取舍得讲清楚不然调出来的参数是拍脑袋的。maxTextureSize是第一刀。UI 图集绝大多数情况下 2048 就够普通场景贴图 1024 甚至 512 都没问题只有主角身上的特写贴图才需要 2048 以上。要注意的是这个值不会放大图片只会把超过上限的图缩小到不超过这个值所以设小了会掉清晰度。我的经验是移动端 UI 图标类 512UI 大图 1024场景贴图 1024角色贴图 2048天空盒 2048。textureCompression有四个枚举值Uncompressed、Compressed、CompressedHQ、CompressedLQ。很多人不知道CompressedHQ会让某些平台走最高质量的压缩变体包体比Compressed大一点但画质更接近原图。UI 元素我一般用Compressed法线贴图用CompressedHQ。纹理格式是最影响包体的。Android 上优先 ASTCiOS 上也是 ASTCPC 上 DXT5 或 BC7。这里有个硬门槛Android 低端机有一部分不支持 ASTC需要设置 ETC2 回退importer.androidETC2FallbackOverride AndroidETC2FallbackOverride.UseBuildSettings;这个枚举还可以直接指定RGBA32、RGBA16、None。设成RGBA16是最省的老兼容方案虽然画质差但至少不炸内存。mipmapEnabled要分场景。UI 图集和 2D 精灵一定关掉开了只会白白多占三分之一体积而且 UI 基本是 1:1 显示mipmap 用不上。3D 场景贴图要开不然远处物体上会出现严重的闪烁噪点。isReadable关掉能省一份内存副本对包体的影响不大但配合streamingMipmaps可以显著降低加载峰值内存。crunchedCompression是个陷阱。开了之后包体确实会小一些但它用的是 Crunch 算法运行时需要 CPU 实时解压加载时间长、内存占用高而且不支持 Mipmap 的某些组合。我实测下来只有在真机上确实压力不大、又特别缺包体空间的情况下才开它一般项目不建议。3.2 平台覆盖设置与格式选择对照Unity 的纹理导入有三层默认设置、平台覆盖设置、平台回退。批量改写必须用SetPlatformTextureSettings只改默认层是没用的真机构建会走平台覆盖。各平台的格式我整理成一张表平台推荐格式位率说明Android主流机型ASTC_6x63.56 bpp兼容性与体积的平衡点Android低端兼容ETC2_RGBA88 bpp通过 fallback 覆盖Android无透明通道ETC2_RGB44 bpp不透明的图必须用这个iOSASTC_6x63.56 bppA 系列芯片全支持iOS老设备PVRTC_RGBA44 bpp现在基本不需要了PC / 主机DXT5 或 BC78 bppBC7 画质更好压得慢PC无透明DXT14 bpp体积只有 DXT5 的一半选 ASTC 块大小的时候记住一个规律块越大压得越狠、画质越差。4x4 是 8bpp几乎等于不压5x5 是 5.12bpp6x6 是 3.56bpp8x8 是 2bpp12x12 只有 0.89bpp。UI 元素文字多、边缘硬建议 6x6大面积的背景图和纯色插画可以上 8x8 甚至 10x10肉眼看不出区别。同一张图的不同用途格式也可能不同靠人工判断不现实我用文件名后缀来识别static TextureImporterFormat PickFormat(string path, string platform) { var name Path.GetFileNameWithoutExtension(path).ToLower(); bool hasAlpha name.Contains(_alpha) || name.Contains(_rgba); if (platform Android) { if (!hasAlpha) return TextureImporterFormat.ETC2_RGB4; return TextureImporterFormat.ASTC_6x6; } if (platform Standalone) return hasAlpha ? TextureImporterFormat.DXT5 : TextureImporterFormat.DXT1; return TextureImporterFormat.ASTC_6x6; }这套命名约定要提前跟美术打好招呼不然识别不出来。我们后来直接把是否带透明通道写进了出图规范。3.3 批量改写的代码骨架与注意事项核心逻辑其实很短public static int Apply(TextureRule rule) { var paths FindAssetsTexture2D(new[] { rule.folder }); int changed 0; AssetDatabase.StartAssetEditing(); try { for (int i 0; i paths.Count; i) { if (i % 20 0) { bool cancel EditorUtility.DisplayCancelableProgressBar( 图片压缩, $处理中 {i}/{paths.Count}\n{paths[i]}, (float)i / paths.Count); if (cancel) break; } var importer AssetImporter.GetAtPath(paths[i]) as TextureImporter; if (importer null) continue; importer.textureType rule.textureType; importer.maxTextureSize rule.maxSize; importer.textureCompression rule.compression; importer.mipmapEnabled rule.mipmap; importer.isReadable rule.readable; importer.npotScale TextureImporterNPOTScale.ToNearest; ApplyPlatform(importer, Android, rule.androidFormat, rule.maxSize); ApplyPlatform(importer, iPhone, rule.iosFormat, rule.maxSize); ApplyPlatform(importer, Standalone, rule.standaloneFormat, rule.maxSize); importer.SaveAndReimport(); changed; } } finally { EditorUtility.ClearProgressBar(); AssetDatabase.StopAssetEditing(); AssetDatabase.Refresh(); } return changed; } static void ApplyPlatform(TextureImporter importer, string platform, TextureImporterFormat format, int maxSize) { var settings new TextureImporterPlatformSettings { name platform, overridden true, maxTextureSize maxSize, format format, textureCompression TextureImporterCompression.Compressed, compressionQuality 50, crunchedCompression false, allPlatforms false }; importer.SetPlatformTextureSettings(settings); }这里有几个必须说的坑。提示iOS 平台在SetPlatformTextureSettings里的 name 是iPhone不是iOS。写错了不会报错设置会静默失效构建出来还是默认格式。这个坑极难发现因为编辑器里看不出来只有看构建日志或者用 AssetBundle 分析工具才能发现。提示npotScale ToNearest会把非二次幂的图缩到最近的二次幂。如果你的 UI 图本来就是按设计尺寸出的比如 200×80设成ToNearest会被强行拉到 256×128多出来的透明边会白白占图集空间。UI 图建议设TextureImporterNPOTScale.None让图集打包器去管尺寸。还有一个容易被忽略的点compressionQuality对 ASTC 和 ETC2 基本无效它主要作用于 Crunch。设了也不会有坏处但别指望调它能改变画质。改完记得跑一次AssetDatabase.Refresh()然后让你的包体统计工具重新算一遍对比一下前后差异。我一般会在日志里打出来处理 428 张贴图预计节省 213.6 MB。4. 批量生成图集与图集变体4.1 按目录规则批量建图集图集的价值不只是draw call 少更直接的是——它能把小图拼成一张大图减少纹理本身的对齐浪费。一张 100×100 的散图单独导入实际占用的显存和包体是按照 128×128 的二次幂来算的取决于你的 npot 设置拼接之后这部分浪费就没了。批量建图集的思路是一个目录对应一个图集或者目录下每个二级子目录对应一个图集。我倾向于后者因为 UI 通常按模块分目录比如Assets/UI/Role/、Assets/UI/Bag/每个模块一个图集加载和卸载的粒度都合适。public static SpriteAtlas CreateAtlas(string atlasPath, string[] folders, bool includeInBuild) { var atlas new SpriteAtlas(); AssetDatabase.CreateAsset(atlas, atlasPath); SpriteAtlasExtensions.Add(atlas, folders); SpriteAtlasExtensions.SetIncludeInBuild(atlas, includeInBuild); var packing new SpriteAtlasPackingSettings { padding 4, enableRotation false, enableTightPacking false, blockOffset 1 }; SpriteAtlasExtensions.SetPackingSettings(atlas, packing); var tex new SpriteAtlasTextureSettings { readable false, generateMipMaps false, sRGB true, filterMode FilterMode.Bilinear, anisoLevel 1 }; SpriteAtlasExtensions.SetTextureSettings(atlas, tex); EditorUtility.SetDirty(atlas); AssetDatabase.SaveAssets(); return atlas; }SpriteAtlasPackingSettings里那四个参数值得掰开讲。padding是图集内每个精灵之间的间隔像素。设 0 会导致相邻精灵在采样时互相渗色尤其是带透明通道的图边缘会出现明显的杂色描边。UI 图集我一般设 2 到 4。设大了浪费空间设小了画面脏4 是性价比比较高的值。enableRotation允许打包器旋转精灵来节省空间。听起来很美但 UI 精灵旋转了以后如果你代码里有任何基于sprite.rect的手工计算全都会错位。UI 图集我一律关掉。只有纯装饰性的、按图集整体渲染的场景精灵才考虑开。enableTightPacking是紧贴打包。开了以后打包器会贴着图像的实际像素边界排布节省空间。但紧贴打包会让精灵的rect变成不规则形状某些依赖矩形边界的逻辑比如九宫格会出问题。而且它更吃 CPU打包时间明显变长。我默认关闭只有图集塞不下、又不愿意拆包的时候才开。blockOffset是块对齐主要用于 ATC 和 ASTC 这类按块压缩的格式。设 1 表示按 4 像素块对齐限制设 4 更保险。这个参数设错可能导致压缩后出现轻微噪点。4.2 图集变体的实现思路与落地细节图集变体的意义在于master 图集定义收录哪些精灵变体图集继承这份清单但用自己的纹理设置——通常是更小的 maxSize 或更省空间的格式。运行时根据设备性能档位挑一个加载高端机用 2048 的 ASTC 6x6低端机用 1024 的 ETC2内存占用能差出一倍多。创建变体有一点绕。Unity 没有公开的CreateVariant接口社区里比较通行的做法是直接操作序列化字段public static SpriteAtlas CreateVariant(SpriteAtlas master, string path, int maxSize, TextureImporterFormat fmt) { var variant new SpriteAtlas(); AssetDatabase.CreateAsset(variant, path); var so new SerializedObject(variant); var isVariant so.FindProperty(m_IsVariant); var masterProp so.FindProperty(m_MasterAtlas); if (isVariant null || masterProp null) { Debug.LogError(当前 Unity 版本的 SpriteAtlas 序列化字段名有变化请检查); return null; } isVariant.boolValue true; masterProp.objectReferenceValue master; so.ApplyModifiedPropertiesWithoutUndo(); var platform new TextureImporterPlatformSettings { name Android, overridden true, maxTextureSize maxSize, format fmt, textureCompression TextureImporterCompression.Compressed, allPlatforms false }; SpriteAtlasExtensions.SetPlatformSettings(variant, platform); EditorUtility.SetDirty(variant); AssetDatabase.SaveAssets(); return variant; }注意变体图集本身不需要再Add精灵目录它继承 master 的收录列表。如果你手贱加了一次会出现重复收录打包时 Unity 只会警告一句然后行为变得不可预测。注意m_IsVariant和m_MasterAtlas是内部序列化字段。它们的名字在我测试过的 2019 到 2022 之间没有变但不排除未来版本调整。所以在代码里加了 null 判断和明确的报错日志一旦失效能第一时间发现而不是静默生成一堆废资源。变体数量不要贪多。我做的是两档HD2048 / ASTC_6x6和 LITE1024 / ETC2_RGBA8。运行时通过SpriteAtlasManager.atlasRequested回调根据设备内存档次返回对应变体代码大概是这样void OnEnable() { SpriteAtlasManager.atlasRequested OnAtlasRequested; } void OnAtlasRequested(string tag, System.ActionSpriteAtlas callback) { var atlas IsLowEndDevice() ? liteAtlas : hdAtlas; callback(atlas); }IsLowEndDevice()的判断依据我一般综合设备内存和显存不要只看型号白名单维护起来太累。4.3 打包参数调优与踩坑点图集打包最耗时间的两个开关是enableTightPacking和enableRotation前面说了默认关。但真正影响包体的其实是图集的平台压缩设置。很多人建完图集就不管了结果图集走的是默认的Automatic格式在 Android 上可能被压成 RGBA32白忙一场。所以建图集的时候一定要显式设置平台格式var androidSetting new TextureImporterPlatformSettings { name Android, overridden true, maxTextureSize 2048, format TextureImporterFormat.ASTC_6x6, textureCompression TextureImporterCompression.Compressed, compressionQuality 50, allPlatforms false }; SpriteAtlasExtensions.SetPlatformSettings(atlas, androidSetting);图集还有一个高频坑是图集冗余。同一个精灵如果出现在两个图集里Unity 会把它复制两份打进包包体直接翻倍。批量建图集最容易出这个问题因为目录划分如果重叠了比如Assets/UI和图集规则里又把Assets/UI/Bag单独列了一遍就会被重复收录。我的处理办法是建图集之前先跑一次路径冲突检查把所有规则涉及到的目录展开成文件列表找出出现在两个规则里的文件直接报错让配置的人去修。static Liststring FindOverlaps(List(string atlasName, string[] folders) rules) { var map new Dictionarystring, Liststring(); foreach (var (atlasName, folders) in rules) { foreach (var f in AssetDatabase.FindAssets(t:Sprite, folders)) { var p AssetDatabase.GUIDToAssetPath(f); if (!map.ContainsKey(p)) map[p] new Liststring(); map[p].Add(atlasName); } } return map.Where(kv kv.Value.Count 1).Select(kv kv.Key).ToList(); }另外图集尺寸也要盯。一键建图集最大的问题是容易产出 4096×4096 甚至更大的图集因为打包器会尽量把目录下所有图塞进一张。在移动端超过 4096 的纹理很多机型不支持Unity 会强行降级到 2048 并重新缩小效果就是图集整体变模糊。所以我在建图集时会把 maxTextureSize 卡在 2048并且记录每个图集的填充率超过 85% 就提示拆分。5. 动画压缩曲线精度、关键帧剔除与骨骼剥离5.1 动画压缩的三个维度动画压缩要从三个方向下手缺一不可。第一个维度是导入压缩参数作用于 FBX 里的动画。Unity 的ModelImporter提供了动画压缩模式和相关误差阈值这是官方压缩成本最低效果也最直接。第二个维度是关键帧精简作用于独立的.anim文件。这类资源 Unity 不做自动压缩需要自己遍历曲线删点。第三个维度是骨骼层级剥离也就是optimizeGameObjects。一个角色的 FBX 里可能有几十个空物体和辅助骨骼运行时根本用不上但都会被实例化。开启优化后Unity 只保留你显式指定的暴露节点其余的合并进 SkinnedMeshRenderer 内部实例化和内存都会明显下降。我一般按这个顺序执行先剥离骨骼再改导入压缩最后处理独立的 anim。5.2 ModelImporter 批量写入压缩参数对于 FBX改成 Optimal 模式并把误差阈值放得比默认值大一些public static void OptimizeModel(string path, float posErr, float rotErr, float scaleErr) { var importer AssetImporter.GetAtPath(path) as ModelImporter; if (importer null || !importer.importAnimation) return; importer.animationCompression ModelImporterAnimationCompression.Optimal; importer.animationPositionError posErr; importer.animationRotationError rotErr; importer.animationScaleError scaleErr; importer.resampleCurves true; // 剥离骨骼层级只保留需要暴露的节点 importer.optimizeGameObjects true; importer.SaveAndReimport(); }误差阈值的单位是百分比误差Unity 默认给的是位置 0.5、旋转 0.5、缩放 0.5。这里的数值不是越小越好也不是越大越好。我实测的经验值是这样位置误差 0.5 到 2 之间旋转误差 0.5 到 3 之间缩放误差 0.5 到 2 之间。对于格斗、射击这类对动作还原要求高的项目旋转误差不要超过 1对于卡牌、休闲游戏放到 3 甚至 5 肉眼也看不出来。有个转折点需要注意误差阈值一旦超过某个临界值动画会出现明显的抖动或者跳帧。这个临界值跟你的动画本身有关关键帧越密、动作越快的动画越敏感。我的做法是给每个角色挑一个最有代表性的动作用几组阈值各导一遍在真机上并排播放对比挑出画质能接受的那组再推到同类型的全部动画上。提示resampleCurves true会按照固定帧率重采样曲线通常能让压缩效果更稳定但也会让那些依赖原始关键帧时序的动画比如带精确触发点的动作轻微走样。如果你的项目里有靠动画事件驱动的逻辑重采样后一定要重新测一遍事件触发时机。批量处理的时候动画导入特别慢一个 50MB 的角色 FBX 可能要导十几秒。我一般把它单独放在一个批处理任务里让它夜里跑白天看结果。别和纹理优化混在一起做不然进度条会让人崩溃。5.3 独立动画资源的关键帧剔除实现.anim文件没有 ModelImporter只能手工处理曲线。我实现的是一个基于线性插值误差的迭代剔除对每条曲线的每个中间关键帧用左右邻居做线性插值如果实际值跟插值结果的差小于容差就删掉这个点。反复迭代直到没有点可删。public static int ReduceClip(AnimationClip clip, float posTol, float rotTol, float scaleTol) { int removedTotal 0; foreach (var binding in AnimationUtility.GetCurveBindings(clip)) { var curve AnimationUtility.GetEditorCurve(clip, binding); if (curve null || curve.length 3) continue; float tol PickTolerance(binding.propertyName, posTol, rotTol, scaleTol); var reduced ReduceCurve(curve, tol); if (reduced.length curve.length) { AnimationUtility.SetEditorCurve(clip, binding, reduced); removedTotal curve.length - reduced.length; } } // 精简后重算四元数连续性避免旋转出现反向插值 clip.EnsureQuaternionContinuity(); EditorUtility.SetDirty(clip); return removedTotal; } static float PickTolerance(string prop, float posTol, float rotTol, float scaleTol) { if (prop.StartsWith(m_LocalPosition)) return posTol; if (prop.StartsWith(m_LocalRotation) || prop.StartsWith(localEulerAngles)) return rotTol; if (prop.StartsWith(m_LocalScale)) return scaleTol; return posTol; } static AnimationCurve ReduceCurve(AnimationCurve curve, float tol) { var keys new ListKeyframe(curve.keys); bool removed true; while (removed) { removed false; for (int i 1; i keys.Count - 1; i) { var a keys[i - 1]; var b keys[i]; var c keys[i 1]; float t Mathf.InverseLerp(a.time, c.time, b.time); float lerp Mathf.Lerp(a.value, c.value, t); if (Mathf.Abs(b.value - lerp) tol) { keys.RemoveAt(i); removed true; i--; } } } var result new AnimationCurve(keys.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; }几个必须注意的点。第一旋转曲线是由m_LocalRotation.x/y/z/w四条曲线组成的每条都是 -1 到 1 的值所以容差应该设得很小0.001 到 0.005 这个量级不能直接用位置容差。第二删点之后一定要把左右切线模式重设为 Auto否则会保留旧的切线曲线形状会扭曲。第三EnsureQuaternionContinuity()必须在改完曲线之后调用不然四元数会走短路径的反面角色旋转起来会突然翻个跟头。旋转容差的换算关系大概是四元数分量误差 0.005 大约对应 0.57 度的角度误差肉眼基本看不出来。位置容差 0.01 在 Unity 的米制单位下是 1 厘米对大多数人形角色来说也是可接受范围。还有个提升点处理完这轮之后如果曲线里有大量连续相同值的片段比如角色静止不动那几秒可以再跑一次常量段合并把整段只保留首尾两个关键帧压缩率会再上一截。6. 打包前后的体积核对与问题排查6.1 体积统计与对比改了半天没个数是不行的。我写了个简单的目录体积统计跑一遍就知道每块省了多少public static long MeasureFolder(string folder) { if (!Directory.Exists(folder)) return 0; long total 0; foreach (var f in Directory.GetFiles(folder, *, SearchOption.AllDirectories)) { if (f.EndsWith(.meta)) continue; try { total new FileInfo(f).Length; } catch { /* 文件被占用时跳过 */ } } return total; } public static string Human(long bytes) { string[] units { B, KB, MB, GB }; double v bytes; int i 0; while (v 1024 i units.Length - 1) { v / 1024; i; } return ${v:F2} {units[i]}; }注意这个统计的是源文件大小不等于打包后的大小因为构建时还会重新压缩。要拿准确的构建体积得用BuildPipeline.BuildPlayer的返回值var report BuildPipeline.BuildPlayer(options); var summary report.summary; Debug.Log($构建结果: {summary.result}, $总大小: {Human((long)summary.totalSize)}, $耗时: {summary.totalTime.TotalSeconds:F1} 秒);summary.totalSize是最终包体的字节数这个才是准的。我的习惯是每次跑完优化都做一次构建把包体大小记到一个表格里形成基线。后面谁改了什么导致包体涨了一对比就现形。这个表格比任何口头约定都管用。6.2 常见问题速查现象可能原因排查方向改了纹理格式但包体没变平台覆盖设置没生效或平台名写错检查 name 是否为Android/iPhone确认overridden true图集打包后画质发糊图集尺寸超上限被强制降级检查图集实际尺寸卡 maxSize提高填充率告警阈值精灵边缘出现杂色描边padding 太小或 alpha 膨胀未开启padding 调到 42021 以上版本开启 alpha dilation图集重复收录目录规则重叠跑一次路径冲突检查合并或拆分规则动画改动后角色抖动误差阈值过大把旋转误差降回 1 以内逐条曲线对比动画播放时角色翻跟头四元数连续性被破坏改动后调用EnsureQuaternionContinuity()批处理中途卡死StopAssetEditing未执行用 try/finally 包裹异常时也要清理变体图集不生效序列化字段名变更或运行时代码未接管检查m_IsVariant是否存在确认 atlasRequested 回调已注册编辑器改完没立即生效资源未重新导入收尾统一调用AssetDatabase.Refresh()构建时提示纹理过大存在未纳入规则的资源扫描一次全量资源找出不在任何规则目录下的漏网之鱼6.3 踩过的坑与实操心得说几个我印象最深的坑。有一次优化完包体从 870MB 掉到 480MB大家都很开心结果第二天测试提了一堆 UI 糊掉的 bug。查了半天发现是图集优化的时候把 maxSize 卡在 1024但项目里有一批 2 倍图是按 2048 出的被强行拉到 1024 之后在高分辨率机型上放大显示就糊了。解决方式是给图集规则加一个白名单目录白名单里的图集不参与降尺寸只做格式压缩。这个教训是批量操作一定要留逃生口不能一刀切。还有一个更隐蔽的。我们在 Android 上一直用 ASTC 6x6某天有玩家反馈在一台老机器上所有贴图都变成紫色的占位图。原因是那台设备不支持 ASTC而我们没配 ETC2 回退Unity 构建时就把格式直接写死了。后来统一加了importer.androidETC2FallbackOverride AndroidETC2FallbackOverride.RGBA16;包体略微涨了一点但兼容性保住了。这件事之后我养成了一个习惯任何涉及压缩格式的批量操作都会在真机矩阵里至少覆盖一台低端设备再放行。第三个是关于执行顺序的。我最开始把图片优化和动画优化放在同一个批处理里跑结果每次跑到一半编辑器都会卡住进度条不动只有强杀进程。后来才想明白是因为AssetDatabase.StartAssetEditing()期间处理了 FBX 这种依赖链复杂的资源导入器之间互相等待形成了死锁。现在的做法是纹理、图集、动画各自独立成一次事务三次依次执行中间各调一次AssetDatabase.Refresh()。这样虽然总时间差不多但稳定得多出问题也容易定位。最后一个体会是关于预览功能的价值。一开始我觉得预览没什么用反正执行也就几秒钟的事直接跑不就行了。直到有一次误操作把规则里的 maxSize 从 1024 改成了 128 但没注意一点执行全项目的贴图都被重新导入了。虽然可以用版本管理回滚但重新导入花了大半个小时。从那以后我给预览按钮加了明确的统计输出——将影响 428 个资源预计包体变化 -213.6 MB基于历史均值估算并且执行前弹二次确认。这个改动看着啰嗦但它救过我至少三次。整套扩展跑完一轮我们项目的 Android 包从 870MB 降到了 430MB 左右iOS 从 1.2GB 降到 620MB。具体分配大概是纹理格式和尺寸优化贡献了约 300MB图集合并贡献了约 90MB动画压缩贡献了约 50MB。这个收益比预想的大得多而代价只是两周的开发时间和若干次真机验证。