HumanoidRetargeter/Cleanup/TwistBoneFollow.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Target;
namespace HumanoidRetargeter.Cleanup;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Drives unmapped limb deform bones from the joints whose motion they distribute.
/// Auto-rigged exports (Auto-Rig Pro <c>forearm_twist.l</c>, AdvancedSkeleton
/// <c>ElbowPart1_L</c>, Biped <c>Bip01 L ForeTwist</c>) spread limb roll across helper
/// bones the game constrains at runtime; a baked retarget that leaves them at rest
/// candy-wraps the skin — the reported wrist "spike fans" when the hand pronates.
/// </summary>
/// <remarks>
/// Detection is geometric, name-free: an UNMAPPED bone whose parent is a mapped limb
/// bone (upper/lower arm or leg) and whose rest position lies ON the segment from that
/// parent to the parent's mapped chain child (within 15° of the axis, fraction
/// 0.05..1.1 along it). Each detected twist follows the chain child's per-frame local
/// ROLL — the twist component of its rotation delta about the limb axis — scaled by the
/// twist's fractional position (a bone at 60% of the forearm takes 60% of the hand's
/// roll; ARP's proximal <c>arm_twist</c> at fraction ~0 correctly takes ~none). Pure
/// swing carries no twist component, so elbows/knees bending never move these bones.
/// Serial deform bones between two mapped limb joints are handled separately: their
/// world-space motion delta is interpolated between the endpoints while both mapped
/// endpoint transforms remain unchanged. This covers rigs that split each bend/twist
/// section into two weighted bones without relying on exporter-specific names. An
/// unmapped sibling at the mapped joint's same pivot follows its complete rotation;
/// this covers dual control/deform rigs where a mechanism forearm/femur drives the next
/// joint while coincident anatomical bones carry the skin.
/// </remarks>
public static class TwistBoneFollow
{
private static readonly (BoneRole Parent, BoneRole Child)[] Segments =
{
(BoneRole.ClavicleL, BoneRole.UpperArmL),
(BoneRole.UpperArmL, BoneRole.LowerArmL), (BoneRole.LowerArmL, BoneRole.HandL),
(BoneRole.ClavicleR, BoneRole.UpperArmR),
(BoneRole.UpperArmR, BoneRole.LowerArmR), (BoneRole.LowerArmR, BoneRole.HandR),
(BoneRole.Hips, BoneRole.UpperLegL),
(BoneRole.UpperLegL, BoneRole.LowerLegL), (BoneRole.LowerLegL, BoneRole.FootL),
(BoneRole.Hips, BoneRole.UpperLegR),
(BoneRole.UpperLegR, BoneRole.LowerLegR), (BoneRole.LowerLegR, BoneRole.FootR),
};
private readonly record struct InlineBone(int Bone, int Parent, int Child, float Fraction);
private readonly record struct FullFollower(int Bone, int Driver);
/// <summary>Applies the pass in place; returns how many limb helpers were driven.</summary>
public static int Apply(
IReadOnlyList<XForm[]> frames, TargetRig rig, IReadOnlySet<int>? excluded)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(rig);
var skeleton = rig.Skeleton;
var twists = new List<(int Bone, int Driver, Vector3 Axis, float Fraction)>();
var fullFollowers = new List<FullFollower>();
var fullFollowerBones = new HashSet<int>();
foreach (var (parentRole, childRole) in Segments)
{
if (rig.BoneForRole(parentRole) is not { } parent
|| rig.BoneForRole(childRole) is not { } child
|| skeleton[child].ParentIndex != parent)
continue;
// Limb axis and length in the PARENT's local space (the chain child's rest
// local translation).
var axis = skeleton[child].RestLocal.Pos;
var length = axis.Length();
if (length < 1e-3f)
continue;
axis /= length;
for (var i = 0; i < skeleton.Count; i++)
{
if (i == child || skeleton[i].ParentIndex != parent
|| rig.RoleOf(i) is not null || excluded?.Contains(i) == true)
continue;
var pos = skeleton[i].RestLocal.Pos;
// Blender control/deform exports commonly put a mechanism joint and one
// or more skinned anatomical joints at the same pivot (MCH_forearm beside
// radius/ulna, MCH_femur beside femur). The mapped mechanism drives the
// next joint, but its deform siblings need the complete bend and roll;
// treating them as ordinary twist bones copies roll only and leaves the
// mesh behind while the hand/leg moves away.
if ((pos - skeleton[child].RestLocal.Pos).Length()
<= MathF.Max(0.01f, length * 0.01f))
{
if (fullFollowerBones.Add(i))
fullFollowers.Add(new FullFollower(i, child));
continue;
}
var along = Vector3.Dot(pos, axis);
var fraction = along / length;
if (fraction is < 0.05f or > 1.1f)
continue;
var offAxis = (pos - axis * along).Length();
if (offAxis > MathF.Tan(15f * MathF.PI / 180f) * MathF.Max(along, 1e-3f))
continue;
twists.Add((i, child, axis, Math.Clamp(fraction, 0f, 1f)));
}
}
var inline = FindInlineBones(rig, excluded);
if (twists.Count == 0 && inline.Count == 0 && fullFollowers.Count == 0)
return 0;
foreach (var frame in frames)
{
foreach (var follower in fullFollowers)
{
// Both bones share a parent, so the driver's local-space rotation delta
// can be applied directly while retaining the deform bone's bind offset.
var delta = MathQ.Normalize(frame[follower.Driver].Rot
* Quaternion.Conjugate(skeleton[follower.Driver].RestLocal.Rot));
frame[follower.Bone] = new XForm(
frame[follower.Bone].Pos,
MathQ.Normalize(delta * skeleton[follower.Bone].RestLocal.Rot));
}
foreach (var (bone, driver, axis, fraction) in twists)
{
// The driver's rotation delta from rest, in the shared parent's space,
// forced to the SHORTEST arc (W >= 0) so the twist angle below is
// continuous in (-180°, 180°) and never flips representation.
var delta = MathQ.Normalize(
frame[driver].Rot * Quaternion.Conjugate(skeleton[driver].RestLocal.Rot));
if (delta.W < 0f)
delta = new Quaternion(-delta.X, -delta.Y, -delta.Z, -delta.W);
// Twist component about the limb axis (swing-twist decomposition).
var proj = Vector3.Dot(new Vector3(delta.X, delta.Y, delta.Z), axis);
// Ill-conditioned when the delta approaches a pure 180° SWING (both the
// axis projection and W collapse toward 0): the decomposition then
// amplifies noise into huge fake rolls — measured on a throw clip, the
// kicking foot injected ±99° into the calf twist bone and the calf skin
// flipped upward ("the leg is up"). Keep rest instead.
var conditioning = MathF.Sqrt(proj * proj + delta.W * delta.W);
if (conditioning < 0.2f)
continue;
var angle = 2f * MathF.Atan2(proj, delta.W);
var scaled = Quaternion.CreateFromAxisAngle(axis, angle * fraction);
frame[bone] = new XForm(
frame[bone].Pos, MathQ.Normalize(scaled * skeleton[bone].RestLocal.Rot));
}
if (inline.Count > 0)
FollowInlineBones(frame, skeleton, inline);
}
return twists.Count + inline.Count + fullFollowers.Count;
}
private static List<InlineBone> FindInlineBones(
TargetRig rig, IReadOnlySet<int>? excluded)
{
var skeleton = rig.Skeleton;
var result = new List<InlineBone>();
var seen = new HashSet<int>();
foreach (var (parentRole, childRole) in Segments)
{
if (rig.BoneForRole(parentRole) is not { } parent
|| rig.BoneForRole(childRole) is not { } child)
continue;
var path = new List<int>();
for (var bone = skeleton[child].ParentIndex;
bone >= 0 && bone != parent;
bone = skeleton[bone].ParentIndex)
path.Add(bone);
if (path.Count == 0
|| skeleton[path[^1]].ParentIndex != parent
|| path.Any(bone => rig.RoleOf(bone) is not null
|| excluded?.Contains(bone) == true))
continue;
path.Reverse();
var length = 0f;
var previous = parent;
foreach (var bone in path.Append(child))
{
length += (skeleton.RestWorld[bone].Pos
- skeleton.RestWorld[previous].Pos).Length();
previous = bone;
}
if (length < 1e-3f)
continue;
var along = 0f;
previous = parent;
foreach (var bone in path)
{
along += (skeleton.RestWorld[bone].Pos
- skeleton.RestWorld[previous].Pos).Length();
if (seen.Add(bone))
result.Add(new InlineBone(bone, parent, child, along / length));
previous = bone;
}
}
return result;
}
private static void FollowInlineBones(
XForm[] frame, Skeleton.Skeleton skeleton, IReadOnlyList<InlineBone> inline)
{
var world = new Skeleton.Pose(frame).ToWorld(skeleton);
var desired = world.ToArray();
var pathBones = new HashSet<int>();
foreach (var group in inline.GroupBy(entry => (entry.Parent, entry.Child)))
{
var parent = group.Key.Parent;
var child = group.Key.Child;
var parentDelta = MathQ.Normalize(world[parent].Rot
* Quaternion.Conjugate(skeleton.RestWorld[parent].Rot));
var childDelta = MathQ.Normalize(world[child].Rot
* Quaternion.Conjugate(skeleton.RestWorld[child].Rot));
if (Quaternion.Dot(parentDelta, childDelta) < 0f)
childDelta = new Quaternion(
-childDelta.X, -childDelta.Y, -childDelta.Z, -childDelta.W);
foreach (var entry in group)
{
var delta = MathQ.Normalize(Quaternion.Slerp(
parentDelta, childDelta, entry.Fraction));
desired[entry.Bone] = new XForm(
world[entry.Bone].Pos,
MathQ.Normalize(delta * skeleton.RestWorld[entry.Bone].Rot));
pathBones.Add(entry.Bone);
}
// Compensate the mapped endpoint locally so its already-solved world transform
// remains exact after its intermediary parent starts following the motion.
pathBones.Add(child);
}
foreach (var bone in pathBones.OrderBy(index => index))
{
var parent = skeleton[bone].ParentIndex;
frame[bone] = parent < 0
? desired[bone]
: XForm.ToLocal(desired[parent], desired[bone]);
}
}
}