feat: 드로그 기본기2 ActorCore 모션 적용
- ActorCore 펀치/어퍼컷/촙 후보 FBX와 휴머노이드 클립 추출 에디터 창을 추가 - 드로그 강타, 기본기2, 기본기3 스킬 클립과 기본기2 패턴 3타 스텝을 새 모션 흐름에 맞게 갱신 - 미사용 도약/발구르기 파생 클립을 정리하고 아직 참조 중인 클립은 보존 - Unity 세션 부재로 에디터 컴파일과 콘솔 검증은 이번 커밋에서 미수행
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using UnityEditor;
|
||||
|
||||
/// <summary>
|
||||
/// ActorCore 기준 FBX를 참고해 소스 FBX에서 휴머노이드 클립을 추출하는 에디터 윈도우입니다.
|
||||
/// 소스 FBX는 우선 Create From This Model로 재임포트한 뒤, 유효한 휴머노이드 클립만 .anim으로 저장합니다.
|
||||
/// </summary>
|
||||
public sealed class ActorCoreHumanoidClipExtractorWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "Tools/Animation/Extract ActorCore Humanoid Clips";
|
||||
private const string DefaultReferencePath = "Assets/External/Animations/ActorCore/uppercut-l.fbx";
|
||||
private const string DefaultOutputFolder = "Assets/External/Animations/ActorCore/Extracted";
|
||||
|
||||
private UnityEngine.Object referenceFbx;
|
||||
private UnityEngine.Object sourceFbx;
|
||||
private DefaultAsset outputFolder;
|
||||
private bool overwriteExisting = true;
|
||||
private Vector2 scrollPosition;
|
||||
|
||||
private struct ImporterState
|
||||
{
|
||||
public bool importAnimation;
|
||||
public ModelImporterAnimationType animationType;
|
||||
public ModelImporterAvatarSetup avatarSetup;
|
||||
public Avatar sourceAvatar;
|
||||
public ModelImporterClipAnimation[] clipAnimations;
|
||||
}
|
||||
|
||||
[MenuItem(MenuPath, false, 25)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ActorCoreHumanoidClipExtractorWindow>("ActorCore Clip Extractor");
|
||||
window.minSize = new Vector2(520f, 360f);
|
||||
window.InitializeDefaults();
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
InitializeDefaults();
|
||||
}
|
||||
|
||||
private void InitializeDefaults()
|
||||
{
|
||||
if (referenceFbx == null)
|
||||
referenceFbx = AssetDatabase.LoadMainAssetAtPath(DefaultReferencePath);
|
||||
|
||||
if (outputFolder == null)
|
||||
outputFolder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(DefaultOutputFolder);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(8f);
|
||||
EditorGUILayout.LabelField("ActorCore 휴머노이드 클립 추출", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(4f);
|
||||
EditorGUILayout.HelpBox(
|
||||
"기준 FBX의 휴머노이드 구조를 검증용으로 참고하고, 소스 FBX는 Create From This Model로 재임포트한 뒤 "
|
||||
+ "유효한 휴머노이드 클립만 개별 .anim으로 추출합니다.",
|
||||
MessageType.Info);
|
||||
|
||||
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
|
||||
{
|
||||
scrollPosition = scrollView.scrollPosition;
|
||||
|
||||
referenceFbx = EditorGUILayout.ObjectField(
|
||||
"기준 FBX",
|
||||
referenceFbx,
|
||||
typeof(UnityEngine.Object),
|
||||
false);
|
||||
|
||||
sourceFbx = EditorGUILayout.ObjectField(
|
||||
"소스 FBX",
|
||||
sourceFbx,
|
||||
typeof(UnityEngine.Object),
|
||||
false);
|
||||
|
||||
outputFolder = (DefaultAsset)EditorGUILayout.ObjectField(
|
||||
"출력 폴더",
|
||||
outputFolder,
|
||||
typeof(DefaultAsset),
|
||||
false);
|
||||
|
||||
overwriteExisting = EditorGUILayout.ToggleLeft("기존 .anim 덮어쓰기", overwriteExisting);
|
||||
|
||||
EditorGUILayout.Space(12f);
|
||||
DrawSelectionSummary();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8f);
|
||||
|
||||
using (new EditorGUI.DisabledScope(!CanExtract()))
|
||||
{
|
||||
if (GUILayout.Button("추출", GUILayout.Height(34f)))
|
||||
Extract();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelectionSummary()
|
||||
{
|
||||
EditorGUILayout.LabelField("선택 요약", EditorStyles.boldLabel);
|
||||
|
||||
DrawAssetPathLabel("기준 FBX 경로", referenceFbx);
|
||||
DrawAssetPathLabel("소스 FBX 경로", sourceFbx);
|
||||
DrawAssetPathLabel("출력 폴더 경로", outputFolder);
|
||||
|
||||
if (sourceFbx == null)
|
||||
return;
|
||||
|
||||
string sourcePath = AssetDatabase.GetAssetPath(sourceFbx);
|
||||
if (!IsFbxPath(sourcePath))
|
||||
return;
|
||||
|
||||
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(sourcePath)
|
||||
.OfType<AnimationClip>()
|
||||
.ToArray();
|
||||
|
||||
string[] clipNames = clips.Select(clip => clip.name).ToArray();
|
||||
string clipSummary = clipNames.Length > 0
|
||||
? string.Join(", ", clipNames)
|
||||
: "(없음)";
|
||||
|
||||
EditorGUILayout.LabelField("소스 내장 클립", clipSummary, EditorStyles.wordWrappedLabel);
|
||||
}
|
||||
|
||||
private static void DrawAssetPathLabel(string label, UnityEngine.Object asset)
|
||||
{
|
||||
string assetPath = asset != null ? AssetDatabase.GetAssetPath(asset) : "(미선택)";
|
||||
EditorGUILayout.LabelField(label, assetPath, EditorStyles.wordWrappedLabel);
|
||||
}
|
||||
|
||||
private bool CanExtract()
|
||||
{
|
||||
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
return false;
|
||||
|
||||
return IsValidFbx(referenceFbx) && IsValidFbx(sourceFbx) && IsValidFolder(outputFolder);
|
||||
}
|
||||
|
||||
private static bool IsValidFbx(UnityEngine.Object asset)
|
||||
{
|
||||
return asset != null && IsFbxPath(AssetDatabase.GetAssetPath(asset));
|
||||
}
|
||||
|
||||
private static bool IsFbxPath(string assetPath)
|
||||
{
|
||||
return !string.IsNullOrEmpty(assetPath)
|
||||
&& assetPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsValidFolder(DefaultAsset asset)
|
||||
{
|
||||
if (asset == null)
|
||||
return false;
|
||||
|
||||
string path = AssetDatabase.GetAssetPath(asset);
|
||||
return !string.IsNullOrEmpty(path) && AssetDatabase.IsValidFolder(path);
|
||||
}
|
||||
|
||||
private void Extract()
|
||||
{
|
||||
string referencePath = AssetDatabase.GetAssetPath(referenceFbx);
|
||||
string sourcePath = AssetDatabase.GetAssetPath(sourceFbx);
|
||||
string outputPath = AssetDatabase.GetAssetPath(outputFolder);
|
||||
|
||||
var referenceImporter = AssetImporter.GetAtPath(referencePath) as ModelImporter;
|
||||
var sourceImporter = AssetImporter.GetAtPath(sourcePath) as ModelImporter;
|
||||
|
||||
if (referenceImporter == null || sourceImporter == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"ActorCore Clip Extractor",
|
||||
"FBX ModelImporter를 찾지 못했습니다.",
|
||||
"확인");
|
||||
return;
|
||||
}
|
||||
|
||||
int referenceSkeletonCount = referenceImporter.humanDescription.skeleton?.Length ?? 0;
|
||||
TakeInfo[] originalTakeInfos = sourceImporter.importedTakeInfos ?? Array.Empty<TakeInfo>();
|
||||
ImporterState originalState = CaptureImporterState(sourceImporter);
|
||||
|
||||
if (!PrepareSourceImporter(sourceImporter))
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"ActorCore Clip Extractor",
|
||||
"소스 FBX를 휴머노이드 기준으로 준비하지 못했습니다. 콘솔을 확인해 주세요.",
|
||||
"확인");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(sourcePath)
|
||||
.OfType<AnimationClip>()
|
||||
.Where(IsUsableHumanoidClip)
|
||||
.ToArray();
|
||||
|
||||
if (clips.Length == 0)
|
||||
{
|
||||
TakeInfo[] takeInfos = sourceImporter.importedTakeInfos ?? Array.Empty<TakeInfo>();
|
||||
string takeSummary = takeInfos.Length > 0
|
||||
? string.Join(", ", takeInfos.Select(take => take.name))
|
||||
: "(없음)";
|
||||
int preparedSkeletonCount = sourceImporter.humanDescription.skeleton?.Length ?? 0;
|
||||
bool humanoidImportFailed = originalTakeInfos.Length > 0
|
||||
&& takeInfos.Length == 0
|
||||
&& preparedSkeletonCount == 0;
|
||||
string failureReason = humanoidImportFailed
|
||||
? "원본 FBX에는 테이크가 있었지만, 휴머노이드로 임시 재임포트하는 단계에서 Unity가 유효한 Avatar 구조를 만들지 못했습니다.\n"
|
||||
+ "이 경우는 보통 본 매핑 또는 루트 계층이 휴머노이드 조건을 만족하지 않아 `Invalid Avatar Rig Configuration`이 발생한 상황입니다."
|
||||
: "이 경우는 보통 소스 FBX가 Unity에서 애니메이션 테이크를 읽지 못하는 상태입니다.\n"
|
||||
+ "원본 툴에서 애니메이션 포함 상태로 다시 export하거나, Blender에서 NLA/All Actions 기반으로 다시 export해야 합니다.";
|
||||
|
||||
EditorUtility.DisplayDialog(
|
||||
"ActorCore Clip Extractor",
|
||||
"추출 가능한 휴머노이드 클립이 없습니다.\n\n"
|
||||
+ $"원본 테이크 수: {originalTakeInfos.Length}\n"
|
||||
+ $"Unity가 감지한 테이크 수: {takeInfos.Length}\n"
|
||||
+ $"휴머노이드 스켈레톤 수: {preparedSkeletonCount}\n"
|
||||
+ $"테이크 목록: {takeSummary}\n\n"
|
||||
+ failureReason,
|
||||
"확인");
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> createdPaths = new List<string>();
|
||||
|
||||
AssetDatabase.StartAssetEditing();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (AnimationClip clip in clips)
|
||||
{
|
||||
string fileName = SanitizeClipName(clip.name);
|
||||
string targetPath = $"{outputPath}/{fileName}.anim";
|
||||
|
||||
if (!overwriteExisting)
|
||||
targetPath = AssetDatabase.GenerateUniqueAssetPath(targetPath);
|
||||
|
||||
AnimationClip existingClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(targetPath);
|
||||
AnimationClip copiedClip = UnityEngine.Object.Instantiate(clip);
|
||||
copiedClip.name = fileName;
|
||||
|
||||
if (existingClip != null && overwriteExisting)
|
||||
{
|
||||
EditorUtility.CopySerialized(copiedClip, existingClip);
|
||||
EditorUtility.SetDirty(existingClip);
|
||||
}
|
||||
else
|
||||
{
|
||||
AssetDatabase.CreateAsset(copiedClip, targetPath);
|
||||
}
|
||||
|
||||
createdPaths.Add(targetPath);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssetDatabase.StopAssetEditing();
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
|
||||
int sourceSkeletonCount = sourceImporter.humanDescription.skeleton?.Length ?? 0;
|
||||
|
||||
string message = $"기준 스켈레톤 수: {referenceSkeletonCount}\n"
|
||||
+ $"소스 스켈레톤 수: {sourceSkeletonCount}\n"
|
||||
+ $"추출 클립 수: {createdPaths.Count}\n\n"
|
||||
+ string.Join("\n", createdPaths);
|
||||
|
||||
Debug.Log($"[ActorCoreHumanoidClipExtractor] {message}");
|
||||
EditorUtility.DisplayDialog("ActorCore Clip Extractor", message, "확인");
|
||||
}
|
||||
finally
|
||||
{
|
||||
RestoreImporterState(sourceImporter, originalState);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PrepareSourceImporter(ModelImporter importer)
|
||||
{
|
||||
if (importer == null)
|
||||
return false;
|
||||
|
||||
importer.importAnimation = true;
|
||||
importer.animationType = ModelImporterAnimationType.Human;
|
||||
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
|
||||
importer.sourceAvatar = null;
|
||||
importer.SaveAndReimport();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ImporterState CaptureImporterState(ModelImporter importer)
|
||||
{
|
||||
return new ImporterState
|
||||
{
|
||||
importAnimation = importer.importAnimation,
|
||||
animationType = importer.animationType,
|
||||
avatarSetup = importer.avatarSetup,
|
||||
sourceAvatar = importer.sourceAvatar,
|
||||
clipAnimations = CloneClipAnimations(importer.clipAnimations),
|
||||
};
|
||||
}
|
||||
|
||||
private static void RestoreImporterState(ModelImporter importer, ImporterState state)
|
||||
{
|
||||
if (importer == null)
|
||||
return;
|
||||
|
||||
importer.importAnimation = state.importAnimation;
|
||||
importer.animationType = state.animationType;
|
||||
importer.avatarSetup = state.avatarSetup;
|
||||
importer.sourceAvatar = state.sourceAvatar;
|
||||
importer.clipAnimations = state.clipAnimations ?? Array.Empty<ModelImporterClipAnimation>();
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
private static ModelImporterClipAnimation[] CloneClipAnimations(ModelImporterClipAnimation[] clips)
|
||||
{
|
||||
if (clips == null || clips.Length == 0)
|
||||
return Array.Empty<ModelImporterClipAnimation>();
|
||||
|
||||
return clips.Select(clip => new ModelImporterClipAnimation
|
||||
{
|
||||
name = clip.name,
|
||||
takeName = clip.takeName,
|
||||
firstFrame = clip.firstFrame,
|
||||
lastFrame = clip.lastFrame,
|
||||
loopTime = clip.loopTime,
|
||||
loopPose = clip.loopPose,
|
||||
cycleOffset = clip.cycleOffset,
|
||||
lockRootRotation = clip.lockRootRotation,
|
||||
lockRootHeightY = clip.lockRootHeightY,
|
||||
lockRootPositionXZ = clip.lockRootPositionXZ,
|
||||
keepOriginalOrientation = clip.keepOriginalOrientation,
|
||||
keepOriginalPositionY = clip.keepOriginalPositionY,
|
||||
keepOriginalPositionXZ = clip.keepOriginalPositionXZ,
|
||||
heightFromFeet = clip.heightFromFeet,
|
||||
mirror = clip.mirror,
|
||||
maskType = clip.maskType,
|
||||
additiveReferencePoseFrame = clip.additiveReferencePoseFrame,
|
||||
hasAdditiveReferencePose = clip.hasAdditiveReferencePose,
|
||||
wrapMode = clip.wrapMode,
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static bool IsUsableHumanoidClip(AnimationClip clip)
|
||||
{
|
||||
if (clip == null)
|
||||
return false;
|
||||
|
||||
if (clip.name.StartsWith("__preview__", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
if (!clip.humanMotion)
|
||||
return false;
|
||||
|
||||
if (clip.empty)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string SanitizeClipName(string clipName)
|
||||
{
|
||||
string sanitized = clipName;
|
||||
int separatorIndex = sanitized.LastIndexOf('|');
|
||||
if (separatorIndex >= 0 && separatorIndex + 1 < sanitized.Length)
|
||||
sanitized = sanitized.Substring(separatorIndex + 1);
|
||||
|
||||
foreach (char invalid in Path.GetInvalidFileNameChars())
|
||||
sanitized = sanitized.Replace(invalid, '_');
|
||||
|
||||
return sanitized.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d1baf05ee0d5d7cf838a1599adbb71d
|
||||
Reference in New Issue
Block a user