Files
5.24BuYu/Assets/MyGame/Framework/Editor/AnimationClipMismatchChecker.cs
T

1623 lines
56 KiB
C#
Raw Normal View History

2026-05-04 17:21:52 +08:00
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
public class HumanoidBoneMismatchChecker : EditorWindow
{
// 参考对象(可以是Prefab或Hierarchy中的对象)
private GameObject referenceObject;
// 从参考对象读取的实际骨骼结构(带完整路径)
private List<string> actualBoneFullPaths = new List<string>();
private List<string> actualBoneNames = new List<string>();
private Dictionary<string, string> bonePathToName = new Dictionary<string, string>();
private List<AnimationClip> clips = new List<AnimationClip>();
private Dictionary<AnimationClip, List<string>> mismatchDetails = new Dictionary<AnimationClip, List<string>>();
private Dictionary<AnimationClip, int> mismatchCountMap = new Dictionary<AnimationClip, int>();
private Dictionary<AnimationClip, float> matchPercentageMap = new Dictionary<AnimationClip, float>();
private Dictionary<AnimationClip, List<string>> animationBonesCache = new Dictionary<AnimationClip, List<string>>();
// 多条件检测设置
private bool useMismatchCount = true;
private bool useMatchPercentage = true;
private bool useRequiredBonesList = false;
private bool useMinBoneCount = false;
private int mismatchCountThreshold = 3;
private float matchPercentageRequired = 80f;
private int minBoneCountRequired = 10;
private List<string> requiredBonesList = new List<string>();
private string newRequiredBone = "";
private Vector2 requiredBonesScrollPos;
// 匹配模式
private bool useFullPathMatch = true;
// 判断逻辑
private bool useAndLogic = true;
private Vector2 clipListScrollPos;
private Vector2 resultScrollPos;
private Vector2 boneListScrollPos;
private string rootFolder = "Assets/Animations";
private string goodSubFolder = "good";
private string badSubFolder = "bad";
private bool exportBadClips = true;
private bool hasRunCheck = false;
private bool showAdvancedInfo = false;
private bool showBoneList = true;
private bool showConditionSettings = true;
// 异步处理
private bool isProcessing = false;
private float progress = 0f;
private string currentProgressText = "";
private Task currentTask;
// 缓存数据用于异步处理
private struct ClipData
{
public string clipPath;
public string clipName;
public List<string> bonePaths; // 预缓存的骨骼路径
}
private List<ClipData> cachedClipData = new List<ClipData>();
[MenuItem("Tools/动画骨骼检测工具")]
public static void ShowWindow()
{
GetWindow<HumanoidBoneMismatchChecker>("动画骨骼检测工具");
}
Vector2 scrollPos;
private void OnGUI()
{
// 整体滚动区域
scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
GUILayout.Label("动画骨骼检测工具", EditorStyles.boldLabel);
// 1. 参考对象
DrawReferenceObjectSection();
GUILayout.Space(10);
// 2. 动画列表
DrawClipList();
GUILayout.Space(10);
// 3. 条件设置
DrawConditionSettings();
GUILayout.Space(10);
// 4. 检测按钮
if (!isProcessing)
{
if (GUILayout.Button("开始检测动画", GUILayout.Height(40)))
RunCheckAsync();
}
else
{
DrawProgressBar();
EditorGUILayout.EndScrollView();
return;
}
GUILayout.Space(10);
// 5. 检测结果
GUILayout.Label("检测结果", EditorStyles.boldLabel);
DrawResultList();
GUILayout.Space(20);
EditorGUILayout.EndScrollView();
// 6. 导出区域(固定在底部,不随滚动)
GUILayout.Space(10);
EditorGUILayout.BeginVertical("box");
GUILayout.Label("导出设置", EditorStyles.boldLabel);
rootFolder = EditorGUILayout.TextField("导出根目录", rootFolder);
goodSubFolder = EditorGUILayout.TextField("适配子目录", goodSubFolder);
badSubFolder = EditorGUILayout.TextField("不适配子目录", badSubFolder);
exportBadClips = EditorGUILayout.Toggle("导出不适配动画", exportBadClips);
GUILayout.Space(10);
if (GUILayout.Button("导出动画", GUILayout.Height(35)))
ExportResults();
EditorGUILayout.EndVertical();
}
private void DrawProgressBar()
{
GUILayout.Label("正在检测动画...", EditorStyles.boldLabel);
GUILayout.Space(10);
EditorGUILayout.HelpBox(currentProgressText, MessageType.Info);
Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField");
EditorGUI.ProgressBar(rect, progress, $"{progress * 100:F1}%");
GUILayout.Space(10);
if (GUILayout.Button("取消", GUILayout.Height(30)))
{
isProcessing = false;
if (currentTask != null)
{
currentTask = null;
}
Repaint();
}
}
// -----------------------------
// 刷新动画缓存(修复:刷新界面 + 清空旧结果)
// -----------------------------
private void RefreshAnimationCache()
{
if (isProcessing) return;
Debug.Log("开始刷新动画缓存...");
PreCacheAnimationBones();
Debug.Log($"动画缓存刷新完成!共缓存 {cachedClipData.Count} 个动画");
// 清空旧检测结果
ClearResults();
// 强制刷新界面
Repaint();
EditorUtility.DisplayDialog(
"刷新完成",
$"动画缓存刷新完成!\n共缓存 {cachedClipData.Count} 个动画",
"确定"
);
}
// -----------------------------
// 参考对象选择区域
// -----------------------------
private void DrawReferenceObjectSection()
{
GUILayout.Label("1. 选择参考对象(骨骼来源)", EditorStyles.boldLabel);
Rect dropArea = GUILayoutUtility.GetRect(0, 50, GUILayout.ExpandWidth(true));
GUI.Box(dropArea, "拖拽 GameObject 到这里\n(可从Hierarchy或Project拖入)");
HandleReferenceObjectDragDrop(dropArea);
EditorGUI.BeginChangeCheck();
referenceObject = (GameObject)EditorGUILayout.ObjectField("参考对象", referenceObject, typeof(GameObject), true);
if (EditorGUI.EndChangeCheck() && referenceObject != null)
{
LoadGameObjectBones(referenceObject);
ClearResults();
}
if (referenceObject != null && actualBoneFullPaths.Count > 0)
{
string sourceType = PrefabUtility.IsPartOfAnyPrefab(referenceObject) ? "Prefab" : "场景对象";
EditorGUILayout.HelpBox($"✓ 已加载: {referenceObject.name} ({sourceType})\n骨骼数量: {actualBoneFullPaths.Count}", MessageType.Info);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("刷新骨骼列表", GUILayout.Width(100)))
{
LoadGameObjectBones(referenceObject);
}
if (GUILayout.Button("显示骨骼层级", GUILayout.Width(100)))
{
ShowBoneHierarchy();
}
EditorGUILayout.EndHorizontal();
}
else if (referenceObject != null && actualBoneFullPaths.Count == 0)
{
EditorGUILayout.HelpBox("⚠️ 未能从该对象读取到骨骼结构,请确保对象包含骨骼层级", MessageType.Warning);
}
else
{
EditorGUILayout.HelpBox("请拖入一个带骨骼结构的GameObject(场景中的角色或Prefab)", MessageType.Warning);
}
}
private void HandleReferenceObjectDragDrop(Rect dropArea)
{
Event evt = Event.current;
if (!dropArea.Contains(evt.mousePosition))
return;
switch (evt.type)
{
case EventType.DragUpdated:
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
evt.Use();
break;
case EventType.DragPerform:
DragAndDrop.AcceptDrag();
foreach (Object obj in DragAndDrop.objectReferences)
{
if (obj is GameObject go)
{
referenceObject = go;
LoadGameObjectBones(referenceObject);
ClearResults();
break;
}
}
evt.Use();
break;
}
}
private void ClearResults()
{
mismatchDetails.Clear();
mismatchCountMap.Clear();
matchPercentageMap.Clear();
animationBonesCache.Clear();
hasRunCheck = false;
}
// -----------------------------
// 加载GameObject的骨骼结构(带完整层级路径)
// -----------------------------
private void LoadGameObjectBones(GameObject obj)
{
actualBoneFullPaths.Clear();
actualBoneNames.Clear();
bonePathToName.Clear();
if (obj == null) return;
GameObject sourceObject = obj;
GameObject tempInstance = null;
if (PrefabUtility.IsPartOfPrefabAsset(obj) && !PrefabUtility.IsPartOfNonAssetPrefabInstance(obj))
{
tempInstance = (GameObject)PrefabUtility.InstantiatePrefab(obj);
sourceObject = tempInstance;
}
try
{
GetAllBonesWithPath(sourceObject.transform, "");
Debug.Log($"成功从 {(PrefabUtility.IsPartOfAnyPrefab(obj) ? "Prefab" : "场景对象")} 加载 {actualBoneFullPaths.Count} 个骨骼");
if (actualBoneFullPaths.Count > 0)
{
Debug.Log("骨骼层级示例(前10个):");
foreach (string path in actualBoneFullPaths.Take(10))
{
Debug.Log($" └─ {path}");
}
}
}
finally
{
if (tempInstance != null)
{
DestroyImmediate(tempInstance);
}
}
}
private void GetAllBonesWithPath(Transform trans, string parentPath)
{
string currentPath = string.IsNullOrEmpty(parentPath) ? trans.name : parentPath + "/" + trans.name;
actualBoneFullPaths.Add(currentPath);
actualBoneNames.Add(trans.name);
bonePathToName[currentPath] = trans.name;
foreach (Transform child in trans)
{
GetAllBonesWithPath(child, currentPath);
}
}
private void ShowBoneHierarchy()
{
string message = "骨骼层级结构:\n\n";
foreach (string path in actualBoneFullPaths)
{
int depth = path.Count(c => c == '/');
string indent = new string(' ', depth * 2);
string boneName = path.Split('/').Last();
message += $"{indent}└─ {boneName}\n";
if (message.Length > 30000)
{
message += "\n... (继续显示会过长,已截断)";
break;
}
}
EditorUtility.DisplayDialog("骨骼层级结构", message, "确定");
}
// -----------------------------
// 显示骨骼列表
// -----------------------------
private void DrawBoneList()
{
showBoneList = EditorGUILayout.Foldout(showBoneList, $"骨骼结构 ({actualBoneFullPaths.Count} 个骨骼,带完整路径)", true);
if (showBoneList)
{
EditorGUILayout.BeginVertical("box");
boneListScrollPos = EditorGUILayout.BeginScrollView(boneListScrollPos, GUILayout.Height(150));
string search = EditorGUILayout.TextField("搜索:", "");
var bonesToShow = actualBoneFullPaths;
if (!string.IsNullOrEmpty(search))
{
bonesToShow = actualBoneFullPaths.Where(b => b.ToLower().Contains(search.ToLower())).ToList();
}
EditorGUILayout.LabelField($"共 {bonesToShow.Count} 个骨骼", EditorStyles.miniLabel);
foreach (string bonePath in bonesToShow.Take(80))
{
GUILayout.Label($"• {bonePath}", GUILayout.Width(350));
}
if (bonesToShow.Count > 80)
{
GUILayout.Label($"... 还有 {bonesToShow.Count - 80} 个骨骼", EditorStyles.miniLabel);
}
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}
}
// -----------------------------
// 动画列表
// -----------------------------
private void DrawClipList()
{
Rect dropArea = GUILayoutUtility.GetRect(0, 60, GUILayout.ExpandWidth(true));
GUI.Box(dropArea, "拖拽 AnimationClip 到这里\n支持多选拖拽");
HandleClipDragDrop(dropArea);
GUILayout.BeginHorizontal();
GUILayout.Label($"当前动画列表 ({clips.Count} 个)", EditorStyles.boldLabel);
if (GUILayout.Button("清空列表", GUILayout.Width(80)))
{
clips.Clear();
ClearResults();
cachedClipData.Clear();
}
GUILayout.EndHorizontal();
if (clips.Count > 0)
{
clipListScrollPos = EditorGUILayout.BeginScrollView(clipListScrollPos, GUILayout.Height(100));
for (int i = 0; i < clips.Count; i++)
{
GUILayout.BeginHorizontal();
clips[i] = (AnimationClip)EditorGUILayout.ObjectField(clips[i], typeof(AnimationClip), false);
if (GUILayout.Button("✖", GUILayout.Width(25)))
{
var clipToRemove = clips[i];
clips.RemoveAt(i);
mismatchDetails.Remove(clipToRemove);
mismatchCountMap.Remove(clipToRemove);
matchPercentageMap.Remove(clipToRemove);
animationBonesCache.Remove(clipToRemove);
// 同时从缓存中移除
cachedClipData.RemoveAll(c => c.clipPath == AssetDatabase.GetAssetPath(clipToRemove));
i--;
continue;
}
GUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
}
else
{
EditorGUILayout.HelpBox("暂无动画,请拖入 AnimationClip", MessageType.Info);
}
GUILayout.BeginHorizontal();
if (GUILayout.Button("从文件夹添加"))
{
string folder = EditorUtility.OpenFolderPanel("选择动画文件夹", "Assets", "");
if (!string.IsNullOrEmpty(folder))
{
string relativePath = GetRelativePath(folder);
AddClipsFromFolder(relativePath);
ClearResults();
}
}
if (GUILayout.Button("添加选中的动画"))
{
foreach (var obj in Selection.objects)
{
if (obj is AnimationClip clip && !clips.Contains(clip))
clips.Add(clip);
}
ClearResults();
}
GUILayout.EndHorizontal();
}
private void HandleClipDragDrop(Rect dropArea)
{
Event evt = Event.current;
if (!dropArea.Contains(evt.mousePosition))
return;
switch (evt.type)
{
case EventType.DragUpdated:
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
evt.Use();
break;
case EventType.DragPerform:
DragAndDrop.AcceptDrag();
foreach (Object obj in DragAndDrop.objectReferences)
{
if (obj is AnimationClip clip && !clips.Contains(clip))
clips.Add(clip);
}
ClearResults();
evt.Use();
break;
}
}
private void AddClipsFromFolder(string folderPath)
{
string[] guids = AssetDatabase.FindAssets("t:AnimationClip", new[] { folderPath });
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (clip != null && !clips.Contains(clip))
clips.Add(clip);
}
Debug.Log($"从 {folderPath} 添加了 {guids.Length} 个动画");
}
private string GetRelativePath(string absolutePath)
{
string dataPath = Application.dataPath;
if (absolutePath.StartsWith(dataPath))
{
return "Assets" + absolutePath.Substring(dataPath.Length);
}
return absolutePath;
}
// -----------------------------
// 多条件检测设置
// -----------------------------
private void DrawConditionSettings()
{
showConditionSettings = EditorGUILayout.Foldout(showConditionSettings, "3. 多条件检测设置", true);
if (!showConditionSettings) return;
EditorGUILayout.BeginVertical("box");
// 匹配模式选择
EditorGUILayout.BeginHorizontal();
GUILayout.Label("骨骼匹配模式:", GUILayout.Width(100));
if (GUILayout.Toggle(useFullPathMatch, "完整路径匹配(推荐)", GUILayout.Width(150)))
useFullPathMatch = true;
if (GUILayout.Toggle(!useFullPathMatch, "仅名称匹配(宽松)", GUILayout.Width(130)))
useFullPathMatch = false;
EditorGUILayout.EndHorizontal();
if (useFullPathMatch)
{
EditorGUILayout.HelpBox("完整路径匹配:动画中的 \"Hips/Spine/Chest\" 必须与模型中的路径完全一致", MessageType.Info);
}
else
{
EditorGUILayout.HelpBox("仅名称匹配:只比较骨骼名称,不比较层级路径(可能误匹配)", MessageType.Warning);
}
GUILayout.Space(10);
// 判断逻辑选择
EditorGUILayout.BeginHorizontal();
GUILayout.Label("可选条件判断逻辑:", GUILayout.Width(120));
if (GUILayout.Toggle(useAndLogic, "AND(全部满足才算适配)", GUILayout.Width(180)))
useAndLogic = true;
if (GUILayout.Toggle(!useAndLogic, "OR(任一满足即适配)", GUILayout.Width(150)))
useAndLogic = false;
EditorGUILayout.EndHorizontal();
EditorGUILayout.HelpBox(
"⚠️ 注意:必要骨骼和最小骨骼数是强制条件,不满足直接标记为不适配!",
MessageType.Warning);
GUILayout.Space(10);
// ========== 强制条件区域 ==========
GUILayout.Label("【强制条件】不满足直接标记不适配", EditorStyles.boldLabel);
// 条件3:最小骨骼数(强制)
EditorGUILayout.BeginHorizontal();
useMinBoneCount = EditorGUILayout.Toggle(useMinBoneCount, GUILayout.Width(20));
GUILayout.Label("强制条件A:最小骨骼数量", GUILayout.Width(150));
if (useMinBoneCount)
{
minBoneCountRequired = EditorGUILayout.IntSlider(minBoneCountRequired, 1, 100);
GUILayout.Label($"≥ {minBoneCountRequired}", GUILayout.Width(40));
}
EditorGUILayout.EndHorizontal();
if (useMinBoneCount)
{
EditorGUILayout.HelpBox("动画使用的骨骼数量必须达到最小值,防止单根骨骼动画被误判为适配", MessageType.None);
}
// 条件4:必要骨骼列表(强制)
EditorGUILayout.BeginHorizontal();
useRequiredBonesList = EditorGUILayout.Toggle(useRequiredBonesList, GUILayout.Width(20));
GUILayout.Label("强制条件B:必要骨骼列表", GUILayout.Width(150));
EditorGUILayout.EndHorizontal();
if (useRequiredBonesList)
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.HelpBox(
"⚠️ 强制条件:缺失任何必要骨骼,动画将直接标记为不适配!",
MessageType.Error);
GUILayout.BeginHorizontal();
newRequiredBone = EditorGUILayout.TextField("添加必要骨骼路径:", newRequiredBone);
if (GUILayout.Button("添加", GUILayout.Width(60)) && !string.IsNullOrEmpty(newRequiredBone))
{
if (!requiredBonesList.Contains(newRequiredBone))
requiredBonesList.Add(newRequiredBone);
newRequiredBone = "";
}
GUILayout.EndHorizontal();
// 快捷按钮行
GUILayout.BeginHorizontal();
if (GUILayout.Button("从当前骨骼列表选择", GUILayout.Width(150)))
{
ShowBoneSelectionDialog();
}
if (GUILayout.Button("添加根骨骼作为必要", GUILayout.Width(150)))
{
if (actualBoneFullPaths.Count > 0)
{
string rootBone = actualBoneFullPaths[0];
if (!requiredBonesList.Contains(rootBone))
requiredBonesList.Add(rootBone);
Debug.Log($"添加根骨骼: {rootBone}");
}
}
if (GUILayout.Button("清空列表", GUILayout.Width(100)))
{
requiredBonesList.Clear();
}
GUILayout.EndHorizontal();
if (requiredBonesList.Count > 0)
{
requiredBonesScrollPos = EditorGUILayout.BeginScrollView(requiredBonesScrollPos, GUILayout.Height(100));
for (int i = 0; i < requiredBonesList.Count; i++)
{
GUILayout.BeginHorizontal();
GUILayout.Label($"• {requiredBonesList[i]}", GUILayout.Width(350));
if (GUILayout.Button("删除", GUILayout.Width(50)))
{
requiredBonesList.RemoveAt(i);
i--;
}
GUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
}
EditorGUILayout.EndVertical();
}
GUILayout.Space(10);
// ========== 可选条件区域 ==========
GUILayout.Label("【可选条件】根据AND/OR逻辑判断", EditorStyles.boldLabel);
// 条件1:缺失数量
EditorGUILayout.BeginHorizontal();
useMismatchCount = EditorGUILayout.Toggle(useMismatchCount, GUILayout.Width(20));
GUILayout.Label("可选条件A:缺失骨骼数量", GUILayout.Width(130));
if (useMismatchCount)
{
mismatchCountThreshold = EditorGUILayout.IntSlider(mismatchCountThreshold, 0, 50);
GUILayout.Label($"< {mismatchCountThreshold}", GUILayout.Width(40));
}
EditorGUILayout.EndHorizontal();
// 条件2:匹配百分比
EditorGUILayout.BeginHorizontal();
useMatchPercentage = EditorGUILayout.Toggle(useMatchPercentage, GUILayout.Width(20));
GUILayout.Label("可选条件B:骨骼匹配百分比", GUILayout.Width(130));
if (useMatchPercentage)
{
matchPercentageRequired = EditorGUILayout.Slider(matchPercentageRequired, 0f, 100f);
GUILayout.Label($"≥ {matchPercentageRequired}%", GUILayout.Width(60));
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
private void ShowBoneSelectionDialog()
{
GenericMenu menu = new GenericMenu();
foreach (string bonePath in actualBoneFullPaths)
{
string path = bonePath;
string cleanPath = RemoveCommonPrefixes(path);
string displayName = $"{path}";
if (cleanPath != path.ToLower())
displayName += $" (匹配名: {cleanPath})";
menu.AddItem(new GUIContent(displayName), requiredBonesList.Contains(path), () => {
if (!requiredBonesList.Contains(path))
requiredBonesList.Add(path);
});
}
menu.ShowAsContext();
}
// -----------------------------
// 移除常见的前缀
// -----------------------------
private string RemoveCommonPrefixes(string path)
{
string[] prefixes = { "mixamorig:", "uma:", "character:", "armature|", "root|" };
string result = path.ToLower();
foreach (string prefix in prefixes)
{
if (result.StartsWith(prefix))
{
result = result.Substring(prefix.Length);
break;
}
}
return result;
}
// -----------------------------
// 从动画中提取骨骼路径(必须在主线程调用)
// -----------------------------
private List<string> GetAnimationBonePaths(AnimationClip clip)
{
if (animationBonesCache.ContainsKey(clip))
return animationBonesCache[clip];
HashSet<string> animationBones = new HashSet<string>();
var bindings = AnimationUtility.GetCurveBindings(clip);
foreach (var binding in bindings)
{
if (binding.type == typeof(Transform) || binding.type == typeof(Animator))
{
string path = binding.path;
if (string.IsNullOrEmpty(path)) continue;
animationBones.Add(path);
}
}
var objectBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);
foreach (var binding in objectBindings)
{
if (binding.type == typeof(Transform) || binding.type == typeof(Animator))
{
string path = binding.path;
if (string.IsNullOrEmpty(path)) continue;
animationBones.Add(path);
}
}
var result = animationBones.ToList();
animationBonesCache[clip] = result;
return result;
}
// -----------------------------
// 分析动画骨骼(完整修复版)
// -----------------------------
private (List<string> mismatchedBones, List<string> matchedBones, int matchedCount, int totalAnimBones, float matchPercentage)
AnalyzeAnimationBonesData(
List<string> animationBonePaths,
List<string> modelBoneFullPaths,
bool useFullPathMatchLocal)
{
List<string> mismatchedBones = new List<string>();
List<string> matchedBones = new List<string>();
// 模型没有骨骼
if (modelBoneFullPaths.Count == 0)
{
mismatchedBones.Add("错误:参考对象没有骨骼结构");
return (mismatchedBones, matchedBones, 0, 0, 0f);
}
// 动画没有骨骼
if (animationBonePaths.Count == 0)
{
mismatchedBones.Add("警告:动画中没有检测到任何骨骼曲线");
return (mismatchedBones, matchedBones, 0, 0, 0f);
}
int totalAnimBones = animationBonePaths.Count;
// 预处理模型骨骼路径
HashSet<string> modelPathSet = new HashSet<string>(modelBoneFullPaths.Select(p => p.ToLower()));
Dictionary<string, string> modelCleanMap = new Dictionary<string, string>();
foreach (string modelPath in modelBoneFullPaths)
{
modelCleanMap[modelPath.ToLower()] = RemoveCommonPrefixes(modelPath.ToLower());
}
bool[] matchResults = new bool[totalAnimBones];
// -----------------------------
// 并行匹配动画骨骼
// -----------------------------
Parallel.For(0, totalAnimBones, idx =>
{
string animPath = animationBonePaths[idx];
string lowerAnimPath = animPath.ToLower();
string cleanAnimPath = RemoveCommonPrefixes(lowerAnimPath);
bool found = false;
foreach (var kvp in modelCleanMap)
{
string modelFull = kvp.Key;
string modelClean = kvp.Value;
if (useFullPathMatchLocal)
{
// 完整路径匹配
if (lowerAnimPath == modelFull || cleanAnimPath == modelClean)
{
found = true;
break;
}
// 末尾匹配(防止前缀不同)
if (cleanAnimPath.EndsWith("/" + modelClean) ||
cleanAnimPath == modelClean ||
modelClean.EndsWith("/" + cleanAnimPath))
{
found = true;
break;
}
}
else
{
// 名称匹配
string animName = lowerAnimPath.Split('/').Last();
string modelName = modelFull.Split('/').Last();
if (animName == modelName)
{
found = true;
break;
}
}
}
matchResults[idx] = found;
});
// -----------------------------
// 收集结果
// -----------------------------
for (int i = 0; i < totalAnimBones; i++)
{
if (matchResults[i])
matchedBones.Add(animationBonePaths[i]);
else
mismatchedBones.Add(animationBonePaths[i]);
}
int matchedCount = matchedBones.Count;
float matchPercentage = totalAnimBones > 0 ? (matchedCount * 100f / totalAnimBones) : 0f;
return (mismatchedBones, matchedBones, matchedCount, totalAnimBones, matchPercentage);
}
// -----------------------------
// 检查必要骨骼条件(纯数据计算)- 强制条件
// -----------------------------
private (bool passed, List<string> missingBones) CheckRequiredBonesConditionData(
List<string> animationBonePaths,
List<string> requiredBones,
bool useFullPathMatchLocal)
{
if (requiredBones.Count == 0)
return (true, new List<string>());
// 获取动画中的骨骼信息
HashSet<string> animPathSet = new HashSet<string>(animationBonePaths.Select(p => p.ToLower()));
HashSet<string> animCleanSet = new HashSet<string>(animationBonePaths.Select(p => RemoveCommonPrefixes(p)));
HashSet<string> animNameSet = new HashSet<string>(animationBonePaths.Select(p => p.Split('/').Last().ToLower()));
List<string> missingBones = new List<string>();
foreach (string required in requiredBones)
{
string lowerRequired = required.ToLower();
string cleanRequired = RemoveCommonPrefixes(lowerRequired);
string requiredName = lowerRequired.Split('/').Last();
bool found = false;
if (useFullPathMatchLocal)
{
// 1. 完整路径匹配
if (animPathSet.Contains(lowerRequired))
{
found = true;
}
// 2. 清理前缀后匹配
else if (animCleanSet.Contains(cleanRequired))
{
found = true;
}
// 3. 骨骼名称匹配
else if (animNameSet.Contains(requiredName))
{
found = true;
}
// 4. 后缀匹配
else
{
foreach (string animPath in animationBonePaths)
{
string cleanAnim = RemoveCommonPrefixes(animPath);
if (cleanAnim.EndsWith("/" + cleanRequired) || cleanAnim == cleanRequired)
{
found = true;
break;
}
}
}
}
else
{
found = animNameSet.Contains(requiredName);
}
if (!found)
{
missingBones.Add(required);
}
}
return (missingBones.Count == 0, missingBones);
}
// -----------------------------
// 动画适配判定(完整修复版)
// -----------------------------
private (bool isCompatible, string forceReason, string optionalReason) IsClipCompatibleData(
int mismatchCount,
int totalAnimBones,
float matchPercentage,
List<string> animPaths,
List<string> requiredBones,
bool useRequiredBonesList,
bool useMinBoneCount,
bool useMismatchCount,
bool useMatchPercentage,
int mismatchThreshold,
float matchPercentageRequired,
int minBoneCountRequired,
bool useAndLogic,
bool useFullPathMatch)
{
string forceReason = "";
string optionalReason = "";
// -----------------------------
// 1. 强制条件(不满足直接不适配)
// -----------------------------
// A. 最小骨骼数
if (useMinBoneCount)
{
if (totalAnimBones < minBoneCountRequired)
{
forceReason += $"[强制条件] 动画骨骼数 {totalAnimBones} < 最小要求 {minBoneCountRequired}\n";
return (false, forceReason, optionalReason);
}
}
// B. 必要骨骼列表
if (useRequiredBonesList && requiredBones.Count > 0)
{
foreach (var req in requiredBones)
{
bool found = false;
foreach (var animBone in animPaths)
{
if (useFullPathMatch)
{
// 完整路径匹配
if (animBone.ToLower() == req.ToLower())
{
found = true;
break;
}
}
else
{
// 名称匹配
string animName = animBone.Split('/').Last().ToLower();
string reqName = req.Split('/').Last().ToLower();
if (animName == reqName)
{
found = true;
break;
}
}
}
if (!found)
{
forceReason += $"[强制条件] 缺少必要骨骼:{req}\n";
return (false, forceReason, optionalReason);
}
}
}
// -----------------------------
// 2. 可选条件(根据 AND / OR 判断)
// -----------------------------
List<bool> optionalResults = new List<bool>();
// A. 缺失数量
if (useMismatchCount)
{
bool ok = mismatchCount < mismatchThreshold;
optionalResults.Add(ok);
if (!ok)
optionalReason += $"[可选条件] 缺失骨骼数 {mismatchCount} ≥ 阈值 {mismatchThreshold}\n";
}
// B. 匹配百分比
if (useMatchPercentage)
{
bool ok = matchPercentage >= matchPercentageRequired;
optionalResults.Add(ok);
if (!ok)
optionalReason += $"[可选条件] 匹配率 {matchPercentage:F1}% < 要求 {matchPercentageRequired}%\n";
}
// 如果没有启用任何可选条件 → 默认通过
if (optionalResults.Count == 0)
return (true, forceReason, optionalReason);
// AND 逻辑:全部满足才通过
if (useAndLogic)
{
bool allOK = optionalResults.All(r => r);
return (allOK, forceReason, optionalReason);
}
else
{
// OR 逻辑:任意满足即可
bool anyOK = optionalResults.Any(r => r);
return (anyOK, forceReason, optionalReason);
}
}
private string GetConditionDescription()
{
List<string> forcedConditions = new List<string>();
List<string> optionalConditions = new List<string>();
if (useMinBoneCount)
forcedConditions.Add($"最小骨骼 ≥ {minBoneCountRequired}");
if (useRequiredBonesList && requiredBonesList.Count > 0)
forcedConditions.Add($"必要骨骼({requiredBonesList.Count}个)");
if (useMismatchCount)
optionalConditions.Add($"缺失 < {mismatchCountThreshold}");
if (useMatchPercentage)
optionalConditions.Add($"匹配 ≥ {matchPercentageRequired}%");
string result = "";
if (forcedConditions.Count > 0)
result += $"【强制】{string.Join("、", forcedConditions)}";
if (optionalConditions.Count > 0)
{
if (result.Length > 0) result += " + ";
result += $"【可选】{string.Join(useAndLogic ? " 且 " : " 或 ", optionalConditions)}";
}
if (result.Length == 0)
return "无条件";
return result;
}
private void PreCacheAnimationBones()
{
cachedClipData.Clear();
foreach (var clip in clips)
{
if (clip == null) continue;
string path = AssetDatabase.GetAssetPath(clip);
string name = clip.name;
List<string> bones = GetAnimationBonePaths(clip); // 这里必须在主线程
cachedClipData.Add(new ClipData
{
clipPath = path,
clipName = name,
bonePaths = bones
});
}
}
// -----------------------------
// 异步执行检测
// -----------------------------
// 添加临时存储
private ConcurrentDictionary<string, (List<string> mismatched, int count, float percentage)> tempResults;
// ... 中间的代码保持不变 ...
// 异步检测入口(主线程)
private async void RunCheckAsync()
{
if (isProcessing || clips.Count == 0 || referenceObject == null)
{
EditorUtility.DisplayDialog("提示", "请先选择参考对象并添加动画!", "确定");
return;
}
// 确保缓存已构建(主线程中构建)
if (cachedClipData == null || cachedClipData.Count == 0)
{
PreCacheAnimationBones(); // 这里面要在主线程里填好 clipName / bonePaths
}
isProcessing = true;
hasRunCheck = false;
progress = 0f;
currentProgressText = "准备开始检测...";
Repaint();
mismatchDetails.Clear();
mismatchCountMap.Clear();
matchPercentageMap.Clear();
// 拷贝一份本地数据,避免并发修改
var clipDataListLocal = new List<ClipData>(cachedClipData);
var modelBones = new List<string>(actualBoneFullPaths);
var requiredBones = new List<string>(requiredBonesList);
// 把当前设置也拍一份,避免子线程访问 Editor 状态
bool useFullPathMatchLocal = useFullPathMatch;
bool useRequiredBonesListLocal = useRequiredBonesList;
bool useMinBoneCountLocal = useMinBoneCount;
bool useMismatchCountLocal = useMismatchCount;
bool useMatchPercentageLocal = useMatchPercentage;
int mismatchCountThresholdLocal = mismatchCountThreshold;
float matchPercentageRequiredLocal = matchPercentageRequired;
int minBoneCountRequiredLocal = minBoneCountRequired;
bool useAndLogicLocal = useAndLogic;
// 子线程执行纯数据计算
await Task.Run(() =>
{
RunCheckInternal(
clipDataListLocal,
modelBones,
requiredBones,
useFullPathMatchLocal,
useRequiredBonesListLocal,
useMinBoneCountLocal,
useMismatchCountLocal,
useMatchPercentageLocal,
mismatchCountThresholdLocal,
matchPercentageRequiredLocal,
minBoneCountRequiredLocal,
useAndLogicLocal
);
});
// 子线程结束后,把结果应用回主线程
if (tempResults != null)
{
foreach (var clip in clips)
{
string path = AssetDatabase.GetAssetPath(clip);
if (tempResults.TryGetValue(path, out var data))
{
var (mismatched, count, percentage) = data;
mismatchDetails[clip] = mismatched;
mismatchCountMap[clip] = count;
matchPercentageMap[clip] = percentage;
}
}
}
isProcessing = false;
hasRunCheck = true;
currentProgressText = "检测完成!";
progress = 1f;
Repaint();
EditorUtility.DisplayDialog("完成", "动画检测已完成!", "确定");
}
private void RunCheckInternal(
List<ClipData> clipDataListLocal,
List<string> modelBones,
List<string> requiredBones,
bool useFullPathMatchLocal,
bool useRequiredBonesListLocal,
bool useMinBoneCountLocal,
bool useMismatchCountLocal,
bool useMatchPercentageLocal,
int mismatchCountThresholdLocal,
float matchPercentageRequiredLocal,
int minBoneCountRequiredLocal,
bool useAndLogicLocal)
{
int total = clipDataListLocal.Count;
int badCount = 0;
int perfectCount = 0;
int goodCount = 0;
var results = new ConcurrentDictionary<string, (List<string> mismatched, int count, float percentage)>();
Parallel.For(0, total, i =>
{
var clipData = clipDataListLocal[i];
List<string> animationBones = clipData.bonePaths;
if (animationBones == null || animationBones.Count == 0)
return;
// 分析动画骨骼(纯数据)
var (mismatchedBones, matchedBones, matchedCount, totalAnimBones, matchPercentage) =
AnalyzeAnimationBonesData(animationBones, modelBones, useFullPathMatchLocal);
results[clipData.clipPath] = (mismatchedBones, mismatchedBones.Count, matchPercentage);
// 判定适配
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
mismatchedBones.Count,
totalAnimBones,
matchPercentage,
animationBones,
requiredBones,
useRequiredBonesListLocal,
useMinBoneCountLocal,
useMismatchCountLocal,
useMatchPercentageLocal,
mismatchCountThresholdLocal,
matchPercentageRequiredLocal,
minBoneCountRequiredLocal,
useAndLogicLocal,
useFullPathMatchLocal);
bool forcedFailed = !string.IsNullOrEmpty(forceReason);
string reason = string.IsNullOrEmpty(forceReason)
? optionalReason
: forceReason + optionalReason;
if (!isCompatible)
Interlocked.Increment(ref badCount);
else if (mismatchedBones.Count == 0 && matchPercentage >= 99.9f)
Interlocked.Increment(ref perfectCount);
else
Interlocked.Increment(ref goodCount);
string statusIcon = isCompatible ? "✓" : "✗";
string statusText;
if (!isCompatible && forcedFailed)
statusText = "不适配(强制条件)";
else if (!isCompatible)
statusText = "不适配(可选条件)";
else if (mismatchedBones.Count == 0 && matchPercentage >= 99.9f)
statusText = "完美适配";
else
statusText = "适配";
// 这里只输出纯文本日志,不访问 Unity 对象
lock (this)
{
Debug.Log($"[{statusIcon}] 动画 [{clipData.clipName}]");
Debug.Log($" ├─ 动画骨骼数: {totalAnimBones}");
Debug.Log($" ├─ 匹配骨骼: {matchedCount}/{totalAnimBones} ({matchPercentage:F1}%)");
Debug.Log($" ├─ 判定结果: {statusText}");
if (!string.IsNullOrEmpty(reason))
Debug.Log($" └─ 原因: {reason}");
if (mismatchedBones.Count > 0 && mismatchedBones.Count <= 5)
{
foreach (string bone in mismatchedBones)
Debug.Log($" └─ 不匹配: {bone}");
}
}
});
tempResults = results;
currentProgressText = $"检测完成! 完美:{perfectCount} 适配:{goodCount} 不适配:{badCount}";
}
private void DrawResultList()
{
if (!hasRunCheck)
{
EditorGUILayout.HelpBox("尚未检测,请先点击“开始检测动画”", MessageType.Info);
return;
}
if (clips.Count == 0)
{
EditorGUILayout.HelpBox("没有动画可显示", MessageType.Warning);
return;
}
// 先把所有结果算一遍,按“是否符合条件 + 匹配率”排序
var resultList = new List<(AnimationClip clip, int totalBones, int mismatch, int matched, float percent, bool isCompatible, bool forcedFailed, string reason)>();
foreach (var clip in clips)
{
if (!mismatchDetails.ContainsKey(clip))
continue;
// 动画骨骼路径 & 统计
List<string> animPaths = GetAnimationBonePaths(clip);
int totalAnimBones = animPaths.Count;
int mismatch = mismatchCountMap.TryGetValue(clip, out var mis) ? mis : 0;
float percentage = matchPercentageMap.TryGetValue(clip, out var p) ? p : 0f;
int matchedCount = Mathf.Max(0, totalAnimBones - mismatch);
// 用当前设置重新跑一遍判定逻辑(关键:这里会用到最小节点数 + 必要骨骼)
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
mismatch,
totalAnimBones,
percentage,
animPaths,
requiredBonesList,
useRequiredBonesList,
useMinBoneCount,
useMismatchCount,
useMatchPercentage,
mismatchCountThreshold,
matchPercentageRequired,
minBoneCountRequired,
useAndLogic,
useFullPathMatch
);
bool forcedFailed = !string.IsNullOrEmpty(forceReason);
string reason = string.IsNullOrEmpty(forceReason)
? optionalReason
: forceReason + optionalReason;
resultList.Add((clip, totalAnimBones, mismatch, matchedCount, percentage, isCompatible, forcedFailed, reason));
}
// 排序:先符合条件的,再不符合的;同类里按匹配率从高到低
resultList = resultList
.OrderByDescending(r => r.isCompatible)
.ThenByDescending(r => r.percent)
.ToList();
// 开始画 UI
resultScrollPos = EditorGUILayout.BeginScrollView(resultScrollPos, GUILayout.Height(260));
// 表头
EditorGUILayout.BeginHorizontal();
GUILayout.Label("状态", GUILayout.Width(40));
GUILayout.Label("动画名", GUILayout.Width(200));
GUILayout.Label("总骨骼", GUILayout.Width(60));
GUILayout.Label("匹配数", GUILayout.Width(70));
GUILayout.Label("缺失数", GUILayout.Width(60));
GUILayout.Label("匹配率", GUILayout.Width(70));
GUILayout.Label("判定结果", GUILayout.Width(120));
EditorGUILayout.EndHorizontal();
GUILayout.Space(4);
foreach (var r in resultList)
{
var clip = r.clip;
// 状态 & 文案
string statusIcon;
string statusText;
Color rowColor;
if (!r.isCompatible && r.forcedFailed)
{
statusIcon = "✗";
statusText = "不适配(强制条件)";
rowColor = new Color(1f, 0.8f, 0.8f);
}
else if (!r.isCompatible)
{
statusIcon = "✗";
statusText = "不适配(可选条件)";
rowColor = new Color(1f, 0.9f, 0.8f);
}
else if (r.mismatch == 0 && r.percent >= 99.9f)
{
statusIcon = "✓";
statusText = "完美适配";
rowColor = new Color(0.8f, 1f, 0.8f);
}
else
{
statusIcon = "•";
statusText = "适配";
rowColor = new Color(1f, 1f, 0.9f);
}
// 横向一行平铺
var oldColor = GUI.backgroundColor;
GUI.backgroundColor = rowColor;
EditorGUILayout.BeginHorizontal("box");
GUI.backgroundColor = oldColor;
GUILayout.Label(statusIcon, GUILayout.Width(40));
GUILayout.Label(clip.name, GUILayout.Width(200));
GUILayout.Label(r.totalBones.ToString(), GUILayout.Width(60));
GUILayout.Label(r.matched.ToString(), GUILayout.Width(70));
GUILayout.Label(r.mismatch.ToString(), GUILayout.Width(60));
GUILayout.Label($"{r.percent:F1}%", GUILayout.Width(70));
GUILayout.Label(statusText, GUILayout.Width(120));
// 详情按钮:展开缺失骨骼 + 原因
if (GUILayout.Button("详情", GUILayout.Width(50)))
{
var msg = $"动画:{clip.name}\n" +
$"总骨骼:{r.totalBones}\n" +
$"匹配骨骼:{r.matched}\n" +
$"缺失骨骼:{r.mismatch}\n" +
$"匹配率:{r.percent:F1}%\n\n";
if (!string.IsNullOrEmpty(r.reason))
msg += $"判定原因:\n{r.reason}\n\n";
if (mismatchDetails.TryGetValue(clip, out var list) && list.Count > 0)
{
msg += "缺失骨骼列表(最多显示 50 个):\n";
foreach (var bone in list.Take(50))
msg += $" • {bone}\n";
if (list.Count > 50)
msg += $" ... 还有 {list.Count - 50} 个未显示\n";
}
EditorUtility.DisplayDialog("检测详情", msg, "确定");
}
EditorGUILayout.EndHorizontal();
GUILayout.Space(2);
}
EditorGUILayout.EndScrollView();
}
private void ShowDetailsPopup(AnimationClip clip, List<string> mismatchedBones, float percentage, int matchedCount, int totalAnimBones, bool isCompatible, string reason, bool forcedFailed)
{
string message = $"━━━━━━━━━━━━━━━━━━━━\n";
message += $"🎬 动画: {clip.name}\n";
message += $"━━━━━━━━━━━━━━━━━━━━\n\n";
message += $"📦 参考对象: {referenceObject.name}\n";
message += $"🦴 参考骨骼数: {actualBoneFullPaths.Count}\n";
message += $"🔍 匹配模式: {(useFullPathMatch ? "完整路径匹配" : "仅名称匹配")}\n\n";
message += $"━━━━━━━━━━━━━━━━━━━━\n";
message += $"📊 检测结果:\n";
message += $" 动画使用骨骼: {totalAnimBones} 个\n";
message += $" 匹配骨骼: {matchedCount} 个\n";
message += $" 匹配度: {percentage:F1}%\n\n";
message += $"━━━━━━━━━━━━━━━━━━━━\n";
message += $"⚙️ 判定条件:\n";
message += $" {GetConditionDescription()}\n";
// 显示各条件状态
List<string> animPaths = GetAnimationBonePaths(clip);
// 强制条件状态
message += $"\n【强制条件】\n";
if (useMinBoneCount)
{
bool minOk = totalAnimBones >= minBoneCountRequired;
message += $" 最小骨骼数({minBoneCountRequired}): {(minOk ? "✓ " + totalAnimBones : "✗ " + totalAnimBones)}\n";
}
if (useRequiredBonesList && requiredBonesList.Count > 0)
{
var (requiredOk, missing) = CheckRequiredBonesConditionData(animPaths, requiredBonesList, useFullPathMatch);
message += $" 必要骨骼: {(requiredOk ? "✓ 满足" : "✗ 不满足")}\n";
if (!requiredOk && missing.Count > 0)
{
foreach (string m in missing)
{
message += $" • 缺失: {m}\n";
}
}
}
// 可选条件状态
message += $"\n【可选条件】\n";
if (useMismatchCount)
{
bool mismatchOk = mismatchedBones.Count < mismatchCountThreshold;
message += $" 缺失数量({mismatchedBones.Count}): {(mismatchOk ? "✓ <" + mismatchCountThreshold : "✗ ≥" + mismatchCountThreshold)}\n";
}
if (useMatchPercentage)
{
bool percentOk = percentage >= matchPercentageRequired;
message += $" 匹配百分比({percentage:F1}%): {(percentOk ? "✓ ≥" + matchPercentageRequired : "✗ <" + matchPercentageRequired)}\n";
}
message += $"\n━━━━━━━━━━━━━━━━━━━━\n";
message += $"📋 最终判定: {(isCompatible ? "✓ 适配" : "✗ 不适配")}\n";
if (!isCompatible && !string.IsNullOrEmpty(reason))
{
message += $"📌 原因: {reason}\n";
}
message += $"\n━━━━━━━━━━━━━━━━━━━━\n";
if (mismatchedBones != null && mismatchedBones.Count > 0)
{
var realMissing = mismatchedBones.Where(b => !b.Contains("警告") && !b.Contains("请先")).ToList();
if (realMissing.Count > 0)
{
message += $"\n⚠️ 不匹配的骨骼路径 ({realMissing.Count} 个):\n\n";
message += string.Join("\n", realMissing.Select(b => "• " + b));
}
}
else
{
message += $"\n✓ 所有动画骨骼路径都匹配!";
}
EditorUtility.DisplayDialog("骨骼匹配详情", message, "确定");
}
private void ExportResults()
{
if (!hasRunCheck)
{
EditorUtility.DisplayDialog("提示", "请先执行检测!", "确定");
return;
}
string root = rootFolder;
if (!Directory.Exists(root))
Directory.CreateDirectory(root);
string goodPath = Path.Combine(root, goodSubFolder);
string badPath = Path.Combine(root, badSubFolder);
if (!Directory.Exists(goodPath))
Directory.CreateDirectory(goodPath);
if (exportBadClips && !Directory.Exists(badPath))
Directory.CreateDirectory(badPath);
int goodCount = 0;
int badCount = 0;
foreach (var clip in clips)
{
if (!mismatchDetails.ContainsKey(clip))
continue;
List<string> animPaths = GetAnimationBonePaths(clip);
int totalAnimBones = animPaths.Count;
int mismatch = mismatchCountMap[clip];
float percentage = matchPercentageMap[clip];
// 使用最终判定逻辑
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
mismatch,
totalAnimBones,
percentage,
animPaths,
requiredBonesList,
useRequiredBonesList,
useMinBoneCount,
useMismatchCount,
useMatchPercentage,
mismatchCountThreshold,
matchPercentageRequired,
minBoneCountRequired,
useAndLogic,
useFullPathMatch
);
string src = AssetDatabase.GetAssetPath(clip);
string dst = Path.Combine(isCompatible ? goodPath : badPath, clip.name + ".anim");
if (isCompatible)
{
File.Copy(src, dst, true);
goodCount++;
}
else if (exportBadClips)
{
File.Copy(src, dst, true);
badCount++;
}
}
AssetDatabase.Refresh();
EditorUtility.DisplayDialog(
"导出完成",
$"适配动画:{goodCount}\n不适配动画:{badCount}",
"确定"
);
}
}