Core/Retargeting/HandRetargeter.cs
#nullable enable
using System.Numerics;
using HumanoidHandRetargeter.Calibration;
using HumanoidHandRetargeter.Formats;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Skeleton;
using HumanoidHandRetargeter.Validation;
namespace HumanoidHandRetargeter.Retargeting;
using Vector3 = System.Numerics.Vector3;
/// <summary>One offline solve path for static poses and baked clips. Target translations
/// and unmapped local transforms stay authored; helper constraints remain target-owned.</summary>
public static class HandRetargeter
{
/// <summary>One continuous graph stream, retaining digit angle history between evaluated frames.</summary>
public sealed class PoseStream
{
private readonly HandRetargetProfile profile;
private readonly HandMotionOptions options;
private readonly AngleHistory history;
public PoseStream(HandRetargetProfile profile, HandMotionOptions options)
{ this.profile = profile; this.options = options; history = new(profile); }
public Pose Step(Pose source)
{
var pose = Solve(profile, source, history);
if (options.TransferWristPosition) ApplyWristMotion(profile, source, pose, options);
var sourceWorld = source.ToWorld(profile.Source);
foreach (var pair in profile.PreservedTracks)
{
var targetWorld = pose.ToWorld(profile.Target);
var parent = profile.Target[pair.Target].ParentIndex;
pose.Locals[pair.Target] = parent < 0 ? sourceWorld[pair.Source] : XForm.ToLocal(targetWorld[parent], sourceWorld[pair.Source]);
}
return pose;
}
}
public static Pose RetargetPose(HandRetargetProfile profile, Pose sourcePose)
=> Solve(profile, sourcePose, null);
/// <summary>Retains timing and fixes quaternion signs. Stateful angle unwrapping is
/// local to this clip, so a calibrated profile may safely be shared by concurrent jobs.</summary>
public static Clip RetargetClip(HandRetargetProfile profile, Clip sourceClip, CancellationToken cancellationToken = default)
=> Bake(profile, sourceClip, new HandMotionOptions { TransferWristPosition = false }, cancellationToken);
/// <summary>The shared editor preview/export bake including optional wrist travel and arm IK.</summary>
public static Clip Bake(HandRetargetProfile profile, Clip sourceClip, HandMotionOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(sourceClip);
ArgumentNullException.ThrowIfNull(options);
if (sourceClip.FrameCount == 0)
throw new RigValidationException(new[] { new RigIssue("empty-clip", "Source animation has no frames.") });
var frames = new List<XForm[]>(sourceClip.FrameCount);
var continuity = new AngleHistory(profile);
foreach (var sourceFrame in sourceClip.Frames)
{
cancellationToken.ThrowIfCancellationRequested();
var pose = Solve(profile, new Pose(sourceFrame), continuity);
if (options.TransferWristPosition) ApplyWristMotion(profile, new Pose(sourceFrame), pose, options);
if(profile.PreservedTracks.Length>0)
{
var sourceWorld=new Pose(sourceFrame).ToWorld(profile.Source);
foreach(var pair in profile.PreservedTracks)
{
var targetWorld=pose.ToWorld(profile.Target);var parent=profile.Target[pair.Target].ParentIndex;
pose.Locals[pair.Target]=parent<0?sourceWorld[pair.Source]:XForm.ToLocal(targetWorld[parent],sourceWorld[pair.Source]);
}
}
frames.Add(pose.Locals);
}
QuaternionContinuity.AlignFrames(frames);
return new Clip(sourceClip.Name, sourceClip.Fps, sourceClip.Looping, frames, sourceClip.NativeFps);
}
private static void ApplyWristMotion(HandRetargetProfile profile, Pose source, Pose target, HandMotionOptions options)
{
if(options.WeaponSpaceOffset.HasValue&&options.SolveArmIk)
foreach(var shoulder in profile.FpsShoulders)target.Locals[shoulder.Key].Pos=shoulder.Value;
var sourceWorld = source.ToWorld(profile.Source);
foreach (var pair in profile.WristMotion)
{
var world = target.ToWorld(profile.Target);
var travel = Vector3.Transform(sourceWorld[pair.Source].Pos - profile.Source.RestWorld[pair.Source].Pos, options.WristTravelBasis ?? pair.Basis);
var desired = profile.Target.RestWorld[pair.Target].Pos + travel * (options.ScaleWristTravel ? pair.Scale : 1f);
var wristRotation = world[pair.Target].Rot;
if(options.WeaponSpaceOffset is {} weaponOffset)
{
wristRotation=MathQ.Normalize(sourceWorld[pair.Source].Rot*pair.GripRotationOffset);
desired=sourceWorld[pair.Source].Pos+weaponOffset
+Vector3.Transform(pair.SourceGripLocal,sourceWorld[pair.Source].Rot)
-Vector3.Transform(pair.TargetGripLocal,wristRotation);
}
if (options.SolveArmIk && pair.UpperArm is int upper && pair.Forearm is int lower)
{
if(options.WeaponSpaceOffset.HasValue)
{
// Detached FPS arms can move their open shoulder ends. Avoid
// folding long/thick forearms back through their own upper arm
// when the weapon grip lies very close to the shoulder.
if(profile.Target[upper].ParentIndex<0)
{
var shoulder=HandViewSpace.ClearElbowFold(world[upper].Pos,world[lower].Pos,world[pair.Target].Pos,desired);
target.Locals[upper].Pos=shoulder;
world=target.ToWorld(profile.Target);
}
// FPS arm meshes have free shoulder ends. Move the shoulder enough
// to reach the fixed weapon grip instead of stretching either arm segment.
var reach=Vector3.Distance(world[upper].Pos,world[lower].Pos)+Vector3.Distance(world[lower].Pos,world[pair.Target].Pos);
var toGrip=desired-world[upper].Pos;var distance=toGrip.Length();
if(distance>reach*.98f&&distance>1e-5f)
{
var shoulder=world[upper].Pos+toGrip/distance*(distance-reach*.98f);
var parent=profile.Target[upper].ParentIndex;
target.Locals[upper].Pos=parent<0?shoulder:Vector3.Transform(shoulder-world[parent].Pos,Quaternion.Conjugate(world[parent].Rot));
world=target.ToWorld(profile.Target);
}
}
var correction = TwoBoneIk.Solve(world[upper].Pos, world[lower].Pos, world[pair.Target].Pos, desired, 0, pair.BendAxis);
void SetWorldRotation(int bone, Quaternion rotation)
{
var parent = profile.Target[bone].ParentIndex;
target.Locals[bone].Rot = MathQ.Normalize(parent < 0 ? rotation : Quaternion.Conjugate(world[parent].Rot) * rotation);
world = target.ToWorld(profile.Target);
}
var oldLower = world[lower].Rot;
SetWorldRotation(upper, correction.UpperWorldDelta * world[upper].Rot);
SetWorldRotation(lower, correction.LowerWorldDelta * oldLower);
if(options.WeaponSpaceOffset.HasValue && pair.SourceForearm is int sourceElbow && pair.SourceUpperArm is int sourceShoulder)
{
// Transfer the source bend plane between the two shoulder-to-wrist axes.
// An absolute source elbow position can lie on the target arm's axis
// when proportions differ, flipping its pole as the weapon recoils.
var axis=world[pair.Target].Pos-world[upper].Pos;
var sourceAxis=sourceWorld[pair.Source].Pos-sourceWorld[sourceShoulder].Pos;
if(axis.LengthSquared()>1e-8f&&sourceAxis.LengthSquared()>1e-8f)
{
axis=Vector3.Normalize(axis);
sourceAxis=Vector3.Normalize(sourceAxis);
var actual=world[lower].Pos-world[upper].Pos;
var wanted=sourceWorld[sourceElbow].Pos-sourceWorld[sourceShoulder].Pos;
wanted-=sourceAxis*Vector3.Dot(wanted,sourceAxis);
wanted=Vector3.Transform(wanted,MathQ.FromTo(sourceAxis,axis));
actual-=axis*Vector3.Dot(actual,axis);
// A straight arm has no defined pole: retain the preceding IK plane.
if(actual.LengthSquared()>1e-6f&&wanted.LengthSquared()>1e-6f)
{
actual=Vector3.Normalize(actual);wanted=Vector3.Normalize(wanted);
var angle=MathF.Atan2(Vector3.Dot(axis,Vector3.Cross(actual,wanted)),Vector3.Dot(actual,wanted));
var pole=Quaternion.CreateFromAxisAngle(axis,angle);
oldLower=world[lower].Rot;
SetWorldRotation(upper,pole*world[upper].Rot);
SetWorldRotation(lower,pole*oldLower);
}
}
}
if(options.WeaponSpaceOffset.HasValue&&!pair.HasTwistHelpers)
{
// Without authored twist helpers, put axial pronation in the forearm
// instead of winding the wrist through the fixed palm orientation.
// Rotating about elbow-to-wrist leaves the grip position unchanged.
var axis=world[pair.Target].Pos-world[lower].Pos;
if(axis.LengthSquared()>1e-8f)
{
var restRelative=MathQ.Normalize(Quaternion.Conjugate(profile.Target.RestWorld[lower].Rot)*profile.Target.RestWorld[pair.Target].Rot);
var relaxedLower=MathQ.Normalize(wristRotation*Quaternion.Conjugate(restRelative));
var delta=MathQ.Normalize(relaxedLower*Quaternion.Conjugate(world[lower].Rot));
MathQ.SwingTwist(delta,Vector3.Normalize(axis),out _,out var roll);
SetWorldRotation(lower,roll*world[lower].Rot);
}
}
SetWorldRotation(pair.Target, wristRotation);
if(options.WeaponSpaceOffset.HasValue&&Vector3.Distance(world[pair.Target].Pos,desired)>.1f)
throw new InvalidOperationException("The target arm cannot reach the weapon grip without stretching. Adjust the rig's shoulder placement or disable Preserve weapon grip.");
}
else
{
// Partial rigs cannot reach with a two-link chain. Preserve the authored
// wrist path by translating the wrist relative to its actual parent.
var parent = profile.Target[pair.Target].ParentIndex;
target.Locals[pair.Target].Pos = parent < 0 ? desired
: Vector3.Transform(desired - world[parent].Pos, Quaternion.Conjugate(world[parent].Rot));
target.Locals[pair.Target].Rot=MathQ.Normalize(parent<0?wristRotation:Quaternion.Conjugate(world[parent].Rot)*wristRotation);
}
}
var errors = PoseValidator.Validate(profile.Target, target);
if (errors.Count > 0) throw new RigValidationException(errors);
}
private static Pose Solve(HandRetargetProfile profile, Pose sourcePose, AngleHistory? history)
{
ArgumentNullException.ThrowIfNull(profile);
var errors = PoseValidator.Validate(profile.Source, sourcePose);
if (errors.Count > 0) throw new RigValidationException(errors);
var sourceWorld = sourcePose.ToWorld(profile.Source);
errors = PoseValidator.Validate(profile.Source, new Pose(sourceWorld));
if (errors.Count > 0) throw new RigValidationException(errors);
var sourceDeltas = new Quaternion[sourceWorld.Length];
for (var i = 0; i < sourceWorld.Length; i++)
sourceDeltas[i] = MathQ.Normalize(sourceWorld[i].Rot * Quaternion.Conjugate(profile.Source.RestWorld[i].Rot));
var targetDeltas = Enumerable.Repeat(Quaternion.Identity, profile.Target.Count).ToArray();
var solved = new bool[profile.Target.Count];
foreach (var pair in profile.Pairs)
{
var parentSource = pair.SourceParent >= 0 ? sourceDeltas[pair.SourceParent] : Quaternion.Identity;
var parentTarget = pair.TargetParent >= 0 ? targetDeltas[pair.TargetParent] : Quaternion.Identity;
var canonical = MathQ.Normalize(Quaternion.Conjugate(pair.SourceFrame) * Quaternion.Conjugate(parentSource)
* sourceDeltas[pair.Source] * pair.SourceFrame);
targetDeltas[pair.Target] = MathQ.Normalize(parentTarget * pair.TargetFrame * canonical * Quaternion.Conjugate(pair.TargetFrame));
solved[pair.Target] = true;
}
for (var d = 0; d < profile.Distributions.Length; d++)
{
var digit = profile.Distributions[d];
var curls = new float[digit.Source.Length];
var spread = 0f;
var previous = sourceDeltas[digit.SourceWrist];
for (var s = 0; s < digit.Source.Length; s++)
{
var frame = digit.SourceFrames[s];
var delta = sourceDeltas[digit.Source[s]];
var canonical = MathQ.Normalize(Quaternion.Conjugate(frame) * Quaternion.Conjugate(previous) * delta * frame);
// Legacy FingerSolver decomposition: canonical Y curl, Z splay, discard X twist when redistributing.
MathQ.SwingTwist(canonical, Vector3.UnitY, out var swing, out var curl);
curls[s] = SignedAngle(curl, Vector3.UnitY);
if (history is not null) curls[s] = history.Unwrap(d, s, curls[s], false);
if (s <= digit.ProximalSourceIndex)
{
MathQ.SwingTwist(swing, Vector3.UnitZ, out _, out var splay);
var angle = SignedAngle(splay, Vector3.UnitZ);
if (history is not null) angle = history.Unwrap(d, s, angle, true);
spread += angle;
}
previous = delta;
}
var accumulated = targetDeltas[digit.TargetWrist];
for (var t = 0; t < digit.Target.Length; t++)
{
var angle = 0f;
for (var s = 0; s < curls.Length; s++) angle += digit.Weights[t, s] * curls[s];
var motion = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angle);
if (t == 0) motion = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, spread) * motion;
var frame = digit.TargetFrames[t];
accumulated = MathQ.Normalize(accumulated * frame * motion * Quaternion.Conjugate(frame));
targetDeltas[digit.Target[t]] = accumulated;
solved[digit.Target[t]] = true;
}
}
var targetPose = Pose.Rest(profile.Target);
var targetWorld = new XForm[profile.Target.Count];
for (var i = 0; i < targetWorld.Length; i++)
{
var parent = profile.Target[i].ParentIndex;
if (solved[i])
{
var desiredWorld = targetDeltas[i] * profile.Target.RestWorld[i].Rot;
targetPose.Locals[i].Rot = MathQ.Normalize(parent < 0 ? desiredWorld : Quaternion.Conjugate(targetWorld[parent].Rot) * desiredWorld);
}
targetWorld[i] = parent < 0 ? targetPose.Locals[i] : XForm.Compose(targetWorld[parent], targetPose.Locals[i]);
}
errors = PoseValidator.Validate(profile.Target, targetPose);
if (errors.Count > 0) throw new RigValidationException(errors);
errors = PoseValidator.Validate(profile.Target, new Pose(targetWorld));
if (errors.Count > 0) throw new RigValidationException(errors);
return targetPose;
}
// Same signed twist convention as the legacy FingerSolver.
private static float SignedAngle(Quaternion twist, Vector3 axis)
{
var angle = 2f * MathF.Atan2(twist.X * axis.X + twist.Y * axis.Y + twist.Z * axis.Z, twist.W);
if (angle > MathF.PI) angle -= 2f * MathF.PI;
if (angle < -MathF.PI) angle += 2f * MathF.PI;
return angle;
}
private sealed class AngleHistory
{
private readonly float[][] _curl;
private readonly float[][] _spread;
public AngleHistory(HandRetargetProfile profile)
{
_curl = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();
_spread = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();
}
public float Unwrap(int digit, int joint, float angle, bool spread)
{
var values = spread ? _spread : _curl;
var previous = values[digit][joint];
if (float.IsFinite(previous))
angle += 2f * MathF.PI * MathF.Round((previous - angle) / (2f * MathF.PI));
values[digit][joint] = angle;
return angle;
}
}
}