using UnityEngine; namespace KindRetargeting { /// /// HIK 스타일 2-Pass 접지 시스템. /// /// Pass 1 (Update, Order 5 → IK 전): /// IK 타겟 위치를 수정하여 Toes가 바닥을 뚫지 않도록 보정. /// Toe Pivot 감지: 발끝이 바닥에 있고 발목이 올라가면 /// 발목 타겟을 역산하여 Toes가 groundHeight에 고정. /// /// Pass 2 (LateUpdate → IK 후): /// IK 결과의 잔차를 Foot 회전으로 미세 보정. /// 위치 변경 없음 — 본 길이 보존. /// /// 힙 높이 보정은 CRS의 floorHeight가 담당합니다 (이중 보정 방지). /// [DefaultExecutionOrder(5)] public class FootGroundingController : MonoBehaviour { [Header("접지 설정")] [Tooltip("바닥 Y 좌표 (월드 공간)")] public float groundHeight = 0f; [Tooltip("접지 보정 강도")] [Range(0f, 1f)] public float groundingWeight = 1f; [Tooltip("이 높이 이상이면 AIRBORNE (보정 안 함)")] public float activationHeight = 0.5f; [Tooltip("Toes가 이 범위 안이면 접지 중으로 판정")] public float plantThreshold = 0.02f; [Header("스무딩")] [Tooltip("보정량 변화 속도 (높을수록 빠른 반응)")] [Range(1f, 30f)] public float smoothSpeed = 10f; 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() { ikSolver = GetComponent(); animator = GetComponent(); if (animator == null || !animator.isHuman || ikSolver == null) return; if (leftFoot == null && rightFoot == null) return; leftFoot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); rightFoot = animator.GetBoneTransform(HumanBodyBones.RightFoot); leftToes = animator.GetBoneTransform(HumanBodyBones.LeftToes); rightToes = animator.GetBoneTransform(HumanBodyBones.RightToes); if (leftFoot == null || rightFoot == null) return; // Toes 존재 여부 + 캐싱 leftHasToes = leftToes != null; rightHasToes = rightToes != null; if (leftHasToes) { leftLocalToesOffset = leftFoot.InverseTransformPoint(leftToes.position); leftFootHeight = Mathf.Abs(leftFoot.position.y - leftToes.position.y); } else { leftFootHeight = 0.05f; // Toes 없을 때 기본 발목 높이 } if (rightHasToes) { rightLocalToesOffset = rightFoot.InverseTransformPoint(rightToes.position); rightFootHeight = Mathf.Abs(rightFoot.position.y - rightToes.position.y); } else { rightFootHeight = 0.05f; } isInitialized = true; } /// /// Pass 1: Pre-IK — IK 타겟 위치를 수정합니다. /// private void Update() { if (!isInitialized || groundingWeight < 0.001f) return; float leftAdj = AdjustFootTarget( ikSolver.leftLeg, leftLocalToesOffset, leftFootHeight, leftHasToes, ikSolver.leftLeg.positionWeight); float rightAdj = AdjustFootTarget( 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)); } /// /// 발 IK 타겟을 접지 모드에 따라 보정합니다. /// Toes가 없는 아바타는 발목 Y 클램프만 수행합니다. /// private float AdjustFootTarget(TwoBoneIKSolver.LimbIK limb, Vector3 localToesOffset, float footHeight, bool hasToes, float ikWeight) { if (limb.target == null || ikWeight < 0.01f) return 0f; Transform ankleTarget = limb.target; 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; if (ankleY < minAnkleY) { float adj = (minAnkleY - ankleY) * weight; anklePos.y += adj; ankleTarget.position = anklePos; return adj; } return 0f; } // === Toes 있는 아바타: 예측 기반 보정 === Vector3 predictedToesWorld = anklePos + ankleTarget.rotation * localToesOffset; float predictedToesY = predictedToesWorld.y; float adjustment = 0f; if (predictedToesY < groundHeight + plantThreshold) { float toesError = groundHeight - predictedToesY; if (ankleY < groundHeight + footHeight + plantThreshold) { // PLANTED: 발 전체가 바닥 근처 float minAnkleY = groundHeight + footHeight; if (ankleY < minAnkleY) { adjustment = (minAnkleY - ankleY) * weight; anklePos.y += adjustment; } if (toesError > 0f) { float extra = toesError * weight; anklePos.y += extra; adjustment += extra; } } else { // TOE_PIVOT: 발끝 고정, 발목 올라감 if (toesError > 0f) { adjustment = toesError * weight; anklePos.y += adjustment; } } ankleTarget.position = anklePos; } else { // Toes 충분히 위 → 발목만 바닥 아래 방지 float minAnkleY = groundHeight + footHeight; if (ankleY < minAnkleY) { adjustment = (minAnkleY - ankleY) * weight; anklePos.y += adjustment; ankleTarget.position = anklePos; } } return adjustment; } /// /// Pass 2: Post-IK — 잔차를 Foot 회전으로 미세 보정합니다. /// private void LateUpdate() { if (!isInitialized || groundingWeight < 0.001f) return; if (leftHasToes) AlignFootToGround(leftFoot, leftToes, ikSolver.leftLeg.positionWeight); if (rightHasToes) AlignFootToGround(rightFoot, rightToes, ikSolver.rightLeg.positionWeight); } /// /// IK 후 실제 Toes 위치를 확인하고, Foot 본을 pitch 회전하여 잔차 보정. /// 바닥 아래로 뚫린 경우만 보정합니다. /// private void AlignFootToGround(Transform foot, Transform toes, float ikWeight) { if (foot == null || toes == null) return; if (ikWeight < 0.01f) return; if (foot.position.y - groundHeight > activationHeight) return; float weight = groundingWeight * ikWeight; float actualToesY = toes.position.y; float error = actualToesY - groundHeight; if (Mathf.Abs(error) < 0.001f) return; // 바닥 아래로 뚫린 경우만 보정 if (error > plantThreshold) return; Vector3 footToToes = toes.position - foot.position; float horizontalDist = new Vector2(footToToes.x, footToToes.z).magnitude; if (horizontalDist < 0.001f) return; float pitchAngle = Mathf.Atan2(error, horizontalDist) * Mathf.Rad2Deg; Vector3 footForwardFlat = new Vector3(footToToes.x, 0f, footToToes.z).normalized; Vector3 pitchAxis = Vector3.Cross(Vector3.up, footForwardFlat); if (pitchAxis.sqrMagnitude < 0.001f) return; pitchAxis.Normalize(); Quaternion correction = Quaternion.AngleAxis(-pitchAngle, pitchAxis); foot.rotation = Quaternion.Slerp(foot.rotation, correction * foot.rotation, weight); } } }