#if UNITY_EDITOR using System.Globalization; using System.Text; using Sirenix.OdinInspector; using Sirenix.OdinInspector.Editor; using UnityEditor; using UnityEngine; namespace SLSUtilities.FunctionalAnimation { /// /// 用于分析 AnimationClip 指定时间区间内 Root Motion 位移与旋转的编辑器工具。 /// 工具只读取动画曲线,不会修改 AnimationClip 或其它资产。 /// public class RootMotionCalibrationWindow : OdinEditorWindow { private const float MinimumSampleRate = 30f; private const float MinimumIntervalDuration = 0.0001f; [Title("Root Motion Calibration")] [ShowInInspector, AssetsOnly, PropertyOrder(-100)] [LabelText("Animation Clip")] [OnValueChanged(nameof(OnAnimationClipChanged))] private AnimationClip animationClip; [ShowInInspector, ReadOnly, PropertyOrder(-99)] [LabelText("Clip Duration")] private float ClipDuration => animationClip != null ? animationClip.length : 0f; [ShowInInspector, ReadOnly, PropertyOrder(-98)] [LabelText("Frame Rate")] private float FrameRate => animationClip != null ? animationClip.frameRate : 0f; [ShowInInspector, PropertyOrder(-90)] [OnInspectorGUI] private void DrawIntervalControls() { if (animationClip == null) { EditorGUILayout.HelpBox("请拖入一个 AnimationClip。", MessageType.Info); return; } float duration = Mathf.Max(animationClip.length, MinimumIntervalDuration); float start = Mathf.Clamp(analysisRange.x, 0f, duration); float end = Mathf.Clamp(analysisRange.y, 0f, duration); EditorGUILayout.LabelField("Analysis Interval", EditorStyles.boldLabel); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Start", GUILayout.Width(45f)); start = EditorGUILayout.FloatField(start); EditorGUILayout.LabelField("End", GUILayout.Width(35f)); end = EditorGUILayout.FloatField(end); EditorGUILayout.EndHorizontal(); EditorGUILayout.MinMaxSlider("Range", ref start, ref end, 0f, duration); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Whole Clip")) { start = 0f; end = duration; } if (GUILayout.Button("Analyze")) { analysisRange = new Vector2(start, end); Analyze(); } EditorGUILayout.EndHorizontal(); start = Mathf.Clamp(start, 0f, duration); end = Mathf.Clamp(end, 0f, duration); if (end < start) { (start, end) = (end, start); } analysisRange = new Vector2(start, end); DrawIntervalPreview(duration, start, end); } [ShowInInspector, PropertyOrder(-80)] [ReadOnly, LabelText("Curve Status")] private string CurveStatus => string.IsNullOrEmpty(curveStatus) ? "尚未分析" : curveStatus; [ShowInInspector, PropertyOrder(-70)] [ReadOnly, LabelText("Interval")] private string IntervalText => $"{analysisRange.x.ToString("F4", CultureInfo.InvariantCulture)} s - " + $"{analysisRange.y.ToString("F4", CultureInfo.InvariantCulture)} s"; [TitleGroup("Root Motion Result", Order = 0)] [ShowInInspector, ReadOnly, LabelText("Interval Duration")] private float IntervalDuration => result.intervalDuration; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Start Position")] private Vector3 StartPosition => result.startPosition; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("End Position")] private Vector3 EndPosition => result.endPosition; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Displacement")] private Vector3 Displacement => result.displacement; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Horizontal Displacement")] private Vector3 HorizontalDisplacement => result.horizontalDisplacement; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Path Distance")] private float PathDistance => result.pathDistance; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Average Velocity")] private Vector3 AverageVelocity => result.averageVelocity; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Average Horizontal Speed")] private float AverageHorizontalSpeed => result.averageHorizontalSpeed; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Peak Horizontal Speed")] private float PeakHorizontalSpeed => result.peakHorizontalSpeed; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Rotation Delta")] private float RotationDelta => result.rotationDelta; [TitleGroup("Root Motion Result")] [ShowInInspector, ReadOnly, LabelText("Average Angular Speed")] private float AverageAngularSpeed => result.averageAngularSpeed; [TitleGroup("Root Motion Result")] [Button("Copy Result")] private void CopyResult() { if (!hasAnalysis) { Analyze(); } GUIUtility.systemCopyBuffer = BuildResultText(); } [TitleGroup("Root Motion Result")] [Button("Copy CSV Row")] private void CopyCsvRow() { if (!hasAnalysis) { Analyze(); } GUIUtility.systemCopyBuffer = BuildCsvRow(); } private Vector2 analysisRange; private string curveStatus; private bool hasAnalysis; private CalibrationResult result; private RootMotionCurves curves; [MenuItem("Tools/SLS Utilities/Functional Animation/Root Motion Calibration")] private static void OpenWindow() { var window = GetWindow(); window.titleContent = new GUIContent("Root Motion Calibration"); window.Show(); } /// /// 双击 AnimationClip 时直接打开校准窗口,并自动填入该动画。 /// [UnityEditor.Callbacks.OnOpenAsset(1)] private static bool OnOpenAsset(int instanceId, int line) { var clip = EditorUtility.InstanceIDToObject(instanceId) as AnimationClip; if (clip == null) { return false; } OpenWindow(); var window = GetWindow(); window.animationClip = clip; window.OnAnimationClipChanged(); return true; } private void OnAnimationClipChanged() { hasAnalysis = false; result = default; curveStatus = string.Empty; if (animationClip == null) { analysisRange = Vector2.zero; return; } analysisRange = new Vector2(0f, animationClip.length); Analyze(); } private void DrawIntervalPreview(float duration, float start, float end) { Rect rect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, GUILayout.Height(28f)); EditorGUI.DrawRect(rect, new Color(0.18f, 0.18f, 0.18f)); float startX = Mathf.Lerp(rect.x, rect.xMax, start / duration); float endX = Mathf.Lerp(rect.x, rect.xMax, end / duration); EditorGUI.DrawRect( new Rect(startX, rect.y + 4f, Mathf.Max(1f, endX - startX), rect.height - 8f), new Color(0.25f, 0.65f, 1f, 0.8f)); GUI.Label(new Rect(rect.x + 4f, rect.y + 4f, rect.width - 8f, rect.height - 8f), "Selected Root Motion Interval", EditorStyles.centeredGreyMiniLabel); } private void Analyze() { if (animationClip == null) { hasAnalysis = false; return; } float duration = Mathf.Max(animationClip.length, MinimumIntervalDuration); float start = Mathf.Clamp(analysisRange.x, 0f, duration); float end = Mathf.Clamp(analysisRange.y, 0f, duration); if (end < start) { (start, end) = (end, start); } analysisRange = new Vector2(start, end); curves = RootMotionCurves.Read(animationClip); curveStatus = curves.GetStatusText(); if (!curves.HasPosition) { hasAnalysis = false; result = default; return; } result = CalibrationResult.Calculate(curves, start, end, animationClip.frameRate); hasAnalysis = true; } private string BuildResultText() { var builder = new StringBuilder(); builder.AppendLine($"Animation Clip: {animationClip.name}"); builder.AppendLine($"Interval: {IntervalText}"); builder.AppendLine($"Interval Duration: {result.intervalDuration:F4} s"); builder.AppendLine($"Start Position: {FormatVector(result.startPosition)}"); builder.AppendLine($"End Position: {FormatVector(result.endPosition)}"); builder.AppendLine($"Displacement: {FormatVector(result.displacement)}"); builder.AppendLine($"Horizontal Displacement: {FormatVector(result.horizontalDisplacement)}"); builder.AppendLine($"Path Distance: {result.pathDistance:F4} m"); builder.AppendLine($"Average Velocity: {FormatVector(result.averageVelocity)} m/s"); builder.AppendLine($"Average Horizontal Speed: {result.averageHorizontalSpeed:F4} m/s"); builder.AppendLine($"Peak Horizontal Speed: {result.peakHorizontalSpeed:F4} m/s"); builder.AppendLine($"Rotation Delta: {result.rotationDelta:F4} deg"); builder.AppendLine($"Average Angular Speed: {result.averageAngularSpeed:F4} deg/s"); return builder.ToString(); } private string BuildCsvRow() { return string.Join(",", animationClip.name, analysisRange.x.ToString("F4", CultureInfo.InvariantCulture), analysisRange.y.ToString("F4", CultureInfo.InvariantCulture), result.intervalDuration.ToString("F4", CultureInfo.InvariantCulture), result.displacement.x.ToString("F4", CultureInfo.InvariantCulture), result.displacement.y.ToString("F4", CultureInfo.InvariantCulture), result.displacement.z.ToString("F4", CultureInfo.InvariantCulture), result.pathDistance.ToString("F4", CultureInfo.InvariantCulture), result.averageHorizontalSpeed.ToString("F4", CultureInfo.InvariantCulture), result.peakHorizontalSpeed.ToString("F4", CultureInfo.InvariantCulture), result.rotationDelta.ToString("F4", CultureInfo.InvariantCulture), result.averageAngularSpeed.ToString("F4", CultureInfo.InvariantCulture)); } private static string FormatVector(Vector3 value) { return $"({value.x.ToString("F4", CultureInfo.InvariantCulture)}, " + $"{value.y.ToString("F4", CultureInfo.InvariantCulture)}, " + $"{value.z.ToString("F4", CultureInfo.InvariantCulture)})"; } private struct RootMotionCurves { public AnimationCurve positionX; public AnimationCurve positionY; public AnimationCurve positionZ; public AnimationCurve rotationX; public AnimationCurve rotationY; public AnimationCurve rotationZ; public AnimationCurve rotationW; public bool HasPosition => positionX != null || positionY != null || positionZ != null; public bool HasRotation => rotationX != null || rotationY != null || rotationZ != null || rotationW != null; public static RootMotionCurves Read(AnimationClip clip) { var bindings = AnimationUtility.GetCurveBindings(clip); var result = new RootMotionCurves { positionX = FindCurve(clip, bindings, "RootT.x", "m_LocalPosition.x", "localPosition.x"), positionY = FindCurve(clip, bindings, "RootT.y", "m_LocalPosition.y", "localPosition.y"), positionZ = FindCurve(clip, bindings, "RootT.z", "m_LocalPosition.z", "localPosition.z"), rotationX = FindCurve(clip, bindings, "RootQ.x", "m_LocalRotation.x", "localRotation.x"), rotationY = FindCurve(clip, bindings, "RootQ.y", "m_LocalRotation.y", "localRotation.y"), rotationZ = FindCurve(clip, bindings, "RootQ.z", "m_LocalRotation.z", "localRotation.z"), rotationW = FindCurve(clip, bindings, "RootQ.w", "m_LocalRotation.w", "localRotation.w") }; return result; } public string GetStatusText() { if (!HasPosition) { return "未找到 RootT 或根节点 Position 曲线,无法进行区间位移分析。"; } return HasRotation ? "已找到 Root Position 与 Root Rotation 曲线。" : "已找到 Root Position 曲线,但未找到 Root Rotation 曲线。"; } public Vector3 EvaluatePosition(float time) { return new Vector3( Evaluate(positionX, time), Evaluate(positionY, time), Evaluate(positionZ, time)); } public Quaternion EvaluateRotation(float time) { Quaternion rotation = new Quaternion( Evaluate(rotationX, time), Evaluate(rotationY, time), Evaluate(rotationZ, time), rotationW == null ? 1f : Evaluate(rotationW, time)); return rotation.normalized; } private static AnimationCurve FindCurve(AnimationClip clip, EditorCurveBinding[] bindings, params string[] propertyNames) { foreach (var binding in bindings) { if (!string.IsNullOrEmpty(binding.path)) { continue; } bool matches = false; foreach (string propertyName in propertyNames) { if (binding.propertyName == propertyName) { matches = true; break; } } if (matches) { return AnimationUtility.GetEditorCurve(clip, binding); } } return null; } private static float Evaluate(AnimationCurve curve, float time) { return curve == null ? 0f : curve.Evaluate(time); } } private struct CalibrationResult { public float intervalDuration; public Vector3 startPosition; public Vector3 endPosition; public Vector3 displacement; public Vector3 horizontalDisplacement; public float pathDistance; public Vector3 averageVelocity; public float averageHorizontalSpeed; public float peakHorizontalSpeed; public float rotationDelta; public float averageAngularSpeed; public static CalibrationResult Calculate(RootMotionCurves curves, float start, float end, float frameRate) { float duration = Mathf.Max(end - start, MinimumIntervalDuration); int sampleCount = Mathf.Max(2, Mathf.CeilToInt(duration * Mathf.Max(frameRate, MinimumSampleRate))); Vector3 startPosition = curves.EvaluatePosition(start); Vector3 endPosition = curves.EvaluatePosition(end); Vector3 displacement = endPosition - startPosition; float pathDistance = 0f; float peakSpeed = 0f; float angleSum = 0f; Vector3 previousPosition = startPosition; Quaternion previousRotation = curves.EvaluateRotation(start); for (int i = 1; i <= sampleCount; i++) { float t = Mathf.Lerp(start, end, i / (float)sampleCount); Vector3 currentPosition = curves.EvaluatePosition(t); Quaternion currentRotation = curves.EvaluateRotation(t); float sampleDuration = duration / sampleCount; Vector3 delta = currentPosition - previousPosition; float horizontalDistance = new Vector3(delta.x, 0f, delta.z).magnitude; pathDistance += delta.magnitude; peakSpeed = Mathf.Max(peakSpeed, horizontalDistance / sampleDuration); angleSum += Quaternion.Angle(previousRotation, currentRotation); previousPosition = currentPosition; previousRotation = currentRotation; } Vector3 horizontalDisplacement = new Vector3(displacement.x, 0f, displacement.z); float rotationDelta = Quaternion.Angle(curves.EvaluateRotation(start), curves.EvaluateRotation(end)); return new CalibrationResult { intervalDuration = duration, startPosition = startPosition, endPosition = endPosition, displacement = displacement, horizontalDisplacement = horizontalDisplacement, pathDistance = pathDistance, averageVelocity = displacement / duration, averageHorizontalSpeed = horizontalDisplacement.magnitude / duration, peakHorizontalSpeed = peakSpeed, rotationDelta = rotationDelta, averageAngularSpeed = angleSum / duration }; } } } } #endif