using System.Collections.Generic; using UnityEngine; /// /// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under /// (Thumb, IndexFinger, MiddleFinger, RingFinger, LittleFinger), /// walks each single-child bone spine to its *_end leaf, and creates/configures a /// on every finger root with sensible per-joint curl limits (the /// thumb gets its own 2-joint range). Owns per-finger curl/target/weight and drives every /// finger's solve once per frame in LateUpdate (so FingerIK does not double-solve, and the /// override lands AFTER any Animator pose for the frame). /// /// Runs in edit mode too ([ExecuteAlways]): an editor tick solves live so per-finger curl, /// the IK weight, and the per-finger target slots pose the hand WITHOUT entering Play, and /// dragging an assigned target in the scene view updates the reach live. The solve only re-runs /// when an input actually changed, so an idle hand never re-dirties the scene. /// /// Minimal wiring: drop on WhiteHand (or Hand); leave handRoot empty to auto-find the 'Hand' /// node by name. Fingers auto-discover (in edit mode the first time, deferred to a safe point; /// in Play via discoverOnAwake). Right-click the component header for "Rediscover" (after rig /// edits) and "Reset To Rest". /// [ExecuteAlways] [DisallowMultipleComponent] public class HandIK : MonoBehaviour { public enum Finger { Thumb, Index, Middle, Ring, Little } [Header("Rig")] [Tooltip("The 'Hand' node whose 5 children are the finger roots. Empty = auto-find by name under this object.")] [SerializeField] private Transform handRoot; [Tooltip("Run discovery automatically in Awake.")] [SerializeField] private bool discoverOnAwake = true; [Header("Targets (edit-mode assignable, dragged live)")] [Tooltip("Per-finger IK goal in rig order (Thumb, Index, Middle, Ring, Little). Assign a transform and drag it in the scene view; that finger reaches it live. A filled slot overrides the finger's own Target; an empty slot leaves a target set directly on the FingerIK intact (so either place works). Empty everywhere = the finger is curl-only.")] [SerializeField] private Transform[] fingerTargets = new Transform[5]; [Header("Solver defaults (applied on discovery)")] [Tooltip("CCD sweeps per finger solve.")] [Range(1, 16)] [SerializeField] private int iterations = 10; [Tooltip("IK reach weight applied to every finger on discovery (0 = pure curl, 1 = full IK).")] [Range(0f, 1f)] [SerializeField] private float ikWeight = 1f; // Indexed by (int)Finger. Built by Discover(); not hand-edited, so serialized hidden. [SerializeField, HideInInspector] private FingerIK[] fingers = new FingerIK[5]; // Exact finger-root names in rig order, matching the Finger enum. private static readonly string[] RootNames = { "Thumb", "IndexFinger", "MiddleFinger", "RingFinger", "LittleFinger" }; // Default per-joint flexion limits (degrees, rest-relative; + closes the fist on this // rig). Four long fingers: base (MCP), middle (PIP), distal (DIP). Small negative mins // allow a little back-of-hand (upward) bend when an IK target pulls there. private static readonly float[] FingerMin = { -25f, -12f, -12f }; private static readonly float[] FingerMax = { 70f, 90f, 70f }; // Thumb has only 2 rotating joints; its opposition lives in the rest local-Z pose, so // these are pure flexion deltas on top of rest. private static readonly float[] ThumbMin = { -20f, -12f }; private static readonly float[] ThumbMax = { 55f, 80f }; // This rig flexes every finger (thumb included) around local -X toward the palm; local +X // curls the back of the hand (verified). Pushed to each FingerIK on discovery so a // re-discovery overwrites any stale serialized axis. Single entry = reused for all joints. private static readonly Vector3[] CurlHingeAxis = { new Vector3(-1f, 0f, 0f) }; public FingerIK Get(Finger f) => (fingers != null && (int)f < fingers.Length) ? fingers[(int)f] : null; private void Awake() { if (!handRoot) handRoot = FindHandRoot(); if (Application.isPlaying && discoverOnAwake) Discover(); } private void Reset() => handRoot = FindHandRoot(); // Broadcast the IK weight only when it actually changes, so editing other fields (or a // per-finger ikWeight on a FingerIK) is not clobbered every OnValidate. Pushing it live is // what makes the slider actually move the fingers (discovery alone left it inert). private float _lastIkWeight = -1f; private void OnValidate() { if (!handRoot) handRoot = FindHandRoot(); if (!Mathf.Approximately(ikWeight, _lastIkWeight)) { _lastIkWeight = ikWeight; if (FingersResolved()) SetIkWeightAll(ikWeight); } } // Single writer for all finger bones. Drives the solve in LateUpdate in Play (overrides any // animation applied between Update and LateUpdate) and from the editor tick in edit mode, so // both share one path. FingerIK.solveInLateUpdate is left OFF on discovery (no double-solve). private void LateUpdate() { if (Application.isPlaying) SolveAll(); } // Pushes each per-finger target, then solves. Shared by Play (LateUpdate) and edit mode // (EditorTick). Non-destructive: a Finger Target slot OVERRIDES the finger's own target // when assigned, but an EMPTY slot leaves a target set directly on the FingerIK intact // (so assigning on either surface works, and the slot never silently wipes it). private void SolveAll() { if (fingers == null) return; for (int i = 0; i < fingers.Length; i++) { FingerIK ik = fingers[i]; if (!ik) continue; Transform slot = (fingerTargets != null && i < fingerTargets.Length) ? fingerTargets[i] : null; if (slot) ik.SetTarget(slot); ik.Solve(); } } private bool FingersResolved() { if (fingers == null || fingers.Length != RootNames.Length) return false; for (int i = 0; i < fingers.Length; i++) if (!fingers[i]) return false; return true; } public void SetCurl(Finger f, float value) { var ik = Get(f); if (ik) ik.SetCurl(value); } public void SetTarget(Finger f, Transform t) { int i = (int)f; if (fingerTargets != null && i >= 0 && i < fingerTargets.Length) fingerTargets[i] = t; var ik = Get(f); if (ik) ik.SetTarget(t); } public void SetIkWeight(Finger f, float value) { var ik = Get(f); if (ik) ik.SetIkWeight(value); } public void SetSpread(Finger f, float degrees) { var ik = Get(f); if (ik) ik.SetSpread(degrees); } /// Pushes the IK reach weight to every discovered finger (0 = pure curl, 1 = full IK). public void SetIkWeightAll(float value) { ikWeight = Mathf.Clamp01(value); if (fingers == null) return; for (int i = 0; i < fingers.Length; i++) if (fingers[i]) fingers[i].SetIkWeight(ikWeight); } [ContextMenu("Reset To Rest")] public void ResetAll() { if (fingers == null) return; for (int i = 0; i < fingers.Length; i++) if (fingers[i]) fingers[i].ResetToRest(); } /// /// (Re)builds the 5 FingerIK components from the rig. Idempotent: reuses an existing /// FingerIK on each root (GetComponent before AddComponent), so rediscovery in a posed /// hand re-captures rest from the CURRENT pose only via Setup. /// [ContextMenu("Rediscover")] public void Discover() { if (!handRoot) handRoot = FindHandRoot(); if (!handRoot) { Debug.LogWarning("HandIK: no handRoot; cannot discover fingers.", this); return; } if (fingers == null || fingers.Length != RootNames.Length) fingers = new FingerIK[RootNames.Length]; var chain = new List(8); for (int f = 0; f < RootNames.Length; f++) { Transform root = FindDeepChild(handRoot, RootNames[f]); if (!root) { Debug.LogWarning($"HandIK: finger root '{RootNames[f]}' not found under {handRoot.name}.", this); fingers[f] = null; continue; } // Walk the single-child spine root..*_end. Every bone before the _end leaf is a // rotating joint; the _end leaf is the fingertip (no joint of its own). chain.Clear(); Transform node = root; Transform tipLeaf = null; while (node) { if (node.name.EndsWith("_end")) { tipLeaf = node; break; } chain.Add(node); node = node.childCount > 0 ? node.GetChild(0) : null; } if (!tipLeaf && chain.Count > 1) { // No _end sentinel: treat the deepest bone as the tip so a non-conforming // rig still solves with chain.Count-1 joints. tipLeaf = chain[chain.Count - 1]; chain.RemoveAt(chain.Count - 1); } FingerIK ik = root.GetComponent(); if (!ik) ik = root.gameObject.AddComponent(); ik.Setup(chain, tipLeaf); ConfigureFinger(ik, (Finger)f); fingers[f] = ik; } } // Applies the per-finger limits and shared solver settings. Thumb uses its 2-joint range. private void ConfigureFinger(FingerIK ik, Finger f) { if (f == Finger.Thumb) { ik.SetLimits(ThumbMin, ThumbMax); ik.SetSpreadLimits(-10f, 45f); // opposition is the thumb's primary base DOF } else { ik.SetLimits(FingerMin, FingerMax); ik.SetSpreadLimits(-12f, 12f); } ik.SetHingeAxes(CurlHingeAxis); // rig curl direction (palm-ward); overrides stale serialized axes ik.SetIterations(iterations); ik.SetIkWeight(ikWeight); ik.SolveInLateUpdate = false; // this manager drives the solve } private Transform FindHandRoot() { Transform found = FindDeepChild(transform, "Hand"); return found ? found : transform; } private static Transform FindDeepChild(Transform parent, string targetName) { for (int i = 0; i < parent.childCount; i++) { Transform c = parent.GetChild(i); if (c.name == targetName) return c; Transform deep = FindDeepChild(c, targetName); if (deep) return deep; } return null; } #if UNITY_EDITOR private double _lastSig = double.NaN; // Edit-mode live drive. LateUpdate is unreliable outside Play, so tick the solve from the // editor loop; re-solve only when an input changed so an idle hand never re-dirties the scene. private void OnEnable() { if (Application.isPlaying) return; UnityEditor.EditorApplication.update -= EditorTick; UnityEditor.EditorApplication.update += EditorTick; if (!FingersResolved()) UnityEditor.EditorApplication.delayCall += DeferredDiscover; } private void OnDisable() => UnityEditor.EditorApplication.update -= EditorTick; // AddComponent is illegal during Awake/OnEnable/OnValidate, so structural discovery is // deferred to a delayCall (a legal point) and persisted by marking the scene dirty. private void DeferredDiscover() { if (!this || Application.isPlaying || FingersResolved()) return; Discover(); UnityEditor.EditorUtility.SetDirty(this); if (gameObject.scene.IsValid()) UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene); } private void EditorTick() { if (Application.isPlaying) return; if (!this) { UnityEditor.EditorApplication.update -= EditorTick; return; } if (!FingersResolved()) return; double sig = InputSignature(); if (sig == _lastSig) return; _lastSig = sig; SolveAll(); UnityEditor.SceneView.RepaintAll(); } // Cheap ADDITIVE fold over everything that affects the solved pose; any change triggers one // re-solve. NOT compounding (h*31 grows to ~1e38 over 5 fingers, so later fingers' sub-mm // target drags vanish in double epsilon -> only the first targets refreshed live). Each // finger contributes at the same scale via a distinct per-finger weight, so every finger's // drag is detected and the chance of two independent edits cancelling is measure-zero. private double InputSignature() { double h = 0.0; for (int i = 0; i < fingers.Length; i++) { FingerIK ik = fingers[i]; double w = (i + 1) * 0.123; if (!ik) { h += w * 2.0; continue; } h += w * (ik.Curl * 1.7 + ik.IkWeight * 2.9 + ik.Spread * 0.013); // Effective target mirrors SolveAll's resolution (slot overrides, else the finger's // own target) so dragging a target assigned on EITHER surface triggers a re-solve. Transform slot = (fingerTargets != null && i < fingerTargets.Length) ? fingerTargets[i] : null; Transform t = slot ? slot : ik.Target; if (t) { Vector3 p = t.position; Quaternion q = t.rotation; h += w * (p.x * 1.0 + p.y * 7.0 + p.z * 13.0); h += w * (q.x * 3.0 + q.y * 5.0 + q.z * 11.0 + q.w * 17.0); } } return h; } #endif private void OnDrawGizmos() { if (fingers == null) return; foreach (var ik in fingers) { if (!ik || !ik.Tip) continue; Gizmos.color = Color.green; Gizmos.DrawWireSphere(ik.Tip.position, 0.04f); if (ik.Target) { Gizmos.color = Color.yellow; Gizmos.DrawLine(ik.Tip.position, ik.Target.position); } } } }