Trim restated narration, dead commented-out code, and oversized class headers; keep all why-comments (authority, timing, edge cases), tooltips, and serialization caveats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
345 lines
14 KiB
C#
345 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under
|
|
/// <see cref="handRoot"/>, walks each single-child bone spine to its *_end leaf, and
|
|
/// creates/configures a <see cref="FingerIK"/> per finger root (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 (no double-solve; the override lands AFTER any Animator pose).
|
|
///
|
|
/// Runs in edit mode too ([ExecuteAlways]): an editor tick solves live (curl / weight / dragged
|
|
/// targets) without entering Play, re-running only when an input changed so an idle hand never
|
|
/// re-dirties the scene. Right-click the header for "Rediscover" (after rig edits) and "Reset To Rest".
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Pushes the IK reach weight to every discovered finger (0 = pure curl, 1 = full IK).</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// (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.
|
|
/// </summary>
|
|
[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<Transform>(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<FingerIK>();
|
|
if (!ik) ik = root.gameObject.AddComponent<FingerIK>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|