侧边栏壁纸
博主头像
LittleAO的学习小站 博主等级

在知识的沙漠寻找绿洲

  • 累计撰写 125 篇文章
  • 累计创建 27 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Unity 自定义导入管线

LittleAO
2026-01-14 / 0 评论 / 0 点赞 / 11 阅读 / 0 字
温馨提示:
本文最后更新于2026-01-14,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

技术栈:Unity 编辑器工具开发

在Unity日常开发中可能会遇到以下问题:

  1. 团队合作,我想要约束素材的某些规范,例如命名、一些设置的调整等,但难以维护。

  2. 导入某些资源时,我想针对性的对这些资源进行优化处理。

Unity提供了AssetPostprocesser类可以帮助开发者自定义想要的素材导入管线,如此即可解决上述的问题。API文档如下:

https://docs.unity3d.com/ScriptReference/AssetPostprocessor.html

写一个最简单的流水线

继承UnityEditor.AssetPostprocessor类,可以实现资源导入管线的自定义,从而实现对资源流程规范和资源优化等方面的处理。一个最简单的资源流水线定义结构如下:

using UnityEditor;
using UnityEngine;

public class MyAssetPipeline : AssetPostprocessor
{
    static void OnPostprocessAllAssets(
        string[] importedAssets, 
        string[] deletedAssets, 
        string[] movedAssets, 
        string[] movedFromAssetPaths)
    {
        foreach (var str in importedAssets)
        {
            Debug.Log("导入了: " + str);
        }
    }
}

该段代码能够在日志面板输出当前导入了哪些资源。将脚本放置在Editor目录下,编译后向AssetPackage拖入任意一个资源即可触发脚本逻辑。

静态函数OnPostprocessAllAssets全局监听,当任意资源发生变动时会触发该函数。在这个函数中,我们可以处理文件级的操作,例如重命名等。

如果想要更改项目中已存在的资源,可以使用成员函数OnPreprocessAssetOnPostprocess[type]。顾名思义,两种函数分别对文件导入前后做处理。

  • 预处理阶段:可以对导入素材的设置进行处理,素材处理过程中,就直接按照设置让Unity处理,避免按照默认处理完后,再改设置再Unity处理。

  • 后处理阶段:此时Unity已经对资源处理完成,如果想要对处理完成的资源进行进一步处理(例如添加脚本等),在此阶段处理。

下面的示例脚本,对项目中所有的Texture和Cubemap做了处理:

using UnityEditor;
using UnityEngine;

public class MyAssetPipeline : AssetPostprocessor
{
    void OnPreprocessTexture()
    {
        if (!assetPath.Contains("fx_")) return;
        Debug.Log("OnPreprocessCubemap: " + assetPath);
    }

    void OnPostprocessCubemap(Cubemap cubemap)
    {
        Debug.Log("OnPostprocessCubemap cubemap count: "+ cubemap.mipmapCount);
    }
}

编译完成这个脚本后,Unity开始对项目中所有的Texture遍历处理,并执行OnPreprocessTexture逻辑。OnPostprocessCubemap同理。

为了不误伤其他资源,推荐使用守卫模式,!assetPath.Contains来避免对所有相关资源做处理。

根据所导入类型的不同,需要使用不同的生命周期回调,详情可见API文档,这里做一个简单的列表表示可以有哪些回调:

方法名

中文名称

调用时机

说明

OnAssignMaterialModel

分配材质模型

提供源材质时

提供源材质

OnPostprocessAllAssets

后处理所有资源

任何数量的资源导入完成后(资源进度条到达末尾时)

在所有资源导入完成后调用

OnPostprocessAnimation

后处理动画

AnimationClip 完成导入时

当动画剪辑完成导入时调用

OnPostprocessAssetbundleNameChanged

后处理资源包名称变更

资源被分配到不同的资源包时

当资源被分配到不同的资源包时调用

OnPostprocessAudio

后处理音频

音频剪辑完成导入时

当音频剪辑完成导入时调用

OnPostprocessCubemap

后处理立方体贴图

立方体贴图纹理完成导入之前

在立方体贴图纹理完成导入之前调用

OnPostprocessGameObjectWithAnimatedUserProperties

后处理带动画用户属性的游戏对象

自定义属性的动画曲线完成导入时

当自定义属性的动画曲线完成导入时调用

OnPostprocessGameObjectWithUserProperties

后处理带用户属性的游戏对象

每个至少有一个用户属性的 GameObject 导入时

为每个至少有一个用户属性的 GameObject 调用

OnPostprocessMaterial

后处理材质

ModelImporter 导入期间创建新材质时

当在模型导入器导入期间创建新材质时调用

OnPostprocessMeshHierarchy

后处理网格层次结构

新的变换层次结构完成导入时

当新的变换层次结构完成导入时调用

OnPostprocessModel

后处理模型

模型完成导入时

当模型完成导入时调用

OnPostprocessPrefab

后处理预制体

Prefab 完成导入时

当预制体完成导入时调用

OnPostprocessSpeedTree

后处理 SpeedTree

SpeedTree 资源完成导入时

当 SpeedTree 资源完成导入时调用

OnPostprocessSprites

后处理精灵

精灵纹理完成导入时

当精灵纹理完成导入时调用

OnPostprocessTexture

后处理纹理

Unity 压缩 Texture2D 之前

在 Unity 压缩纹理之前调用

OnPostprocessTexture2DArray

后处理纹理2D数组

Unity 压缩 Texture2DArray 之前

在 Unity 压缩纹理2D数组之前调用

OnPostprocessTexture3D

后处理纹理3D

Unity 压缩 Texture3D 之前

在 Unity 压缩纹理3D之前调用

OnPreprocessAnimation

预处理动画

从模型(.fbx、.mb 等)导入动画之前

在从模型导入动画之前调用

OnPreprocessAsset

预处理资源

任何资源导入之前

在任何资源导入之前调用

OnPreprocessAudio

预处理音频

音频剪辑导入之前

在音频剪辑导入之前调用

OnPreprocessCameraDescription

预处理相机描述

从模型导入器导入相机时

当从模型导入器导入相机时调用

OnPreprocessLightDescription

预处理光源描述

从模型导入器导入光源时

当从模型导入器导入光源时调用

OnPreprocessMaterialDescription

预处理材质描述

ModelImporter 导入期间创建新材质时

当在模型导入器导入期间创建新材质时调用

OnPreprocessModel

预处理模型

模型(.fbx、.mb 等)导入之前

在模型导入之前调用

OnPreprocessSpeedTree

预处理 SpeedTree

SpeedTree 资源(.spm 文件)导入之前

在 SpeedTree 资源导入之前调用

OnPreprocessTexture

预处理纹理

纹理导入器运行之前

在纹理导入器运行之前调用

使用AssetImporter做更多事

AssetPostprocessor中有三个属性:

Property

中文名称

说明

assetImporter

资源导入器

资源导入器的引用

assetPath

资源路径

正在导入的资源路径名

context

导入上下文

导入上下文

最常用的是assetPathassetImporterassetImporterAssetImporter的实例,AssetImporter可以根据当前处理类型的不同,使用不同的子类类型,将原始文件转换成Unity可用的资源。例如FBX -> GameObject、PNG -> Texture2D。

请关注这样一个现象:当导入FBX文件时,Importer会将FBX文件转换成GameObject。但是使用文件管理器查看文件,文件的大小、数据都没有发生改变。这体现了Unity的资源管理的核心机制:当资源文件放在Asset目录下时,Unity不会修改这里的文件,会做的就是创建meta文件标识这个文件的GUID。Importer会处理这个原始文件,转换成Unity可以识别的二进制文件并保存在Library目录下。放在Library目录下的数据是Unity运行时实际加载的数据。当我们说“AssetPostprocessor 修改了资源”时,通常是指它改变了生成到 Library 中的那份数据,而 Assets/ 目录下的原始文件通常保持原样。

因此也解释了一个现象:为什么首次打开没有Library的工程时为什么会这么慢。

https://docs.unity3d.com/ScriptReference/AssetImporter.html

之前所说的更改导入参数,就是更改AssetImporter的参数。常用的AssetImporter类型和参数如下表所示:

Importer 类名

对应文件类型

常用设置

ModelImporter

.fbx, .obj, .blend

isReadable, meshCompression, importAnimation, materialImportMode

TextureImporter

.png, .jpg, .tga, .psd

textureType (Sprite/Default), maxTextureSize, compressionQuality, mipmapEnabled

AudioImporter

.wav, .mp3, .ogg

forceToMono, loadType (Streaming/Compressed), preloadAudioData

VideoClipImporter

.mp4, .mov

视频转码设置

PluginImporter

.dll, .so, .bundle

设置该插件支持哪些平台 (Win/Android/iOS)

ShaderImporter

.shader, .hlsl

Shader 相关设置

下面的示例脚本可以将所有导入的图片默认更改为Sprite格式:

using UnityEditor;
using UnityEngine;

public class TextureImportSetting : AssetPostprocessor
{
    // 在图片导入前被调用
    void OnPreprocessTexture()
    {
        // 1. 获取导入器引用
        TextureImporter importer = (TextureImporter)assetImporter;

        // 2. 检查是否已经是 Sprite(防止重复修改导致死循环)
        if (importer.textureType != TextureImporterType.Sprite)
        {
            // 3. 修改为 Sprite 格式
            importer.textureType = TextureImporterType.Sprite;
        }
    }
}

Unity执行Processor的机制

当你编写一个 AssetPostprocessor 时,Unity 会在后台计算这个脚本的 Hash 值。默认情况下,一旦 AssetPostprocessor 代码发生变化(重新编译),Unity 会保守地认为所有可能受影响的资源都需要重新导入。为了避免这个问题,Unity 提供了GetVersion()方法:

public override uint GetVersion()
{
    return 1; // 如果逻辑变了,手动把这个数字改成 2
}

Unity 会将这个 GetVersion 的返回值作为资源导入依赖的一部分存入 Library 数据库。这样即使修改了 Postprocessor 的逻辑,但是没有修改GetVersion的返回值,Unity会认为版本没变,也不会重新导入相关资源。

除了版本号,你还可以在 assetImporter.userData 中写入自定义字符串作为标记。

void OnPreprocessTexture()
{
    if (assetImporter.userData.Contains("Processed_By_Me")) return;
    
    // 做处理...
    
    assetImporter.userData += ";Processed_By_Me";
}

即使重新导入,只要 Importer 设置没被重置,这个标记就会存在。通常用于防止重复处理或标记特殊资源。

实际应用

根据目录自动压缩Texture

using UnityEngine;
using UnityEditor;
using UnityEditor.Presets;

public class TexturePresetApplier : AssetPostprocessor
{
    void OnPreprocessTexture()
    {
        string presetName = ExtractPresetName(assetPath);
        if (!string.IsNullOrEmpty(presetName))
        {
            ApplyPreset(presetName);
        }
    }
    
    private string ExtractPresetName(string path)
    {
        // 使用 switch expression (C# 8.0+) 优化多个 Contains 判断
        return path switch
        {
            var p when p.Contains("/64/") => "64",
            var p when p.Contains("/128/") => "128",
            var p when p.Contains("/256/") => "256",
            var p when p.Contains("/512/") => "512",
            var p when p.Contains("/1024/") => "1024",
            var p when p.Contains("/2048/") => "2048",
            _ => null
        };
    }
    
    void ApplyPreset(string presetName)
    {
        // 获取预设文件路径(根据你的预设文件路径调整)
        string presetPath = $"Assets/Editor/Preset/{presetName}.preset";

        // 加载预设
        Preset preset = AssetDatabase.LoadAssetAtPath<Preset>(presetPath);
        TextureImporter textureImport = (TextureImporter)assetImporter;
        // 如果预设存在,应用到当前资源
        if (preset != null)
        {
            // 将 texture 转换为 Object 类型
            preset.ApplyTo(textureImport);
        }
        else
        {
            Debug.LogWarning($"Preset not found: {presetPath}");
        }
    }

在对应目录设置preset,当图片文件导入素材库时,判断其路径是否包含尺寸数字(例如文件导入到了256文件夹下),如果导入了就自动设置图片的参数,实现图片的自动压缩处理。

压缩FBX动画文件

根据https://zhuanlan.zhihu.com/p/353402448改写而来

using System;
using UnityEditor;
using UnityEngine;

namespace CustomTools
{
    /// <summary>
    /// 处理以 _ani 结尾的 FBX 动画文件:
    /// 1. 移除恒定的 Scale 曲线
    /// 2. 设置动画压缩模式为 Optimal
    /// 3. 关键帧精度压缩为 f3
    /// </summary>
    public class FbxAnimationPostProcessor : AssetPostprocessor
    {
        public override uint GetVersion()
        {
            return 2;
        }

        private bool ShouldProcess()
        {
            return assetPath.EndsWith("_ani.fbx", StringComparison.OrdinalIgnoreCase);
        }

        public void OnPreprocessModel()
        {
            if (!ShouldProcess()) return;

            ModelImporter modelImporter = assetImporter as ModelImporter;
            if (modelImporter == null) return;
            
            // 仅处理包含动画的模型
            if (modelImporter.importAnimation)
            {
                // 在导入前设置动画压缩参数(用户导入后仍可在 Inspector 中修改)
                if (modelImporter.animationCompression != ModelImporterAnimationCompression.Optimal)
                {
                    modelImporter.animationCompression = ModelImporterAnimationCompression.Optimal;
                }

                // 降低压缩误差容限,减少抖动(默认通常是0.5,这里设为0.1或更小)
                modelImporter.animationRotationError = 0.1f;
                modelImporter.animationPositionError = 0.1f;
                modelImporter.animationScaleError = 0.1f;
            }
        }

        public void OnPostprocessModel(GameObject gameObject)
        {
            if (!ShouldProcess()) return;

            ModelImporter modelImporter = assetImporter as ModelImporter;
            if (modelImporter == null) return;
            
            // 仅处理包含动画的模型
            if (modelImporter.importAnimation)
            {
                OptimizeAnimationCurves(modelImporter);
            }
        }

        private void OptimizeAnimationCurves(ModelImporter modelImporter)
        {
            // 处理 Clip 中的曲线精度
            ModelImporterClipAnimation[] clipAnimations = modelImporter.clipAnimations;
            if (clipAnimations == null || clipAnimations.Length == 0) return;

            bool hasChanges = false;
            for (int i = 0; i < clipAnimations.Length; i++)
            {
                var clip = clipAnimations[i];
                var curves = clip.curves;
                if (curves == null) continue;

                for (int j = 0; j < curves.Length; j++)
                {
                    var curveData = curves[j];
                    AnimationCurve curve = curveData.curve;
                    if (curve == null || curve.keys == null) continue;

                    Keyframe[] keys = curve.keys;
                    bool curveModified = false;

                    for (int k = 0; k < keys.Length; k++)
                    {
                        Keyframe key = keys[k];
                        float oldVal = key.value;
                        float oldIn = key.inTangent;
                        float oldOut = key.outTangent;

                        key.value = (float)Math.Round(key.value, 3);
                        key.inTangent = (float)Math.Round(key.inTangent, 3);
                        key.outTangent = (float)Math.Round(key.outTangent, 3);

                        if (!Mathf.Approximately(oldVal, key.value) ||
                            !Mathf.Approximately(oldIn, key.inTangent) ||
                            !Mathf.Approximately(oldOut, key.outTangent))
                        {
                            keys[k] = key;
                            curveModified = true;
                        }
                    }

                    if (curveModified)
                    {
                        curve.keys = keys;
                        curveData.curve = curve;
                        curves[j] = curveData;
                        hasChanges = true;
                    }
                }

                clipAnimations[i] = clip;
            }

            if (hasChanges)
            {
                modelImporter.clipAnimations = clipAnimations;
            }
        }

        void OnPostprocessAnimation(GameObject root, AnimationClip clip)
        {
            if (!ShouldProcess()) return;
            
            // 移除 Scale 曲线
            var bindings = AnimationUtility.GetCurveBindings(clip);
            foreach (var binding in bindings)
            {
                string name = binding.propertyName.ToLower();
                if (name.Contains("scale"))
                {
                    AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
                    if (curve != null && IsCurveConstant(curve))
                    {
                        // 获取节点的默认Scale值
                        float defaultValue = GetDefaultScaleValue(root, binding);
                        float curveValue = curve.keys.Length > 0 ? curve.keys[0].value : defaultValue;
                        
                        // 只有当曲线值恒定且等于默认值时,才删除曲线
                        // 如果曲线值恒定但不等于默认值,必须保留曲线
                        if (Mathf.Abs(curveValue - defaultValue) < 0.001f)
                        {
                            AnimationUtility.SetEditorCurve(clip, binding, null);
                        }
                    }
                }
            }

            // 精度压缩 (针对已经在内存中生成的 Clip)
            CompressClipPrecision(clip);
        }

        private float GetDefaultScaleValue(GameObject root, EditorCurveBinding binding)
        {
            if (root == null) return 1f;
            
            Transform targetTransform = string.IsNullOrEmpty(binding.path) 
                ? root.transform 
                : root.transform.Find(binding.path);
            
            if (targetTransform == null) return 1f;
            
            // 根据属性名返回对应的默认值
            string propName = binding.propertyName.ToLower();
            if (propName.Contains("m_localscale.x") || propName == "scale.x")
                return targetTransform.localScale.x;
            else if (propName.Contains("m_localscale.y") || propName == "scale.y")
                return targetTransform.localScale.y;
            else if (propName.Contains("m_localscale.z") || propName == "scale.z")
                return targetTransform.localScale.z;
            
            return 1f;
        }

        private bool IsCurveConstant(AnimationCurve curve)
        {
            if (curve == null || curve.keys.Length == 0) return true;
            if (curve.keys.Length == 1) return true;

            float firstVal = curve.keys[0].value;
            // 允许极小的浮点误差
            float epsilon = 0.001f;

            for (int i = 1; i < curve.keys.Length; i++)
            {
                if (Mathf.Abs(curve.keys[i].value - firstVal) > epsilon)
                {
                    return false;
                }
            }
            return true;
        }

        private void CompressClipPrecision(AnimationClip clip)
        {
            var bindings = AnimationUtility.GetCurveBindings(clip);
            foreach (var binding in bindings)
            {
                AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
                if (curve == null || curve.keys == null) continue;

                Keyframe[] keys = curve.keys;
                bool modified = false;

                for (int i = 0; i < keys.Length; i++)
                {
                    Keyframe key = keys[i];
                    key.value = (float)Math.Round(key.value, 3);
                    key.inTangent = (float)Math.Round(key.inTangent, 3);
                    key.outTangent = (float)Math.Round(key.outTangent, 3);
                    keys[i] = key;
                    modified = true;
                }

                if (modified)
                {
                    curve.keys = keys;
                    AnimationUtility.SetEditorCurve(clip, binding, curve);
                }
            }
        }
    }
}

该脚本可以对FBX的动画文件进行压缩,其压缩步骤是:

  1. 将动画压缩模式调整为Optimal;

  2. 移除不必要的 Scale 曲线;

  3. 将关键帧精度压缩为 f3 (保留3位小数)

这样处理可以将动画文件的大小减少90%甚至更多。

0

评论区