Code/RigControl/RigConstraintSolver.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette;
/// <summary>
/// Solves the constraints authored on a RigDocument.
///
/// CONSTRAINTS ACT WHILE POSING, AND BAKE INTO KEYFRAMES. Dragging a hand runs the IK solve and
/// writes ordinary FK keyframes for the whole chain, rather than storing "there is IK here" and
/// re-solving it at playback. That's a deliberate trade:
///
/// - Playback needs no solver at all, so a clip plays identically in the editor, in game, and
/// anywhere else that can read a keyframe. Nothing to keep in sync.
/// - The cost is that changing a constraint later doesn't retroactively change poses already
/// authored under the old one. Re-drag the effector to re-solve.
///
/// The alternative - live constraints evaluated every frame - needs the IK goal to be its own
/// animatable object, which is a bigger change to the asset format than this pass is making.
/// </summary>
public static class RigConstraintSolver
{
/// <summary>Clamp a bone's parent-space rotation to any enabled Limit constraint targeting it.
/// Returns the transform unchanged when nothing applies.</summary>
public static Transform ClampToLimits( RigDocument rig, string boneName, Transform local )
{
if ( rig is null || string.IsNullOrEmpty( boneName ) )
return local;
foreach ( var limit in rig.LimitConstraints )
{
if ( !limit.Enabled || limit.TargetBone != boneName )
continue;
var angles = local.Rotation.Angles();
var clamped = new Angles(
Math.Clamp( angles.pitch, limit.MinAngles.pitch, limit.MaxAngles.pitch ),
Math.Clamp( angles.yaw, limit.MinAngles.yaw, limit.MaxAngles.yaw ),
Math.Clamp( angles.roll, limit.MinAngles.roll, limit.MaxAngles.roll ) );
// Weight blends between the free and the clamped rotation, so a limit can soften a
// joint rather than only hard-stopping it.
var weight = limit.Weight.Clamp( 0f, 1f );
var result = Rotation.Lerp( local.Rotation, clamped.ToRotation(), weight );
local = new Transform( local.Position, result, local.Scale );
}
return local;
}
public static IkConstraint FindIkFor( RigDocument rig, string boneName ) =>
rig?.IkConstraints.FirstOrDefault( c => c.Enabled && c.TargetBone == boneName );
/// <summary>
/// One solved bone: the world transform to write.
/// </summary>
public readonly record struct SolvedBone( BoneCollection.Bone Bone, Transform World );
/// <summary>
/// Two-bone IK, closed form. Given a chain end (hand), its parent (elbow) and grandparent
/// (shoulder), rotate the two upper bones so the end lands on <paramref name="target"/>.
///
/// Closed form rather than iterative because two bones have an exact answer - the law of
/// cosines gives the elbow angle directly. FABRIK and CCD exist for chains longer than this;
/// they're iterative, and for the arm/leg case they'd be a slower way to get a worse result.
///
/// Returns false when the chain isn't three bones deep, or is degenerate (zero-length bones).
/// </summary>
public static bool TrySolveTwoBone( SkinnedModelRenderer renderer, BoneCollection.Bone end,
Vector3 target, Vector3 poleDirection, out List<SolvedBone> solved )
{
solved = null;
if ( !renderer.IsValid() || end?.Parent is not { } mid || mid.Parent is not { } root )
return false;
if ( !renderer.TryGetBoneTransform( root, out var rootTx )
|| !renderer.TryGetBoneTransform( mid, out var midTx )
|| !renderer.TryGetBoneTransform( end, out var endTx ) )
return false;
var a = rootTx.Position;
var b = midTx.Position;
var c = endTx.Position;
var upperLength = (b - a).Length;
var lowerLength = (c - b).Length;
if ( upperLength <= 0.001f || lowerLength <= 0.001f )
return false;
var toTarget = target - a;
var distance = toTarget.Length;
if ( distance <= 0.001f )
return false;
// Clamped just inside full extension - at exactly L1+L2 the bend plane is undefined and
// the elbow snaps to an arbitrary side.
var reach = upperLength + lowerLength;
distance = distance.Clamp( MathF.Abs( upperLength - lowerLength ) + 0.001f, reach - 0.001f );
var dir = toTarget.Normal;
var clampedTarget = a + dir * distance;
// Law of cosines: the angle at the root between the chain direction and the upper bone.
var cosRoot = (upperLength * upperLength + distance * distance - lowerLength * lowerLength)
/ (2f * upperLength * distance);
var rootAngle = MathF.Acos( cosRoot.Clamp( -1f, 1f ) ).RadianToDegree();
// The bend plane is spanned by the chain direction and the pole. Cross gives the axis to
// swing the upper bone around; if the pole is parallel to the chain that cross collapses,
// so fall back to the current elbow offset, and then to any perpendicular.
var bendAxis = Vector3.Cross( dir, poleDirection.Normal );
if ( bendAxis.Length < 0.001f )
bendAxis = Vector3.Cross( dir, (b - a).Normal );
if ( bendAxis.Length < 0.001f )
bendAxis = Vector3.Cross( dir, MathF.Abs( dir.z ) < 0.9f ? Vector3.Up : Vector3.Forward );
bendAxis = bendAxis.Normal;
var newMidPosition = a + Rotation.FromAxis( bendAxis, rootAngle ) * dir * upperLength;
// Rotations are applied as deltas from where each bone currently points, so this never has
// to know which local axis the skeleton treats as "along the bone" - a convention that
// varies per rig and is not safe to assume.
var upperDelta = DeltaBetween( b - a, newMidPosition - a );
var lowerDelta = DeltaBetween( c - b, clampedTarget - newMidPosition );
solved = new List<SolvedBone>
{
new( root, new Transform( a, upperDelta * rootTx.Rotation, rootTx.Scale ) ),
new( mid, new Transform( newMidPosition, lowerDelta * midTx.Rotation, midTx.Scale ) ),
new( end, new Transform( clampedTarget, endTx.Rotation, endTx.Scale ) )
};
return true;
}
/// <summary>Shortest rotation taking one direction onto another, built from an explicit
/// axis and angle - Rotation.FromAxis is the one primitive here that's certain to exist and
/// behave, and the degenerate cases (parallel, antiparallel) need handling anyway.</summary>
private static Rotation DeltaBetween( Vector3 from, Vector3 to )
{
if ( from.Length < 0.001f || to.Length < 0.001f )
return Rotation.Identity;
from = from.Normal;
to = to.Normal;
var dot = Vector3.Dot( from, to ).Clamp( -1f, 1f );
if ( dot > 0.99999f )
return Rotation.Identity;
// Exactly opposite: no unique shortest arc, so pick any perpendicular axis and flip.
if ( dot < -0.99999f )
{
var perpendicular = Vector3.Cross( from, MathF.Abs( from.z ) < 0.9f ? Vector3.Up : Vector3.Forward );
return Rotation.FromAxis( perpendicular.Normal, 180f );
}
var axis = Vector3.Cross( from, to ).Normal;
var angle = MathF.Acos( dot ).RadianToDegree();
return Rotation.FromAxis( axis, angle );
}
}