Add finger IK system for the hand rig
FingerIK: per-finger hinge-constrained CCD solver over a local +Y bone chain. Procedural curl seed (rest-relative) blended with optional IK reach toward a target via ikWeight, plus a lateral DOF on the base joint so targets can pull fingers slightly sideways and upward (back-of-hand). Runs in edit mode via [ExecuteAlways] + an EditorApplication.update tick. HandIK: auto-discovers the five fingers under the hand root, wires a FingerIK per finger (thumb gets its own limits), exposes inspector target slots, and drives all solves once per frame/tick. Pushes the rig curl hinge axis (-X) and limits centrally so re-discovery corrects already serialized components. Live ikWeight broadcast; non-destructive target resolution (a FingerIK's own target survives an empty manager slot). Input signature is additive so every finger refreshes live while dragging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
38330c4fcc
commit
7d787b768d
4 files changed
+777
No files matched your search
@@ -0,0 +1,421 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Single-finger procedural pose + IK solver for a LOCAL +Y bone chain. Flexion (curl)
|
||||
/// is rotation around each joint's local hinge axis (default local -X, the palm-ward
|
||||
/// flex direction for the WhiteHand rig); negative per-joint limits allow a little
|
||||
/// back-of-hand (upward) bend when an IK target pulls there. Spread/abduction is around
|
||||
/// local Z on the base joint only and is also IK-solved toward the target for slight
|
||||
/// sideways reach. Works for any chain length: the thumb (2 rotating joints) and the long
|
||||
/// fingers (3) use identical code.
|
||||
///
|
||||
/// Two drive modes, blended per frame:
|
||||
/// 1. Procedural curl. <see cref="curl"/> (0..1) lerps every joint from its rest angle
|
||||
/// (0) toward its per-joint <see cref="maxCurl"/> (closed fist). This is written first
|
||||
/// every frame and is the SEED pose for the IK pass.
|
||||
/// 2. IK reach. When a <see cref="target"/> is assigned and <see cref="ikWeight"/> > 0,
|
||||
/// CCD bends the chain (seeded from the curl pose) so the fingertip reaches the target,
|
||||
/// constraining every joint to its hinge axis and angle limits mid-sweep. The final
|
||||
/// per-joint pose is Slerp(seedCurl, solvedIK, ikWeight) so curl and IK never fight and
|
||||
/// nothing accumulates frame-to-frame.
|
||||
///
|
||||
/// All curl math is REST-RELATIVE: the captured rest localRotation is the 0-degree datum,
|
||||
/// so the rig's modeled fan/opposition offsets (e.g. thumb's ~55 deg local-Z rest) are
|
||||
/// preserved instead of being snapped to identity. Positive curl closes the fist for every
|
||||
/// digit on this rig.
|
||||
///
|
||||
/// Added at runtime onto the finger-root GameObject by <see cref="HandIK"/>, or wired by
|
||||
/// hand via <see cref="Setup"/>. By default the owning <see cref="HandIK"/> drives the
|
||||
/// solve once per frame; set <see cref="SolveInLateUpdate"/> if using this component alone.
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
public class FingerIK : MonoBehaviour
|
||||
{
|
||||
[Header("Chain")]
|
||||
[Tooltip("Rotating joints, root -> tip (EXCLUDES the *_end leaf). Auto-filled by HandIK.Setup.")]
|
||||
[SerializeField] private List<Transform> joints = new List<Transform>();
|
||||
|
||||
[Tooltip("Fingertip transform (the *_end leaf). Has no joint of its own; CCD reaches it to the target.")]
|
||||
[SerializeField] private Transform tip;
|
||||
|
||||
[Tooltip("Optional world-space goal. When set (and ikWeight > 0) CCD reaches the tip to it; otherwise curl drives the pose. HandIK's matching Finger Target slot overrides this when assigned; an empty HandIK slot leaves a target set here intact.")]
|
||||
[SerializeField] private Transform target;
|
||||
|
||||
[Header("Solver")]
|
||||
[Tooltip("CCD sweeps per Solve. 8-12 is plenty for short finger chains.")]
|
||||
[Range(1, 16)] [SerializeField] private int iterations = 10;
|
||||
|
||||
[Tooltip("Stop early once the tip is within this distance (world units) of the target.")]
|
||||
[SerializeField] private float tolerance = 0.001f;
|
||||
|
||||
[Tooltip("0 = pure curl pose (no IK), 1 = full IK reach. Blends the solved pose against the curl seed.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float ikWeight = 1f;
|
||||
|
||||
[Header("Curl")]
|
||||
[Tooltip("0 = open (rest), 1 = fully curled (each joint at its maxCurl). Drives the pose and seeds the IK.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float curl;
|
||||
|
||||
[Tooltip("Curl bias per joint depth so distal joints curl more than the base for a natural fist (1 = uniform).")]
|
||||
[SerializeField] private AnimationCurve curlProfile = AnimationCurve.Linear(0f, 1f, 1f, 1f);
|
||||
|
||||
[Tooltip("Spread/abduction around local Z (degrees), applied to the base joint only. For the thumb this is opposition.")]
|
||||
[SerializeField] private float spread;
|
||||
|
||||
[Tooltip("Spread clamp (degrees), [min, max], applied to the base joint's spread/opposition DOF.")]
|
||||
[SerializeField] private Vector2 spreadLimits = new Vector2(-12f, 12f);
|
||||
|
||||
[Header("Per-joint limits (degrees, rest-relative; + = curl toward palm)")]
|
||||
[Tooltip("Local hinge (flexion) axis per joint. Default local -X (-1,0,0) = palm-ward flex on this rig. joints[i] uses hingeAxes[i]; short arrays reuse the last entry.")]
|
||||
[SerializeField] private Vector3[] hingeAxes;
|
||||
|
||||
[Tooltip("Min curl angle per joint. 0 = open hand. Negative allows hyperextension.")]
|
||||
[SerializeField] private float[] minCurl;
|
||||
|
||||
[Tooltip("Max curl angle per joint (closed fist).")]
|
||||
[SerializeField] private float[] maxCurl;
|
||||
|
||||
[Header("Execution")]
|
||||
[Tooltip("If true, this component solves itself in LateUpdate. Leave OFF when a HandIK manager drives the solve (avoids double-solving).")]
|
||||
[SerializeField] private bool solveInLateUpdate;
|
||||
|
||||
// Rest localRotation per joint, captured before the first Solve mutates the bones.
|
||||
// Serialized so an edit-mode capture survives domain reload; do NOT re-capture in a
|
||||
// curled pose or the rest datum is poisoned.
|
||||
[SerializeField, HideInInspector] private Quaternion[] restRotations;
|
||||
|
||||
// Per-frame scratch: the curl seed and the solved local rotations, so the final blend
|
||||
// uses seed-vs-solved cleanly instead of blending in place.
|
||||
private Quaternion[] _seedLocal;
|
||||
private Quaternion[] _solvedLocal;
|
||||
|
||||
private static readonly Vector3 SpreadAxis = Vector3.forward; // local Z
|
||||
|
||||
public int JointCount => joints != null ? joints.Count : 0;
|
||||
public Transform Tip => tip;
|
||||
public Transform Target => target;
|
||||
public float Curl => curl;
|
||||
public float IkWeight => ikWeight;
|
||||
public float Spread => spread;
|
||||
|
||||
public bool SolveInLateUpdate
|
||||
{
|
||||
get => solveInLateUpdate;
|
||||
set => solveInLateUpdate = value;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (HasChain() && (restRotations == null || restRotations.Length != joints.Count))
|
||||
CaptureRest();
|
||||
EnsureScratch();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (Application.isPlaying && solveInLateUpdate) Solve();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Standalone edit-mode drive: when this finger has no HandIK owner (solveInLateUpdate ON),
|
||||
// tick the solve from the editor loop so curl/target edits show live without entering Play.
|
||||
// Managed fingers (HandIK sets solveInLateUpdate OFF) make this a no-op -> no double-solve.
|
||||
private void OnEnable()
|
||||
{
|
||||
if (Application.isPlaying) return;
|
||||
UnityEditor.EditorApplication.update -= EditorTick;
|
||||
UnityEditor.EditorApplication.update += EditorTick;
|
||||
}
|
||||
|
||||
private void OnDisable() => UnityEditor.EditorApplication.update -= EditorTick;
|
||||
|
||||
private void EditorTick()
|
||||
{
|
||||
if (Application.isPlaying) return;
|
||||
if (!this) { UnityEditor.EditorApplication.update -= EditorTick; return; }
|
||||
if (solveInLateUpdate) Solve();
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Wires the chain (rotating joints root..tip, plus the *_end tip leaf), sizes the
|
||||
/// per-joint limit arrays to sensible defaults, and captures the rest pose. Called by
|
||||
/// HandIK or by hand. Capturing rest here (right after the joints are assigned, before
|
||||
/// any Solve) keeps the rest datum at the modeled bind pose.
|
||||
/// </summary>
|
||||
public void Setup(IList<Transform> chainJoints, Transform tipLeaf)
|
||||
{
|
||||
joints = chainJoints != null ? new List<Transform>(chainJoints) : new List<Transform>();
|
||||
tip = tipLeaf;
|
||||
EnsureLimitArrays();
|
||||
// Capture rest ONLY when missing/stale (chain length changed). Re-capturing during a
|
||||
// re-discovery of an already-posed hand would record a curled pose as the 0-degree
|
||||
// datum and permanently poison every curl. Use ResetToRest first, then re-capture.
|
||||
if (restRotations == null || restRotations.Length != joints.Count) CaptureRest();
|
||||
EnsureScratch();
|
||||
}
|
||||
|
||||
/// <summary>Overrides the per-joint hinge limits (degrees, rest-relative). Lengths are clamped/extended to the joint count.</summary>
|
||||
public void SetLimits(float[] mins, float[] maxes)
|
||||
{
|
||||
EnsureLimitArrays();
|
||||
if (mins != null)
|
||||
for (int i = 0; i < minCurl.Length; i++) minCurl[i] = mins[Mathf.Min(i, mins.Length - 1)];
|
||||
if (maxes != null)
|
||||
for (int i = 0; i < maxCurl.Length; i++) maxCurl[i] = maxes[Mathf.Min(i, maxes.Length - 1)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the per-joint local hinge (flexion) axis. Sized to the joint count; a short
|
||||
/// array reuses its last entry. Lets HandIK push the rig's curl direction centrally so a
|
||||
/// re-discovery corrects any stale serialized axis on existing FingerIK components.
|
||||
/// </summary>
|
||||
public void SetHingeAxes(Vector3[] axes)
|
||||
{
|
||||
EnsureLimitArrays();
|
||||
if (axes == null || axes.Length == 0) return;
|
||||
for (int i = 0; i < hingeAxes.Length; i++)
|
||||
{
|
||||
Vector3 a = axes[Mathf.Min(i, axes.Length - 1)];
|
||||
hingeAxes[i] = a == Vector3.zero ? Vector3.left : a.normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets the base-joint spread/opposition clamp (degrees).</summary>
|
||||
public void SetSpreadLimits(float min, float max) => spreadLimits = new Vector2(min, max);
|
||||
|
||||
/// <summary>Captures each joint's current localRotation as the rest reference (0-degree curl datum).</summary>
|
||||
public void CaptureRest()
|
||||
{
|
||||
if (!HasChain()) return;
|
||||
restRotations = new Quaternion[joints.Count];
|
||||
for (int i = 0; i < joints.Count; i++)
|
||||
restRotations[i] = joints[i] ? joints[i].localRotation : Quaternion.identity;
|
||||
}
|
||||
|
||||
public void SetTarget(Transform t) => target = t;
|
||||
public void SetCurl(float value) => curl = Mathf.Clamp01(value);
|
||||
public void SetIkWeight(float value) => ikWeight = Mathf.Clamp01(value);
|
||||
public void SetIterations(int value) => iterations = Mathf.Clamp(value, 1, 16);
|
||||
public void SetSpread(float degrees) => spread = Mathf.Clamp(degrees, spreadLimits.x, spreadLimits.y);
|
||||
|
||||
/// <summary>Restores every joint to its captured rest localRotation.</summary>
|
||||
public void ResetToRest()
|
||||
{
|
||||
if (!HasChain() || restRotations == null) return;
|
||||
for (int i = 0; i < joints.Count && i < restRotations.Length; i++)
|
||||
if (joints[i]) joints[i].localRotation = restRotations[i];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One solve step. (1) Write the procedural curl seed to every joint. (2) If no target
|
||||
/// or ikWeight <= 0, that seed IS the pose (cheap, deterministic). (3) Otherwise run
|
||||
/// CCD from the seed, constraining each joint to its hinge + limits mid-sweep, then
|
||||
/// blend each joint Slerp(seed, solved, ikWeight). Spread is applied last on the base.
|
||||
/// </summary>
|
||||
public void Solve()
|
||||
{
|
||||
if (!HasChain() || restRotations == null || restRotations.Length != joints.Count) return;
|
||||
EnsureScratch();
|
||||
|
||||
WriteCurlSeed();
|
||||
|
||||
bool runIk = target && tip && ikWeight > 0f;
|
||||
if (runIk)
|
||||
{
|
||||
SolveCcd();
|
||||
SolveBaseLateral(); // target-driven sideways DOF on the base (folds in manual spread)
|
||||
for (int i = 0; i < joints.Count; i++)
|
||||
{
|
||||
if (!joints[i]) continue;
|
||||
joints[i].localRotation = ikWeight >= 1f
|
||||
? _solvedLocal[i]
|
||||
: Quaternion.Slerp(_seedLocal[i], _solvedLocal[i], ikWeight);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplySpread(); // manual spread only when IK isn't driving the base laterally
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasChain() => joints != null && joints.Count > 0;
|
||||
|
||||
private void EnsureScratch()
|
||||
{
|
||||
int n = joints != null ? joints.Count : 0;
|
||||
if (_seedLocal == null || _seedLocal.Length != n) _seedLocal = new Quaternion[n];
|
||||
if (_solvedLocal == null || _solvedLocal.Length != n) _solvedLocal = new Quaternion[n];
|
||||
}
|
||||
|
||||
private Vector3 HingeAxis(int i)
|
||||
{
|
||||
if (hingeAxes == null || hingeAxes.Length == 0) return Vector3.left;
|
||||
Vector3 a = hingeAxes[Mathf.Min(i, hingeAxes.Length - 1)];
|
||||
return a == Vector3.zero ? Vector3.left : a.normalized;
|
||||
}
|
||||
|
||||
private float MinCurl(int i) =>
|
||||
(minCurl != null && minCurl.Length > 0) ? minCurl[Mathf.Min(i, minCurl.Length - 1)] : 0f;
|
||||
|
||||
private float MaxCurl(int i) =>
|
||||
(maxCurl != null && maxCurl.Length > 0) ? maxCurl[Mathf.Min(i, maxCurl.Length - 1)] : 90f;
|
||||
|
||||
private void EnsureLimitArrays()
|
||||
{
|
||||
int n = joints != null ? joints.Count : 0;
|
||||
if (hingeAxes == null || hingeAxes.Length != n)
|
||||
{
|
||||
hingeAxes = new Vector3[n];
|
||||
for (int i = 0; i < n; i++) hingeAxes[i] = Vector3.left;
|
||||
}
|
||||
if (minCurl == null || minCurl.Length != n) minCurl = new float[n]; // 0 = open
|
||||
if (maxCurl == null || maxCurl.Length != n)
|
||||
{
|
||||
maxCurl = new float[n];
|
||||
for (int i = 0; i < n; i++) maxCurl[i] = 90f;
|
||||
}
|
||||
}
|
||||
|
||||
// Procedural curl pose: each joint = rest * AngleAxis(Lerp(0, maxCurl, curl * profile), hinge).
|
||||
// Stored into _seedLocal AND written to the transform so CCD bends from this pose.
|
||||
private void WriteCurlSeed()
|
||||
{
|
||||
float span = Mathf.Max(1, joints.Count - 1);
|
||||
for (int i = 0; i < joints.Count; i++)
|
||||
{
|
||||
float depth = joints.Count > 1 ? i / span : 0f;
|
||||
float bias = curlProfile != null ? curlProfile.Evaluate(depth) : 1f;
|
||||
float theta = Mathf.Clamp(Mathf.Lerp(0f, MaxCurl(i), curl * bias), MinCurl(i), MaxCurl(i));
|
||||
Quaternion seed = restRotations[i] * Quaternion.AngleAxis(theta, HingeAxis(i));
|
||||
_seedLocal[i] = seed;
|
||||
if (joints[i]) joints[i].localRotation = seed;
|
||||
}
|
||||
}
|
||||
|
||||
// Hinge-constrained CCD: sweep tip -> base. Each joint rotates ONLY about its world hinge
|
||||
// axis by the in-plane angle that swings the tip toward the target, then is clamped to its
|
||||
// limits. Rotating along the real DOF (instead of a free minimal-arc that the hinge clamp
|
||||
// then discards) keeps CCD in the rig's curl direction, so it actually reaches curl-side
|
||||
// targets within [min, max]. Re-read the tip after every write (the subtree moved). Snapshot
|
||||
// the result into _solvedLocal so the final blend is seed-vs-solved.
|
||||
private void SolveCcd()
|
||||
{
|
||||
float sqrTol = tolerance * tolerance;
|
||||
|
||||
for (int it = 0; it < iterations; it++)
|
||||
{
|
||||
for (int i = joints.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Transform j = joints[i];
|
||||
if (!j) continue;
|
||||
|
||||
Vector3 jointPos = j.position;
|
||||
Vector3 toTip = tip.position - jointPos;
|
||||
Vector3 toTarget = target.position - jointPos;
|
||||
if (toTip.sqrMagnitude < 1e-8f || toTarget.sqrMagnitude < 1e-8f) continue;
|
||||
|
||||
// World hinge axis (invariant under the joint's own twist about local X).
|
||||
Vector3 axisW = (j.rotation * HingeAxis(i)).normalized;
|
||||
Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
|
||||
Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
|
||||
if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) continue;
|
||||
|
||||
float hingeAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
|
||||
j.rotation = Quaternion.AngleAxis(hingeAng, axisW) * j.rotation;
|
||||
|
||||
ConstrainToHinge(i);
|
||||
}
|
||||
|
||||
if ((tip.position - target.position).sqrMagnitude <= sqrTol) break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < joints.Count; i++)
|
||||
_solvedLocal[i] = joints[i] ? joints[i].localRotation : restRotations[i];
|
||||
}
|
||||
|
||||
// Rest-relative swing-twist hinge constraint: isolate the twist about the hinge axis
|
||||
// from (Inverse(rest) * local), drop the swing (spread/Y drift the world CCD added),
|
||||
// extract the signed hinge angle, clamp to [min, max], and rebuild rest * AngleAxis.
|
||||
// One assignment enforces BOTH the hinge plane and the limits with no accumulated drift.
|
||||
private void ConstrainToHinge(int i)
|
||||
{
|
||||
Transform j = joints[i];
|
||||
if (!j) return;
|
||||
|
||||
Vector3 axis = HingeAxis(i);
|
||||
Quaternion rel = Quaternion.Inverse(restRotations[i]) * j.localRotation;
|
||||
|
||||
// Twist component of rel about axis (swing-twist for a principal axis).
|
||||
Vector3 v = new Vector3(rel.x, rel.y, rel.z);
|
||||
Vector3 p = Vector3.Project(v, axis);
|
||||
float mag = Mathf.Sqrt(p.x * p.x + p.y * p.y + p.z * p.z + rel.w * rel.w);
|
||||
Quaternion twist = mag < 1e-8f
|
||||
? Quaternion.identity // 180-degree swing: undefined twist
|
||||
: new Quaternion(p.x / mag, p.y / mag, p.z / mag, rel.w / mag);
|
||||
|
||||
// Signed hinge angle: ToAngleAxis gives [0,180] with a possibly-flipped axis;
|
||||
// recover the sign from the dot with the hinge axis.
|
||||
twist.ToAngleAxis(out float ang, out Vector3 ax);
|
||||
float signed = ang * Mathf.Sign(Vector3.Dot(ax, axis));
|
||||
signed = Mathf.Clamp(signed, MinCurl(i), MaxCurl(i));
|
||||
|
||||
j.localRotation = restRotations[i] * Quaternion.AngleAxis(signed, axis);
|
||||
}
|
||||
|
||||
// Target-driven lateral (abduction) on the base joint about local Z, run once after the
|
||||
// flexion CCD. Z tip-motion is orthogonal to the X flexion plane on this rig, so a small
|
||||
// sideways swing toward the target composes cleanly on top of the curl/flexion pose. The
|
||||
// manual spread is folded in as a bias and the total is clamped to spreadLimits, then written
|
||||
// into the base's solved rotation so the ikWeight blend covers lateral too.
|
||||
private void SolveBaseLateral()
|
||||
{
|
||||
Transform b = joints[0];
|
||||
if (!b || !tip || !target) return;
|
||||
|
||||
b.localRotation = _solvedLocal[0]; // pose the base at its flexion-solved rotation to read world Z
|
||||
Vector3 axisW = (b.rotation * SpreadAxis).normalized;
|
||||
Vector3 toTip = tip.position - b.position;
|
||||
Vector3 toTarget = target.position - b.position;
|
||||
|
||||
float latAng = 0f;
|
||||
Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
|
||||
Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
|
||||
if (tipPlane.sqrMagnitude > 1e-10f && tgtPlane.sqrMagnitude > 1e-10f)
|
||||
latAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
|
||||
|
||||
latAng = Mathf.Clamp(latAng + spread, spreadLimits.x, spreadLimits.y);
|
||||
_solvedLocal[0] = _solvedLocal[0] * Quaternion.AngleAxis(latAng, SpreadAxis);
|
||||
}
|
||||
|
||||
// Base-joint spread/opposition about local Z, composed AFTER curl so the two DOFs don't
|
||||
// fight. Applied on the final base rotation when IK isn't driving the base laterally.
|
||||
private void ApplySpread()
|
||||
{
|
||||
if (joints.Count == 0 || !joints[0]) return;
|
||||
float s = Mathf.Clamp(spread, spreadLimits.x, spreadLimits.y);
|
||||
if (Mathf.Approximately(s, 0f)) return;
|
||||
joints[0].localRotation = joints[0].localRotation * Quaternion.AngleAxis(s, SpreadAxis);
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!HasChain()) return;
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
for (int i = 0; i < joints.Count - 1; i++)
|
||||
if (joints[i] && joints[i + 1]) Gizmos.DrawLine(joints[i].position, joints[i + 1].position);
|
||||
|
||||
Transform last = joints[joints.Count - 1];
|
||||
if (last && tip) Gizmos.DrawLine(last.position, tip.position);
|
||||
if (tip) Gizmos.DrawWireSphere(tip.position, 0.03f);
|
||||
|
||||
if (target && tip)
|
||||
{
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawLine(tip.position, target.position);
|
||||
Gizmos.DrawWireSphere(target.position, 0.05f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 716a391c8e627654dab900c7386c898a
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under
|
||||
/// <see cref="handRoot"/> (Thumb, IndexFinger, MiddleFinger, RingFinger, LittleFinger),
|
||||
/// walks each single-child bone spine to its *_end leaf, and creates/configures a
|
||||
/// <see cref="FingerIK"/> 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".
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbe3de6a9f2cea544993dbddc6dedaa8
|
||||
Reference in new issue
Block a user