// Copyright (C) 2023 Nicholas Maltbie // // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and // associated documentation files (the "Software"), to deal in the Software without restriction, // including without limitation the rights to use, copy, modify, merge, publish, distribute, // sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING // BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, // ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using nickmaltbie.OpenKCC.Utils; using nickmaltbie.TestUtilsUnity; using UnityEngine; namespace nickmaltbie.OpenKCC.Animation { /// /// Script to attach player feet to ground as the player /// moves around naturally. /// [RequireComponent(typeof(Animator))] public class HumanoidFootIK : MonoBehaviour { /// /// Array of each kind of foot associated with a human character. /// public static readonly Foot[] Feet = new Foot[] { Foot.LeftFoot, Foot.RightFoot }; /// /// Animator component for managing the player avatar. /// private Animator animator; /// /// Current speed at which the hips are moving to match the grounded /// position. /// private float hipOffsetSpeed; /// /// The current offset of the hip location from the current /// grounded location. /// private float hipOffset; /// /// Unity service for managing time delta. /// internal IUnityService unityService = UnityService.Instance; /// /// Raycast helper for mocking raycast behavior. /// internal IRaycastHelper raycastHelper = RaycastHelper.Instance; /// /// Layer for calculating collisions with ground. /// [Tooltip("Layer for calculating collisions with ground.")] public LayerMask feetCollisionDetection = -1; /// /// Distance at which this will check to the ground from the player's /// knee height. /// [Tooltip("Distance to check for ground from knee height.")] public float groundCheckDist = 1.5f; /// /// How high player will lift feet when stepping in place. /// [Tooltip("How high player will lift feet when stepping in place.")] public float stepHeight = 0.1f; /// /// Distance threshold above which the player will release /// their foot from the ground. This is the distance between the current /// target/desired position and the current position the foot is on the /// ground. /// [Tooltip("Distance threshold above which the player will release their grounded feet.")] public float strideThresholdDistance = 0.75f; /// /// Degrees of rotation above which the player will rotate their foot /// when their foot is a given number of degrees away from their /// current facing. /// [Tooltip("Degree threshold above which the player will rotate and replace their feet.")] public float strideThresholdDegrees = 45; /// /// Time for the player to complete a grounded stride. /// [Tooltip("Time needed to complete a grounded stride.")] public float strideTime = 0.25f; /// /// How far off the ground are the feet bones when the player's foot /// is grounded. /// [Tooltip("Height offset for feet when grounded.")] public float footGroundedHeight = 0.05f; /// /// Max distance the player's foot should be from the hips, above /// which the player will over their hips down to properly place /// feet on the ground. /// [Tooltip("Max distance the player's foot should be from the hips.")] public float maxHipFootDistance = 0.85f; /// /// Max distance player can raise or lower hips. /// [Tooltip("Max distance player can raise or lower hips.")] public float maxHipOffset = 0.45f; /// /// Time to take to sooth hip offset from the ground. /// [Tooltip("Time to take to sooth hip offset from the ground.")] public float hipSmoothTime = 0.35f; /// /// Time to blend position between world position and overlap position. /// [Tooltip("Time to correct overlapping foot position.")] public float overlapBlendTime = 0.05f; /// /// Test debug value to disable correcting for overlap. /// internal bool correctForOverlap = true; /// /// Left foot target for player. /// public FootTarget LeftFootTarget { get; private set; } /// /// Right foot target for player. /// public FootTarget RightFootTarget { get; private set; } /// /// Gest the most recent stride time between /// the left and right foot targets for this avatar. /// public float MostRecentStrideTime => Mathf.Max(LeftFootTarget.StrideStartTime, RightFootTarget.StrideStartTime); /// /// Gets if the player can take a stride with their feet right now. /// This checks that neither foot has taken a stride within the past /// stride time threshold. /// public bool CanTakeStride => (MostRecentStrideTime + strideTime) <= unityService.time; /// /// Configure and setup the humanoid foot ik controller. /// public void Awake() { animator = GetComponent(); SetupTargets(); } /// /// On validate function to update values when they are changed /// in the editor. /// public void OnValidate() { SetupTargets(); } /// /// Setup the individual foot targets based on the configuration values. /// private void SetupTargets() { LeftFootTarget = new FootTarget(Foot.LeftFoot, animator, stepHeight, strideTime, footGroundedHeight); RightFootTarget = new FootTarget(Foot.RightFoot, animator, stepHeight, strideTime, footGroundedHeight); LeftFootTarget.unityService = unityService; RightFootTarget.unityService = unityService; } /// /// Gets the foot target based on the selection. /// /// Foot to select (right or left). /// FootTarget for managing that foot's grounded state. public FootTarget GetFootTarget(Foot foot) { if (foot == Foot.LeftFoot) { return LeftFootTarget; } else if (foot == Foot.RightFoot) { return RightFootTarget; } return null; } /// /// Update called each frame to lerp the foot ik weights. /// public void Update() { foreach (Foot foot in Feet) { GetFootTarget(foot).LerpFootIKWeight(); } } /// /// Update the feet positions based on some current delta /// in position for moving ground targets. /// /// Delta position in world space to move the feet. public void UpdateFeetPositions(Vector3 deltaPos) { if (deltaPos.magnitude <= KCCUtils.Epsilon) { return; } foreach (Foot foot in Feet) { UpdateFootPosition(foot, deltaPos); } } /// /// Called each time the animator is updated. This is used to get the /// currently desired position of the feet from the animator's current /// animation then update the foot based on its current state. /// /// Layer index for the animator. public void OnAnimatorIK(int layerIndex) { UpdateFootTargets(); // Move hips according to target positions float targetHipOffset = Mathf.Clamp(GetTargetHipOffset(), -maxHipOffset, 0); hipOffset = Mathf.SmoothDamp(hipOffset, targetHipOffset, ref hipOffsetSpeed, hipSmoothTime, Mathf.Infinity, unityService.deltaTime); transform.localPosition = Vector3.up * hipOffset; } /// /// Update the foot target position for a specific foot. /// /// foot to update. /// Delta position in world space to move the feet. public void UpdateFootPosition(Foot foot, Vector3 deltaPos) { FootTarget target = GetFootTarget(foot); if (target.State != FootState.Grounded) { return; } Vector3 newPos = target.TargetFootPosition + deltaPos; // If we're overlapping with something, move the foot back if (VerifySpotOverlap(newPos, out IRaycastHit hit)) { newPos = hit.point; } // Recompute angle for the foot as well and check if grounded target has changed. HumanBodyBones kneeBone = foot == Foot.LeftFoot ? HumanBodyBones.LeftLowerLeg : HumanBodyBones.RightLowerLeg; Transform kneeTransform = animator.GetBoneTransform(kneeBone); Transform hipTransform = animator.GetBoneTransform(HumanBodyBones.Hips); var heightOffset = Vector3.Project(kneeTransform.position - newPos, Vector3.up); Vector3 source = newPos + heightOffset; bool grounded = GetFootGroundedInfo(source, out Vector3 groundedPos, out Vector3 groundNormal, out GameObject floor); if (grounded && floor == target.Floor) { Vector3 footForward = Vector3.ProjectOnPlane(target.FootForward, groundNormal).normalized; target.UpdateStrideTarget(groundedPos, Quaternion.LookRotation(footForward, groundNormal), groundNormal, true); } else if (grounded && floor != target.Floor) { var footForward = Vector3.ProjectOnPlane(hipTransform.forward, groundNormal); var rotation = Quaternion.LookRotation(footForward, groundNormal); target.StartStride(groundedPos, rotation, floor, footForward, groundNormal, true); } else { target.ReleaseFoot(); } } /// /// Gets the target hip offset based on the current vertical offset /// of each foot. /// /// The new target hip offset based on the current distance from /// the hips. public float GetTargetHipOffset() { // Average the hip offset required of each foot float leftOffset = GetVerticalOffsetByFoot(Foot.LeftFoot); float rightOffset = GetVerticalOffsetByFoot(Foot.RightFoot); return Mathf.Min(leftOffset, rightOffset); } /// /// Update the left and right foot targets based on their current state. /// private void UpdateFootTargets() { foreach (Foot foot in Feet) { FootTarget target = GetFootTarget(foot); switch (target.State) { case FootState.Grounded: UpdateFootWhenGrounded(target); break; case FootState.Released: UpdateFootWhenReleased(target); break; } UpdateFootIKState(target); } } /// /// Update the foot IK state based on the current target configuration. /// /// Foot target to update. private void UpdateFootIKState(FootTarget target) { AvatarIKGoal goal = target.Foot == Foot.LeftFoot ? AvatarIKGoal.LeftFoot : AvatarIKGoal.RightFoot; Transform hips = animator.GetBoneTransform(HumanBodyBones.Hips); // If we are overlapping with something, snap it back Transform footTransform = GetFootTransform(target.Foot); bool overlapping = VerifySpotOverlap(footTransform.position, out IRaycastHit footHit); Vector3 worldPos = footTransform.position; if (overlapping && target.State == FootState.Released) { Vector3 correctedPos = footHit.point + footGroundedHeight * footHit.normal; if (target.OverlapTime <= 0) { target.OverlapCorrectedPosition = correctedPos; } else { target.OverlapCorrectedPosition = Vector3.Lerp(target.OverlapCorrectedPosition, correctedPos, 0.5f); } target.OverlapTime += unityService.deltaTime; target.OverlapTime = Mathf.Clamp(target.OverlapTime, 0, overlapBlendTime); } else { target.OverlapTime -= unityService.deltaTime; target.OverlapTime = Mathf.Clamp(target.OverlapTime, 0, overlapBlendTime); } if (target.OverlapTime > 0) { float lerp = Mathf.Clamp(target.OverlapTime / overlapBlendTime, 0, 1); worldPos = Vector3.Lerp(worldPos, target.OverlapCorrectedPosition, lerp); } animator.SetIKPosition(goal, Vector3.Lerp(worldPos, target.FootIKTargetPos(), target.FootIKWeight)); animator.SetIKRotation(goal, target.FootIKTargetRot()); animator.SetIKPositionWeight(goal, 1); animator.SetIKRotationWeight(goal, target.FootIKWeight); if (target.OverlapTime > 0) { float lerp = Mathf.Clamp(target.OverlapTime / overlapBlendTime, 0, 1); var footForward = Vector3.ProjectOnPlane(hips.forward, footHit.normal); animator.SetIKRotationWeight(goal, lerp); animator.SetIKRotation(goal, Quaternion.LookRotation(footForward, footHit.normal)); } } /// /// Update the foot when in the released state. /// /// Foot to update private void UpdateFootWhenReleased(FootTarget target) { if (target.OverGroundThreshold() && GetFootGroundedTransform(target.Foot, out Vector3 groundedPos, out Quaternion groundedRot, out Vector3 groundNormal, out GameObject groundedFloor, out Vector3 groundedForward)) { target.StartStride(groundedPos, groundedRot, groundedFloor, groundedForward, groundNormal, false); } } /// /// Update foot targets when the foot is grounded. /// /// Foot target to update. private void UpdateFootWhenGrounded(FootTarget target) { bool shouldRelease = target.UnderReleaseThreshold(); if (shouldRelease) { target.ReleaseFoot(); } else if (GetFootTargetPosViaHips(target.Foot, out Vector3 hipGroundPos, out Quaternion hipGroundRot, out Vector3 hipNormal, out GameObject hipFloor, out Vector3 hipForward)) { // Check if we have exceeded the target angle or target distance float deltaDist = Vector3.ProjectOnPlane(hipGroundPos - target.TargetFootPosition, Vector3.up).magnitude; float deltaAngle = Quaternion.Angle(hipGroundRot, target.TargetFootRotation); bool distThreshold = deltaDist >= strideThresholdDistance; bool turnThreshold = deltaAngle >= strideThresholdDegrees; // When replacing the foot, try to move the foot up towards the hip if the // step is behind the hips. if (CanTakeStride && !target.MidStride) { if (distThreshold) { target.ReleaseFoot(); } else if (turnThreshold) { target.StartStride(hipGroundPos, hipGroundRot, hipFloor, hipForward, hipNormal, true); } } else if (target.MidStride && target.CanUpdateStrideTarget()) { target.UpdateStrideTarget(hipGroundPos, hipGroundRot, Vector3.up); } } } /// /// Get the foot transform from the animator avatar. /// /// Foot to check. /// Bone transform for that avatar's selected foot. private Transform GetFootTransform(Foot foot) { HumanBodyBones footBone = foot == Foot.LeftFoot ? HumanBodyBones.LeftFoot : HumanBodyBones.RightFoot; return animator.GetBoneTransform(footBone); } /// /// Gets the foot's desired hip offset based on the current grounded /// state. Basically, if a foot is grounded on floor too far away from the /// avatar, the avatar should move down to account for this height /// difference. /// /// Foot target for the avatar. /// Desired hip offset for the selected foot. private float GetVerticalOffsetByFoot(Foot foot) { FootTarget target = GetFootTarget(foot); if (target.State != FootState.Grounded) { return 0; } Transform hipTransform = animator.GetBoneTransform(HumanBodyBones.Hips); float dist = Vector3.Project(hipTransform.position - Vector3.up * hipOffset - target.FootIKTargetPos(), Vector3.up).magnitude; return Mathf.Clamp(maxHipFootDistance - dist, -maxHipFootDistance, 0); } /// /// Get where a foot's current target ground position /// is in the vertical plane defined by the player's hips. /// This will take wherever the foot is in world space and project /// it onto the plane that is normal to the hip's forward vector. /// This gets where the foot would be if the player were to lift up /// their foot and bring it in line with their hips from the foot's /// current location. /// /// Foot target to check position of. /// Desired foot position in world space. /// Desired foot rotation in world space. /// Ground normal for the surface the foot is standing on. /// GameObject/collider player is currently standing on. /// Forward vector for the foot's desired rotation. /// True if the foot has a surface to ground on, false otherwise. private bool GetFootTargetPosViaHips(Foot foot, out Vector3 groundPos, out Quaternion rotation, out Vector3 groundNormal, out GameObject floor, out Vector3 footForward) { HumanBodyBones kneeBone = foot == Foot.LeftFoot ? HumanBodyBones.LeftLowerLeg : HumanBodyBones.RightLowerLeg; Transform hipTransform = animator.GetBoneTransform(HumanBodyBones.Hips); Transform kneeTransform = animator.GetBoneTransform(kneeBone); Transform footTransform = GetFootTransform(foot); Vector3 footPos = footTransform.position; Vector3 source = Vector3.ProjectOnPlane(footPos - hipTransform.position, hipTransform.forward) + hipTransform.position; source.y = kneeTransform.position.y; bool grounded = GetFootGroundedInfo(source, out groundPos, out groundNormal, out floor); footForward = Vector3.ProjectOnPlane(hipTransform.forward, groundNormal); rotation = Quaternion.LookRotation(footForward, groundNormal); return grounded; } /// /// Gets the foot's current grounded location in world space given /// the current location of the foot from the animator. /// Will draw a line from the knee height of the player down and check if /// the foot can stand on some surface under the player. /// /// Foot target to check position of. /// Desired foot position in world space. /// Desired foot rotation in world space. /// Ground normal for the surface the foot is standing on. /// GameObject/collider player is currently standing on. /// Forward vector for the foot's desired rotation. /// True if the foot has a surface to ground on, false otherwise. private bool GetFootGroundedTransform(Foot foot, out Vector3 groundedPos, out Quaternion rotation, out Vector3 groundNormal, out GameObject floor, out Vector3 footForward) { HumanBodyBones kneeBone = foot == Foot.LeftFoot ? HumanBodyBones.LeftLowerLeg : HumanBodyBones.RightLowerLeg; Transform kneeTransform = animator.GetBoneTransform(kneeBone); Transform footTransform = GetFootTransform(foot); Transform hipTransform = animator.GetBoneTransform(HumanBodyBones.Hips); var heightOffset = Vector3.Project(kneeTransform.position - footTransform.position, Vector3.up); bool grounded = GetFootGroundedInfo(footTransform.position + heightOffset, out groundedPos, out groundNormal, out floor); footForward = Vector3.ProjectOnPlane(hipTransform.forward, groundNormal); rotation = Quaternion.LookRotation(footForward, groundNormal); return grounded; } /// /// Gets the foot's current grounded location in world space given /// the current configuration. /// /// Source position to draw ray from. /// Desired foot position in world space. /// Desired foot rotation in world space. /// Ground normal for the surface the foot is standing on. /// GameObject/collider player is currently standing on. /// True if the foot has a surface to ground on, false otherwise. private bool GetFootGroundedInfo(Vector3 sourcePos, out Vector3 groundedPos, out Vector3 groundNormal, out GameObject floor) { bool grounded = raycastHelper.DoRaycastInDirection(sourcePos, Vector3.down, groundCheckDist, out IRaycastHit hitInfo, layerMask: feetCollisionDetection); groundedPos = grounded ? hitInfo.point : Vector3.zero; groundNormal = grounded ? hitInfo.normal : Vector3.up; floor = grounded ? hitInfo.collider?.gameObject : null; return grounded; } /// /// Verifies if a target position for the foot is overlapping with anything. /// /// New target position for the foot. /// Hit associated with the reachability check. /// True if reachable, false otherwise. private bool VerifySpotOverlap(Vector3 newTarget, out IRaycastHit hit) { if (!correctForOverlap) { hit = new RaycastHitWrapper(new RaycastHit()); return false; } // Check if the foot is currently in an overlapping position Transform hips = animator.GetBoneTransform(HumanBodyBones.Hips); Vector3 dir = (newTarget - hips.position).normalized; float dist = Vector3.Distance(newTarget, hips.position); return raycastHelper.DoRaycastInDirection( hips.position, dir, dist, out hit, layerMask: feetCollisionDetection); } } }