diff --git a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs index 56e62c5e7..9a3fc9256 100644 --- a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs +++ b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs @@ -10,6 +10,8 @@ namespace KindRetargeting { private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; + private SerializedObject groundingSO; + // SerializedProperty private SerializedProperty sourceAnimatorProp; private SerializedProperty targetAnimatorProp; @@ -28,6 +30,12 @@ namespace KindRetargeting // Dynamic UI private Label cacheStatusLabel; + protected override void OnDisable() + { + base.OnDisable(); + groundingSO = null; + } + protected override void OnEnable() { base.OnEnable(); @@ -194,7 +202,7 @@ namespace KindRetargeting var grounding = script.GetComponent(); if (grounding != null) { - var groundingSO = new SerializedObject(grounding); + groundingSO = new SerializedObject(grounding); var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이"); foldout.Add(groundHeightField); diff --git a/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs b/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs index 63882c4d3..5c5cf6351 100644 --- a/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs +++ b/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs @@ -87,6 +87,7 @@ public class RetargetingControlWindow : EditorWindow { EditorApplication.update -= OnEditorUpdate; Undo.undoRedoPerformed -= OnUndoRedoPerformed; + if (characterContainer != null) characterContainer.Unbind(); DisposeTrackedSerializedObjects(); }); @@ -116,8 +117,8 @@ public class RetargetingControlWindow : EditorWindow private void DisposeTrackedSerializedObjects() { - foreach (var so in trackedSerializedObjects) - so?.Dispose(); + // Dispose하지 않고 참조만 해제 → GC가 바인딩 시스템 정리 후 자연 수거 + // 직접 Dispose하면 UI Toolkit 바인딩 큐에 남은 pending 요청이 죽은 SO에 접근하여 NullReferenceException 발생 trackedSerializedObjects.Clear(); } @@ -158,8 +159,10 @@ public class RetargetingControlWindow : EditorWindow private void RebuildCharacterPanels() { if (characterContainer == null) return; - characterContainer.Clear(); + // 바인딩 해제 후 Clear → Dispose 순서로 처리해야 NullReferenceException 방지 + characterContainer.Unbind(); DisposeTrackedSerializedObjects(); + characterContainer.Clear(); if (retargetingScripts == null || retargetingScripts.Length == 0) { @@ -340,7 +343,8 @@ public class RetargetingControlWindow : EditorWindow container.schedule.Execute(() => { - if (so == null || so.targetObject == null) return; + try { if (so == null || so.targetObject == null) return; } + catch (System.Exception) { return; } so.Update(); var normProp = so.FindProperty("debugAxisNormalizer"); if (normProp == null || !Application.isPlaying) { axisLabel.text = ""; return; } @@ -497,7 +501,8 @@ public class RetargetingControlWindow : EditorWindow // 비활성 시 숨김 handFoldout.schedule.Execute(() => { - if (fso == null || fso.targetObject == null) return; + try { if (fso == null || fso.targetObject == null) return; } + catch (System.Exception) { return; } fso.Update(); bool enabled = fso.FindProperty($"{prefix}HandEnabled").boolValue; slidersRow.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None; @@ -758,7 +763,7 @@ public class RetargetingControlWindow : EditorWindow return; } AutoCalibrateAll(script, so); - }) { text = "전체 자동 보정 (크기 + 힙 높이)", tooltip = "소스/타겟 목 높이 비율로 아바타 크기를 맞추고, 다리 길이 차이로 힙 높이를 자동 보정합니다. (플레이 모드 전용)" }; + }) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "소스/타겟 목 높이 비율로 아바타 크기를 맞추고, 다리 길이 차이로 힙 높이를 자동 보정하고, 머리 정면 캘리브레이션을 수행합니다. (플레이 모드 전용)" }; autoBtn.style.marginTop = 4; autoBtn.style.height = 28; box.Add(autoBtn); @@ -889,58 +894,57 @@ public class RetargetingControlWindow : EditorWindow return; } - // 1. 아바타 크기를 1로 리셋 (즉시 적용) + // ── 프레임 1: 스케일 리셋 + 다리 보정 ── script.ResetScale(); var scaleProp = so.FindProperty("avatarScale"); scaleProp.floatValue = 1f; + so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); so.ApplyModifiedProperties(); - // 2. 다리 길이 자동 보정 (스케일 1 상태) - float hipsOffset0 = CalculateHipsOffsetFromLegDifference(script); - so.FindProperty("hipsOffsetY").floatValue = hipsOffset0; - so.ApplyModifiedProperties(); - - // 3. 목 높이 측정 - Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck); - Transform targetNeck = target.GetBoneTransform(HumanBodyBones.Neck); - - if (sourceNeck == null || targetNeck == null) - { - Debug.LogWarning("목 본을 찾을 수 없습니다."); - return; - } - - float sourceNeckY = sourceNeck.position.y; - float targetNeckY = targetNeck.position.y; - - if (targetNeckY < 0.01f) - { - Debug.LogWarning("타겟 목 높이가 0에 가깝습니다."); - return; - } - - // 4. 소스/타겟 비율 → avatarScale - float scaleRatio = Mathf.Clamp(sourceNeckY / targetNeckY, 0.1f, 3f); - script.SetAvatarScale(scaleRatio); - scaleProp.floatValue = scaleRatio; - so.ApplyModifiedProperties(); - - Debug.Log($"크기 보정: 소스 목 Y={sourceNeckY:F4}, 타겟 목 Y={targetNeckY:F4} → avatarScale = {scaleRatio:F3}"); - - // 5. 스케일 적용 후 다리 길이 자동 보정 + // ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ── EditorApplication.delayCall += () => { if (script == null) return; - // delayCall 시점에서 캡처된 so가 Dispose되었을 수 있으므로 새로 생성 - var freshSo = new SerializedObject(script); - float hipsOffset = CalculateHipsOffsetFromLegDifference(script); - freshSo.FindProperty("hipsOffsetY").floatValue = hipsOffset; - freshSo.ApplyModifiedProperties(); - freshSo.Dispose(); + Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck); + Transform targetNeck = target.GetBoneTransform(HumanBodyBones.Neck); + if (sourceNeck == null || targetNeck == null) { Debug.LogWarning("목 본을 찾을 수 없습니다."); return; } - script.SaveSettings(); - Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {hipsOffset:F4}m"); + float sourceNeckY = sourceNeck.position.y; + float targetNeckY = targetNeck.position.y; + if (targetNeckY < 0.01f) { Debug.LogWarning("타겟 목 높이가 0에 가깝습니다."); return; } + + float scaleRatio = Mathf.Clamp(sourceNeckY / targetNeckY, 0.1f, 3f); + script.SetAvatarScale(scaleRatio); + + var so2 = new SerializedObject(script); + so2.FindProperty("avatarScale").floatValue = scaleRatio; + so2.ApplyModifiedProperties(); + so2.Dispose(); + + Debug.Log($"크기 보정: 소스 목 Y={sourceNeckY:F4}, 타겟 목 Y={targetNeckY:F4} → avatarScale = {scaleRatio:F3}"); + + // ── 프레임 3: 스케일 반영 후 다리 재보정 + 머리 정면 캘리 + 저장 ── + EditorApplication.delayCall += () => + { + if (script == null) return; + + var so3 = new SerializedObject(script); + float finalHipsOffset = CalculateHipsOffsetFromLegDifference(script); + so3.FindProperty("hipsOffsetY").floatValue = finalHipsOffset; + + var xProp = so3.FindProperty("headRotationOffsetX"); + var yProp = so3.FindProperty("headRotationOffsetY"); + var zProp = so3.FindProperty("headRotationOffsetZ"); + if (xProp != null && yProp != null && zProp != null) + CalibrateHeadToForward(so3, xProp, yProp, zProp); + + so3.ApplyModifiedProperties(); + so3.Dispose(); + + script.SaveSettings(); + Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {finalHipsOffset:F4}m"); + }; }; }