- SerializedObject 직접 Dispose 대신 GC 자연 수거로 변경 (바인딩 큐 충돌 방지) - 전체 자동 보정을 3프레임 순차 실행으로 변경 (스케일/리타게팅 반영 대기) - 전체 자동 보정에 머리 정면 캘리브레이션 포함 - schedule 콜백에 Dispose된 SO 접근 방어 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
16 KiB
C#
343 lines
16 KiB
C#
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using UnityEditor.UIElements;
|
|
|
|
namespace KindRetargeting
|
|
{
|
|
[CustomEditor(typeof(CustomRetargetingScript))]
|
|
public class CustomRetargetingScriptEditor : BaseRetargetingEditor
|
|
{
|
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
|
|
|
private SerializedObject groundingSO;
|
|
|
|
// SerializedProperty
|
|
private SerializedProperty sourceAnimatorProp;
|
|
private SerializedProperty targetAnimatorProp;
|
|
private SerializedProperty hipsOffsetXProp;
|
|
private SerializedProperty hipsOffsetYProp;
|
|
private SerializedProperty hipsOffsetZProp;
|
|
private SerializedProperty debugAxisNormalizerProp;
|
|
private SerializedProperty fingerCopyModeProp;
|
|
private SerializedProperty kneeInOutWeightProp;
|
|
private SerializedProperty kneeFrontBackWeightProp;
|
|
private SerializedProperty footFrontBackOffsetProp;
|
|
private SerializedProperty footInOutOffsetProp;
|
|
private SerializedProperty floorHeightProp;
|
|
private SerializedProperty avatarScaleProp;
|
|
|
|
// Dynamic UI
|
|
private Label cacheStatusLabel;
|
|
|
|
protected override void OnDisable()
|
|
{
|
|
base.OnDisable();
|
|
groundingSO = null;
|
|
}
|
|
|
|
protected override void OnEnable()
|
|
{
|
|
base.OnEnable();
|
|
sourceAnimatorProp = serializedObject.FindProperty("sourceAnimator");
|
|
targetAnimatorProp = serializedObject.FindProperty("targetAnimator");
|
|
hipsOffsetXProp = serializedObject.FindProperty("hipsOffsetX");
|
|
hipsOffsetYProp = serializedObject.FindProperty("hipsOffsetY");
|
|
hipsOffsetZProp = serializedObject.FindProperty("hipsOffsetZ");
|
|
debugAxisNormalizerProp = serializedObject.FindProperty("debugAxisNormalizer");
|
|
fingerCopyModeProp = serializedObject.FindProperty("fingerCopyMode");
|
|
kneeInOutWeightProp = serializedObject.FindProperty("kneeInOutWeight");
|
|
kneeFrontBackWeightProp = serializedObject.FindProperty("kneeFrontBackWeight");
|
|
footFrontBackOffsetProp = serializedObject.FindProperty("footFrontBackOffset");
|
|
footInOutOffsetProp = serializedObject.FindProperty("footInOutOffset");
|
|
floorHeightProp = serializedObject.FindProperty("floorHeight");
|
|
avatarScaleProp = serializedObject.FindProperty("avatarScale");
|
|
}
|
|
|
|
public override VisualElement CreateInspectorGUI()
|
|
{
|
|
var root = new VisualElement();
|
|
|
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
|
|
|
// 원본 Animator
|
|
root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator"));
|
|
|
|
// 아바타 크기 설정
|
|
var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true };
|
|
scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기"));
|
|
root.Add(scaleFoldout);
|
|
|
|
// 힙 위치 보정
|
|
root.Add(BuildHipsSection());
|
|
|
|
// 무릎 위치 조정
|
|
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = true };
|
|
kneeFoldout.Add(new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 뒤로, 양수: 앞으로" });
|
|
kneeFoldout.Q<Slider>().BindProperty(kneeFrontBackWeightProp);
|
|
var kneeInOut = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 안쪽, 양수: 바깥쪽" };
|
|
kneeInOut.BindProperty(kneeInOutWeightProp);
|
|
kneeFoldout.Add(kneeInOut);
|
|
root.Add(kneeFoldout);
|
|
|
|
// 발 IK 위치 조정
|
|
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true };
|
|
var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" };
|
|
footFB.BindProperty(footFrontBackOffsetProp);
|
|
footFoldout.Add(footFB);
|
|
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" };
|
|
footIO.BindProperty(footInOutOffsetProp);
|
|
footFoldout.Add(footIO);
|
|
root.Add(footFoldout);
|
|
|
|
// 손가락 복제 설정
|
|
root.Add(BuildFingerCopySection());
|
|
|
|
// 바닥 높이 조정
|
|
var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false };
|
|
floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)"));
|
|
|
|
root.Add(floorFoldout);
|
|
|
|
// 접지 설정 (FootGroundingController)
|
|
root.Add(BuildGroundingSection());
|
|
|
|
// 캐시 상태 + 캘리브레이션 버튼
|
|
root.Add(BuildCacheSection());
|
|
|
|
// 변경 시 저장
|
|
root.TrackSerializedObjectValue(serializedObject, so =>
|
|
{
|
|
if (target == null) return;
|
|
var script = (CustomRetargetingScript)target;
|
|
if (script.targetAnimator != null)
|
|
script.SaveSettings();
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
private VisualElement BuildHipsSection()
|
|
{
|
|
var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" };
|
|
|
|
// 축 매핑 정보
|
|
var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info);
|
|
foldout.Add(axisInfo);
|
|
|
|
// 주기적으로 축 매핑 정보 갱신
|
|
foldout.schedule.Execute(() =>
|
|
{
|
|
if (target == null || debugAxisNormalizerProp == null) return;
|
|
serializedObject.Update();
|
|
Vector3 axisMapping = debugAxisNormalizerProp.vector3Value;
|
|
|
|
if (Application.isPlaying && axisMapping != Vector3.one)
|
|
{
|
|
string GetAxisName(float value)
|
|
{
|
|
int axis = Mathf.RoundToInt(Mathf.Abs(value));
|
|
string sign = value > 0 ? "+" : "-";
|
|
return axis switch { 1 => $"{sign}X", 2 => $"{sign}Y", 3 => $"{sign}Z", _ => "?" };
|
|
}
|
|
axisInfo.text = "T-포즈에서 분석된 축 매핑:\n" +
|
|
$" 좌우 오프셋 → 로컬 {GetAxisName(axisMapping.x)} 축\n" +
|
|
$" 상하 오프셋 → 로컬 {GetAxisName(axisMapping.y)} 축\n" +
|
|
$" 앞뒤 오프셋 → 로컬 {GetAxisName(axisMapping.z)} 축\n\n" +
|
|
"이 매핑 덕분에 모든 아바타에서 동일하게 작동합니다.";
|
|
}
|
|
else
|
|
{
|
|
axisInfo.text = "플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.";
|
|
}
|
|
}).Every(500);
|
|
|
|
foldout.Add(new PropertyField(hipsOffsetXProp, "좌우 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 왼쪽(-) / 오른쪽(+)" });
|
|
foldout.Add(new PropertyField(hipsOffsetYProp, "상하 오프셋 (↓-/+↑)") { tooltip = "캐릭터 기준 아래(-) / 위(+)" });
|
|
foldout.Add(new PropertyField(hipsOffsetZProp, "앞뒤 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 뒤(-) / 앞(+)" });
|
|
foldout.Add(new HelpBox("로컬 좌표계 기반: 캐릭터의 회전 상태와 관계없이 항상 캐릭터 기준으로 이동합니다.", HelpBoxMessageType.Info));
|
|
|
|
// 다리 길이 자동 보정 버튼
|
|
var autoHipsBtn = new Button(() =>
|
|
{
|
|
if (!Application.isPlaying)
|
|
{
|
|
Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다.");
|
|
return;
|
|
}
|
|
var script = (CustomRetargetingScript)target;
|
|
float offset = CalculateHipsOffsetFromLegDifference(script);
|
|
hipsOffsetYProp.floatValue = offset;
|
|
serializedObject.ApplyModifiedProperties();
|
|
script.SaveSettings();
|
|
Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}");
|
|
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" };
|
|
autoHipsBtn.style.marginTop = 4;
|
|
foldout.Add(autoHipsBtn);
|
|
|
|
return foldout;
|
|
}
|
|
|
|
private VisualElement BuildFingerCopySection()
|
|
{
|
|
var foldout = new Foldout { text = "손가락 복제 설정" };
|
|
foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." });
|
|
return foldout;
|
|
}
|
|
|
|
private VisualElement BuildGroundingSection()
|
|
{
|
|
var foldout = new Foldout { text = "접지 설정 (FootGrounding)", value = false };
|
|
|
|
foldout.Add(new HelpBox(
|
|
"HIK 스타일 2-Pass 접지 시스템:\n" +
|
|
"• Pre-IK: IK 타겟을 조정하여 발이 바닥을 뚫지 않도록 보정\n" +
|
|
"• Post-IK: Foot 회전으로 Toes 접지 잔차 미세 보정\n" +
|
|
"• Toe Pivot: 발끝 고정 + 발목 회전 자동 감지",
|
|
HelpBoxMessageType.Info));
|
|
|
|
// FootGroundingController의 SerializedObject를 직접 바인딩
|
|
var script = (CustomRetargetingScript)target;
|
|
var grounding = script.GetComponent<FootGroundingController>();
|
|
if (grounding != null)
|
|
{
|
|
groundingSO = new SerializedObject(grounding);
|
|
|
|
var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이");
|
|
foldout.Add(groundHeightField);
|
|
|
|
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
|
|
weightSlider.BindProperty(groundingSO.FindProperty("groundingWeight"));
|
|
foldout.Add(weightSlider);
|
|
|
|
var activationField = new PropertyField(groundingSO.FindProperty("activationHeight"), "활성화 높이");
|
|
activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)";
|
|
foldout.Add(activationField);
|
|
|
|
var thresholdField = new PropertyField(groundingSO.FindProperty("plantThreshold"), "접지 판정 범위");
|
|
thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정";
|
|
foldout.Add(thresholdField);
|
|
|
|
var smoothField = new PropertyField(groundingSO.FindProperty("smoothSpeed"), "보정 스무딩 속도");
|
|
smoothField.tooltip = "보정량 변화 속도 (높을수록 빠른 반응, 낮으면 부드러운 전환)";
|
|
foldout.Add(smoothField);
|
|
|
|
foldout.Add(new HelpBox("힙 높이 보정은 '바닥 높이 조정' 섹션의 floorHeight로 제어합니다.", HelpBoxMessageType.Info));
|
|
|
|
foldout.TrackSerializedObjectValue(groundingSO, so => so.ApplyModifiedProperties());
|
|
}
|
|
else
|
|
{
|
|
foldout.Add(new HelpBox("FootGroundingController 컴포넌트를 찾을 수 없습니다.", HelpBoxMessageType.Warning));
|
|
}
|
|
|
|
return foldout;
|
|
}
|
|
|
|
private VisualElement BuildCacheSection()
|
|
{
|
|
var container = new VisualElement { style = { marginTop = 8 } };
|
|
|
|
var script = (CustomRetargetingScript)target;
|
|
bool hasCached = script.HasCachedSettings();
|
|
|
|
cacheStatusLabel = new Label(hasCached ?
|
|
"캘리브레이션 데이터가 저장되어 있습니다." :
|
|
"저장된 캘리브레이션 데이터가 없습니다.");
|
|
cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
|
container.Add(cacheStatusLabel);
|
|
|
|
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
|
|
|
|
var calibBtn = new Button(() =>
|
|
{
|
|
((CustomRetargetingScript)target).I_PoseCalibration();
|
|
UpdateCacheStatus();
|
|
}) { text = "I-포즈 캘리브레이션" };
|
|
calibBtn.style.flexGrow = 1;
|
|
calibBtn.style.marginRight = 2;
|
|
btnRow.Add(calibBtn);
|
|
|
|
var deleteCacheBtn = new Button(() =>
|
|
{
|
|
((CustomRetargetingScript)target).ResetPoseAndCache();
|
|
UpdateCacheStatus();
|
|
}) { text = "캐시 데이터 삭제" };
|
|
deleteCacheBtn.style.flexGrow = 1;
|
|
btnRow.Add(deleteCacheBtn);
|
|
|
|
container.Add(btnRow);
|
|
|
|
// 주기적으로 캐시 상태 갱신
|
|
container.schedule.Execute(() =>
|
|
{
|
|
if (target == null) return;
|
|
bool cached = ((CustomRetargetingScript)target).HasCachedSettings();
|
|
deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
|
|
cacheStatusLabel.text = cached ?
|
|
"캘리브레이션 데이터가 저장되어 있습니다." :
|
|
"저장된 캘리브레이션 데이터가 없습니다.";
|
|
}).Every(1000);
|
|
|
|
deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None;
|
|
|
|
return container;
|
|
}
|
|
|
|
private void UpdateCacheStatus()
|
|
{
|
|
if (cacheStatusLabel == null || target == null) return;
|
|
bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings();
|
|
cacheStatusLabel.text = hasCached ?
|
|
"캘리브레이션 데이터가 저장되어 있습니다." :
|
|
"저장된 캘리브레이션 데이터가 없습니다.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다.
|
|
/// 소스 다리가 더 길면 → 음수 (힙을 내려서 타겟이 뜨지 않게)
|
|
/// 소스 다리가 더 짧으면 → 양수 (힙을 올려서 타겟 다리를 펴줌)
|
|
/// </summary>
|
|
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
|
{
|
|
Animator source = script.sourceAnimator;
|
|
Animator target = script.targetAnimator;
|
|
|
|
if (source == null || target == null || !source.isHuman || !target.isHuman)
|
|
{
|
|
Debug.LogWarning("소스/타겟 Animator가 없거나 Humanoid가 아닙니다.");
|
|
return 0f;
|
|
}
|
|
|
|
float sourceLeg = GetLegLength(source);
|
|
float targetLeg = GetLegLength(target);
|
|
|
|
if (sourceLeg < 0.01f || targetLeg < 0.01f)
|
|
{
|
|
Debug.LogWarning("다리 길이를 계산할 수 없습니다. 본이 올바르게 설정되어 있는지 확인해주세요.");
|
|
return 0f;
|
|
}
|
|
|
|
// 소스 다리가 더 길면 타겟이 뜨므로 힙을 내려야 함 (음수)
|
|
// 소스 다리가 더 짧으면 타겟 다리가 구부러지므로 힙을 올려야 함 (양수)
|
|
float diff = targetLeg - sourceLeg;
|
|
Debug.Log($"소스 다리 길이: {sourceLeg:F4}, 타겟 다리 길이: {targetLeg:F4}, 힙 오프셋: {diff:F4}m");
|
|
return diff;
|
|
}
|
|
|
|
private float GetLegLength(Animator animator)
|
|
{
|
|
Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
|
Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
|
Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
|
|
|
|
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
|
|
|
|
float upper = Vector3.Distance(upperLeg.position, lowerLeg.position);
|
|
float lower = Vector3.Distance(lowerLeg.position, foot.position);
|
|
return upper + lower;
|
|
}
|
|
}
|
|
}
|