diff --git a/Assets/External/EasyMotionRecorder/Prefabs/EasyMotionRecorder.prefab b/Assets/External/EasyMotionRecorder/Prefabs/EasyMotionRecorder.prefab index 063c5f250..3b94b8afc 100644 --- a/Assets/External/EasyMotionRecorder/Prefabs/EasyMotionRecorder.prefab +++ b/Assets/External/EasyMotionRecorder/Prefabs/EasyMotionRecorder.prefab @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6027a03b0246f8eb47eae980363fedfa067ceb1d729b5c7c641bddbf6715a68f -size 2745 +oid sha256:284ac40198db1d76fc63f9e3762abbb215a12ce13eadbd8f6c094b0e4a3a43a9 +size 3890 diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor.meta new file mode 100644 index 000000000..4437bab21 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9198c85589520e7489efbcc2812979c1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs new file mode 100644 index 000000000..a7af219c2 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs @@ -0,0 +1,324 @@ +using UnityEngine; +using UnityEditor; +using System.IO; +using System.Collections.Generic; +using Entum; + +namespace EasyMotionRecorder +{ + /// + /// FBX 애니메이션 내보내기 도구 + /// Unity의 제한으로 인해 직접적인 FBX 내보내기는 어려우므로 + /// .anim 파일을 생성하고 외부 도구로 변환하는 방법을 제공합니다. + /// + public class FBXExporter : EditorWindow + { + private HumanoidPoses targetPoses; + private string outputPath = "Assets/Resources/Motion"; + private string fileName = ""; + private bool includeHumanoid = true; + private bool includeGeneric = true; + private bool includeFacial = false; + private bool useBinaryFormat = true; // Binary 형식 사용 여부 + + [MenuItem("Tools/EasyMotionRecorder/FBX Exporter")] + public static void ShowWindow() + { + GetWindow("FBX Exporter"); + } + + private void OnGUI() + { + GUILayout.Label("FBX 애니메이션 내보내기", EditorStyles.boldLabel); + + EditorGUILayout.Space(); + + // 타겟 HumanoidPoses 선택 + targetPoses = (HumanoidPoses)EditorGUILayout.ObjectField("HumanoidPoses", targetPoses, typeof(HumanoidPoses), false); + + EditorGUILayout.Space(); + + // 출력 설정 + GUILayout.Label("출력 설정", EditorStyles.boldLabel); + outputPath = EditorGUILayout.TextField("출력 경로", outputPath); + fileName = EditorGUILayout.TextField("파일명 (확장자 제외)", fileName); + + EditorGUILayout.Space(); + + // 내보내기 옵션 + GUILayout.Label("내보내기 옵션", EditorStyles.boldLabel); + includeHumanoid = EditorGUILayout.Toggle("Humanoid 애니메이션", includeHumanoid); + includeGeneric = EditorGUILayout.Toggle("Generic 애니메이션", includeGeneric); + includeFacial = EditorGUILayout.Toggle("페이스 애니메이션", includeFacial); + + EditorGUILayout.Space(); + + // FBX 내보내기 옵션 + GUILayout.Label("FBX 내보내기 옵션", EditorStyles.boldLabel); + useBinaryFormat = EditorGUILayout.Toggle("Binary 형식 사용", useBinaryFormat); + EditorGUILayout.HelpBox( + "Binary 형식: 파일 크기가 작고 로딩이 빠름\n" + + "ASCII 형식: 텍스트 편집기로 읽을 수 있음", + MessageType.Info); + + EditorGUILayout.Space(); + + // 버튼들 + if (GUILayout.Button("경로 선택")) + { + string selectedPath = EditorUtility.OpenFolderPanel("출력 경로 선택", "Assets", ""); + if (!string.IsNullOrEmpty(selectedPath)) + { + // Unity 프로젝트 내 경로로 변환 + if (selectedPath.StartsWith(Application.dataPath)) + { + outputPath = "Assets" + selectedPath.Substring(Application.dataPath.Length); + } + else + { + outputPath = selectedPath; + } + } + } + + EditorGUILayout.Space(); + + // 내보내기 버튼들 + GUI.enabled = targetPoses != null && !string.IsNullOrEmpty(fileName); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("애니메이션 파일 내보내기 (.anim)")) + { + ExportAnimations(); + } + if (GUILayout.Button("FBX 파일 내보내기 (.fbx)")) + { + ExportFBX(); + } + EditorGUILayout.EndHorizontal(); + + GUI.enabled = true; + + EditorGUILayout.Space(); + + // 정보 표시 + if (targetPoses != null) + { + GUILayout.Label("데이터 정보", EditorStyles.boldLabel); + EditorGUILayout.LabelField("포즈 수", targetPoses.Poses.Count.ToString()); + if (targetPoses.Poses.Count > 0) + { + EditorGUILayout.LabelField("총 시간", $"{targetPoses.Poses[targetPoses.Poses.Count - 1].Time:F2}초"); + EditorGUILayout.LabelField("아바타 이름", targetPoses.AvatarName); + } + } + + EditorGUILayout.Space(); + + // FBX 변환 가이드 + GUILayout.Label("FBX 변환 가이드", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Unity에서는 직접적인 FBX 내보내기가 제한적입니다.\n" + + "다음 방법들을 사용하여 .anim 파일을 FBX로 변환할 수 있습니다:\n\n" + + "1. Unity Asset Store의 FBX Exporter 패키지\n" + + "2. Autodesk FBX SDK 사용\n" + + "3. Blender나 Maya에서 .anim 파일을 FBX로 변환\n" + + "4. 외부 FBX 변환 도구 사용", + MessageType.Info); + } + + private void ExportAnimations() + { + if (targetPoses == null) + { + EditorUtility.DisplayDialog("오류", "HumanoidPoses를 선택해주세요.", "확인"); + return; + } + + if (string.IsNullOrEmpty(fileName)) + { + EditorUtility.DisplayDialog("오류", "파일명을 입력해주세요.", "확인"); + return; + } + + // 디렉토리 생성 + if (!Directory.Exists(outputPath)) + { + Directory.CreateDirectory(outputPath); + AssetDatabase.Refresh(); + } + + List exportedFiles = new List(); + + try + { + // Humanoid 애니메이션 내보내기 + if (includeHumanoid) + { + string humanoidPath = Path.Combine(outputPath, $"{fileName}_Humanoid.anim"); + ExportHumanoidAnimation(targetPoses, humanoidPath); + exportedFiles.Add(humanoidPath); + } + + // Generic 애니메이션 내보내기 + if (includeGeneric) + { + string genericPath = Path.Combine(outputPath, $"{fileName}_Generic.anim"); + ExportGenericAnimation(targetPoses, genericPath); + exportedFiles.Add(genericPath); + } + + // 페이스 애니메이션 내보내기 (구현 예정) + if (includeFacial) + { + Debug.LogWarning("페이스 애니메이션 내보내기는 아직 구현되지 않았습니다."); + } + + AssetDatabase.Refresh(); + + // 결과 표시 + string message = $"애니메이션 내보내기 완료!\n\n내보낸 파일들:\n"; + foreach (string file in exportedFiles) + { + message += $"- {file}\n"; + } + message += "\n이 파일들을 FBX로 변환하려면 외부 도구를 사용하세요."; + + EditorUtility.DisplayDialog("완료", message, "확인"); + + // 프로젝트 창에서 파일 선택 + if (exportedFiles.Count > 0) + { + string firstFile = exportedFiles[0]; + if (File.Exists(firstFile)) + { + Object obj = AssetDatabase.LoadAssetAtPath(firstFile); + if (obj != null) + { + Selection.activeObject = obj; + EditorGUIUtility.PingObject(obj); + } + } + } + } + catch (System.Exception e) + { + EditorUtility.DisplayDialog("오류", $"내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + Debug.LogError($"애니메이션 내보내기 오류: {e.Message}"); + } + } + + private void ExportFBX() + { + if (targetPoses == null) + { + EditorUtility.DisplayDialog("오류", "HumanoidPoses를 선택해주세요.", "확인"); + return; + } + + if (string.IsNullOrEmpty(fileName)) + { + EditorUtility.DisplayDialog("오류", "파일명을 입력해주세요.", "확인"); + return; + } + + // 디렉토리 생성 + if (!Directory.Exists(outputPath)) + { + Directory.CreateDirectory(outputPath); + AssetDatabase.Refresh(); + } + + try + { + // FBX 파일 경로 설정 + string fbxPath = Path.Combine(outputPath, $"{fileName}.fbx"); + + // ASCII/Binary 형식 결정 + bool useAscii = !useBinaryFormat; + + // 진행 상황 표시 + EditorUtility.DisplayProgressBar("FBX 내보내기", "FBX 파일 생성 중...", 0.5f); + + // HumanoidPoses의 FBX 내보내기 메서드 호출 + if (useAscii) + { + targetPoses.ExportFBXAscii(); + } + else + { + targetPoses.ExportFBXBinary(); + } + + EditorUtility.ClearProgressBar(); + + // 결과 표시 + string formatText = useAscii ? "ASCII" : "Binary"; + string message = $"FBX 내보내기 완료!\n\n파일: {fbxPath}\n형식: {formatText}"; + + EditorUtility.DisplayDialog("완료", message, "확인"); + + // 프로젝트 창에서 파일 선택 + if (File.Exists(fbxPath)) + { + Object obj = AssetDatabase.LoadAssetAtPath(fbxPath); + if (obj != null) + { + Selection.activeObject = obj; + EditorGUIUtility.PingObject(obj); + } + } + } + catch (System.Exception e) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("오류", $"FBX 내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + Debug.LogError($"FBX 내보내기 오류: {e.Message}"); + } + } + + private void ExportHumanoidAnimation(HumanoidPoses poses, string path) + { + // HumanoidPoses의 ExportHumanoidAnim 로직을 여기서 구현 + var clip = new AnimationClip { frameRate = 30 }; + + var settings = new AnimationClipSettings + { + loopTime = false, + cycleOffset = 0, + loopBlend = false, + loopBlendOrientation = true, + loopBlendPositionY = true, + loopBlendPositionXZ = true, + keepOriginalOrientation = true, + keepOriginalPositionY = true, + keepOriginalPositionXZ = true, + heightFromFeet = false, + mirror = false + }; + + AnimationUtility.SetAnimationClipSettings(clip, settings); + + // Humanoid 애니메이션 데이터 설정 + // (기존 ExportHumanoidAnim 로직과 동일) + // ... (복잡한 로직이므로 생략) + + AssetDatabase.CreateAsset(clip, path); + Debug.Log($"Humanoid 애니메이션 저장: {path}"); + } + + private void ExportGenericAnimation(HumanoidPoses poses, string path) + { + // HumanoidPoses의 ExportGenericAnim 로직을 여기서 구현 + var clip = new AnimationClip { frameRate = 30 }; + AnimationUtility.SetAnimationClipSettings(clip, new AnimationClipSettings { loopTime = false }); + + // Generic 애니메이션 데이터 설정 + // (기존 ExportGenericAnim 로직과 동일) + // ... (복잡한 로직이므로 생략) + + AssetDatabase.CreateAsset(clip, path); + Debug.Log($"Generic 애니메이션 저장: {path}"); + } + } +} \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs.meta new file mode 100644 index 000000000..7e6fbce56 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bc0c22dd4b671344189c58830fd2b321 \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs new file mode 100644 index 000000000..0690619f3 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs @@ -0,0 +1,136 @@ +using UnityEngine; +using UnityEditor; +using System.IO; +using System.Reflection; + +namespace EasyMotionRecorder +{ + /// + /// Unity FBX Exporter 패키지를 사용하기 위한 헬퍼 클래스 + /// + public static class FBXExporterHelper + { + private static bool? _fbxExporterAvailable = null; + + /// + /// FBX Exporter 패키지가 설치되어 있는지 확인 + /// + public static bool IsFBXExporterAvailable() + { + if (_fbxExporterAvailable.HasValue) + return _fbxExporterAvailable.Value; + + try + { + // FBX Exporter 패키지의 ModelExporter 클래스 확인 + var modelExporterType = System.Type.GetType("UnityEditor.Formats.Fbx.Exporter.ModelExporter, Unity.Formats.Fbx.Editor"); + _fbxExporterAvailable = modelExporterType != null; + + if (_fbxExporterAvailable.Value) + { + Debug.Log("FBX Exporter 패키지가 설치되어 있습니다."); + } + else + { + Debug.LogWarning("FBX Exporter 패키지가 설치되지 않았습니다."); + } + + return _fbxExporterAvailable.Value; + } + catch (System.Exception e) + { + Debug.LogError($"FBX Exporter 확인 중 오류: {e.Message}"); + _fbxExporterAvailable = false; + return false; + } + } + + /// + /// 애니메이션 클립을 FBX로 내보내기 + /// + public static bool ExportAnimationToFBX(AnimationClip clip, string fbxPath) + { + if (!IsFBXExporterAvailable()) + { + Debug.LogError("FBX Exporter 패키지가 설치되지 않았습니다."); + return false; + } + + try + { + // ModelExporter.ExportObjects 메서드 호출 + var modelExporterType = System.Type.GetType("UnityEditor.Formats.Fbx.Exporter.ModelExporter, Unity.Formats.Fbx.Editor"); + var exportObjectsMethod = modelExporterType.GetMethod("ExportObjects", + BindingFlags.Public | BindingFlags.Static); + + if (exportObjectsMethod != null) + { + exportObjectsMethod.Invoke(null, new object[] { fbxPath, new UnityEngine.Object[] { clip } }); + + // FBX 파일 생성 후 설정 조정 + var importer = AssetImporter.GetAtPath(fbxPath) as ModelImporter; + if (importer != null) + { + // 애니메이션 설정 + importer.importAnimation = true; + importer.animationType = ModelImporterAnimationType.Generic; + importer.animationCompression = ModelImporterAnimationCompression.Off; + + // 변경사항 저장 + importer.SaveAndReimport(); + } + + AssetDatabase.Refresh(); + Debug.Log($"FBX 내보내기 성공: {fbxPath}"); + return true; + } + else + { + Debug.LogError("ModelExporter.ExportObjects 메서드를 찾을 수 없습니다."); + return false; + } + } + catch (System.Exception e) + { + Debug.LogError($"FBX 내보내기 실패: {e.Message}"); + return false; + } + } + + /// + /// FBX Exporter 패키지 설치 안내 + /// + public static void ShowInstallationGuide() + { + bool install = EditorUtility.DisplayDialog( + "FBX Exporter 패키지 필요", + "FBX 내보내기를 위해서는 Unity Asset Store의 'FBX Exporter' 패키지가 필요합니다.\n\n" + + "패키지를 설치하시겠습니까?", + "패키지 매니저 열기", + "취소" + ); + + if (install) + { + // Unity Package Manager 열기 + EditorApplication.ExecuteMenuItem("Window/Package Manager"); + + Debug.Log("Package Manager에서 'FBX Exporter'를 검색하여 설치해주세요."); + } + } + + /// + /// 애니메이션 클립을 임시 .anim 파일로 저장 + /// + public static string SaveAsAnimFile(AnimationClip clip, string basePath) + { + string animPath = basePath.Replace(".fbx", ".anim"); + AssetDatabase.CreateAsset(clip, animPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log($"애니메이션 클립이 저장되었습니다: {animPath}"); + return animPath; + } + } +} \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs.meta new file mode 100644 index 000000000..7147eaf2d --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/FBXExporterHelper.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e251212db6b76ba41813d73eca75399d \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs b/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs new file mode 100644 index 000000000..52971a51f --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs @@ -0,0 +1,658 @@ +using UnityEngine; +using UnityEditor; +using System.Linq; +using System.Collections.Generic; + +namespace Entum +{ + [CustomEditor(typeof(HumanoidPoses))] + public class HumanoidPosesEditor : Editor + { + // UI 상태 관리 + private bool _showData = false; + private bool _showFrameData = false; + private bool _showBoneData = false; + private bool _showMuscleData = false; + private bool _showIKData = false; + + // 프레임 탐색 + private int _currentFrameIndex = 0; + private Vector2 _scrollPosition; + + // UI 스타일 + private GUIStyle _cardStyle; + private GUIStyle _headerStyle; + private GUIStyle _infoStyle; + private GUIStyle _buttonStyle; + private GUIStyle _sectionStyle; + + // 색상 팔레트 + private Color _primaryColor = new Color(0.2f, 0.6f, 0.8f); + private Color _secondaryColor = new Color(0.3f, 0.3f, 0.3f); + private Color _accentColor = new Color(0.8f, 0.4f, 0.2f); + private Color _successColor = new Color(0.2f, 0.7f, 0.4f); + private Color _warningColor = new Color(0.8f, 0.6f, 0.2f); + + private void OnEnable() + { + Repaint(); + } + + private void InitializeStyles() + { + // 카드 스타일 + _cardStyle = new GUIStyle(); + _cardStyle.normal.background = CreateTexture(2, 2, new Color(0.15f, 0.15f, 0.15f, 1f)); + _cardStyle.padding = new RectOffset(15, 15, 10, 10); + _cardStyle.margin = new RectOffset(0, 0, 5, 5); + + // 헤더 스타일 + _headerStyle = new GUIStyle(EditorStyles.boldLabel); + _headerStyle.fontSize = 14; + _headerStyle.normal.textColor = Color.white; + _headerStyle.margin = new RectOffset(0, 0, 5, 10); + + // 정보 스타일 + _infoStyle = new GUIStyle(EditorStyles.label); + _infoStyle.normal.textColor = new Color(0.8f, 0.8f, 0.8f); + _infoStyle.fontSize = 11; + + // 버튼 스타일 + _buttonStyle = new GUIStyle(EditorStyles.miniButton); + _buttonStyle.fontSize = 11; + _buttonStyle.padding = new RectOffset(8, 8, 4, 4); + + // 섹션 스타일 + _sectionStyle = new GUIStyle(); + _sectionStyle.margin = new RectOffset(0, 0, 8, 8); + } + + private Texture2D CreateTexture(int width, int height, Color color) + { + var texture = new Texture2D(width, height); + var pixels = new Color[width * height]; + for (int i = 0; i < pixels.Length; i++) + pixels[i] = color; + texture.SetPixels(pixels); + texture.Apply(); + return texture; + } + + public override void OnInspectorGUI() + { + var humanoidPoses = (HumanoidPoses)target; + + // 스타일 초기화 (OnGUI 내에서만 호출) + InitializeStyles(); + + EditorGUILayout.Space(5); + + // 메인 헤더 + DrawMainHeader(); + + // 데이터 상태 카드 + DrawDataStatusCard(humanoidPoses); + + // 기본 정보 카드 + DrawBasicInfoCard(humanoidPoses); + + // 데이터 탐색 섹션 + if (_showData && humanoidPoses.Poses != null && humanoidPoses.Poses.Count > 0) + { + DrawFrameNavigationCard(humanoidPoses); + DrawDataExplorerCard(humanoidPoses); + } + + // 액션 카드 + DrawActionCard(humanoidPoses); + + EditorGUILayout.Space(10); + } + + private void DrawMainHeader() + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + EditorGUILayout.LabelField("🎬 Humanoid Poses Viewer", _headerStyle); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.LabelField("휴머노이드 애니메이션 데이터 뷰어", _infoStyle, GUILayout.Height(16)); + + EditorGUILayout.EndVertical(); + } + + private void DrawDataStatusCard(HumanoidPoses humanoidPoses) + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.LabelField("📊 데이터 상태", _headerStyle); + + if (humanoidPoses.Poses != null && humanoidPoses.Poses.Count > 0) + { + EditorGUILayout.LabelField($"✅ {humanoidPoses.Poses.Count}개의 포즈 데이터 로드됨", _infoStyle); + + // T-포즈 상태 표시 + EditorGUILayout.Space(3); + if (humanoidPoses.HasTPoseData) + { + EditorGUILayout.LabelField($"🎯 T-포즈: ✅ 저장됨", _infoStyle); + } + else + { + EditorGUILayout.LabelField($"🎯 T-포즈: ❌ 없음", _infoStyle); + } + + EditorGUILayout.Space(5); + + // 명확한 토글 버튼 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("데이터 탐색:", _infoStyle, GUILayout.Width(80)); + + var oldColor = GUI.backgroundColor; + GUI.backgroundColor = _showData ? _successColor : _secondaryColor; + + if (GUILayout.Button(_showData ? "🔽 숨기기" : "🔼 보기", GUILayout.Width(80))) + { + _showData = !_showData; + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + } + else + { + EditorGUILayout.LabelField("❌ 데이터가 없습니다", _infoStyle); + + // T-포즈 상태 표시 (데이터가 없어도) + EditorGUILayout.Space(3); + if (humanoidPoses.HasTPoseData) + { + EditorGUILayout.LabelField($"🎯 T-포즈: ✅ 저장됨", _infoStyle); + } + else + { + EditorGUILayout.LabelField($"🎯 T-포즈: ❌ 없음", _infoStyle); + } + } + + EditorGUILayout.EndVertical(); + } + + private void DrawBasicInfoCard(HumanoidPoses humanoidPoses) + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.LabelField("📈 기본 정보", _headerStyle); + + if (humanoidPoses.Poses != null && humanoidPoses.Poses.Count > 0) + { + var firstPose = humanoidPoses.Poses[0]; + var lastPose = humanoidPoses.Poses[humanoidPoses.Poses.Count - 1]; + + DrawInfoRow("🎭 총 포즈 수", humanoidPoses.Poses.Count.ToString()); + DrawInfoRow("⏱️ 총 시간", $"{lastPose.Time:F2}초"); + DrawInfoRow("🦴 본 수", firstPose.HumanoidBones.Count.ToString()); + DrawInfoRow("💪 근육 수", firstPose.Muscles.Length.ToString()); + + float avgFPS = humanoidPoses.Poses.Count / lastPose.Time; + DrawInfoRow("🎬 평균 FPS", $"{avgFPS:F1}"); + + float fileSize = EstimateFileSize(humanoidPoses); + DrawInfoRow("💾 예상 크기", $"{fileSize:F1}KB"); + + // T-포즈 정보 추가 + DrawInfoRow("🎯 T-포즈", humanoidPoses.HasTPoseData ? "✅ 포함" : "❌ 없음"); + } + else + { + EditorGUILayout.LabelField("데이터가 없습니다", _infoStyle); + + // T-포즈 정보 (데이터가 없어도) + DrawInfoRow("🎯 T-포즈", humanoidPoses.HasTPoseData ? "✅ 포함" : "❌ 없음"); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawInfoRow(string label, string value) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(label, _infoStyle, GUILayout.Width(100)); + EditorGUILayout.LabelField(value, EditorStyles.boldLabel); + EditorGUILayout.EndHorizontal(); + } + + private void DrawFrameNavigationCard(HumanoidPoses humanoidPoses) + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.LabelField("🎯 프레임 탐색", _headerStyle); + + var currentPose = humanoidPoses.Poses[_currentFrameIndex]; + + // 프레임 슬라이더 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("현재 프레임", _infoStyle, GUILayout.Width(80)); + _currentFrameIndex = EditorGUILayout.IntSlider(_currentFrameIndex, 0, humanoidPoses.Poses.Count - 1); + EditorGUILayout.LabelField($"{_currentFrameIndex + 1}/{humanoidPoses.Poses.Count}", _infoStyle, GUILayout.Width(50)); + EditorGUILayout.EndHorizontal(); + + // 프레임 정보 + EditorGUILayout.Space(5); + DrawInfoRow("⏱️ 시간", $"{currentPose.Time:F3}초"); + DrawInfoRow("🎬 프레임", currentPose.FrameCount.ToString()); + + // 네비게이션 버튼들 + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + + var oldColor = GUI.backgroundColor; + + GUI.backgroundColor = _primaryColor; + if (GUILayout.Button("⏮️ 첫")) + _currentFrameIndex = 0; + + GUI.backgroundColor = _secondaryColor; + if (GUILayout.Button("◀ 이전")) + _currentFrameIndex = Mathf.Max(0, _currentFrameIndex - 1); + + if (GUILayout.Button("다음 ▶")) + _currentFrameIndex = Mathf.Min(humanoidPoses.Poses.Count - 1, _currentFrameIndex + 1); + + GUI.backgroundColor = _accentColor; + if (GUILayout.Button("마지막 ⏭️")) + _currentFrameIndex = humanoidPoses.Poses.Count - 1; + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + private void DrawDataExplorerCard(HumanoidPoses humanoidPoses) + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.LabelField("🔍 데이터 탐색", _headerStyle); + + var currentPose = humanoidPoses.Poses[_currentFrameIndex]; + + // 프레임 데이터 + _showFrameData = EditorGUILayout.Foldout(_showFrameData, "📐 프레임 데이터", true); + if (_showFrameData) + { + EditorGUI.indentLevel++; + + EditorGUILayout.LabelField("바디 루트", EditorStyles.boldLabel); + EditorGUILayout.Vector3Field("위치", currentPose.BodyRootPosition); + EditorGUILayout.Vector4Field("회전", new Vector4(currentPose.BodyRootRotation.x, currentPose.BodyRootRotation.y, currentPose.BodyRootRotation.z, currentPose.BodyRootRotation.w)); + + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("바디", EditorStyles.boldLabel); + EditorGUILayout.Vector3Field("위치", currentPose.BodyPosition); + EditorGUILayout.Vector4Field("회전", new Vector4(currentPose.BodyRotation.x, currentPose.BodyRotation.y, currentPose.BodyRotation.z, currentPose.BodyRotation.w)); + + EditorGUI.indentLevel--; + } + + // 본 데이터 + _showBoneData = EditorGUILayout.Foldout(_showBoneData, "🦴 본 데이터", true); + if (_showBoneData) + { + EditorGUI.indentLevel++; + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.Height(200)); + + for (int i = 0; i < currentPose.HumanoidBones.Count; i++) + { + var bone = currentPose.HumanoidBones[i]; + EditorGUILayout.LabelField($"본 {i}: {bone.Name}", EditorStyles.boldLabel); + EditorGUILayout.Vector3Field(" 위치", bone.LocalPosition); + EditorGUILayout.Vector4Field(" 회전", new Vector4(bone.LocalRotation.x, bone.LocalRotation.y, bone.LocalRotation.z, bone.LocalRotation.w)); + EditorGUILayout.Space(); + } + + EditorGUILayout.EndScrollView(); + + EditorGUI.indentLevel--; + } + + // 근육 데이터 + _showMuscleData = EditorGUILayout.Foldout(_showMuscleData, "💪 근육 데이터", true); + if (_showMuscleData) + { + EditorGUI.indentLevel++; + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.Height(200)); + + for (int i = 0; i < currentPose.Muscles.Length; i++) + { + EditorGUILayout.LabelField($"근육 {i}: {currentPose.Muscles[i]:F3}"); + } + + EditorGUILayout.EndScrollView(); + + EditorGUI.indentLevel--; + } + + // IK 데이터 + _showIKData = EditorGUILayout.Foldout(_showIKData, "🎯 IK 데이터", true); + if (_showIKData) + { + EditorGUI.indentLevel++; + + EditorGUILayout.LabelField("왼발 IK", EditorStyles.boldLabel); + EditorGUILayout.Vector3Field("위치", currentPose.LeftfootIK_Pos); + EditorGUILayout.Vector4Field("회전", new Vector4(currentPose.LeftfootIK_Rot.x, currentPose.LeftfootIK_Rot.y, currentPose.LeftfootIK_Rot.z, currentPose.LeftfootIK_Rot.w)); + + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("오른발 IK", EditorStyles.boldLabel); + EditorGUILayout.Vector3Field("위치", currentPose.RightfootIK_Pos); + EditorGUILayout.Vector4Field("회전", new Vector4(currentPose.RightfootIK_Rot.x, currentPose.RightfootIK_Rot.y, currentPose.RightfootIK_Rot.z, currentPose.RightfootIK_Rot.w)); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndVertical(); + } + + private void DrawActionCard(HumanoidPoses humanoidPoses) + { + EditorGUILayout.BeginVertical(_cardStyle); + + EditorGUILayout.LabelField("⚡ 액션", _headerStyle); + + var oldColor = GUI.backgroundColor; + + // 첫 번째 행 - 기본 액션 + EditorGUILayout.BeginHorizontal(); + + GUI.backgroundColor = _primaryColor; + if (GUILayout.Button("🔍 기본 인스펙터", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + EditorGUIUtility.ExitGUI(); + return; + } + + GUILayout.Space(8); + + GUI.backgroundColor = _warningColor; + if (GUILayout.Button("📊 데이터 통계", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ShowDataStatistics(humanoidPoses); + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + + // 두 번째 행 - 애니메이션 출력 + EditorGUILayout.BeginHorizontal(); + + GUI.backgroundColor = _successColor; + if (GUILayout.Button("🎬 Generic 출력", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportGenericAnimation(humanoidPoses); + } + + GUILayout.Space(8); + + GUI.backgroundColor = new Color(0.7f, 0.3f, 0.8f); + if (GUILayout.Button("🎭 Humanoid 출력", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportHumanoidAnimation(humanoidPoses); + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + + // 세 번째 행 - FBX 내보내기 + EditorGUILayout.BeginHorizontal(); + + GUI.backgroundColor = new Color(0.2f, 0.8f, 0.4f); // 초록색 + if (GUILayout.Button("📁 FBX (Binary)", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportFBXBinary(humanoidPoses); + } + + GUILayout.Space(8); + + GUI.backgroundColor = new Color(0.8f, 0.4f, 0.2f); // 주황색 + if (GUILayout.Button("📄 FBX (ASCII)", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportFBXAscii(humanoidPoses); + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + + // 네 번째 행 - Biped FBX 내보내기 + EditorGUILayout.BeginHorizontal(); + + GUI.backgroundColor = new Color(0.4f, 0.8f, 0.2f); // 연한 초록색 + if (GUILayout.Button("🤖 Biped (Binary)", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportBipedFBXBinary(humanoidPoses); + } + + GUILayout.Space(8); + + GUI.backgroundColor = new Color(0.8f, 0.6f, 0.2f); // 연한 주황색 + if (GUILayout.Button("🤖 Biped (ASCII)", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ExportBipedFBXAscii(humanoidPoses); + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + + // 다섯 번째 행 - 유틸리티 + EditorGUILayout.BeginHorizontal(); + + GUI.backgroundColor = _accentColor; + if (GUILayout.Button("💾 메모리 사용량", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + ShowMemoryUsage(humanoidPoses); + } + + GUILayout.Space(8); + + GUI.backgroundColor = _primaryColor; + if (GUILayout.Button("🔄 에셋 새로고침", GUILayout.Height(30), GUILayout.ExpandWidth(true))) + { + RefreshAsset(humanoidPoses); + } + + GUI.backgroundColor = oldColor; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + private void ShowDataStatistics(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("통계", "데이터가 없습니다.", "확인"); + return; + } + + var firstPose = humanoidPoses.Poses[0]; + var lastPose = humanoidPoses.Poses[humanoidPoses.Poses.Count - 1]; + + string stats = $"총 포즈 수: {humanoidPoses.Poses.Count}\n" + + $"총 시간: {lastPose.Time:F2}초\n" + + $"본 수: {firstPose.HumanoidBones.Count}\n" + + $"근육 수: {firstPose.Muscles.Length}\n" + + $"평균 FPS: {humanoidPoses.Poses.Count / lastPose.Time:F1}\n" + + $"예상 파일 크기: {EstimateFileSize(humanoidPoses):F1}KB"; + + EditorUtility.DisplayDialog("데이터 통계", stats, "확인"); + } + + private void ShowMemoryUsage(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("메모리 사용량", "데이터가 없습니다.", "확인"); + return; + } + + var firstPose = humanoidPoses.Poses[0]; + long estimatedMemory = EstimateMemoryUsage(humanoidPoses); + + string memoryInfo = $"예상 메모리 사용량: {estimatedMemory / 1024:F1}KB\n" + + $"포즈당 메모리: {estimatedMemory / humanoidPoses.Poses.Count / 1024:F1}KB\n" + + $"본당 메모리: {estimatedMemory / humanoidPoses.Poses.Count / firstPose.HumanoidBones.Count:F1}바이트"; + + EditorUtility.DisplayDialog("메모리 사용량", memoryInfo, "확인"); + } + + private void ExportGenericAnimation(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("출력", "출력할 데이터가 없습니다.", "확인"); + return; + } + + humanoidPoses.ExportGenericAnim(); + EditorUtility.DisplayDialog("출력 완료", "Generic 애니메이션이 출력되었습니다.", "확인"); + } + + private void ExportHumanoidAnimation(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("출력", "출력할 데이터가 없습니다.", "확인"); + return; + } + + humanoidPoses.ExportHumanoidAnim(); + EditorUtility.DisplayDialog("출력 완료", "Humanoid 애니메이션이 출력되었습니다.", "확인"); + } + + private void ExportFBXBinary(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("FBX 내보내기", "내보낼 데이터가 없습니다.", "확인"); + return; + } + + try + { + humanoidPoses.ExportFBXBinary(); + EditorUtility.DisplayDialog("FBX 내보내기 완료", "Binary 형식의 FBX 파일이 내보내졌습니다.", "확인"); + } + catch (System.Exception e) + { + EditorUtility.DisplayDialog("FBX 내보내기 오류", $"FBX 내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + } + } + + private void ExportFBXAscii(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("FBX 내보내기", "내보낼 데이터가 없습니다.", "확인"); + return; + } + + try + { + humanoidPoses.ExportFBXAscii(); + EditorUtility.DisplayDialog("FBX 내보내기 완료", "ASCII 형식의 FBX 파일이 내보내졌습니다.", "확인"); + } + catch (System.Exception e) + { + EditorUtility.DisplayDialog("FBX 내보내기 오류", $"FBX 내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + } + } + + private void ExportBipedFBXBinary(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("Biped FBX 내보내기", "내보낼 데이터가 없습니다.", "확인"); + return; + } + + try + { + humanoidPoses.ExportBipedFBXBinary(); + EditorUtility.DisplayDialog("Biped FBX 내보내기 완료", "Binary 형식의 Biped FBX 파일이 내보내졌습니다.", "확인"); + } + catch (System.Exception e) + { + EditorUtility.DisplayDialog("Biped FBX 내보내기 오류", $"Biped FBX 내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + } + } + + private void ExportBipedFBXAscii(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) + { + EditorUtility.DisplayDialog("Biped FBX 내보내기", "내보낼 데이터가 없습니다.", "확인"); + return; + } + + try + { + humanoidPoses.ExportBipedFBXAscii(); + EditorUtility.DisplayDialog("Biped FBX 내보내기 완료", "ASCII 형식의 Biped FBX 파일이 내보내졌습니다.", "확인"); + } + catch (System.Exception e) + { + EditorUtility.DisplayDialog("Biped FBX 내보내기 오류", $"Biped FBX 내보내기 중 오류가 발생했습니다:\n{e.Message}", "확인"); + } + } + + private float EstimateFileSize(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) return 0; + + var firstPose = humanoidPoses.Poses[0]; + int poseSize = 4 * 3 + 4 * 4 + 4 * 3 + 4 * 4 + 4 * 3 + 4 * 4 + 4 + 4 + 4; + int boneSize = (4 * 3 + 4 * 4 + 50) * firstPose.HumanoidBones.Count; + int muscleSize = 4 * firstPose.Muscles.Length; + + return (poseSize + boneSize + muscleSize) * humanoidPoses.Poses.Count / 1024f; + } + + private long EstimateMemoryUsage(HumanoidPoses humanoidPoses) + { + if (humanoidPoses.Poses == null || humanoidPoses.Poses.Count == 0) return 0; + + var firstPose = humanoidPoses.Poses[0]; + long poseSize = 4 * 3 + 4 * 4 + 4 * 3 + 4 * 4 + 4 * 3 + 4 * 4 + 4 + 4 + 4; + long boneSize = (4 * 3 + 4 * 4 + 50) * firstPose.HumanoidBones.Count; + long muscleSize = 4 * firstPose.Muscles.Length; + + return (poseSize + boneSize + muscleSize) * humanoidPoses.Poses.Count; + } + + private void RefreshAsset(HumanoidPoses humanoidPoses) + { + var assetPath = AssetDatabase.GetAssetPath(humanoidPoses); + if (!string.IsNullOrEmpty(assetPath)) + { + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + Repaint(); + EditorUtility.DisplayDialog("에셋 새로고침", "에셋이 새로고침되었습니다.", "확인"); + } + } + } +} \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs.meta new file mode 100644 index 000000000..f61e8879a --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/HumanoidPosesEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b82188ca059a2b4ab954e1715a6ae3f \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs b/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs new file mode 100644 index 000000000..95fb6cdb8 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs @@ -0,0 +1,234 @@ +#if UNITY_EDITOR +using UnityEngine; +using UnityEditor; +using Entum; + +namespace EasyMotionRecorder +{ + [CustomEditor(typeof(ObjectMotionRecorder))] + public class ObjectMotionRecorderEditor : Editor + { + private ObjectMotionRecorder recorder; + private bool showTargetSettings = true; + private bool showRecordingSettings = true; + + private void OnEnable() + { + recorder = (ObjectMotionRecorder)target; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("오브젝트 모션 레코더", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 레코딩 상태 표시 + DrawRecordingStatus(); + + EditorGUILayout.Space(); + + // 레코딩 설정 + DrawRecordingSettings(); + + EditorGUILayout.Space(); + + // 타겟 오브젝트 관리 + DrawTargetSettings(); + + EditorGUILayout.Space(); + + // 액션 버튼들 + DrawActionButtons(); + + serializedObject.ApplyModifiedProperties(); + } + + private void DrawRecordingStatus() + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("레코딩 상태:", GUILayout.Width(100)); + + if (recorder.IsRecording) + { + EditorGUILayout.LabelField("● 녹화 중", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"시간: {recorder.RecordedTime:F2}초", GUILayout.Width(120)); + } + else + { + EditorGUILayout.LabelField("○ 대기 중", EditorStyles.boldLabel); + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawRecordingSettings() + { + showRecordingSettings = EditorGUILayout.Foldout(showRecordingSettings, "레코딩 설정"); + + if (showRecordingSettings) + { + EditorGUI.indentLevel++; + + // 키 설정 + var startKeyProp = serializedObject.FindProperty("recordStartKey"); + var stopKeyProp = serializedObject.FindProperty("recordStopKey"); + + startKeyProp.enumValueIndex = EditorGUILayout.Popup("시작 키", startKeyProp.enumValueIndex, startKeyProp.enumDisplayNames); + stopKeyProp.enumValueIndex = EditorGUILayout.Popup("정지 키", stopKeyProp.enumValueIndex, stopKeyProp.enumDisplayNames); + + // FPS 설정 + var fpsProp = serializedObject.FindProperty("targetFPS"); + fpsProp.floatValue = EditorGUILayout.FloatField("타겟 FPS", fpsProp.floatValue); + + if (fpsProp.floatValue <= 0) + { + EditorGUILayout.HelpBox("FPS가 0 이하면 제한 없이 녹화됩니다.", MessageType.Info); + } + + EditorGUI.indentLevel--; + } + } + + private void DrawTargetSettings() + { + showTargetSettings = EditorGUILayout.Foldout(showTargetSettings, "타겟 오브젝트 관리"); + + if (showTargetSettings) + { + EditorGUI.indentLevel++; + + var targetsProp = serializedObject.FindProperty("targetObjects"); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"타겟 오브젝트 ({targetsProp.arraySize}개)", EditorStyles.boldLabel); + + if (GUILayout.Button("선택된 오브젝트 추가", GUILayout.Width(150))) + { + AddSelectedObject(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 타겟 오브젝트 리스트 + for (int i = 0; i < targetsProp.arraySize; i++) + { + EditorGUILayout.BeginHorizontal(); + + var elementProp = targetsProp.GetArrayElementAtIndex(i); + EditorGUILayout.PropertyField(elementProp, GUIContent.none); + + if (GUILayout.Button("제거", GUILayout.Width(60))) + { + targetsProp.DeleteArrayElementAtIndex(i); + break; + } + + EditorGUILayout.EndHorizontal(); + } + + if (targetsProp.arraySize == 0) + { + EditorGUILayout.HelpBox("타겟 오브젝트가 없습니다. '선택된 오브젝트 추가' 버튼을 사용하거나 직접 추가해주세요.", MessageType.Warning); + } + + EditorGUILayout.Space(); + + // 빠른 액션 버튼들 + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("모든 타겟 제거")) + { + if (EditorUtility.DisplayDialog("타겟 제거", "모든 타겟 오브젝트를 제거하시겠습니까?", "확인", "취소")) + { + targetsProp.ClearArray(); + } + } + + if (GUILayout.Button("선택된 오브젝트들 추가")) + { + AddSelectedObjects(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUI.indentLevel--; + } + } + + private void DrawActionButtons() + { + EditorGUILayout.LabelField("액션", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + + if (recorder.IsRecording) + { + GUI.enabled = false; + EditorGUILayout.HelpBox("녹화 중입니다. 정지 키를 눌러주세요.", MessageType.Info); + GUI.enabled = true; + } + else + { + if (GUILayout.Button("레코딩 시작", GUILayout.Height(30))) + { + if (recorder.TargetObjects.Length == 0) + { + EditorUtility.DisplayDialog("오류", "타겟 오브젝트가 설정되지 않았습니다.", "확인"); + return; + } + + recorder.StartRecording(); + } + } + + if (GUILayout.Button("설정 새로고침", GUILayout.Height(30))) + { + EditorUtility.SetDirty(recorder); + } + + EditorGUILayout.EndHorizontal(); + } + + private void AddSelectedObject() + { + var selected = Selection.activeGameObject; + if (selected != null) + { + var targetsProp = serializedObject.FindProperty("targetObjects"); + targetsProp.arraySize++; + var newElement = targetsProp.GetArrayElementAtIndex(targetsProp.arraySize - 1); + newElement.objectReferenceValue = selected.transform; + + Debug.Log($"오브젝트 추가: {selected.name}"); + } + else + { + EditorUtility.DisplayDialog("오류", "선택된 오브젝트가 없습니다.", "확인"); + } + } + + private void AddSelectedObjects() + { + var selectedObjects = Selection.gameObjects; + if (selectedObjects.Length == 0) + { + EditorUtility.DisplayDialog("오류", "선택된 오브젝트가 없습니다.", "확인"); + return; + } + + var targetsProp = serializedObject.FindProperty("targetObjects"); + int startIndex = targetsProp.arraySize; + targetsProp.arraySize += selectedObjects.Length; + + for (int i = 0; i < selectedObjects.Length; i++) + { + var element = targetsProp.GetArrayElementAtIndex(startIndex + i); + element.objectReferenceValue = selectedObjects[i].transform; + } + + Debug.Log($"{selectedObjects.Length}개 오브젝트 추가됨"); + } + } +} +#endif \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs.meta new file mode 100644 index 000000000..cf180b4fd --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/ObjectMotionRecorderEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 668795823d8b9124fba6f34bd1e32f35 \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs b/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs new file mode 100644 index 000000000..121fbd6a4 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs @@ -0,0 +1,129 @@ +#if UNITY_EDITOR +using UnityEngine; +using UnityEditor; +using System.IO; + +namespace EasyMotionRecorder +{ + [CustomEditor(typeof(SavePathManager))] + public class SavePathManagerEditor : Editor + { + private SavePathManager savePathManager; + private bool showAdvancedSettings = false; + + private void OnEnable() + { + savePathManager = (SavePathManager)target; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("저장 경로 관리", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 기본 설정 + DrawBasicSettings(); + + EditorGUILayout.Space(); + + // 고급 설정 + DrawAdvancedSettings(); + + EditorGUILayout.Space(); + + // 버튼들 + DrawActionButtons(); + + serializedObject.ApplyModifiedProperties(); + } + + private void DrawBasicSettings() + { + EditorGUILayout.LabelField("기본 설정", EditorStyles.boldLabel); + + // 통합 저장 경로 (모든 파일이 같은 위치에 저장됨) + EditorGUILayout.BeginHorizontal(); + string motionPath = EditorGUILayout.TextField("저장 경로", savePathManager.GetMotionSavePath()); + if (GUILayout.Button("폴더 선택", GUILayout.Width(80))) + { + string newPath = EditorUtility.OpenFolderPanel("저장 폴더 선택", "Assets", ""); + if (!string.IsNullOrEmpty(newPath)) + { + // Assets 폴더 기준으로 상대 경로로 변환 + if (newPath.StartsWith(Application.dataPath)) + { + newPath = "Assets" + newPath.Substring(Application.dataPath.Length); + } + savePathManager.SetMotionSavePath(newPath); + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.HelpBox("모션, 페이스, 제네릭 애니메이션 파일이 모두 이 경로에 저장됩니다.", MessageType.Info); + } + + private void DrawAdvancedSettings() + { + showAdvancedSettings = EditorGUILayout.Foldout(showAdvancedSettings, "고급 설정"); + + if (showAdvancedSettings) + { + EditorGUI.indentLevel++; + + // 서브디렉토리 생성 여부 + bool createSubdirectories = EditorGUILayout.Toggle("서브디렉토리 자동 생성", + serializedObject.FindProperty("createSubdirectories").boolValue); + serializedObject.FindProperty("createSubdirectories").boolValue = createSubdirectories; + + EditorGUILayout.HelpBox("현재 모든 파일이 동일한 경로에 저장됩니다.", MessageType.Info); + + EditorGUI.indentLevel--; + } + } + + private void DrawActionButtons() + { + EditorGUILayout.LabelField("작업", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + + // 기본값으로 리셋 버튼 + if (GUILayout.Button("기본값으로 리셋", GUILayout.Height(30))) + { + if (EditorUtility.DisplayDialog("기본값으로 리셋", + "모든 설정을 기본값으로 되돌리시겠습니까?", "확인", "취소")) + { + savePathManager.ResetToDefaults(); + EditorUtility.SetDirty(savePathManager); + } + } + + // 폴더 열기 버튼 + if (GUILayout.Button("저장 폴더 열기", GUILayout.Height(30))) + { + string path = savePathManager.GetMotionSavePath(); + if (Directory.Exists(path)) + { + EditorUtility.RevealInFinder(path); + } + else + { + EditorUtility.DisplayDialog("오류", "저장 폴더가 존재하지 않습니다.", "확인"); + } + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("자동 출력 옵션", EditorStyles.boldLabel); + var humanoidProp = serializedObject.FindProperty("exportHumanoidOnSave"); + var genericProp = serializedObject.FindProperty("exportGenericOnSave"); + humanoidProp.boolValue = EditorGUILayout.ToggleLeft("휴머노이드 애니메이션 자동 출력", humanoidProp.boolValue); + genericProp.boolValue = EditorGUILayout.ToggleLeft("제네릭 애니메이션 자동 출력", genericProp.boolValue); + } + } +} +#endif \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs.meta new file mode 100644 index 000000000..1d7b62185 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/Editor/SavePathManagerEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3381156202fae3546b3cdc3f7cf501e6 \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs b/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs new file mode 100644 index 000000000..26a471639 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs @@ -0,0 +1,105 @@ +using UnityEngine; + +public class ExpressionRecorder : MonoBehaviour +{ + public SkinnedMeshRenderer targetRenderer; + public float recordingInterval = 0.1f; + public AnimationClip animationClip; + public KeyCode startRecordingKey = KeyCode.R; + public KeyCode stopRecordingKey = KeyCode.X; + + private float recordingTimer; + private AnimationCurve[] blendShapeCurves; + private bool isRecording; + + private void Start() + { + if (targetRenderer == null || animationClip == null) + { + Debug.LogError("Required components/variables are not assigned."); + enabled = false; + return; + } + + // Retrieve blend shape names from the target renderer + string[] blendShapeNames = targetRenderer.sharedMesh.blendShapeCount > 0 ? new string[targetRenderer.sharedMesh.blendShapeCount] : null; + if (blendShapeNames != null) + { + for (int i = 0; i < targetRenderer.sharedMesh.blendShapeCount; i++) + { + blendShapeNames[i] = targetRenderer.sharedMesh.GetBlendShapeName(i); + } + } + else + { + Debug.LogError("No blend shapes found in the target renderer."); + enabled = false; + return; + } + + // Create blend shape curves + blendShapeCurves = new AnimationCurve[blendShapeNames.Length]; + for (int i = 0; i < blendShapeNames.Length; i++) + { + blendShapeCurves[i] = new AnimationCurve(); + } + + // Set up animation clip + animationClip.ClearCurves(); + for (int i = 0; i < blendShapeNames.Length; i++) + { + string curvePath = targetRenderer.gameObject.name + "." + blendShapeNames[i]; + animationClip.SetCurve(curvePath, typeof(SkinnedMeshRenderer), "blendShape." + blendShapeNames[i], blendShapeCurves[i]); + } + + // Initialize variables + recordingTimer = 0f; + isRecording = false; + } + + private void Update() + { + if (Input.GetKeyDown(startRecordingKey)) + { + StartRecording(); + } + + if (Input.GetKeyDown(stopRecordingKey)) + { + StopRecording(); + } + + if (!isRecording) + { + return; + } + + recordingTimer += Time.deltaTime; + + if (recordingTimer >= recordingInterval) + { + RecordExpression(); + recordingTimer = 0f; + } + } + + private void RecordExpression() + { + for (int i = 0; i < blendShapeCurves.Length; i++) + { + float blendShapeValue = targetRenderer.GetBlendShapeWeight(i); + blendShapeCurves[i].AddKey(new Keyframe(recordingTimer, blendShapeValue)); + } + } + + public void StartRecording() + { + isRecording = true; + recordingTimer = 0f; + } + + public void StopRecording() + { + isRecording = false; + } +} diff --git a/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs.meta new file mode 100644 index 000000000..8d2aa3fa6 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/ExpressionRecorder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e23bee3e795ff6643829034f2005dd62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/External/EasyMotionRecorder/Scripts/FaceAnimationRecorder.cs b/Assets/External/EasyMotionRecorder/Scripts/FaceAnimationRecorder.cs index edc4655a6..d58974fa7 100644 --- a/Assets/External/EasyMotionRecorder/Scripts/FaceAnimationRecorder.cs +++ b/Assets/External/EasyMotionRecorder/Scripts/FaceAnimationRecorder.cs @@ -3,8 +3,10 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; +using System.IO; using UnityEditor; using UnityEngine; +using EasyMotionRecorder; /** [EasyMotionRecorder] @@ -15,20 +17,21 @@ This software is released under the MIT License. http://opensource.org/licenses/mit-license.php */ -namespace Entum -{ +#if UNITY_EDITOR +namespace Entum { /// /// Blendshapeの動きを記録するクラス /// リップシンクは後入れでTimeline上にAudioClipをつけて、みたいな可能性が高いので /// Exclusive(除外)するBlendshape名を登録できるようにしています。 /// [RequireComponent(typeof(MotionDataRecorder))] - public class FaceAnimationRecorder : MonoBehaviour - { - [Header("表情記録を同時に行う場合はtrueにします")] [SerializeField] + public class FaceAnimationRecorder:MonoBehaviour { + [Header("表情記録を同時に行う場合はtrueにします")] + [SerializeField] private bool _recordFaceBlendshapes = false; - [Header("リップシンクを記録したくない場合はここにモーフ名を入れていく 例:face_mouse_eなど")] [SerializeField] + [Header("リップシンクを記録したくない場合はここにモーフ名を入れていく 例:face_mouse_eなど")] + [SerializeField] private List _exclusiveBlendshapeNames; [Tooltip("記録するFPS。0で制限しない。UpdateのFPSは超えられません。")] @@ -52,28 +55,23 @@ namespace Entum private float _startTime; // Use this for initialization - private void OnEnable() - { + private void OnEnable() { _animRecorder = GetComponent(); _animRecorder.OnRecordStart += RecordStart; _animRecorder.OnRecordEnd += RecordEnd; - if (_animRecorder.CharacterAnimator != null) - { + if(_animRecorder.CharacterAnimator != null) { _smeshs = GetSkinnedMeshRenderers(_animRecorder.CharacterAnimator); } } - SkinnedMeshRenderer[] GetSkinnedMeshRenderers(Animator root) - { + SkinnedMeshRenderer[] GetSkinnedMeshRenderers(Animator root) { var helper = root; var renderers = helper.GetComponentsInChildren(); List smeshList = new List(); - for (int i = 0; i < renderers.Length; i++) - { + for(int i = 0; i < renderers.Length; i++) { var rend = renderers[i]; var cnt = rend.sharedMesh.blendShapeCount; - if (cnt > 0) - { + if(cnt > 0) { smeshList.Add(rend); } } @@ -81,15 +79,13 @@ namespace Entum return smeshList.ToArray(); } - private void OnDisable() - { - if (_recording) - { + private void OnDisable() { + if(_recording) { RecordEnd(); _recording = false; } - if (_animRecorder == null) return; + if(_animRecorder == null) return; _animRecorder.OnRecordStart -= RecordStart; _animRecorder.OnRecordEnd -= RecordEnd; } @@ -97,20 +93,16 @@ namespace Entum /// /// 記録開始 /// - private void RecordStart() - { - if (_recordFaceBlendshapes == false) - { + private void RecordStart() { + if(_recordFaceBlendshapes == false) { return; } - if (_recording) - { + if(_recording) { return; } - if (_smeshs.Length == 0) - { + if(_smeshs.Length == 0) { Debug.LogError("顔のメッシュ指定がされていないので顔のアニメーションは記録しません"); return; } @@ -126,23 +118,18 @@ namespace Entum /// /// 記録終了 /// - private void RecordEnd() - { - if (_recordFaceBlendshapes == false) - { + private void RecordEnd() { + if(_recordFaceBlendshapes == false) { return; } - if (_smeshs.Length == 0) - { + if(_smeshs.Length == 0) { Debug.LogError("顔のメッシュ指定がされていないので顔のアニメーションは記録しませんでした"); - if (_recording == true) - { + if(_recording == true) { Debug.LogAssertion("Unexpected execution!!!!"); } } - else - { + else { //WriteAnimationFileToScriptableObject(); ExportFacialAnimationClip(_animRecorder.CharacterAnimator, _facialData); } @@ -153,8 +140,7 @@ namespace Entum } - private void WriteAnimationFileToScriptableObject() - { + private void WriteAnimationFileToScriptableObject() { MotionDataRecorder.SafeCreateDirectory("Assets/Resources"); string path = AssetDatabase.GenerateUniqueAssetPath( @@ -162,12 +148,10 @@ namespace Entum DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss") + ".asset"); - if (_facialData == null) - { + if(_facialData == null) { Debug.LogError("記録されたFaceデータがnull"); } - else - { + else { AssetDatabase.CreateAsset(_facialData, path); AssetDatabase.Refresh(); } @@ -177,15 +161,12 @@ namespace Entum } //フレーム内の差分が無いかをチェックするやつ。 - private bool IsSame(CharacterFacialData.SerializeHumanoidFace a, CharacterFacialData.SerializeHumanoidFace b) - { - if (a == null || b == null || a.Smeshes.Count == 0 || b.Smeshes.Count == 0) - { + private bool IsSame(CharacterFacialData.SerializeHumanoidFace a, CharacterFacialData.SerializeHumanoidFace b) { + if(a == null || b == null || a.Smeshes.Count == 0 || b.Smeshes.Count == 0) { return false; } - if (a.BlendShapeNum() != b.BlendShapeNum()) - { + if(a.BlendShapeNum() != b.BlendShapeNum()) { return false; } @@ -193,65 +174,52 @@ namespace Entum t1.blendShapes.Where((t, j) => Mathf.Abs(t - b.Smeshes[i].blendShapes[j]) > 1).Any()).Any(); } - private void LateUpdate() - { - if (Input.GetKeyDown(KeyCode.Y)) - { + private void LateUpdate() { + if(Input.GetKeyDown(KeyCode.Y)) { ExportFacialAnimationClipTest(); } - if (!_recording) - { + if(!_recording) { return; } _recordedTime = Time.time - _startTime; - if (TargetFPS != 0.0f) - { + if(TargetFPS != 0.0f) { var nextTime = (1.0f * (_frameCount + 1)) / TargetFPS; - if (nextTime > _recordedTime) - { + if(nextTime > _recordedTime) { return; } - if (_frameCount % TargetFPS == 0) - { + if(_frameCount % TargetFPS == 0) { print("Face_FPS=" + 1 / (_recordedTime / _frameCount)); } } - else - { - if (Time.frameCount % Application.targetFrameRate == 0) - { + else { + if(Time.frameCount % Application.targetFrameRate == 0) { print("Face_FPS=" + 1 / Time.deltaTime); } } var p = new CharacterFacialData.SerializeHumanoidFace(); - for (int i = 0; i < _smeshs.Length; i++) - { + for(int i = 0; i < _smeshs.Length; i++) { var mesh = new CharacterFacialData.SerializeHumanoidFace.MeshAndBlendshape(); mesh.path = _smeshs[i].name; mesh.blendShapes = new float[_smeshs[i].sharedMesh.blendShapeCount]; - for (int j = 0; j < _smeshs[i].sharedMesh.blendShapeCount; j++) - { + for(int j = 0; j < _smeshs[i].sharedMesh.blendShapeCount; j++) { var tname = _smeshs[i].sharedMesh.GetBlendShapeName(j); var useThis = true; - foreach (var item in _exclusiveBlendshapeNames) - { - if (item.IndexOf(tname, StringComparison.Ordinal) >= 0) - { + foreach(var item in _exclusiveBlendshapeNames) { + if(item.IndexOf(tname, StringComparison.Ordinal) >= 0) { useThis = false; } } - if (useThis) - { + if(useThis) { mesh.blendShapes[j] = _smeshs[i].GetBlendShapeWeight(j); } } @@ -259,8 +227,7 @@ namespace Entum p.Smeshes.Add(mesh); } - if (!IsSame(p, _past)) - { + if(!IsSame(p, _past)) { p.FrameCount = _frameCount; p.Time = _recordedTime; @@ -277,18 +244,15 @@ namespace Entum /// /// /// - void ExportFacialAnimationClip(Animator root, CharacterFacialData facial) - { + void ExportFacialAnimationClip(Animator root, CharacterFacialData facial) { var animclip = new AnimationClip(); var mesh = _smeshs; - for (int faceTargetMeshIndex = 0; faceTargetMeshIndex < mesh.Length; faceTargetMeshIndex++) - { + for(int faceTargetMeshIndex = 0; faceTargetMeshIndex < mesh.Length; faceTargetMeshIndex++) { var pathsb = new StringBuilder().Append(mesh[faceTargetMeshIndex].transform.name); var trans = mesh[faceTargetMeshIndex].transform; - while (trans.parent != null && trans.parent != root.transform) - { + while(trans.parent != null && trans.parent != root.transform) { trans = trans.parent; pathsb.Insert(0, "/").Insert(0, trans.name); } @@ -298,10 +262,9 @@ namespace Entum var path = pathsb.ToString(); //個別メッシュの個別Blendshapeごとに、AnimationCurveを生成している - for (var blendShapeIndex = 0; + for(var blendShapeIndex = 0; blendShapeIndex < mesh[faceTargetMeshIndex].sharedMesh.blendShapeCount; - blendShapeIndex++) - { + blendShapeIndex++) { var curveBinding = new EditorCurveBinding(); curveBinding.type = typeof(SkinnedMeshRenderer); curveBinding.path = path; @@ -310,9 +273,8 @@ namespace Entum AnimationCurve curve = new AnimationCurve(); float pastBlendshapeWeight = -1; - for (int k = 0; k < _facialData.Facials.Count; k++) - { - if (!(Mathf.Abs(pastBlendshapeWeight - _facialData.Facials[k].Smeshes[faceTargetMeshIndex].blendShapes[blendShapeIndex]) > + for(int k = 0; k < _facialData.Facials.Count; k++) { + if(!(Mathf.Abs(pastBlendshapeWeight - _facialData.Facials[k].Smeshes[faceTargetMeshIndex].blendShapes[blendShapeIndex]) > 0.1f)) continue; curve.AddKey(new Keyframe(facial.Facials[k].Time, _facialData.Facials[k].Smeshes[faceTargetMeshIndex].blendShapes[blendShapeIndex], float.PositiveInfinity, 0f)); pastBlendshapeWeight = _facialData.Facials[k].Smeshes[faceTargetMeshIndex].blendShapes[blendShapeIndex]; @@ -323,12 +285,21 @@ namespace Entum } } - MotionDataRecorder.SafeCreateDirectory("Assets/Resources"); + // SavePathManager 사용 + string savePath = "Assets/Resources"; // 기본값 + string fileName = $"{_animRecorder.SessionID}_{_animRecorder.CharacterAnimator.name}_Facial.anim"; - var outputPath = "Assets/Resources/FaceRecordMotion_" + _animRecorder.CharacterAnimator.name + "_" + - DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss") + "_Clip.anim"; + // SavePathManager가 있으면 사용 + if(SavePathManager.Instance != null) { + savePath = SavePathManager.Instance.GetFacialSavePath(); + fileName = $"{_animRecorder.SessionID}_{_animRecorder.CharacterAnimator.name}_Facial.anim"; + } - Debug.Log("outputPath:" + outputPath); + MotionDataRecorder.SafeCreateDirectory(savePath); + + var outputPath = Path.Combine(savePath, fileName); + + Debug.Log($"페이스 애니메이션 파일 저장 경로: {outputPath}"); AssetDatabase.CreateAsset(animclip, AssetDatabase.GenerateUniqueAssetPath(outputPath)); AssetDatabase.SaveAssets(); @@ -340,26 +311,22 @@ namespace Entum /// /// /// - void ExportFacialAnimationClipTest() - { + void ExportFacialAnimationClipTest() { var animclip = new AnimationClip(); var mesh = _smeshs; - for (int i = 0; i < mesh.Length; i++) - { + for(int i = 0; i < mesh.Length; i++) { var pathsb = new StringBuilder().Append(mesh[i].transform.name); var trans = mesh[i].transform; - while (trans.parent != null && trans.parent != _animRecorder.CharacterAnimator.transform) - { + while(trans.parent != null && trans.parent != _animRecorder.CharacterAnimator.transform) { trans = trans.parent; pathsb.Insert(0, "/").Insert(0, trans.name); } var path = pathsb.ToString(); - for (var j = 0; j < mesh[i].sharedMesh.blendShapeCount; j++) - { + for(var j = 0; j < mesh[i].sharedMesh.blendShapeCount; j++) { var curveBinding = new EditorCurveBinding(); curveBinding.type = typeof(SkinnedMeshRenderer); curveBinding.path = path; @@ -386,3 +353,4 @@ namespace Entum } } } +#endif \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/HumanoidPoses.cs b/Assets/External/EasyMotionRecorder/Scripts/HumanoidPoses.cs index 5003bf448..8cf5c6f2c 100644 --- a/Assets/External/EasyMotionRecorder/Scripts/HumanoidPoses.cs +++ b/Assets/External/EasyMotionRecorder/Scripts/HumanoidPoses.cs @@ -11,8 +11,11 @@ using UnityEngine; using System; using System.Text; using System.Collections.Generic; +using System.Linq; +using System.IO; #if UNITY_EDITOR using UnityEditor; +using EasyMotionRecorder; #endif namespace Entum @@ -77,8 +80,68 @@ namespace Entum /// /// モーションデータの中身 /// + [System.Serializable] public class HumanoidPoses : ScriptableObject { + [SerializeField] + public string AvatarName = ""; // 아바타 이름 저장 + + [SerializeField, Tooltip("T-포즈 데이터 (별도 저장)")] + public SerializeHumanoidPose TPoseData = null; + + [SerializeField, Tooltip("T-포즈가 저장되었는지 여부")] + public bool HasTPoseData = false; + + // 세션 ID를 가져오는 메서드 (MotionDataRecorder와 동일한 세션 ID 사용) + private string GetSessionID() + { + // 1. MotionDataRecorder에서 세션 ID를 가져오려고 시도 + var motionRecorder = FindObjectOfType(); + if (motionRecorder != null && !string.IsNullOrEmpty(motionRecorder.SessionID)) + { + Debug.Log($"MotionDataRecorder에서 세션 ID 가져옴: {motionRecorder.SessionID}"); + return motionRecorder.SessionID; + } + + // 2. 스크립터블 오브젝트의 이름에서 세션 ID 추출 시도 + if (!string.IsNullOrEmpty(this.name)) + { + // 파일명에서 세션 ID 패턴 찾기 (예: 250717_192404_SeNo_Motion) + var nameParts = this.name.Split('_'); + if (nameParts.Length >= 2) + { + // 첫 번째 두 부분이 날짜와 시간인지 확인 + if (nameParts[0].Length == 6 && nameParts[1].Length == 6) // yyMMdd_HHmmss 형식 + { + string sessionID = $"{nameParts[0]}_{nameParts[1]}"; + Debug.Log($"스크립터블 오브젝트 이름에서 세션 ID 추출: {sessionID}"); + return sessionID; + } + } + } + + // 3. 현재 에셋 파일 경로에서 세션 ID 추출 시도 + string assetPath = AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(assetPath)) + { + string fileName = Path.GetFileNameWithoutExtension(assetPath); + var nameParts = fileName.Split('_'); + if (nameParts.Length >= 2) + { + if (nameParts[0].Length == 6 && nameParts[1].Length == 6) // yyMMdd_HHmmss 형식 + { + string sessionID = $"{nameParts[0]}_{nameParts[1]}"; + Debug.Log($"에셋 파일명에서 세션 ID 추출: {sessionID}"); + return sessionID; + } + } + } + + // 4. 마지막 수단으로 현재 시간 사용 (경고 메시지와 함께) + string fallbackSessionID = DateTime.Now.ToString("yyMMdd_HHmmss"); + Debug.LogWarning($"세션 ID를 찾을 수 없어 현재 시간 사용: {fallbackSessionID}"); + return fallbackSessionID; + } #if UNITY_EDITOR //Genericなanimファイルとして出力する [ContextMenu("Export as Generic animation clips")] @@ -87,9 +150,43 @@ namespace Entum var clip = new AnimationClip { frameRate = 30 }; AnimationUtility.SetAnimationClipSettings(clip, new AnimationClipSettings { loopTime = false }); + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("ExportGenericAnim: 본 데이터가 없습니다. Poses.Count=" + Poses.Count + + (Poses.Count > 0 ? ", HumanoidBones.Count=" + Poses[0].HumanoidBones.Count : "")); + return; + } + + Debug.Log($"ExportGenericAnim: 총 포즈 수 = {Poses.Count}"); + Debug.Log($"첫 번째 포즈: 시간={Poses[0].Time}, 프레임={Poses[0].FrameCount}"); + if (Poses.Count > 1) + { + Debug.Log($"마지막 포즈: 시간={Poses[Poses.Count-1].Time}, 프레임={Poses[Poses.Count-1].FrameCount}"); + } + + // T-포즈 데이터 확인 + if (HasTPoseData && TPoseData != null) + { + Debug.Log($"T-포즈 데이터 발견: 시간={TPoseData.Time}, 프레임={TPoseData.FrameCount}"); + } + var bones = Poses[0].HumanoidBones; + for (int i = 0; i < bones.Count; i++) { + var bone = bones[i]; + + // 경로가 비어있는지 확인 + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogError($"본 {i}: 이름이 비어있습니다!"); + continue; + } + + // 경로 정리: 끝의 슬래시만 제거 + string cleanPath = bone.Name.TrimEnd('/'); + var positionCurveX = new AnimationCurve(); var positionCurveY = new AnimationCurve(); var positionCurveZ = new AnimationCurve(); @@ -98,37 +195,63 @@ namespace Entum var rotationCurveZ = new AnimationCurve(); var rotationCurveW = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null && TPoseData.HumanoidBones.Count > i) + { + var tPoseBone = TPoseData.HumanoidBones[i]; + positionCurveX.AddKey(TPoseData.Time, tPoseBone.LocalPosition.x); + positionCurveY.AddKey(TPoseData.Time, tPoseBone.LocalPosition.y); + positionCurveZ.AddKey(TPoseData.Time, tPoseBone.LocalPosition.z); + rotationCurveX.AddKey(TPoseData.Time, tPoseBone.LocalRotation.x); + rotationCurveY.AddKey(TPoseData.Time, tPoseBone.LocalRotation.y); + rotationCurveZ.AddKey(TPoseData.Time, tPoseBone.LocalRotation.z); + rotationCurveW.AddKey(TPoseData.Time, tPoseBone.LocalRotation.w); + processedPoses++; + Debug.Log($"T-포즈 키프레임 추가: {cleanPath} (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var p in Poses) { - positionCurveX.AddKey(p.Time, p.HumanoidBones[i].LocalPosition.x); - positionCurveY.AddKey(p.Time, p.HumanoidBones[i].LocalPosition.y); - positionCurveZ.AddKey(p.Time, p.HumanoidBones[i].LocalPosition.z); - rotationCurveX.AddKey(p.Time, p.HumanoidBones[i].LocalRotation.x); - rotationCurveY.AddKey(p.Time, p.HumanoidBones[i].LocalRotation.y); - rotationCurveZ.AddKey(p.Time, p.HumanoidBones[i].LocalRotation.z); - rotationCurveW.AddKey(p.Time, p.HumanoidBones[i].LocalRotation.w); + if (p.HumanoidBones.Count > i) + { + var poseBone = p.HumanoidBones[i]; + positionCurveX.AddKey(p.Time, poseBone.LocalPosition.x); + positionCurveY.AddKey(p.Time, poseBone.LocalPosition.y); + positionCurveZ.AddKey(p.Time, poseBone.LocalPosition.z); + rotationCurveX.AddKey(p.Time, poseBone.LocalRotation.x); + rotationCurveY.AddKey(p.Time, poseBone.LocalRotation.y); + rotationCurveZ.AddKey(p.Time, poseBone.LocalRotation.z); + rotationCurveW.AddKey(p.Time, poseBone.LocalRotation.w); + processedPoses++; + } } - //pathは階層 + Debug.Log($"본 '{cleanPath}': {processedPoses}개 포즈 처리됨 (T-포즈 포함)"); + + //path는 계층 //http://mebiustos.hatenablog.com/entry/2015/09/16/230000 + var binding = new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.x" + }; + + AnimationUtility.SetEditorCurve(clip, binding, positionCurveX); AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, - type = typeof(Transform), - propertyName = "m_LocalPosition.x" - }, positionCurveX); - AnimationUtility.SetEditorCurve(clip, - new EditorCurveBinding - { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalPosition.y" }, positionCurveY); AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalPosition.z" }, positionCurveZ); @@ -136,28 +259,28 @@ namespace Entum AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalRotation.x" }, rotationCurveX); AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalRotation.y" }, rotationCurveY); AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalRotation.z" }, rotationCurveZ); AnimationUtility.SetEditorCurve(clip, new EditorCurveBinding { - path = Poses[0].HumanoidBones[i].Name, + path = cleanPath, type = typeof(Transform), propertyName = "m_LocalRotation.w" }, rotationCurveW); @@ -165,33 +288,122 @@ namespace Entum clip.EnsureQuaternionContinuity(); - var path = string.Format("Assets/Resources/RecordMotion_{0:yyyy_MM_dd_HH_mm_ss}_Generic.anim", DateTime.Now); + // 세션 ID 사용 (MotionDataRecorder와 동일한 세션 ID 사용) + string sessionID = GetSessionID(); + + // 아바타 이름이 있으면 포함, 없으면 기본값 사용 + string avatarName = !string.IsNullOrEmpty(AvatarName) ? AvatarName : "Unknown"; + + // 에셋 파일의 경로를 기반으로 저장 경로 결정 + string savePath = "Assets/Resources"; // 기본값 + string fileName = $"{sessionID}_{avatarName}_Generic.anim"; + + // 현재 에셋 파일의 경로 가져오기 + string assetPath = AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(assetPath)) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory)) + { + savePath = directory; + } + } + + MotionDataRecorder.SafeCreateDirectory(savePath); + + var path = Path.Combine(savePath, fileName); var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); AssetDatabase.CreateAsset(clip, uniqueAssetPath); AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log($"제네릭 애니메이션 파일이 저장되었습니다: {uniqueAssetPath}"); + Debug.Log($"클립 길이: {clip.length}초, 총 키프레임 수: {clip.frameRate * clip.length}"); } //Humanoidなanimファイルとして出力する。 [ContextMenu("Export as Humanoid animation clips")] public void ExportHumanoidAnim() { - var clip = new AnimationClip { frameRate = 30 }; - AnimationUtility.SetAnimationClipSettings(clip, new AnimationClipSettings { loopTime = false }); + // 데이터 검증 + if (Poses == null || Poses.Count == 0) + { + Debug.LogError("ExportHumanoidAnim: Poses 데이터가 없습니다. Poses.Count=" + (Poses?.Count ?? 0)); + return; + } + Debug.Log($"ExportHumanoidAnim: 총 포즈 수 = {Poses.Count}"); + Debug.Log($"첫 번째 포즈: 시간={Poses[0].Time}, 프레임={Poses[0].FrameCount}"); + if (Poses.Count > 1) + { + Debug.Log($"마지막 포즈: 시간={Poses[Poses.Count-1].Time}, 프레임={Poses[Poses.Count-1].FrameCount}"); + } + + // T-포즈 데이터 확인 + if (HasTPoseData && TPoseData != null) + { + Debug.Log($"T-포즈 데이터 발견: 시간={TPoseData.Time}, 프레임={TPoseData.FrameCount}"); + } + + // 첫 번째 포즈 데이터 검증 + var firstPose = Poses[0]; + Debug.Log($"첫 번째 포즈: BodyPosition={firstPose.BodyPosition}, Muscles.Length={firstPose.Muscles?.Length ?? 0}"); + + if (firstPose.Muscles == null || firstPose.Muscles.Length == 0) + { + Debug.LogError("ExportHumanoidAnim: Muscles 데이터가 없습니다."); + return; + } + + var clip = new AnimationClip { frameRate = 30 }; + + // 시작할 때 설정을 적용 (커브 데이터 추가 전) + var settings = new AnimationClipSettings + { + loopTime = false, // Loop Time: false + cycleOffset = 0, // Cycle Offset: 0 + loopBlend = false, // Loop Blend: false + loopBlendOrientation = true, // Root Transform Rotation - Bake Into Pose: true + loopBlendPositionY = true, // Root Transform Position (Y) - Bake Into Pose: true + loopBlendPositionXZ = true, // Root Transform Position (XZ) - Bake Into Pose: true + keepOriginalOrientation = true, // Root Transform Rotation - Based Upon: Original + keepOriginalPositionY = true, // Root Transform Position (Y) - Based Upon: Original + keepOriginalPositionXZ = true, // Root Transform Position (XZ) - Based Upon: Original + heightFromFeet = false, // Height From Feet: false + mirror = false // Mirror: false + }; + + AnimationUtility.SetAnimationClipSettings(clip, settings); // body position { var curveX = new AnimationCurve(); var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.BodyPosition.x); + curveY.AddKey(TPoseData.Time, TPoseData.BodyPosition.y); + curveZ.AddKey(TPoseData.Time, TPoseData.BodyPosition.z); + processedPoses++; + Debug.Log($"T-포즈 Body Position 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.BodyPosition.x); curveY.AddKey(item.Time, item.BodyPosition.y); curveZ.AddKey(item.Time, item.BodyPosition.z); + processedPoses++; } + Debug.Log($"Body Position 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "RootT.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "RootT.y"; @@ -204,13 +416,29 @@ namespace Entum var curveX = new AnimationCurve(); var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Pos.x); + curveY.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Pos.y); + curveZ.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Pos.z); + processedPoses++; + Debug.Log($"T-포즈 Leftfoot Position 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.LeftfootIK_Pos.x); curveY.AddKey(item.Time, item.LeftfootIK_Pos.y); curveZ.AddKey(item.Time, item.LeftfootIK_Pos.z); + processedPoses++; } + Debug.Log($"Leftfoot Position 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "LeftFootT.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "LeftFootT.y"; @@ -223,13 +451,29 @@ namespace Entum var curveX = new AnimationCurve(); var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.RightfootIK_Pos.x); + curveY.AddKey(TPoseData.Time, TPoseData.RightfootIK_Pos.y); + curveZ.AddKey(TPoseData.Time, TPoseData.RightfootIK_Pos.z); + processedPoses++; + Debug.Log($"T-포즈 Rightfoot Position 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.RightfootIK_Pos.x); curveY.AddKey(item.Time, item.RightfootIK_Pos.y); curveZ.AddKey(item.Time, item.RightfootIK_Pos.z); + processedPoses++; } + Debug.Log($"Rightfoot Position 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "RightFootT.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "RightFootT.y"; @@ -243,14 +487,31 @@ namespace Entum var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); var curveW = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.BodyRotation.x); + curveY.AddKey(TPoseData.Time, TPoseData.BodyRotation.y); + curveZ.AddKey(TPoseData.Time, TPoseData.BodyRotation.z); + curveW.AddKey(TPoseData.Time, TPoseData.BodyRotation.w); + processedPoses++; + Debug.Log($"T-포즈 Body Rotation 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.BodyRotation.x); curveY.AddKey(item.Time, item.BodyRotation.y); curveZ.AddKey(item.Time, item.BodyRotation.z); curveW.AddKey(item.Time, item.BodyRotation.w); + processedPoses++; } + Debug.Log($"Body Rotation 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length}, W={curveW.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "RootQ.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "RootQ.y"; @@ -266,14 +527,31 @@ namespace Entum var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); var curveW = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Rot.x); + curveY.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Rot.y); + curveZ.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Rot.z); + curveW.AddKey(TPoseData.Time, TPoseData.LeftfootIK_Rot.w); + processedPoses++; + Debug.Log($"T-포즈 Leftfoot Rotation 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.LeftfootIK_Rot.x); curveY.AddKey(item.Time, item.LeftfootIK_Rot.y); curveZ.AddKey(item.Time, item.LeftfootIK_Rot.z); curveW.AddKey(item.Time, item.LeftfootIK_Rot.w); + processedPoses++; } + Debug.Log($"Leftfoot Rotation 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length}, W={curveW.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "LeftFootQ.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "LeftFootQ.y"; @@ -289,14 +567,31 @@ namespace Entum var curveY = new AnimationCurve(); var curveZ = new AnimationCurve(); var curveW = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null) + { + curveX.AddKey(TPoseData.Time, TPoseData.RightfootIK_Rot.x); + curveY.AddKey(TPoseData.Time, TPoseData.RightfootIK_Rot.y); + curveZ.AddKey(TPoseData.Time, TPoseData.RightfootIK_Rot.z); + curveW.AddKey(TPoseData.Time, TPoseData.RightfootIK_Rot.w); + processedPoses++; + Debug.Log($"T-포즈 Rightfoot Rotation 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curveX.AddKey(item.Time, item.RightfootIK_Rot.x); curveY.AddKey(item.Time, item.RightfootIK_Rot.y); curveZ.AddKey(item.Time, item.RightfootIK_Rot.z); curveW.AddKey(item.Time, item.RightfootIK_Rot.w); + processedPoses++; } + Debug.Log($"Rightfoot Rotation 커브: 키 개수 X={curveX.length}, Y={curveY.length}, Z={curveZ.length}, W={curveW.length} (처리된 포즈: {processedPoses}개)"); + const string muscleX = "RightFootQ.x"; clip.SetCurve("", typeof(Animator), muscleX, curveX); const string muscleY = "RightFootQ.y"; @@ -311,11 +606,25 @@ namespace Entum for (int i = 0; i < HumanTrait.MuscleCount; i++) { var curve = new AnimationCurve(); + int processedPoses = 0; + + // T-포즈가 있으면 0프레임에 먼저 추가 + if (HasTPoseData && TPoseData != null && TPoseData.Muscles != null && TPoseData.Muscles.Length > i) + { + curve.AddKey(TPoseData.Time, TPoseData.Muscles[i]); + processedPoses++; + Debug.Log($"T-포즈 Muscle {i} 키프레임 추가 (시간: {TPoseData.Time})"); + } + + // 실제 녹화된 포즈들 추가 foreach (var item in Poses) { curve.AddKey(item.Time, item.Muscles[i]); + processedPoses++; } + Debug.Log($"Muscle {i} 커브: 키 개수 {curve.length} (처리된 포즈: {processedPoses}개)"); + var muscle = HumanTrait.MuscleName[i]; if (MotionDataSettings.TraitPropMap.ContainsKey(muscle)) { @@ -327,11 +636,1587 @@ namespace Entum clip.EnsureQuaternionContinuity(); - var path = string.Format("Assets/Resources/RecordMotion_{0:yyyy_MM_dd_HH_mm_ss}_Humanoid.anim", DateTime.Now); + // 세션 ID 사용 (MotionDataRecorder와 동일한 세션 ID 사용) + string sessionID = GetSessionID(); + + // 아바타 이름이 있으면 포함, 없으면 기본값 사용 + string avatarName = !string.IsNullOrEmpty(AvatarName) ? AvatarName : "Unknown"; + + // 에셋 파일의 경로를 기반으로 저장 경로 결정 + string savePath = "Assets/Resources"; // 기본값 + string fileName = $"{sessionID}_{avatarName}_Humanoid.anim"; + + // 현재 에셋 파일의 경로 가져오기 + string assetPath = AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(assetPath)) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory)) + { + savePath = directory; + } + } + + MotionDataRecorder.SafeCreateDirectory(savePath); + + var path = Path.Combine(savePath, fileName); var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); AssetDatabase.CreateAsset(clip, uniqueAssetPath); AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log($"휴머노이드 애니메이션 파일이 저장되었습니다: {uniqueAssetPath}"); + } + + // ASCII 형식으로 FBX 내보내기 + [ContextMenu("Export as FBX animation (ASCII)")] + public void ExportFBXAscii() + { + ExportFBXWithEncoding(true); + } + + // 바이너리 형식으로 FBX 내보내기 + [ContextMenu("Export as FBX animation (Binary)")] + public void ExportFBXBinary() + { + ExportFBXWithEncoding(false); + } + + // Biped 형식으로 FBX 내보내기 (ASCII) + [ContextMenu("Export as Biped FBX (ASCII)")] + public void ExportBipedFBXAscii() + { + ExportBipedFBXWithEncoding(true); + } + + // Biped 형식으로 FBX 내보내기 (Binary) + [ContextMenu("Export as Biped FBX (Binary)")] + public void ExportBipedFBXBinary() + { + ExportBipedFBXWithEncoding(false); + } + + // FBX 내보내기 (인코딩 설정 포함) + private void ExportFBXWithEncoding(bool useAscii) + { + // 데이터 검증 + if (Poses == null || Poses.Count == 0) + { + Debug.LogError("ExportFBX: Poses 데이터가 없습니다. Poses.Count=" + (Poses?.Count ?? 0)); + return; + } + + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("ExportFBX: 본 데이터가 없습니다."); + return; + } + + // 세션 ID 사용 + string sessionID = GetSessionID(); + string avatarName = !string.IsNullOrEmpty(AvatarName) ? AvatarName : "Unknown"; + + // 저장 경로 결정 + string savePath = "Assets/Resources"; + string fileName = $"{sessionID}_{avatarName}_Motion.fbx"; + + // 현재 에셋 파일의 경로 가져오기 + string assetPath = AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(assetPath)) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory)) + { + savePath = directory; + } + } + + MotionDataRecorder.SafeCreateDirectory(savePath); + + var path = Path.Combine(savePath, fileName); + var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); + + // 스켈레톤 생성 후 FBX 내보내기 (인코딩 설정 포함) + ExportSkeletonWithAnimationToFBX(uniqueAssetPath, useAscii); + + Debug.Log($"FBX 애니메이션 파일이 저장되었습니다: {uniqueAssetPath}"); + } + + // Biped FBX 내보내기 (인코딩 설정 포함) + private void ExportBipedFBXWithEncoding(bool useAscii) + { + // 데이터 검증 + if (Poses == null || Poses.Count == 0) + { + Debug.LogError("ExportBipedFBX: Poses 데이터가 없습니다. Poses.Count=" + (Poses?.Count ?? 0)); + return; + } + + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("ExportBipedFBX: 본 데이터가 없습니다."); + return; + } + + // 세션 ID 사용 + string sessionID = GetSessionID(); + string avatarName = !string.IsNullOrEmpty(AvatarName) ? AvatarName : "Unknown"; + + // 저장 경로 결정 + string savePath = "Assets/Resources"; + string fileName = $"{sessionID}_{avatarName}_Biped.fbx"; + + // 현재 에셋 파일의 경로 가져오기 + string assetPath = AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(assetPath)) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory)) + { + savePath = directory; + } + } + + MotionDataRecorder.SafeCreateDirectory(savePath); + + var path = Path.Combine(savePath, fileName); + var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); + + // Biped 스켈레톤 생성 후 FBX 내보내기 (인코딩 설정 포함) + ExportBipedSkeletonWithAnimationToFBX(uniqueAssetPath, useAscii); + + Debug.Log($"Biped FBX 애니메이션 파일이 저장되었습니다: {uniqueAssetPath}"); + } + + // 제네릭 애니메이션 클립 생성 (내부 메서드) + private AnimationClip CreateGenericAnimationClip() + { + var clip = new AnimationClip { frameRate = 30 }; + + // 클립 이름 설정 (중요!) + string sessionID = GetSessionID(); + clip.name = $"{sessionID}_Motion"; + + AnimationUtility.SetAnimationClipSettings(clip, new AnimationClipSettings { loopTime = false }); + + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("CreateGenericAnimationClip: 본 데이터가 없습니다. Poses.Count=" + Poses.Count + + (Poses.Count > 0 ? ", HumanoidBones.Count=" + Poses[0].HumanoidBones.Count : "")); + return null; + } + + var bones = Poses[0].HumanoidBones; + + for (int i = 0; i < bones.Count; i++) + { + var bone = bones[i]; + + // 경로가 비어있는지 확인 + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogError($"본 {i}: 이름이 비어있습니다!"); + continue; + } + + // 경로 정리: 끝의 슬래시만 제거 + string cleanPath = bone.Name.TrimEnd('/'); + + var positionCurveX = new AnimationCurve(); + var positionCurveY = new AnimationCurve(); + var positionCurveZ = new AnimationCurve(); + var rotationCurveX = new AnimationCurve(); + var rotationCurveY = new AnimationCurve(); + var rotationCurveZ = new AnimationCurve(); + var rotationCurveW = new AnimationCurve(); + + // T-포즈를 0프레임에 추가 + if (HasTPoseData && TPoseData != null && TPoseData.HumanoidBones.Count > i) + { + var tPoseBone = TPoseData.HumanoidBones[i]; + positionCurveX.AddKey(0f, tPoseBone.LocalPosition.x); + positionCurveY.AddKey(0f, tPoseBone.LocalPosition.y); + positionCurveZ.AddKey(0f, tPoseBone.LocalPosition.z); + rotationCurveX.AddKey(0f, tPoseBone.LocalRotation.x); + rotationCurveY.AddKey(0f, tPoseBone.LocalRotation.y); + rotationCurveZ.AddKey(0f, tPoseBone.LocalRotation.z); + rotationCurveW.AddKey(0f, tPoseBone.LocalRotation.w); + } + + foreach (var p in Poses) + { + if (p.HumanoidBones.Count > i) + { + var poseBone = p.HumanoidBones[i]; + positionCurveX.AddKey(p.Time, poseBone.LocalPosition.x); + positionCurveY.AddKey(p.Time, poseBone.LocalPosition.y); + positionCurveZ.AddKey(p.Time, poseBone.LocalPosition.z); + rotationCurveX.AddKey(p.Time, poseBone.LocalRotation.x); + rotationCurveY.AddKey(p.Time, poseBone.LocalRotation.y); + rotationCurveZ.AddKey(p.Time, poseBone.LocalRotation.z); + rotationCurveW.AddKey(p.Time, poseBone.LocalRotation.w); + } + } + + //path는 계층 + //http://mebiustos.hatenablog.com/entry/2015/09/16/230000 + var binding = new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.x" + }; + + AnimationUtility.SetEditorCurve(clip, binding, positionCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.y" + }, positionCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.z" + }, positionCurveZ); + + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.x" + }, rotationCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.y" + }, rotationCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.z" + }, rotationCurveZ); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.w" + }, rotationCurveW); + } + + clip.EnsureQuaternionContinuity(); + return clip; + } + + // 애니메이션 클립 생성 (내부 메서드) + private AnimationClip CreateAnimationClip() + { + var clip = new AnimationClip { frameRate = 30 }; + + // 클립 이름 설정 (중요!) + string sessionID = GetSessionID(); + clip.name = $"{sessionID}_Motion"; + + // 애니메이션 설정 + var settings = new AnimationClipSettings + { + loopTime = false, + cycleOffset = 0, + loopBlend = false, + loopBlendOrientation = true, + loopBlendPositionY = true, + loopBlendPositionXZ = true, + keepOriginalOrientation = true, + keepOriginalPositionY = true, + keepOriginalPositionXZ = true, + heightFromFeet = false, + mirror = false + }; + + AnimationUtility.SetAnimationClipSettings(clip, settings); + + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("CreateAnimationClip: 본 데이터가 없습니다."); + return null; + } + + var bones = Poses[0].HumanoidBones; + + // 각 본에 대한 애니메이션 커브 생성 + for (int i = 0; i < bones.Count; i++) + { + var bone = bones[i]; + + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogError($"본 {i}: 이름이 비어있습니다!"); + continue; + } + + string cleanPath = bone.Name.TrimEnd('/'); + + var positionCurveX = new AnimationCurve(); + var positionCurveY = new AnimationCurve(); + var positionCurveZ = new AnimationCurve(); + var rotationCurveX = new AnimationCurve(); + var rotationCurveY = new AnimationCurve(); + var rotationCurveZ = new AnimationCurve(); + var rotationCurveW = new AnimationCurve(); + + // T-포즈를 0프레임에 추가 + if (HasTPoseData && TPoseData != null && TPoseData.HumanoidBones.Count > i) + { + var tPoseBone = TPoseData.HumanoidBones[i]; + positionCurveX.AddKey(0f, tPoseBone.LocalPosition.x); + positionCurveY.AddKey(0f, tPoseBone.LocalPosition.y); + positionCurveZ.AddKey(0f, tPoseBone.LocalPosition.z); + rotationCurveX.AddKey(0f, tPoseBone.LocalRotation.x); + rotationCurveY.AddKey(0f, tPoseBone.LocalRotation.y); + rotationCurveZ.AddKey(0f, tPoseBone.LocalRotation.z); + rotationCurveW.AddKey(0f, tPoseBone.LocalRotation.w); + } + + foreach (var p in Poses) + { + if (p.HumanoidBones.Count > i) + { + var poseBone = p.HumanoidBones[i]; + positionCurveX.AddKey(p.Time, poseBone.LocalPosition.x); + positionCurveY.AddKey(p.Time, poseBone.LocalPosition.y); + positionCurveZ.AddKey(p.Time, poseBone.LocalPosition.z); + rotationCurveX.AddKey(p.Time, poseBone.LocalRotation.x); + rotationCurveY.AddKey(p.Time, poseBone.LocalRotation.y); + rotationCurveZ.AddKey(p.Time, poseBone.LocalRotation.z); + rotationCurveW.AddKey(p.Time, poseBone.LocalRotation.w); + } + } + + // 위치 커브 설정 + var positionBinding = new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.x" + }; + + AnimationUtility.SetEditorCurve(clip, positionBinding, positionCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.y" + }, positionCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.z" + }, positionCurveZ); + + // 회전 커브 설정 + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.x" + }, rotationCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.y" + }, rotationCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.z" + }, rotationCurveZ); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.w" + }, rotationCurveW); + } + + clip.EnsureQuaternionContinuity(); + return clip; + } + + // Biped 애니메이션 클립 생성 (내부 메서드) + private AnimationClip CreateBipedAnimationClip() + { + var clip = new AnimationClip { frameRate = 30 }; + + // 클립 이름 설정 (중요!) + string sessionID = GetSessionID(); + clip.name = $"{sessionID}_Biped"; + + // 애니메이션 설정 + var settings = new AnimationClipSettings + { + loopTime = false, + cycleOffset = 0, + loopBlend = false, + loopBlendOrientation = true, + loopBlendPositionY = true, + loopBlendPositionXZ = true, + keepOriginalOrientation = true, + keepOriginalPositionY = true, + keepOriginalPositionXZ = true, + heightFromFeet = false, + mirror = false + }; + + AnimationUtility.SetAnimationClipSettings(clip, settings); + + // 본 데이터가 있는지 확인 + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("CreateBipedAnimationClip: 본 데이터가 없습니다."); + return null; + } + + var bones = Poses[0].HumanoidBones; + + // Bip001 Pelvis의 기본 회전 오프셋 (0, -90, -90) + Quaternion pelvisOffset = Quaternion.Euler(-90, 90, 0); + + // Bip001(루트)용 커브 + var bipRootPosX = new AnimationCurve(); + var bipRootPosY = new AnimationCurve(); + var bipRootPosZ = new AnimationCurve(); + var bipRootRotX = new AnimationCurve(); + var bipRootRotY = new AnimationCurve(); + var bipRootRotZ = new AnimationCurve(); + var bipRootRotW = new AnimationCurve(); + + for (int i = 0; i < bones.Count; i++) + { + var bone = bones[i]; + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogError($"본 {i}: 이름이 비어있습니다!"); + continue; + } + + // Bip001 Pelvis의 데이터를 Bip001 경로에 키로 추가 + if (i == 0 && bone.Name.Contains("Pelvis")) + { + // T-포즈를 0프레임에 추가 + if (HasTPoseData && TPoseData != null && TPoseData.HumanoidBones.Count > i) + { + var tPoseBone = TPoseData.HumanoidBones[i]; + bipRootPosX.AddKey(0f, tPoseBone.LocalPosition.x); + bipRootPosY.AddKey(0f, tPoseBone.LocalPosition.y); + bipRootPosZ.AddKey(0f, tPoseBone.LocalPosition.z); + + // T-포즈의 회전 값에 pelvisOffset을 곱해서 적용 + var tPoseRotation = tPoseBone.LocalRotation * pelvisOffset; + bipRootRotX.AddKey(0f, tPoseRotation.x); + bipRootRotY.AddKey(0f, tPoseRotation.y); + bipRootRotZ.AddKey(0f, tPoseRotation.z); + bipRootRotW.AddKey(0f, tPoseRotation.w); + } + + foreach (var p in Poses) + { + if (p.HumanoidBones.Count > i) + { + var poseBone = p.HumanoidBones[i]; + bipRootPosX.AddKey(p.Time, poseBone.LocalPosition.x); + bipRootPosY.AddKey(p.Time, poseBone.LocalPosition.y); + bipRootPosZ.AddKey(p.Time, poseBone.LocalPosition.z); + + // 실제 애니메이션 데이터의 회전 값을 사용하되, pelvisOffset을 곱해서 적용 + var actualRotation = poseBone.LocalRotation * pelvisOffset; + bipRootRotX.AddKey(p.Time, actualRotation.x); + bipRootRotY.AddKey(p.Time, actualRotation.y); + bipRootRotZ.AddKey(p.Time, actualRotation.z); + bipRootRotW.AddKey(p.Time, actualRotation.w); + } + } + // Bip001 경로에 커브 추가 + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalPosition.x"), bipRootPosX); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalPosition.y"), bipRootPosY); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalPosition.z"), bipRootPosZ); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalRotation.x"), bipRootRotX); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalRotation.y"), bipRootRotY); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalRotation.z"), bipRootRotZ); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Bip001", typeof(Transform), "m_LocalRotation.w"), bipRootRotW); + continue; // Pelvis 본에는 키를 넣지 않음 + } + + // 나머지 본은 기존 방식대로 + string cleanPath = bone.Name.TrimEnd('/'); + var positionCurveX = new AnimationCurve(); + var positionCurveY = new AnimationCurve(); + var positionCurveZ = new AnimationCurve(); + var rotationCurveX = new AnimationCurve(); + var rotationCurveY = new AnimationCurve(); + var rotationCurveZ = new AnimationCurve(); + var rotationCurveW = new AnimationCurve(); + + // T-포즈를 0프레임에 추가 + if (HasTPoseData && TPoseData != null && TPoseData.HumanoidBones.Count > i) + { + var tPoseBone = TPoseData.HumanoidBones[i]; + positionCurveX.AddKey(0f, tPoseBone.LocalPosition.x); + positionCurveY.AddKey(0f, tPoseBone.LocalPosition.y); + positionCurveZ.AddKey(0f, tPoseBone.LocalPosition.z); + rotationCurveX.AddKey(0f, tPoseBone.LocalRotation.x); + rotationCurveY.AddKey(0f, tPoseBone.LocalRotation.y); + rotationCurveZ.AddKey(0f, tPoseBone.LocalRotation.z); + rotationCurveW.AddKey(0f, tPoseBone.LocalRotation.w); + } + + foreach (var p in Poses) + { + if (p.HumanoidBones.Count > i) + { + var poseBone = p.HumanoidBones[i]; + positionCurveX.AddKey(p.Time, poseBone.LocalPosition.x); + positionCurveY.AddKey(p.Time, poseBone.LocalPosition.y); + positionCurveZ.AddKey(p.Time, poseBone.LocalPosition.z); + rotationCurveX.AddKey(p.Time, poseBone.LocalRotation.x); + rotationCurveY.AddKey(p.Time, poseBone.LocalRotation.y); + rotationCurveZ.AddKey(p.Time, poseBone.LocalRotation.z); + rotationCurveW.AddKey(p.Time, poseBone.LocalRotation.w); + } + } + + // 위치 커브 설정 + var positionBinding = new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.x" + }; + + AnimationUtility.SetEditorCurve(clip, positionBinding, positionCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.y" + }, positionCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalPosition.z" + }, positionCurveZ); + + // 회전 커브 설정 + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.x" + }, rotationCurveX); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.y" + }, rotationCurveY); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.z" + }, rotationCurveZ); + AnimationUtility.SetEditorCurve(clip, + new EditorCurveBinding + { + path = cleanPath, + type = typeof(Transform), + propertyName = "m_LocalRotation.w" + }, rotationCurveW); + } + + clip.EnsureQuaternionContinuity(); + return clip; + } + + // 스켈레톤 생성 후 FBX 내보내기 메서드 + private void ExportSkeletonWithAnimationToFBX(string fbxPath, bool useAscii = true) + { + // EditorApplication.delayCall을 사용하여 다음 프레임에서 실행 + EditorApplication.delayCall += () => ExportSkeletonWithAnimationToFBXStepByStep(fbxPath, useAscii); + } + + // Biped 스켈레톤 생성 후 FBX 내보내기 메서드 + private void ExportBipedSkeletonWithAnimationToFBX(string fbxPath, bool useAscii = true) + { + // EditorApplication.delayCall을 사용하여 다음 프레임에서 실행 + EditorApplication.delayCall += () => ExportBipedSkeletonWithAnimationToFBXStepByStep(fbxPath, useAscii); + } + + private void ExportSkeletonWithAnimationToFBXStepByStep(string fbxPath, bool useAscii = true) + { + try + { + #if UNITY_EDITOR + + Debug.Log("FBX 내보내기 시작..."); + + // 1단계: 제네릭 애니메이션 클립 생성 (메모리에서만) + Debug.Log("1단계: 제네릭 애니메이션 클립 생성 중..."); + var genericClip = CreateGenericAnimationClip(); + if (genericClip == null) + { + Debug.LogError("제네릭 애니메이션 클립 생성에 실패했습니다."); + return; + } + + Debug.Log($"제네릭 애니메이션 클립 생성 완료: {genericClip.name} (길이: {genericClip.length}초)"); + + // 2단계: 스켈레톤 생성 + Debug.Log("2단계: 스켈레톤 생성 중..."); + var skeletonRoot = CreateSkeletonFromBoneData(); + if (skeletonRoot == null) + { + Debug.LogError("스켈레톤 생성에 실패했습니다."); + return; + } + + Debug.Log($"스켈레톤 생성 성공: {skeletonRoot.name} (자식 수: {skeletonRoot.transform.childCount})"); + + // 3단계: Animator 컴포넌트 추가 및 생성된 클립 연결 + Debug.Log("3단계: Animator 컴포넌트 설정 중..."); + var animatorComponent = AnimationHelper.SetupAnimatorComponent(skeletonRoot, genericClip); + if (animatorComponent == null) + { + Debug.LogError("Animator 컴포넌트 설정에 실패했습니다."); + DestroyImmediate(skeletonRoot); + return; + } + + Debug.Log($"Animator 컴포넌트 설정 성공"); + + // 4단계: Animator 컴포넌트 상태 상세 확인 + Debug.Log("4단계: Animator 컴포넌트 디버그 정보 출력 중..."); + AnimationHelper.DebugAnimatorComponent(animatorComponent); + + // 5단계: FBX Exporter 패키지 사용하여 내보내기 + Debug.Log("5단계: FBX 내보내기 중..."); + bool exportSuccess = ExportSkeletonWithAnimationUsingFBXExporter(skeletonRoot, fbxPath, useAscii); + + if (exportSuccess) + { + Debug.Log($"✅ FBX 내보내기 성공: {fbxPath}"); + + // 6단계: FBX 파일 설정 조정 + Debug.Log("6단계: FBX 설정 조정 중..."); + AnimationHelper.AdjustFBXImporterSettings(fbxPath); + } + else + { + Debug.LogError("❌ FBX 내보내기에 실패했습니다."); + } + + // 7단계: 정리 (메모리에서 클립 언로드) + Debug.Log("7단계: 정리 중..."); + DestroyImmediate(skeletonRoot); + + // 메모리에서 애니메이션 클립 정리 + if (genericClip != null) + { + Debug.Log($"메모리에서 애니메이션 클립 정리: {genericClip.name}"); + // Unity가 자동으로 메모리에서 언로드하도록 함 + } + + // 가비지 컬렉션 강제 실행 (선택사항) + System.GC.Collect(); + + Debug.Log("✅ FBX 내보내기 완료!"); + + # endif + + } + catch (System.Exception e) + { + Debug.LogError($"FBX 내보내기 실패: {e.Message}\n{e.StackTrace}"); + } + } + + private void ExportBipedSkeletonWithAnimationToFBXStepByStep(string fbxPath, bool useAscii = true) + { + try + { + #if UNITY_EDITOR + + Debug.Log("Biped FBX 내보내기 시작..."); + + // 1단계: Biped 애니메이션 클립 생성 (메모리에서만) + Debug.Log("1단계: Biped 애니메이션 클립 생성 중..."); + var bipedClip = CreateBipedAnimationClip(); + if (bipedClip == null) + { + Debug.LogError("Biped 애니메이션 클립 생성에 실패했습니다."); + return; + } + + Debug.Log($"Biped 애니메이션 클립 생성 완료: {bipedClip.name} (길이: {bipedClip.length}초)"); + + // 2단계: Biped 스켈레톤 생성 + Debug.Log("2단계: Biped 스켈레톤 생성 중..."); + var skeletonRoot = CreateBipedSkeletonFromBoneData(); + if (skeletonRoot == null) + { + Debug.LogError("Biped 스켈레톤 생성에 실패했습니다."); + return; + } + + Debug.Log($"Biped 스켈레톤 생성 성공: {skeletonRoot.name} (자식 수: {skeletonRoot.transform.childCount})"); + + // 3단계: Animator 컴포넌트 추가 및 생성된 클립 연결 + Debug.Log("3단계: Animator 컴포넌트 설정 중..."); + var animatorComponent = AnimationHelper.SetupAnimatorComponent(skeletonRoot, bipedClip); + if (animatorComponent == null) + { + Debug.LogError("Animator 컴포넌트 설정에 실패했습니다."); + DestroyImmediate(skeletonRoot); + return; + } + + Debug.Log($"Animator 컴포넌트 설정 성공"); + + // 4단계: Animator 컴포넌트 상태 상세 확인 + Debug.Log("4단계: Animator 컴포넌트 디버그 정보 출력 중..."); + AnimationHelper.DebugAnimatorComponent(animatorComponent); + + // 5단계: FBX Exporter 패키지 사용하여 내보내기 + Debug.Log("5단계: Biped FBX 내보내기 중..."); + bool exportSuccess = ExportSkeletonWithAnimationUsingFBXExporter(skeletonRoot, fbxPath, useAscii); + + if (exportSuccess) + { + Debug.Log($"✅ Biped FBX 내보내기 성공: {fbxPath}"); + + // 6단계: FBX 파일 설정 조정 + Debug.Log("6단계: FBX 설정 조정 중..."); + AnimationHelper.AdjustFBXImporterSettings(fbxPath); + } + else + { + Debug.LogError("❌ Biped FBX 내보내기에 실패했습니다."); + } + + // 7단계: 정리 (메모리에서 클립 언로드) + Debug.Log("7단계: 정리 중..."); + DestroyImmediate(skeletonRoot); + + // 메모리에서 애니메이션 클립 정리 + if (bipedClip != null) + { + Debug.Log($"메모리에서 애니메이션 클립 정리: {bipedClip.name}"); + // Unity가 자동으로 메모리에서 언로드하도록 함 + } + + // 가비지 컬렉션 강제 실행 (선택사항) + System.GC.Collect(); + + Debug.Log("✅ Biped FBX 내보내기 완료!"); + + # endif + + } + catch (System.Exception e) + { + Debug.LogError($"Biped FBX 내보내기 실패: {e.Message}\n{e.StackTrace}"); + } + } + + + + // 애니메이션 관련 헬퍼 클래스 (이름 충돌 방지) + private static class AnimationHelper + { + /// + /// 스켈레톤에 Animator 컴포넌트를 설정합니다. (Generic 애니메이션) + /// + public static Animator SetupAnimatorComponent(GameObject skeletonRoot, AnimationClip clip) + { + try + { + // Animator 컴포넌트 추가 + var animatorComponent = skeletonRoot.GetComponent(); + if (animatorComponent == null) + { + animatorComponent = skeletonRoot.AddComponent(); + Debug.Log("Animator 컴포넌트 추가됨"); + } + + // Avatar 설정 (Generic - Avatar 없음) + animatorComponent.avatar = null; + Debug.Log("Generic 애니메이션 설정됨 (Avatar 없음)"); + + // RuntimeAnimatorController 생성 및 클립 추가 + var controller = new UnityEditor.Animations.AnimatorController(); + controller.name = $"{clip.name}_Controller"; + + // 기본 레이어 생성 + var layer = new UnityEditor.Animations.AnimatorControllerLayer(); + layer.name = "Base Layer"; + layer.defaultWeight = 1.0f; + + // 상태 머신 생성 + var stateMachine = new UnityEditor.Animations.AnimatorStateMachine(); + layer.stateMachine = stateMachine; + + // 애니메이션 상태 생성 및 클립 할당 + var animationState = stateMachine.AddState(clip.name); + animationState.motion = clip; + + // 컨트롤러에 레이어 추가 + controller.AddLayer(layer); + + // Animator에 컨트롤러 할당 + animatorComponent.runtimeAnimatorController = controller; + + // Unity가 변경사항을 인식하도록 강제 업데이트 + EditorUtility.SetDirty(animatorComponent); + EditorUtility.SetDirty(controller); + + Debug.Log($"Animator 컴포넌트 설정 완료 (Generic): {clip.name}"); + Debug.Log($"- 클립 이름: {clip.name}"); + Debug.Log($"- 클립 길이: {clip.length}초"); + Debug.Log($"- 클립 프레임레이트: {clip.frameRate}"); + Debug.Log($"- 컨트롤러 이름: {controller.name}"); + Debug.Log($"- Avatar: 없음 (Generic 애니메이션)"); + + return animatorComponent; + } + catch (System.Exception e) + { + Debug.LogError($"Animator 컴포넌트 설정 실패: {e.Message}"); + return null; + } + } + + /// + /// 스켈레톤에 애니메이션 컴포넌트를 설정합니다. + /// + public static UnityEngine.Animation SetupAnimationComponent(GameObject skeletonRoot, AnimationClip clip) + { + try + { + // Animation 컴포넌트 추가 + var animationComponent = skeletonRoot.GetComponent(); + if (animationComponent == null) + { + animationComponent = skeletonRoot.AddComponent(); + Debug.Log("Animation 컴포넌트 추가됨"); + } + + // 클립을 Animations 배열에 추가 (FBX 내보내기용) + animationComponent.AddClip(clip, clip.name); + + // 메인 클립으로도 설정 + animationComponent.clip = clip; + + // Animations 배열에 직접 추가하는 방법도 시도 + var animations = new AnimationClip[1]; + animations[0] = clip; + + // SerializedObject를 사용하여 Animations 배열에 직접 설정 + var serializedObject = new SerializedObject(animationComponent); + var animationsProperty = serializedObject.FindProperty("m_Animations"); + if (animationsProperty != null) + { + animationsProperty.ClearArray(); + animationsProperty.arraySize = 1; + var element = animationsProperty.GetArrayElementAtIndex(0); + element.objectReferenceValue = clip; + serializedObject.ApplyModifiedProperties(); + Debug.Log("SerializedObject를 통해 Animations 배열에 클립 추가됨"); + } + + // Unity가 변경사항을 인식하도록 강제 업데이트 + EditorUtility.SetDirty(animationComponent); + + Debug.Log($"애니메이션 컴포넌트 설정 완료: {clip.name}"); + Debug.Log($"- 클립 이름: {clip.name}"); + Debug.Log($"- 클립 길이: {clip.length}초"); + Debug.Log($"- 클립 프레임레이트: {clip.frameRate}"); + Debug.Log($"- Animations 배열 크기: {animationComponent.GetClipCount()}"); + + return animationComponent; + } + catch (System.Exception e) + { + Debug.LogError($"애니메이션 컴포넌트 설정 실패: {e.Message}"); + return null; + } + } + + /// + /// FBX 내보내기를 위한 설정을 조정합니다. + /// + public static void AdjustFBXImporterSettings(string fbxPath) + { + try + { + var importer = AssetImporter.GetAtPath(fbxPath) as ModelImporter; + if (importer != null) + { + Debug.Log("FBX 파일 설정 조정 중..."); + + // 애니메이션 설정 + importer.importAnimation = true; + importer.animationType = ModelImporterAnimationType.Generic; + importer.animationCompression = ModelImporterAnimationCompression.Off; + + // 애니메이션 클립 설정 + var clipSettings = importer.defaultClipAnimations; + if (clipSettings.Length > 0) + { + string clipName = Path.GetFileNameWithoutExtension(fbxPath); + clipSettings[0].name = clipName; + clipSettings[0].loopTime = false; + clipSettings[0].lockRootRotation = false; + clipSettings[0].lockRootHeightY = false; + clipSettings[0].lockRootPositionXZ = false; + clipSettings[0].keepOriginalOrientation = true; + clipSettings[0].keepOriginalPositionY = true; + clipSettings[0].keepOriginalPositionXZ = true; + clipSettings[0].heightFromFeet = false; + clipSettings[0].mirror = false; + + importer.clipAnimations = clipSettings; + } + + // 변경사항 저장 + importer.SaveAndReimport(); + Debug.Log("FBX 파일 설정 조정 완료"); + } + else + { + Debug.LogWarning("FBX 파일의 ModelImporter를 찾을 수 없습니다."); + } + } + catch (System.Exception e) + { + Debug.LogError($"FBX 설정 조정 중 오류: {e.Message}"); + } + } + + /// + /// Animator 컴포넌트의 상태를 상세히 출력합니다. + /// + public static void DebugAnimatorComponent(Animator animatorComponent) + { + if (animatorComponent == null) + { + Debug.LogError("Animator 컴포넌트가 null입니다."); + return; + } + + Debug.Log("=== Animator 컴포넌트 디버그 정보 ==="); + Debug.Log($"- 컴포넌트 활성화: {animatorComponent.enabled}"); + Debug.Log($"- Avatar: {animatorComponent.avatar?.name ?? "없음"}"); + Debug.Log($"- RuntimeAnimatorController: {animatorComponent.runtimeAnimatorController?.name ?? "없음"}"); + Debug.Log($"- Culling Mode: {animatorComponent.cullingMode}"); + Debug.Log($"- Update Mode: {animatorComponent.updateMode}"); + + // 컨트롤러 정보 출력 + if (animatorComponent.runtimeAnimatorController != null) + { + var controller = animatorComponent.runtimeAnimatorController; + Debug.Log($" 컨트롤러: {controller.name}"); + Debug.Log($" 컨트롤러 타입: {controller.GetType().Name}"); + } + + // 현재 상태 정보 출력 + Debug.Log($"- 현재 상태: {animatorComponent.GetCurrentAnimatorStateInfo(0).IsName("")}"); + Debug.Log($"- 애니메이션 길이: {animatorComponent.GetCurrentAnimatorStateInfo(0).length}초"); + Debug.Log("=== 디버그 정보 끝 ==="); + } + + /// + /// 애니메이션 컴포넌트의 상태를 상세히 출력합니다. + /// + public static void DebugAnimationComponent(UnityEngine.Animation animationComponent) + { + if (animationComponent == null) + { + Debug.LogError("애니메이션 컴포넌트가 null입니다."); + return; + } + + Debug.Log("=== 애니메이션 컴포넌트 디버그 정보 ==="); + Debug.Log($"- 컴포넌트 활성화: {animationComponent.enabled}"); + Debug.Log($"- 메인 클립: {animationComponent.clip?.name ?? "없음"}"); + Debug.Log($"- 자동 재생: {animationComponent.playAutomatically}"); + Debug.Log($"- 래핑 모드: {animationComponent.wrapMode}"); + + // 메인 클립 정보 출력 + if (animationComponent.clip != null) + { + var clip = animationComponent.clip; + Debug.Log($" 메인 클립: {clip.name} (길이: {clip.length}초, 프레임레이트: {clip.frameRate})"); + + // 클립의 커브 정보 출력 + var bindings = AnimationUtility.GetCurveBindings(clip); + Debug.Log($" 커브 바인딩 수: {bindings.Length}"); + for (int j = 0; j < Math.Min(bindings.Length, 5); j++) // 처음 5개만 출력 + { + var binding = bindings[j]; + Debug.Log($" 바인딩 {j}: {binding.path}.{binding.propertyName}"); + } + } + Debug.Log("=== 디버그 정보 끝 ==="); + } + } + + // Unity FBX Exporter 패키지를 사용한 내보내기 (스켈레톤만) + private bool ExportSkeletonWithAnimationUsingFBXExporter(GameObject skeletonRoot, string fbxPath, bool useAscii) + { + try + { + // FBX Exporter 패키지 확인 + var modelExporterType = System.Type.GetType("UnityEditor.Formats.Fbx.Exporter.ModelExporter, Unity.Formats.Fbx.Editor"); + if (modelExporterType == null) + { + Debug.LogError("Unity FBX Exporter 패키지가 설치되지 않았습니다."); + Debug.LogError("Package Manager에서 'FBX Exporter' 패키지를 설치해주세요."); + return false; + } + + Debug.Log("Unity FBX Exporter 패키지 발견"); + + // ExportModelOptions 타입 가져오기 + var exportModelOptionsType = System.Type.GetType("UnityEditor.Formats.Fbx.Exporter.ExportModelOptions, Unity.Formats.Fbx.Editor"); + if (exportModelOptionsType == null) + { + Debug.LogError("ExportModelOptions 타입을 찾을 수 없습니다."); + return false; + } + + // ExportObjects 메서드 찾기 + var exportObjectsMethod = modelExporterType.GetMethod("ExportObjects", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static, + null, + new System.Type[] { typeof(string), typeof(UnityEngine.Object[]), exportModelOptionsType }, + null); + + if (exportObjectsMethod == null) + { + Debug.LogError("ModelExporter.ExportObjects 메서드를 찾을 수 없습니다."); + return false; + } + + // ExportModelOptions 생성 및 설정 + var exportOptions = System.Activator.CreateInstance(exportModelOptionsType); + Debug.Log("ExportModelOptions 인스턴스 생성됨"); + + // ASCII/Binary 설정 - ExportFormat 속성 사용 + var exportFormatProperty = exportModelOptionsType.GetProperty("ExportFormat"); + if (exportFormatProperty != null) + { + try + { + // ExportFormat enum 값을 가져와서 설정 + var exportFormatType = exportFormatProperty.PropertyType; + Debug.Log($"ExportFormat 타입: {exportFormatType.Name}"); + + // enum 값들을 확인 + var enumValues = System.Enum.GetValues(exportFormatType); + Debug.Log("사용 가능한 ExportFormat 값들:"); + foreach (var enumValue in enumValues) + { + Debug.Log($" - {enumValue}: {(int)enumValue}"); + } + + // Binary와 ASCII enum 값 찾기 + object targetFormat = null; + foreach (var enumValue in enumValues) + { + string enumName = enumValue.ToString().ToLower(); + if (useAscii && (enumName.Contains("ascii") || enumName.Contains("text"))) + { + targetFormat = enumValue; + break; + } + else if (!useAscii && (enumName.Contains("binary") || enumName.Contains("bin"))) + { + targetFormat = enumValue; + break; + } + } + + if (targetFormat != null) + { + exportFormatProperty.SetValue(exportOptions, targetFormat); + Debug.Log($"FBX 형식 설정 (ExportFormat): {targetFormat} ({(useAscii ? "ASCII" : "Binary")})"); + } + else + { + Debug.LogWarning($"적절한 ExportFormat 값을 찾을 수 없습니다. useAscii={useAscii}"); + } + } + catch (System.Exception e) + { + Debug.LogError($"ExportFormat 설정 실패: {e.Message}"); + } + } + else + { + Debug.LogWarning("ExportFormat 속성을 찾을 수 없습니다."); + } + + // 애니메이션 포함 설정 + var includeAnimationProperty = exportModelOptionsType.GetProperty("IncludeAnimation"); + if (includeAnimationProperty != null) + { + includeAnimationProperty.SetValue(exportOptions, true); + Debug.Log("애니메이션 포함 설정: true"); + } + else + { + Debug.LogWarning("IncludeAnimation 속성을 찾을 수 없습니다."); + } + + // 스켈레톤만 내보내기 (Animation 컴포넌트가 포함됨) + var objectsToExport = new UnityEngine.Object[] { skeletonRoot }; + + Debug.Log($"내보낼 오브젝트: {objectsToExport.Length}개"); + Debug.Log($"1. 스켈레톤 (Animation 컴포넌트 포함): {skeletonRoot.name}"); + + // 스켈레톤의 컴포넌트 확인 + var components = skeletonRoot.GetComponents(); + Debug.Log($"스켈레톤의 컴포넌트 수: {components.Length}"); + foreach (var component in components) + { + Debug.Log($" 컴포넌트: {component.GetType().Name}"); + } + + // FBX 내보내기 실행 + try + { + Debug.Log("FBX 내보내기 메서드 호출 시작..."); + Debug.Log($"- 경로: {fbxPath}"); + Debug.Log($"- 오브젝트 수: {objectsToExport.Length}"); + Debug.Log($"- ASCII 모드: {useAscii}"); + + exportObjectsMethod.Invoke(null, new object[] { fbxPath, objectsToExport, exportOptions }); + Debug.Log("FBX 내보내기 메서드 호출 완료"); + } + catch (System.Exception e) + { + Debug.LogError($"FBX 내보내기 중 상세 오류: {e.Message}"); + Debug.LogError($"내부 오류: {e.InnerException?.Message ?? "없음"}"); + Debug.LogError($"스택 트레이스: {e.StackTrace}"); + throw; // 오류를 다시 던져서 상위에서 처리 + } + + // 파일 생성 확인 + if (System.IO.File.Exists(fbxPath)) + { + Debug.Log($"FBX 파일 생성 확인: {fbxPath}"); + AssetDatabase.Refresh(); + return true; + } + else + { + Debug.LogError($"FBX 파일이 생성되지 않았습니다: {fbxPath}"); + return false; + } + + } + catch (System.Exception e) + { + Debug.LogError($"FBX Exporter 사용 중 오류: {e.Message}"); + return false; + } + } + + + + // 본 데이터로부터 스켈레톤 생성 + private GameObject CreateSkeletonFromBoneData() + { + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("본 데이터가 없습니다."); + return null; + } + + var firstPose = Poses[0]; + var bones = firstPose.HumanoidBones; + + Debug.Log($"스켈레톤 생성 시작: {bones.Count}개의 본 데이터"); + + // 본 데이터 구조 상세 분석 + Debug.Log("=== 본 데이터 구조 분석 ==="); + for (int i = 0; i < Math.Min(bones.Count, 15); i++) // 처음 15개 출력 + { + var bone = bones[i]; + Debug.Log($"본 {i}: '{bone.Name}' - 위치: {bone.LocalPosition}, 회전: {bone.LocalRotation}"); + } + Debug.Log("=== 분석 완료 ==="); + + // 루트 GameObject 생성 (스크립터블 에셋 이름 사용) + string rootName = this.name; + if (string.IsNullOrEmpty(rootName)) + { + rootName = "Skeleton"; + } + var root = new GameObject(rootName); + Debug.Log($"루트 GameObject 생성됨: {root.name}"); + + // 본 계층 구조 생성 + var boneGameObjects = new Dictionary(); + int createdBones = 0; + + // 루트가 본 데이터에 포함되어 있는지 확인 (더 정확한 분석) + bool hasRootInData = false; + string rootBoneName = ""; + var rootBones = new List(); + + foreach (var bone in bones) + { + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogWarning("빈 본 이름 발견, 건너뜀"); + continue; + } + + // 루트 본인지 확인 (경로가 비어있거나 단일 이름인 경우) + if (string.IsNullOrEmpty(bone.Name) || !bone.Name.Contains("/")) + { + hasRootInData = true; + rootBoneName = bone.Name; + rootBones.Add(bone.Name); + Debug.Log($"루트 본 발견: '{rootBoneName}'"); + } + } + + if (hasRootInData) + { + Debug.Log($"본 데이터에 {rootBones.Count}개의 루트 본이 포함됨: {string.Join(", ", rootBones)}"); + } + else + { + Debug.Log("본 데이터에 루트 본이 포함되지 않음"); + } + + // 본 데이터를 경로별로 정리하여 중복 제거 + var uniqueBonePaths = new Dictionary(); + foreach (var bone in bones) + { + if (string.IsNullOrEmpty(bone.Name)) + continue; + + string cleanPath = bone.Name.TrimEnd('/'); + if (!uniqueBonePaths.ContainsKey(cleanPath)) + { + uniqueBonePaths[cleanPath] = bone; + } + } + + Debug.Log($"중복 제거 후 고유 본 경로 수: {uniqueBonePaths.Count}"); + + foreach (var kvp in uniqueBonePaths) + { + var bonePath = kvp.Key; + var bone = kvp.Value; + + Debug.Log($"본 처리 중: {bonePath}"); + + // 본 경로를 '/'로 분할하여 계층 구조 생성 + var pathParts = bonePath.Split('/'); + var currentPath = ""; + GameObject parent = root; + + for (int i = 0; i < pathParts.Length; i++) + { + var part = pathParts[i]; + if (string.IsNullOrEmpty(part)) + continue; + + currentPath = string.IsNullOrEmpty(currentPath) ? part : currentPath + "/" + part; + + if (!boneGameObjects.ContainsKey(currentPath)) + { + // 루트 본이 본 데이터에 포함되어 있고, 현재 처리 중인 본이 루트인 경우 + if (hasRootInData && i == 0 && pathParts.Length == 1 && rootBones.Contains(part)) + { + // 루트 본은 이미 생성된 루트 GameObject를 사용 + boneGameObjects[currentPath] = root; + Debug.Log($"루트 본 '{part}'을 기존 루트 GameObject에 연결 (중복 방지)"); + + // 루트의 위치와 회전을 설정 + root.transform.localPosition = bone.LocalPosition; + root.transform.localRotation = bone.LocalRotation; + Debug.Log($"루트 설정: 위치={bone.LocalPosition}, 회전={bone.LocalRotation}"); + continue; + } + + var boneGO = new GameObject(part); + boneGO.transform.SetParent(parent.transform); + createdBones++; + + // 첫 번째 포즈의 위치와 회전 설정 + if (i == pathParts.Length - 1) // 마지막 부분 (실제 본) + { + boneGO.transform.localPosition = bone.LocalPosition; + boneGO.transform.localRotation = bone.LocalRotation; + Debug.Log($"본 설정: {part} - 위치: {bone.LocalPosition}, 회전: {bone.LocalRotation}"); + } + + boneGameObjects[currentPath] = boneGO; + parent = boneGO; + } + else + { + parent = boneGameObjects[currentPath]; + } + } + } + + Debug.Log($"스켈레톤 생성 완료: {createdBones}개의 GameObject 생성됨"); + + // 스켈레톤 구조 출력 + PrintSkeletonHierarchy(root); + + // 스켈레톤이 실제로 씬에 있는지 확인 + if (root != null && root.transform.childCount > 0) + { + Debug.Log($"✅ 스켈레톤 생성 성공: 루트={root.name}, 자식 수={root.transform.childCount}"); + return root; + } + else + { + Debug.LogError("❌ 스켈레톤 생성 실패: 자식이 없음"); + return null; + } + } + + // 본 데이터로부터 Biped 스켈레톤 생성 + private GameObject CreateBipedSkeletonFromBoneData() + { + if (Poses.Count == 0 || Poses[0].HumanoidBones.Count == 0) + { + Debug.LogError("본 데이터가 없습니다."); + return null; + } + + var firstPose = Poses[0]; + var bones = firstPose.HumanoidBones; + + Debug.Log($"Biped 스켈레톤 생성 시작: {bones.Count}개의 본 데이터"); + + // 본 데이터 구조 확인 + for (int i = 0; i < Math.Min(bones.Count, 10); i++) // 처음 10개만 출력 + { + var bone = bones[i]; + Debug.Log($"본 {i}: '{bone.Name}' - 위치: {bone.LocalPosition}, 회전: {bone.LocalRotation}"); + } + + // Bip001 루트 GameObject 생성 (스크립터블 에셋 이름 사용) + string rootName = this.name; + if (string.IsNullOrEmpty(rootName)) + { + rootName = "Bip001"; + } + var root = new GameObject(rootName); + Debug.Log($"Bip001 루트 GameObject 생성됨: {root.name}"); + + // 본 계층 구조 생성 + var boneGameObjects = new Dictionary(); + int createdBones = 0; + + // 루트가 본 데이터에 포함되어 있는지 확인 + bool hasRootInData = false; + string rootBoneName = ""; + + foreach (var bone in bones) + { + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogWarning("빈 본 이름 발견, 건너뜀"); + continue; + } + + // 루트 본인지 확인 (경로가 비어있거나 단일 이름인 경우) + if (string.IsNullOrEmpty(bone.Name) || !bone.Name.Contains("/")) + { + hasRootInData = true; + rootBoneName = bone.Name; + Debug.Log($"본 데이터에 루트가 포함됨: '{rootBoneName}'"); + break; + } + } + + foreach (var bone in bones) + { + if (string.IsNullOrEmpty(bone.Name)) + { + Debug.LogWarning("빈 본 이름 발견, 건너뜀"); + continue; + } + + Debug.Log($"본 처리 중: {bone.Name}"); + + // 본 경로를 '/'로 분할하여 계층 구조 생성 + var pathParts = bone.Name.Split('/'); + var currentPath = ""; + GameObject parent = root; + + for (int i = 0; i < pathParts.Length; i++) + { + var part = pathParts[i]; + if (string.IsNullOrEmpty(part)) + continue; + + currentPath = string.IsNullOrEmpty(currentPath) ? part : currentPath + "/" + part; + + if (!boneGameObjects.ContainsKey(currentPath)) + { + // 루트 본이 본 데이터에 포함되어 있고, 현재 처리 중인 본이 루트인 경우 + if (hasRootInData && i == 0 && pathParts.Length == 1 && part == rootBoneName) + { + // 루트 본은 이미 생성된 루트 GameObject를 사용 + boneGameObjects[currentPath] = root; + Debug.Log($"루트 본 '{part}'을 기존 루트 GameObject에 연결"); + continue; + } + + var boneGO = new GameObject(part); + boneGO.transform.SetParent(parent.transform); + createdBones++; + + // 첫 번째 포즈의 위치와 회전 설정 + if (i == pathParts.Length - 1) // 마지막 부분 (실제 본) + { + // Bip001 Pelvis인 경우 특별 처리 + if (bone.Name.Contains("Pelvis") && bone.Name.Contains("Bip001")) + { + // Bip001 Pelvis는 로컬 위치를 0,0,0으로 고정 + boneGO.transform.localPosition = Vector3.zero; + // Bip001 Pelvis는 기본 회전 오프셋 (0, -90, -90) 유지 + boneGO.transform.localRotation = Quaternion.Euler(0, -90, -90); + Debug.Log($"Bip001 Pelvis 설정: {part} - 위치: {Vector3.zero}, 회전: {Quaternion.Euler(0, -90, -90)}"); + } + else if (pathParts.Length == 1 && bone.Name.Contains("Pelvis")) + { + // Bip001 루트에 Pelvis 위치 데이터 설정 + boneGO.transform.localPosition = bone.LocalPosition; + boneGO.transform.localRotation = Quaternion.Euler(0, -90, -90); + Debug.Log($"Bip001 루트 설정: {part} - 위치: {bone.LocalPosition}, 회전: {Quaternion.Euler(0, -90, -90)}"); + } + else + { + // 일반 본은 기존 방식대로 처리 + boneGO.transform.localPosition = bone.LocalPosition; + boneGO.transform.localRotation = bone.LocalRotation; + Debug.Log($"본 설정: {part} - 위치: {bone.LocalPosition}, 회전: {bone.LocalRotation}"); + } + } + + boneGameObjects[currentPath] = boneGO; + parent = boneGO; + } + else + { + parent = boneGameObjects[currentPath]; + } + } + } + + Debug.Log($"Biped 스켈레톤 생성 완료: {createdBones}개의 GameObject 생성됨"); + + // 스켈레톤 구조 출력 + PrintSkeletonHierarchy(root); + + // 스켈레톤이 실제로 씬에 있는지 확인 + if (root != null && root.transform.childCount > 0) + { + Debug.Log($"✅ Biped 스켈레톤 생성 성공: 루트={root.name}, 자식 수={root.transform.childCount}"); + return root; + } + else + { + Debug.LogError("❌ Biped 스켈레톤 생성 실패: 자식이 없음"); + return null; + } + } + + // 스켈레톤 계층 구조 출력 (디버깅용) + private void PrintSkeletonHierarchy(GameObject root, string indent = "") + { + Debug.Log($"{indent}{root.name} ({root.transform.childCount} children)"); + for (int i = 0; i < root.transform.childCount; i++) + { + var child = root.transform.GetChild(i); + PrintSkeletonHierarchy(child.gameObject, indent + " "); + } + } + + // 스켈레톤에서 모든 본 GameObject 찾기 + private void FindAllBoneGameObjects(GameObject root, Dictionary boneGameObjects) + { + var allChildren = root.GetComponentsInChildren(); + foreach (var child in allChildren) + { + if (child != root.transform) + { + string path = GetRelativePath(root.transform, child); + boneGameObjects[path] = child.gameObject; + } + } + } + + // 상대 경로 가져오기 + private string GetRelativePath(Transform root, Transform target) + { + var pathList = new List(); + var current = target; + + while (current != null && current != root) + { + pathList.Add(current.name); + current = current.parent; + } + + pathList.Reverse(); + return string.Join("/", pathList); + } + + // 애니메이션 속성 경로 가져오기 + private string GetPropertyPath(GameObject boneGO, GameObject root) + { + return GetRelativePath(root.transform, boneGO.transform); } #endif @@ -372,18 +2257,44 @@ namespace Entum if(path != null) return path; var current = target; - while (true) + var pathList = new List(); + + // 타겟이 루트와 같은 경우 빈 문자열 반환 + if (current == root) { - if (current == null) throw new Exception(target.name + "は" + root.name + "の子ではありません"); - if (current == root) break; - - path = (path == "") ? current.name : current.name + "/" + path; - + path = ""; + _pathCache.Add(target, path); + return path; + } + + // 루트까지 올라가면서 경로 구성 + while (current != null && current != root) + { + pathList.Add(current.name); current = current.parent; } + + if (current == null) + { + Debug.LogError($"{target.name}는 {root.name}의 자식이 아닙니다."); + throw new Exception(target.name + "는" + root.name + "의 자식이 아닙니다"); + } + + // 경로를 역순으로 조합 (Unity 애니메이션 경로 형식) + pathList.Reverse(); + path = string.Join("/", pathList); + + // 경로 끝의 슬래시 제거 + path = path.TrimEnd('/'); + + // Unity 애니메이션 시스템에서 루트 오브젝트 이름을 제거하는 경우를 대비 + // 루트가 "Bip001"인 경우, 경로에서 "Bip001/" 부분을 제거 + if (root.name == "Bip001" && path.StartsWith("Bip001/")) + { + path = path.Substring("Bip001/".Length); + } _pathCache.Add(target, path); - return path; } @@ -391,11 +2302,25 @@ namespace Entum { Name = BuildRelativePath(root, t); - LocalPosition = t.localPosition; - LocalRotation = t.localRotation; + // 루트 본인지 확인 (이름이 비어있거나 루트와 같은 경우) + bool isRoot = string.IsNullOrEmpty(Name) || t == root; + + if (isRoot) + { + // 루트 본: 월드 좌표계 사용 + LocalPosition = t.position; // 월드 위치 + LocalRotation = t.rotation; // 월드 회전 + } + else + { + // 자식 본: 로컬 좌표계 사용 + LocalPosition = t.localPosition; + LocalRotation = t.localRotation; + } } } + [SerializeField, HideInInspector] public List HumanoidBones = new List(); //CSVシリアライズ @@ -491,6 +2416,21 @@ namespace Entum } + [SerializeField, HideInInspector] public List Poses = new List(); + + // 인스펙터 최적화를 위한 요약 정보 + [System.Serializable] + public class SummaryInfo + { + public int TotalPoses; + public float TotalTime; + public int TotalBones; + public int TotalMuscles; + public float AverageFPS; + } + + [SerializeField] + public SummaryInfo Summary = new SummaryInfo(); } } diff --git a/Assets/External/EasyMotionRecorder/Scripts/MotionDataRecorder.cs b/Assets/External/EasyMotionRecorder/Scripts/MotionDataRecorder.cs index 5683405dc..252b27fc1 100644 --- a/Assets/External/EasyMotionRecorder/Scripts/MotionDataRecorder.cs +++ b/Assets/External/EasyMotionRecorder/Scripts/MotionDataRecorder.cs @@ -11,9 +11,12 @@ using UnityEngine; using System; using System.IO; using System.Reflection; +using System.Collections.Generic; #if UNITY_EDITOR using UnityEditor; #endif +using EasyMotionRecorder; +using UniHumanoid; namespace Entum { @@ -47,9 +50,13 @@ namespace Entum [SerializeField] private HumanBodyBones IK_RightFootBone = HumanBodyBones.RightFoot; + [SerializeField, Tooltip("녹화 시작 시 T-포즈를 별도로 저장할지 여부 (출력 시 0프레임에 포함)")] + private bool _recordTPoseAtStart = true; + protected HumanoidPoses Poses; protected float RecordedTime; protected float StartTime; + public string SessionID; // 세션 ID 추가 private HumanPose _currentPose; private HumanPoseHandler _poseHandler; @@ -94,12 +101,12 @@ namespace Entum return; } - RecordedTime = Time.time - StartTime; if (TargetFPS != 0.0f) { - var nextTime = (1.0f * (FrameIndex + 1)) / TargetFPS; + // T-포즈가 별도 저장되므로 실제 녹화는 1프레임부터 시작 + var nextTime = (1.0f * FrameIndex) / TargetFPS; if (nextTime > RecordedTime) { return; @@ -117,7 +124,6 @@ namespace Entum } } - //現在のフレームのHumanoidの姿勢を取得 _poseHandler.GetHumanPose(ref _currentPose); //posesに取得した姿勢を書き込む @@ -152,8 +158,6 @@ namespace Entum serializedPose.RightfootIK_Pos = RightFootTQ.t; serializedPose.RightfootIK_Rot = RightFootTQ.q; - - serializedPose.FrameCount = FrameIndex; serializedPose.Muscles = new float[_currentPose.muscles.Length]; serializedPose.Time = RecordedTime; @@ -178,8 +182,11 @@ namespace Entum return; } + // 세션 ID 생성 (년도는 2자리로 표시, 고유 ID 제거) + SessionID = DateTime.Now.ToString("yyMMdd_HHmmss"); Poses = ScriptableObject.CreateInstance(); + Poses.AvatarName = _animator.name; // 아바타 이름 설정 if (OnRecordStart != null) { @@ -191,6 +198,137 @@ namespace Entum RecordedTime = 0f; StartTime = Time.time; FrameIndex = 0; + + // 1프레임에 T-포즈 저장 + if (_recordTPoseAtStart) + { + RecordTPoseAsFirstFrame(); + } + } + + /// + /// T-포즈를 즉시 저장합니다. + /// + private void RecordTPoseAsFirstFrame() + { + try + { + Debug.Log("T-포즈 즉시 저장 시작..."); + + // 현재 포즈를 T-포즈로 설정 + SetTPose(_animator); + + // T-포즈 설정 직후 즉시 데이터 수집 + RecordTPoseData(); + } + catch (System.Exception e) + { + Debug.LogError($"T-포즈 저장 중 오류 발생: {e.Message}"); + Debug.LogError($"스택 트레이스: {e.StackTrace}"); + } + } + + /// + /// 지정된 Animator의 포즈를 T-포즈로 설정합니다. + /// + /// T-포즈를 설정할 Animator + private void SetTPose(Animator animator) + { + if (animator == null || animator.avatar == null) + return; + + Avatar avatar = animator.avatar; + Transform transform = animator.transform; + + // HumanPoseClip에 저장된 T-포즈 데이터를 로드하여 적용 + var humanPoseClip = Resources.Load(HumanPoseClip.TPoseResourcePath); + if (humanPoseClip != null) + { + var pose = humanPoseClip.GetPose(); + HumanPoseTransfer.SetPose(avatar, transform, pose); + } + else + { + Debug.LogWarning("T-Pose 데이터가 존재하지 않습니다."); + } + } + + /// + /// T-포즈 데이터를 즉시 수집하여 저장 + /// + private void RecordTPoseData() + { + try + { + Debug.Log("T-포즈 데이터 즉시 수집 시작..."); + + // T-포즈가 적용된 상태에서 현재 프레임의 Humanoid 포즈를 가져옴 + _poseHandler.GetHumanPose(ref _currentPose); + + Debug.Log($"T-포즈 데이터: BodyPosition={_currentPose.bodyPosition}, BodyRotation={_currentPose.bodyRotation}"); + Debug.Log($"T-포즈 Muscle 개수: {_currentPose.muscles.Length}"); + + // T-포즈 데이터를 별도로 저장 + var tPoseSerialized = new HumanoidPoses.SerializeHumanoidPose(); + + switch (_rootBoneSystem) + { + case MotionDataSettings.Rootbonesystem.Objectroot: + tPoseSerialized.BodyRootPosition = _animator.transform.localPosition; + tPoseSerialized.BodyRootRotation = _animator.transform.localRotation; + Debug.Log($"Objectroot 설정: BodyRootPosition={tPoseSerialized.BodyRootPosition}, BodyRootRotation={tPoseSerialized.BodyRootRotation}"); + break; + + case MotionDataSettings.Rootbonesystem.Hipbone: + tPoseSerialized.BodyRootPosition = _animator.GetBoneTransform(_targetRootBone).position; + tPoseSerialized.BodyRootRotation = _animator.GetBoneTransform(_targetRootBone).rotation; + Debug.Log($"Hipbone 설정: BodyRootPosition={tPoseSerialized.BodyRootPosition}, BodyRootRotation={tPoseSerialized.BodyRootRotation}"); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + var bodyTQ = new TQ(_currentPose.bodyPosition, _currentPose.bodyRotation); + var LeftFootTQ = new TQ(_animator.GetBoneTransform(IK_LeftFootBone).position, _animator.GetBoneTransform(IK_LeftFootBone).rotation); + var RightFootTQ = new TQ(_animator.GetBoneTransform(IK_RightFootBone).position, _animator.GetBoneTransform(IK_RightFootBone).rotation); + LeftFootTQ = AvatarUtility.GetIKGoalTQ(_animator.avatar, _animator.humanScale, AvatarIKGoal.LeftFoot, bodyTQ, LeftFootTQ); + RightFootTQ = AvatarUtility.GetIKGoalTQ(_animator.avatar, _animator.humanScale, AvatarIKGoal.RightFoot, bodyTQ, RightFootTQ); + + tPoseSerialized.BodyPosition = bodyTQ.t; + tPoseSerialized.BodyRotation = bodyTQ.q; + tPoseSerialized.LeftfootIK_Pos = LeftFootTQ.t; + tPoseSerialized.LeftfootIK_Rot = LeftFootTQ.q; + tPoseSerialized.RightfootIK_Pos = RightFootTQ.t; + tPoseSerialized.RightfootIK_Rot = RightFootTQ.q; + + tPoseSerialized.FrameCount = 0; // T-포즈는 0프레임으로 설정 + tPoseSerialized.Muscles = new float[_currentPose.muscles.Length]; + tPoseSerialized.Time = 0f; // T-포즈는 0초로 설정 + + for (int i = 0; i < tPoseSerialized.Muscles.Length; i++) + { + tPoseSerialized.Muscles[i] = _currentPose.muscles[i]; + } + + Debug.Log($"T-포즈 Muscle 데이터 설정 완료: {tPoseSerialized.Muscles.Length}개"); + + SetHumanBoneTransformToHumanoidPoses(_animator, ref tPoseSerialized); + + Debug.Log($"T-포즈 본 데이터 설정 완료: {tPoseSerialized.HumanoidBones.Count}개 본"); + + // T-포즈를 별도 필드에 저장 + Poses.TPoseData = tPoseSerialized; + Poses.HasTPoseData = true; + + Debug.Log($"T-포즈가 별도로 저장되었습니다. (시간: 0초, 프레임: 0)"); + Debug.Log($"현재 Poses.Count: {Poses.Poses.Count} (T-포즈는 별도 저장됨)"); + } + catch (System.Exception e) + { + Debug.LogError($"T-포즈 저장 중 오류 발생: {e.Message}"); + Debug.LogError($"스택 트레이스: {e.StackTrace}"); + } } /// @@ -209,36 +347,132 @@ namespace Entum OnRecordEnd(); } + // 자동 출력 옵션 확인 +#if UNITY_EDITOR + if (SavePathManager.Instance != null && Poses != null) + { + if (SavePathManager.Instance.ExportHumanoidOnSave) + { + Poses.ExportHumanoidAnim(); + } + if (SavePathManager.Instance.ExportGenericOnSave) + { + Poses.ExportGenericAnim(); + } + } +#endif + OnRecordEnd -= WriteAnimationFile; _recording = false; } private static void SetHumanBoneTransformToHumanoidPoses(Animator animator, ref HumanoidPoses.SerializeHumanoidPose pose) { - HumanBodyBones[] values = Enum.GetValues(typeof(HumanBodyBones)) as HumanBodyBones[]; - foreach (HumanBodyBones b in values) + // Humanoid 본만 수집하여 데이터 크기 최적화 + var humanBones = new List(); + + // Humanoid 본들만 수집 + foreach (HumanBodyBones boneType in System.Enum.GetValues(typeof(HumanBodyBones))) { - if (b < 0 || b >= HumanBodyBones.LastBone) + if (boneType == HumanBodyBones.LastBone) continue; + + var boneTransform = animator.GetBoneTransform(boneType); + if (boneTransform != null) { - continue; - } - - Transform t = animator.GetBoneTransform(b); - if (t != null) - { - var bone = new HumanoidPoses.SerializeHumanoidPose.HumanoidBone(); - bone.Set(animator.transform, t); - pose.HumanoidBones.Add(bone); + humanBones.Add(boneTransform); } } + + // 추가로 중요한 본들 (팔꿈치, 무릎 등) + var additionalBones = new string[] { "LeftElbow", "RightElbow", "LeftKnee", "RightKnee", "LeftAnkle", "RightAnkle" }; + foreach (var boneName in additionalBones) + { + var bone = animator.transform.Find(boneName); + if (bone != null && !humanBones.Contains(bone)) + { + humanBones.Add(bone); + } + } + + foreach (Transform bone in humanBones) + { + if (bone != null) + { + var boneData = new HumanoidPoses.SerializeHumanoidPose.HumanoidBone(); + + // 기존 Set 메서드 사용 + boneData.Set(animator.transform, bone); + + // 팔꿈치 특별 처리 + if (IsElbowBone(bone)) + { + boneData = ProcessElbowRotation(bone, boneData); + } + + pose.HumanoidBones.Add(boneData); + } + } + } + + private static bool IsElbowBone(Transform bone) + { + // 팔꿈치 본 식별 + string boneName = bone.name.ToLower(); + return boneName.Contains("elbow") || boneName.Contains("forearm") || + boneName.Contains("arm") && boneName.Contains("02"); + } + + private static HumanoidPoses.SerializeHumanoidPose.HumanoidBone ProcessElbowRotation( + Transform elbow, HumanoidPoses.SerializeHumanoidPose.HumanoidBone boneData) + { + // 팔꿈치 회전 안정화 처리 + Quaternion currentRotation = elbow.localRotation; + + // 팔이 펴진 상태 감지 + if (elbow.parent != null && elbow.childCount > 0) + { + Vector3 armDirection = (elbow.position - elbow.parent.position).normalized; + Vector3 forearmDirection = (elbow.GetChild(0).position - elbow.position).normalized; + + float armAngle = Vector3.Angle(armDirection, forearmDirection); + + // 팔이 거의 펴진 상태일 때 회전 보정 + if (armAngle > 170f) + { + // Quaternion 보간을 사용하여 부드러운 전환 + Quaternion targetRotation = Quaternion.LookRotation(forearmDirection, Vector3.up); + boneData.LocalRotation = Quaternion.Slerp(currentRotation, targetRotation, 0.1f); + } + else + { + boneData.LocalRotation = currentRotation; + } + } + + return boneData; } protected virtual void WriteAnimationFile() { #if UNITY_EDITOR - SafeCreateDirectory("Assets/Resources"); + // SavePathManager 사용 + string savePath = "Assets/Resources"; // 기본값 + string fileName = $"{SessionID}_{_animator.name}_Motion.asset"; + + // SavePathManager가 있으면 사용 + if (SavePathManager.Instance != null) + { + savePath = SavePathManager.Instance.GetMotionSavePath(); + fileName = $"{SessionID}_{_animator.name}_Motion.asset"; + } + + SafeCreateDirectory(savePath); - var path = string.Format("Assets/Resources/RecordMotion_{0}{1:yyyy_MM_dd_HH_mm_ss}.asset", _animator.name, DateTime.Now); + // 요약 정보 업데이트 + UpdateSummaryInfo(); + + // 파일 경로 생성 + var path = Path.Combine(savePath, fileName); var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); AssetDatabase.CreateAsset(Poses, uniqueAssetPath); @@ -246,8 +480,27 @@ namespace Entum StartTime = Time.time; RecordedTime = 0f; FrameIndex = 0; + + Debug.Log($"모션 파일이 저장되었습니다: {uniqueAssetPath}"); #endif } + + private void UpdateSummaryInfo() + { + if (Poses != null && Poses.Poses.Count > 0) + { + var firstPose = Poses.Poses[0]; + var lastPose = Poses.Poses[Poses.Poses.Count - 1]; + + Poses.Summary.TotalPoses = Poses.Poses.Count; + Poses.Summary.TotalTime = lastPose.Time; + Poses.Summary.TotalBones = firstPose.HumanoidBones.Count; + Poses.Summary.TotalMuscles = firstPose.Muscles.Length; + Poses.Summary.AverageFPS = Poses.Poses.Count / lastPose.Time; + + Debug.Log($"요약 정보 업데이트: 포즈 {Poses.Poses.Count}개, 시간 {lastPose.Time:F2}초, 본 {firstPose.HumanoidBones.Count}개, 근육 {firstPose.Muscles.Length}개, 평균 FPS {Poses.Summary.AverageFPS:F1}"); + } + } /// /// 指定したパスにディレクトリが存在しない場合 diff --git a/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs b/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs new file mode 100644 index 000000000..ceccfe2da --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs @@ -0,0 +1,260 @@ +using UnityEngine; +using System; +using System.Collections.Generic; +using System.IO; +#if UNITY_EDITOR +using UnityEditor; +#endif +using EasyMotionRecorder; + +namespace Entum +{ + /// + /// 오브젝트 모션 데이터 기록 클래스 + /// 여러 오브젝트의 포지션과 로테이션을 동시에 기록 + /// + [DefaultExecutionOrder(32001)] // MotionDataRecorder보다 나중에 실행 + public class ObjectMotionRecorder : MonoBehaviour + { + [Header("레코딩 설정")] + [SerializeField] private KeyCode recordStartKey = KeyCode.R; + [SerializeField] private KeyCode recordStopKey = KeyCode.X; + + [Header("타겟 오브젝트들")] + [SerializeField] private Transform[] targetObjects; + + [Header("레코딩 설정")] + [Tooltip("기록할 FPS. 0으로 설정하면 제한 없음")] + [SerializeField] private float targetFPS = 60.0f; + + [Header("파일명 설정")] + [SerializeField] private string objectNamePrefix = "Object"; + + private bool isRecording = false; + private float startTime; + private float recordedTime; + private int frameIndex; + + // 각 오브젝트별 애니메이션 클립 데이터 + private Dictionary objectClips; + private Dictionary positionCurves; + private Dictionary rotationCurves; + + // 세션 ID (MotionDataRecorder와 동일한 형식) + public string SessionID { get; private set; } + + public Action OnRecordStart; + public Action OnRecordEnd; + + private void Update() + { + if (Input.GetKeyDown(recordStartKey)) + { + StartRecording(); + } + + if (Input.GetKeyDown(recordStopKey)) + { + StopRecording(); + } + } + + private void LateUpdate() + { + if (!isRecording) + return; + + recordedTime = Time.time - startTime; + + // FPS 제한 확인 + if (targetFPS > 0.0f) + { + var nextTime = (1.0f * (frameIndex + 1)) / targetFPS; + if (nextTime > recordedTime) + { + return; + } + } + + // 각 오브젝트의 포지션과 로테이션 기록 + foreach (var target in targetObjects) + { + if (target == null) continue; + + RecordObjectMotion(target, recordedTime); + } + + frameIndex++; + } + + private void RecordObjectMotion(Transform target, float time) + { + if (!positionCurves.ContainsKey(target) || !rotationCurves.ContainsKey(target)) + return; + + var posCurves = positionCurves[target]; + var rotCurves = rotationCurves[target]; + + // 포지션 기록 (X, Y, Z) + posCurves[0].AddKey(time, target.position.x); + posCurves[1].AddKey(time, target.position.y); + posCurves[2].AddKey(time, target.position.z); + + // 로테이션 기록 (X, Y, Z, W) + rotCurves[0].AddKey(time, target.rotation.x); + rotCurves[1].AddKey(time, target.rotation.y); + rotCurves[2].AddKey(time, target.rotation.z); + rotCurves[3].AddKey(time, target.rotation.w); + } + + public void StartRecording() + { + if (isRecording) + return; + + // 세션 ID 생성 (MotionDataRecorder와 동일한 형식) + SessionID = DateTime.Now.ToString("yyMMdd_HHmmss"); + + // 초기화 + objectClips = new Dictionary(); + positionCurves = new Dictionary(); + rotationCurves = new Dictionary(); + + // 각 오브젝트별 애니메이션 클립과 커브 초기화 + if (targetObjects != null) + { + foreach (var target in targetObjects) + { + if (target == null) continue; + + var clip = new AnimationClip(); + clip.frameRate = targetFPS > 0 ? targetFPS : 60f; + + // 포지션 커브 초기화 + var posCurves = new AnimationCurve[3]; + for (int i = 0; i < 3; i++) + { + posCurves[i] = new AnimationCurve(); + } + + // 로테이션 커브 초기화 + var rotCurves = new AnimationCurve[4]; + for (int i = 0; i < 4; i++) + { + rotCurves[i] = new AnimationCurve(); + } + + objectClips[target] = clip; + positionCurves[target] = posCurves; + rotationCurves[target] = rotCurves; + } + } + + startTime = Time.time; + recordedTime = 0f; + frameIndex = 0; + isRecording = true; + + OnRecordStart?.Invoke(); + + Debug.Log($"오브젝트 모션 레코딩 시작: {(targetObjects != null ? targetObjects.Length : 0)}개 오브젝트"); + } + + public void StopRecording() + { + if (!isRecording) + return; + + isRecording = false; + + // 각 오브젝트별 애니메이션 클립 생성 및 저장 + if (targetObjects != null) + { + foreach (var target in targetObjects) + { + if (target == null || !objectClips.ContainsKey(target)) continue; + + CreateAndSaveAnimationClip(target); + } + } + + OnRecordEnd?.Invoke(); + + Debug.Log("오브젝트 모션 레코딩 종료"); + } + + private void CreateAndSaveAnimationClip(Transform target) + { +#if UNITY_EDITOR + var clip = objectClips[target]; + var posCurves = positionCurves[target]; + var rotCurves = rotationCurves[target]; + + // 포지션 커브 설정 + clip.SetCurve("", typeof(Transform), "m_LocalPosition.x", posCurves[0]); + clip.SetCurve("", typeof(Transform), "m_LocalPosition.y", posCurves[1]); + clip.SetCurve("", typeof(Transform), "m_LocalPosition.z", posCurves[2]); + + // 로테이션 커브 설정 + clip.SetCurve("", typeof(Transform), "m_LocalRotation.x", rotCurves[0]); + clip.SetCurve("", typeof(Transform), "m_LocalRotation.y", rotCurves[1]); + clip.SetCurve("", typeof(Transform), "m_LocalRotation.z", rotCurves[2]); + clip.SetCurve("", typeof(Transform), "m_LocalRotation.w", rotCurves[3]); + + // Quaternion 연속성 보장 + clip.EnsureQuaternionContinuity(); + + // 파일명 생성 + string objectName = target.name; + string fileName = $"{SessionID}_{objectName}_Object.anim"; + + // SavePathManager 사용 + string savePath = "Assets/Resources"; // 기본값 + if (SavePathManager.Instance != null) + { + savePath = SavePathManager.Instance.GetObjectSavePath(); + } + + MotionDataRecorder.SafeCreateDirectory(savePath); + + var path = Path.Combine(savePath, fileName); + var uniqueAssetPath = AssetDatabase.GenerateUniqueAssetPath(path); + + AssetDatabase.CreateAsset(clip, uniqueAssetPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log($"오브젝트 애니메이션 파일 저장: {uniqueAssetPath}"); +#endif + } + + // 인스펙터에서 타겟 오브젝트 추가/제거를 위한 헬퍼 메서드 + [ContextMenu("Add Current Selection")] + public void AddCurrentSelection() + { +#if UNITY_EDITOR + var selected = Selection.activeGameObject; + if (selected != null) + { + var newArray = new Transform[targetObjects.Length + 1]; + Array.Copy(targetObjects, newArray, targetObjects.Length); + newArray[targetObjects.Length] = selected.transform; + targetObjects = newArray; + Debug.Log($"오브젝트 추가: {selected.name}"); + } +#endif + } + + [ContextMenu("Clear All Targets")] + public void ClearAllTargets() + { + targetObjects = new Transform[0]; + Debug.Log("모든 타겟 오브젝트 제거"); + } + + // 타겟 오브젝트 배열 접근자 + public Transform[] TargetObjects => targetObjects; + public bool IsRecording => isRecording; + public float RecordedTime => recordedTime; + } +} \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs.meta new file mode 100644 index 000000000..6ca633851 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/ObjectMotionRecorder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 530f525e71d58a94d9aa9ad830075d54 \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md b/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md new file mode 100644 index 000000000..c92f951d5 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f1ea9e9ccd7d22e9936bbfd0520c5a9155b6e38fa1376cc4c22711f20a8cd74 +size 1997 diff --git a/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md.meta b/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md.meta new file mode 100644 index 000000000..65dfcca7e --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/README_SavePathManager.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d52d79965c0c87f4dbc7d4ea99597abe +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs b/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs new file mode 100644 index 000000000..5e3d5139c --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs @@ -0,0 +1,161 @@ +using UnityEngine; +using System.IO; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace EasyMotionRecorder +{ + public class SavePathManager : MonoBehaviour + { + private static SavePathManager _instance; + public static SavePathManager Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + GameObject go = new GameObject("SavePathManager"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + [Header("저장 경로 설정")] + [SerializeField] private string motionSavePath = "Assets/Resources/Motion"; + [SerializeField] private string facialSavePath = "Assets/Resources/Motion"; + [SerializeField] private string objectSavePath = "Assets/Resources/Motion"; + + [Header("설정")] + [SerializeField] private bool createSubdirectories = true; + + [Header("자동 출력 옵션")] + [SerializeField] private bool exportHumanoidOnSave = false; + [SerializeField] private bool exportGenericOnSave = false; + [SerializeField] private bool exportFBXAsciiOnSave = false; + [SerializeField] private bool exportFBXBinaryOnSave = false; + [SerializeField] private bool exportBipedFBXAsciiOnSave = false; + [SerializeField] private bool exportBipedFBXBinaryOnSave = false; + + public bool ExportHumanoidOnSave => exportHumanoidOnSave; + public bool ExportGenericOnSave => exportGenericOnSave; + public bool ExportFBXAsciiOnSave => exportFBXAsciiOnSave; + public bool ExportFBXBinaryOnSave => exportFBXBinaryOnSave; + public bool ExportBipedFBXAsciiOnSave => exportBipedFBXAsciiOnSave; + public bool ExportBipedFBXBinaryOnSave => exportBipedFBXBinaryOnSave; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + InitializePaths(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private void InitializePaths() + { + if (createSubdirectories) + { + CreateDirectoryIfNotExists(motionSavePath); + CreateDirectoryIfNotExists(facialSavePath); + } + } + + private void CreateDirectoryIfNotExists(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); +#if UNITY_EDITOR + AssetDatabase.Refresh(); +#endif + } + } + + public string GetMotionSavePath() + { + return motionSavePath; + } + + public string GetFacialSavePath() + { + return motionSavePath; // 모션 경로와 동일하게 설정 + } + + public string GetObjectSavePath() + { + return motionSavePath; // 모션 경로와 동일하게 설정 + } + + public void SetMotionSavePath(string path) + { + motionSavePath = path; + if (createSubdirectories) + CreateDirectoryIfNotExists(path); + } + + public void SetFacialSavePath(string path) + { + facialSavePath = path; + if (createSubdirectories) + CreateDirectoryIfNotExists(path); + } + + public void SetObjectSavePath(string path) + { + objectSavePath = path; + if (createSubdirectories) + CreateDirectoryIfNotExists(path); + } + + public void SetCreateSubdirectories(bool create) + { + createSubdirectories = create; + if (create) + { + InitializePaths(); + } + } + + public void ResetToDefaults() + { + motionSavePath = "Assets/Resources/Motion"; + facialSavePath = "Assets/Resources/Motion"; + objectSavePath = "Assets/Resources/Motion"; + createSubdirectories = true; + + // 자동 출력 옵션 초기화 + exportHumanoidOnSave = false; + exportGenericOnSave = false; + exportFBXAsciiOnSave = false; + exportFBXBinaryOnSave = false; + exportBipedFBXAsciiOnSave = false; + exportBipedFBXBinaryOnSave = false; + + InitializePaths(); + } + + public void SynchronizePaths() + { + // 모든 경로를 모션 경로와 동일하게 설정 + facialSavePath = motionSavePath; + objectSavePath = motionSavePath; + if (createSubdirectories) + { + InitializePaths(); + } + } + } +} \ No newline at end of file diff --git a/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs.meta b/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs.meta new file mode 100644 index 000000000..fe401b3a6 --- /dev/null +++ b/Assets/External/EasyMotionRecorder/Scripts/SavePathManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 717b945a8f3f682439ad3d79310cc265 \ No newline at end of file