using UnityEngine; namespace KindRetargeting { /// /// HIK 스타일 2-Pass 접지 시스템. /// /// Pass 1 (OnUpdate, Order 5 → IK 전): /// IK 타겟 위치를 수정하여 Toes가 바닥을 뚫지 않도록 보정. /// /// Pass 2 (OnLateUpdate → IK 후): /// IK 결과의 잔차를 Foot 회전으로 미세 보정. /// [System.Serializable] public class FootGroundingController { [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; private Vector3 leftLocalToesOffset; private Vector3 rightLocalToesOffset; private float leftFootHeight; private float rightFootHeight; private bool leftHasToes; private bool rightHasToes; private float leftPrevAdj; private float rightPrevAdj; private bool isInitialized; public void Initialize(TwoBoneIKSolver ikSolver, Animator animator) { this.ikSolver = ikSolver; this.animator = animator; if (animator == null || !animator.isHuman || ikSolver == 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; 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; } 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 타겟 위치를 수정합니다. /// public void OnUpdate() { 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)); } 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; if (ankleY - groundHeight > activationHeight) return 0f; float weight = groundingWeight * ikWeight; if (!hasToes) { float minAnkleY = groundHeight + footHeight; if (ankleY < minAnkleY) { float adj = (minAnkleY - ankleY) * weight; anklePos.y += adj; ankleTarget.position = anklePos; return adj; } return 0f; } 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) { 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 { if (toesError > 0f) { adjustment = toesError * weight; anklePos.y += adjustment; } } ankleTarget.position = anklePos; } else { float minAnkleY = groundHeight + footHeight; if (ankleY < minAnkleY) { adjustment = (minAnkleY - ankleY) * weight; anklePos.y += adjustment; ankleTarget.position = anklePos; } } return adjustment; } /// /// Pass 2: Post-IK — 잔차를 Foot 회전으로 미세 보정합니다. /// public void OnLateUpdate() { if (!isInitialized || groundingWeight < 0.001f) return; if (leftHasToes) AlignFootToGround(leftFoot, leftToes, ikSolver.leftLeg.positionWeight); if (rightHasToes) AlignFootToGround(rightFoot, rightToes, ikSolver.rightLeg.positionWeight); } 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); } } }