Fix : 에디터 SerializedObject 바인딩 NullReferenceException 해결 및 자동 보정 개선

- SerializedObject 직접 Dispose 대신 GC 자연 수거로 변경 (바인딩 큐 충돌 방지)
- 전체 자동 보정을 3프레임 순차 실행으로 변경 (스케일/리타게팅 반영 대기)
- 전체 자동 보정에 머리 정면 캘리브레이션 포함
- schedule 콜백에 Dispose된 SO 접근 방어 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user 2026-03-07 00:33:07 +09:00
parent 14874d5b6e
commit b0c967e1fd
2 changed files with 61 additions and 49 deletions

View File

@ -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<FootGroundingController>();
if (grounding != null)
{
var groundingSO = new SerializedObject(grounding);
groundingSO = new SerializedObject(grounding);
var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이");
foldout.Add(groundHeightField);

View File

@ -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; }
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 = {hipsOffset:F4}m");
Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {finalHipsOffset:F4}m");
};
};
}