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

View File

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

View File

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