美术同学在群里丢过来一句贴图我导完了然后你打开工程一看那批 UI 图的Read/Write Enabled全开着Max Size统一 2048压缩格式是默认的CompressedsRGB 也没关。跑起来没问题包体大了 80MB低端机上贴图内存直接顶到天花板。这类问题在 Unity 项目里出现的频率远比大多数人想象的高——因为 Unity 的默认导入设置是让你能跑起来的配置而不是让你跑得好的配置。把资源导入规范写进 Wiki、贴在墙上、写进新人手册效果都很有限真正能守住这条线的是Unity AssetPostProcessor 加上 Presets 组成的资源导入自动化脚本。前者负责在导入管线里强制修正后者负责给美术和策划一个默认就是对的的起点。下面这套东西我在几个不同规模的团队里都落地过从 5 人小项目到几十人并行开发的中型工程踩过的坑基本都在这儿了。1. 规范写在文档里就一定会烂掉资源导入问题的真实来源1.1 默认导入设置为什么能跑但很贵先说清楚一件事Unity 的导入默认值是有历史包袱的。它要保证一个从 Asset Store 下载的包、一个新人随手拖进来的 fbx、一张从网上下载的 png都能立刻显示出来所以默认值天然偏安全和宽容。TextureImporter默认会给贴图开 mipmap、默认 sRGB、默认最大尺寸 2048、默认平台格式走CompressedModelImporter默认关闭 Read/Write 但会导入材质和动画mesh compression 关着AudioImporter默认DecompressOnLoad也就是整段音频解成 PCM 常驻内存。这些默认值单个看都合理问题在于它们是按资源类型统一设的而不是按用途设的。一张 4K 的 PBR 基础色贴图、一张 64x64 的图标、一张需要被脚本读像素的 mask 图在 Unity 眼里都是Texture2D默认设置只能给一个折中方案。而折中方案在真实项目里的表现就是小图浪费了尺寸余量大图压不住显存需要读像素的图没开 Read/Write 导致运行时报错法线贴图被当成 sRGB 处理导致光照发灰。更麻烦的是这些设置是记录在.meta文件里的。也就是说它不是一个运行时能改的参数而是跟着资源走的持久化数据。一旦某个资源以错误的设置导入了这个错误就被固化进了版本库后面所有人拉下来都是错的直到有人手动把它改对——而手动改对的那次提交又会在合并时和别人的修改打架。1.2 手工导入流程的三种典型失效模式我把见过的失效情况归成三类几乎覆盖了所有规范明明写了却依然出问题的场景。第一类是不知道。新人入职没人告诉他贴图要放在Assets/Art/Textures/UI下面他就顺手放在Assets/Textures了。规则是按目录分档的目录不对分档自然不对。这类问题靠文档解决不了因为文档不会被读第二遍。第二类是知道但漏了。老美术很清楚角色贴图要 ASTC 6x6但一次导 40 张贴图时他改了 38 张剩下两张忘改。人做重复劳动时出错不是态度问题是概率问题。导入设置的修改尤其容易漏因为它在 Inspector 里是一堆折叠面板改完还没什么即时反馈。第三类是改了但被覆盖。这个最隐蔽。美术改好了设置提交另一个同学从自己的分支合并过来.meta文件冲突了随手选了一边设置又回去了。或者更简单——美术在 Inspector 里改完设置忘了到File Save Project或者没触发重新导入.meta根本没落盘提交上去的还是旧值。三类问题的共同点是它们都不是靠更强的执行力能解决的。你需要一个不依赖人、不依赖记忆、只依赖资源路径就自动生效的机制。1.3 自动化真正要解决的是一致性而不是规范这一点我想单独强调因为它决定了脚本的设计思路。很多人写 AssetPostProcessor 时脑子里想的是我要把规范写进代码于是写出一大堆条件判断和特例最后脚本本身变成了没人敢改的怪物。换个角度想规范是目标状态脚本是保证任何资源都收敛到目标状态的函数。这里的关键词是收敛和幂等。脚本不应该关心这个资源从哪来、被谁改过、改过几次它只应该在每次导入时把该资源的所有相关字段推向目标值。这样设计出来的脚本天然能处理美术改了设置又合并回来这种场景——只要它再导入一次就会被拉回正轨。至于 Preset它的定位完全不同它不负责纠正只负责起点。当一个资源第一次被拖进工程时Preset 决定它的初始设置是什么。如果 Preset 是对的那么绝大多数资源在导入的那一刻就已经合规了AssetPostProcessor 只需要处理少数例外和边界情况。两者配合工作量会小一个数量级。2. AssetPostProcessor 的回调链路与执行时机拆解2.1 静态回调与实例回调的分工AssetPostprocessor这个类里有两套完全不同的回调混用是新手最容易犯的错。一套是实例回调比如OnPreprocessTexture、OnPostprocessTexture、OnPreprocessModel、OnPostprocessModel、OnPreprocessAudio。这些是非静态方法Unity 会为每一次资源导入新建一个 AssetPostprocessor 子类的实例并把当前资源的assetPath和assetImporter绑定到这个实例上。所以你在OnPreprocessTexture里可以直接写assetPath和assetImporter它们指向的就是正在导入的那个资源。这里有个重要含义每次导入都是新实例实例字段不会跨资源保留状态。想让某个计数器在多次导入间累加必须用静态字段但静态字段又会在编辑器域重载Domain Reload时被清掉这是后面排查部分会讲的一个坑。另一套是静态回调主要是OnPostprocessAllAssets还有GetVersion、GetPostprocessOrder。OnPostprocessAllAssets在一批资源导入全部结束后被调用一次参数是importedAssets、deletedAssets、movedAssets、movedFromAssetPaths四个数组较新版本还多了个表示是否发生了域重载的布尔参数。它的用途不是改导入设置而是做跨资源的后处理比如某个图集的所有小图都导完了重新生成一张合并索引或者某个目录下新增了资源需要刷新一份配置表。一个直接的判断标准要改Importer的字段用实例回调要动其它资源或者工程级数据用OnPostprocessAllAssets。2.2 一次资源导入的完整顺序理解顺序才能理解为什么有些写法无效。一次典型的贴图导入大致经历这些阶段Unity 检测到文件新增或变更生成/更新.meta文件。如果有匹配的Preset被注册为该类型资源的默认 PresetPreset 的值会被写入新创建的Importer作为初始状态。调用AssetPostprocessor的OnPreprocessTexture此时assetImporter已经可以安全读写。导入器按照最终确定的设置真正执行解码、缩放、压缩、生成 mipmap。调用OnPostprocessTexture(Texture2D tex)参数是已经生成好的纹理对象。所有资源完成后调用OnPostprocessAllAssets。这里有两个容易被忽略的结论。第一Preset 在 preprocess 之前。所以你完全可以在OnPreprocessTexture里读取 Preset 设下的值再决定是否覆盖。反过来想用 Preset 覆盖脚本的判断是做不到的脚本永远是最后一道。知道这个顺序就不会再纠结为什么我在 Preset 里设了但没生效——脚本优先级更高去看脚本。第二OnPostprocessTexture里不要再改assetImporter。此时资源已经导入完了你在这一步改了 Importer改动不会自动生效除非你显式调用SaveAndReimport()而那会触发一次完整的重新导入等于一个资源导了两遍。如果这个重新导入又触发OnPreprocessTexture里的逻辑就可能出现你没预料到的连锁反应。所以改设置的动作全部集中在 preprocess 阶段。2.3 GetVersion 与重新导入的触发条件GetVersion()是AssetPostprocessor里最容易被低估的一个方法。它返回一个uintUnity 会把每个资源的.meta里记录的版本号和你当前返回的值做比对不一致就强制重新导入该类型的资源。这个机制的价值在于当你的规范脚本逻辑发生了实质变化比如原来贴图最大尺寸是 2048现在改成 1024你不需要挨个选中资源手动 Reimport。只要在GetVersion里把版本号加一下次编辑器启动或者资源被扫描到时所有贴图都会自动按新规范重新导入一遍。但它也是性能炸弹。规则写错一次返回了一个每次调用都不同的值比如用了时间戳整个工程的贴图会在每次刷新时全量重导编辑器会卡到你怀疑人生。所以GetVersion必须返回硬编码的常量且只在规范真正变化时才手动 1。我在项目里的做法是在文件顶部放一个显式命名的常量public class TextureImportRules : AssetPostprocessor { // 规范版本 // 1 - 初始版本 // 2 - UI 图标最大尺寸从 1024 降到 512 // 3 - 法线贴图强制关闭 sRGB private const uint RuleVersion 3; public override uint GetVersion() RuleVersion; }顺带说一句GetPostprocessOrder()它返回 int用来决定多个 AssetPostprocessor 之间的执行顺序值越小越先执行。工程里如果有第三方插件也写了 AssetPostprocessor有些图集工具会这么干你就需要用它来保证自己的规则跑在合适的位置避免你的设置被插件的逻辑覆盖。3. Presets 与代码约束的分工哪些交给 Preset哪些必须写代码3.1 Preset 能做什么、不能做什么Preset 的本质是把某个 Importer 的字段快照保存成一个.preset资产然后在新建同类型资源时把这个快照应用上去。它的注册入口是Project Settings Preset Manager你可以为TextureImporter、ModelImporter、AudioImporter、PhysicsMaterial等类型指定默认 Preset甚至可以指定多个并按顺序应用。它擅长的事情有三件一是给非技术同学一个拖进来就对的默认值不需要他们理解 Importer 的任何一个字段二是让默认值可视化在 Inspector 里点几下就能改改完所有新资源立刻跟着变比改代码快得多三是覆盖不同资源类型的初步分档比如你可以做两套贴图 Preset一套给 UI一套给场景然后手工分配给不同文件夹——但注意默认 Preset 只能有一个匹配规则按目录自动切换是 Preset 做不到的。它做不到的事情也很明确不能按路径做条件判断不能做计算不能处理例外不能纠正已经被导入的老资源。这就是为什么 Preset 单独用的时候总会在项目跑到中期开始失控——早期资源少手工分配 Preset 还行资源上千之后没有人会记得哪个文件夹该用哪套 Preset。3.2 两者叠加时的实际优先级前面说过顺序Preset 先OnPreprocessTexture后。所以真实的优先级是代码 Preset。这个顺序有好有坏。好处是你永远有一个兜底的强制层任何 Preset 配错、任何手工改错只要资源经过一次导入就会被代码拉回来。坏处是如果代码写得过于激进Preset 就完全没意义了——美术在 Inspector 里调好的参数下次导入又被脚本盖回去这种情况会直接引发 resentment团队会觉得这个脚本很烦。我的经验是划一条线Preset 负责常规档位代码负责强制约束与例外。具体来说下面这些字段属于强制约束必须写在代码里不允许被 Preset 或者手工修改字段为什么必须由代码锁定法线贴图的sRGBTexture false手工改错会导致光照整体偏色且很难定位特定目录下贴图的isReadable脚本运行时会因读像素失败而报错属于功能性依赖UI 图集的mipmapEnabled falseUI 通常按原尺寸绘制mipmap 只增加内存不带来收益音频在指定目录的loadType直接决定内存峰值误设会导致低端机崩溃模型的importCameras / importLights美术导出的 fbx 经常带相机和灯光不关掉会污染场景而像filterMode、wrapMode、anisoLevel这些偏美术手感、对性能影响有限的字段我倾向于交给 Preset允许美术在 Inspector 里微调脚本不插手。这条线画清楚之后脚本的代码量和维护成本都会明显下降。3.3 用 Preset 兜住脚本够不到的场景还有一类场景是脚本管不到的在 Unity 之外创建的资源。比如策划用外部工具批量生成了 500 张图标直接塞进工程目录或者美术用命令行工具导出了一批 fbx。这些资源一旦被 Unity 扫描到就会走导入流程脚本当然也会触发但会有一些细微差别——比如某些 Importer 字段在首次导入时是默认值Preset 的作用就在这个时候体现出来。更实用的一点是Preset 可以让新建资源这个动作本身带上规范。比如在Assets/Art/Textures/UI目录下右键Create Texture新建一张空白贴图Preset 会直接把它的mipmapEnabled关掉、maxTextureSize设成 512美术拿到手就是对的。这比事后纠正体验好得多。4. 规则路由用目录约定和 assetPath 匹配把资源分派到不同规范4.1 先定目录结构再写代码自动化脚本的稳定性90% 取决于目录约定是否清晰。如果资源在工程里是随便放的脚本就只能写成一堆if特判最后必然失控。我推荐的结构大致是这样Assets/ Art/ Textures/ UI/ UI 界面图不生成 mipmap尺寸上限 1024 Icons/ 图标尺寸上限 256强制 Sprite Characters/ 角色贴图允许 2048ASTC 6x6 Environment/ 场景贴图允许 2048ASTC 8x8 Masks/ 需要脚本读像素的遮罩强制 isReadable NormalMaps/ 法线贴图强制 sRGBfalse Models/ 模型关闭相机与灯光导入 Audio/ BGM/ 背景音乐Streaming预加载关闭 SFX/ 音效DecompressOnLoad预加载开启 Resources/ 运行时加载资源规则更严格这个结构的核心思路是目录即规则。每个目录对应一套参数档位规则用前缀匹配就能确定不需要解析文件名不需要读配置。好处是规则可解释——任何人看到一张贴图放在Art/Textures/UI下就知道它是什么设置不用打开 Inspector。4.2 规则表的两种实现方式实现上有两条路各有取舍。硬编码规则在脚本里写一串if/else或者switch直接按路径前缀返回参数。优点是简单直接调试方便IDE 能跳转重构有保障。缺点是新增档位要改代码非程序员无法参与。数据驱动规则把规则写成ScriptableObject或者 JSON脚本读取配置来决定参数。优点是策划/TA 可以自己加档位不用等程序改代码缺点是多了一层间接配置文件本身也会进版本库、也会冲突而且要处理配置不存在时用哪套默认值的问题。我在中小项目里基本都用硬编码因为档位数量通常在 6 到 10 之间改代码的成本完全可接受而且硬编码能被编译期检查保护。只有在档位超过 20 个、或者有非程序员需要频繁调整的情况下才值得上数据驱动——这时候我会用ScriptableObject而不是 JSON因为ScriptableObject可以直接在 Inspector 里编辑还有对象引用和类型安全。4.3 匹配函数的写法与三个边界不管用哪种方式路径匹配函数都要处理三个边界情况这三个我自己全踩过。第一分隔符统一用正斜杠。Unity 的assetPath在 Windows 上也是Assets/Art/Textures/UI/xxx.png用的是/。但在脚本里拼路径时如果用了Path.Combine在某些情况下会得到反斜杠导致StartsWith匹配失败。统一用/手工拼接或者匹配前做一次Replace(\\, /)。第二注意前缀匹配的包含关系。Assets/Art/Textures/UI和Assets/Art/Textures/UI_Old在字符串层面是前缀关系StartsWith(Assets/Art/Textures/UI)会把UI_Old也匹配进去。所以目录匹配必须以/结尾或者用assetPath.StartsWith(prefix /)。这是一个非常典型的、只在特定目录命名下才暴露的 bug。第三大小写敏感不可靠。不同操作系统的文件系统大小写策略不一样工程挪到另一台机器上时可能出现匹配失效。我的做法是全部用统一的小写目录名匹配前把assetPath转小写比较从源头上避免。private static bool InFolder(string assetPath, string folder) { // folder 传入时统一不带结尾斜杠如 Assets/Art/Textures/UI return assetPath.StartsWith(folder /, System.StringComparison.OrdinalIgnoreCase); }5. 贴图导入的参数落地与显存占用估算5.1 maxTextureSize 应该按用途倒推maxTextureSize是最常被设错的字段因为大多数人凭感觉设。正确的做法是按屏幕上的实际显示尺寸倒推再留一点余量。一张 UI 图如果它在 1080p 下的设计尺寸是 300x300 像素那么它在 4K 屏上最多显示到 600x600。设成maxTextureSize 1024已经绰绰有余设成 2048 就是纯粹的内存浪费。一个全屏背景图1080p 下是 1920x10804K 下是 3840x2160那就需要 4096——但这时候更该考虑的是用一张 2048 的图配拉伸还是真的需要 4K 精度。场景贴图同理。第一人称游戏里玩家永远看不到的远处装饰物贴图512 是合理的主角身上镜头会怼近的贴图2048 甚至 4096 才有意义。这个判断需要美术和程序一起过一遍资源清单但它带来的收益非常直接——下面这张表是我常用的参考档位用途maxTextureSize备注UI 图标256关闭 mipmapUI 面板/按钮512 ~ 1024关闭 mipmapUI 全屏背景2048关闭 mipmap视情况开角色主体2048ASTC 6x6场景小物件512ASTC 8x8场景地编大件2048ASTC 8x8遮罩/查找表256不压缩关闭 mipmap开启 Read/Write法线贴图1024 ~ 2048关闭 sRGB压缩率可放宽5.2 压缩格式背后的显存账很多人对压缩格式的感知停留在ASTC 比 ETC2 好但具体差多少算一遍就清楚了。以一张 2048x2048 的贴图为例格式每像素位数单张显存不含 mip含 mip约 33%RGBA32未压缩32 bpp16.8 MB22.4 MBETC2 RGBA88 bpp4.2 MB5.6 MBETC2 RGB44 bpp2.1 MB2.8 MBASTC 6x6约 3.56 bpp1.87 MB2.5 MBASTC 8x82.0 bpp1.05 MB1.4 MB算一下就知道差距有多大一张未压缩的 2048 贴图换成 ASTC 8x8 之后只要 1.4MB省了 93%。如果工程里有 200 张这样的贴图这个选择决定的是 4.5GB 和 280MB 的差别——前者在任何目标设备上都跑不起来后者是个很正常的量级。ASTC 的块大小选择有个经验规则6x6 是质量与体积的平衡点8x8 用于远景和小物件4x4 只在有明显块状伪影且镜头会长时间注视的资源上用。注意 ASTC 6x6 相对于 4x4 只省了约 44% 的体积但对平滑渐变的贴图来说6x6 的伪影在正常视距下基本看不出来所以除非是 UI 里的大面积渐变或者角色脸部一般不需要上 4x4。提醒一句不同平台对格式的支持不一样。ASTC 在现代移动端和桌面端都没问题但如果你的目标设备比较老可能还需要在平台覆盖设置里指定 ETC2 或者 DXT。TextureImporterPlatformSettings就是干这个的。5.3 平台覆盖设置与 sRGB、mipmap 的取舍平台覆盖设置Platform Override是一个很容易被忽略但非常实用的机制。你可以给同一个资源设置不同平台下的不同参数比如 Android 上用 ASTC 6x6iOS 上也用 ASTC 6x6但 Windows 上用 DXT5。代码里的写法是构造一个TextureImporterPlatformSettings对象然后调SetPlatformTextureSettingsvar android new TextureImporterPlatformSettings { name Android, overridden true, maxTextureSize 1024, format TextureImporterFormat.ASTC_6x6, textureCompression TextureImporterCompression.Compressed, compressionQuality 50, crunchedCompression false, }; importer.SetPlatformTextureSettings(android);关于crunchedCompression它在 ETC 系列上是有效的能进一步压缩磁盘体积但会增加加载时的解压耗时。移动端如果包体压力大可以开桌面上一般没意义。再单独说两个字段。sRGBTexture必须按贴图的语义来决定基础色、自发光、UI 图这些是色彩数据用 sRGB法线贴图、粗糙度、金属度、遮罩、查找表这些是数值数据必须sRGBTexture false。区分错了的后果是光照算错而且错误很隐蔽——看起来能用但整体色调和美术的参考图对不上排查起来非常费时间。mipmapEnabled的判断标准是这张图会不会被缩得很小显示。UI 图通常按原尺寸绘制缩放场景不多mipmap 白白增加 33% 内存关掉3D 场景里的贴图会被远处的相机拉远缩小mipmap 能显著减少采样时的闪烁和远处的噪点开着。另外如果工程开了 Mipmap Streaming纹理流送mipmap 的额外成本可以在运行时按需加载这时候开启的代价会小很多。6. 模型、音频与动画资源的导入细则6.1 ModelImporter 的关键开关模型导入的坑主要集中在美术导出的 fbx 里带了不该带的东西和关闭了不该关的东西。必须关掉的importCameras和importLights。DCC 工具导出时经常把相机和灯光一起打包如果 Unity 照单全收场景里会莫名其妙多出一堆相机和点光源轻则影响烘焙重则改变整个场景的光照。这两个开关在代码里直接false没有任何犹豫的余地。importVisibility也通常关掉因为它是 Maya 的可见性动画Unity 里基本用不上。importBlendShapes要按项目需求判断如果角色不用表情动画就关掉能省下不少内存。必须按需判断的isReadable。默认关闭是对的只有需要运行时读顶点或修改网格时才开。开了之后网格数据会在内存里保留一份可读写副本一个几万面的角色网格可能就是好几 MB。meshCompression一般设成Off或者Low因为中高档位在某些平台上会有可感知的精度损失而且压缩后的网格顶点数据在某些非标准光照下会出现瑕疵。optimizeMeshPolygons和optimizeMeshVertices建议开启。前者会重排三角形索引以提高 GPU 缓存命中率后者会合并重复顶点。这两个操作都是导入期一次性完成的运行时零成本收益是渲染效率的提升。唯一的风险是某些依赖原始顶点顺序的自定义 Shader 会出问题如果遇到就针对性关掉。还有一个特别值得说的animationType。如果模型需要人形动画重定向必须设成Humanoid并配置 Avatar如果只是普通动画用Generic更省事如果模型完全不带动画直接None能避免导入一堆无用的动画数据。这个字段设错的典型症状是动画播出来人形扭曲或者重定向时骨骼对不上。void OnPreprocessModel() { var importer (ModelImporter)assetImporter; importer.importCameras false; importer.importLights false; importer.importVisibility false; importer.importBlendShapes false; importer.isReadable false; importer.optimizeMeshPolygons true; importer.optimizeMeshVertices true; importer.weldVertices true; importer.meshCompression ModelImporterMeshCompression.Off; importer.animationType ModelImporterAnimationType.Generic; }版本提醒ModelImporter的材质相关字段在不同 Unity 版本里改过名字早期是importMaterials加importMaterialLocation后来变成了materialImportMode这种枚举形式。写跨版本的脚本前先在目标版本的 API 文档里确认一下字段名不然会编译不过。6.2 音频的三种 loadType 与场景选择AudioImporter里最影响内存的就是loadType它有三种取值对应完全不同的内存与延迟特性。DecompressOnLoad加载时把压缩音频解成 PCM 放进内存。好处是播放时零解码开销适合短音效、会被频繁播放的声音坏处是内存占用大一段 30 秒的 44.1kHz 立体声音频解成 PCM 大约是 10MB。CompressedInMemory内存里保留压缩数据播放时实时解码。内存省很多但解码会占 CPU。适合中等长度的音频。Streaming从磁盘流式读取内存占用极低但依赖 IO 性能适合背景音乐这类长音频。按这个特性规则很清晰Audio/SFX下的短音效统一DecompressOnLoadAudio/BGM下的长音乐统一Streaming。同时 BGM 要关掉preloadAudioData避免在加载场景时把整段音乐预读进来造成卡顿。void OnPreprocessAudio() { var importer (AudioImporter)assetImporter; var settings importer.defaultSampleSettings; if (InFolder(assetPath, Assets/Art/Audio/BGM)) { settings.loadType AudioClipLoadType.Streaming; settings.compressionFormat AudioCompressionFormat.Vorbis; settings.quality 0.7f; settings.preloadAudioData false; } else if (InFolder(assetPath, Assets/Art/Audio/SFX)) { settings.loadType AudioClipLoadType.DecompressOnLoad; settings.compressionFormat AudioCompressionFormat.Vorbis; settings.quality 0.8f; settings.preloadAudioData true; } importer.defaultSampleSettings settings; importer.forceToMono false; }关于qualityVorbis 的 0.7 到 0.8 在绝大多数音效上听不出差别比默认的 1.0 能省下可观的体积。BGM 可以更低一些但要注意如果音乐里有明显的镲片或弦乐高频压太狠会有水声般的伪影这个一定要实际听不能只看数字。6.3 材质映射与 OnAssignMaterialModel模型导入还有一个独立于 Importer 字段的环节材质生成。当 fbx 里引用了材质时Unity 默认会为每个材质生成一个.mat资产内嵌在模型里或者独立文件命名可能是Material_1、Material_2这种毫无意义的名字。这对工程管理是个灾难。OnAssignMaterialModel就是用来接管这个过程的。它的签名大致是接收一个Material类型的源材质、渲染器名称、以及一个AssetImportContext返回一个Material。你可以在这里按命名规则查找工程里预制的材质命中就直接返回返回null则走默认逻辑。实际的用法通常是约定模型里的材质命名规则比如M_Body、M_Hair然后在工程里维护一套同名的材质库导入时按名字匹配。这样美术在 DCC 里改完模型重新导出材质引用不会断也不需要每次手工重连。这套机制在角色量大的项目里能省下大量时间。需要注意两点一是这个回调里不要做耗时操作它会对模型里的每个材质都调用一次二是返回的材质必须是工程里已存在的资产临时new Material()出来的对象不会被打包还会在下次导入时丢失。7. 幂等写入、批量重导入与性能控制7.1 幂等赋值是减少 diff 的关键AssetPostProcessor 里最容易忽略的性能和协作问题是无意义赋值导致的.meta变化。如果你每次都无条件写importer.maxTextureSize 1024;即使这个资源本来就是 1024Unity 也可能判定该 Importer 发生了变化进而触发重新序列化和重新导入。在资源多的时候这会造成大量无意义的.meta文件变动版本库里天天出现几百个文件的 diff代码审查根本没法看。解决办法很简单赋值前先比较。private static void SetIfChanged(ref int current, int target, System.Actionint setter) { if (current target) return; setter(target); }实际用起来会更直接一些因为TextureImporter的很多字段是属性可以直接读if (importer.maxTextureSize ! targetSize) importer.maxTextureSize targetSize; if (importer.mipmapEnabled ! wantMipmap) importer.mipmapEnabled wantMipmap; if (importer.sRGBTexture ! wantSRGB) importer.sRGBTexture wantSRGB;这几行看起来啰嗦但它把只在必要时改这个原则落实了。实测在一个两三千张贴图的工程里改成幂等赋值之后规范调整时的git status从几百个文件降到了实际需要变的那几十个。7.2 批量重导入要用 StartAssetEditing 包起来当你需要强制重导入一批资源时比如改了GetVersion之后或者写了个菜单工具批量修正一定要用AssetDatabase.StartAssetEditing()和StopAssetEditing()把循环包起来AssetDatabase.StartAssetEditing(); try { foreach (var path in paths) { AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); } } finally { AssetDatabase.StopAssetEditing(); } AssetDatabase.Refresh();原理是StartAssetEditing会暂停 Unity 的资源导入队列和自动刷新让你把所有的导入请求一次性排好队StopAssetEditing之后统一处理。如果不加这一对每个ImportAsset都会触发一次完整的导入加刷新周期几百个资源跑下来可能从 30 秒变成十几分钟。这里必须用try/finally因为如果中途抛异常而没有调用StopAssetEditingUnity 会一直处于暂停导入状态表现为文件改了不生效、资源不刷新只能重启编辑器——这个坑我踩过一次排查了半小时才反应过来。7.3 CI 上的耗时控制把导入规范校验放到流水线上是个好主意但要注意耗时。CI 机器上通常是全新 checkout第一次打开工程会全量导入这本身就是最慢的一步。如果这时候GetVersion又触发了二次全量重导入构建时间会直接翻倍。我的做法是分两步本地开发用GetVersion保证资源在编辑器里保持最新规范CI 上则用离线校验代替强制重导入。具体来说写一个静态方法遍历工程里的资源读取.meta或者通过AssetImporter.GetAtPath拿到 Importer逐项比对规范把不合规的资源路径和字段打印出来最后用非零退出码让流水线失败。public static void Validate() { var guids AssetDatabase.FindAssets(t:Texture, new[] { Assets/Art }); var bad new Liststring(); foreach (var guid in guids) { var path AssetDatabase.GUIDToAssetPath(guid); var importer AssetImporter.GetAtPath(path) as TextureImporter; if (importer null) continue; var expected TextureRule.For(path); if (importer.maxTextureSize ! expected.MaxSize || importer.sRGBTexture ! expected.SRGB) { bad.Add(path); } } if (bad.Count 0) { foreach (var p in bad) Debug.LogError($[ImportRule] 不合规: {p}); EditorApplication.Exit(1); } }这个校验只读不写跑一遍通常在一分钟以内比强制重导入快得多。而且它的价值在于明确指出哪里不合规而不是默默修好了让人不知道发生了什么。规范类的问题让人看见比修好更重要。8. 排查链路脚本看起来生效其实没生效这一节讲的是排查方法不是结论。因为 AssetPostProcessor 的失效原因很多直接给答案帮助有限把链路走一遍才能自己定位。8.1 第一步确认回调到底有没有被调用绝大多数脚本不生效的问题卡在第一步——回调根本没跑。检查脚本位置。AssetPostprocessor的子类必须位于Editor目录下任意层级的Editor文件夹都行或者被#if UNITY_EDITOR包裹或者整个脚本所在的 asmdef 是 Editor 平台。放在运行时程序集里类会被编译进包体但回调在编辑器里不会触发。这是最常见的失效原因。检查类本身。AssetPostprocessor的子类不能是泛型类也不能是嵌套类写在另一个类内部的类这两条约束不满足时 Unity 找不到它。类名和文件名不要求一致但建议一致方便排查。检查是否有编译错误。编辑器有任何一个脚本编译不通过所有 AssetPostprocessor 都不会执行。这是最隐蔽的一种——你盯着自己的脚本看半天结果是另一个模块少了个分号。排查手段是看 Console 有没有红色的编译错误或者直接看编辑器右下角有没有转圈的编译图标。加临时日志确认。在OnPreprocessTexture开头打一句Debug.Log(assetPath)然后重新导入一个资源右键Reimport看 Console 有没有输出。有输出说明回调跑了问题在后面没输出说明回调没跑回到上面三条继续查。8.2 第二步确认改动有没有落盘回调跑了但设置没变通常是改了但被覆盖或者没保存。先看是不是被后面的逻辑覆盖。如果工程里有多个 AssetPostprocessor 都在处理贴图执行顺序会影响最终结果。用GetPostprocessOrder()显式指定顺序或者干脆合并到一个类里。第三方插件尤其是一些自动图集、自动压缩工具经常会写自己的 Postprocessor这是很容易忽略的干扰源。再看是不是根本没触发重新导入。OnPreprocessTexture只在资源被导入时执行。如果你只是修改了脚本工程里已经存在的资源不会自动重新走一遍流程。必须显式触发选中资源右键Reimport或者改GetVersion的返回值让 Unity 全量重导。这是最多人困惑的点——我明明改了代码为什么 Inspector 里的值没变。最后看有没有落盘。编辑器里 Importer 的改动可能还没写回.meta文件里。AssetDatabase.SaveAssets()能强制写盘但更靠谱的做法是让重新导入流程自然完成导完之后.meta会自动更新。可以在改完之后用文本编辑器打开某个资源的.meta确认一下。8.3 两个经典事故递归重导入与域重载丢状态递归重导入。这是最危险的一种。如果在OnPreprocessTexture里调用了importer.SaveAndReimport()就会触发一次新的导入新一轮导入又调用OnPreprocessTexture又调一次SaveAndReimport——编辑器直接卡死或者堆栈溢出。类似的问题还会出现在OnPostprocessAllAssets里修改资源修改会触发新的导入新的导入又触发OnPostprocessAllAssets。判断方法很简单如果你看到编辑器在导入时卡住、CPU 跑满、或者 Console 里疯狂刷同一条日志八成是递归了。解决办法是在修改前后加一个静态标志位做守卫或者严格区分什么阶段做什么事——改 Importer 只在 preprocess 阶段需要二次处理的资源收集到一个列表里在OnPostprocessAllAssets或者延迟回调里统一处理。private static bool s_IsReimporting; void OnPreprocessTexture() { if (s_IsReimporting) return; // ... 正常的规则逻辑绝不在这里调用 SaveAndReimport }域重载丢失静态状态。Unity 在脚本重新编译、进入播放模式等时机会重新加载程序集并重置静态字段这就是 Domain Reload。如果你用静态字段做缓存或者计数器它会在这些时机莫名清空。如果做的是本轮导入中收集的资源列表这种一次性数据清空其实是对的但如果做的是已经处理过的路径集合这种需要跨编译保留的状态就必须存到EditorPrefs、ScriptableSingleton或者一个临时资产里。排查这类问题的特征是行为看起来随机——有时候对有时候不对重启编辑器又好了。遇到这种症状先怀疑静态状态。9. 让规范长期活着从本地脚本到提交前校验9.1 一个可自查的编辑器菜单脚本能自动纠正当然好但我强烈建议再加一个只读的检查入口做成菜单项放在Tools/资源规范检查下面。原因是这样自动纠正会让问题变得不可见团队里没人知道曾经有多少资源不合规、哪些目录是重灾区。而检查工具输出的报告能反过来告诉你规范本身是不是有问题——比如某个目录持续出现大量不合规很可能是这一档的参数设得不合理或者目录的划分方式跟大家的使用习惯冲突。报告的形式我一般做两个视图一个是按目录聚合的统计看哪个目录问题最多一个是按字段聚合的统计看哪个字段最常被改错。字段维度的统计特别有用如果一个字段反复出问题说明要么脚本没覆盖到要么这个字段的规范本身就和实际需求有冲突。9.2 提交前与流水线两道关本地脚本解决的是编辑器里资源是对的状态但解决不了提交上去的是不是对的。所以还需要两道关。第一道是提交前钩子。如果团队用 Git可以在.githooks/pre-commit里检查这次提交是否修改了Art目录下的资源如果有就调用 Unity 的批处理模式跑一遍校验方法不通过就拒绝提交。这道关的好处是反馈快而且能拦住改完没保存这类问题。第二道是流水线校验。在构建任务前面插一个校验步骤用-batchmode -quit -executeMethod跑前面写的Validate()方法。这道关可能有人觉得多余因为本地已经拦了。但实际上本地钩子是可以被绕过的--no-verify而且有人会用别的客户端提交。流水线上的校验是最后一道也是最可靠的一道。这两道关都要注意一个细节校验的失败信息必须包含具体的资源路径和具体字段。不合规: Assets/Art/Textures/UI/btn_ok.png (maxTextureSize2048, 期望 512)这样的输出比一句规范校验失败有用一百倍。9.3 规范本身的版本管理最后说一个容易被忽略的事规范是会变的而规范的变更需要像代码一样被管理。具体做法是把规则常量集中到一个文件里用注释记录每一次变更的原因。比如前面GetVersion里那段注释就是干这个的。当美术说角色贴图 2048 不够需要 4096时改的不只是那个数字还要在注释里写清楚为什么改、什么时候改的、影响范围是什么。这样半年后有人问为什么角色贴图是 4096能查到答案而不是看一堆没有上下文的历史提交。同时规范的变更最好走一次代码审查并且在合并前先在本地跑一遍全量重导入确认没有意外。因为规范变更的影响面是整个工程的资源一旦改错全组人拉下来都要重导一遍。我自己的习惯是改完规范先在本地切一个分支改GetVersion加一跑完重导入看一遍git status和git diff确认变化的文件数量符合预期再提交。这个习惯拦下过好几次正则写错导致误伤整个目录的事故。如果你的项目还在早期资源量在一千以内我的建议是先把目录约定和哈希值级别的校验做起来AssetPostProcessor 从最痛的两三个字段开始写别一上来就追求全覆盖。规范的价值在于被执行而不在于被写全。等团队习惯了资源拖进来就是对的这个体验之后再逐步把更多字段纳入强制约束阻力会小得多。
阅读完成 · 觉得有帮助?