Files
Cielonos/Assets/Scripts/SLSUtilities/FunctionalAnimation/Editor/RootMotionCalibrationWindow.cs
SoulliesOfficial 39b43680a9 爆更
2026-07-18 03:16:20 -04:00

461 lines
19 KiB
C#

#if UNITY_EDITOR
using System.Globalization;
using System.Text;
using Sirenix.OdinInspector;
using Sirenix.OdinInspector.Editor;
using UnityEditor;
using UnityEngine;
namespace SLSUtilities.FunctionalAnimation
{
/// <summary>
/// 用于分析 AnimationClip 指定时间区间内 Root Motion 位移与旋转的编辑器工具。
/// 工具只读取动画曲线,不会修改 AnimationClip 或其它资产。
/// </summary>
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<RootMotionCalibrationWindow>();
window.titleContent = new GUIContent("Root Motion Calibration");
window.Show();
}
/// <summary>
/// 双击 AnimationClip 时直接打开校准窗口,并自动填入该动画。
/// </summary>
[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<RootMotionCalibrationWindow>();
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