Reorganize scripts into subfolders by responsibility
Move scripts under Assets/Scripts/ into Components, GameMode, Helper, Network, Enum, Struct and ScriptableObject folders. Pure file moves (plus folder .meta files); no code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
382bfb36df
commit
e23fae0c50
126 files changed
+112
No files matched your search
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbc24cf68e2d52b42a210e4039b9ba61
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 741ea71ad8a1001469a879e03bca0296
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c34d766647ee8994d83c19705febc269
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe9179f5a6af7349909c74b7b867309
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5dd11660d05b5284b8015c842079c2ac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d09de72a6314d2d40bd136e616623b80
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e8296651f102d441aee2e6912d11d18
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,414 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Single-finger procedural pose + IK solver for a LOCAL +Y bone chain. Flexion (curl) rotates around
|
||||
/// each joint's local hinge axis (default local -X = palm-ward on the WhiteHand rig); spread/abduction
|
||||
/// is around local Z on the base joint only. Any chain length (thumb and long fingers share the code).
|
||||
///
|
||||
/// Two drive modes, blended per frame:
|
||||
/// 1. Procedural curl: <see cref="curl"/> (0..1) lerps every joint from rest toward its per-joint
|
||||
/// <see cref="maxCurl"/> - written first each frame, and the SEED pose for the IK pass.
|
||||
/// 2. IK reach: with a <see cref="target"/> and <see cref="ikWeight"/> > 0, CCD bends the chain from
|
||||
/// that seed, hinge/limit-constrained mid-sweep; the final pose is Slerp(seed, solved, 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), preserving
|
||||
/// the rig's modeled fan/opposition offsets. Added at runtime by <see cref="HandIK"/>, or wired by hand
|
||||
/// via <see cref="Setup"/>; set <see cref="SolveInLateUpdate"/> only when 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 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;
|
||||
if (!TryInPlaneAngle(toTip, toTarget, axisW, out float hingeAng)) continue;
|
||||
|
||||
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;
|
||||
|
||||
TryInPlaneAngle(toTip, toTarget, axisW, out float latAng); // degenerate -> 0 (spread bias still applies)
|
||||
|
||||
latAng = Mathf.Clamp(latAng + spread, spreadLimits.x, spreadLimits.y);
|
||||
_solvedLocal[0] = _solvedLocal[0] * Quaternion.AngleAxis(latAng, SpreadAxis);
|
||||
}
|
||||
|
||||
// Signed angle from toTip to toTarget projected onto the plane orthogonal to axis; false when
|
||||
// either projection is degenerate (angle is then 0 and must not be applied as a solve step).
|
||||
private static bool TryInPlaneAngle(Vector3 toTip, Vector3 toTarget, Vector3 axis, out float angle)
|
||||
{
|
||||
angle = 0f;
|
||||
Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axis);
|
||||
Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axis);
|
||||
if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) return false;
|
||||
|
||||
angle = Vector3.SignedAngle(tipPlane, tgtPlane, axis);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 716a391c8e627654dab900c7386c898a
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13d52fb2a77e25f4ba76c99ac5abb56e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,332 +0,0 @@
|
||||
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 = GeometryUtil.FindDeep(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 = GeometryUtil.FindDeep(transform, "Hand");
|
||||
return found ? found : transform;
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbe3de6a9f2cea544993dbddc6dedaa8
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c027a1302afba694a87436cdd0589908
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8216044785376fa43acab56ba15cd4be
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user