Refactor : FootGroundingController를 Serializable 모듈로 전환

- FootGroundingController: MonoBehaviour → [Serializable] 클래스
- Start() → Initialize(TwoBoneIKSolver, Animator)
- Update()/LateUpdate() → OnUpdate()/OnLateUpdate()
- CRS에서 footGrounding 필드로 소유, Update/LateUpdate에서 호출
- CustomRetargetingScriptEditor: groundingSO 제거, serializedObject 경로로 접근

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user 2026-03-07 23:06:57 +09:00
parent 64a2069b69
commit 62a5a9bbb5
3 changed files with 39 additions and 67 deletions

View File

@ -98,6 +98,9 @@ namespace KindRetargeting
[Header("어깨 보정")]
[SerializeField] public ShoulderCorrectionFunction shoulderCorrection = new ShoulderCorrectionFunction();
[Header("발 접지")]
[SerializeField] public FootGroundingController footGrounding = new FootGroundingController();
[Header("프랍 부착")]
[SerializeField] public PropLocationController propLocation = new PropLocationController();
@ -328,6 +331,9 @@ namespace KindRetargeting
if (targetAnimator != null)
shoulderCorrection.Initialize(targetAnimator);
// 발 접지 모듈 초기화
footGrounding.Initialize(ikSolver, targetAnimator);
// 프랍 부착 모듈 초기화
if (targetAnimator != null)
propLocation.Initialize(targetAnimator);
@ -831,6 +837,9 @@ namespace KindRetargeting
// 어깨 보정 (기존 ExecutionOrder 3)
shoulderCorrection.OnUpdate();
// 발 접지 Pre-IK (기존 ExecutionOrder 5)
footGrounding.OnUpdate();
// IK 솔버 (기존 ExecutionOrder 6)
ikSolver.OnUpdate();
@ -847,6 +856,9 @@ namespace KindRetargeting
/// </summary>
void LateUpdate()
{
// 발 접지 Post-IK (기존 FootGroundingController LateUpdate)
footGrounding.OnLateUpdate();
ApplyHeadRotationOffset();
ApplyHeadScale();
}

View File

@ -10,8 +10,6 @@ namespace KindRetargeting
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private SerializedObject groundingSO;
// SerializedProperty
private SerializedProperty sourceAnimatorProp;
private SerializedProperty targetAnimatorProp;
@ -33,7 +31,7 @@ namespace KindRetargeting
protected override void OnDisable()
{
base.OnDisable();
groundingSO = null;
// groundingSO 삭제됨 — footGrounding은 CRS 내부 모듈
}
protected override void OnEnable()
@ -197,36 +195,27 @@ namespace KindRetargeting
"• Toe Pivot: 발끝 고정 + 발목 회전 자동 감지",
HelpBoxMessageType.Info));
// FootGroundingController의 SerializedObject를 직접 바인딩
var script = (CustomRetargetingScript)target;
var grounding = script.GetComponent<FootGroundingController>();
if (grounding != null)
{
groundingSO = new SerializedObject(grounding);
// FootGroundingController는 CRS 내부 모듈 — serializedObject의 프로퍼티 경로로 접근
var groundHeightField = new PropertyField(serializedObject.FindProperty("footGrounding.groundHeight"), "바닥 높이");
foldout.Add(groundHeightField);
var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이");
foldout.Add(groundHeightField);
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
weightSlider.BindProperty(serializedObject.FindProperty("footGrounding.groundingWeight"));
foldout.Add(weightSlider);
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
weightSlider.BindProperty(groundingSO.FindProperty("groundingWeight"));
foldout.Add(weightSlider);
var activationField = new PropertyField(serializedObject.FindProperty("footGrounding.activationHeight"), "활성화 높이");
activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)";
foldout.Add(activationField);
var activationField = new PropertyField(groundingSO.FindProperty("activationHeight"), "활성화 높이");
activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)";
foldout.Add(activationField);
var thresholdField = new PropertyField(serializedObject.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위");
thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정";
foldout.Add(thresholdField);
var thresholdField = new PropertyField(groundingSO.FindProperty("plantThreshold"), "접지 판정 범위");
thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정";
foldout.Add(thresholdField);
var smoothField = new PropertyField(serializedObject.FindProperty("footGrounding.smoothSpeed"), "보정 스무딩 속도");
smoothField.tooltip = "보정량 변화 속도 (높을수록 빠른 반응, 낮으면 부드러운 전환)";
foldout.Add(smoothField);
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());
}
foldout.Add(new HelpBox("힙 높이 보정은 '바닥 높이 조정' 섹션의 floorHeight로 제어합니다.", HelpBoxMessageType.Info));
else
{
foldout.Add(new HelpBox("FootGroundingController 컴포넌트를 찾을 수 없습니다.", HelpBoxMessageType.Warning));

View File

@ -5,19 +5,14 @@ namespace KindRetargeting
/// <summary>
/// HIK 스타일 2-Pass 접지 시스템.
///
/// Pass 1 (Update, Order 5 → IK 전):
/// Pass 1 (OnUpdate, Order 5 → IK 전):
/// IK 타겟 위치를 수정하여 Toes가 바닥을 뚫지 않도록 보정.
/// Toe Pivot 감지: 발끝이 바닥에 있고 발목이 올라가면
/// 발목 타겟을 역산하여 Toes가 groundHeight에 고정.
///
/// Pass 2 (LateUpdate → IK 후):
/// Pass 2 (OnLateUpdate → IK 후):
/// IK 결과의 잔차를 Foot 회전으로 미세 보정.
/// 위치 변경 없음 — 본 길이 보존.
///
/// 힙 높이 보정은 CRS의 floorHeight가 담당합니다 (이중 보정 방지).
/// </summary>
[DefaultExecutionOrder(5)]
public class FootGroundingController : MonoBehaviour
[System.Serializable]
public class FootGroundingController
{
[Header("접지 설정")]
[Tooltip("바닥 Y 좌표 (월드 공간)")]
@ -41,38 +36,31 @@ namespace KindRetargeting
private TwoBoneIKSolver ikSolver;
private Animator animator;
// 타겟 아바타 캐싱
private Transform leftFoot;
private Transform rightFoot;
private Transform leftToes;
private Transform rightToes;
// Toes의 Foot 로컬 오프셋 (T-pose에서 캐싱)
private Vector3 leftLocalToesOffset;
private Vector3 rightLocalToesOffset;
// flat 상태에서 발목 최소 높이 (Foot.y - Toes.y)
private float leftFootHeight;
private float rightFootHeight;
// Toes 본 존재 여부
private bool leftHasToes;
private bool rightHasToes;
// 스무딩용: 이전 프레임 보정량
private float leftPrevAdj;
private float rightPrevAdj;
private bool isInitialized;
private void Start()
public void Initialize(TwoBoneIKSolver ikSolver, Animator animator)
{
var crs = GetComponent<CustomRetargetingScript>();
if (crs != null) ikSolver = crs.ikSolver;
animator = GetComponent<Animator>();
this.ikSolver = ikSolver;
this.animator = animator;
if (animator == null || !animator.isHuman || ikSolver == null) return;
if (leftFoot == null && rightFoot == null) return;
leftFoot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
rightFoot = animator.GetBoneTransform(HumanBodyBones.RightFoot);
@ -81,7 +69,6 @@ namespace KindRetargeting
if (leftFoot == null || rightFoot == null) return;
// Toes 존재 여부 + 캐싱
leftHasToes = leftToes != null;
rightHasToes = rightToes != null;
@ -92,7 +79,7 @@ namespace KindRetargeting
}
else
{
leftFootHeight = 0.05f; // Toes 없을 때 기본 발목 높이
leftFootHeight = 0.05f;
}
if (rightHasToes)
@ -111,7 +98,7 @@ namespace KindRetargeting
/// <summary>
/// Pass 1: Pre-IK — IK 타겟 위치를 수정합니다.
/// </summary>
private void Update()
public void OnUpdate()
{
if (!isInitialized || groundingWeight < 0.001f) return;
@ -122,16 +109,11 @@ namespace KindRetargeting
ikSolver.rightLeg, rightLocalToesOffset, rightFootHeight,
rightHasToes, ikSolver.rightLeg.positionWeight);
// 스무딩: 보정량 급변 방지
float dt = Time.deltaTime * smoothSpeed;
leftPrevAdj = Mathf.Lerp(leftPrevAdj, leftAdj, Mathf.Clamp01(dt));
rightPrevAdj = Mathf.Lerp(rightPrevAdj, rightAdj, Mathf.Clamp01(dt));
}
/// <summary>
/// 발 IK 타겟을 접지 모드에 따라 보정합니다.
/// Toes가 없는 아바타는 발목 Y 클램프만 수행합니다.
/// </summary>
private float AdjustFootTarget(TwoBoneIKSolver.LimbIK limb, Vector3 localToesOffset,
float footHeight, bool hasToes, float ikWeight)
{
@ -141,12 +123,10 @@ namespace KindRetargeting
Vector3 anklePos = ankleTarget.position;
float ankleY = anklePos.y;
// AIRBORNE 체크
if (ankleY - groundHeight > activationHeight) return 0f;
float weight = groundingWeight * ikWeight;
// === Toes 없는 아바타: 단순 Y 클램프 ===
if (!hasToes)
{
float minAnkleY = groundHeight + footHeight;
@ -160,7 +140,6 @@ namespace KindRetargeting
return 0f;
}
// === Toes 있는 아바타: 예측 기반 보정 ===
Vector3 predictedToesWorld = anklePos + ankleTarget.rotation * localToesOffset;
float predictedToesY = predictedToesWorld.y;
@ -172,7 +151,6 @@ namespace KindRetargeting
if (ankleY < groundHeight + footHeight + plantThreshold)
{
// PLANTED: 발 전체가 바닥 근처
float minAnkleY = groundHeight + footHeight;
if (ankleY < minAnkleY)
{
@ -189,7 +167,6 @@ namespace KindRetargeting
}
else
{
// TOE_PIVOT: 발끝 고정, 발목 올라감
if (toesError > 0f)
{
adjustment = toesError * weight;
@ -201,7 +178,6 @@ namespace KindRetargeting
}
else
{
// Toes 충분히 위 → 발목만 바닥 아래 방지
float minAnkleY = groundHeight + footHeight;
if (ankleY < minAnkleY)
{
@ -217,7 +193,7 @@ namespace KindRetargeting
/// <summary>
/// Pass 2: Post-IK — 잔차를 Foot 회전으로 미세 보정합니다.
/// </summary>
private void LateUpdate()
public void OnLateUpdate()
{
if (!isInitialized || groundingWeight < 0.001f) return;
@ -227,10 +203,6 @@ namespace KindRetargeting
AlignFootToGround(rightFoot, rightToes, ikSolver.rightLeg.positionWeight);
}
/// <summary>
/// IK 후 실제 Toes 위치를 확인하고, Foot 본을 pitch 회전하여 잔차 보정.
/// 바닥 아래로 뚫린 경우만 보정합니다.
/// </summary>
private void AlignFootToGround(Transform foot, Transform toes, float ikWeight)
{
if (foot == null || toes == null) return;
@ -245,7 +217,6 @@ namespace KindRetargeting
if (Mathf.Abs(error) < 0.001f) return;
// 바닥 아래로 뚫린 경우만 보정
if (error > plantThreshold) return;
Vector3 footToToes = toes.position - foot.position;