ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity包体优化:图片压缩、图集变体与动画压缩编辑器扩展

Unity包体优化:图片压缩、图集变体与动画压缩编辑器扩展 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/3crunchedCompression是否用 crunched 压缩磁盘变小的主要来源之一isReadable是否保留 CPU 侧副本不影响包体但吃运行内存alphaIsTransparency是否按透明通道处理影响压缩格式选择与边缘发黑平台覆盖设置TextureImporterPlatformSettings才是大头。默认情况下一个纹理在 Android、iOS、WebGL 上如果没被 override就会退回到通用设置而通用设置里的格式通常是Automatic——Unity 会自己猜猜出来的结果经常是 TrueColor也就是几乎不压缩。这就是我朋友项目里那两百张图的问题来源。正确做法是显式给每个目标平台指定格式。常见的选型如下平台推荐格式适用场景备注AndroidASTC 6x6通用 UI、道具图标6x6 是画质和体积的平衡点AndroidASTC 8x8背景图、大尺寸低细节图体积更小色块边缘可能糊AndroidASTC 4x4高精度角色贴图体积明显变大慎用iOSASTC 6x6与 Android 保持一致A 系列芯片全支持WebGLASTC 6x6 ETC2 兜底需要兼容老浏览器具体看目标浏览器支持度StandaloneDXT5 / 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 ListTextureRule Rules new ListTextureRule { 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 Liststring(); 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.LoadAssetAtPathSprite) .Where(s s ! null) .CastObject() .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.LoadAssetAtPathSpriteAtlas(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 ListKeyframe { 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() GetWindowPackOptimizeWindow(包体优化); 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 MB78 MB降 63.5%图集数量9 张人工维护14 张自动生成数量增加但内存峰值降低动画资产总量46 MB11 MB降 76.1%整体包体268 MB183 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.logBatchEntry.RunAll是个静态方法内部调用前面的三个模块最后检查报告文件里的收益是否低于阈值低于就返回非零退出码让 CI 直接把这次构建标红。这样美术提交素材之后如果误传了一张 4096 的未压缩贴图下一次构建就会被拦住不用等到提审才发现。要提醒的一点是-nographics模式下某些依赖图形设备的操作会失败比如EditorUtility.DisplayCancelableProgressBar就没意义了。批处理入口里应该把这部分逻辑去掉改成纯日志输出否则在 CI 上会报一堆无关的警告干扰排查。推进一步的话可以把PackOptimizeReports里的 CSV 上传到构建系统做趋势图连续几个版本的纹理体积曲线一看就知道有没有人在偷偷塞大图。这个链路我搭过一次投入不大但确实让包体又涨了这种扯皮少了很多。
返回列表