Files
5.24BuYu/Assets/MyGame/Framework/Editor/AnimationClipMismatchChecker.cs
T
2026-10-20 18:30:03 +08:00

1623 lines
56 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}",
"确定"
);
}
}