// 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 System.Collections; using System.Collections.Generic; using System.Linq; using nickmaltbie.OpenKCC.Animation; using Unity.EditorCoroutines.Editor; using UnityEditor; using UnityEngine; namespace nickmaltbie.OpenKCC.Editor { /// /// Custom editor script to manage creating curves for the Humanoid Foot IK. /// [CustomEditor(typeof(HumanoidFootIK), true)] public class HumanoidFootIKEditor : UnityEditor.Editor { #if UNITY_2020_1_OR_NEWER /// /// Task id for generating foot curves in teh background. /// private int taskId = -1; #endif /// /// Rate of sampling per second. /// private int samplingRate = 30; /// /// Current state of the editor dropdown. /// private bool state = false; public override void OnInspectorGUI() { DrawDefaultInspector(); if (state = EditorGUILayout.BeginFoldoutHeaderGroup(state, "Baking Options")) { samplingRate = EditorGUILayout.IntField("Sampling Rate", samplingRate); var footIK = target as HumanoidFootIK; if (GUILayout.Button("Bake Animation Curves")) { EditorCoroutineUtility.StartCoroutine(BakeAnimations(footIK.gameObject, footIK.GetComponent(), footIK), this); } } } /// /// Check if the foot is "grounded" for a specific grounded. height. /// /// /// /// /// public bool IsFootGrounded(Animator animator, HumanBodyBones bone, float footGroundedHeight) { Transform transform = animator.GetBoneTransform(bone); return transform.position.y <= footGroundedHeight; } public Dictionary GetPoses(Animator animator) { var state = new Dictionary(); foreach (Transform transform in animator.GetComponentsInChildren()) { state[transform] = (transform.position, transform.rotation); } return state; } /// /// Reset the player pose to some saved position. /// /// POsition and rotation of each bone. public void ResetPlayerPose(Dictionary poses) { foreach (KeyValuePair kvp in poses) { kvp.Key.position = kvp.Value.Item1; kvp.Key.rotation = kvp.Value.Item2; } } /// /// Bake the animation for the avatar's foot IK curves. /// /// Game object for the avatar. /// Animator for the avatar. /// Foot IK Controller. /// Enumerable of action for baking action. public IEnumerator BakeAnimations(GameObject go, Animator animator, HumanoidFootIK footIK) { Dictionary pose = GetPoses(animator); #if UNITY_2020_1_OR_NEWER taskId = Progress.Start("FootIKCurves", "Baking Foot IK Curves", Progress.Options.None, -1); #endif AnimationClip[] clips = animator.runtimeAnimatorController.animationClips; int clipCount = clips.Length; int current = 0; // Setup the curves for foot locking to ground foreach (AnimationClip clip in animator.runtimeAnimatorController.animationClips) { float clipPercent = (float)current / clipCount; #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, clipPercent, $"Working on clip:{clip.name}"); #endif string assetPath = AssetDatabase.GetAssetPath(clip); var leftInfoCurve = new ClipAnimationInfoCurve(); var rightInfoCurve = new ClipAnimationInfoCurve(); leftInfoCurve.name = FootTarget.LeftFootIKWeight; rightInfoCurve.name = FootTarget.RightFootIKWeight; var importer = AssetImporter.GetAtPath(assetPath) as ModelImporter; ModelImporterClipAnimation[] anims = importer.clipAnimations; int animIndex = Enumerable.Range(0, anims.Length).FirstOrDefault(i => anims[i].name == clip.name); anims[animIndex].curves = anims[animIndex].curves.Where(c => c.name != FootTarget.LeftFootIKWeight && c.name != FootTarget.RightFootIKWeight).ToArray(); importer.clipAnimations = anims; // So you actually need to cleanup the animations first... // otherwise it will fail to save. yield return null; #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, (float)current / clipCount, $"Cleaning up existing curves for:{clip.name}"); #endif importer.SaveAndReimport(); ResetPlayerPose(pose); yield return null; // Sample for each frame and check if foot is grounded int frames = Mathf.CeilToInt(clip.length * samplingRate); var leftFootKeys = new Keyframe[frames]; var rightFootKeys = new Keyframe[frames]; yield return null; float time = 0.0f; clip.SampleAnimation(go, time); bool leftGrounded = IsFootGrounded(animator, HumanBodyBones.LeftFoot, footIK.footGroundedHeight); bool rightGrounded = IsFootGrounded(animator, HumanBodyBones.RightFoot, footIK.footGroundedHeight); leftFootKeys[0] = new Keyframe(time, leftGrounded ? 1.0f : 0.0f); rightFootKeys[0] = new Keyframe(time, rightGrounded ? 1.0f : 0.0f); yield return null; #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, (float)current / clipCount, $"Processing frame:0 for clip:{clip.name}."); #endif for (int i = 1; i < frames - 1; i++) { time = (float)i / samplingRate; clip.SampleAnimation(go, time); leftGrounded = IsFootGrounded(animator, HumanBodyBones.LeftFoot, footIK.footGroundedHeight); rightGrounded = IsFootGrounded(animator, HumanBodyBones.RightFoot, footIK.footGroundedHeight); float keyframeTime = (float)i / frames; leftFootKeys[i] = new Keyframe(keyframeTime, leftGrounded ? 1.0f : 0.0f); rightFootKeys[i] = new Keyframe(keyframeTime, rightGrounded ? 1.0f : 0.0f); #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, (float)current / clipCount + (float)i / frames * 1 / clipCount, $"Processing frame:{i} for clip:{clip.name}."); #endif yield return null; } time = clip.length; clip.SampleAnimation(go, time); leftGrounded = IsFootGrounded(animator, HumanBodyBones.LeftFoot, footIK.footGroundedHeight); rightGrounded = IsFootGrounded(animator, HumanBodyBones.RightFoot, footIK.footGroundedHeight); current++; #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, (float)current / clipCount, $"Processing frame:end for clip:{clip.name}."); #endif leftFootKeys[frames - 1] = new Keyframe(1.0f, leftGrounded ? 1.0f : 0.0f); rightFootKeys[frames - 1] = new Keyframe(1.0f, rightGrounded ? 1.0f : 0.0f); yield return null; leftFootKeys = Enumerable.Range(0, leftFootKeys.Length).Where( i => { int before = i - 1; int after = i + 1; bool sameBefore = before >= 0 && leftFootKeys[before].value == leftFootKeys[i].value; bool sameAfter = after < frames && leftFootKeys[after].value == leftFootKeys[i].value; return !sameBefore || !sameAfter; } ).Select(i => leftFootKeys[i]).ToArray(); rightFootKeys = Enumerable.Range(0, rightFootKeys.Length).Where( i => { int before = i - 1; int after = i + 1; bool sameBefore = before >= 0 && rightFootKeys[before].value == rightFootKeys[i].value; bool sameAfter = after < frames && rightFootKeys[after].value == rightFootKeys[i].value; return !sameBefore || !sameAfter; } ).Select(i => rightFootKeys[i]).ToArray(); leftInfoCurve.curve = new AnimationCurve(leftFootKeys); rightInfoCurve.curve = new AnimationCurve(rightFootKeys); anims[animIndex].curves = Enumerable.Concat( anims[animIndex].curves.Where(c => c.name != FootTarget.LeftFootIKWeight && c.name != FootTarget.RightFootIKWeight), new[] { leftInfoCurve, rightInfoCurve }).ToArray(); importer.clipAnimations = anims; yield return null; #if UNITY_2020_1_OR_NEWER Progress.Report(taskId, (float)current / clipCount, $"Saving results for clip:{clip.name}"); #endif importer.SaveAndReimport(); ResetPlayerPose(pose); yield return null; } #if UNITY_2020_1_OR_NEWER Progress.Remove(taskId); #endif } } }