前言
技术栈:Unity 编辑器工具开发
在Unity日常开发中可能会遇到以下问题:
团队合作,我想要约束素材的某些规范,例如命名、一些设置的调整等,但难以维护。
导入某些资源时,我想针对性的对这些资源进行优化处理。
Unity提供了AssetPostprocesser类可以帮助开发者自定义想要的素材导入管线,如此即可解决上述的问题。API文档如下:
写一个最简单的流水线
继承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全局监听,当任意资源发生变动时会触发该函数。在这个函数中,我们可以处理文件级的操作,例如重命名等。
如果想要更改项目中已存在的资源,可以使用成员函数OnPreprocessAsset和OnPostprocess[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文档,这里做一个简单的列表表示可以有哪些回调:
使用AssetImporter做更多事
AssetPostprocessor中有三个属性:
最常用的是assetPath和assetImporter。assetImporter为AssetImporter的实例,AssetImporter可以根据当前处理类型的不同,使用不同的子类类型,将原始文件转换成Unity可用的资源。例如FBX -> GameObject、PNG -> Texture2D。
请关注这样一个现象:当导入FBX文件时,Importer会将FBX文件转换成GameObject。但是使用文件管理器查看文件,文件的大小、数据都没有发生改变。这体现了Unity的资源管理的核心机制:当资源文件放在Asset目录下时,Unity不会修改这里的文件,会做的就是创建meta文件标识这个文件的GUID。Importer会处理这个原始文件,转换成Unity可以识别的二进制文件并保存在Library目录下。放在Library目录下的数据是Unity运行时实际加载的数据。当我们说“AssetPostprocessor 修改了资源”时,通常是指它改变了生成到 Library 中的那份数据,而 Assets/ 目录下的原始文件通常保持原样。
因此也解释了一个现象:为什么首次打开没有Library的工程时为什么会这么慢。
之前所说的更改导入参数,就是更改AssetImporter的参数。常用的AssetImporter类型和参数如下表所示:
下面的示例脚本可以将所有导入的图片默认更改为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动画文件
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的动画文件进行压缩,其压缩步骤是:
将动画压缩模式调整为Optimal;
移除不必要的 Scale 曲线;
将关键帧精度压缩为 f3 (保留3位小数)
这样处理可以将动画文件的大小减少90%甚至更多。
评论区