
做模型资源这块的同行应该都有这种感觉美术那边发版节奏一快FBX 文件的数量和更新频率就完全压不住了。早先我们项目里专门有一个人负责手动处理模型打包打开每个 FBX检查材质引用、调整导入参数再把资源拖进打包配置里。这套流程最折磨人的不是花时间而是没有任何可校验性——你永远说不清楚上个版本到底哪几个模型漏打了哪几个打进去的是旧资源。后来我花了两天时间写了一个编辑器工具在项目窗口里选中任意多个 FBX右键点一下脚本自动设置 AssetBundle 名称、收集依赖、执行构建最后输出一份清单。这篇文章把实现思路和完整代码都摊开讲准备做批量出包工具的人可以直接参考或者至少拿到一个能跑的起点。1. 我为什么放弃手动给FBX设置AssetBundleName1.1 一套模型从美术交付到线上加载要经历什么很多人一提到 AssetBundle 就下意识觉得“打包就是点一下构建按钮”但实际上一条模型资源的完整生命链路很长美术导出 FBX 文件我们把它放进 Unity 工程调整导入设置指定 AssetBundleName 或 Build 列表执行构建把产物上传到下载地址最后再由运行时加载并实例化。中间任何一步断了线上看到的模型都是错的。我见过最多的情况是美术更新了一个模型文件贴图也换了名字但因为旧资源的 AssetBundleName 在 .meta 文件里还留着构建工具把新旧两份资源一起打进了包。表面上看构建成功没有报错实际运行时却加载到了旧资源。你在编辑器里看 Prefab 一切正常真机上就是一张旧脸。这种问题查起来异常痛苦因为构建产物本身是二进制你很难一眼看出里面装的是什么。手动设置 AssetBundleName 最坑的一点在于它本质上是在给资源写一个逻辑路径但这个逻辑路径在开发期几乎是透明的你只能从 Inspector 底部一个小框里确认。项目小的时候大家还能凭记忆记住哪个模型打了哪个包项目一大这层记忆就彻底靠不住了。所以我的结论很简单凡是规则固定的操作就应该写成工具凡是重复动作就应该让脚本去做人只需要在例外情况下介入。1.2 人肉打包最常见的四个翻车现场我总结了一下这几年手工打包踩过的坑基本集中在下面四类。第一类共用贴图被重复打进多个 Bundle。模型 A 和模型 B 共用一张贴图 C你分别给 A、B 打了两个包。构建日志里一切正常但打开产物统计就会发现贴图 C 被复制了两份。这对包体体积的伤害是沉默的因为不打开依赖分析工具根本看不见。第二类文件名大小写不一致。Windows 下编辑器不区分资源名可以正常加载但构建和发布一旦切到区分大小写的环境比如 Linux 服务器或者是某些资源校验逻辑严格的真机环境路径匹配失败模型直接加载不出来。最要命的是本地测不出来只能等测试那边反馈。第三类AssetBundleName 被误清空。有些版本管理工具或者美术的导入流程会重写 .meta 文件AssetBundleName 被清掉后资源仍然能进构建产物但它会附着到其他 Bundle 下面导致依赖关系变成一团乱麻。手动检查几十个文件时这种状态基本靠肉眼找不到。第四类只设置了 FBX 的 BundleName忘了带材质和贴图。这个是最经典的因为构建工具不会提示你资源缺了什么。FBX 本身打进去了运行时加载模型却一片紫色查到最后是贴图没有归属到任何 Bundle白白卡了两天进度。2. 一键打包工具怎么落地脚本设计、参数和完整代码2.1 菜单入口与选中文件收集做编辑器工具的第一步是决定入口。我这里选了项目窗口右键菜单因为操作直觉最顺美术或开发在资源目录里选中模型文件右键就执行不需要额外开面板。核心代码很直白using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; public static class FBXAssetBundleBuilder { [MenuItem(Assets/一键打包选中FBX为AB包, false, 30)] public static void BuildSelectedFBX() { Object[] selections Selection.objects; if (selections null || selections.Length 0) { Debug.LogWarning(没有选中任何资源); return; } Liststring fbxPaths new Liststring(); foreach (Object obj in selections) { string path AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(path)) continue; if (path.EndsWith(.fbx, System.StringComparison.OrdinalIgnoreCase)) { fbxPaths.Add(path); } } if (fbxPaths.Count 0) { Debug.LogWarning(选中文件里没有FBX); return; } BuildFBXAssetBundles(fbxPaths); } }代码逻辑很简单从Selection.objects拿到选中的对象转换成资源路径过滤出.fbx后缀的文件再把路径列表交给真实的构建函数。这里有必要说明一点Selection.objects拿到的可能是 FBX 资产也可能是 FBX 导入后自动生成的 GameObject但二者都能通过AssetDatabase.GetAssetPath拿到同一个路径所以不需要额外区分。如果你希望工具更宽容一点可以改成“选中的文件夹也自动递归扫描里面所有 FBX”。这个扩展思路很实用但实现时要注意递归扫描会遍历大量全英文路径建议还是老老实实用AssetDatabase.FindAssets(t:Model)来筛效率高很多而且不会误匹配到其他文件类型。2.2 修改AssetBundleName之前的预处理逻辑在真正调用构建接口之前工具还应该做几件事。第一件是确认导入设置FBX 模型的 Scale Factor、动画类型、Read/Write 开关等配置如果不同版本间不一致构建产物表现也会不一致。我这里只做最关键的检查塑形文件是否有动画、是否开启了读写、材质提取方式是否合理。举两个最典型的设置Read/Write Enabled如果打开Unity 会为网格保留一份 CPU 副本模型内存在构建后会成倍增长但如果运行时要做网格变形、碰撞体动态生成这类操作这个开关又必须保留。所以工具不应该一刀切而是根据可配置参数来。我在工具里放了一个枚举允许打包时指定“纯静态展示模型”和“需要运行时改网格”两种模式前者自动关闭读写后者保持开启。第二件是处理 AssetBundleName 本身。我这里没有直接改 .meta 文件里的 AssetBundleName而是改用AssetBundleBuild结构来构建。用AssetBundleBuild的好处是构建时的 Bundle 名称和资源归属关系是临时传入构建管线的不会污染工程里每个资源的 .meta 状态。对版本管理来说这是最温和的做法避免了工具跑一遍整个工程几百个 .meta 文件全部爆改的恐怖场景。2.3 构建Bundle的完整代码核心构建函数贴出来依赖处理和名称规则都在里面private static void BuildFBXAssetBundles(Liststring fbxPaths) { string outputRoot Path.Combine(AssetBundles, EditorUserBuildSettings.activeBuildTarget.ToString()); if (!Directory.Exists(outputRoot)) { Directory.CreateDirectory(outputRoot); } ListAssetBundleBuild builds new ListAssetBundleBuild(); foreach (string fbxPath in fbxPaths) { string fullPath fbxPath.Replace(\\, /); string relativeName fullPath .Replace(Assets/Art/Models/, ) .ToLower(); // 去掉扩展名 relativeName Path.ChangeExtension(relativeName, null); string bundleName models/ relativeName; // 收集依赖 string[] deps AssetDatabase.GetDependencies(fbxPath, true); Liststring assetNames new Liststring(); assetNames.Add(fbxPath); foreach (string dep in deps) { if (dep.EndsWith(.cs)) continue; if (dep.StartsWith(Assets/Art/Common)) continue; assetNames.Add(dep); } AssetBundleBuild build new AssetBundleBuild(); build.assetBundleName bundleName; build.assetNames assetNames.ToArray(); builds.Add(build); } AssetBundleManifest manifest BuildPipeline.BuildAssetBundles( outputRoot, builds.ToArray(), BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DeterministicAssetBundle, EditorUserBuildSettings.activeBuildTarget ); if (manifest null) { Debug.LogError(AB包构建失败请查看Console日志); return; } Debug.Log($构建完成共生成 {builds.Count} 个Bundle); ExportBuildManifest(outputRoot, builds, manifest); }几个设计点解释一下。bundleName用相对路径自动生成比如Assets/Art/Models/Building/House.fbx会变成models/building/house这样命名基本不会冲突路径本身就代表唯一性。Unity 对 AssetBundle 名称有硬性要求全部小写只允许字母、数字、点、斜杠所以.ToLower()不能省。GetDependencies(fbxPath, true)会把 FBX 关联到的材质、贴图、动画等全部路径都带出来。这里对Assets/Art/Common目录做了排除是为了把公共贴图和材质之外别的资源分隔到独立共享包避免每个模型包都塞一份公共材质。这个策略的具体利弊我在下一节专门讲。构建参数里我选了ChunkBasedCompression和DeterministicAssetBundle。前者是 LZ4 压缩加载速度快包体也不会像 LZMA 那样在首次解压时卡顿后者保证相同输入产出相同二进制对增量更新和缓存友好。如果你的项目偏单机、包体敏感可以考虑改成 LZMA但要做好首次加载的等待预算。2.4 构建结果的组织结构与Manifest文件构建完成后输出目录里会有每个 Bundle 文件以及一个和目录同名的 Manifest 文件。Store 阶段只用 Bundle 产物本身Manifest 是给运行时和出包工具读的里面有每个 Bundle 的 CRC、依赖关系、哈希值等信息。我这里额外写了一个ExportBuildManifest方法把构建结果做成一个 CSV 清单方便测试同事核对版本private static void ExportBuildManifest(string outputRoot, ListAssetBundleBuild builds, AssetBundleManifest manifest) { StringBuilder sb new StringBuilder(); sb.AppendLine(BundleName,FileSize,Dependencies); foreach (AssetBundleBuild build in builds) { string abPath Path.Combine(outputRoot, build.assetBundleName); if (!File.Exists(abPath)) { sb.AppendLine(${build.assetBundleName},MISSING,); continue; } FileInfo fi new FileInfo(abPath); string[] deps manifest.GetDirectDependencies(build.assetBundleName); sb.AppendLine(${build.assetBundleName},{fi.Length},{string.Join(;, deps)}); } File.WriteAllText(Path.Combine(outputRoot, build_manifest.csv), sb.ToString()); }这个 CSV 看着不起眼但排查线上问题时是宝贝。真机上报一个模型加载失败先看清单里对应 Bundle 的大小是不是 0再看它依赖了哪些别的包基本就能锁定问题出在哪个环节。运行时加载这块顺便提一句AB 包的下载路径建议和 BundleName 一一对应CDN 上目录结构直接复制本地的models/building/house这种层级能省掉运行时拼路径的脑内换算。加载顺序上Manifest 会提供依赖关系当你要加载某 Bundle 前先把依赖 Bundle 全部请求完再加载否则会出现资源引用为 null 的诡异现象。3. 不能只盯FBX依赖资源才是AB包体积失控的大头3.1 打一个模型至少要带上哪些东西FBX 文件本身通常只包含网格数据、骨骼权重和动画曲线材质、贴图、Shader 全是以外部资源形式存在的。Unity 的 AssetBundle 构建不会在打包时自动把贴图烘焙进网格文件如果没有显式把依赖资源放进 Bundle 或依赖包里加载出来的模型就是一个个紫红色的空壳。因此一个能让线上正常显示的模型 Bundle至少要包含这些内容资源类型说明缺失后果FBX 网格模型几何与骨骼完全加载不出来材质资产每个子网格对应的 Material模型渲染异常贴图Albedo、Normal、Metallic 等表面全紫或黑Shader材质用到的渲染管线着色器材质失效动画剪辑人形或通用动画无法播放动作看到这个清单就明白了把 FBX 单独打一个包是伪命题完整地带上依赖才是有意义的包。当然你也可以把依赖资源单独打成“共享包”让多个模型包共用但那需要对工程目录和资源归属有非常清晰的划分否则很容易出现“包都构建成功了运行时却因为加载共享包超时而黑屏”的情况。3.2 用AssetDatabase.GetDependencies收敛依赖工具里最关键的依赖收集就是AssetDatabase.GetDependencies(fbxPath, true)。它会递归扫出这个 FBX 引用到的所有资源路径包括嵌在子物体里的材质参数、贴图引用、动画引用的骨架等。这里有三个细节值得说。第一接口的第二个参数是recursive必须传true。如果传false拿到的只有直接引用但材质引用的贴图、Shader 引用的变体等间接依赖全部丢失构建出来的包基本必炸。第二GetDependencies返回的结果里会包含 FBX 自身你不需要把自己再手动加一遍。我上面的代码里最后其实手动加了fbxPath这是为了保险而为之实际做的时候你可以先打印一下deps的内容再决定要不要去重。要注意避免重复添加导致数组里出现两个相同路径部分老版本 Unity 会在这种情况下报重复资源错误。第三.cs脚本文件不要打进去。脚本资源本身不带进 AB 包但你在 OnAssetImported 回调里给模型添加的 MonoBehaviour 最终还是会引用到脚本所以构建时把.cs路径过滤掉的逻辑必须保留。不然轻则构建报警重则把不可序列化的 MonoBehaviour 状态打进包运行时直接报类型缺失。3.3 打包粒度取舍每个FBX单独打还是合并打这也是一个所有做资源管理的人都会纠结的问题。常见的做法有三种。按文件打每个 FBX 一个 Bundle。优点是独立性强、按需加载方便、更新粒度精确缺点是依赖资源重复打包概率高包体碎片化严重。适合模型文件少、更新频繁的项目。按目录打整个Assets/Art/Models/Building目录打成一个 Bundle。优点是依赖重复被控制住了缺点是只更新一栋楼也要下载整批模型流量浪费明显。适合模型体量小、内部关联紧密的场景。混合策略公共资源划拨到共享包模型按文件打。这是目前我比较推荐的方式也是上面工具默认使用的策略。公共目录如Assets/Art/Common独立成包模型包只打自身和私有贴图。运行进游戏后先加载共享包再按需加载模型包。从实现角度说混合策略的复杂度在名字规划上。模型包的名字必须唯一且可预测共享包的名字同理。我建议在项目初期就定一套目录规范比如路径包名规则Assets/Art/Modelsmodels/{相对路径}Assets/Art/Commoncommon/{相对路径}Assets/Art/Effectseffects/{相对路径}这样资源检测和出包工具都能依靠路径快速判断资源归属避免了后续靠人工维护大量白名单。3.4 一张图看懂重复资源的产生路径重复资源的路径其实是这样的模型 A 引用贴图 C模型 B 也引用贴图 C。如果两个模型包构建时没有把贴图 C 划到一个公共包而是直接塞进各自的包构建管线就会把 C 做成两份完全相同的二进制拷贝。你从体积上能看见它们但从目录结构里很难察觉。用 AssetBundle Browser 这类工具能彻底看清重复资源。就算你不想装插件打开构建出的 CSV 清单比较一下同尺寸的 Bundle如果里面出现多个名称相同但体积几乎一样的 Bundle基本就是重复资源。解决重复资源最干净的办法是在源头上规划公共目录。美术产出的贴图如果明确是公用的就应该放进Assets/Art/Common/Textures打包工具看到这个前缀就自动把它归入公共包。模型私有的部件贴图留在模型目录下跟随模型包走。这套机制越早定后期返工的代价越小等模型量大了再挪目录极易出现大量资源的引用被打破然后你又要花一个下午去修预制体丢失的引用。4. 同批做出来的几个“小玩意”4.1 批量生成Prefab并自动补ColliderFBX 打包之后另一个明显痛点是要手动拖入场景或创建 Prefab尤其是几十个模型都要挂 Collider 的时候。我顺手加了一个生成 Prefab 的菜单项选中 FBX 后在当前目录旁边生成同名 Prefab并将其加好BoxCollider。[MenuItem(Assets/为选中FBX批量创建Prefab并补Collider, false, 40)] public static void CreatePrefabWithCollider() { foreach (Object obj in Selection.objects) { string path AssetDatabase.GetAssetPath(obj); if (!path.EndsWith(.fbx, System.StringComparison.OrdinalIgnoreCase)) continue; GameObject source obj as GameObject; if (source null) { source AssetDatabase.LoadAssetAtPathGameObject(path); } GameObject instance (GameObject)PrefabUtility.InstantiatePrefab(source); Bounds bounds CalculateBounds(instance.transform); if (bounds.size.x 0.01f bounds.size.y 0.01f bounds.size.z 0.01f) { BoxCollider col instance.GetComponentBoxCollider(); if (col null) col instance.AddComponentBoxCollider(); col.center bounds.center - instance.transform.position; col.size bounds.size; } string prefabPath Path.ChangeExtension(path, .prefab); PrefabUtility.SaveAsPrefabAsset(instance, prefabPath); Object.DestroyImmediate(instance); AssetDatabase.ImportAsset(prefabPath); } }这里的CalculateBounds会遍历所有子物体上的Renderer.bounds取合并包围盒。不能直接用根节点的Renderer因为模型根节点经常没有渲染组件。生成完 Prefab 后后续美术迭代模型只要更新 FBXPrefab 引用不会断因为 Prefab 保存的是模型资产的引用而不是拷贝。这个小工具对场景搭建的效率提升非常明显以前一小时手动摆模型加碰撞体现在选中几十个文件一键搞定。4.2 给FBX自动生成项目缩略图Unity 的 Project 窗口对模型会自动生成缩略图但有时候模型导入参数不对缩略图就是一坨黑或一团乱。做资源管理时黑缩略图会严重影响美术同事的辨认效率。所以我写了另一个小工具选中 FBX 后用AssetPreview.GetAssetPreview强制生成预览图并保存到模型同目录的一个.png方便直接放进文档或资源清单里。实现上没太多可说的本质上是调用编辑器接口触发资源预览再截屏。但需要注意有些模型的预览在资源首次导入时拿不到必须等AssetPreview.IsLoadingAssetPreview状态变成可用之后才能截取否则截图是张空白图。实际项目里我是在工具里加了个[InitializeOnLoadMethod]资源导入完成后延迟一帧再截图成功率会高很多。4.3 构建平台切换与命令行批处理编辑器右键点击只适合手动出包真正要高频迭代或者放到持续集成里跑还得支持命令行模式。我给工具加了一个静态入口允许用-executeMethod FBXAssetBundleBuilder.BuildFromCommandLine调用同时解析以下几个参数。Unity -batchmode -quit -projectPath ./MyProject \ -executeMethod FBXAssetBundleBuilder.BuildFromCommandLine \ -buildTarget Android \ -fbxFolder Assets/Art/Models/BuildingBuildFromCommandLine做的事情很简单把传入的文件夹路径递归收集 FBX再复用上面的BuildFBXAssetBundles函数。有了这个入口之后我可以在打包机或者本机跑一个批处理脚本每晚自动重新构建所有变更模型生成的 CSV 清单自动发到消息群谁动了哪些资源一目了然。这个能力的价值在跨平台构建时尤其明显。同一个模型Windows、Android、iOS 的贴图压缩格式和着色器变体能差出一截用编辑器手动切BuildTarget来回构建不仅慢而且容易漏掉平台差异。命令行把平台作为参数传进去一套脚本打三端省心非常多。4.4 构建日志汇总和AB包清单导出AB 包构建过程会打大量的日志但 Console 里的内容在批处理模式下是看不到的。我索性在工具里做了一个日志汇总器构建完成后不仅输出 CSV 清单还顺带把每个 Bundle 对应哪些资源路径、是否有失败资源和警告内容全部记录下来。代码量不大无非是把BuildPipeline.BuildAssetBundles前后的 Debug 信息收集到StringBuilder再写到一个日志文件。但这项能力帮我省过几次大麻烦。有一次模型导入后提示某个动画文件骨骼不匹配编辑器手动构建时 Console 会闪红可一旦切到批处理模式这种错误会直接静默吞掉构建出来的包照样成功但运行时动画完全不动。有了日志汇总批处理构建失败后你会第一时间看到错误原因而不是等到线上出问题了再反推。这类小工具的共性是它们都服务于同一个原则——把人从重复劳动里解放出来并且把让“过程中的不确定性”变成可回溯的记录。5. 用了两个项目后踩到的坑和最后的优化方向5.1 文件名大小写导致的构建缓存陷阱这是我在实际项目里踩得最深的一次。有一版工具写的时候没有强制.ToLower()美术同事在 Mac 上把一个模型文件夹重命名成了首字母大写结果构建出来的 BundleName 在本地一切正常代码仓库合并到另一个同事的 Windows 环境后再构建文件名大小写被 Git 的换行处理搞乱了整个 Bundle 在真机上怎么都加载不出来。后来我把规则定死所有 BundleName 一律小写且统一用/作为路径分隔符。代码里每次生成 BundleName 都走同一个StringExtensions.ToAssetBundleName()方法不允许任何地方单独拼字符串。这个习惯一旦养成基本能从根上消灭一类大小写问题。5.2 变体Variant不是用来管贴图质量等级的曾经有一段时间我想偷懒用 AssetBundleVariant 做贴图的高、中、低三档切换比如在 BundleName 后面加_hd、_ld。实际跑起来才发现Variant 是专门用于同一逻辑资源不同形态切换的比如一个模型的低模和高模。它的加载逻辑要求主 Bundle 和 Variant Bundle 成对出现而且切换时机要在加载前决定。如果只想做贴图质量分档直接投资不同的贴图压缩格式和缩放比例或者用AssetBundleRequest切换贴图引用都比 Variant 简单得多。我用错的那段时间构建没问题但运行时切换档位后模型经常出现引用错乱排查成本远高于它带来的微薄收益。后来全部改成按需替换贴图引用稳定性和代码可读性都高了不少。5.3 被打进AB包的GameObject与场景资源不能混用有一种坑特别隐蔽模型包里偶尔会混入一些场景片段比如临时用来摆造型的灯光、摄像机。这些资源打进 Bundle 后运行时可以直接LoadAssetGameObject但如果它引用了一个只存在于某个场景里的灯光设置运行时加载就会因为场景上下文缺失而变得不可预期。所以我在收集assetNames时加了一个过滤器只允许Model类型资源可以被打进模型包绝不允许Scene或者带有场景依赖的 Prefab 混入。这是一条很简单的规则但能避免掉大量难以定位的加载期问题。5.4 自动化脚本的容错设计构建失败也要留下日志编辑器脚本跑得多了会发现批处理模式下的失败极其安静不像在编辑器里能直观看到红色报错。所以我把构建失败也归类成正常输出一旦BuildAssetBundles返回的 Manifest 为 null工具就立即把当前收集到的所有路径、文件名、构建参数全部写入日志文件再用EditorApplication.Exit(1)退出方便持续集成拿到确定的失败信号。这个改动看起来很小但对团队协作影响巨大。以前构建失败大家各自在本地猜原因现在失败日志会明确告诉你是某个 FBX 路径不存在、还是某张贴图格式不支持省掉大量“你那边能跑吗”“我这边报错不一样”的无效沟通。回过头看这套工具最后真正沉淀下来的并不是那几千行代码而是一套资源管理的规矩哪些目录归公共包、哪些归模型包、BundleName 怎么生成、构建失败怎么定位。工具只是把规矩固化下来而已。如果你也想做类似的一键打包我的建议是先把手动流程完整跑一遍把每一步的检查和记录都列出来再写脚本对应实现。等脚本稳定了你会发现原来最花时间的根本不是构建本身而是围绕构建的那些琐碎确认工作而这一次它们都被一次点击吞掉了。